From a83c41694f162682490934495ce651cede5beca5 Mon Sep 17 00:00:00 2001 From: Randall Stillwell Date: Fri, 17 Apr 2026 15:35:24 -0500 Subject: [PATCH] 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 --- prisma/schema.prisma | 3 + src/app/(dashboard)/profile/page.tsx | 155 ++++++++++++++++++++++ src/app/api/auth/change-password/route.ts | 60 +++++++++ src/app/api/auth/me/route.ts | 110 ++++++--------- src/lib/user-profile.tsx | 63 ++++----- 5 files changed, 287 insertions(+), 104 deletions(-) create mode 100644 src/app/api/auth/change-password/route.ts diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 06aa79c..cf8d67c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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()) diff --git a/src/app/(dashboard)/profile/page.tsx b/src/app/(dashboard)/profile/page.tsx index 763d073..20826c8 100644 --- a/src/app/(dashboard)/profile/page.tsx +++ b/src/app/(dashboard)/profile/page.tsx @@ -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() { + + {/* Security — Change Password */} + + + + + Security + + + {profile.hasPassword + ? "Change your account password" + : "Set a password for your account"} + + + +
+ {profile.hasPassword && ( +
+ +
+ + setPwForm((p) => ({ ...p, currentPassword: e.target.value })) + } + placeholder="••••••••" + /> + +
+
+ )} +
+ +
+ + setPwForm((p) => ({ ...p, newPassword: e.target.value })) + } + placeholder="At least 8 characters" + /> + +
+
+
+ + + setPwForm((p) => ({ ...p, confirmPassword: e.target.value })) + } + placeholder="Repeat new password" + /> +
+
+ + {pwForm.newPassword.length > 0 && pwForm.newPassword.length < 8 && ( +

+ Password must be at least 8 characters +

+ )} + {pwForm.confirmPassword.length > 0 && + pwForm.newPassword !== pwForm.confirmPassword && ( +

+ Passwords do not match +

+ )} + + +
+
); } diff --git a/src/app/api/auth/change-password/route.ts b/src/app/api/auth/change-password/route.ts new file mode 100644 index 0000000..79a947a --- /dev/null +++ b/src/app/api/auth/change-password/route.ts @@ -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 }); + } +} diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts index 82683bd..269e6cc 100644 --- a/src/app/api/auth/me/route.ts +++ b/src/app/api/auth/me/route.ts @@ -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 = {}; - 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 }); } diff --git a/src/lib/user-profile.tsx b/src/lib/user-profile.tsx index ba4dc4b..7fca1cb 100644 --- a/src/lib/user-profile.tsx +++ b/src/lib/user-profile.tsx @@ -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) => Promise; @@ -49,32 +49,37 @@ function getInitials(name: string): string { return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase(); } -function loadLocalProfile(): Partial { - if (typeof window === "undefined") return {}; - try { - const raw = localStorage.getItem(STORAGE_KEY); - if (raw) return JSON.parse(raw); - } catch {} - return {}; -} - -function saveLocalProfile(profile: Partial) { - 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>({}); + const [dbProfile, setDbProfile] = React.useState>({}); 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) => { - setLocalOverrides((prev) => { - const next = { ...prev, ...updates }; - saveLocalProfile(next); - return next; - }); + setDbProfile((prev) => ({ ...prev, ...updates })); const apiPayload: Record = {}; - 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,