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:
parent
028840a887
commit
a83c41694f
5 changed files with 287 additions and 104 deletions
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
60
src/app/api/auth/change-password/route.ts
Normal file
60
src/app/api/auth/change-password/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue