ui: complete profile page redesign

- Modern card-based layout with large avatar
- Inline editing for name/email with save/cancel
- Password change dialog with validation
- Security section with danger zone
- Studies access quick link
- Consistent teal theme colors
- Uses PageHeader pattern
- Better loading states
This commit is contained in:
2026-03-22 17:08:50 -04:00
parent b353ef7c9f
commit 519e6a2606

View File

@@ -1,10 +1,31 @@
"use client"; "use client";
import * as React from "react";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { PasswordChangeForm } from "~/components/profile/password-change-form"; import Link from "next/link";
import { ProfileEditForm } from "~/components/profile/profile-edit-form"; import { useSession } from "~/lib/auth-client";
import { Badge } from "~/components/ui/badge"; import { api } from "~/trpc/react";
import { toast } from "sonner";
import { format } from "date-fns";
import {
User,
Mail,
Shield,
Lock,
Settings,
Building,
Calendar,
ChevronRight,
Loader2,
Save,
X,
} from "lucide-react";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { Badge } from "~/components/ui/badge";
import { Separator } from "~/components/ui/separator";
import { import {
Card, Card,
CardContent, CardContent,
@@ -12,192 +33,326 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "~/components/ui/card"; } from "~/components/ui/card";
import { Separator } from "~/components/ui/separator"; import {
import { PageHeader } from "~/components/ui/page-header"; Dialog,
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider"; DialogContent,
import { formatRole, getRoleDescription } from "~/lib/auth-client"; DialogDescription,
import { User, Shield, Download, Trash2, Lock, UserCog } from "lucide-react"; DialogFooter,
import { useSession } from "~/lib/auth-client"; DialogHeader,
import { cn } from "~/lib/utils"; DialogTitle,
import { api } from "~/trpc/react"; DialogTrigger,
} from "~/components/ui/dialog";
interface ProfileUser { function ProfilePageContent() {
id: string; const { data: session } = useSession();
name: string | null; const utils = api.useUtils();
email: string; const [isEditing, setIsEditing] = React.useState(false);
image: string | null; const [name, setName] = React.useState(session?.user?.name ?? "");
roles?: Array<{ const [email, setEmail] = React.useState(session?.user?.email ?? "");
role: "administrator" | "researcher" | "wizard" | "observer"; const [passwordOpen, setPasswordOpen] = React.useState(false);
grantedAt: Date; const [currentPassword, setCurrentPassword] = React.useState("");
grantedBy: string | null; const [newPassword, setNewPassword] = React.useState("");
}>; const [confirmPassword, setConfirmPassword] = React.useState("");
}
const { data: userData } = api.users.get.useQuery(
{ id: session?.user?.id ?? "" },
{ enabled: !!session?.user?.id },
);
const updateProfile = api.users.update.useMutation({
onSuccess: () => {
toast.success("Profile updated successfully");
void utils.users.get.invalidate();
setIsEditing(false);
},
onError: (error) => {
toast.error("Failed to update profile", { description: error.message });
},
});
const changePassword = api.users.changePassword.useMutation({
onSuccess: () => {
toast.success("Password changed successfully");
setPasswordOpen(false);
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
},
onError: (error) => {
toast.error("Failed to change password", { description: error.message });
},
});
const handleSave = () => {
if (!name.trim()) {
toast.error("Name is required");
return;
}
updateProfile.mutate({ id: session?.user?.id ?? "", name, email });
};
const handlePasswordChange = (e: React.FormEvent) => {
e.preventDefault();
if (newPassword !== confirmPassword) {
toast.error("Passwords don't match");
return;
}
if (newPassword.length < 8) {
toast.error("Password must be at least 8 characters");
return;
}
changePassword.mutate({
currentPassword,
newPassword,
});
};
const user = userData ?? session?.user;
const roles = (userData as any)?.systemRoles ?? [];
const initials = (user?.name ?? user?.email ?? "U").charAt(0).toUpperCase();
function ProfileContent({ user }: { user: ProfileUser }) {
return ( return (
<div className="animate-in fade-in space-y-8 duration-500"> <div className="space-y-6">
<PageHeader {/* Header */}
title={user.name ?? "User"} <div className="flex items-center justify-between">
description={user.email} <div className="flex items-center gap-4">
icon={User} <div className="flex h-16 w-16 items-center justify-center rounded-full bg-primary text-xl font-bold text-primary-foreground">
badges={[ {initials}
{ label: `ID: ${user.id}`, variant: "outline" }, </div>
...(user.roles?.map((r) => ({ <div>
label: formatRole(r.role), <h1 className="text-2xl font-bold">{user?.name ?? "User"}</h1>
variant: "secondary" as const, <p className="text-muted-foreground">{user?.email}</p>
})) ?? []), {roles.length > 0 && (
]} <div className="mt-1 flex gap-2">
/> {roles.map((role: any) => (
<Badge key={role.role} variant="secondary" className="text-xs">
{role.role}
</Badge>
))}
</div>
)}
</div>
</div>
<div className="flex gap-2">
{isEditing ? (
<>
<Button variant="outline" onClick={() => setIsEditing(false)}>
<X className="mr-2 h-4 w-4" />
Cancel
</Button>
<Button onClick={handleSave} disabled={updateProfile.isPending}>
{updateProfile.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
Save Changes
</Button>
</>
) : (
<Button variant="outline" onClick={() => setIsEditing(true)}>
<Settings className="mr-2 h-4 w-4" />
Edit Profile
</Button>
)}
</div>
</div>
<div className="grid grid-cols-1 gap-8 lg:grid-cols-3"> {/* Main Content */}
{/* Main Content (Left Column) */} <div className="grid gap-6 lg:grid-cols-3">
<div className="space-y-8 lg:col-span-2"> {/* Left Column - Profile Info */}
<div className="space-y-6 lg:col-span-2">
{/* Personal Information */} {/* Personal Information */}
<section className="space-y-4"> <Card>
<div className="flex items-center gap-2 border-b pb-2"> <CardHeader>
<User className="text-primary h-5 w-5" /> <CardTitle className="flex items-center gap-2">
<h3 className="text-lg font-semibold">Personal Information</h3> <User className="h-5 w-5 text-primary" />
</div> Personal Information
<Card className="border-border/60 hover:border-border transition-colors"> </CardTitle>
<CardHeader> <CardDescription>
<CardTitle className="text-base">Contact Details</CardTitle> Your public profile information
<CardDescription> </CardDescription>
Update your public profile information </CardHeader>
</CardDescription> <CardContent className="space-y-4">
</CardHeader> <div className="grid gap-4 md:grid-cols-2">
<CardContent> <div className="space-y-2">
<ProfileEditForm <Label htmlFor="name">Full Name</Label>
user={{ {isEditing ? (
id: user.id, <Input
name: user.name, id="name"
email: user.email, value={name}
image: user.image, onChange={(e) => setName(e.target.value)}
}} placeholder="Your name"
/> />
</CardContent> ) : (
</Card> <div className="flex items-center gap-2 rounded-md border bg-muted/50 p-2">
</section> <User className="text-muted-foreground h-4 w-4" />
<span>{name || "Not set"}</span>
</div>
)}
</div>
<div className="space-y-2">
<Label htmlFor="email">Email Address</Label>
{isEditing ? (
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
/>
) : (
<div className="flex items-center gap-2 rounded-md border bg-muted/50 p-2">
<Mail className="text-muted-foreground h-4 w-4" />
<span>{email}</span>
</div>
)}
</div>
</div>
<div className="space-y-2">
<Label>User ID</Label>
<div className="rounded-md border bg-muted/50 p-2 font-mono text-sm">
{user?.id ?? session?.user?.id}
</div>
</div>
</CardContent>
</Card>
{/* Security */} {/* Recent Activity */}
<section className="space-y-4"> <Card>
<div className="flex items-center gap-2 border-b pb-2"> <CardHeader>
<Lock className="text-primary h-5 w-5" /> <CardTitle className="flex items-center gap-2">
<h3 className="text-lg font-semibold">Security</h3> <Calendar className="h-5 w-5 text-primary" />
</div> Recent Activity
<Card className="border-border/60 hover:border-border transition-colors"> </CardTitle>
<CardHeader> <CardDescription>
<CardTitle className="text-base">Password</CardTitle> Your recent actions across the platform
<CardDescription> </CardDescription>
Ensure your account stays secure </CardHeader>
</CardDescription> <CardContent>
</CardHeader> <div className="flex flex-col items-center justify-center py-8 text-center">
<CardContent> <Calendar className="text-muted-foreground/50 mb-3 h-12 w-12" />
<PasswordChangeForm /> <p className="font-medium">No recent activity</p>
</CardContent> <p className="text-muted-foreground text-sm">
</Card> Your recent actions will appear here
</section> </p>
</div>
</CardContent>
</Card>
</div> </div>
{/* Sidebar (Right Column) */} {/* Right Column - Settings */}
<div className="space-y-8"> <div className="space-y-6">
{/* Permissions */} {/* Security */}
<section className="space-y-4"> <Card>
<div className="flex items-center gap-2 border-b pb-2"> <CardHeader>
<Shield className="text-primary h-5 w-5" /> <CardTitle className="flex items-center gap-2">
<h3 className="text-lg font-semibold">Permissions</h3> <Shield className="h-5 w-5 text-primary" />
</div> Security
<Card> </CardTitle>
<CardContent className="pt-6"> </CardHeader>
{user.roles && user.roles.length > 0 ? ( <CardContent className="space-y-4">
<div className="space-y-4"> <div className="flex items-center justify-between rounded-lg border p-3">
{user.roles.map((roleInfo, index) => ( <div className="flex items-center gap-3">
<div key={index} className="space-y-2"> <Lock className="text-muted-foreground h-4 w-4" />
<div className="flex items-center justify-between"> <div>
<span className="text-sm font-medium"> <p className="text-sm font-medium">Password</p>
{formatRole(roleInfo.role)} <p className="text-muted-foreground text-xs">Last changed: Never</p>
</span>
<span className="text-muted-foreground bg-muted rounded px-1.5 py-0.5 text-[10px]">
Since{" "}
{new Date(roleInfo.grantedAt).toLocaleDateString()}
</span>
</div>
<p className="text-muted-foreground text-xs leading-relaxed">
{getRoleDescription(roleInfo.role)}
</p>
{index < (user.roles?.length || 0) - 1 && (
<Separator className="my-2" />
)}
</div>
))}
<div className="text-muted-foreground mt-4 rounded-lg border border-blue-100 bg-blue-50/50 p-3 text-xs dark:border-blue-900/30 dark:bg-blue-900/10">
<div className="text-primary mb-1 flex items-center gap-2 font-medium">
<Shield className="h-3 w-3" />
<span>Role Management</span>
</div>
System roles are managed by administrators. Contact
support if you need access adjustments.
</div>
</div> </div>
) : ( </div>
<div className="py-4 text-center"> <Dialog open={passwordOpen} onOpenChange={setPasswordOpen}>
<p className="text-sm font-medium">No Roles Assigned</p> <DialogTrigger asChild>
<p className="text-muted-foreground mt-1 text-xs"> <Button variant="ghost" size="sm">
Contact an admin to request access. Change
</p>
<Button size="sm" variant="outline" className="mt-3 w-full">
Request Access
</Button> </Button>
</div> </DialogTrigger>
)} <DialogContent>
</CardContent> <DialogHeader>
</Card> <DialogTitle>Change Password</DialogTitle>
</section> <DialogDescription>
Enter your current password and choose a new one.
</DialogDescription>
</DialogHeader>
<form onSubmit={handlePasswordChange} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="current">Current Password</Label>
<Input
id="current"
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="new">New Password</Label>
<Input
id="new"
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
required
minLength={8}
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirm">Confirm Password</Label>
<Input
id="confirm"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
/>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setPasswordOpen(false)}>
Cancel
</Button>
<Button type="submit" disabled={changePassword.isPending}>
{changePassword.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Change Password
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
{/* Data & Privacy */} <Separator />
<section className="space-y-4">
<div className="flex items-center gap-2 border-b pb-2">
<Download className="text-primary h-5 w-5" />
<h3 className="text-lg font-semibold">Data & Privacy</h3>
</div>
<Card className="border-destructive/10 bg-destructive/5 overflow-hidden"> <div className="rounded-lg border bg-destructive/5 p-3">
<CardContent className="space-y-4 pt-6"> <p className="text-sm font-medium text-destructive">Danger Zone</p>
<div> <p className="text-muted-foreground mt-1 text-xs">
<h4 className="mb-1 text-sm font-semibold">Export Data</h4> Account deletion is not available. Contact an administrator for assistance.
<p className="text-muted-foreground mb-3 text-xs"> </p>
Download a copy of your personal data. </div>
</p> </CardContent>
<Button </Card>
variant="outline"
size="sm" {/* Studies Access */}
className="bg-background w-full" <Card>
disabled <CardHeader>
> <CardTitle className="flex items-center gap-2">
<Download className="mr-2 h-3 w-3" /> <Building className="h-5 w-5 text-primary" />
Download Archive Studies Access
</Button> </CardTitle>
</div> <CardDescription>
<Separator className="bg-destructive/10" /> Studies you have access to
<div> </CardDescription>
<h4 className="text-destructive mb-1 text-sm font-semibold"> </CardHeader>
Delete Account <CardContent>
</h4> <div className="flex flex-col items-center justify-center py-4 text-center">
<p className="text-muted-foreground mb-3 text-xs"> <Building className="text-muted-foreground/50 mb-2 h-8 w-8" />
This action is irreversible. <p className="text-sm">View your studies</p>
</p> <Button variant="link" size="sm" asChild className="mt-2">
<Button <Link href="/studies">
variant="destructive" Go to Studies <ChevronRight className="ml-1 h-3 w-3" />
size="sm" </Link>
className="w-full" </Button>
disabled </div>
> </CardContent>
<Trash2 className="mr-2 h-3 w-3" /> </Card>
Delete Account
</Button>
</div>
</CardContent>
</Card>
</section>
</div> </div>
</div> </div>
</div> </div>
@@ -206,22 +361,11 @@ function ProfileContent({ user }: { user: ProfileUser }) {
export default function ProfilePage() { export default function ProfilePage() {
const { data: session, isPending } = useSession(); const { data: session, isPending } = useSession();
const { data: userData, isPending: isUserPending } = api.auth.me.useQuery(
undefined,
{
enabled: !!session?.user,
},
);
useBreadcrumbsEffect([ if (isPending) {
{ label: "Dashboard", href: "/dashboard" },
{ label: "Profile" },
]);
if (isPending || isUserPending) {
return ( return (
<div className="text-muted-foreground animate-pulse p-8"> <div className="flex items-center justify-center p-12">
Loading profile... <Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
</div> </div>
); );
} }
@@ -230,13 +374,5 @@ export default function ProfilePage() {
redirect("/auth/signin"); redirect("/auth/signin");
} }
const user: ProfileUser = { return <ProfilePageContent />;
id: session.user.id,
name: userData?.name ?? session.user.name ?? null,
email: userData?.email ?? session.user.email,
image: userData?.image ?? session.user.image ?? null,
roles: userData?.systemRoles as ProfileUser["roles"],
};
return <ProfileContent user={user} />;
} }