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

criação de mecanica para troca de senha, e melhoria na exibição de subtasks

parent f85352f0
import { ShieldCheck } from "lucide-react";
import { Search, ShieldCheck, X } from "lucide-react";
import Link from "next/link";
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 { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Table,
TableBody,
......@@ -15,9 +18,14 @@ import { ResetPasswordDialog } from "@/components/admin/reset-password-dialog";
export const metadata = { title: "Administração — Petruz Tasks" };
export default async function AdminPage() {
export default async function AdminPage({
searchParams,
}: {
searchParams: { q?: string };
}) {
const admin = await requireSuperAdmin();
const users = await listAllUsers();
const query = searchParams.q?.trim() ?? "";
const users = await listAllUsers(query);
return (
<div className="space-y-6">
......@@ -32,6 +40,33 @@ export default async function AdminPage() {
</p>
</div>
<form method="GET" className="flex max-w-md items-center gap-2">
<div className="relative flex-1">
<Search className="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
name="q"
defaultValue={query}
placeholder="Buscar por nome ou e-mail..."
className="pl-9"
/>
</div>
<Button type="submit" variant="secondary">
Buscar
</Button>
{query && (
<Button variant="ghost" size="icon" asChild title="Limpar busca">
<Link href="/admin">
<X className="h-4 w-4" />
</Link>
</Button>
)}
</form>
{users.length === 0 ? (
<div className="rounded-lg border border-dashed p-12 text-center text-sm text-muted-foreground">
Nenhum usuário encontrado para “{query}”.
</div>
) : (
<div className="rounded-lg border">
<Table>
<TableHeader>
......@@ -77,6 +112,7 @@ export default async function AdminPage() {
</TableBody>
</Table>
</div>
)}
<p className="text-xs text-muted-foreground">
Para promover alguém a super admin, execute no banco de dados:{" "}
......
import { KeyRound } from "lucide-react";
import { requireUser } from "@/lib/authz";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { ChangePasswordForm } from "@/components/account/change-password-form";
export const metadata = { title: "Minha conta — Petruz Tasks" };
export default async function AccountPage() {
const user = await requireUser();
return (
<div className="mx-auto max-w-md space-y-6">
<div>
<h1 className="text-2xl font-bold">Minha conta</h1>
<p className="text-sm text-muted-foreground">
{user.name} · {user.email}
</p>
</div>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg">
<KeyRound className="h-5 w-5 text-primary" />
Trocar minha senha
</CardTitle>
<CardDescription>
Confirme sua senha atual e escolha uma nova. A troca vale apenas
para a sua conta.
</CardDescription>
</CardHeader>
<CardContent>
<ChangePasswordForm />
</CardContent>
</Card>
</div>
);
}
"use client";
import { useEffect, useRef } from "react";
import { useFormState, useFormStatus } from "react-dom";
import { toast } from "sonner";
import { changeOwnPasswordAction, type ActionState } from "@/lib/actions";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Alert, AlertDescription } from "@/components/ui/alert";
function SubmitButton() {
const { pending } = useFormStatus();
return (
<Button type="submit" disabled={pending} className="w-full">
{pending ? "Salvando..." : "Trocar senha"}
</Button>
);
}
const initialState: ActionState = {};
export function ChangePasswordForm() {
const [state, formAction] = useFormState(
changeOwnPasswordAction,
initialState
);
const formRef = useRef<HTMLFormElement>(null);
useEffect(() => {
if (state.success) {
toast.success("Senha alterada com sucesso.");
formRef.current?.reset();
}
}, [state]);
return (
<form ref={formRef} action={formAction} className="space-y-4">
{state.error && (
<Alert variant="destructive">
<AlertDescription>{state.error}</AlertDescription>
</Alert>
)}
<div className="space-y-2">
<Label htmlFor="current-password">Senha atual</Label>
<Input
id="current-password"
name="currentPassword"
type="password"
autoComplete="current-password"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="new-password">Nova senha</Label>
<Input
id="new-password"
name="newPassword"
type="password"
autoComplete="new-password"
minLength={8}
required
/>
<p className="text-xs text-muted-foreground">Mínimo de 8 caracteres.</p>
</div>
<SubmitButton />
</form>
);
}
import { LogOut } from "lucide-react";
import Link from "next/link";
import { KeyRound, LogOut } from "lucide-react";
import { logoutAction } from "@/lib/actions";
import type { SessionUser } from "@/lib/auth";
import { ThemeToggle } from "@/components/theme-toggle";
......@@ -33,6 +34,12 @@ export function Topbar({ user }: { user: SessionUser }) {
<p className="text-xs text-muted-foreground">{user.email}</p>
</div>
</div>
<Button variant="ghost" size="icon" asChild title="Minha conta">
<Link href="/conta">
<KeyRound className="h-4 w-4" />
<span className="sr-only">Minha conta</span>
</Link>
</Button>
<form action={logoutAction}>
<Button variant="ghost" size="icon" title="Sair" type="submit">
<LogOut className="h-4 w-4" />
......
import {
CheckCircle2,
Circle,
Eye,
LoaderCircle,
} from "lucide-react";
import { CheckCircle2, Circle, Eye, LoaderCircle } from "lucide-react";
import { TASK_STATUSES } from "@/lib/constants";
import type { Task } from "@/lib/data";
import { Card, CardContent } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { cn } from "@/lib/utils";
const STATUS_VISUALS: Record<
string,
{ icon: typeof Circle; text: string; bar: string }
{ icon: typeof Circle; text: string; iconBg: string }
> = {
todo: {
icon: Circle,
text: "text-slate-500 dark:text-slate-400",
bar: "bg-slate-400",
text: "text-slate-600 dark:text-slate-300",
iconBg: "bg-slate-500/10 text-slate-500",
},
in_progress: {
icon: LoaderCircle,
text: "text-blue-600 dark:text-blue-400",
bar: "bg-blue-500",
iconBg: "bg-blue-500/10 text-blue-500",
},
review: {
icon: Eye,
text: "text-purple-600 dark:text-purple-400",
bar: "bg-purple-500",
iconBg: "bg-purple-500/10 text-purple-500",
},
done: {
icon: CheckCircle2,
text: "text-green-600 dark:text-green-400",
bar: "bg-green-500",
iconBg: "bg-green-500/10 text-green-500",
},
};
/** Cartões de acompanhamento por status + barra geral do espaço. */
/** Cartões de acompanhamento por status do espaço. */
export function StatusSummary({ tasks }: { tasks: Task[] }) {
const total = tasks.length;
const done = tasks.filter((t) => t.Status === "done").length;
const overallPercent = total > 0 ? Math.round((done / total) * 100) : 0;
return (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
{TASK_STATUSES.map((status) => {
const visual = STATUS_VISUALS[status.value];
const count = tasks.filter((t) => t.Status === status.value).length;
const percent = total > 0 ? Math.round((count / total) * 100) : 0;
const Icon = visual.icon;
return (
<Card key={status.value}>
<CardContent className="space-y-2 p-4">
<div className="flex items-center justify-between">
<span
className={cn(
"flex items-center gap-1.5 text-sm font-medium",
visual.text
)}
>
<Icon className="h-4 w-4" />
{status.label}
</span>
<span className="text-2xl font-bold">{count}</span>
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-muted">
<div
className={cn("h-full rounded-full transition-all", visual.bar)}
style={{ width: `${percent}%` }}
/>
</div>
<p className="text-xs text-muted-foreground">
{percent}% das tarefas
</p>
</CardContent>
</Card>
);
})}
</div>
{total > 0 && (
<div className="flex items-center gap-3 rounded-lg border bg-card px-4 py-3">
<span className="whitespace-nowrap text-sm font-medium">
Andamento geral
</span>
<Progress value={overallPercent} className="h-2.5" />
<span className="whitespace-nowrap text-sm font-semibold text-primary">
{overallPercent}%
</span>
</div>
)}
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
{TASK_STATUSES.map((status) => {
const visual = STATUS_VISUALS[status.value];
const count = tasks.filter((t) => t.Status === status.value).length;
const Icon = visual.icon;
return (
<div
key={status.value}
className="flex items-center gap-3 rounded-xl border bg-card p-4"
>
<span
className={cn(
"flex h-10 w-10 shrink-0 items-center justify-center rounded-lg",
visual.iconBg
)}
>
<Icon className="h-5 w-5" />
</span>
<div className="min-w-0">
<p className="text-2xl font-bold leading-none">{count}</p>
<p
className={cn(
"mt-1 truncate text-xs font-medium",
visual.text
)}
>
{status.label}
</p>
</div>
</div>
);
})}
</div>
);
}
import Link from "next/link";
import {
CheckCircle2,
ChevronRight,
Circle,
Eye,
Folder,
LoaderCircle,
Plus,
UserRound,
......@@ -27,10 +25,10 @@ function initials(name: string): string {
}
export const STATUS_CHIPS = [
{ value: "todo", label: "A Fazer", icon: Circle, className: "text-slate-500 dark:text-slate-400" },
{ value: "in_progress", label: "Em Andamento", icon: LoaderCircle, className: "text-blue-600 dark:text-blue-400" },
{ value: "review", label: "Em Revisão", icon: Eye, className: "text-purple-600 dark:text-purple-400" },
{ value: "done", label: "Concluídas", icon: CheckCircle2, className: "text-green-600 dark:text-green-400" },
{ value: "todo", label: "A Fazer", icon: Circle, className: "text-slate-600 dark:text-slate-300", chipBg: "bg-slate-500/10" },
{ value: "in_progress", label: "Em Andamento", icon: LoaderCircle, className: "text-blue-600 dark:text-blue-400", chipBg: "bg-blue-500/10" },
{ value: "review", label: "Em Revisão", icon: Eye, className: "text-purple-600 dark:text-purple-400", chipBg: "bg-purple-500/10" },
{ value: "done", label: "Concluídas", icon: CheckCircle2, className: "text-green-600 dark:text-green-400", chipBg: "bg-green-500/10" },
] as const;
interface PersonGroup {
......@@ -41,7 +39,7 @@ interface PersonGroup {
}
/**
* Pastas por membro: clicar na pasta abre a página da pessoa
* Pastas por membro em grade: clicar no card abre a página da pessoa
* com apenas as tarefas dela.
*/
export function TaskByPerson({
......@@ -75,7 +73,7 @@ export function TaskByPerson({
}
return (
<div className="space-y-3">
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{groups.map((group) => {
const total = group.tasks.length;
// Média do % das tarefas: reflete progresso parcial, não só concluídas.
......@@ -90,94 +88,109 @@ export function TaskByPerson({
) / total
)
: 0;
const href = `/workspaces/${workspaceId}/membro/${group.userId ?? 0}`;
const isOwn = group.userId === currentUserId;
const canQuickCreate =
group.userId !== null && (isAdmin || isOwn);
return (
<div
key={group.key}
className="flex items-center gap-2 rounded-lg border bg-card pr-2 transition hover:border-primary/60 hover:shadow-sm"
className="group relative rounded-xl border bg-card transition hover:border-primary/50 hover:shadow-md"
>
<Link
href={href}
className="flex min-w-0 flex-1 flex-wrap items-center gap-3 px-4 py-3"
href={`/workspaces/${workspaceId}/membro/${group.userId ?? 0}`}
className="block space-y-4 p-5"
>
<Folder className="h-5 w-5 shrink-0 text-primary" />
<Avatar className="h-8 w-8">
<AvatarFallback
className={
group.name
? "bg-primary text-primary-foreground text-xs"
: "bg-muted text-muted-foreground"
}
>
{group.name ? (
initials(group.name)
) : (
<UserRound className="h-4 w-4" />
)}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p className="truncate font-medium leading-tight">
{group.name ?? "Sem responsável"}
</p>
<p className="text-xs text-muted-foreground">
{total === 0
? "Nenhuma tarefa atribuída"
: `${total} tarefa${total > 1 ? "s" : ""} — clique para abrir a pasta`}
</p>
<div className="flex items-center gap-3">
<Avatar className="h-12 w-12 ring-2 ring-primary/15">
<AvatarFallback
className={
group.name
? "bg-primary text-primary-foreground text-sm font-semibold"
: "bg-muted text-muted-foreground"
}
>
{group.name ? (
initials(group.name)
) : (
<UserRound className="h-5 w-5" />
)}
</AvatarFallback>
</Avatar>
<div className="min-w-0">
<p className="truncate font-semibold leading-tight">
{group.name ?? "Sem responsável"}
{isOwn && (
<span className="ml-1.5 text-xs font-normal text-primary">
(você)
</span>
)}
</p>
<p className="text-xs text-muted-foreground">
{total === 0
? "Nenhuma tarefa"
: `${total} tarefa${total > 1 ? "s" : ""}`}
</p>
</div>
</div>
<div className="hidden items-center gap-3 md:flex">
<div className="flex flex-wrap gap-1.5">
{STATUS_CHIPS.map((chip) => {
const count = group.tasks.filter(
(t) => t.Status === chip.value
).length;
if (count === 0) return null;
const Icon = chip.icon;
return (
<span
key={chip.value}
title={chip.label}
className={cn(
"flex items-center gap-1 text-xs",
count > 0 ? chip.className : "text-muted-foreground/40"
"inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium",
chip.chipBg,
chip.className
)}
title={chip.label}
>
<Icon className="h-3.5 w-3.5" />
<Icon className="h-3 w-3" />
{count}
</span>
);
})}
{total === 0 && (
<span className="rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">
Pasta vazia
</span>
)}
</div>
<div className="flex w-36 items-center gap-2">
<Progress value={percent} className="h-2" />
<div className="flex items-center gap-2">
<Progress value={percent} className="h-1.5" />
<span className="text-xs font-semibold text-muted-foreground">
{percent}%
</span>
</div>
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />
</Link>
{group.userId !== null &&
(isAdmin || group.userId === currentUserId) && (
<TaskDialog
workspaceId={workspaceId}
members={members}
defaultAssigneeId={group.userId}
currentUserId={currentUserId}
isAdmin={isAdmin}
trigger={
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 text-muted-foreground"
title={`Nova tarefa para ${group.name}`}
>
<Plus className="h-4 w-4" />
</Button>
}
/>
{canQuickCreate && (
<div className="absolute right-3 top-3">
<TaskDialog
workspaceId={workspaceId}
members={members}
defaultAssigneeId={group.userId ?? undefined}
currentUserId={currentUserId}
isAdmin={isAdmin}
trigger={
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground transition-opacity md:opacity-0 md:group-hover:opacity-100"
title={`Nova tarefa para ${group.name}`}
>
<Plus className="h-4 w-4" />
</Button>
}
/>
</div>
)}
</div>
);
......
import { Pencil } from "lucide-react";
"use client";
import { Fragment, useState } from "react";
import { CheckCircle2, ChevronRight, Circle, Pencil } from "lucide-react";
import type { Subtask, Task, WorkspaceMember } from "@/lib/data";
import { formatDate, toISODate } from "@/lib/dates";
import { canEditTask } from "@/lib/permissions";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import {
Table,
......@@ -11,9 +15,11 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import { cn } from "@/lib/utils";
import { DueBadge } from "./due-badge";
import { PriorityBadge } from "./priority-badge";
import { StatusBadge } from "./status-badge";
import { SubtaskList } from "./subtask-list";
import { TaskDeleteButton } from "./task-delete-button";
import { TaskDialog, type TaskFormValues } from "./task-dialog";
import { TaskProgress } from "./task-progress";
......@@ -34,6 +40,17 @@ export function toFormValues(task: Task): TaskFormValues {
};
}
function initials(name: string): string {
return name
.split(" ")
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase())
.join("");
}
const COLUMN_COUNT = 6;
export function TaskTable({
tasks,
members,
......@@ -49,110 +66,215 @@ export function TaskTable({
currentUserId: number;
isAdmin: boolean;
}) {
const [expanded, setExpanded] = useState<number[]>([]);
function toggleExpanded(taskId: number) {
setExpanded((prev) =>
prev.includes(taskId)
? prev.filter((id) => id !== taskId)
: [...prev, taskId]
);
}
if (tasks.length === 0) {
return (
<div className="rounded-lg border border-dashed p-12 text-center text-sm text-muted-foreground">
<div className="rounded-xl border border-dashed p-12 text-center text-sm text-muted-foreground">
Nenhuma tarefa aqui ainda.
</div>
);
}
return (
<div className="rounded-lg border">
<div className="overflow-hidden rounded-xl border bg-card">
<Table>
<TableHeader>
<TableRow>
<TableHead>Tarefa</TableHead>
<TableHead>Status</TableHead>
<TableHead>Progresso</TableHead>
<TableHead>Prioridade</TableHead>
<TableHead>Responsável</TableHead>
<TableHead>Início</TableHead>
<TableHead>Vencimento</TableHead>
<TableRow className="border-b bg-muted/40 hover:bg-muted/40">
<TableHead className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Tarefa
</TableHead>
<TableHead className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Status
</TableHead>
<TableHead className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Progresso
</TableHead>
<TableHead className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Responsável
</TableHead>
<TableHead className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Vencimento
</TableHead>
<TableHead className="w-[90px]" />
</TableRow>
</TableHeader>
<TableBody>
{tasks.map((task) => {
const editable = canEditTask(task, currentUserId, isAdmin);
const subtasks = subtasksByTask[task.Id] ?? [];
const hasSubtasks = subtasks.length > 0;
const isOpen = expanded.includes(task.Id);
return (
<TableRow key={task.Id}>
<TableCell className="max-w-[280px]">
<p className="truncate font-medium">{task.Title}</p>
{task.Description && (
<p className="truncate text-xs text-muted-foreground">
{task.Description}
</p>
<Fragment key={task.Id}>
<TableRow
className={cn(
"group border-b transition-colors hover:bg-muted/30",
isOpen ? "border-0 bg-muted/20" : "last:border-0"
)}
</TableCell>
<TableCell>
{editable ? (
<TaskStatusSelect taskId={task.Id} status={task.Status} />
) : (
<StatusBadge status={task.Status} />
)}
</TableCell>
<TableCell>
{editable ? (
<TaskProgressEditor
taskId={task.Id}
status={task.Status}
subtaskCount={task.SubtaskCount}
subtaskDone={task.SubtaskDone}
manualProgress={task.Progress}
/>
) : (
<TaskProgress
status={task.Status}
subtaskCount={task.SubtaskCount}
subtaskDone={task.SubtaskDone}
manualProgress={task.Progress}
/>
)}
</TableCell>
<TableCell>
<PriorityBadge priority={task.Priority} />
</TableCell>
<TableCell className="text-sm">
{task.AssigneeName ?? (
<span className="text-muted-foreground"></span>
)}
</TableCell>
<TableCell className="text-sm">
{formatDate(task.StartDate)}
</TableCell>
<TableCell>
<div className="flex flex-col gap-1">
<span className="text-sm">{formatDate(task.DueDate)}</span>
<DueBadge dueDate={task.DueDate} status={task.Status} />
</div>
</TableCell>
<TableCell>
{editable && (
<div className="flex items-center justify-end">
<TaskDialog
workspaceId={workspaceId}
members={members}
task={toFormValues(task)}
subtasks={subtasksByTask[task.Id] ?? []}
currentUserId={currentUserId}
isAdmin={isAdmin}
trigger={
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground"
title="Editar tarefa"
>
<Pencil className="h-4 w-4" />
</Button>
}
>
<TableCell className="max-w-[300px] py-3">
<div className="flex items-center gap-1.5">
{hasSubtasks ? (
<button
type="button"
onClick={() => toggleExpanded(task.Id)}
title={
isOpen
? "Recolher subtarefas"
: "Expandir subtarefas"
}
className="flex shrink-0 items-center gap-1 rounded-md px-1 py-0.5 text-xs text-muted-foreground hover:bg-muted hover:text-foreground"
>
<ChevronRight
className={cn(
"h-4 w-4 transition-transform",
isOpen && "rotate-90"
)}
/>
{subtasks.filter((s) => s.IsDone).length}/
{subtasks.length}
</button>
) : (
<span className="w-5 shrink-0" />
)}
<p className="truncate font-medium">{task.Title}</p>
<PriorityBadge priority={task.Priority} />
</div>
{task.Description && (
<p className="ml-6 mt-0.5 truncate text-xs text-muted-foreground">
{task.Description}
</p>
)}
</TableCell>
<TableCell className="py-3">
{editable ? (
<TaskStatusSelect taskId={task.Id} status={task.Status} />
) : (
<StatusBadge status={task.Status} />
)}
</TableCell>
<TableCell className="py-3">
{editable ? (
<TaskProgressEditor
taskId={task.Id}
status={task.Status}
subtaskCount={task.SubtaskCount}
subtaskDone={task.SubtaskDone}
manualProgress={task.Progress}
/>
) : (
<TaskProgress
status={task.Status}
subtaskCount={task.SubtaskCount}
subtaskDone={task.SubtaskDone}
manualProgress={task.Progress}
/>
<TaskDeleteButton taskId={task.Id} />
)}
</TableCell>
<TableCell className="py-3">
{task.AssigneeName ? (
<div className="flex items-center gap-2">
<Avatar className="h-7 w-7">
<AvatarFallback className="bg-primary/15 text-[10px] font-semibold text-primary">
{initials(task.AssigneeName)}
</AvatarFallback>
</Avatar>
<span className="hidden text-sm lg:inline">
{task.AssigneeName}
</span>
</div>
) : (
<span className="text-sm text-muted-foreground"></span>
)}
</TableCell>
<TableCell className="py-3">
<div className="flex items-center gap-2">
<span
className="text-sm text-muted-foreground"
title={
task.StartDate
? `Início: ${formatDate(task.StartDate)}`
: undefined
}
>
{formatDate(task.DueDate)}
</span>
<DueBadge dueDate={task.DueDate} status={task.Status} />
</div>
)}
</TableCell>
</TableRow>
</TableCell>
<TableCell className="py-3">
{editable && (
<div className="flex items-center justify-end transition-opacity md:opacity-0 md:group-hover:opacity-100">
<TaskDialog
workspaceId={workspaceId}
members={members}
task={toFormValues(task)}
subtasks={subtasks}
currentUserId={currentUserId}
isAdmin={isAdmin}
trigger={
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground"
title="Editar tarefa"
>
<Pencil className="h-4 w-4" />
</Button>
}
/>
<TaskDeleteButton
taskId={task.Id}
taskTitle={task.Title}
/>
</div>
)}
</TableCell>
</TableRow>
{isOpen && (
<TableRow className="border-b bg-muted/20 hover:bg-muted/20 last:border-0">
<TableCell colSpan={COLUMN_COUNT} className="py-3 pl-12">
<div className="max-w-md">
{editable ? (
<SubtaskList taskId={task.Id} subtasks={subtasks} />
) : (
<ul className="space-y-1.5">
{subtasks.map((subtask) => (
<li
key={subtask.Id}
className="flex items-center gap-2 text-sm"
>
{subtask.IsDone ? (
<CheckCircle2 className="h-4 w-4 shrink-0 text-green-500" />
) : (
<Circle className="h-4 w-4 shrink-0 text-muted-foreground" />
)}
<span
className={cn(
subtask.IsDone &&
"text-muted-foreground line-through"
)}
>
{subtask.Title}
</span>
</li>
))}
</ul>
)}
</div>
</TableCell>
</TableRow>
)}
</Fragment>
);
})}
</TableBody>
......
......@@ -104,6 +104,44 @@ export async function logoutAction(): Promise<void> {
redirect("/login");
}
// ---------- Conta (troca da própria senha) ----------
const changePasswordSchema = z.object({
currentPassword: z.string().min(1, "Informe sua senha atual."),
newPassword: z
.string()
.min(8, "A nova senha deve ter no mínimo 8 caracteres."),
});
export async function changeOwnPasswordAction(
_prev: ActionState,
formData: FormData
): Promise<ActionState> {
const user = await requireUser();
const parsed = changePasswordSchema.safeParse({
currentPassword: formData.get("currentPassword"),
newPassword: formData.get("newPassword"),
});
if (!parsed.success) return { error: firstError(parsed.error) };
try {
const record = await data.getUserById(user.id);
if (
!record ||
!(await verifyPassword(parsed.data.currentPassword, record.PasswordHash))
) {
return { error: "Senha atual incorreta." };
}
const hash = await hashPassword(parsed.data.newPassword);
await data.updateUserPassword(user.id, hash);
} catch (err) {
return {
error: err instanceof Error ? err.message : "Erro ao trocar a senha.",
};
}
return { success: true };
}
// ---------- Administração (super admin) ----------
const resetPasswordSchema = z.object({
......
......@@ -103,14 +103,27 @@ export async function getUserRole(userId: number): Promise<string | null> {
return result.recordset[0]?.Role ?? null;
}
export async function listAllUsers(): Promise<UserSummary[]> {
export async function listAllUsers(query?: string): Promise<UserSummary[]> {
const pool = await getPool();
const request = pool.request();
let where = "";
if (query?.trim()) {
request.input("q", sql.NVarChar(255), `%${query.trim()}%`);
where = "WHERE Name LIKE @q OR Email LIKE @q";
}
const result = await request.query(
`SELECT Id, Name, Email, Role, CreatedAt FROM dbo.Users ${where} ORDER BY Name`
);
return result.recordset;
}
export async function getUserById(id: number): Promise<UserRecord | null> {
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;
.input("id", sql.Int, id)
.query("SELECT * FROM dbo.Users WHERE Id = @id");
return result.recordset[0] ?? null;
}
export async function updateUserPassword(
......
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