feat: improve auth seeding and sidebar ux

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
rever-tecnologia 2025-10-06 10:25:08 -03:00
parent cebe1b9bf1
commit 98e15b816e
5 changed files with 226 additions and 14 deletions

View file

@ -6,6 +6,21 @@ Construir o nucleo de tickets compartilhado entre web e desktop (Tauri), garanti
### Contato principal
- **Esdras Renan** — monkeyesdras@gmail.com
### Credenciais seed (ambiente local)
- Administrador padrão: `admin@sistema.dev` / `admin123`
- Agentes carregados via seed (senha inicial `agent123`, altere após o primeiro acesso):
- Gabriel Oliveira — gabriel.oliveira@rever.com.br
- George Araujo — george.araujo@rever.com.br
- Hugo Soares — hugo.soares@rever.com.br
- Julio Cesar — julio@rever.com.br
- Lorena Magalhães — lorena@rever.com.br
- Rever — renan.pac@paulicon.com.br
- Telão — suporte@rever.com.br
- Thiago Medeiros — thiago.medeiros@rever.com.br
- Weslei Magalhães — weslei@rever.com.br
> Observação: todos os usuários acima foram sincronizados com o Convex. Atualize as senhas imediatamente após o primeiro login.
## Fase A - Fundamentos da plataforma
1. **Scaffold e DX**
- Criar projeto Next.js (App Router) com Typescript, ESLint, Tailwind, shadcn/ui.

138
web/scripts/seed-agents.mjs Normal file
View file

@ -0,0 +1,138 @@
import pkg from "@prisma/client"
import { hashPassword } from "better-auth/crypto"
import { ConvexHttpClient } from "convex/browser"
const { PrismaClient } = pkg
const prisma = new PrismaClient()
const USERS = [
{ name: "Administrador", email: "admin@sistema.dev", role: "admin" },
{ name: "Gabriel Oliveira", email: "gabriel.oliveira@rever.com.br", role: "agent" },
{ name: "George Araujo", email: "george.araujo@rever.com.br", role: "agent" },
{ name: "Hugo Soares", email: "hugo.soares@rever.com.br", role: "agent" },
{ name: "Julio Cesar", email: "julio@rever.com.br", role: "agent" },
{ name: "Lorena Magalhães", email: "lorena@rever.com.br", role: "agent" },
{ name: "Rever", email: "renan.pac@paulicon.com.br", role: "agent" },
{ name: "Telão", email: "suporte@rever.com.br", role: "agent" },
{ name: "Thiago Medeiros", email: "thiago.medeiros@rever.com.br", role: "agent" },
{ name: "Weslei Magalhães", email: "weslei@rever.com.br", role: "agent" },
]
const TENANT_ID = process.env.SEED_TENANT_ID ?? "tenant-atlas"
const DEFAULT_AGENT_PASSWORD = process.env.SEED_AGENT_PASSWORD ?? "agent123"
const DEFAULT_ADMIN_PASSWORD = process.env.SEED_ADMIN_PASSWORD ?? "admin123"
const CONVEX_URL = process.env.NEXT_PUBLIC_CONVEX_URL
async function syncConvexUsers(users) {
if (!CONVEX_URL) {
console.warn("NEXT_PUBLIC_CONVEX_URL não definido; sincronização com Convex ignorada.")
return
}
const client = new ConvexHttpClient(CONVEX_URL)
for (const user of users) {
try {
await client.mutation("users:ensureUser", {
tenantId: TENANT_ID,
email: user.email,
name: user.name,
role: user.role.toUpperCase(),
})
} catch (error) {
console.error(`Falha ao sincronizar usuário ${user.email} com Convex`, error)
}
}
}
async function main() {
const emails = USERS.map((user) => user.email.toLowerCase())
const existing = await prisma.authUser.findMany({
where: {
email: {
notIn: emails,
},
},
select: { id: true },
})
if (existing.length > 0) {
const ids = existing.map((user) => user.id)
await prisma.authSession.deleteMany({ where: { userId: { in: ids } } })
await prisma.authAccount.deleteMany({ where: { userId: { in: ids } } })
await prisma.authUser.deleteMany({ where: { id: { in: ids } } })
}
const seededUsers = []
for (const definition of USERS) {
const email = definition.email.toLowerCase()
const role = definition.role ?? "agent"
const password = definition.password ?? (role === "admin" ? DEFAULT_ADMIN_PASSWORD : DEFAULT_AGENT_PASSWORD)
const hashedPassword = await hashPassword(password)
const user = await prisma.authUser.upsert({
where: { email },
update: {
name: definition.name,
role,
tenantId: TENANT_ID,
emailVerified: true,
},
create: {
email,
name: definition.name,
role,
tenantId: TENANT_ID,
emailVerified: true,
accounts: {
create: {
providerId: "credential",
accountId: email,
password: hashedPassword,
},
},
},
include: { accounts: true },
})
const credentialAccount = user.accounts.find(
(account) => account.providerId === "credential" && account.accountId === email,
)
if (credentialAccount) {
await prisma.authAccount.update({
where: { id: credentialAccount.id },
data: { password: hashedPassword },
})
} else {
await prisma.authAccount.create({
data: {
userId: user.id,
providerId: "credential",
accountId: email,
password: hashedPassword,
},
})
}
seededUsers.push({ id: user.id, name: definition.name, email, role })
console.log(`✅ Usuário sincronizado: ${definition.name} <${email}> (${role})`)
}
await syncConvexUsers(seededUsers)
console.log("")
console.log(`Senha padrão agentes: ${DEFAULT_AGENT_PASSWORD}`)
console.log(`Senha padrão administrador: ${DEFAULT_ADMIN_PASSWORD}`)
console.log(`Total de usuários ativos: ${seededUsers.length}`)
}
main()
.catch((error) => {
console.error("Erro ao processar agentes", error)
process.exitCode = 1
})
.finally(async () => {
await prisma.$disconnect()
})

View file

@ -34,6 +34,8 @@ export function QueuesManager() {
const { session, convexUserId } = useAuth()
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
const NO_TEAM_VALUE = "__none__"
const queues = useQuery(
api.queues.list,
convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
@ -202,12 +204,15 @@ export function QueuesManager() {
</div>
<div className="space-y-2">
<Label>Time responsável</Label>
<Select value={teamId ?? ""} onValueChange={(value) => setTeamId(value || undefined)}>
<Select
value={teamId ?? NO_TEAM_VALUE}
onValueChange={(value) => setTeamId(value === NO_TEAM_VALUE ? undefined : value)}
>
<SelectTrigger>
<SelectValue placeholder="Selecione um time" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">Sem time</SelectItem>
<SelectItem value={NO_TEAM_VALUE}>Sem time</SelectItem>
{teams?.map((team) => (
<SelectItem key={team.id} value={team.id}>
{team.name}
@ -292,12 +297,15 @@ export function QueuesManager() {
</div>
<div className="space-y-2">
<Label>Time responsável</Label>
<Select value={teamId ?? ""} onValueChange={(value) => setTeamId(value || undefined)}>
<Select
value={teamId ?? NO_TEAM_VALUE}
onValueChange={(value) => setTeamId(value === NO_TEAM_VALUE ? undefined : value)}
>
<SelectTrigger>
<SelectValue placeholder="Selecione um time" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">Sem time</SelectItem>
<SelectItem value={NO_TEAM_VALUE}>Sem time</SelectItem>
{teams?.map((team) => (
<SelectItem key={team.id} value={team.id}>
{team.name}

View file

@ -13,7 +13,6 @@ import {
Users,
Waypoints,
Timer,
Plug,
Layers3,
UserPlus,
Settings,
@ -34,6 +33,7 @@ import {
SidebarMenuItem,
SidebarRail,
} from "@/components/ui/sidebar"
import { Skeleton } from "@/components/ui/skeleton"
import { useAuth } from "@/lib/auth-client"
import type { LucideIcon } from "lucide-react"
@ -45,6 +45,7 @@ type NavigationItem = {
url: string
icon: LucideIcon
requiredRole?: NavRoleRequirement
exact?: boolean
}
type NavigationGroup = {
@ -79,12 +80,17 @@ const navigation: { versions: string[]; navMain: NavigationGroup[] } = {
title: "Administração",
requiredRole: "admin",
items: [
{ title: "Convites e acessos", url: "/admin", icon: UserPlus, requiredRole: "admin" },
{
title: "Convites e acessos",
url: "/admin",
icon: UserPlus,
requiredRole: "admin",
exact: true,
},
{ title: "Canais & roteamento", url: "/admin/channels", icon: Waypoints, requiredRole: "admin" },
{ title: "Times & papéis", url: "/admin/teams", icon: Users, requiredRole: "admin" },
{ title: "Campos personalizados", url: "/admin/fields", icon: Layers3, requiredRole: "admin" },
{ title: "SLAs", url: "/admin/slas", icon: Timer, requiredRole: "admin" },
{ title: "Integrações", url: "/admin/integrations", icon: Plug, requiredRole: "admin" },
],
},
{
@ -98,12 +104,21 @@ const navigation: { versions: string[]; navMain: NavigationGroup[] } = {
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const pathname = usePathname()
const { isAdmin, isStaff, isCustomer } = useAuth()
const [isHydrated, setIsHydrated] = React.useState(false)
function isActive(url: string) {
React.useEffect(() => {
setIsHydrated(true)
}, [])
function isActive(item: NavigationItem) {
const { url, exact } = item
if (!pathname) return false
if (url === "/dashboard" && pathname === "/") {
return true
}
if (exact) {
return pathname === url
}
return pathname === url || pathname.startsWith(`${url}/`)
}
@ -115,6 +130,34 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
return false
}
if (!isHydrated) {
return (
<Sidebar {...props}>
<SidebarHeader className="gap-3">
<Skeleton className="h-12 w-full rounded-lg" />
<Skeleton className="h-9 w-full rounded-lg" />
</SidebarHeader>
<SidebarContent>
{[0, 1, 2].map((group) => (
<SidebarGroup key={group}>
<SidebarGroupLabel>
<Skeleton className="h-3 w-20 rounded" />
</SidebarGroupLabel>
<SidebarGroupContent>
<div className="space-y-2">
{[0, 1, 2].map((item) => (
<Skeleton key={item} className="h-9 w-full rounded-md" />
))}
</div>
</SidebarGroupContent>
</SidebarGroup>
))}
</SidebarContent>
<SidebarRail />
</Sidebar>
)
}
return (
<Sidebar {...props}>
<SidebarHeader className="gap-3">
@ -123,7 +166,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
versions={[...navigation.versions]}
defaultVersion={navigation.versions[0]}
/>
<SearchForm placeholder="Buscar tickets, macros ou artigos" />
<SearchForm placeholder="Buscar tickets" />
</SidebarHeader>
<SidebarContent>
{navigation.navMain.map((group) => {
@ -137,7 +180,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
<SidebarMenu>
{visibleItems.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton asChild isActive={isActive(item.url)}>
<SidebarMenuButton asChild isActive={isActive(item)}>
<a href={item.url} className="gap-2">
<item.icon className="size-4" />
<span>{item.title}</span>

View file

@ -47,6 +47,13 @@ const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivEleme
<div className={cn("flex flex-col gap-1", className)} {...props} />
)
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
{...props}
/>
)
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
@ -64,4 +71,5 @@ const DialogDescription = React.forwardRef<
DialogDescription.displayName = DialogPrimitive.Description.displayName
export { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription }
export { DialogFooter }