Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 484696a8f5 | |||
| ca1a014c01 | |||
| d01ab85cbb | |||
| 610572eafe | |||
| 73f1af185f |
@@ -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">
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -225,6 +225,7 @@ export default async function TenantDetailPage({
|
|||||||
unreachable). */}
|
unreachable). */}
|
||||||
<section className="mb-8 animate-in animate-in-delay-1">
|
<section className="mb-8 animate-in animate-in-delay-1">
|
||||||
<ConnectPanel
|
<ConnectPanel
|
||||||
|
tenantName={name}
|
||||||
enabledChannels={enabledChannels}
|
enabledChannels={enabledChannels}
|
||||||
phase={tenant.status?.phase ?? "Pending"}
|
phase={tenant.status?.phase ?? "Pending"}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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
5
src/app/icon.svg
Normal 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 |
@@ -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,11 +385,15 @@ 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">
|
||||||
|
<div className="flex gap-1.5 flex-wrap">
|
||||||
{FILTERS.map((f) => (
|
{FILTERS.map((f) => (
|
||||||
<button
|
<button
|
||||||
key={f}
|
key={f}
|
||||||
onClick={() => setFilter(f)}
|
onClick={() => {
|
||||||
|
setFilter(f);
|
||||||
|
setReqPage(1);
|
||||||
|
}}
|
||||||
className={`px-3 py-1 text-xs rounded-full transition-colors ${
|
className={`px-3 py-1 text-xs rounded-full transition-colors ${
|
||||||
filter === f
|
filter === f
|
||||||
? "bg-accent text-surface-0"
|
? "bg-accent text-surface-0"
|
||||||
@@ -330,6 +404,15 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
<SearchInput
|
||||||
|
value={reqSearch}
|
||||||
|
onChange={(v) => {
|
||||||
|
setReqSearch(v);
|
||||||
|
setReqPage(1);
|
||||||
|
}}
|
||||||
|
placeholder={t("searchRequestsPlaceholder")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{loadingRequests ? (
|
{loadingRequests ? (
|
||||||
<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">
|
||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
190
src/components/admin/table-controls.tsx
Normal file
190
src/components/admin/table-controls.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { THREEMA_GATEWAY } from "@/lib/threema-gateway-config";
|
import { THREEMA_GATEWAY } from "@/lib/threema-gateway-config";
|
||||||
|
|
||||||
@@ -16,11 +17,16 @@ import { THREEMA_GATEWAY } from "@/lib/threema-gateway-config";
|
|||||||
* NO channel is enabled it says so explicitly (a running assistant with
|
* NO channel is enabled it says so explicitly (a running assistant with
|
||||||
* no channel is unreachable).
|
* no channel is unreachable).
|
||||||
*
|
*
|
||||||
|
* Once a customer has connected they don't need the steps every visit,
|
||||||
|
* so the panel is dismissible: clicking "I've connected" collapses it
|
||||||
|
* to a slim row and remembers that per-tenant (localStorage). The slim
|
||||||
|
* row keeps a "Show connection details" toggle so it's never lost.
|
||||||
|
* The no-channel warning is NOT dismissible — it's an actionable alert,
|
||||||
|
* not reference material.
|
||||||
|
*
|
||||||
* It is intentionally complementary to ChannelUsers below it:
|
* It is intentionally complementary to ChannelUsers below it:
|
||||||
* - ConnectPanel → "how do *I* reach the assistant"
|
* - ConnectPanel → "how do *I* reach the assistant"
|
||||||
* - ChannelUsers → "*who* is allowed to reach it"
|
* - ChannelUsers → "*who* is allowed to reach it"
|
||||||
* The Threema/Telegram/Discord steps reference the authorised-users
|
|
||||||
* list rather than duplicating it.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// Render order is fixed (not the order packages happen to appear in
|
// Render order is fixed (not the order packages happen to appear in
|
||||||
@@ -40,10 +46,15 @@ const CHANNEL_STEPS_KEY: Record<string, string> = {
|
|||||||
discord: "discordSteps",
|
discord: "discordSteps",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const dismissKey = (tenantName: string) =>
|
||||||
|
`pieced:connect-hidden:${tenantName}`;
|
||||||
|
|
||||||
export function ConnectPanel({
|
export function ConnectPanel({
|
||||||
|
tenantName,
|
||||||
enabledChannels,
|
enabledChannels,
|
||||||
phase,
|
phase,
|
||||||
}: {
|
}: {
|
||||||
|
tenantName: string;
|
||||||
enabledChannels: string[];
|
enabledChannels: string[];
|
||||||
/** Tenant phase — connection details only "work" once it's Ready. */
|
/** Tenant phase — connection details only "work" once it's Ready. */
|
||||||
phase: string;
|
phase: string;
|
||||||
@@ -53,7 +64,41 @@ export function ConnectPanel({
|
|||||||
const channels = CHANNEL_ORDER.filter((c) => enabledChannels.includes(c));
|
const channels = CHANNEL_ORDER.filter((c) => enabledChannels.includes(c));
|
||||||
const ready = phase === "Ready" || phase === "Running" || phase === "Active";
|
const ready = phase === "Ready" || phase === "Running" || phase === "Active";
|
||||||
|
|
||||||
// No channel at all → the assistant is unreachable. Make it loud.
|
// Dismissed state is read from localStorage after mount to avoid a
|
||||||
|
// hydration mismatch (server has no localStorage). `hydrated` gates
|
||||||
|
// the collapsed view so the first paint matches the server output.
|
||||||
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
|
const [hydrated, setHydrated] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
setCollapsed(localStorage.getItem(dismissKey(tenantName)) === "1");
|
||||||
|
} catch {
|
||||||
|
/* private mode / storage disabled — just stay expanded */
|
||||||
|
}
|
||||||
|
setHydrated(true);
|
||||||
|
}, [tenantName]);
|
||||||
|
|
||||||
|
const dismiss = () => {
|
||||||
|
setCollapsed(true);
|
||||||
|
try {
|
||||||
|
localStorage.setItem(dismissKey(tenantName), "1");
|
||||||
|
} catch {
|
||||||
|
/* no-op */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const reopen = () => {
|
||||||
|
setCollapsed(false);
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(dismissKey(tenantName));
|
||||||
|
} catch {
|
||||||
|
/* no-op */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// No channel at all → the assistant is unreachable. Make it loud and
|
||||||
|
// keep it non-dismissible (it's an alert, not reference material).
|
||||||
if (channels.length === 0) {
|
if (channels.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-amber-500/30 bg-amber-500/10 p-5">
|
<div className="rounded-xl border border-amber-500/30 bg-amber-500/10 p-5">
|
||||||
@@ -85,11 +130,51 @@ export function ConnectPanel({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Collapsed: a slim, unobtrusive row with a toggle to bring the full
|
||||||
|
// panel back. Only shown once hydrated so SSR/CSR agree.
|
||||||
|
if (hydrated && collapsed) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between rounded-lg border border-border bg-surface-1 px-4 py-2">
|
||||||
|
<span className="text-xs text-text-muted">{t("title")}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={reopen}
|
||||||
|
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")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-accent/30 bg-accent/5 p-5">
|
<div className="rounded-xl border border-accent/30 bg-accent/5 p-5">
|
||||||
<h2 className="font-display text-base font-semibold text-text-primary mb-1">
|
<div className="flex items-start justify-between gap-3 mb-1">
|
||||||
|
<h2 className="font-display text-base font-semibold text-text-primary">
|
||||||
{t("title")}
|
{t("title")}
|
||||||
</h2>
|
</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={dismiss}
|
||||||
|
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
|
||||||
|
className="h-3.5 w-3.5"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M4.5 12.75l6 6 9-13.5"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{t("dismiss")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<p className="text-xs text-text-secondary mb-4 leading-relaxed">
|
<p className="text-xs text-text-secondary mb-4 leading-relaxed">
|
||||||
{t("description")}
|
{t("description")}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
83
src/components/ui/logo.tsx
Normal file
83
src/components/ui/logo.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
const profile: {
|
||||||
|
givenName: string;
|
||||||
|
familyName: string;
|
||||||
|
displayName: string;
|
||||||
|
preferredLanguage?: string;
|
||||||
|
} = {
|
||||||
givenName: params.givenName,
|
givenName: params.givenName,
|
||||||
familyName: params.familyName,
|
familyName: params.familyName,
|
||||||
displayName,
|
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 ?? "",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
@@ -1005,6 +1016,8 @@
|
|||||||
"threemaBotIdLabel": "Threema-ID",
|
"threemaBotIdLabel": "Threema-ID",
|
||||||
"threemaSteps": "1. Öffnen Sie Threema und scannen Sie diesen QR-Code (oder fügen Sie die obige ID als Kontakt hinzu).\n2. Senden Sie eine Nachricht, um den Chat zu starten.\nStellen Sie sicher, dass Ihre eigene Threema-ID in der Liste der autorisierten Benutzer unten steht – nur gelistete IDs erhalten eine Antwort.",
|
"threemaSteps": "1. Öffnen Sie Threema und scannen Sie diesen QR-Code (oder fügen Sie die obige ID als Kontakt hinzu).\n2. Senden Sie eine Nachricht, um den Chat zu starten.\nStellen Sie sicher, dass Ihre eigene Threema-ID in der Liste der autorisierten Benutzer unten steht – nur gelistete IDs erhalten eine Antwort.",
|
||||||
"telegramSteps": "Öffnen Sie den verbundenen Telegram-Bot und senden Sie ihm eine Nachricht, um den Chat zu starten. Nur die Benutzer-IDs in der Liste der autorisierten Benutzer unten erhalten eine Antwort.",
|
"telegramSteps": "Öffnen Sie den verbundenen Telegram-Bot und senden Sie ihm eine Nachricht, um den Chat zu starten. Nur die Benutzer-IDs in der Liste der autorisierten Benutzer unten erhalten eine Antwort.",
|
||||||
"discordSteps": "Schreiben Sie dem verbundenen Discord-Bot oder erwähnen Sie ihn in einem Kanal, dem er beigetreten ist. Nur die Benutzer-IDs in der Liste der autorisierten Benutzer unten erhalten eine Antwort."
|
"discordSteps": "Schreiben Sie dem verbundenen Discord-Bot oder erwähnen Sie ihn in einem Kanal, dem er beigetreten ist. Nur die Benutzer-IDs in der Liste der autorisierten Benutzer unten erhalten eine Antwort.",
|
||||||
|
"dismiss": "Verbunden",
|
||||||
|
"show": "Verbindungsdetails anzeigen"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
@@ -1005,6 +1016,8 @@
|
|||||||
"threemaBotIdLabel": "Threema ID",
|
"threemaBotIdLabel": "Threema ID",
|
||||||
"threemaSteps": "1. Open Threema and scan this QR code (or add the ID above as a contact).\n2. Send it a message to start chatting.\nMake sure your own Threema ID is on the authorised users list below — only listed IDs get a reply.",
|
"threemaSteps": "1. Open Threema and scan this QR code (or add the ID above as a contact).\n2. Send it a message to start chatting.\nMake sure your own Threema ID is on the authorised users list below — only listed IDs get a reply.",
|
||||||
"telegramSteps": "Open the Telegram bot you connected and send it a message to start chatting. Only the user IDs on the authorised users list below get a reply.",
|
"telegramSteps": "Open the Telegram bot you connected and send it a message to start chatting. Only the user IDs on the authorised users list below get a reply.",
|
||||||
"discordSteps": "Message the Discord bot you connected, or mention it in a channel it has joined. Only the user IDs on the authorised users list below get a reply."
|
"discordSteps": "Message the Discord bot you connected, or mention it in a channel it has joined. Only the user IDs on the authorised users list below get a reply.",
|
||||||
|
"dismiss": "I've connected",
|
||||||
|
"show": "Show connection details"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
@@ -1005,6 +1016,8 @@
|
|||||||
"threemaBotIdLabel": "Identifiant Threema",
|
"threemaBotIdLabel": "Identifiant Threema",
|
||||||
"threemaSteps": "1. Ouvrez Threema et scannez ce QR code (ou ajoutez l'identifiant ci-dessus comme contact).\n2. Envoyez-lui un message pour commencer à discuter.\nAssurez-vous que votre propre identifiant Threema figure dans la liste des utilisateurs autorisés ci-dessous — seuls les identifiants listés reçoivent une réponse.",
|
"threemaSteps": "1. Ouvrez Threema et scannez ce QR code (ou ajoutez l'identifiant ci-dessus comme contact).\n2. Envoyez-lui un message pour commencer à discuter.\nAssurez-vous que votre propre identifiant Threema figure dans la liste des utilisateurs autorisés ci-dessous — seuls les identifiants listés reçoivent une réponse.",
|
||||||
"telegramSteps": "Ouvrez le bot Telegram que vous avez connecté et envoyez-lui un message pour commencer à discuter. Seuls les identifiants utilisateur de la liste des utilisateurs autorisés ci-dessous reçoivent une réponse.",
|
"telegramSteps": "Ouvrez le bot Telegram que vous avez connecté et envoyez-lui un message pour commencer à discuter. Seuls les identifiants utilisateur de la liste des utilisateurs autorisés ci-dessous reçoivent une réponse.",
|
||||||
"discordSteps": "Écrivez au bot Discord que vous avez connecté, ou mentionnez-le dans un salon qu'il a rejoint. Seuls les identifiants utilisateur de la liste des utilisateurs autorisés ci-dessous reçoivent une réponse."
|
"discordSteps": "Écrivez au bot Discord que vous avez connecté, ou mentionnez-le dans un salon qu'il a rejoint. Seuls les identifiants utilisateur de la liste des utilisateurs autorisés ci-dessous reçoivent une réponse.",
|
||||||
|
"dismiss": "Je suis connecté",
|
||||||
|
"show": "Afficher les détails de connexion"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
@@ -1005,6 +1016,8 @@
|
|||||||
"threemaBotIdLabel": "ID Threema",
|
"threemaBotIdLabel": "ID Threema",
|
||||||
"threemaSteps": "1. Apri Threema e scansiona questo codice QR (oppure aggiungi l'ID sopra come contatto).\n2. Inviagli un messaggio per iniziare a chattare.\nAssicurati che il tuo ID Threema sia presente nell'elenco degli utenti autorizzati qui sotto: solo gli ID elencati ricevono una risposta.",
|
"threemaSteps": "1. Apri Threema e scansiona questo codice QR (oppure aggiungi l'ID sopra come contatto).\n2. Inviagli un messaggio per iniziare a chattare.\nAssicurati che il tuo ID Threema sia presente nell'elenco degli utenti autorizzati qui sotto: solo gli ID elencati ricevono una risposta.",
|
||||||
"telegramSteps": "Apri il bot Telegram che hai collegato e inviagli un messaggio per iniziare a chattare. Solo gli ID utente nell'elenco degli utenti autorizzati qui sotto ricevono una risposta.",
|
"telegramSteps": "Apri il bot Telegram che hai collegato e inviagli un messaggio per iniziare a chattare. Solo gli ID utente nell'elenco degli utenti autorizzati qui sotto ricevono una risposta.",
|
||||||
"discordSteps": "Scrivi al bot Discord che hai collegato, oppure menzionalo in un canale a cui si è unito. Solo gli ID utente nell'elenco degli utenti autorizzati qui sotto ricevono una risposta."
|
"discordSteps": "Scrivi al bot Discord che hai collegato, oppure menzionalo in un canale a cui si è unito. Solo gli ID utente nell'elenco degli utenti autorizzati qui sotto ricevono una risposta.",
|
||||||
|
"dismiss": "Mi sono collegato",
|
||||||
|
"show": "Mostra dettagli di connessione"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user