Add change password to profile, persist all profile fields to DB

- Add jobTitle, company, bio columns to User model (previously localStorage only)
- Replace legacy Authentik GET /api/auth/me with session-based profile endpoint
- Update PUT /api/auth/me to persist all profile fields to DB
- Add PUT /api/auth/change-password endpoint with current password verification
- Add Security card to profile page with change password form
- Update user-profile provider to fetch from API instead of localStorage

Made-with: Cursor
This commit is contained in:
Randall Stillwell 2026-04-17 15:35:24 -05:00
parent 028840a887
commit a83c41694f
5 changed files with 287 additions and 104 deletions

View file

@ -17,6 +17,9 @@ model User {
username String?
displayName String?
avatarUrl String @default("")
jobTitle String @default("")
company String @default("")
bio String @default("")
role String @default("viewer")
activeOrgId String?
createdAt DateTime @default(now())

View file

@ -12,6 +12,9 @@ import {
Building2,
Shield,
Loader2,
Lock,
Eye,
EyeOff,
} from "lucide-react";
import { Header } from "@/components/layout/header";
@ -60,6 +63,47 @@ export default function ProfilePage() {
};
const [saving, setSaving] = React.useState(false);
const [pwForm, setPwForm] = React.useState({
currentPassword: "",
newPassword: "",
confirmPassword: "",
});
const [pwSaving, setPwSaving] = React.useState(false);
const [showCurrentPw, setShowCurrentPw] = React.useState(false);
const [showNewPw, setShowNewPw] = React.useState(false);
const handlePasswordChange = async () => {
if (pwForm.newPassword.length < 8) {
toast.error("Password must be at least 8 characters");
return;
}
if (pwForm.newPassword !== pwForm.confirmPassword) {
toast.error("Passwords do not match");
return;
}
setPwSaving(true);
try {
const res = await fetch("/api/auth/change-password", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
currentPassword: pwForm.currentPassword,
newPassword: pwForm.newPassword,
}),
});
const data = await res.json();
if (!res.ok) {
toast.error(data.error || "Failed to change password");
return;
}
toast.success("Password changed successfully");
setPwForm({ currentPassword: "", newPassword: "", confirmPassword: "" });
} catch {
toast.error("Failed to change password");
} finally {
setPwSaving(false);
}
};
const handleSave = async () => {
setSaving(true);
@ -264,6 +308,117 @@ export default function ProfilePage() {
</CardContent>
</Card>
</div>
{/* Security — Change Password */}
<Card variant="glass">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Lock className="size-4" />
Security
</CardTitle>
<CardDescription>
{profile.hasPassword
? "Change your account password"
: "Set a password for your account"}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{profile.hasPassword && (
<div>
<Label className="mb-1.5 block text-xs font-medium text-muted-foreground">
Current Password
</Label>
<div className="relative">
<Input
type={showCurrentPw ? "text" : "password"}
value={pwForm.currentPassword}
onChange={(e) =>
setPwForm((p) => ({ ...p, currentPassword: e.target.value }))
}
placeholder="••••••••"
/>
<button
type="button"
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => setShowCurrentPw(!showCurrentPw)}
tabIndex={-1}
>
{showCurrentPw ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
</button>
</div>
</div>
)}
<div>
<Label className="mb-1.5 block text-xs font-medium text-muted-foreground">
New Password
</Label>
<div className="relative">
<Input
type={showNewPw ? "text" : "password"}
value={pwForm.newPassword}
onChange={(e) =>
setPwForm((p) => ({ ...p, newPassword: e.target.value }))
}
placeholder="At least 8 characters"
/>
<button
type="button"
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => setShowNewPw(!showNewPw)}
tabIndex={-1}
>
{showNewPw ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
</button>
</div>
</div>
<div>
<Label className="mb-1.5 block text-xs font-medium text-muted-foreground">
Confirm New Password
</Label>
<Input
type="password"
value={pwForm.confirmPassword}
onChange={(e) =>
setPwForm((p) => ({ ...p, confirmPassword: e.target.value }))
}
placeholder="Repeat new password"
/>
</div>
</div>
{pwForm.newPassword.length > 0 && pwForm.newPassword.length < 8 && (
<p className="text-xs text-amber-600 dark:text-amber-400">
Password must be at least 8 characters
</p>
)}
{pwForm.confirmPassword.length > 0 &&
pwForm.newPassword !== pwForm.confirmPassword && (
<p className="text-xs text-red-600 dark:text-red-400">
Passwords do not match
</p>
)}
<Button
variant="outline"
className="rounded-xl"
onClick={handlePasswordChange}
disabled={
pwSaving ||
pwForm.newPassword.length < 8 ||
pwForm.newPassword !== pwForm.confirmPassword ||
(profile.hasPassword && !pwForm.currentPassword)
}
>
{pwSaving ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : (
<Lock className="mr-2 size-4" />
)}
{profile.hasPassword ? "Change Password" : "Set Password"}
</Button>
</CardContent>
</Card>
</div>
);
}

View file

@ -0,0 +1,60 @@
import { NextRequest, NextResponse } from "next/server";
import bcrypt from "bcryptjs";
import { auth } from "@/auth";
import { prisma } from "@/lib/db";
export async function PUT(req: NextRequest) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { currentPassword, newPassword } = await req.json();
if (!newPassword || typeof newPassword !== "string" || newPassword.length < 8) {
return NextResponse.json(
{ error: "New password must be at least 8 characters" },
{ status: 400 }
);
}
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: { hashedPassword: true },
});
if (!user) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
}
if (user.hashedPassword) {
if (!currentPassword) {
return NextResponse.json(
{ error: "Current password is required" },
{ status: 400 }
);
}
const valid = await bcrypt.compare(currentPassword, user.hashedPassword);
if (!valid) {
return NextResponse.json(
{ error: "Current password is incorrect" },
{ status: 403 }
);
}
}
const hashedPassword = await bcrypt.hash(newPassword, 12);
await prisma.user.update({
where: { id: session.user.id },
data: { hashedPassword },
});
return NextResponse.json({ ok: true });
} catch (error) {
console.error("[change-password] Error:", error);
return NextResponse.json({ error: "Something went wrong" }, { status: 500 });
}
}

View file

@ -1,80 +1,41 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth";
import { prisma } from "@/lib/db";
import { getOrCreateUser, type AppUser } from "@/lib/auth";
export type AuthentikUser = {
username: string;
name: string;
email: string;
groups: string[];
uid: string;
avatar: string;
};
export type { AppUser };
/**
* Reads Authentik forward-auth headers injected by Traefik and optionally
* enriches with avatar from the Authentik API.
*
* Headers set by authentik forward-auth:
* X-authentik-username, X-authentik-name, X-authentik-email,
* X-authentik-groups, X-authentik-uid
*/
export async function GET(req: NextRequest) {
const username = req.headers.get("x-authentik-username") ?? "";
const name = req.headers.get("x-authentik-name") ?? "";
const email = req.headers.get("x-authentik-email") ?? "";
const groups = req.headers.get("x-authentik-groups") ?? "";
const uid = req.headers.get("x-authentik-uid") ?? "";
if (!username && !email) {
return NextResponse.json(
{ authenticated: false, user: null },
{ status: 200 }
);
export async function GET() {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const user: AuthentikUser = {
username,
name,
email,
groups: groups ? groups.split("|") : [],
uid,
avatar: "",
};
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: {
id: true,
email: true,
displayName: true,
avatarUrl: true,
jobTitle: true,
company: true,
bio: true,
hashedPassword: true,
},
});
const authentikUrl = process.env.AUTHENTIK_URL;
const authentikToken = process.env.AUTHENTIK_API_TOKEN;
if (authentikUrl && authentikToken && uid) {
try {
const res = await fetch(
`${authentikUrl}/api/v3/core/users/?search=${encodeURIComponent(username)}&page_size=1`,
{
headers: { Authorization: `Bearer ${authentikToken}` },
signal: AbortSignal.timeout(5000),
next: { revalidate: 300 },
}
);
if (res.ok) {
const data = await res.json();
const matchedUser = data.results?.[0];
if (matchedUser) {
user.avatar = matchedUser.avatar ?? "";
if (!user.name && matchedUser.name) user.name = matchedUser.name;
}
}
} catch {
// Authentik API unavailable — headers still provide the essentials
}
if (!user) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
}
const dbUser = await getOrCreateUser(req.headers);
return NextResponse.json({ authenticated: true, user, dbUser });
return NextResponse.json({
id: user.id,
email: user.email,
displayName: user.displayName ?? "",
avatarUrl: user.avatarUrl ?? "",
jobTitle: user.jobTitle ?? "",
company: user.company ?? "",
bio: user.bio ?? "",
hasPassword: !!user.hashedPassword,
});
}
export async function PUT(req: NextRequest) {
@ -83,15 +44,18 @@ export async function PUT(req: NextRequest) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { displayName, avatarUrl } = await req.json();
const body = await req.json();
const data: Record<string, string> = {};
if (typeof displayName === "string") data.displayName = displayName;
if (typeof avatarUrl === "string") data.avatarUrl = avatarUrl;
const allowedFields = ["displayName", "avatarUrl", "jobTitle", "company", "bio"];
for (const field of allowedFields) {
if (typeof body[field] === "string") data[field] = body[field];
}
await prisma.user.update({
const user = await prisma.user.update({
where: { id: session.user.id },
data,
select: { id: true, displayName: true, avatarUrl: true, jobTitle: true, company: true, bio: true, email: true },
});
return NextResponse.json({ success: true });
return NextResponse.json({ success: true, user });
}

View file

@ -13,6 +13,7 @@ export type UserProfile = {
company: string;
bio: string;
avatarUrl: string;
hasPassword: boolean;
};
const DEFAULT_PROFILE: UserProfile = {
@ -22,10 +23,9 @@ const DEFAULT_PROFILE: UserProfile = {
company: "",
bio: "",
avatarUrl: "",
hasPassword: false,
};
const STORAGE_KEY = "echo-ocr-user-profile";
type UserProfileContextValue = {
profile: UserProfile;
updateProfile: (updates: Partial<UserProfile>) => Promise<void>;
@ -49,32 +49,37 @@ function getInitials(name: string): string {
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
}
function loadLocalProfile(): Partial<UserProfile> {
if (typeof window === "undefined") return {};
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) return JSON.parse(raw);
} catch {}
return {};
}
function saveLocalProfile(profile: Partial<UserProfile>) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(profile));
} catch {}
}
export function UserProfileProvider({ children }: { children: React.ReactNode }) {
const { data: session, status, update: updateSession } = useSession();
const router = useRouter();
const [localOverrides, setLocalOverrides] = React.useState<Partial<UserProfile>>({});
const [dbProfile, setDbProfile] = React.useState<Partial<UserProfile>>({});
const [mounted, setMounted] = React.useState(false);
React.useEffect(() => {
setMounted(true);
setLocalOverrides(loadLocalProfile());
}, []);
React.useEffect(() => {
if (status === "authenticated") {
fetch("/api/auth/me")
.then((r) => (r.ok ? r.json() : null))
.then((data) => {
if (data) {
setDbProfile({
displayName: data.displayName || "",
email: data.email || "",
avatarUrl: data.avatarUrl || "",
jobTitle: data.jobTitle || "",
company: data.company || "",
bio: data.bio || "",
hasPassword: data.hasPassword ?? false,
});
}
})
.catch(() => {});
}
}, [status]);
const loading = status === "loading";
const isAuthenticated = status === "authenticated";
@ -89,23 +94,19 @@ export function UserProfileProvider({ children }: { children: React.ReactNode })
return {
...base,
...localOverrides,
...(session?.user?.name ? { displayName: session.user.displayName || session.user.name } : {}),
...dbProfile,
...(session?.user?.email ? { email: session.user.email } : {}),
...(session?.user?.avatarUrl ? { avatarUrl: session.user.avatarUrl } : {}),
};
}, [session, localOverrides]);
}, [session, dbProfile]);
const updateProfile = React.useCallback(async (updates: Partial<UserProfile>) => {
setLocalOverrides((prev) => {
const next = { ...prev, ...updates };
saveLocalProfile(next);
return next;
});
setDbProfile((prev) => ({ ...prev, ...updates }));
const apiPayload: Record<string, string> = {};
if (typeof updates.displayName === "string") apiPayload.displayName = updates.displayName;
if (typeof updates.avatarUrl === "string") apiPayload.avatarUrl = updates.avatarUrl;
const persistFields = ["displayName", "avatarUrl", "jobTitle", "company", "bio"] as const;
for (const field of persistFields) {
if (typeof updates[field] === "string") apiPayload[field] = updates[field];
}
if (Object.keys(apiPayload).length > 0) {
try {
@ -157,7 +158,7 @@ export function useUserProfile() {
const ctx = React.useContext(UserProfileContext);
if (!ctx) {
return {
profile: DEFAULT_PROFILE,
profile: { ...DEFAULT_PROFILE },
updateProfile: async () => {},
initials: "",
userId: undefined,