Compare commits

...

4 Commits

Author SHA1 Message Date
484696a8f5 feat(i18n): make language a user profile attribute (register/profile/login)
All checks were successful
Build and Push / build (push) Successful in 1m47s
2026-05-30 12:49:39 +02:00
ca1a014c01 feat(admin): add search, sorting and pagination to admin tables
All checks were successful
Build and Push / build (push) Successful in 1m43s
2026-05-30 12:24:30 +02:00
d01ab85cbb feat(admin): add search, sorting and pagination to admin tables 2026-05-30 12:23:32 +02:00
610572eafe feat(brand): replace placeholder mark with logo + favicon, fix connect button 2026-05-30 12:23:09 +02:00
20 changed files with 821 additions and 98 deletions

View File

@@ -3,6 +3,7 @@
import { signIn } from "next-auth/react"; import { signIn } from "next-auth/react";
import { useTranslations, useLocale } from "next-intl"; import { useTranslations, useLocale } from "next-intl";
import { Link, getPathname } from "@/i18n/navigation"; import { Link, getPathname } from "@/i18n/navigation";
import { Logo } from "@/components/ui/logo";
export default function LoginPage() { export default function LoginPage() {
const t = useTranslations("login"); const t = useTranslations("login");
@@ -25,10 +26,7 @@ export default function LoginPage() {
<div className="relative z-10 w-full max-w-sm px-5 animate-in"> <div className="relative z-10 w-full max-w-sm px-5 animate-in">
{/* Logo mark */} {/* Logo mark */}
<div className="flex justify-center mb-8"> <div className="flex justify-center mb-8">
<div className="relative h-12 w-12"> <Logo className="h-14 w-auto text-accent" />
<div className="absolute inset-0 rounded-lg bg-accent/15" />
<div className="absolute inset-[5px] rounded-md bg-accent" />
</div>
</div> </div>
<div className="bg-surface-1 rounded-2xl border border-border p-8 shadow-2xl shadow-black/40"> <div className="bg-surface-1 rounded-2xl border border-border p-8 shadow-2xl shadow-black/40">

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { useState, useRef, forwardRef } from "react"; import { useState, useRef, forwardRef } from "react";
import { useTranslations } from "next-intl"; import { useTranslations, useLocale } from "next-intl";
import { useRouter, Link } from "@/i18n/navigation"; import { useRouter, Link } from "@/i18n/navigation";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
@@ -41,11 +41,17 @@ export default function RegisterPage() {
const [accountType, setAccountType] = useState<AccountType | null>(null); const [accountType, setAccountType] = useState<AccountType | null>(null);
const locale = useLocale();
const [form, setForm] = useState({ const [form, setForm] = useState({
companyName: "", companyName: "",
givenName: "", givenName: "",
familyName: "", familyName: "",
email: "", email: "",
// Default to the language the register page is being viewed in;
// the user can change it below. This becomes their ZITADEL
// preferredLanguage and the UI language they land on after login.
preferredLanguage: locale,
}); });
const [state, setState] = useState<FormState>("idle"); const [state, setState] = useState<FormState>("idle");
const [error, setError] = useState(""); const [error, setError] = useState("");
@@ -94,6 +100,7 @@ export default function RegisterPage() {
givenName: form.givenName, givenName: form.givenName,
familyName: form.familyName, familyName: form.familyName,
email: form.email, email: form.email,
preferredLanguage: form.preferredLanguage,
isPersonal, isPersonal,
}; };
if (!isPersonal) { if (!isPersonal) {
@@ -295,6 +302,29 @@ export default function RegisterPage() {
/> />
</div> </div>
{/* Preferred language */}
<div>
<label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-1.5">
{t("languageLabel")}
</label>
<select
name="preferredLanguage"
value={form.preferredLanguage}
onChange={(e) =>
setForm((prev) => ({
...prev,
preferredLanguage: e.target.value,
}))
}
className="w-full px-3 py-2 bg-surface-2 border border-border rounded-lg text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-colors"
>
<option value="de">Deutsch</option>
<option value="en">English</option>
<option value="fr">Français</option>
<option value="it">Italiano</option>
</select>
</div>
{error && ( {error && (
<div className="text-xs text-red-400 bg-red-400/10 border border-red-400/20 rounded-lg px-3 py-2"> <div className="text-xs text-red-400 bg-red-400/10 border border-red-400/20 rounded-lg px-3 py-2">
{error} {error}

View File

@@ -30,13 +30,14 @@ export default async function ProfileSettingsPage() {
const t = await getTranslations("settingsProfile"); const t = await getTranslations("settingsProfile");
let initial = { firstName: "", lastName: "", email: user.email }; let initial = { firstName: "", lastName: "", email: user.email, language: "" };
try { try {
const profile = await getHumanUserDetail(user.id); const profile = await getHumanUserDetail(user.id);
initial = { initial = {
firstName: profile.givenName, firstName: profile.givenName,
lastName: profile.familyName, lastName: profile.familyName,
email: profile.email || user.email, email: profile.email || user.email,
language: profile.preferredLanguage,
}; };
} catch (e) { } catch (e) {
// Identity provider unreachable: render the form with whatever // Identity provider unreachable: render the form with whatever

View File

@@ -26,6 +26,7 @@ import {
const updateSchema = z.object({ const updateSchema = z.object({
firstName: z.string().trim().min(1).max(100), firstName: z.string().trim().min(1).max(100),
lastName: z.string().trim().min(1).max(100), lastName: z.string().trim().min(1).max(100),
language: z.enum(["de", "en", "fr", "it"]).optional(),
}); });
export async function GET() { export async function GET() {
@@ -66,6 +67,7 @@ export async function PUT(request: Request) {
userId: user.id, userId: user.id,
givenName: parsed.data.firstName, givenName: parsed.data.firstName,
familyName: parsed.data.lastName, familyName: parsed.data.lastName,
preferredLanguage: parsed.data.language,
}); });
return NextResponse.json({ return NextResponse.json({
displayName: result.displayName, displayName: result.displayName,

5
src/app/icon.svg Normal file
View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="5.5 3.69 38 38" role="img" aria-label="PieCed">
<rect x="5.5" y="3.69" width="38" height="38" rx="7" fill="#0B0F0E"/>
<polygon points="38.5,22.69 31.5,10.566 17.5,10.566 10.5,22.69 17.5,34.814 31.5,34.814"
fill="#10B981" stroke="#10B981" stroke-width="1.6" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 354 B

View File

@@ -5,6 +5,14 @@ import { useTranslations, useFormatter } from "next-intl";
import type { PiecedTenant, TenantRequest } from "@/types"; import type { PiecedTenant, TenantRequest } from "@/types";
import { StatusBadge } from "@/components/ui/status-badge"; import { StatusBadge } from "@/components/ui/status-badge";
import { Modal } from "@/components/ui/modal"; import { Modal } from "@/components/ui/modal";
import {
applyTableView,
nextSort,
SearchInput,
SortableTh,
Pagination,
type SortState,
} from "@/components/admin/table-controls";
import { formatDateTime, formatRelative } from "@/lib/format"; import { formatDateTime, formatRelative } from "@/lib/format";
import Link from "next/link"; import Link from "next/link";
@@ -53,6 +61,21 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
// Shared // Shared
const [error, setError] = useState(""); const [error, setError] = useState("");
// Client-side table view state (search / sort / page) for each tab.
const [reqSearch, setReqSearch] = useState("");
const [reqSort, setReqSort] = useState<SortState>({
key: "created",
dir: "desc",
});
const [reqPage, setReqPage] = useState(1);
const [tenSearch, setTenSearch] = useState("");
const [tenSort, setTenSort] = useState<SortState>({
key: "created",
dir: "desc",
});
const [tenPage, setTenPage] = useState(1);
// Action-scoped error — shown inside the active confirmation modal so // Action-scoped error — shown inside the active confirmation modal so
// a failed approve/reject/delete surfaces next to the action that // a failed approve/reject/delete surfaces next to the action that
// caused it (and keeps the modal open), rather than as a detached // caused it (and keeps the modal open), rather than as a detached
@@ -246,6 +269,53 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
const pendingCount = requests.filter((r) => r.status === "pending").length; const pendingCount = requests.filter((r) => r.status === "pending").length;
// Derived table views: search → sort → paginate, applied client-side
// on top of the already-fetched lists.
const reqView = applyTableView(requests, {
search: reqSearch,
searchOf: (r) => [
r.companyName,
r.contactName,
r.contactEmail,
r.agentName,
r.tenantName,
],
sort: reqSort,
sortOf: (r, key) =>
key === "company"
? r.companyName || ""
: key === "status"
? r.status || ""
: r.createdAt || "",
page: reqPage,
});
const tenView = applyTableView(tenants, {
search: tenSearch,
searchOf: (tn) => [
tn.metadata.name,
tn.spec.displayName,
tn.spec.agentName,
],
sort: tenSort,
sortOf: (tn, key) =>
key === "name"
? tn.spec.displayName || tn.metadata.name
: key === "phase"
? tn.status?.phase || "Pending"
: tn.metadata.creationTimestamp || "",
page: tenPage,
});
const onReqSort = (key: string) => {
setReqSort((s) => nextSort(s, key));
setReqPage(1);
};
const onTenSort = (key: string) => {
setTenSort((s) => nextSort(s, key));
setTenPage(1);
};
return ( return (
<> <>
{/* Tab bar */} {/* Tab bar */}
@@ -315,20 +385,33 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
{/* ───── REQUESTS TAB ───── */} {/* ───── REQUESTS TAB ───── */}
{tab === "requests" && ( {tab === "requests" && (
<> <>
<div className="flex gap-1.5 mb-4 flex-wrap"> <div className="flex items-center justify-between gap-3 mb-4 flex-wrap">
{FILTERS.map((f) => ( <div className="flex gap-1.5 flex-wrap">
<button {FILTERS.map((f) => (
key={f} <button
onClick={() => setFilter(f)} key={f}
className={`px-3 py-1 text-xs rounded-full transition-colors ${ onClick={() => {
filter === f setFilter(f);
? "bg-accent text-surface-0" setReqPage(1);
: "bg-surface-2 text-text-muted hover:text-text-secondary border border-border" }}
}`} className={`px-3 py-1 text-xs rounded-full transition-colors ${
> filter === f
{t(`filter_${f}`)} ? "bg-accent text-surface-0"
</button> : "bg-surface-2 text-text-muted hover:text-text-secondary border border-border"
))} }`}
>
{t(`filter_${f}`)}
</button>
))}
</div>
<SearchInput
value={reqSearch}
onChange={(v) => {
setReqSearch(v);
setReqPage(1);
}}
placeholder={t("searchRequestsPlaceholder")}
/>
</div> </div>
{loadingRequests ? ( {loadingRequests ? (
@@ -340,15 +423,22 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
<div className="bg-surface-1 border border-border rounded-xl p-12 text-center"> <div className="bg-surface-1 border border-border rounded-xl p-12 text-center">
<p className="text-text-secondary text-sm">{t("noRequests")}</p> <p className="text-text-secondary text-sm">{t("noRequests")}</p>
</div> </div>
) : reqView.total === 0 ? (
<div className="bg-surface-1 border border-border rounded-xl p-12 text-center">
<p className="text-text-secondary text-sm">{t("noMatches")}</p>
</div>
) : ( ) : (
<div className="bg-surface-1 border border-border rounded-xl overflow-hidden"> <div className="bg-surface-1 border border-border rounded-xl overflow-hidden">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b border-border text-left"> <tr className="border-b border-border text-left">
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted"> <SortableTh
{t("company")} label={t("company")}
</th> sortKey="company"
sort={reqSort}
onSort={onReqSort}
/>
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted"> <th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted">
{t("contact")} {t("contact")}
</th> </th>
@@ -358,19 +448,26 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted hidden lg:table-cell"> <th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted hidden lg:table-cell">
{t("packages")} {t("packages")}
</th> </th>
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted"> <SortableTh
{t("status")} label={t("status")}
</th> sortKey="status"
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted hidden md:table-cell"> sort={reqSort}
{t("submitted")} onSort={onReqSort}
</th> />
<SortableTh
label={t("submitted")}
sortKey="created"
sort={reqSort}
onSort={onReqSort}
className="hidden md:table-cell"
/>
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted"> <th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted">
{t("actions")} {t("actions")}
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{requests.map((req) => ( {reqView.paged.map((req) => (
<tr <tr
key={req.id} key={req.id}
className="border-b border-border last:border-0 hover:bg-surface-2/50 transition-colors" className="border-b border-border last:border-0 hover:bg-surface-2/50 transition-colors"
@@ -506,6 +603,12 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
</tbody> </tbody>
</table> </table>
</div> </div>
<Pagination
page={reqView.page}
totalPages={reqView.totalPages}
total={reqView.total}
onPage={setReqPage}
/>
</div> </div>
)} )}
</> </>
@@ -543,6 +646,17 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
/> />
</div> </div>
<div className="flex justify-end mb-4">
<SearchInput
value={tenSearch}
onChange={(v) => {
setTenSearch(v);
setTenPage(1);
}}
placeholder={t("searchTenantsPlaceholder")}
/>
</div>
{loadingTenants ? ( {loadingTenants ? (
<div className="bg-surface-1 border border-border rounded-xl p-12 text-center"> <div className="bg-surface-1 border border-border rounded-xl p-12 text-center">
<div className="h-5 w-5 border-2 border-accent border-t-transparent rounded-full animate-spin mx-auto mb-2" /> <div className="h-5 w-5 border-2 border-accent border-t-transparent rounded-full animate-spin mx-auto mb-2" />
@@ -552,37 +666,51 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
<div className="bg-surface-1 border border-border rounded-xl p-12 text-center"> <div className="bg-surface-1 border border-border rounded-xl p-12 text-center">
<p className="text-text-secondary text-sm">{t("noTenants")}</p> <p className="text-text-secondary text-sm">{t("noTenants")}</p>
</div> </div>
) : tenView.total === 0 ? (
<div className="bg-surface-1 border border-border rounded-xl p-12 text-center">
<p className="text-text-secondary text-sm">{t("noMatches")}</p>
</div>
) : ( ) : (
<div className="bg-surface-1 border border-border rounded-xl overflow-hidden"> <div className="bg-surface-1 border border-border rounded-xl overflow-hidden">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b border-border text-left"> <tr className="border-b border-border text-left">
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted"> <SortableTh
{t("name")} label={t("name")}
</th> sortKey="name"
sort={tenSort}
onSort={onTenSort}
/>
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted"> <th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted">
{t("displayName")} {t("displayName")}
</th> </th>
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted"> <SortableTh
{t("phase")} label={t("phase")}
</th> sortKey="phase"
sort={tenSort}
onSort={onTenSort}
/>
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted hidden md:table-cell"> <th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted hidden md:table-cell">
{t("packages")} {t("packages")}
</th> </th>
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted hidden md:table-cell"> <th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted hidden md:table-cell">
{t("spendChf")} {t("spendChf")}
</th> </th>
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted hidden md:table-cell"> <SortableTh
{t("created")} label={t("created")}
</th> sortKey="created"
sort={tenSort}
onSort={onTenSort}
className="hidden md:table-cell"
/>
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted"> <th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted">
{t("actions")} {t("actions")}
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{tenants.map((tenant) => { {tenView.paged.map((tenant) => {
const tenantSpend = const tenantSpend =
health?.spend?.perTenant?.[tenant.metadata.name]; health?.spend?.perTenant?.[tenant.metadata.name];
return ( return (
@@ -680,6 +808,12 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
</tbody> </tbody>
</table> </table>
</div> </div>
<Pagination
page={tenView.page}
totalPages={tenView.totalPages}
total={tenView.total}
onPage={setTenPage}
/>
</div> </div>
)} )}
</> </>

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
@@ -104,25 +104,14 @@ export function NewInvoiceForm({ orgs }: Props) {
<label className="text-xs uppercase tracking-wider text-text-muted"> <label className="text-xs uppercase tracking-wider text-text-muted">
{t("newInvoiceOrgLabel")} {t("newInvoiceOrgLabel")}
</label> </label>
<select <OrgCombobox
orgs={orgs}
value={orgId} value={orgId}
onChange={(e) => onOrgChange(e.target.value)} onChange={onOrgChange}
className="px-3 py-2 rounded-md border border-border bg-surface-2 text-sm" placeholder={t("newInvoiceOrgPlaceholder")}
> noBillingLabel={t("newInvoiceOrgNoBilling")}
<option value="">{t("newInvoiceOrgPlaceholder")}</option> noMatchesLabel={t("newInvoiceOrgNoMatches")}
{orgs.map((o) => ( />
<option
key={o.zitadelOrgId}
value={o.zitadelOrgId}
disabled={!o.hasBillingAddress}
>
{o.companyName ?? o.zitadelOrgId}
{!o.hasBillingAddress
? ` (${t("newInvoiceOrgNoBilling")})`
: ""}
</option>
))}
</select>
{selected && !selected.hasBillingAddress && ( {selected && !selected.hasBillingAddress && (
<p className="text-xs text-error mt-1"> <p className="text-xs text-error mt-1">
{t("newInvoiceOrgBillingMissing")} {t("newInvoiceOrgBillingMissing")}
@@ -164,3 +153,138 @@ export function NewInvoiceForm({ orgs }: Props) {
</Card> </Card>
); );
} }
/**
* Searchable single-select for the billing org. Replaces a plain
* <select> that would become unusable once the customer list grows:
* type to filter by company name or org id, arrow keys to move, Enter
* to pick. Orgs without a billing snapshot stay selectable but are
* flagged — selecting one surfaces the existing "billing missing"
* warning and keeps the submit button disabled.
*/
function OrgCombobox({
orgs,
value,
onChange,
placeholder,
noBillingLabel,
noMatchesLabel,
}: {
orgs: OrgEntry[];
value: string;
onChange: (orgId: string) => void;
placeholder: string;
noBillingLabel: string;
noMatchesLabel: string;
}) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [hi, setHi] = useState(0);
const ref = useRef<HTMLDivElement>(null);
const selected = orgs.find((o) => o.zitadelOrgId === value) || null;
const display = selected ? selected.companyName ?? selected.zitadelOrgId : "";
// Close on outside click so the dropdown doesn't linger.
useEffect(() => {
const onDoc = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
}
};
document.addEventListener("mousedown", onDoc);
return () => document.removeEventListener("mousedown", onDoc);
}, []);
const q = query.trim().toLowerCase();
const filtered = q
? orgs.filter(
(o) =>
(o.companyName ?? "").toLowerCase().includes(q) ||
o.zitadelOrgId.toLowerCase().includes(q)
)
: orgs;
const choose = (o: OrgEntry) => {
onChange(o.zitadelOrgId);
setOpen(false);
setQuery("");
};
return (
<div ref={ref} className="relative">
<input
type="text"
value={open ? query : display}
onChange={(e) => {
setQuery(e.target.value);
setOpen(true);
setHi(0);
}}
onFocus={() => {
setOpen(true);
setQuery("");
}}
onKeyDown={(e) => {
if (e.key === "ArrowDown") {
e.preventDefault();
setOpen(true);
setHi((h) => Math.min(h + 1, filtered.length - 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setHi((h) => Math.max(h - 1, 0));
} else if (e.key === "Enter") {
e.preventDefault();
if (open && filtered[hi]) choose(filtered[hi]);
} else if (e.key === "Escape") {
setOpen(false);
}
}}
placeholder={placeholder}
role="combobox"
aria-expanded={open}
aria-autocomplete="list"
className="w-full px-3 py-2 rounded-md border border-border bg-surface-2 text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-colors"
/>
{open && (
<ul
role="listbox"
className="absolute z-20 mt-1 max-h-64 w-full overflow-auto rounded-md border border-border bg-surface-1 shadow-xl py-1"
>
{filtered.length === 0 ? (
<li className="px-3 py-2 text-xs text-text-muted">
{noMatchesLabel}
</li>
) : (
filtered.map((o, i) => (
<li
key={o.zitadelOrgId}
role="option"
aria-selected={o.zitadelOrgId === value}
onMouseEnter={() => setHi(i)}
// mousedown (not click) so selection runs before the
// input's blur closes the list.
onMouseDown={(e) => {
e.preventDefault();
choose(o);
}}
className={`px-3 py-2 text-sm cursor-pointer flex items-center justify-between gap-2 ${
i === hi ? "bg-surface-3" : "hover:bg-surface-2"
}`}
>
<span className="truncate text-text-primary">
{o.companyName ?? o.zitadelOrgId}
</span>
{!o.hasBillingAddress && (
<span className="text-[10px] text-error shrink-0">
{noBillingLabel}
</span>
)}
</li>
))
)}
</ul>
)}
</div>
);
}

View File

@@ -0,0 +1,190 @@
"use client";
import { useTranslations } from "next-intl";
/**
* Shared client-side table controls for the admin panel.
*
* The admin tables (requests, tenants) load their full result set into
* state already, so search/sort/pagination are applied client-side on
* top — no new API surface. At pilot scale the lists are small enough
* that filtering/sorting in memory is free; if they grow past a few
* hundred rows this is the seam to move server-side (the page/sort
* state would become query params).
*/
export const PAGE_SIZE = 15;
export interface SortState {
key: string;
dir: "asc" | "desc";
}
/**
* Filter → sort → paginate a list. Pure function, called during render.
* `searchOf` returns the haystack strings for a row; `sortOf` returns
* the comparable value for the active sort key (string or number).
*/
export function applyTableView<T>(
items: T[],
opts: {
search: string;
searchOf: (item: T) => (string | null | undefined)[];
sort: SortState;
sortOf: (item: T, key: string) => string | number;
page: number;
pageSize?: number;
}
): { paged: T[]; total: number; totalPages: number; page: number } {
const pageSize = opts.pageSize ?? PAGE_SIZE;
const q = opts.search.trim().toLowerCase();
const filtered = q
? items.filter((it) =>
opts
.searchOf(it)
.some((v) => (v ?? "").toString().toLowerCase().includes(q))
)
: items;
const sorted = [...filtered].sort((a, b) => {
const av = opts.sortOf(a, opts.sort.key);
const bv = opts.sortOf(b, opts.sort.key);
const cmp =
typeof av === "number" && typeof bv === "number"
? av - bv
: String(av).localeCompare(String(bv));
return opts.sort.dir === "asc" ? cmp : -cmp;
});
const total = sorted.length;
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const page = Math.min(Math.max(1, opts.page), totalPages);
const paged = sorted.slice((page - 1) * pageSize, page * pageSize);
return { paged, total, totalPages, page };
}
/** Toggle helper: same key flips direction, new key starts ascending. */
export function nextSort(current: SortState, key: string): SortState {
if (current.key === key) {
return { key, dir: current.dir === "asc" ? "desc" : "asc" };
}
return { key, dir: "asc" };
}
export function SearchInput({
value,
onChange,
placeholder,
}: {
value: string;
onChange: (v: string) => void;
placeholder: string;
}) {
return (
<div className="relative">
<svg
className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-text-muted pointer-events-none"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.75}
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21 21l-4.35-4.35M17 11a6 6 0 11-12 0 6 6 0 0112 0z"
/>
</svg>
<input
type="search"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className="w-full sm:w-72 pl-8 pr-3 py-2 bg-surface-2 border border-border rounded-lg text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-colors"
/>
</div>
);
}
export function SortableTh({
label,
sortKey,
sort,
onSort,
className,
}: {
label: string;
sortKey: string;
sort: SortState;
onSort: (key: string) => void;
className?: string;
}) {
const active = sort.key === sortKey;
return (
<th className={`px-4 py-3 ${className ?? ""}`}>
<button
type="button"
onClick={() => onSort(sortKey)}
className={`inline-flex items-center gap-1 text-xs font-semibold uppercase tracking-wider transition-colors cursor-pointer ${
active ? "text-text-secondary" : "text-text-muted hover:text-text-secondary"
}`}
aria-sort={active ? (sort.dir === "asc" ? "ascending" : "descending") : "none"}
>
{label}
<span className="inline-block w-2 text-[9px]" aria-hidden="true">
{active ? (sort.dir === "asc" ? "▲" : "▼") : ""}
</span>
</button>
</th>
);
}
export function Pagination({
page,
totalPages,
total,
onPage,
}: {
page: number;
totalPages: number;
total: number;
onPage: (p: number) => void;
}) {
const t = useTranslations("admin");
if (totalPages <= 1) {
return (
<div className="flex items-center justify-end px-4 py-2.5 border-t border-border text-xs text-text-muted">
<span>{t("paginationCount", { total })}</span>
</div>
);
}
return (
<div className="flex items-center justify-between px-4 py-2.5 border-t border-border text-xs text-text-muted gap-3">
<span className="tabular-nums">{t("paginationCount", { total })}</span>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => onPage(page - 1)}
disabled={page <= 1}
className="px-2.5 py-1 rounded-md border border-border hover:bg-surface-2 disabled:opacity-40 disabled:cursor-not-allowed transition-colors cursor-pointer"
>
{t("paginationPrev")}
</button>
<span className="tabular-nums">
{t("paginationPage", { page, total: totalPages })}
</span>
<button
type="button"
onClick={() => onPage(page + 1)}
disabled={page >= totalPages}
className="px-2.5 py-1 rounded-md border border-border hover:bg-surface-2 disabled:opacity-40 disabled:cursor-not-allowed transition-colors cursor-pointer"
>
{t("paginationNext")}
</button>
</div>
</div>
);
}

View File

@@ -8,6 +8,7 @@ import { Link } from "@/i18n/navigation";
import { SessionProvider } from "next-auth/react"; import { SessionProvider } from "next-auth/react";
import type { Session } from "next-auth"; import type { Session } from "next-auth";
import { LanguageSwitcher } from "@/components/ui/language-switcher"; import { LanguageSwitcher } from "@/components/ui/language-switcher";
import { Logo } from "@/components/ui/logo";
function NavBar() { function NavBar() {
const t = useTranslations("common"); const t = useTranslations("common");
@@ -79,11 +80,8 @@ function NavBar() {
{/* Logo / brand */} {/* Logo / brand */}
<div className="flex items-center gap-6"> <div className="flex items-center gap-6">
<Link href="/dashboard" className="flex items-center gap-2.5 group"> <Link href="/dashboard" className="flex items-center gap-2.5 group">
{/* Geometric mark */} {/* Brand mark */}
<div className="relative h-7 w-7"> <Logo className="h-7 w-auto text-accent group-hover:text-accent-dim transition-colors" />
<div className="absolute inset-0 rounded-md bg-accent/20 group-hover:bg-accent/30 transition-colors" />
<div className="absolute inset-[3px] rounded-sm bg-accent" />
</div>
<span className="font-display text-base font-semibold tracking-tight text-text-primary"> <span className="font-display text-base font-semibold tracking-tight text-text-primary">
{t("appName")} {t("appName")}
</span> </span>

View File

@@ -2,7 +2,7 @@
import { useState } from "react"; import { useState } from "react";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
import { useTranslations } from "next-intl"; import { useTranslations, useLocale } from "next-intl";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
interface Props { interface Props {
@@ -10,6 +10,8 @@ interface Props {
firstName: string; firstName: string;
lastName: string; lastName: string;
email: string; email: string;
/** Current ZITADEL preferredLanguage; "" if never set. */
language: string;
}; };
/** /**
* Personal-account flag. Drives a small hint about how the ZITADEL * Personal-account flag. Drives a small hint about how the ZITADEL
@@ -43,10 +45,15 @@ interface Props {
*/ */
export function ProfileSettingsForm({ initial, isPersonal, orgName }: Props) { export function ProfileSettingsForm({ initial, isPersonal, orgName }: Props) {
const t = useTranslations("settingsProfile"); const t = useTranslations("settingsProfile");
const locale = useLocale();
const { update } = useSession(); const { update } = useSession();
const [form, setForm] = useState({ const [form, setForm] = useState({
firstName: initial.firstName, firstName: initial.firstName,
lastName: initial.lastName, 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 [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -67,6 +74,7 @@ export function ProfileSettingsForm({ initial, isPersonal, orgName }: Props) {
body: JSON.stringify({ body: JSON.stringify({
firstName: form.firstName.trim(), firstName: form.firstName.trim(),
lastName: form.lastName.trim(), lastName: form.lastName.trim(),
language: form.language,
}), }),
}); });
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));
@@ -79,15 +87,15 @@ export function ProfileSettingsForm({ initial, isPersonal, orgName }: Props) {
// to session.user.name. No re-login needed. // to session.user.name. No re-login needed.
await update({ name: data.displayName }); await update({ name: data.displayName });
setSavedFlash(true); setSavedFlash(true);
// Force a full reload so EVERY server-rendered component picks // If the language changed, land the user on the new locale (a
// up the new session cookie immediately — router.refresh() only // full navigation so every server-rendered surface re-renders in
// re-runs the current route's server components, leaving the // the new language). Otherwise just reload so the new name
// nav-shell (rendered higher in the tree) and other cached // propagates. The 800ms delay lets the "Saved" flash show first.
// segments showing the old name until the user navigates. const localeChanged = form.language && form.language !== locale;
// The 800ms delay lets the "Saved" flash render briefly before const target = localeChanged ? localePath(form.language) : null;
// the page reloads, so the user gets visible feedback.
setTimeout(() => { setTimeout(() => {
window.location.reload(); if (target) window.location.assign(target);
else window.location.reload();
}, 800); }, 800);
} catch (e: any) { } catch (e: any) {
setError(e?.message ?? String(e)); setError(e?.message ?? String(e));
@@ -132,6 +140,20 @@ export function ProfileSettingsForm({ initial, isPersonal, orgName }: Props) {
className="w-full px-3 py-2 rounded-md bg-surface-2 border border-border text-sm text-text-muted cursor-not-allowed" 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>
<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 {/* Personal vs company hint. Personals get the
"this won't change your invoice name" warning since their "this won't change your invoice name" warning since their
ZITADEL name and their invoice identity are intentionally ZITADEL name and their invoice identity are intentionally
@@ -163,6 +185,15 @@ export function ProfileSettingsForm({ initial, isPersonal, orgName }: Props) {
); );
} }
// 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({ function Field({
label, label,
required, required,

View File

@@ -139,7 +139,7 @@ export function ConnectPanel({
<button <button
type="button" type="button"
onClick={reopen} onClick={reopen}
className="text-xs font-medium text-accent hover:text-accent-dim transition-colors cursor-pointer" className="shrink-0 inline-flex items-center rounded-md border border-border px-2.5 py-1 text-xs font-medium text-accent hover:bg-surface-2 hover:border-accent/40 transition-colors cursor-pointer"
> >
{t("show")} {t("show")}
</button> </button>
@@ -156,7 +156,7 @@ export function ConnectPanel({
<button <button
type="button" type="button"
onClick={dismiss} onClick={dismiss}
className="shrink-0 inline-flex items-center gap-1 text-xs font-medium text-text-muted hover:text-text-secondary transition-colors cursor-pointer" className="shrink-0 inline-flex items-center gap-1.5 rounded-md border border-accent/40 bg-accent/10 px-2.5 py-1 text-xs font-medium text-accent hover:bg-accent/20 hover:border-accent/60 transition-colors cursor-pointer"
> >
<svg <svg
className="h-3.5 w-3.5" className="h-3.5 w-3.5"

View File

@@ -0,0 +1,83 @@
/**
* PieCed honeycomb mark.
*
* Six flat-top hexagons: H1/H4 solid, H2/H3 outline, H5/H6 partial.
* All strokes/fills use `currentColor` so the mark inherits its colour
* from the surrounding text colour (e.g. `text-accent`) and adapts to
* hover/theme without editing the SVG. Original brand emerald is
* #10B981, which the accent token matches.
*
* viewBox is portrait (70×106); size it by height and let width follow
* (`h-7 w-auto`).
*/
export function Logo({
className,
title = "PieCed IT",
}: {
className?: string;
title?: string;
}) {
return (
<svg
viewBox="0 0 70 106"
className={className}
role="img"
aria-label={title}
fill="none"
>
<title>{title}</title>
{/* H1 — solid, top-left */}
<polygon
points="38.5,22.69 31.5,10.566 17.5,10.566 10.5,22.69 17.5,34.814 31.5,34.814"
fill="currentColor"
stroke="currentColor"
strokeWidth="1.6"
strokeLinejoin="round"
/>
{/* H2 — outline, upper-right */}
<polygon
points="59.5,34.814 52.5,22.69 38.5,22.69 31.5,34.814 38.5,46.938 52.5,46.938"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinejoin="round"
strokeLinecap="round"
/>
{/* H3 — outline, mid-left */}
<polygon
points="38.5,46.938 31.5,34.814 17.5,34.814 10.5,46.938 17.5,59.062 31.5,59.062"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinejoin="round"
strokeLinecap="round"
/>
{/* H4 — solid, mid-right */}
<polygon
points="59.5,59.062 52.5,46.938 38.5,46.938 31.5,59.062 38.5,71.186 52.5,71.186"
fill="currentColor"
stroke="currentColor"
strokeWidth="1.6"
strokeLinejoin="round"
/>
{/* H5 — partial, lower-left */}
<polyline
points="31.5,83.31 38.5,71.186 31.5,59.062 17.5,59.062 10.5,71.186"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinejoin="round"
strokeLinecap="round"
/>
{/* H6 — partial, lower-right */}
<polyline
points="59.5,83.31 52.5,71.186 38.5,71.186 31.5,83.31 38.5,95.434"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinejoin="round"
strokeLinecap="round"
/>
</svg>
);
}

View File

@@ -111,6 +111,13 @@ export const authConfig: NextAuthConfig = {
if (typeof profile.sub === "string") { if (typeof profile.sub === "string") {
token.sub = profile.sub; token.sub = profile.sub;
} }
// Capture the user's preferred language (OIDC `locale` claim,
// mapped from ZITADEL preferredLanguage). Read once at sign-in;
// middleware uses it to land the user on their language a
// single time per login. Stored as-is and validated downstream.
if (typeof claims.locale === "string") {
token.locale = claims.locale;
}
} }
return token; return token;
}, },
@@ -140,6 +147,7 @@ export const authConfig: NextAuthConfig = {
// both legacy " (Personal)" suffix and current "personal-{8hex}" // both legacy " (Personal)" suffix and current "personal-{8hex}"
// opaque names. // opaque names.
isPersonal: isPersonalOrgName(orgName), isPersonal: isPersonalOrgName(orgName),
locale: (token.locale as string | undefined) ?? undefined,
}; };
(session as any).platformUser = sessionUser; (session as any).platformUser = sessionUser;
// Also overwrite session.user so any client-side code that uses // Also overwrite session.user so any client-side code that uses

View File

@@ -569,6 +569,7 @@ export async function updateHumanUserProfile(params: {
userId: string; userId: string;
givenName: string; givenName: string;
familyName: string; familyName: string;
preferredLanguage?: string;
}): Promise<UpdateHumanUserProfileResult> { }): Promise<UpdateHumanUserProfileResult> {
const path = `/v2/users/human/${encodeURIComponent(params.userId)}`; const path = `/v2/users/human/${encodeURIComponent(params.userId)}`;
// Compose the displayName ourselves so ZITADEL stores something // Compose the displayName ourselves so ZITADEL stores something
@@ -579,13 +580,22 @@ export async function updateHumanUserProfile(params: {
type ZitadelUpdateResponse = { type ZitadelUpdateResponse = {
details?: { changeDate?: string }; details?: { changeDate?: string };
}; };
await zitadelFetch<ZitadelUpdateResponse>(path, "PUT", { // preferredLanguage is part of the same `profile` block; include it
profile: { // only when provided so a name-only update doesn't clobber it.
givenName: params.givenName, const profile: {
familyName: params.familyName, givenName: string;
displayName, familyName: string;
}, displayName: string;
}); preferredLanguage?: string;
} = {
givenName: params.givenName,
familyName: params.familyName,
displayName,
};
if (params.preferredLanguage) {
profile.preferredLanguage = params.preferredLanguage;
}
await zitadelFetch<ZitadelUpdateResponse>(path, "PUT", { profile });
// Re-fetch the user to read back the canonical displayName ZITADEL // Re-fetch the user to read back the canonical displayName ZITADEL
// committed. Should match what we sent, but reading from the source // committed. Should match what we sent, but reading from the source
// of truth catches any sanitization ZITADEL might apply. // of truth catches any sanitization ZITADEL might apply.
@@ -607,6 +617,8 @@ export interface HumanUserDetail {
familyName: string; familyName: string;
displayName: string; displayName: string;
email: string; email: string;
/** ZITADEL profile preferredLanguage (e.g. "de"); "" if unset. */
preferredLanguage: string;
} }
export async function getHumanUserDetail( export async function getHumanUserDetail(
@@ -620,6 +632,7 @@ export async function getHumanUserDetail(
givenName?: string; givenName?: string;
familyName?: string; familyName?: string;
displayName?: string; displayName?: string;
preferredLanguage?: string;
}; };
email?: { email?: string }; email?: { email?: string };
}; };
@@ -636,5 +649,6 @@ export async function getHumanUserDetail(
familyName: human?.profile?.familyName ?? "", familyName: human?.profile?.familyName ?? "",
displayName: human?.profile?.displayName ?? "", displayName: human?.profile?.displayName ?? "",
email: human?.email?.email ?? "", email: human?.email?.email ?? "",
preferredLanguage: human?.profile?.preferredLanguage ?? "",
}; };
} }

View File

@@ -48,7 +48,8 @@
"personalCardTitle": "Privat", "personalCardTitle": "Privat",
"personalCardDescription": "Für Sie persönlich.", "personalCardDescription": "Für Sie persönlich.",
"companyCardTitle": "Unternehmen", "companyCardTitle": "Unternehmen",
"companyCardDescription": "Für Ihr Unternehmen oder Team." "companyCardDescription": "Für Ihr Unternehmen oder Team.",
"languageLabel": "Sprache"
}, },
"onboarding": { "onboarding": {
"loading": "Status wird geladen…", "loading": "Status wird geladen…",
@@ -436,7 +437,14 @@
"approveTitle": "Anfrage genehmigen?", "approveTitle": "Anfrage genehmigen?",
"approveWarning": "Dadurch wird die Infrastruktur des Mandanten bereitgestellt, die Einrichtungsgebühr berechnet und der Kunde benachrichtigt. Bitte prüfen Sie die Angaben, bevor Sie fortfahren.", "approveWarning": "Dadurch wird die Infrastruktur des Mandanten bereitgestellt, die Einrichtungsgebühr berechnet und der Kunde benachrichtigt. Bitte prüfen Sie die Angaben, bevor Sie fortfahren.",
"approveReapproveWarning": "Dies genehmigt eine zuvor abgelehnte Anfrage erneut: Die Infrastruktur des Mandanten wird bereitgestellt, die Einrichtungsgebühr berechnet und der Kunde benachrichtigt.", "approveReapproveWarning": "Dies genehmigt eine zuvor abgelehnte Anfrage erneut: Die Infrastruktur des Mandanten wird bereitgestellt, die Einrichtungsgebühr berechnet und der Kunde benachrichtigt.",
"confirmApprove": "Genehmigen & bereitstellen" "confirmApprove": "Genehmigen & bereitstellen",
"searchRequestsPlaceholder": "Anfragen suchen…",
"searchTenantsPlaceholder": "Mandanten suchen…",
"paginationPrev": "Zurück",
"paginationNext": "Weiter",
"paginationPage": "Seite {page} von {total}",
"paginationCount": "{total} gesamt",
"noMatches": "Keine Treffer."
}, },
"channelUsers": { "channelUsers": {
"title": "Autorisierte Benutzer", "title": "Autorisierte Benutzer",
@@ -847,7 +855,8 @@
"orgsPayByInvoiceOn": "ein", "orgsPayByInvoiceOn": "ein",
"orgsPayByInvoiceOff": "aus", "orgsPayByInvoiceOff": "aus",
"orgsAutoChargeOn": "ein", "orgsAutoChargeOn": "ein",
"orgsAutoChargeOff": "aus" "orgsAutoChargeOff": "aus",
"newInvoiceOrgNoMatches": "Keine passenden Kunden."
}, },
"skillCostDialog": { "skillCostDialog": {
"title": "Aktivierungskosten bestätigen", "title": "Aktivierungskosten bestätigen",
@@ -986,7 +995,9 @@
"saveChanges": "Änderungen speichern", "saveChanges": "Änderungen speichern",
"saving": "Speichern…", "saving": "Speichern…",
"saved": "Gespeichert.", "saved": "Gespeichert.",
"missingRequired": "Vor- und Nachname sind erforderlich." "missingRequired": "Vor- und Nachname sind erforderlich.",
"languageLabel": "Sprache",
"languageHint": "Wird nach der Anmeldung als Ihre Oberflächensprache verwendet."
}, },
"errors": { "errors": {
"title": "Etwas ist schiefgelaufen", "title": "Etwas ist schiefgelaufen",

View File

@@ -48,7 +48,8 @@
"personalCardTitle": "Personal", "personalCardTitle": "Personal",
"personalCardDescription": "For yourself.", "personalCardDescription": "For yourself.",
"companyCardTitle": "Company", "companyCardTitle": "Company",
"companyCardDescription": "For your business or team." "companyCardDescription": "For your business or team.",
"languageLabel": "Language"
}, },
"onboarding": { "onboarding": {
"loading": "Loading status…", "loading": "Loading status…",
@@ -436,7 +437,14 @@
"approveTitle": "Approve request?", "approveTitle": "Approve request?",
"approveWarning": "This provisions the tenant's infrastructure, charges the setup fee, and notifies the customer. Check the request details are correct before continuing.", "approveWarning": "This provisions the tenant's infrastructure, charges the setup fee, and notifies the customer. Check the request details are correct before continuing.",
"approveReapproveWarning": "This re-approves a previously rejected request: it provisions the tenant's infrastructure, charges the setup fee, and notifies the customer.", "approveReapproveWarning": "This re-approves a previously rejected request: it provisions the tenant's infrastructure, charges the setup fee, and notifies the customer.",
"confirmApprove": "Approve & provision" "confirmApprove": "Approve & provision",
"searchRequestsPlaceholder": "Search requests…",
"searchTenantsPlaceholder": "Search tenants…",
"paginationPrev": "Previous",
"paginationNext": "Next",
"paginationPage": "Page {page} of {total}",
"paginationCount": "{total} total",
"noMatches": "No matches."
}, },
"channelUsers": { "channelUsers": {
"title": "Authorized Users", "title": "Authorized Users",
@@ -847,7 +855,8 @@
"orgsPayByInvoiceOn": "on", "orgsPayByInvoiceOn": "on",
"orgsPayByInvoiceOff": "off", "orgsPayByInvoiceOff": "off",
"orgsAutoChargeOn": "on", "orgsAutoChargeOn": "on",
"orgsAutoChargeOff": "off" "orgsAutoChargeOff": "off",
"newInvoiceOrgNoMatches": "No matching customers."
}, },
"skillCostDialog": { "skillCostDialog": {
"title": "Confirm activation cost", "title": "Confirm activation cost",
@@ -986,7 +995,9 @@
"saveChanges": "Save changes", "saveChanges": "Save changes",
"saving": "Saving…", "saving": "Saving…",
"saved": "Saved.", "saved": "Saved.",
"missingRequired": "First and last name are required." "missingRequired": "First and last name are required.",
"languageLabel": "Language",
"languageHint": "Used as your interface language after you sign in."
}, },
"errors": { "errors": {
"title": "Something went wrong", "title": "Something went wrong",

View File

@@ -48,7 +48,8 @@
"personalCardTitle": "Particulier", "personalCardTitle": "Particulier",
"personalCardDescription": "Pour vous.", "personalCardDescription": "Pour vous.",
"companyCardTitle": "Entreprise", "companyCardTitle": "Entreprise",
"companyCardDescription": "Pour votre entreprise ou équipe." "companyCardDescription": "Pour votre entreprise ou équipe.",
"languageLabel": "Langue"
}, },
"onboarding": { "onboarding": {
"loading": "Chargement du statut…", "loading": "Chargement du statut…",
@@ -436,7 +437,14 @@
"approveTitle": "Approuver la demande ?", "approveTitle": "Approuver la demande ?",
"approveWarning": "Cela provisionne l'infrastructure du locataire, facture les frais d'installation et notifie le client. Vérifiez l'exactitude des détails de la demande avant de continuer.", "approveWarning": "Cela provisionne l'infrastructure du locataire, facture les frais d'installation et notifie le client. Vérifiez l'exactitude des détails de la demande avant de continuer.",
"approveReapproveWarning": "Ceci réapprouve une demande précédemment rejetée : l'infrastructure du locataire est provisionnée, les frais d'installation sont facturés et le client est notifié.", "approveReapproveWarning": "Ceci réapprouve une demande précédemment rejetée : l'infrastructure du locataire est provisionnée, les frais d'installation sont facturés et le client est notifié.",
"confirmApprove": "Approuver et provisionner" "confirmApprove": "Approuver et provisionner",
"searchRequestsPlaceholder": "Rechercher des demandes…",
"searchTenantsPlaceholder": "Rechercher des locataires…",
"paginationPrev": "Précédent",
"paginationNext": "Suivant",
"paginationPage": "Page {page} sur {total}",
"paginationCount": "{total} au total",
"noMatches": "Aucun résultat."
}, },
"channelUsers": { "channelUsers": {
"title": "Utilisateurs autorisés", "title": "Utilisateurs autorisés",
@@ -847,7 +855,8 @@
"orgsPayByInvoiceOn": "actif", "orgsPayByInvoiceOn": "actif",
"orgsPayByInvoiceOff": "inactif", "orgsPayByInvoiceOff": "inactif",
"orgsAutoChargeOn": "actif", "orgsAutoChargeOn": "actif",
"orgsAutoChargeOff": "inactif" "orgsAutoChargeOff": "inactif",
"newInvoiceOrgNoMatches": "Aucun client correspondant."
}, },
"skillCostDialog": { "skillCostDialog": {
"title": "Confirmer le coût d'activation", "title": "Confirmer le coût d'activation",
@@ -986,7 +995,9 @@
"saveChanges": "Enregistrer les modifications", "saveChanges": "Enregistrer les modifications",
"saving": "Enregistrement…", "saving": "Enregistrement…",
"saved": "Enregistré.", "saved": "Enregistré.",
"missingRequired": "Le prénom et le nom sont obligatoires." "missingRequired": "Le prénom et le nom sont obligatoires.",
"languageLabel": "Langue",
"languageHint": "Utilisée comme langue d'interface après votre connexion."
}, },
"errors": { "errors": {
"title": "Une erreur est survenue", "title": "Une erreur est survenue",

View File

@@ -48,7 +48,8 @@
"personalCardTitle": "Privato", "personalCardTitle": "Privato",
"personalCardDescription": "Per lei.", "personalCardDescription": "Per lei.",
"companyCardTitle": "Azienda", "companyCardTitle": "Azienda",
"companyCardDescription": "Per la sua azienda o team." "companyCardDescription": "Per la sua azienda o team.",
"languageLabel": "Lingua"
}, },
"onboarding": { "onboarding": {
"loading": "Caricamento stato…", "loading": "Caricamento stato…",
@@ -436,7 +437,14 @@
"approveTitle": "Approvare la richiesta?", "approveTitle": "Approvare la richiesta?",
"approveWarning": "Questa operazione effettua il provisioning dell'infrastruttura del tenant, addebita il costo di attivazione e notifica il cliente. Verifica che i dettagli della richiesta siano corretti prima di continuare.", "approveWarning": "Questa operazione effettua il provisioning dell'infrastruttura del tenant, addebita il costo di attivazione e notifica il cliente. Verifica che i dettagli della richiesta siano corretti prima di continuare.",
"approveReapproveWarning": "Questo riapprova una richiesta precedentemente rifiutata: effettua il provisioning dell'infrastruttura del tenant, addebita il costo di attivazione e notifica il cliente.", "approveReapproveWarning": "Questo riapprova una richiesta precedentemente rifiutata: effettua il provisioning dell'infrastruttura del tenant, addebita il costo di attivazione e notifica il cliente.",
"confirmApprove": "Approva e avvia provisioning" "confirmApprove": "Approva e avvia provisioning",
"searchRequestsPlaceholder": "Cerca richieste…",
"searchTenantsPlaceholder": "Cerca tenant…",
"paginationPrev": "Precedente",
"paginationNext": "Successivo",
"paginationPage": "Pagina {page} di {total}",
"paginationCount": "{total} totali",
"noMatches": "Nessun risultato."
}, },
"channelUsers": { "channelUsers": {
"title": "Utenti autorizzati", "title": "Utenti autorizzati",
@@ -847,7 +855,8 @@
"orgsPayByInvoiceOn": "attivo", "orgsPayByInvoiceOn": "attivo",
"orgsPayByInvoiceOff": "disattivo", "orgsPayByInvoiceOff": "disattivo",
"orgsAutoChargeOn": "attivo", "orgsAutoChargeOn": "attivo",
"orgsAutoChargeOff": "disattivo" "orgsAutoChargeOff": "disattivo",
"newInvoiceOrgNoMatches": "Nessun cliente corrispondente."
}, },
"skillCostDialog": { "skillCostDialog": {
"title": "Confermi costi di attivazione", "title": "Confermi costi di attivazione",
@@ -986,7 +995,9 @@
"saveChanges": "Salvi modifiche", "saveChanges": "Salvi modifiche",
"saving": "Salvataggio…", "saving": "Salvataggio…",
"saved": "Salvato.", "saved": "Salvato.",
"missingRequired": "Nome e cognome sono obbligatori." "missingRequired": "Nome e cognome sono obbligatori.",
"languageLabel": "Lingua",
"languageHint": "Usata come lingua dell'interfaccia dopo l'accesso."
}, },
"errors": { "errors": {
"title": "Si è verificato un errore", "title": "Si è verificato un errore",

View File

@@ -6,6 +6,20 @@ import { routing } from "@/i18n/routing";
const intlMiddleware = createIntlMiddleware(routing); const intlMiddleware = createIntlMiddleware(routing);
// One-time marker: set after we've applied the user's profile language
// once following sign-in, cleared whenever the login page is shown (so
// the next sign-in re-applies it). Keeps the header switcher a
// per-session override rather than forcing the profile locale on every
// navigation.
const LOCALE_INIT_COOKIE = "pieced_locale_init";
const LOCALE_INIT_OPTS = {
path: "/",
httpOnly: true,
sameSite: "lax" as const,
maxAge: 8 * 60 * 60,
};
const publicPaths = ["/login", "/register", "/api/auth", "/api/register"]; const publicPaths = ["/login", "/register", "/api/auth", "/api/register"];
function isPublicPath(pathname: string): boolean { function isPublicPath(pathname: string): boolean {
@@ -26,6 +40,17 @@ export default async function middleware(request: NextRequest) {
return NextResponse.next(); return NextResponse.next();
} }
const stripped = pathname.replace(/^\/(de|fr|it|en)(?=\/|$)/, "") || "/";
// Showing the login page resets the one-time locale marker so the
// next sign-in re-applies the user's profile language. Logout
// redirects here, which makes this the natural reset point.
if (stripped === "/login") {
const res = intlMiddleware(request);
res.cookies.delete(LOCALE_INIT_COOKIE);
return res;
}
// Auth guard for protected paths // Auth guard for protected paths
if (!isPublicPath(pathname)) { if (!isPublicPath(pathname)) {
const session = await auth(); const session = await auth();
@@ -34,6 +59,32 @@ export default async function middleware(request: NextRequest) {
loginUrl.searchParams.set("callbackUrl", pathname); loginUrl.searchParams.set("callbackUrl", pathname);
return NextResponse.redirect(loginUrl); return NextResponse.redirect(loginUrl);
} }
// One-time apply of the user's preferred language after sign-in.
// Gated by LOCALE_INIT_COOKIE (cleared on the /login view), so it
// fires at most once per login; afterwards the URL and the header
// switcher control the locale freely.
const applied = request.cookies.get(LOCALE_INIT_COOKIE)?.value === "1";
const pref = (session as { platformUser?: { locale?: string } })
.platformUser?.locale;
const base = pref?.split("-")[0];
if (!applied && base && routing.locales.includes(base as never)) {
const target =
base === routing.defaultLocale
? stripped
: `/${base}${stripped === "/" ? "" : stripped}`;
if (target !== pathname) {
const url = new URL(target, request.url);
url.search = request.nextUrl.search;
const res = NextResponse.redirect(url);
res.cookies.set(LOCALE_INIT_COOKIE, "1", LOCALE_INIT_OPTS);
return res;
}
// Already on the right locale — mark applied and continue.
const res = intlMiddleware(request);
res.cookies.set(LOCALE_INIT_COOKIE, "1", LOCALE_INIT_OPTS);
return res;
}
} }
return intlMiddleware(request); return intlMiddleware(request);

View File

@@ -3,6 +3,8 @@ export interface ZitadelClaims {
"urn:zitadel:iam:user:resourceowner:id": string; "urn:zitadel:iam:user:resourceowner:id": string;
"urn:zitadel:iam:user:resourceowner:name": string; "urn:zitadel:iam:user:resourceowner:name": string;
"urn:zitadel:iam:org:project:roles"?: Record<string, Record<string, string>>; "urn:zitadel:iam:org:project:roles"?: Record<string, Record<string, string>>;
/** Standard OIDC claim; ZITADEL maps the user's preferredLanguage. */
locale?: string;
} }
/** /**
@@ -64,6 +66,14 @@ export interface SessionUser {
* user's display name instead (Bug 9 — the org name is opaque). * user's display name instead (Bug 9 — the org name is opaque).
*/ */
isPersonal: boolean; isPersonal: boolean;
/**
* The user's preferred UI language, sourced from the ZITADEL profile
* (`preferredLanguage`) via the OIDC `locale` claim at sign-in. Used
* once after login to land the user on their language; the header
* switcher is a per-session URL override that does not change this.
* Undefined for users whose ZITADEL profile predates the claim.
*/
locale?: string;
} }
// PiecedTenant CR (pieced.ch/v1alpha1) // PiecedTenant CR (pieced.ch/v1alpha1)