188 lines
6.5 KiB
TypeScript
188 lines
6.5 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import { useSession } from "next-auth/react";
|
|
import { useTranslations } from "next-intl";
|
|
import { Card } from "@/components/ui/card";
|
|
|
|
interface Props {
|
|
initial: {
|
|
firstName: string;
|
|
lastName: string;
|
|
email: string;
|
|
};
|
|
/**
|
|
* Personal-account flag. Drives a small hint about how the ZITADEL
|
|
* name relates (or doesn't) to invoice identity — see the page
|
|
* server component for the long explanation.
|
|
*/
|
|
isPersonal: boolean;
|
|
/**
|
|
* For company accounts: the display org name. Shown in a small
|
|
* read-only "Member of <org>" hint so the user understands which
|
|
* identity they're editing. Ignored for personals (orgName is an
|
|
* opaque "personal-XXXX" string in that case).
|
|
*/
|
|
orgName: string;
|
|
}
|
|
|
|
/**
|
|
* Edits first/last name in ZITADEL via PUT /api/settings/profile.
|
|
* Email is shown read-only — changing email requires verification
|
|
* flow that ZITADEL's own self-service UI handles.
|
|
*
|
|
* On save, we trigger NextAuth's `update()` from useSession() with
|
|
* the new display name. That routes through our jwt callback
|
|
* (trigger='update' branch) which overlays token.name without a
|
|
* logout/login. After the cookie is updated we trigger a full page
|
|
* reload — every server-rendered surface (nav-shell, dashboard
|
|
* welcome, instance cards) re-reads the cookie on the next request
|
|
* and renders with the new name. router.refresh() alone wasn't
|
|
* enough: it re-runs only the current route's server components,
|
|
* leaving outer-tree segments stale until the user navigates.
|
|
*/
|
|
export function ProfileSettingsForm({ initial, isPersonal, orgName }: Props) {
|
|
const t = useTranslations("settingsProfile");
|
|
const { update } = useSession();
|
|
const [form, setForm] = useState({
|
|
firstName: initial.firstName,
|
|
lastName: initial.lastName,
|
|
});
|
|
const [busy, setBusy] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [savedFlash, setSavedFlash] = useState(false);
|
|
|
|
const submit = async () => {
|
|
setError(null);
|
|
setSavedFlash(false);
|
|
if (!form.firstName.trim() || !form.lastName.trim()) {
|
|
setError(t("missingRequired"));
|
|
return;
|
|
}
|
|
setBusy(true);
|
|
try {
|
|
const res = await fetch("/api/settings/profile", {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
firstName: form.firstName.trim(),
|
|
lastName: form.lastName.trim(),
|
|
}),
|
|
});
|
|
const data = await res.json().catch(() => ({}));
|
|
if (!res.ok) {
|
|
throw new Error(data.error ?? `HTTP ${res.status}`);
|
|
}
|
|
// Phase 6 fix5: push the new display name into the session
|
|
// token. The jwt callback handles trigger='update' and overlays
|
|
// token.name; the next session callback maps token.name back
|
|
// to session.user.name. No re-login needed.
|
|
await update({ name: data.displayName });
|
|
setSavedFlash(true);
|
|
// Force a full reload so EVERY server-rendered component picks
|
|
// up the new session cookie immediately — router.refresh() only
|
|
// re-runs the current route's server components, leaving the
|
|
// nav-shell (rendered higher in the tree) and other cached
|
|
// segments showing the old name until the user navigates.
|
|
// The 800ms delay lets the "Saved" flash render briefly before
|
|
// the page reloads, so the user gets visible feedback.
|
|
setTimeout(() => {
|
|
window.location.reload();
|
|
}, 800);
|
|
} catch (e: any) {
|
|
setError(e?.message ?? String(e));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Card>
|
|
<div className="space-y-4">
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<Field label={t("firstNameLabel")} required>
|
|
<input
|
|
type="text"
|
|
value={form.firstName}
|
|
onChange={(e) =>
|
|
setForm((f) => ({ ...f, firstName: e.target.value }))
|
|
}
|
|
maxLength={100}
|
|
className="w-full px-3 py-2 rounded-md bg-surface-2 border border-border focus:border-accent focus:outline-none text-sm"
|
|
/>
|
|
</Field>
|
|
<Field label={t("lastNameLabel")} required>
|
|
<input
|
|
type="text"
|
|
value={form.lastName}
|
|
onChange={(e) =>
|
|
setForm((f) => ({ ...f, lastName: e.target.value }))
|
|
}
|
|
maxLength={100}
|
|
className="w-full px-3 py-2 rounded-md bg-surface-2 border border-border focus:border-accent focus:outline-none text-sm"
|
|
/>
|
|
</Field>
|
|
</div>
|
|
<Field label={t("emailLabel")} hint={t("emailReadOnlyHint")}>
|
|
<input
|
|
type="email"
|
|
value={initial.email}
|
|
readOnly
|
|
disabled
|
|
className="w-full px-3 py-2 rounded-md bg-surface-2 border border-border text-sm text-text-muted cursor-not-allowed"
|
|
/>
|
|
</Field>
|
|
{/* Personal vs company hint. Personals get the
|
|
"this won't change your invoice name" warning since their
|
|
ZITADEL name and their invoice identity are intentionally
|
|
decoupled. Company accounts get a benign "member of"
|
|
context line so they know which org's identity they're
|
|
editing. */}
|
|
{isPersonal ? (
|
|
<p className="text-xs text-text-muted italic">
|
|
{t("personalAccountHint")}
|
|
</p>
|
|
) : (
|
|
<p className="text-xs text-text-muted italic">
|
|
{t("companyAccountHint", { orgName })}
|
|
</p>
|
|
)}
|
|
{error && <p className="text-sm text-error">{error}</p>}
|
|
{savedFlash && <p className="text-sm text-success">{t("saved")}</p>}
|
|
<div className="flex justify-end">
|
|
<button
|
|
onClick={submit}
|
|
disabled={busy}
|
|
className="px-4 py-2 rounded-md bg-accent text-white text-sm font-medium hover:bg-accent-dim transition-colors disabled:opacity-50 cursor-pointer"
|
|
>
|
|
{busy ? t("saving") : t("saveChanges")}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
function Field({
|
|
label,
|
|
required,
|
|
hint,
|
|
children,
|
|
}: {
|
|
label: string;
|
|
required?: boolean;
|
|
hint?: string;
|
|
children: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<div>
|
|
<label className="block text-xs uppercase tracking-wider text-text-muted mb-1">
|
|
{label}
|
|
{required && <span className="text-error ml-1">*</span>}
|
|
</label>
|
|
{children}
|
|
{hint && <p className="text-xs text-text-muted mt-1 italic">{hint}</p>}
|
|
</div>
|
|
);
|
|
}
|