Commit f85352f0 authored by Paulo Avila's avatar Paulo Avila
Browse files

tela de admin para reset de senha

parent eae4ed0f
Pipeline #21 failed with stages
in 0 seconds
...@@ -67,6 +67,19 @@ Para produção: `npm run build && npm start`. ...@@ -67,6 +67,19 @@ Para produção: `npm run build && npm start`.
espaço com barras de progresso. espaço com barras de progresso.
- **Tema**: claro/escuro (toggle na barra superior), paleta açaí. - **Tema**: claro/escuro (toggle na barra superior), paleta açaí.
## Super admin e reset de senha
Promova um usuário a **super admin** direto no banco:
```sql
UPDATE dbo.Users SET Role = 'superadmin' WHERE Email = 'email@exemplo.com';
```
O usuário promovido passa a ver o item **Administração** no menu (vale na
hora, sem novo login — o papel é lido do banco a cada acesso). Na página
`/admin` ele vê todos os usuários e pode **resetar a senha** de qualquer um
(com gerador de senha aleatória); a nova senha é hasheada com bcrypt.
## Estrutura do banco (criada automaticamente) ## Estrutura do banco (criada automaticamente)
| Tabela | Descrição | | Tabela | Descrição |
......
import { ShieldCheck } from "lucide-react";
import { requireSuperAdmin, SUPER_ADMIN_ROLE } from "@/lib/authz";
import { listAllUsers } from "@/lib/data";
import { formatDate } from "@/lib/dates";
import { Badge } from "@/components/ui/badge";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { ResetPasswordDialog } from "@/components/admin/reset-password-dialog";
export const metadata = { title: "Administração — Petruz Tasks" };
export default async function AdminPage() {
const admin = await requireSuperAdmin();
const users = await listAllUsers();
return (
<div className="space-y-6">
<div>
<h1 className="flex items-center gap-2 text-2xl font-bold">
<ShieldCheck className="h-6 w-6 text-primary" />
Administração de usuários
</h1>
<p className="text-sm text-muted-foreground">
Área restrita a super admins. Resete a senha de qualquer usuário do
sistema.
</p>
</div>
<div className="rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Nome</TableHead>
<TableHead>E-mail</TableHead>
<TableHead>Papel</TableHead>
<TableHead>Cadastro</TableHead>
<TableHead className="w-[160px]" />
</TableRow>
</TableHeader>
<TableBody>
{users.map((user) => (
<TableRow key={user.Id}>
<TableCell className="font-medium">
{user.Name}
{user.Id === admin.id && (
<span className="ml-2 text-xs text-muted-foreground">
(você)
</span>
)}
</TableCell>
<TableCell>{user.Email}</TableCell>
<TableCell>
<Badge
variant={
user.Role === SUPER_ADMIN_ROLE ? "default" : "secondary"
}
>
{user.Role === SUPER_ADMIN_ROLE
? "Super admin"
: "Usuário"}
</Badge>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{formatDate(user.CreatedAt)}
</TableCell>
<TableCell className="text-right">
<ResetPasswordDialog userId={user.Id} userName={user.Name} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<p className="text-xs text-muted-foreground">
Para promover alguém a super admin, execute no banco de dados:{" "}
<code className="rounded bg-muted px-1.5 py-0.5">
UPDATE dbo.Users SET Role = &apos;superadmin&apos; WHERE Email =
&apos;email@exemplo.com&apos;
</code>
</p>
</div>
);
}
import { requireUser } from "@/lib/authz"; import { isSuperAdmin, requireUser } from "@/lib/authz";
import { listWorkspacesForUser } from "@/lib/data"; import { listWorkspacesForUser } from "@/lib/data";
import { Sidebar } from "@/components/layout/sidebar"; import { Sidebar } from "@/components/layout/sidebar";
import { Topbar } from "@/components/layout/topbar"; import { Topbar } from "@/components/layout/topbar";
...@@ -8,15 +8,19 @@ export default async function AppLayout({ ...@@ -8,15 +8,19 @@ export default async function AppLayout({
}: Readonly<{ children: React.ReactNode }>) { }: Readonly<{ children: React.ReactNode }>) {
const user = await requireUser(); const user = await requireUser();
let workspaces: Awaited<ReturnType<typeof listWorkspacesForUser>> = []; let workspaces: Awaited<ReturnType<typeof listWorkspacesForUser>> = [];
let superAdmin = false;
try { try {
workspaces = await listWorkspacesForUser(user.id); [workspaces, superAdmin] = await Promise.all([
listWorkspacesForUser(user.id),
isSuperAdmin(user.id),
]);
} catch (err) { } catch (err) {
console.error("Falha ao carregar workspaces:", err); console.error("Falha ao carregar workspaces:", err);
} }
return ( return (
<div className="flex min-h-screen"> <div className="flex min-h-screen">
<Sidebar workspaces={workspaces} /> <Sidebar workspaces={workspaces} isSuperAdmin={superAdmin} />
<div className="flex min-w-0 flex-1 flex-col"> <div className="flex min-w-0 flex-1 flex-col">
<Topbar user={user} /> <Topbar user={user} />
<main className="flex-1 p-4 md:p-6">{children}</main> <main className="flex-1 p-4 md:p-6">{children}</main>
......
"use client";
import { useEffect, useState } from "react";
import { KeyRound, RefreshCw } from "lucide-react";
import { useFormState, useFormStatus } from "react-dom";
import { toast } from "sonner";
import { resetPasswordAction, type ActionState } from "@/lib/actions";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Alert, AlertDescription } from "@/components/ui/alert";
function generatePassword(): string {
const chars =
"ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789!@#$%";
const bytes = new Uint8Array(12);
crypto.getRandomValues(bytes);
return Array.from(bytes, (b) => chars[b % chars.length]).join("");
}
function SubmitButton() {
const { pending } = useFormStatus();
return (
<Button type="submit" disabled={pending} className="w-full">
{pending ? "Salvando..." : "Resetar senha"}
</Button>
);
}
const initialState: ActionState = {};
export function ResetPasswordDialog({
userId,
userName,
}: {
userId: number;
userName: string;
}) {
const [open, setOpen] = useState(false);
const [password, setPassword] = useState("");
const [state, formAction] = useFormState(resetPasswordAction, initialState);
useEffect(() => {
if (open && state.success) {
toast.success(`Senha de ${userName} alterada. Informe a nova senha ao usuário.`);
setOpen(false);
setPassword("");
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state]);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
<KeyRound className="mr-2 h-4 w-4" />
Resetar senha
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>Resetar senha de {userName}</DialogTitle>
<DialogDescription>
Defina a nova senha e repasse ao usuário por um canal seguro.
Recomende que ele a troque depois.
</DialogDescription>
</DialogHeader>
<form action={formAction} className="space-y-4">
{state.error && (
<Alert variant="destructive">
<AlertDescription>{state.error}</AlertDescription>
</Alert>
)}
<input type="hidden" name="userId" value={userId} />
<div className="space-y-2">
<Label htmlFor={`new-password-${userId}`}>Nova senha</Label>
<div className="flex gap-2">
<Input
id={`new-password-${userId}`}
name="password"
type="text"
minLength={8}
required
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Mínimo 8 caracteres"
autoComplete="off"
/>
<Button
type="button"
variant="secondary"
size="icon"
title="Gerar senha aleatória"
onClick={() => setPassword(generatePassword())}
>
<RefreshCw className="h-4 w-4" />
</Button>
</div>
</div>
<SubmitButton />
</form>
</DialogContent>
</Dialog>
);
}
...@@ -9,6 +9,7 @@ import { ...@@ -9,6 +9,7 @@ import {
PanelLeftClose, PanelLeftClose,
PanelLeftOpen, PanelLeftOpen,
Plus, Plus,
ShieldCheck,
} from "lucide-react"; } from "lucide-react";
import type { Workspace } from "@/lib/data"; import type { Workspace } from "@/lib/data";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
...@@ -16,7 +17,13 @@ import { cn } from "@/lib/utils"; ...@@ -16,7 +17,13 @@ import { cn } from "@/lib/utils";
const STORAGE_KEY = "petruz-sidebar-collapsed"; const STORAGE_KEY = "petruz-sidebar-collapsed";
export function Sidebar({ workspaces }: { workspaces: Workspace[] }) { export function Sidebar({
workspaces,
isSuperAdmin = false,
}: {
workspaces: Workspace[];
isSuperAdmin?: boolean;
}) {
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(false);
useEffect(() => { useEffect(() => {
...@@ -89,6 +96,19 @@ export function Sidebar({ workspaces }: { workspaces: Workspace[] }) { ...@@ -89,6 +96,19 @@ export function Sidebar({ workspaces }: { workspaces: Workspace[] }) {
<Folder className="h-4 w-4 shrink-0" /> <Folder className="h-4 w-4 shrink-0" />
{!collapsed && "Espaços de trabalho"} {!collapsed && "Espaços de trabalho"}
</Link> </Link>
{isSuperAdmin && (
<Link
href="/admin"
title="Administração"
className={cn(
"flex items-center gap-3 rounded-md text-sm font-medium hover:bg-sidebar-accent",
collapsed ? "justify-center p-2.5" : "px-3 py-2"
)}
>
<ShieldCheck className="h-4 w-4 shrink-0" />
{!collapsed && "Administração"}
</Link>
)}
{!collapsed && ( {!collapsed && (
<p className="mt-4 px-3 text-xs font-semibold uppercase tracking-wider text-sidebar-foreground/50"> <p className="mt-4 px-3 text-xs font-semibold uppercase tracking-wider text-sidebar-foreground/50">
......
...@@ -11,6 +11,7 @@ import { ...@@ -11,6 +11,7 @@ import {
} from "./auth"; } from "./auth";
import { import {
assertCanEditTask, assertCanEditTask,
assertSuperAdmin,
assertWorkspaceAdmin, assertWorkspaceAdmin,
getWorkspaceRole, getWorkspaceRole,
requireUser, requireUser,
...@@ -103,6 +104,37 @@ export async function logoutAction(): Promise<void> { ...@@ -103,6 +104,37 @@ export async function logoutAction(): Promise<void> {
redirect("/login"); redirect("/login");
} }
// ---------- Administração (super admin) ----------
const resetPasswordSchema = z.object({
userId: z.coerce.number().int().positive(),
password: z.string().min(8, "A nova senha deve ter no mínimo 8 caracteres."),
});
export async function resetPasswordAction(
_prev: ActionState,
formData: FormData
): Promise<ActionState> {
const user = await requireUser();
const parsed = resetPasswordSchema.safeParse({
userId: formData.get("userId"),
password: formData.get("password"),
});
if (!parsed.success) return { error: firstError(parsed.error) };
try {
await assertSuperAdmin(user);
const hash = await hashPassword(parsed.data.password);
await data.updateUserPassword(parsed.data.userId, hash);
} catch (err) {
return {
error: err instanceof Error ? err.message : "Erro ao resetar a senha.",
};
}
revalidatePath("/admin");
return { success: true };
}
// ---------- Workspaces ---------- // ---------- Workspaces ----------
const workspaceSchema = z.object({ const workspaceSchema = z.object({
......
...@@ -19,6 +19,32 @@ export async function requireUser(): Promise<SessionUser> { ...@@ -19,6 +19,32 @@ export async function requireUser(): Promise<SessionUser> {
return session; return session;
} }
export const SUPER_ADMIN_ROLE = "superadmin";
/** Papel global lido do banco — promoções via SQL valem na hora. */
export async function isSuperAdmin(userId: number): Promise<boolean> {
const pool = await getPool();
const result = await pool
.request()
.input("id", sql.Int, userId)
.query("SELECT Role FROM dbo.Users WHERE Id = @id");
return result.recordset[0]?.Role === SUPER_ADMIN_ROLE;
}
/** Para páginas: redireciona quem não é super admin. */
export async function requireSuperAdmin(): Promise<SessionUser> {
const user = await requireUser();
if (!(await isSuperAdmin(user.id))) redirect("/dashboard");
return user;
}
/** Para actions: lança erro se não for super admin. */
export async function assertSuperAdmin(user: SessionUser): Promise<void> {
if (!(await isSuperAdmin(user.id))) {
throw new Error("Apenas super admins podem executar esta ação.");
}
}
export type WorkspaceRole = "admin" | "member"; export type WorkspaceRole = "admin" | "member";
export async function getWorkspaceRole( export async function getWorkspaceRole(
......
...@@ -85,6 +85,46 @@ export async function createUser( ...@@ -85,6 +85,46 @@ export async function createUser(
return result.recordset[0]; return result.recordset[0];
} }
export interface UserSummary {
Id: number;
Name: string;
Email: string;
Role: string;
CreatedAt: Date;
}
/** Papel global atual direto do banco (não confia no token da sessão). */
export async function getUserRole(userId: number): Promise<string | null> {
const pool = await getPool();
const result = await pool
.request()
.input("id", sql.Int, userId)
.query("SELECT Role FROM dbo.Users WHERE Id = @id");
return result.recordset[0]?.Role ?? null;
}
export async function listAllUsers(): Promise<UserSummary[]> {
const pool = await getPool();
const result = await pool
.request()
.query(
"SELECT Id, Name, Email, Role, CreatedAt FROM dbo.Users ORDER BY Name"
);
return result.recordset;
}
export async function updateUserPassword(
userId: number,
passwordHash: string
): Promise<void> {
const pool = await getPool();
await pool
.request()
.input("id", sql.Int, userId)
.input("hash", sql.NVarChar(255), passwordHash)
.query("UPDATE dbo.Users SET PasswordHash = @hash WHERE Id = @id");
}
// ---------- Workspaces ---------- // ---------- Workspaces ----------
export async function listWorkspacesForUser( export async function listWorkspacesForUser(
......
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment