219 lines
7.9 KiB
TypeScript
219 lines
7.9 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import { useSession } from "next-auth/react";
|
|
import { useTranslations, useLocale } from "next-intl";
|
|
import { Card } from "@/components/ui/card";
|
|
|
|
interface Props {
|
|
initial: {
|
|
firstName: string;
|
|
lastName: string;
|
|
email: string;
|
|
/** Current ZITADEL preferredLanguage; "" if never set. */
|
|
language: 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 locale = useLocale();
|
|
const { update } = useSession();
|
|
const [form, setForm] = useState({
|
|
firstName: initial.firstName,
|
|
lastName: initial.lastName,
|
|
// Fall back to the current UI locale when the profile has no stored
|
|
// preference yet (older accounts), so the selector shows something
|
|
// sensible rather than blank.
|
|
language: initial.language || locale,
|
|
});
|
|
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(),
|
|
language: form.language,
|
|
}),
|
|
});
|
|
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);
|
|
// If the language changed, land the user on the new locale (a
|
|
// full navigation so every server-rendered surface re-renders in
|
|
// the new language). Otherwise just reload so the new name
|
|
// propagates. The 800ms delay lets the "Saved" flash show first.
|
|
const localeChanged = form.language && form.language !== locale;
|
|
const target = localeChanged ? localePath(form.language) : null;
|
|
setTimeout(() => {
|
|
if (target) window.location.assign(target);
|
|
else 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>
|
|
<Field label={t("languageLabel")} hint={t("languageHint")}>
|
|
<select
|
|
value={form.language}
|
|
onChange={(e) =>
|
|
setForm((f) => ({ ...f, language: e.target.value }))
|
|
}
|
|
className="w-full px-3 py-2 rounded-md bg-surface-2 border border-border focus:border-accent focus:outline-none text-sm"
|
|
>
|
|
<option value="de">Deutsch</option>
|
|
<option value="en">English</option>
|
|
<option value="fr">Français</option>
|
|
<option value="it">Italiano</option>
|
|
</select>
|
|
</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-surface-0 text-sm font-medium hover:bg-accent-dim transition-colors disabled:opacity-50 cursor-pointer"
|
|
>
|
|
{busy ? t("saving") : t("saveChanges")}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
// Build the as-needed-prefixed path for a target locale from the
|
|
// current URL (default locale `de` is unprefixed). Client-only — uses
|
|
// window; called from the save handler.
|
|
function localePath(lang: string): string {
|
|
const p =
|
|
window.location.pathname.replace(/^\/(de|fr|it|en)(?=\/|$)/, "") || "/";
|
|
return lang === "de" ? p : `/${lang}${p === "/" ? "" : p}`;
|
|
}
|
|
|
|
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>
|
|
);
|
|
}
|