Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 484696a8f5 | |||
| ca1a014c01 | |||
| d01ab85cbb | |||
| 610572eafe | |||
| 73f1af185f | |||
| c1833c1def | |||
| 521398b0fc | |||
| 74d276b656 | |||
| 3110b40cf9 | |||
| 08f28aeb93 | |||
| fb9c0ad25a | |||
| 322cfae824 | |||
| 7fac3c3aa8 | |||
| bff3aad1ca | |||
| f2a9637058 |
@@ -98,6 +98,7 @@ export default async function AdminBillingPage() {
|
|||||||
<div className="animate-in animate-in-delay-3">
|
<div className="animate-in animate-in-delay-3">
|
||||||
<h2 className="text-lg font-semibold mb-3">{t("balancesTitle")}</h2>
|
<h2 className="text-lg font-semibold mb-3">{t("balancesTitle")}</h2>
|
||||||
<Card>
|
<Card>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="text-xs text-text-muted text-left">
|
<thead className="text-xs text-text-muted text-left">
|
||||||
<tr>
|
<tr>
|
||||||
@@ -126,6 +127,7 @@ export default async function AdminBillingPage() {
|
|||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ import { listTenants } from "@/lib/k8s";
|
|||||||
import { countPendingSkillActivationRequests } from "@/lib/db";
|
import { countPendingSkillActivationRequests } from "@/lib/db";
|
||||||
import { AdminPanel } from "@/components/admin/admin-panel";
|
import { AdminPanel } from "@/components/admin/admin-panel";
|
||||||
|
|
||||||
|
export async function generateMetadata() {
|
||||||
|
const t = await getTranslations("common");
|
||||||
|
return { title: t("admin") };
|
||||||
|
}
|
||||||
|
|
||||||
export default async function AdminPage() {
|
export default async function AdminPage() {
|
||||||
const user = await getSessionUser();
|
const user = await getSessionUser();
|
||||||
if (!user) redirect("/login");
|
if (!user) redirect("/login");
|
||||||
|
|||||||
@@ -26,6 +26,11 @@ import { RunningTotalWidget } from "@/components/billing/running-total-widget";
|
|||||||
* Anyone signed in can view this. The data is org-scoped; even
|
* Anyone signed in can view this. The data is org-scoped; even
|
||||||
* non-owner team members see the same view.
|
* non-owner team members see the same view.
|
||||||
*/
|
*/
|
||||||
|
export async function generateMetadata() {
|
||||||
|
const t = await getTranslations("common");
|
||||||
|
return { title: t("billing") };
|
||||||
|
}
|
||||||
|
|
||||||
export default async function CustomerBillingPage() {
|
export default async function CustomerBillingPage() {
|
||||||
const user = await getSessionUser();
|
const user = await getSessionUser();
|
||||||
if (!user) redirect("/login");
|
if (!user) redirect("/login");
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ export default async function NewInstancePage() {
|
|||||||
hasOrgBilling={hasOrgBilling}
|
hasOrgBilling={hasOrgBilling}
|
||||||
existingOrgBilling={orgBilling}
|
existingOrgBilling={orgBilling}
|
||||||
setupFeeChf={pricing.tenantSetupFeeChf}
|
setupFeeChf={pricing.tenantSetupFeeChf}
|
||||||
|
monthlyFeeChf={pricing.tenantMonthlyFeeChf}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -22,6 +22,11 @@ import { ProvisioningStatus } from "@/components/onboarding/provisioning-status"
|
|||||||
import { formatDateTime } from "@/lib/format";
|
import { formatDateTime } from "@/lib/format";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export async function generateMetadata() {
|
||||||
|
const t = await getTranslations("common");
|
||||||
|
return { title: t("dashboard") };
|
||||||
|
}
|
||||||
|
|
||||||
export default async function DashboardPage() {
|
export default async function DashboardPage() {
|
||||||
const user = await getSessionUser();
|
const user = await getSessionUser();
|
||||||
if (!user) redirect("/login");
|
if (!user) redirect("/login");
|
||||||
@@ -321,6 +326,7 @@ export default async function DashboardPage() {
|
|||||||
hasOrgBilling={hasOrgBilling}
|
hasOrgBilling={hasOrgBilling}
|
||||||
existingOrgBilling={orgBilling}
|
existingOrgBilling={orgBilling}
|
||||||
setupFeeChf={platformPricing.tenantSetupFeeChf}
|
setupFeeChf={platformPricing.tenantSetupFeeChf}
|
||||||
|
monthlyFeeChf={platformPricing.tenantMonthlyFeeChf}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -344,7 +350,7 @@ export default async function DashboardPage() {
|
|||||||
{canCreate && (
|
{canCreate && (
|
||||||
<Link
|
<Link
|
||||||
href="/dashboard/new"
|
href="/dashboard/new"
|
||||||
className="shrink-0 inline-flex items-center gap-1.5 py-2 px-4 bg-accent text-white text-xs font-medium rounded-lg hover:bg-accent-dim transition-colors"
|
className="shrink-0 inline-flex items-center gap-1.5 py-2 px-4 bg-accent text-surface-0 text-xs font-medium rounded-lg hover:bg-accent-dim transition-colors"
|
||||||
>
|
>
|
||||||
<span>+</span> {t("createInstance")}
|
<span>+</span> {t("createInstance")}
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
72
src/app/[locale]/error.tsx
Normal file
72
src/app/[locale]/error.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { Link } from "@/i18n/navigation";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error boundary for the [locale] segment. Catches render/data errors
|
||||||
|
* thrown by any page below the locale layout (which is where K8s, DB,
|
||||||
|
* LiteLLM and Stripe calls happen). Renders inside NextIntlClientProvider,
|
||||||
|
* so translations are available. Root-layout failures fall through to
|
||||||
|
* global-error.tsx instead.
|
||||||
|
*/
|
||||||
|
export default function LocaleError({
|
||||||
|
error,
|
||||||
|
reset,
|
||||||
|
}: {
|
||||||
|
error: Error & { digest?: string };
|
||||||
|
reset: () => void;
|
||||||
|
}) {
|
||||||
|
const t = useTranslations("errors");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Surface the error for log scraping; the digest correlates with
|
||||||
|
// the server-side stack in production.
|
||||||
|
console.error("Portal error boundary:", error);
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-[60vh] items-center justify-center px-5">
|
||||||
|
<div className="w-full max-w-md text-center">
|
||||||
|
<div className="mx-auto mb-5 flex h-14 w-14 items-center justify-center rounded-xl bg-error/10">
|
||||||
|
<svg
|
||||||
|
className="h-7 w-7 text-error"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={1.75}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M12 9v4M12 17h.01M10.3 3.86l-8.5 14.7A1.5 1.5 0 003.1 21h17.8a1.5 1.5 0 001.3-2.44l-8.5-14.7a1.5 1.5 0 00-2.6 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h1 className="font-display text-xl font-semibold text-text-primary mb-2">
|
||||||
|
{t("title")}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-text-secondary mb-6">{t("description")}</p>
|
||||||
|
{error?.digest && (
|
||||||
|
<p className="text-[11px] font-mono text-text-muted mb-6">
|
||||||
|
{error.digest}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center justify-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={reset}
|
||||||
|
className="py-2 px-4 rounded-lg bg-accent text-surface-0 text-sm font-medium hover:bg-accent-dim transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
{t("retry")}
|
||||||
|
</button>
|
||||||
|
<Link
|
||||||
|
href="/dashboard"
|
||||||
|
className="py-2 px-4 rounded-lg border border-border text-sm font-medium text-text-secondary hover:text-text-primary hover:bg-surface-2 transition-colors"
|
||||||
|
>
|
||||||
|
{t("backToDashboard")}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,13 +1,36 @@
|
|||||||
|
import type { Metadata, Viewport } from "next";
|
||||||
import { NextIntlClientProvider } from "next-intl";
|
import { NextIntlClientProvider } from "next-intl";
|
||||||
import { getMessages } from "next-intl/server";
|
import { getMessages, getTranslations } from "next-intl/server";
|
||||||
import { routing } from "@/i18n/routing";
|
import { routing } from "@/i18n/routing";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
import { NavShell } from "@/components/layout/nav-shell";
|
import { NavShell } from "@/components/layout/nav-shell";
|
||||||
|
|
||||||
export function generateStaticParams() {
|
export function generateStaticParams() {
|
||||||
return routing.locales.map((locale) => ({ locale }));
|
return routing.locales.map((locale) => ({ locale }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Metadata API (Next 15) instead of a hand-rolled <head>. The title
|
||||||
|
// template lets each page export a short `title` (e.g. "Dashboard")
|
||||||
|
// that renders as "Dashboard · PieCed". Pages that export no metadata
|
||||||
|
// fall back to the default below.
|
||||||
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
|
const t = await getTranslations("common");
|
||||||
|
const appName = t("appName");
|
||||||
|
return {
|
||||||
|
title: {
|
||||||
|
default: `${appName} Portal`,
|
||||||
|
template: `%s · ${appName}`,
|
||||||
|
},
|
||||||
|
description: "PieCed IT — Multi-tenant AI assistant platform",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const viewport: Viewport = {
|
||||||
|
width: "device-width",
|
||||||
|
initialScale: 1,
|
||||||
|
};
|
||||||
|
|
||||||
export default async function LocaleLayout({
|
export default async function LocaleLayout({
|
||||||
children,
|
children,
|
||||||
params,
|
params,
|
||||||
@@ -22,20 +45,13 @@ export default async function LocaleLayout({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const messages = await getMessages();
|
const messages = await getMessages();
|
||||||
|
const session = await auth();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang={locale} className="dark">
|
<html lang={locale} className="dark">
|
||||||
<head>
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
||||||
<title>PieCed Portal</title>
|
|
||||||
<meta
|
|
||||||
name="description"
|
|
||||||
content="PieCed IT — Multi-tenant AI assistant platform"
|
|
||||||
/>
|
|
||||||
</head>
|
|
||||||
<body className="min-h-screen bg-surface-0 text-text-primary antialiased">
|
<body className="min-h-screen bg-surface-0 text-text-primary antialiased">
|
||||||
<NextIntlClientProvider messages={messages}>
|
<NextIntlClientProvider messages={messages}>
|
||||||
<NavShell>{children}</NavShell>
|
<NavShell session={session}>{children}</NavShell>
|
||||||
</NextIntlClientProvider>
|
</NextIntlClientProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
25
src/app/[locale]/loading.tsx
Normal file
25
src/app/[locale]/loading.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
/**
|
||||||
|
* Loading skeleton for the [locale] segment. Shown during navigation
|
||||||
|
* while a server component fetches (the dashboard, for instance, does
|
||||||
|
* listTenants() + one K8s GET per provisioning row). Textless on
|
||||||
|
* purpose so it needs no translations and adds no layout shift.
|
||||||
|
*/
|
||||||
|
export default function LocaleLoading() {
|
||||||
|
return (
|
||||||
|
<div className="animate-pulse" aria-hidden="true">
|
||||||
|
<div className="mb-8">
|
||||||
|
<div className="h-7 w-48 rounded-md bg-surface-2" />
|
||||||
|
<div className="mt-4 h-4 w-72 rounded bg-surface-1" />
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="h-28 rounded-xl border border-border bg-surface-1"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span className="sr-only">Loading…</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { signIn } from "next-auth/react";
|
import { signIn } from "next-auth/react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations, useLocale } from "next-intl";
|
||||||
import Link from "next/link";
|
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");
|
||||||
|
const locale = useLocale();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 flex items-center justify-center bg-surface-0">
|
<div className="fixed inset-0 flex items-center justify-center bg-surface-0">
|
||||||
@@ -24,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">
|
||||||
@@ -39,7 +38,14 @@ export default function LoginPage() {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => signIn("zitadel", { callbackUrl: "/dashboard" })}
|
onClick={() =>
|
||||||
|
signIn("zitadel", {
|
||||||
|
// Preserve the active locale across the OIDC round-trip.
|
||||||
|
// A bare "/dashboard" would resolve to the default (de)
|
||||||
|
// locale on return; getPathname prefixes it as needed.
|
||||||
|
callbackUrl: getPathname({ href: "/dashboard", locale }),
|
||||||
|
})
|
||||||
|
}
|
||||||
className="
|
className="
|
||||||
w-full py-3 px-4 rounded-lg font-medium text-sm
|
w-full py-3 px-4 rounded-lg font-medium text-sm
|
||||||
bg-accent text-surface-0 cursor-pointer
|
bg-accent text-surface-0 cursor-pointer
|
||||||
|
|||||||
34
src/app/[locale]/not-found.tsx
Normal file
34
src/app/[locale]/not-found.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { Link } from "@/i18n/navigation";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 404 for the [locale] segment. Triggered by notFound() calls in pages
|
||||||
|
* below the locale layout. (A notFound() thrown by the locale layout
|
||||||
|
* itself — e.g. an unknown locale — resolves to the framework default,
|
||||||
|
* which is acceptable for that narrow case.)
|
||||||
|
*/
|
||||||
|
export default async function LocaleNotFound() {
|
||||||
|
const t = await getTranslations("errors");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-[60vh] items-center justify-center px-5">
|
||||||
|
<div className="w-full max-w-md text-center">
|
||||||
|
<div className="font-display text-5xl font-semibold text-accent mb-4 tabular-nums">
|
||||||
|
404
|
||||||
|
</div>
|
||||||
|
<h1 className="font-display text-xl font-semibold text-text-primary mb-2">
|
||||||
|
{t("notFoundTitle")}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-text-secondary mb-6">
|
||||||
|
{t("notFoundDescription")}
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href="/dashboard"
|
||||||
|
className="inline-flex py-2 px-4 rounded-lg bg-accent text-surface-0 text-sm font-medium hover:bg-accent-dim transition-colors"
|
||||||
|
>
|
||||||
|
{t("backToDashboard")}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,13 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "@/i18n/navigation";
|
||||||
|
|
||||||
export default function RootPage() {
|
export default async function RootPage({
|
||||||
redirect("/dashboard");
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ locale: string }>;
|
||||||
|
}) {
|
||||||
|
// Locale-aware redirect: a bare next/navigation redirect("/dashboard")
|
||||||
|
// drops the prefix and lands non-default-locale users on the German
|
||||||
|
// dashboard. The i18n redirect prefixes per the active locale.
|
||||||
|
const { locale } = await params;
|
||||||
|
redirect({ href: "/dashboard", locale });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState, useRef, forwardRef } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations, useLocale } from "next-intl";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter, Link } from "@/i18n/navigation";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
|
|
||||||
type FormState = "idle" | "submitting" | "success" | "error";
|
type FormState = "idle" | "submitting" | "success" | "error";
|
||||||
@@ -41,15 +41,45 @@ 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("");
|
||||||
|
|
||||||
|
// Radiogroup keyboard support. `role="radio"` requires roving
|
||||||
|
// tabindex (one tab stop) + arrow-key navigation between options —
|
||||||
|
// native buttons don't move focus on arrows. The selected card is
|
||||||
|
// the tab stop; when nothing is selected yet the first card is
|
||||||
|
// focusable so keyboard users can enter the group.
|
||||||
|
const TYPES: AccountType[] = ["personal", "company"];
|
||||||
|
const cardRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
||||||
|
|
||||||
|
const rovingTabIndex = (type: AccountType, index: number) =>
|
||||||
|
accountType === type || (accountType === null && index === 0) ? 0 : -1;
|
||||||
|
|
||||||
|
const handleCardKeyDown = (e: React.KeyboardEvent, index: number) => {
|
||||||
|
let next: number | null = null;
|
||||||
|
if (e.key === "ArrowRight" || e.key === "ArrowDown") {
|
||||||
|
next = (index + 1) % TYPES.length;
|
||||||
|
} else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
|
||||||
|
next = (index - 1 + TYPES.length) % TYPES.length;
|
||||||
|
}
|
||||||
|
if (next === null) return;
|
||||||
|
e.preventDefault();
|
||||||
|
setAccountType(TYPES[next]);
|
||||||
|
cardRefs.current[next]?.focus();
|
||||||
|
};
|
||||||
|
|
||||||
const isPersonal = accountType === "personal";
|
const isPersonal = accountType === "personal";
|
||||||
|
|
||||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
@@ -70,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) {
|
||||||
@@ -120,7 +151,7 @@ export default function RegisterPage() {
|
|||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => router.push("/login")}
|
onClick={() => router.push("/login")}
|
||||||
className="w-full py-2.5 px-4 bg-accent text-white text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors"
|
className="w-full py-2.5 px-4 bg-accent text-surface-0 text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors"
|
||||||
>
|
>
|
||||||
{t("goToLogin")}
|
{t("goToLogin")}
|
||||||
</button>
|
</button>
|
||||||
@@ -146,8 +177,13 @@ export default function RegisterPage() {
|
|||||||
className="grid grid-cols-2 gap-3 mb-6 animate-in animate-in-delay-1"
|
className="grid grid-cols-2 gap-3 mb-6 animate-in animate-in-delay-1"
|
||||||
>
|
>
|
||||||
<AccountTypeCard
|
<AccountTypeCard
|
||||||
|
ref={(el) => {
|
||||||
|
cardRefs.current[0] = el;
|
||||||
|
}}
|
||||||
selected={accountType === "personal"}
|
selected={accountType === "personal"}
|
||||||
onClick={() => setAccountType("personal")}
|
onClick={() => setAccountType("personal")}
|
||||||
|
tabIndex={rovingTabIndex("personal", 0)}
|
||||||
|
onKeyDown={(e) => handleCardKeyDown(e, 0)}
|
||||||
label={t("personalCardTitle")}
|
label={t("personalCardTitle")}
|
||||||
description={t("personalCardDescription")}
|
description={t("personalCardDescription")}
|
||||||
icon={
|
icon={
|
||||||
@@ -168,8 +204,13 @@ export default function RegisterPage() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<AccountTypeCard
|
<AccountTypeCard
|
||||||
|
ref={(el) => {
|
||||||
|
cardRefs.current[1] = el;
|
||||||
|
}}
|
||||||
selected={accountType === "company"}
|
selected={accountType === "company"}
|
||||||
onClick={() => setAccountType("company")}
|
onClick={() => setAccountType("company")}
|
||||||
|
tabIndex={rovingTabIndex("company", 1)}
|
||||||
|
onKeyDown={(e) => handleCardKeyDown(e, 1)}
|
||||||
label={t("companyCardTitle")}
|
label={t("companyCardTitle")}
|
||||||
description={t("companyCardDescription")}
|
description={t("companyCardDescription")}
|
||||||
icon={
|
icon={
|
||||||
@@ -261,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}
|
||||||
@@ -270,7 +334,7 @@ export default function RegisterPage() {
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={state === "submitting"}
|
disabled={state === "submitting"}
|
||||||
className="w-full py-2.5 px-4 bg-accent text-white text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
className="w-full py-2.5 px-4 bg-accent text-surface-0 text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
{state === "submitting" ? tCommon("loading") : t("submit")}
|
{state === "submitting" ? tCommon("loading") : t("submit")}
|
||||||
</button>
|
</button>
|
||||||
@@ -278,12 +342,12 @@ export default function RegisterPage() {
|
|||||||
|
|
||||||
<p className="text-xs text-text-muted text-center mt-4">
|
<p className="text-xs text-text-muted text-center mt-4">
|
||||||
{t("hasAccount")}{" "}
|
{t("hasAccount")}{" "}
|
||||||
<a
|
<Link
|
||||||
href="/login"
|
href="/login"
|
||||||
className="text-accent hover:text-accent-dim transition-colors"
|
className="text-accent hover:text-accent-dim transition-colors"
|
||||||
>
|
>
|
||||||
{tCommon("login")}
|
{tCommon("login")}
|
||||||
</a>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
@@ -305,41 +369,42 @@ export default function RegisterPage() {
|
|||||||
* and text colours intensify when selected to give a clear "this one
|
* and text colours intensify when selected to give a clear "this one
|
||||||
* is on" signal beyond just the border colour.
|
* is on" signal beyond just the border colour.
|
||||||
*/
|
*/
|
||||||
function AccountTypeCard({
|
const AccountTypeCard = forwardRef<
|
||||||
selected,
|
HTMLButtonElement,
|
||||||
onClick,
|
{
|
||||||
label,
|
selected: boolean;
|
||||||
description,
|
onClick: () => void;
|
||||||
icon,
|
label: string;
|
||||||
}: {
|
description: string;
|
||||||
selected: boolean;
|
icon: React.ReactNode;
|
||||||
onClick: () => void;
|
tabIndex: number;
|
||||||
label: string;
|
onKeyDown: (e: React.KeyboardEvent) => void;
|
||||||
description: string;
|
}
|
||||||
icon: React.ReactNode;
|
>(function AccountTypeCard(
|
||||||
}) {
|
{ selected, onClick, label, description, icon, tabIndex, onKeyDown },
|
||||||
|
ref
|
||||||
|
) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
ref={ref}
|
||||||
type="button"
|
type="button"
|
||||||
role="radio"
|
role="radio"
|
||||||
aria-checked={selected}
|
aria-checked={selected}
|
||||||
|
tabIndex={tabIndex}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
className={`text-left rounded-xl border p-4 transition-colors cursor-pointer focus:outline-none focus:ring-2 focus:ring-accent/40 ${
|
className={`text-left rounded-xl border p-4 transition-colors cursor-pointer focus:outline-none focus:ring-2 focus:ring-accent/40 ${
|
||||||
selected
|
selected
|
||||||
? "border-accent bg-accent/10"
|
? "border-accent bg-accent/10"
|
||||||
: "border-border bg-surface-2 hover:border-accent/40 hover:bg-surface-3/30"
|
: "border-border bg-surface-2 hover:border-accent/40 hover:bg-surface-3/30"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div
|
<div className={`mb-2 ${selected ? "text-accent" : "text-text-muted"}`}>
|
||||||
className={`mb-2 ${
|
|
||||||
selected ? "text-accent" : "text-text-muted"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{icon}
|
{icon}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={`text-sm font-semibold mb-0.5 ${
|
className={`text-sm font-semibold mb-0.5 ${
|
||||||
selected ? "text-text-primary" : "text-text-primary"
|
selected ? "text-text-primary" : "text-text-secondary"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
@@ -347,4 +412,4 @@ function AccountTypeCard({
|
|||||||
<div className="text-xs text-text-muted leading-snug">{description}</div>
|
<div className="text-xs text-text-muted leading-snug">{description}</div>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ import { Card } from "@/components/ui/card";
|
|||||||
* Access: any authenticated user (the cards themselves gate further;
|
* Access: any authenticated user (the cards themselves gate further;
|
||||||
* non-owner users would not see "Billing" as actionable, etc.).
|
* non-owner users would not see "Billing" as actionable, etc.).
|
||||||
*/
|
*/
|
||||||
|
export async function generateMetadata() {
|
||||||
|
const t = await getTranslations("common");
|
||||||
|
return { title: t("settings") };
|
||||||
|
}
|
||||||
|
|
||||||
export default async function SettingsPage() {
|
export default async function SettingsPage() {
|
||||||
const user = await getSessionUser();
|
const user = await getSessionUser();
|
||||||
if (!user) redirect("/login");
|
if (!user) redirect("/login");
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ import { TicketCategoryLabel } from "@/components/support/ticket-category-label"
|
|||||||
* having recent activity, but we don't sort by status; that's a
|
* having recent activity, but we don't sort by status; that's a
|
||||||
* filter the admin can add later if the queue grows.
|
* filter the admin can add later if the queue grows.
|
||||||
*/
|
*/
|
||||||
|
export async function generateMetadata() {
|
||||||
|
const t = await getTranslations("common");
|
||||||
|
return { title: t("support") };
|
||||||
|
}
|
||||||
|
|
||||||
export default async function SupportListPage() {
|
export default async function SupportListPage() {
|
||||||
const user = await getSessionUser();
|
const user = await getSessionUser();
|
||||||
if (!user) redirect("/login");
|
if (!user) redirect("/login");
|
||||||
@@ -48,7 +53,7 @@ export default async function SupportListPage() {
|
|||||||
{!user.isPlatform && (
|
{!user.isPlatform && (
|
||||||
<Link
|
<Link
|
||||||
href="/support/new"
|
href="/support/new"
|
||||||
className="text-sm font-medium px-4 py-2 rounded-lg bg-accent text-white hover:bg-accent/90 transition-colors"
|
className="text-sm font-medium px-4 py-2 rounded-lg bg-accent text-surface-0 hover:bg-accent/90 transition-colors"
|
||||||
>
|
>
|
||||||
{t("newTicket")}
|
{t("newTicket")}
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Card } from "@/components/ui/card";
|
|||||||
import { BackLink } from "@/components/ui/back-link";
|
import { BackLink } from "@/components/ui/back-link";
|
||||||
import { TeamList } from "@/components/team/team-list";
|
import { TeamList } from "@/components/team/team-list";
|
||||||
import { InviteForm } from "@/components/team/invite-form";
|
import { InviteForm } from "@/components/team/invite-form";
|
||||||
|
import { AccessOverview } from "@/components/team/access-overview";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* /team — manage org members.
|
* /team — manage org members.
|
||||||
@@ -17,6 +18,11 @@ import { InviteForm } from "@/components/team/invite-form";
|
|||||||
* `<TeamList>` and `<InviteForm>` client components handle live
|
* `<TeamList>` and `<InviteForm>` client components handle live
|
||||||
* updates after invites and refreshes.
|
* updates after invites and refreshes.
|
||||||
*/
|
*/
|
||||||
|
export async function generateMetadata() {
|
||||||
|
const t = await getTranslations("common");
|
||||||
|
return { title: t("team") };
|
||||||
|
}
|
||||||
|
|
||||||
export default async function TeamPage() {
|
export default async function TeamPage() {
|
||||||
const user = await getSessionUser();
|
const user = await getSessionUser();
|
||||||
if (!user) redirect("/login");
|
if (!user) redirect("/login");
|
||||||
@@ -65,6 +71,16 @@ export default async function TeamPage() {
|
|||||||
canEditRoles={isCustomerOwner(user)}
|
canEditRoles={isCustomerOwner(user)}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* Access overview — single place to see which member can reach
|
||||||
|
which assistant, instead of checking each tenant page. */}
|
||||||
|
<section className="mt-8 animate-in animate-in-delay-3">
|
||||||
|
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-1">
|
||||||
|
{t("accessTitle")}
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-text-muted mb-3">{t("accessDescription")}</p>
|
||||||
|
<AccessOverview />
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { WorkspaceEditor } from "@/components/packages/workspace-editor";
|
|||||||
import { ChannelUsers } from "@/components/channel-users/channel-users";
|
import { ChannelUsers } from "@/components/channel-users/channel-users";
|
||||||
import { AssignedUsersPanel } from "@/components/tenants/assigned-users-panel";
|
import { AssignedUsersPanel } from "@/components/tenants/assigned-users-panel";
|
||||||
import { SubscriptionToggle } from "@/components/tenants/subscription-toggle";
|
import { SubscriptionToggle } from "@/components/tenants/subscription-toggle";
|
||||||
|
import { ConnectPanel } from "@/components/tenants/connect-panel";
|
||||||
import { formatDateTime, formatRelative } from "@/lib/format";
|
import { formatDateTime, formatRelative } from "@/lib/format";
|
||||||
import { CHANNEL_PACKAGE_IDS } from "@/lib/packages";
|
import { CHANNEL_PACKAGE_IDS } from "@/lib/packages";
|
||||||
|
|
||||||
@@ -216,6 +217,20 @@ export default async function TenantDetailPage({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Connect: how the customer actually reaches their assistant.
|
||||||
|
The portal manages the assistant; the assistant lives in the
|
||||||
|
customer's messaging app. This bridges that gap right at the
|
||||||
|
top of the page (and calls out the case where no channel is
|
||||||
|
enabled, which would otherwise leave a running assistant
|
||||||
|
unreachable). */}
|
||||||
|
<section className="mb-8 animate-in animate-in-delay-1">
|
||||||
|
<ConnectPanel
|
||||||
|
tenantName={name}
|
||||||
|
enabledChannels={enabledChannels}
|
||||||
|
phase={tenant.status?.phase ?? "Pending"}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
{/* Usage */}
|
{/* Usage */}
|
||||||
<section className="mb-8 animate-in animate-in-delay-1">
|
<section className="mb-8 animate-in animate-in-delay-1">
|
||||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
|
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
78
src/app/global-error.tsx
Normal file
78
src/app/global-error.tsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Last-resort boundary for errors thrown in the root layout itself
|
||||||
|
* (before the locale layout / intl provider mount). It replaces the
|
||||||
|
* entire document, so it must render its own <html>/<body> and cannot
|
||||||
|
* use translations or rely on the app stylesheet being applied — styles
|
||||||
|
* are inlined with the palette's hex values so it renders correctly in
|
||||||
|
* isolation. Everything below the locale layout is handled by
|
||||||
|
* [locale]/error.tsx instead; this should almost never be seen.
|
||||||
|
*/
|
||||||
|
export default function GlobalError({
|
||||||
|
error,
|
||||||
|
reset,
|
||||||
|
}: {
|
||||||
|
error: Error & { digest?: string };
|
||||||
|
reset: () => void;
|
||||||
|
}) {
|
||||||
|
useEffect(() => {
|
||||||
|
console.error("Portal global error:", error);
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<html lang="en" className="dark">
|
||||||
|
<body
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
minHeight: "100vh",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
background: "#0a0c10",
|
||||||
|
color: "#e8ecf4",
|
||||||
|
fontFamily: "system-ui, sans-serif",
|
||||||
|
padding: "20px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ maxWidth: "28rem", textAlign: "center" }}>
|
||||||
|
<h1 style={{ fontSize: "1.25rem", fontWeight: 600, margin: "0 0 0.5rem" }}>
|
||||||
|
Something went wrong
|
||||||
|
</h1>
|
||||||
|
<p style={{ fontSize: "0.875rem", color: "#8892a4", margin: "0 0 1.5rem" }}>
|
||||||
|
An unexpected error occurred. Please try again.
|
||||||
|
</p>
|
||||||
|
{error?.digest && (
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: "11px",
|
||||||
|
fontFamily: "monospace",
|
||||||
|
color: "#565e6e",
|
||||||
|
margin: "0 0 1.5rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{error.digest}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={reset}
|
||||||
|
style={{
|
||||||
|
padding: "0.5rem 1rem",
|
||||||
|
borderRadius: "0.5rem",
|
||||||
|
border: "none",
|
||||||
|
background: "#00d4aa",
|
||||||
|
color: "#0a0c10",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
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 |
@@ -4,6 +4,15 @@ import { useState, useEffect, useCallback } from "react";
|
|||||||
import { useTranslations, useFormatter } from "next-intl";
|
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 {
|
||||||
|
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";
|
||||||
|
|
||||||
@@ -35,6 +44,11 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
|||||||
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
||||||
const [rejectModal, setRejectModal] = useState<string | null>(null);
|
const [rejectModal, setRejectModal] = useState<string | null>(null);
|
||||||
const [rejectNotes, setRejectNotes] = useState("");
|
const [rejectNotes, setRejectNotes] = useState("");
|
||||||
|
// Approve is the highest-consequence request action — it provisions
|
||||||
|
// real infrastructure and triggers the billable setup fee — so it now
|
||||||
|
// goes through a confirmation modal like reject/delete, instead of
|
||||||
|
// firing on a single click.
|
||||||
|
const [approveModal, setApproveModal] = useState<string | null>(null);
|
||||||
|
|
||||||
// Tenants state
|
// Tenants state
|
||||||
const [tenants, setTenants] = useState<PiecedTenant[]>(initialTenants);
|
const [tenants, setTenants] = useState<PiecedTenant[]>(initialTenants);
|
||||||
@@ -48,6 +62,26 @@ 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
|
||||||
|
// a failed approve/reject/delete surfaces next to the action that
|
||||||
|
// caused it (and keeps the modal open), rather than as a detached
|
||||||
|
// panel-level banner that isn't tied to any row.
|
||||||
|
const [actionError, setActionError] = useState("");
|
||||||
|
|
||||||
// ─── Requests fetching ───
|
// ─── Requests fetching ───
|
||||||
const fetchRequests = useCallback(async () => {
|
const fetchRequests = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -125,18 +159,21 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
|||||||
// ─── Request actions ───
|
// ─── Request actions ───
|
||||||
const handleApprove = async (id: string) => {
|
const handleApprove = async (id: string) => {
|
||||||
setActionLoading(id);
|
setActionLoading(id);
|
||||||
setError("");
|
setActionError("");
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/admin/requests/${id}/approve`, {
|
const res = await fetch(`/api/admin/requests/${id}/approve`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json().catch(() => ({}));
|
||||||
throw new Error(data.error || "Approve failed");
|
throw new Error(data.error || "Approve failed");
|
||||||
}
|
}
|
||||||
|
setApproveModal(null);
|
||||||
await fetchRequests();
|
await fetchRequests();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setError(e.message);
|
// Keep the modal open so the admin sees why provisioning didn't
|
||||||
|
// start; the error renders inside the dialog next to the action.
|
||||||
|
setActionError(e.message);
|
||||||
} finally {
|
} finally {
|
||||||
setActionLoading(null);
|
setActionLoading(null);
|
||||||
}
|
}
|
||||||
@@ -144,7 +181,7 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
|||||||
|
|
||||||
const handleReject = async (id: string) => {
|
const handleReject = async (id: string) => {
|
||||||
setActionLoading(id);
|
setActionLoading(id);
|
||||||
setError("");
|
setActionError("");
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/admin/requests/${id}/reject`, {
|
const res = await fetch(`/api/admin/requests/${id}/reject`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -152,14 +189,14 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
|||||||
body: JSON.stringify({ adminNotes: rejectNotes || undefined }),
|
body: JSON.stringify({ adminNotes: rejectNotes || undefined }),
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json().catch(() => ({}));
|
||||||
throw new Error(data.error || "Reject failed");
|
throw new Error(data.error || "Reject failed");
|
||||||
}
|
}
|
||||||
setRejectModal(null);
|
setRejectModal(null);
|
||||||
setRejectNotes("");
|
setRejectNotes("");
|
||||||
await fetchRequests();
|
await fetchRequests();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setError(e.message);
|
setActionError(e.message);
|
||||||
} finally {
|
} finally {
|
||||||
setActionLoading(null);
|
setActionLoading(null);
|
||||||
}
|
}
|
||||||
@@ -189,7 +226,7 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
|||||||
|
|
||||||
const handleDelete = async (name: string) => {
|
const handleDelete = async (name: string) => {
|
||||||
setActionLoading(name);
|
setActionLoading(name);
|
||||||
setError("");
|
setActionError("");
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/admin/tenants/${name}/delete`, {
|
const res = await fetch(`/api/admin/tenants/${name}/delete`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -216,7 +253,7 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
|||||||
fetchTenants();
|
fetchTenants();
|
||||||
setTimeout(() => fetchTenants(), 1500);
|
setTimeout(() => fetchTenants(), 1500);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setError(e.message);
|
setActionError(e.message);
|
||||||
} finally {
|
} finally {
|
||||||
setActionLoading(null);
|
setActionLoading(null);
|
||||||
}
|
}
|
||||||
@@ -232,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 */}
|
||||||
@@ -246,7 +330,7 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
|||||||
>
|
>
|
||||||
{t("requests")}
|
{t("requests")}
|
||||||
{pendingCount > 0 && tab !== "requests" && (
|
{pendingCount > 0 && tab !== "requests" && (
|
||||||
<span className="ml-1.5 inline-flex items-center justify-center h-4 min-w-[16px] px-1 text-[10px] font-bold bg-accent text-white rounded-full">
|
<span className="ml-1.5 inline-flex items-center justify-center h-4 min-w-[16px] px-1 text-[10px] font-bold bg-accent text-surface-0 rounded-full">
|
||||||
{pendingCount}
|
{pendingCount}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -301,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-white"
|
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 ? (
|
||||||
@@ -326,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>
|
||||||
@@ -344,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"
|
||||||
@@ -436,16 +547,20 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
|||||||
{req.status === "pending" && (
|
{req.status === "pending" && (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleApprove(req.id)}
|
onClick={() => {
|
||||||
|
setActionError("");
|
||||||
|
setApproveModal(req.id);
|
||||||
|
}}
|
||||||
disabled={actionLoading === req.id}
|
disabled={actionLoading === req.id}
|
||||||
className="px-2.5 py-1 text-xs font-medium bg-emerald-500/15 text-emerald-400 rounded-md hover:bg-emerald-500/25 transition-colors disabled:opacity-50"
|
className="px-2.5 py-1 text-xs font-medium bg-emerald-500/15 text-emerald-400 rounded-md hover:bg-emerald-500/25 transition-colors disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{actionLoading === req.id
|
{t("approve")}
|
||||||
? "…"
|
|
||||||
: t("approve")}
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setRejectModal(req.id)}
|
onClick={() => {
|
||||||
|
setActionError("");
|
||||||
|
setRejectModal(req.id);
|
||||||
|
}}
|
||||||
disabled={actionLoading === req.id}
|
disabled={actionLoading === req.id}
|
||||||
className="px-2.5 py-1 text-xs font-medium bg-red-500/15 text-red-400 rounded-md hover:bg-red-500/25 transition-colors disabled:opacity-50"
|
className="px-2.5 py-1 text-xs font-medium bg-red-500/15 text-red-400 rounded-md hover:bg-red-500/25 transition-colors disabled:opacity-50"
|
||||||
>
|
>
|
||||||
@@ -466,7 +581,10 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
|||||||
)}
|
)}
|
||||||
{req.status === "rejected" && (
|
{req.status === "rejected" && (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleApprove(req.id)}
|
onClick={() => {
|
||||||
|
setActionError("");
|
||||||
|
setApproveModal(req.id);
|
||||||
|
}}
|
||||||
disabled={actionLoading === req.id}
|
disabled={actionLoading === req.id}
|
||||||
className="px-2.5 py-1 text-xs font-medium bg-amber-500/15 text-amber-400 rounded-md hover:bg-amber-500/25 transition-colors disabled:opacity-50"
|
className="px-2.5 py-1 text-xs font-medium bg-amber-500/15 text-amber-400 rounded-md hover:bg-amber-500/25 transition-colors disabled:opacity-50"
|
||||||
>
|
>
|
||||||
@@ -485,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>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -522,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" />
|
||||||
@@ -531,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 (
|
||||||
@@ -642,9 +791,10 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
|||||||
: t("suspend")}
|
: t("suspend")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() =>
|
onClick={() => {
|
||||||
setDeleteModal(tenant.metadata.name)
|
setActionError("");
|
||||||
}
|
setDeleteModal(tenant.metadata.name);
|
||||||
|
}}
|
||||||
disabled={actionLoading === tenant.metadata.name}
|
disabled={actionLoading === tenant.metadata.name}
|
||||||
className="px-2.5 py-1 text-xs font-medium bg-red-500/15 text-red-400 rounded-md hover:bg-red-500/25 transition-colors disabled:opacity-50"
|
className="px-2.5 py-1 text-xs font-medium bg-red-500/15 text-red-400 rounded-md hover:bg-red-500/25 transition-colors disabled:opacity-50"
|
||||||
>
|
>
|
||||||
@@ -658,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>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -772,10 +928,75 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ───── APPROVE MODAL ───── */}
|
||||||
|
<Modal
|
||||||
|
open={!!approveModal}
|
||||||
|
onClose={() => {
|
||||||
|
setApproveModal(null);
|
||||||
|
setActionError("");
|
||||||
|
}}
|
||||||
|
ariaLabel={t("approveTitle")}
|
||||||
|
>
|
||||||
|
{approveModal &&
|
||||||
|
(() => {
|
||||||
|
const req = requests.find((r) => r.id === approveModal);
|
||||||
|
const isReapprove = req?.status === "rejected";
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h3 className="font-display text-lg font-semibold text-text-primary mb-2">
|
||||||
|
{t("approveTitle")}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-text-secondary mb-2">
|
||||||
|
{isReapprove
|
||||||
|
? t("approveReapproveWarning")
|
||||||
|
: t("approveWarning")}
|
||||||
|
</p>
|
||||||
|
{req && (
|
||||||
|
<p className="text-xs font-mono text-accent bg-surface-2 border border-border rounded-lg px-3 py-2 mb-4">
|
||||||
|
{req.companyName}
|
||||||
|
{req.agentName ? ` · ${req.agentName}` : ""}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{actionError && (
|
||||||
|
<p className="text-xs text-red-400 bg-red-400/10 border border-red-400/20 rounded-lg px-3 py-2 mb-4">
|
||||||
|
{actionError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setApproveModal(null);
|
||||||
|
setActionError("");
|
||||||
|
}}
|
||||||
|
className="px-4 py-2 text-sm text-text-secondary hover:text-text-primary transition-colors"
|
||||||
|
>
|
||||||
|
{t("cancelAction")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleApprove(approveModal)}
|
||||||
|
disabled={actionLoading === approveModal}
|
||||||
|
className="px-4 py-2 text-sm font-medium bg-emerald-500/15 text-emerald-400 rounded-lg hover:bg-emerald-500/25 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{actionLoading === approveModal ? "…" : t("confirmApprove")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
{/* ───── REJECT MODAL ───── */}
|
{/* ───── REJECT MODAL ───── */}
|
||||||
{rejectModal && (
|
<Modal
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
open={!!rejectModal}
|
||||||
<div className="bg-surface-1 border border-border rounded-xl p-6 max-w-md w-full mx-4 shadow-2xl">
|
onClose={() => {
|
||||||
|
setRejectModal(null);
|
||||||
|
setRejectNotes("");
|
||||||
|
setActionError("");
|
||||||
|
}}
|
||||||
|
ariaLabel={t("rejectTitle")}
|
||||||
|
>
|
||||||
|
{rejectModal && (
|
||||||
|
<>
|
||||||
<h3 className="font-display text-lg font-semibold text-text-primary mb-4">
|
<h3 className="font-display text-lg font-semibold text-text-primary mb-4">
|
||||||
{t("rejectTitle")}
|
{t("rejectTitle")}
|
||||||
</h3>
|
</h3>
|
||||||
@@ -789,11 +1010,17 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
|||||||
rows={3}
|
rows={3}
|
||||||
className="w-full px-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 resize-none mb-4"
|
className="w-full px-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 resize-none mb-4"
|
||||||
/>
|
/>
|
||||||
|
{actionError && (
|
||||||
|
<p className="text-xs text-red-400 bg-red-400/10 border border-red-400/20 rounded-lg px-3 py-2 mb-4">
|
||||||
|
{actionError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<div className="flex gap-2 justify-end">
|
<div className="flex gap-2 justify-end">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setRejectModal(null);
|
setRejectModal(null);
|
||||||
setRejectNotes("");
|
setRejectNotes("");
|
||||||
|
setActionError("");
|
||||||
}}
|
}}
|
||||||
className="px-4 py-2 text-sm text-text-secondary hover:text-text-primary transition-colors"
|
className="px-4 py-2 text-sm text-text-secondary hover:text-text-primary transition-colors"
|
||||||
>
|
>
|
||||||
@@ -807,14 +1034,21 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
|||||||
{actionLoading === rejectModal ? "…" : t("confirmReject")}
|
{actionLoading === rejectModal ? "…" : t("confirmReject")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
</div>
|
)}
|
||||||
)}
|
</Modal>
|
||||||
|
|
||||||
{/* ───── DELETE MODAL ───── */}
|
{/* ───── DELETE MODAL ───── */}
|
||||||
{deleteModal && (
|
<Modal
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
open={!!deleteModal}
|
||||||
<div className="bg-surface-1 border border-border rounded-xl p-6 max-w-md w-full mx-4 shadow-2xl">
|
onClose={() => {
|
||||||
|
setDeleteModal(null);
|
||||||
|
setActionError("");
|
||||||
|
}}
|
||||||
|
ariaLabel={t("deleteTitle")}
|
||||||
|
>
|
||||||
|
{deleteModal && (
|
||||||
|
<>
|
||||||
<h3 className="font-display text-lg font-semibold text-text-primary mb-2">
|
<h3 className="font-display text-lg font-semibold text-text-primary mb-2">
|
||||||
{t("deleteTitle")}
|
{t("deleteTitle")}
|
||||||
</h3>
|
</h3>
|
||||||
@@ -824,9 +1058,17 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
|||||||
<p className="text-xs font-mono text-accent bg-surface-2 border border-border rounded-lg px-3 py-2 mb-4">
|
<p className="text-xs font-mono text-accent bg-surface-2 border border-border rounded-lg px-3 py-2 mb-4">
|
||||||
{deleteModal}
|
{deleteModal}
|
||||||
</p>
|
</p>
|
||||||
|
{actionError && (
|
||||||
|
<p className="text-xs text-red-400 bg-red-400/10 border border-red-400/20 rounded-lg px-3 py-2 mb-4">
|
||||||
|
{actionError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<div className="flex gap-2 justify-end">
|
<div className="flex gap-2 justify-end">
|
||||||
<button
|
<button
|
||||||
onClick={() => setDeleteModal(null)}
|
onClick={() => {
|
||||||
|
setDeleteModal(null);
|
||||||
|
setActionError("");
|
||||||
|
}}
|
||||||
className="px-4 py-2 text-sm text-text-secondary hover:text-text-primary transition-colors"
|
className="px-4 py-2 text-sm text-text-secondary hover:text-text-primary transition-colors"
|
||||||
>
|
>
|
||||||
{t("cancelAction")}
|
{t("cancelAction")}
|
||||||
@@ -839,9 +1081,9 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
|||||||
{actionLoading === deleteModal ? "…" : t("confirmDelete")}
|
{actionLoading === deleteModal ? "…" : t("confirmDelete")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
</div>
|
)}
|
||||||
)}
|
</Modal>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useMemo, useCallback } from "react";
|
import { useState, useMemo, useCallback } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "@/i18n/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { Card, CardHeader } from "@/components/ui/card";
|
import { Card, CardHeader } from "@/components/ui/card";
|
||||||
import type {
|
import type {
|
||||||
@@ -336,6 +336,7 @@ export function CustomInvoiceEditor({ draft, orgBilling }: Props) {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader>{t("editorLinesHeading")}</CardHeader>
|
<CardHeader>{t("editorLinesHeading")}</CardHeader>
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="text-xs text-text-muted text-left">
|
<thead className="text-xs text-text-muted text-left">
|
||||||
<tr>
|
<tr>
|
||||||
@@ -420,6 +421,7 @@ export function CustomInvoiceEditor({ draft, orgBilling }: Props) {
|
|||||||
})}
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
<div className="flex gap-2 mt-3">
|
<div className="flex gap-2 mt-3">
|
||||||
<button
|
<button
|
||||||
onClick={addLine}
|
onClick={addLine}
|
||||||
@@ -525,7 +527,7 @@ export function CustomInvoiceEditor({ draft, orgBilling }: Props) {
|
|||||||
<button
|
<button
|
||||||
onClick={issue}
|
onClick={issue}
|
||||||
disabled={busy !== null || !canIssue}
|
disabled={busy !== null || !canIssue}
|
||||||
className="px-4 py-2 rounded-md bg-accent text-white text-sm disabled:opacity-50"
|
className="px-4 py-2 rounded-md bg-accent text-surface-0 text-sm disabled:opacity-50"
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{busy === "issue" ? t("issuing") : t("editorIssueBtn")}
|
{busy === "issue" ? t("issuing") : t("editorIssueBtn")}
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export function DraftList({ drafts, orgNameMap }: Props) {
|
|||||||
<p className="text-text-secondary mb-4">{t("draftsEmpty")}</p>
|
<p className="text-text-secondary mb-4">{t("draftsEmpty")}</p>
|
||||||
<Link
|
<Link
|
||||||
href="/admin/billing/invoices/new"
|
href="/admin/billing/invoices/new"
|
||||||
className="inline-block px-4 py-2 rounded-md bg-accent text-white text-sm"
|
className="inline-block px-4 py-2 rounded-md bg-accent text-surface-0 text-sm"
|
||||||
>
|
>
|
||||||
{t("newInvoiceBtn")}
|
{t("newInvoiceBtn")}
|
||||||
</Link>
|
</Link>
|
||||||
@@ -71,11 +71,12 @@ export function DraftList({ drafts, orgNameMap }: Props) {
|
|||||||
<div className="flex justify-end p-3 border-b border-border">
|
<div className="flex justify-end p-3 border-b border-border">
|
||||||
<Link
|
<Link
|
||||||
href="/admin/billing/invoices/new"
|
href="/admin/billing/invoices/new"
|
||||||
className="inline-block px-3 py-1.5 rounded-md bg-accent text-white text-sm"
|
className="inline-block px-3 py-1.5 rounded-md bg-accent text-surface-0 text-sm"
|
||||||
>
|
>
|
||||||
{t("newInvoiceBtn")}
|
{t("newInvoiceBtn")}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="text-xs text-text-muted text-left">
|
<thead className="text-xs text-text-muted text-left">
|
||||||
<tr>
|
<tr>
|
||||||
@@ -140,6 +141,7 @@ export function DraftList({ drafts, orgNameMap }: Props) {
|
|||||||
})}
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -216,7 +216,7 @@ export function GenerateForm({ orgs }: Props) {
|
|||||||
<button
|
<button
|
||||||
onClick={commit}
|
onClick={commit}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
className="px-4 py-2 rounded-md bg-accent text-white text-sm disabled:opacity-50"
|
className="px-4 py-2 rounded-md bg-accent text-surface-0 text-sm disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{busy ? t("saving") : t("commitBtn")}
|
{busy ? t("saving") : t("commitBtn")}
|
||||||
</button>
|
</button>
|
||||||
@@ -265,6 +265,7 @@ function DraftPreview({ draft }: { draft: InvoiceDraft }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="text-xs text-text-muted text-left">
|
<thead className="text-xs text-text-muted text-left">
|
||||||
<tr>
|
<tr>
|
||||||
@@ -323,6 +324,7 @@ function DraftPreview({ draft }: { draft: InvoiceDraft }) {
|
|||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 pt-3 border-t border-border space-y-1 text-sm">
|
<div className="mt-4 pt-3 border-t border-border space-y-1 text-sm">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, Fragment } from "react";
|
import { useState, Fragment } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "@/i18n/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { Card, CardHeader } from "@/components/ui/card";
|
import { Card, CardHeader } from "@/components/ui/card";
|
||||||
import type { CreditNote, InvoiceDetail, InvoiceStatus } from "@/types";
|
import type { CreditNote, InvoiceDetail, InvoiceStatus } from "@/types";
|
||||||
@@ -247,7 +247,7 @@ export function InvoiceDetailView({ detail, creditNotes = [] }: Props) {
|
|||||||
<button
|
<button
|
||||||
onClick={() => setNoteOpen(true)}
|
onClick={() => setNoteOpen(true)}
|
||||||
disabled={busyAction !== null}
|
disabled={busyAction !== null}
|
||||||
className="px-4 py-2 rounded-md bg-accent text-white text-sm disabled:opacity-50"
|
className="px-4 py-2 rounded-md bg-accent text-surface-0 text-sm disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{t("markPaidBtn")}
|
{t("markPaidBtn")}
|
||||||
</button>
|
</button>
|
||||||
@@ -264,7 +264,7 @@ export function InvoiceDetailView({ detail, creditNotes = [] }: Props) {
|
|||||||
<button
|
<button
|
||||||
onClick={markPaid}
|
onClick={markPaid}
|
||||||
disabled={busyAction !== null}
|
disabled={busyAction !== null}
|
||||||
className="px-3 py-1.5 rounded-md bg-accent text-white text-sm disabled:opacity-50"
|
className="px-3 py-1.5 rounded-md bg-accent text-surface-0 text-sm disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{busyAction === "mark-paid" ? t("saving") : t("confirm")}
|
{busyAction === "mark-paid" ? t("saving") : t("confirm")}
|
||||||
</button>
|
</button>
|
||||||
@@ -463,6 +463,7 @@ export function InvoiceDetailView({ detail, creditNotes = [] }: Props) {
|
|||||||
{creditNotes.length > 0 && (
|
{creditNotes.length > 0 && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>{t("creditNotesPanelTitle")}</CardHeader>
|
<CardHeader>{t("creditNotesPanelTitle")}</CardHeader>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="text-xs text-text-muted text-left">
|
<thead className="text-xs text-text-muted text-left">
|
||||||
<tr>
|
<tr>
|
||||||
@@ -518,12 +519,14 @@ export function InvoiceDetailView({ detail, creditNotes = [] }: Props) {
|
|||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Lines */}
|
{/* Lines */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>{t("lineItemsTitle")}</CardHeader>
|
<CardHeader>{t("lineItemsTitle")}</CardHeader>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="text-xs text-text-muted text-left">
|
<thead className="text-xs text-text-muted text-left">
|
||||||
<tr>
|
<tr>
|
||||||
@@ -572,6 +575,7 @@ export function InvoiceDetailView({ detail, creditNotes = [] }: Props) {
|
|||||||
})}
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
<div className="mt-4 pt-3 border-t border-border space-y-1 text-sm">
|
<div className="mt-4 pt-3 border-t border-border space-y-1 text-sm">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-text-muted">{t("subtotal")}</span>
|
<span className="text-text-muted">{t("subtotal")}</span>
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ export function InvoicesTable({ initialInvoices }: Props) {
|
|||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href="/admin/billing/invoices/new"
|
href="/admin/billing/invoices/new"
|
||||||
className="px-3 py-1.5 rounded-md bg-accent text-white text-sm"
|
className="px-3 py-1.5 rounded-md bg-accent text-surface-0 text-sm"
|
||||||
>
|
>
|
||||||
+ {t("newInvoiceBtn")}
|
+ {t("newInvoiceBtn")}
|
||||||
</Link>
|
</Link>
|
||||||
@@ -126,6 +126,7 @@ export function InvoicesTable({ initialInvoices }: Props) {
|
|||||||
{t("noInvoicesFound")}
|
{t("noInvoicesFound")}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="text-xs text-text-muted text-left">
|
<thead className="text-xs text-text-muted text-left">
|
||||||
<tr>
|
<tr>
|
||||||
@@ -178,6 +179,7 @@ export function InvoicesTable({ initialInvoices }: Props) {
|
|||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
</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")}
|
||||||
@@ -155,7 +144,7 @@ export function NewInvoiceForm({ orgs }: Props) {
|
|||||||
<button
|
<button
|
||||||
onClick={onSubmit}
|
onClick={onSubmit}
|
||||||
disabled={busy || !orgId || !selected?.hasBillingAddress}
|
disabled={busy || !orgId || !selected?.hasBillingAddress}
|
||||||
className="px-4 py-2 rounded-md bg-accent text-white text-sm disabled:opacity-50"
|
className="px-4 py-2 rounded-md bg-accent text-surface-0 text-sm disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{busy ? t("creating") : t("newInvoiceContinueBtn")}
|
{busy ? t("creating") : t("newInvoiceContinueBtn")}
|
||||||
</button>
|
</button>
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ export function OrgPaymentModeList({ orgs }: Props) {
|
|||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="text-xs text-text-muted text-left">
|
<thead className="text-xs text-text-muted text-left">
|
||||||
<tr>
|
<tr>
|
||||||
@@ -153,6 +154,7 @@ export function OrgPaymentModeList({ orgs }: Props) {
|
|||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -236,7 +236,7 @@ export function PricingEditor({
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={savingPricing}
|
disabled={savingPricing}
|
||||||
className="px-4 py-2 rounded-md bg-accent text-white text-sm disabled:opacity-50"
|
className="px-4 py-2 rounded-md bg-accent text-surface-0 text-sm disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{savingPricing ? t("saving") : t("save")}
|
{savingPricing ? t("saving") : t("save")}
|
||||||
</button>
|
</button>
|
||||||
@@ -255,6 +255,7 @@ export function PricingEditor({
|
|||||||
<p className="text-sm text-text-muted mb-4">{t("skillPricingDesc")}</p>
|
<p className="text-sm text-text-muted mb-4">{t("skillPricingDesc")}</p>
|
||||||
|
|
||||||
{initialSkillPricing.length > 0 ? (
|
{initialSkillPricing.length > 0 ? (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm mb-6">
|
<table className="w-full text-sm mb-6">
|
||||||
<thead className="text-xs text-text-muted text-left">
|
<thead className="text-xs text-text-muted text-left">
|
||||||
<tr>
|
<tr>
|
||||||
@@ -319,6 +320,7 @@ export function PricingEditor({
|
|||||||
})}
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-text-muted italic mb-4">{t("noSkillsPriced")}</p>
|
<p className="text-sm text-text-muted italic mb-4">{t("noSkillsPriced")}</p>
|
||||||
)}
|
)}
|
||||||
@@ -401,7 +403,7 @@ export function PricingEditor({
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={addingSkill || !newSkillId}
|
disabled={addingSkill || !newSkillId}
|
||||||
className="px-4 py-2 rounded-md bg-accent text-white text-sm disabled:opacity-50"
|
className="px-4 py-2 rounded-md bg-accent text-surface-0 text-sm disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{addingSkill ? t("saving") : t("add")}
|
{addingSkill ? t("saving") : t("add")}
|
||||||
</button>
|
</button>
|
||||||
@@ -473,7 +475,7 @@ function InlinePriceEditor({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
className="text-xs px-2 py-1 bg-accent text-white rounded"
|
className="text-xs px-2 py-1 bg-accent text-surface-0 rounded"
|
||||||
>
|
>
|
||||||
{busy ? "…" : "✓"}
|
{busy ? "…" : "✓"}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -147,7 +147,7 @@ export function CronControls({ initialRecent, initialLastSuccess }: Props) {
|
|||||||
<button
|
<button
|
||||||
onClick={triggerIssue}
|
onClick={triggerIssue}
|
||||||
disabled={busy !== null}
|
disabled={busy !== null}
|
||||||
className="px-4 py-2 rounded-md bg-accent text-white text-sm font-medium hover:bg-accent-dim transition-colors disabled:opacity-50 cursor-pointer"
|
className="px-4 py-2 rounded-md bg-accent text-surface-0 text-sm font-medium hover:bg-accent-dim transition-colors disabled:opacity-50 cursor-pointer"
|
||||||
>
|
>
|
||||||
{busy === "issue" ? t("running") : t("runIssueNow")}
|
{busy === "issue" ? t("running") : t("runIssueNow")}
|
||||||
</button>
|
</button>
|
||||||
@@ -165,7 +165,7 @@ export function CronControls({ initialRecent, initialLastSuccess }: Props) {
|
|||||||
<button
|
<button
|
||||||
onClick={triggerReminders}
|
onClick={triggerReminders}
|
||||||
disabled={busy !== null}
|
disabled={busy !== null}
|
||||||
className="px-4 py-2 rounded-md bg-accent text-white text-sm font-medium hover:bg-accent-dim transition-colors disabled:opacity-50 cursor-pointer"
|
className="px-4 py-2 rounded-md bg-accent text-surface-0 text-sm font-medium hover:bg-accent-dim transition-colors disabled:opacity-50 cursor-pointer"
|
||||||
>
|
>
|
||||||
{busy === "reminders" ? t("running") : t("runRemindersNow")}
|
{busy === "reminders" ? t("running") : t("runRemindersNow")}
|
||||||
</button>
|
</button>
|
||||||
@@ -194,6 +194,7 @@ export function CronControls({ initialRecent, initialLastSuccess }: Props) {
|
|||||||
{t("noRunsYet")}
|
{t("noRunsYet")}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="text-xs text-text-muted text-left">
|
<thead className="text-xs text-text-muted text-left">
|
||||||
<tr>
|
<tr>
|
||||||
@@ -241,6 +242,7 @@ export function CronControls({ initialRecent, initialLastSuccess }: Props) {
|
|||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ export function OpenClawAdminPanel({ initialDefaults, tenants }: Props) {
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={savingDefault}
|
disabled={savingDefault}
|
||||||
className="text-sm font-medium px-4 py-2 rounded-lg bg-accent text-white hover:bg-accent/90 transition-colors disabled:opacity-50"
|
className="text-sm font-medium px-4 py-2 rounded-lg bg-accent text-surface-0 hover:bg-accent/90 transition-colors disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{savingDefault ? tCommon("loading") : t("saveDefault")}
|
{savingDefault ? tCommon("loading") : t("saveDefault")}
|
||||||
</button>
|
</button>
|
||||||
@@ -265,7 +265,7 @@ function TenantOverrideRow({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => submit(false)}
|
onClick={() => submit(false)}
|
||||||
disabled={saving || !tag.trim()}
|
disabled={saving || !tag.trim()}
|
||||||
className="text-xs px-3 py-1.5 rounded-lg bg-accent text-white hover:bg-accent/90 transition-colors disabled:opacity-50"
|
className="text-xs px-3 py-1.5 rounded-lg bg-accent text-surface-0 hover:bg-accent/90 transition-colors disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{saving ? tCommon("loading") : t("saveOverride")}
|
{saving ? tCommon("loading") : t("saveOverride")}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ export function PendingSkillRequests({ initialRows }: Props) {
|
|||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="text-xs text-text-muted text-left">
|
<thead className="text-xs text-text-muted text-left">
|
||||||
<tr>
|
<tr>
|
||||||
@@ -146,7 +147,7 @@ export function PendingSkillRequests({ initialRows }: Props) {
|
|||||||
<button
|
<button
|
||||||
onClick={() => approve(row.id)}
|
onClick={() => approve(row.id)}
|
||||||
disabled={busyId !== null}
|
disabled={busyId !== null}
|
||||||
className="text-xs px-3 py-1.5 rounded-md bg-accent text-white disabled:opacity-50"
|
className="text-xs px-3 py-1.5 rounded-md bg-accent text-surface-0 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{busyId === row.id ? t("working") : t("approveBtn")}
|
{busyId === row.id ? t("working") : t("approveBtn")}
|
||||||
</button>
|
</button>
|
||||||
@@ -199,6 +200,7 @@ export function PendingSkillRequests({ initialRows }: Props) {
|
|||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -36,6 +36,7 @@ export function CustomerCreditNoteList({ creditNotes }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="text-xs text-text-muted text-left">
|
<thead className="text-xs text-text-muted text-left">
|
||||||
<tr>
|
<tr>
|
||||||
@@ -96,6 +97,7 @@ export function CustomerCreditNoteList({ creditNotes }: Props) {
|
|||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ export function CustomerInvoiceDetail({ invoice, lines }: Props) {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="text-xs text-text-muted text-left">
|
<thead className="text-xs text-text-muted text-left">
|
||||||
<tr>
|
<tr>
|
||||||
@@ -160,6 +161,7 @@ export function CustomerInvoiceDetail({ invoice, lines }: Props) {
|
|||||||
</tr>
|
</tr>
|
||||||
</tfoot>
|
</tfoot>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ export function CustomerInvoiceList({ invoices }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="text-xs text-text-muted text-left">
|
<thead className="text-xs text-text-muted text-left">
|
||||||
<tr>
|
<tr>
|
||||||
@@ -104,6 +105,7 @@ export function CustomerInvoiceList({ invoices }: Props) {
|
|||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export function PayInvoiceButton({ invoiceNumber }: Props) {
|
|||||||
<button
|
<button
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
className="px-4 py-2 rounded-md bg-accent text-white text-sm font-medium hover:bg-accent-dim transition-colors disabled:opacity-50 cursor-pointer"
|
className="px-4 py-2 rounded-md bg-accent text-surface-0 text-sm font-medium hover:bg-accent-dim transition-colors disabled:opacity-50 cursor-pointer"
|
||||||
>
|
>
|
||||||
{busy ? t("redirectingToStripe") : t("payWithCard")}
|
{busy ? t("redirectingToStripe") : t("payWithCard")}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ export function RunningTotalWidget({ isOwner }: Props) {
|
|||||||
{noConfig && isOwner && (
|
{noConfig && isOwner && (
|
||||||
<Link
|
<Link
|
||||||
href="/settings/billing"
|
href="/settings/billing"
|
||||||
className="inline-block mt-2 px-4 py-2 rounded-md bg-accent text-white text-sm font-medium hover:bg-accent-dim transition-colors"
|
className="inline-block mt-2 px-4 py-2 rounded-md bg-accent text-surface-0 text-sm font-medium hover:bg-accent-dim transition-colors"
|
||||||
>
|
>
|
||||||
{t("configureBillingCta")}
|
{t("configureBillingCta")}
|
||||||
</Link>
|
</Link>
|
||||||
@@ -160,6 +160,7 @@ export function RunningTotalWidget({ isOwner }: Props) {
|
|||||||
<summary className="cursor-pointer text-text-muted hover:text-text-secondary">
|
<summary className="cursor-pointer text-text-muted hover:text-text-secondary">
|
||||||
{t("breakdownToggle", { count: draft.lines.length })}
|
{t("breakdownToggle", { count: draft.lines.length })}
|
||||||
</summary>
|
</summary>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full mt-2 text-xs">
|
<table className="w-full mt-2 text-xs">
|
||||||
<tbody>
|
<tbody>
|
||||||
{draft.lines.map((ln, i) => (
|
{draft.lines.map((ln, i) => (
|
||||||
@@ -188,6 +189,7 @@ export function RunningTotalWidget({ isOwner }: Props) {
|
|||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
</details>
|
</details>
|
||||||
)}
|
)}
|
||||||
<p className="text-[10px] text-text-muted mt-3 italic">{t("draftNote")}</p>
|
<p className="text-[10px] text-text-muted mt-3 italic">{t("draftNote")}</p>
|
||||||
|
|||||||
@@ -328,7 +328,7 @@ export function ChannelUsers({
|
|||||||
<button
|
<button
|
||||||
onClick={() => handleAdd(channel)}
|
onClick={() => handleAdd(channel)}
|
||||||
disabled={saving || !inputValues[channel]?.trim()}
|
disabled={saving || !inputValues[channel]?.trim()}
|
||||||
className="px-4 py-2 text-sm font-medium bg-accent text-white rounded-lg hover:bg-accent-dim transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
className="px-4 py-2 text-sm font-medium bg-accent text-surface-0 rounded-lg hover:bg-accent-dim transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
{saving ? "…" : t("add")}
|
{saving ? "…" : t("add")}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -263,7 +263,7 @@ export function BudgetEditableCard({
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
className="text-sm px-4 py-2 rounded-lg bg-accent text-white hover:bg-accent/90 transition-colors disabled:opacity-50"
|
className="text-sm px-4 py-2 rounded-lg bg-accent text-surface-0 hover:bg-accent/90 transition-colors disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{saving ? tCommon("loading") : tCommon("save")}
|
{saving ? tCommon("loading") : tCommon("save")}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations, useLocale } from "next-intl";
|
||||||
import { useEffect, useState, useCallback } from "react";
|
import { useEffect, useState, useCallback } from "react";
|
||||||
import { BudgetEditableCard } from "@/components/dashboard/budget-editable-card";
|
import { BudgetEditableCard } from "@/components/dashboard/budget-editable-card";
|
||||||
|
|
||||||
@@ -84,42 +84,149 @@ function formatMonth(month: string, locale: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function UsageChart({ data }: { data: DailyUsage[] }) {
|
function UsageChart({ data }: { data: DailyUsage[] }) {
|
||||||
|
const t = useTranslations("usage");
|
||||||
|
const locale = useLocale();
|
||||||
|
// Which day's detail is shown in the readout. Defaults to the most
|
||||||
|
// recent day; hover (mouse), tap (touch) or focus (keyboard) all
|
||||||
|
// update it. The previous version put per-day numbers only in SVG
|
||||||
|
// <title> hover tooltips, which are unreachable on touch devices and
|
||||||
|
// invisible to keyboard users — this readout fixes both.
|
||||||
|
const [selected, setSelected] = useState<number | null>(null);
|
||||||
|
|
||||||
if (!data.length) return null;
|
if (!data.length) return null;
|
||||||
const maxTokens = Math.max(...data.map((d) => d.inputTokens + d.outputTokens), 1);
|
|
||||||
|
const maxTokens = Math.max(
|
||||||
|
...data.map((d) => d.inputTokens + d.outputTokens),
|
||||||
|
1
|
||||||
|
);
|
||||||
const barW = Math.max(4, Math.floor(600 / data.length) - 2);
|
const barW = Math.max(4, Math.floor(600 / data.length) - 2);
|
||||||
const h = 120;
|
const h = 120;
|
||||||
|
|
||||||
|
const activeIndex = selected ?? data.length - 1;
|
||||||
|
const active = data[activeIndex];
|
||||||
|
|
||||||
|
const dayLabel = (iso: string) => {
|
||||||
|
const [y, m, dd] = iso.split("-").map(Number);
|
||||||
|
return new Date(y, m - 1, dd).toLocaleDateString(locale, {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const barAria = (d: DailyUsage) =>
|
||||||
|
`${dayLabel(d.date)}: ${fmt(d.inputTokens)} ${t("inputTokens")}, ${fmt(
|
||||||
|
d.outputTokens
|
||||||
|
)} ${t("outputTokens")}, ${chf(d.spend)}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-x-auto">
|
<div>
|
||||||
<svg
|
{/* Readout — the touch/keyboard-accessible equivalent of the old
|
||||||
viewBox={`0 0 ${Math.max(data.length * (barW + 2), 600)} ${h + 24}`}
|
hover-only tooltip. Always reflects the active day. */}
|
||||||
className="w-full h-36"
|
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1 mb-2 text-xs">
|
||||||
preserveAspectRatio="xMinYMid meet"
|
<span className="font-medium text-text-primary">
|
||||||
>
|
{dayLabel(active.date)}
|
||||||
{data.map((d, i) => {
|
</span>
|
||||||
const total = d.inputTokens + d.outputTokens;
|
<span className="text-text-secondary tabular-nums">
|
||||||
const totalH = (total / maxTokens) * h;
|
{fmt(active.inputTokens)} {t("inputTokens")}
|
||||||
const inputH = (d.inputTokens / maxTokens) * h;
|
</span>
|
||||||
const x = i * (barW + 2);
|
<span className="text-text-secondary tabular-nums">
|
||||||
return (
|
{fmt(active.outputTokens)} {t("outputTokens")}
|
||||||
<g key={d.date}>
|
</span>
|
||||||
<title>{d.date}: {fmt(d.inputTokens)} in / {fmt(d.outputTokens)} out — {chf(d.spend)}</title>
|
<span className="text-accent tabular-nums">{chf(active.spend)}</span>
|
||||||
<rect x={x} y={h - totalH} width={barW} height={totalH - inputH} rx={1} fill="var(--color-accent)" opacity={0.3} />
|
</div>
|
||||||
<rect x={x} y={h - inputH} width={barW} height={inputH} rx={1} fill="var(--color-accent)" opacity={0.7} />
|
|
||||||
{i % 7 === 0 && (
|
<div className="overflow-x-auto">
|
||||||
<text x={x + barW / 2} y={h + 14} textAnchor="middle" fill="var(--color-text-muted)" fontSize="8">{d.date.slice(8)}</text>
|
<svg
|
||||||
)}
|
viewBox={`0 0 ${Math.max(data.length * (barW + 2), 600)} ${h + 24}`}
|
||||||
</g>
|
className="w-full h-36"
|
||||||
);
|
preserveAspectRatio="xMinYMid meet"
|
||||||
})}
|
role="group"
|
||||||
</svg>
|
aria-label={t("dailyBreakdown")}
|
||||||
|
>
|
||||||
|
{data.map((d, i) => {
|
||||||
|
const total = d.inputTokens + d.outputTokens;
|
||||||
|
const totalH = (total / maxTokens) * h;
|
||||||
|
const inputH = (d.inputTokens / maxTokens) * h;
|
||||||
|
const x = i * (barW + 2);
|
||||||
|
const isActive = i === activeIndex;
|
||||||
|
return (
|
||||||
|
<g
|
||||||
|
key={d.date}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label={barAria(d)}
|
||||||
|
aria-pressed={isActive}
|
||||||
|
className="cursor-pointer focus:outline-none"
|
||||||
|
onClick={() => setSelected(i)}
|
||||||
|
onMouseEnter={() => setSelected(i)}
|
||||||
|
onFocus={() => setSelected(i)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
setSelected(i);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<title>{barAria(d)}</title>
|
||||||
|
{/* Full-height transparent hit area so thin bars stay
|
||||||
|
easy to tap on touch screens. */}
|
||||||
|
<rect x={x} y={0} width={barW} height={h} fill="transparent" />
|
||||||
|
<rect
|
||||||
|
x={x}
|
||||||
|
y={h - totalH}
|
||||||
|
width={barW}
|
||||||
|
height={Math.max(0, totalH - inputH)}
|
||||||
|
rx={1}
|
||||||
|
fill="var(--color-accent)"
|
||||||
|
opacity={isActive ? 0.5 : 0.3}
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
x={x}
|
||||||
|
y={h - inputH}
|
||||||
|
width={barW}
|
||||||
|
height={inputH}
|
||||||
|
rx={1}
|
||||||
|
fill="var(--color-accent)"
|
||||||
|
opacity={isActive ? 1 : 0.7}
|
||||||
|
/>
|
||||||
|
{isActive && (
|
||||||
|
<rect
|
||||||
|
x={x - 1}
|
||||||
|
y={Math.max(0, h - totalH) - 1}
|
||||||
|
width={barW + 2}
|
||||||
|
height={Math.max(2, totalH) + 1}
|
||||||
|
rx={1.5}
|
||||||
|
fill="none"
|
||||||
|
stroke="var(--color-accent)"
|
||||||
|
strokeWidth={1}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{i % 7 === 0 && (
|
||||||
|
<text
|
||||||
|
x={x + barW / 2}
|
||||||
|
y={h + 14}
|
||||||
|
textAnchor="middle"
|
||||||
|
fill="var(--color-text-muted)"
|
||||||
|
fontSize="8"
|
||||||
|
>
|
||||||
|
{d.date.slice(8)}
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
<div className="flex items-center gap-4 text-xs text-text-muted mt-1">
|
<div className="flex items-center gap-4 text-xs text-text-muted mt-1">
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<span className="inline-block h-2 w-2 rounded-sm bg-accent opacity-70" /> Input
|
<span className="inline-block h-2 w-2 rounded-sm bg-accent opacity-70" />{" "}
|
||||||
|
{t("legendInput")}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<span className="inline-block h-2 w-2 rounded-sm bg-accent opacity-30" /> Output
|
<span className="inline-block h-2 w-2 rounded-sm bg-accent opacity-30" />{" "}
|
||||||
|
{t("legendOutput")}
|
||||||
</span>
|
</span>
|
||||||
|
<span className="ml-auto text-text-muted/70">{t("chartHint")}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -161,6 +268,7 @@ export function UsageDisplay({
|
|||||||
canEditBudget?: boolean;
|
canEditBudget?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const t = useTranslations("usage");
|
const t = useTranslations("usage");
|
||||||
|
const locale = useLocale();
|
||||||
const [month, setMonth] = useState(getCurrentMonth);
|
const [month, setMonth] = useState(getCurrentMonth);
|
||||||
const [data, setData] = useState<UsageData | null>(null);
|
const [data, setData] = useState<UsageData | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -202,7 +310,7 @@ export function UsageDisplay({
|
|||||||
←
|
←
|
||||||
</button>
|
</button>
|
||||||
<span className="font-display text-sm font-medium text-text-primary">
|
<span className="font-display text-sm font-medium text-text-primary">
|
||||||
{formatMonth(month, "en")}
|
{formatMonth(month, locale)}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => setMonth((m) => shiftMonth(m, 1))}
|
onClick={() => setMonth((m) => shiftMonth(m, 1))}
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { signOut, useSession } from "next-auth/react";
|
import { signOut, useSession } from "next-auth/react";
|
||||||
import { usePathname } from "@/i18n/navigation";
|
import { usePathname } from "@/i18n/navigation";
|
||||||
import { Link } from "@/i18n/navigation";
|
import { Link } from "@/i18n/navigation";
|
||||||
import { SessionProvider } from "next-auth/react";
|
import { SessionProvider } from "next-auth/react";
|
||||||
|
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");
|
||||||
@@ -13,6 +16,15 @@ function NavBar() {
|
|||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const user = (session as any)?.platformUser;
|
const user = (session as any)?.platformUser;
|
||||||
|
|
||||||
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
|
|
||||||
|
// Close the mobile menu on any navigation. Without this the panel
|
||||||
|
// would stay open across route changes (the component doesn't
|
||||||
|
// unmount — it lives in the layout).
|
||||||
|
useEffect(() => {
|
||||||
|
setMobileOpen(false);
|
||||||
|
}, [pathname]);
|
||||||
|
|
||||||
// Hide the nav entirely on auth-only routes. These pages have no
|
// Hide the nav entirely on auth-only routes. These pages have no
|
||||||
// session yet — showing "Dashboard" / "Sign Out" is misleading at
|
// session yet — showing "Dashboard" / "Sign Out" is misleading at
|
||||||
// best (the buttons would 401 or redirect-loop). Keep this list
|
// best (the buttons would 401 or redirect-loop). Keep this list
|
||||||
@@ -21,17 +33,55 @@ function NavBar() {
|
|||||||
const isAuthRoute = pathname === "/login" || pathname === "/register";
|
const isAuthRoute = pathname === "/login" || pathname === "/register";
|
||||||
if (isAuthRoute) return null;
|
if (isAuthRoute) return null;
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Visibility gates — computed once, shared by the desktop nav and the
|
||||||
|
// mobile panel so the two can never diverge.
|
||||||
|
//
|
||||||
|
// - team: owner+platform only AND not a personal account (Bug 8 —
|
||||||
|
// personal accounts have no team). Matches `canMutate` /
|
||||||
|
// `user.isPersonal === false` server-side.
|
||||||
|
// - settings: anyone who can mutate org-level state (owners + platform).
|
||||||
|
// `user`-role customers don't see it (canMutate is false).
|
||||||
|
// - billing / support: any signed-in user (org-scoped server-side).
|
||||||
|
// - admin: platform only.
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
const isOwner =
|
||||||
|
user && Array.isArray(user.roles) && user.roles.includes("owner");
|
||||||
|
const showTeam = !!user && !user.isPersonal && (user.isPlatform || isOwner);
|
||||||
|
const showSettings = !!user && (user.isPlatform || isOwner);
|
||||||
|
const showBilling = !!user;
|
||||||
|
const showSupport = !!user;
|
||||||
|
const showAdmin = !!user?.isPlatform;
|
||||||
|
|
||||||
|
// Active-state helper. Dashboard/Admin previously used exact `===`,
|
||||||
|
// so sub-routes (/dashboard/new, /admin/billing, …) showed no active
|
||||||
|
// item. startsWith keeps the parent lit on its children too.
|
||||||
|
const isActive = (href: string) =>
|
||||||
|
pathname === href || pathname.startsWith(`${href}/`);
|
||||||
|
|
||||||
|
const links = [
|
||||||
|
{ href: "/dashboard", label: t("dashboard"), show: !!user },
|
||||||
|
{ href: "/team", label: t("team"), show: showTeam },
|
||||||
|
{ href: "/settings", label: t("settings"), show: showSettings },
|
||||||
|
{ href: "/billing", label: t("billing"), show: showBilling },
|
||||||
|
{ href: "/support", label: t("support"), show: showSupport },
|
||||||
|
{ href: "/admin", label: t("admin"), show: showAdmin },
|
||||||
|
].filter((l) => l.show);
|
||||||
|
|
||||||
|
const displayName = user
|
||||||
|
? user.isPersonal
|
||||||
|
? user.name || (user.email ? user.email.split("@")[0] : user.orgName)
|
||||||
|
: user.orgName
|
||||||
|
: "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-0 z-50 border-b border-border bg-surface-1/80 backdrop-blur-md">
|
<header className="sticky top-0 z-50 border-b border-border bg-surface-1/80 backdrop-blur-md">
|
||||||
<div className="mx-auto flex h-14 max-w-6xl items-center justify-between px-5">
|
<div className="mx-auto flex h-14 max-w-6xl items-center justify-between px-5">
|
||||||
{/* 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>
|
||||||
@@ -40,98 +90,96 @@ function NavBar() {
|
|||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* Nav links */}
|
{/* Desktop nav links */}
|
||||||
<nav className="hidden sm:flex items-center gap-1 ml-2">
|
<nav className="hidden sm:flex items-center gap-1 ml-2">
|
||||||
<NavLink href="/dashboard" active={pathname === "/dashboard"}>
|
{links.map((l) => (
|
||||||
{t("dashboard")}
|
<NavLink key={l.href} href={l.href} active={isActive(l.href)}>
|
||||||
</NavLink>
|
{l.label}
|
||||||
{/* Slice 7: /team is owner+platform only AND personal
|
|
||||||
accounts are excluded — they have no team to manage
|
|
||||||
(Bug 8). Match server-side gates (`canMutate`,
|
|
||||||
`user.isPersonal === false`). The roles array carries
|
|
||||||
either "owner" or "user" for customer sessions;
|
|
||||||
isPlatform covers the platform side. */}
|
|
||||||
{user &&
|
|
||||||
!user.isPersonal &&
|
|
||||||
(user.isPlatform ||
|
|
||||||
(Array.isArray(user.roles) && user.roles.includes("owner"))) && (
|
|
||||||
<NavLink href="/team" active={pathname === "/team"}>
|
|
||||||
{t("team")}
|
|
||||||
</NavLink>
|
|
||||||
)}
|
|
||||||
{/* Bug 35: /settings is shown to anyone who can mutate org-level
|
|
||||||
state — owners and platform admins. Personal accounts also
|
|
||||||
see it; their billing page is optional but the entry point
|
|
||||||
exists for consistency. `user`-role customers don't see it
|
|
||||||
(canMutate is false). */}
|
|
||||||
{user &&
|
|
||||||
(user.isPlatform ||
|
|
||||||
(Array.isArray(user.roles) && user.roles.includes("owner"))) && (
|
|
||||||
<NavLink
|
|
||||||
href="/settings"
|
|
||||||
active={pathname.startsWith("/settings")}
|
|
||||||
>
|
|
||||||
{t("settings")}
|
|
||||||
</NavLink>
|
|
||||||
)}
|
|
||||||
{/* Phase 3: Billing visible to anyone signed in. The
|
|
||||||
page is org-scoped server-side — non-owner members
|
|
||||||
see the same invoice history their owner does, but
|
|
||||||
actions like "configure billing details" are gated
|
|
||||||
separately on the settings page. Personal accounts
|
|
||||||
see their own (single-tenant) invoices. */}
|
|
||||||
{user && (
|
|
||||||
<NavLink
|
|
||||||
href="/billing"
|
|
||||||
active={pathname.startsWith("/billing")}
|
|
||||||
>
|
|
||||||
{t("billing")}
|
|
||||||
</NavLink>
|
</NavLink>
|
||||||
)}
|
))}
|
||||||
{/* Feature 5: Support is available to every signed-in
|
|
||||||
user. Customers see their own tickets only; platform
|
|
||||||
admins see the queue. */}
|
|
||||||
{user && (
|
|
||||||
<NavLink
|
|
||||||
href="/support"
|
|
||||||
active={pathname.startsWith("/support")}
|
|
||||||
>
|
|
||||||
{t("support")}
|
|
||||||
</NavLink>
|
|
||||||
)}
|
|
||||||
{user?.isPlatform && (
|
|
||||||
<NavLink href="/admin" active={pathname === "/admin"}>
|
|
||||||
{t("admin")}
|
|
||||||
</NavLink>
|
|
||||||
)}
|
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right side */}
|
{/* Right side */}
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
{user && (
|
{user && (
|
||||||
// For personal accounts the orgName is opaque
|
|
||||||
// ("personal-3f2a8b1c") or a synthetic legacy
|
|
||||||
// "Name (Personal)" — neither is what we want in the nav.
|
|
||||||
// Show the user's display name instead. The detection logic
|
|
||||||
// and fallback chain live in `lib/personal-org.ts`; keeping
|
|
||||||
// a thin inline branch here avoids importing a server-only
|
|
||||||
// helper into a client component.
|
|
||||||
<span className="hidden md:inline text-xs text-text-secondary font-mono">
|
<span className="hidden md:inline text-xs text-text-secondary font-mono">
|
||||||
{user.isPersonal
|
{displayName}
|
||||||
? user.name || (user.email ? user.email.split("@")[0] : user.orgName)
|
|
||||||
: user.orgName}
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<LanguageSwitcher />
|
<LanguageSwitcher />
|
||||||
<button
|
<button
|
||||||
onClick={() => signOut({ callbackUrl: "/login" })}
|
onClick={() => signOut({ callbackUrl: "/login" })}
|
||||||
className="text-xs font-medium text-text-secondary hover:text-error transition-colors cursor-pointer"
|
className="hidden sm:inline text-xs font-medium text-text-secondary hover:text-error transition-colors cursor-pointer"
|
||||||
>
|
>
|
||||||
{t("logout")}
|
{t("logout")}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* Mobile menu toggle — only shown below the `sm` breakpoint,
|
||||||
|
where the desktop nav and logout button are hidden. */}
|
||||||
|
{user && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMobileOpen((v) => !v)}
|
||||||
|
aria-expanded={mobileOpen}
|
||||||
|
aria-controls="mobile-nav"
|
||||||
|
aria-label={t("menu")}
|
||||||
|
className="sm:hidden inline-flex items-center justify-center h-8 w-8 -mr-1 rounded-md text-text-secondary hover:text-text-primary hover:bg-surface-2 transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="h-5 w-5"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.75"
|
||||||
|
strokeLinecap="round"
|
||||||
|
>
|
||||||
|
{mobileOpen ? (
|
||||||
|
<path d="M6 6l12 12M18 6L6 18" />
|
||||||
|
) : (
|
||||||
|
<path d="M4 7h16M4 12h16M4 17h16" />
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile panel */}
|
||||||
|
{user && mobileOpen && (
|
||||||
|
<nav
|
||||||
|
id="mobile-nav"
|
||||||
|
className="sm:hidden border-t border-border bg-surface-1 px-3 py-3"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{links.map((l) => (
|
||||||
|
<Link
|
||||||
|
key={l.href}
|
||||||
|
href={l.href}
|
||||||
|
className={`px-3 py-2.5 rounded-md text-sm font-medium transition-colors ${
|
||||||
|
isActive(l.href)
|
||||||
|
? "bg-surface-3 text-text-primary"
|
||||||
|
: "text-text-secondary hover:text-text-primary hover:bg-surface-2"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{l.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 pt-3 border-t border-border flex items-center justify-between px-3">
|
||||||
|
<span className="text-xs text-text-secondary font-mono truncate">
|
||||||
|
{displayName}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => signOut({ callbackUrl: "/login" })}
|
||||||
|
className="text-xs font-medium text-text-secondary hover:text-error transition-colors cursor-pointer shrink-0 ml-3"
|
||||||
|
>
|
||||||
|
{t("logout")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
)}
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -162,9 +210,19 @@ function NavLink({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NavShell({ children }: { children: React.ReactNode }) {
|
export function NavShell({
|
||||||
|
children,
|
||||||
|
session,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
// Server-resolved session passed down from the locale layout. Seeding
|
||||||
|
// SessionProvider with it means useSession() is populated on the first
|
||||||
|
// client render, so the nav links render immediately instead of
|
||||||
|
// popping in after the client-side session fetch (CLS / flash).
|
||||||
|
session: Session | null;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<SessionProvider>
|
<SessionProvider session={session}>
|
||||||
<NavBar />
|
<NavBar />
|
||||||
<main className="mx-auto max-w-6xl px-5 py-8">{children}</main>
|
<main className="mx-auto max-w-6xl px-5 py-8">{children}</main>
|
||||||
</SessionProvider>
|
</SessionProvider>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "@/i18n/navigation";
|
||||||
import { OnboardingWizard } from "./wizard";
|
import { OnboardingWizard } from "./wizard";
|
||||||
import type { OrgBilling } from "@/types";
|
import type { OrgBilling } from "@/types";
|
||||||
|
|
||||||
@@ -31,6 +31,12 @@ interface OnboardingFlowProps {
|
|||||||
* step. Forwarded straight to the wizard.
|
* step. Forwarded straight to the wizard.
|
||||||
*/
|
*/
|
||||||
setupFeeChf?: number | null;
|
setupFeeChf?: number | null;
|
||||||
|
/**
|
||||||
|
* Recurring per-tenant monthly fee (net CHF). Forwarded to the
|
||||||
|
* wizard's review-step cost summary so the customer sees the ongoing
|
||||||
|
* commitment, not just the one-time setup fee.
|
||||||
|
*/
|
||||||
|
monthlyFeeChf?: number | null;
|
||||||
/**
|
/**
|
||||||
* Bug 6: when present, the wizard is rendered in edit mode against
|
* Bug 6: when present, the wizard is rendered in edit mode against
|
||||||
* the given pending request. See `OnboardingWizard` for the full
|
* the given pending request. See `OnboardingWizard` for the full
|
||||||
@@ -59,6 +65,7 @@ export function OnboardingFlow({
|
|||||||
hasOrgBilling,
|
hasOrgBilling,
|
||||||
existingOrgBilling,
|
existingOrgBilling,
|
||||||
setupFeeChf,
|
setupFeeChf,
|
||||||
|
monthlyFeeChf,
|
||||||
editingRequest,
|
editingRequest,
|
||||||
}: OnboardingFlowProps) {
|
}: OnboardingFlowProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -71,6 +78,7 @@ export function OnboardingFlow({
|
|||||||
hasOrgBilling={hasOrgBilling}
|
hasOrgBilling={hasOrgBilling}
|
||||||
existingOrgBilling={existingOrgBilling}
|
existingOrgBilling={existingOrgBilling}
|
||||||
setupFeeChf={setupFeeChf}
|
setupFeeChf={setupFeeChf}
|
||||||
|
monthlyFeeChf={monthlyFeeChf}
|
||||||
editingRequest={editingRequest}
|
editingRequest={editingRequest}
|
||||||
onComplete={() => {
|
onComplete={() => {
|
||||||
// Navigate back to /dashboard and re-fetch on the server. The
|
// Navigate back to /dashboard and re-fetch on the server. The
|
||||||
|
|||||||
@@ -432,25 +432,35 @@ export function ProvisioningStatus({ requestId, canAct }: Props) {
|
|||||||
<span className="text-xs text-text-muted">{t("phase")}</span>
|
<span className="text-xs text-text-muted">{t("phase")}</span>
|
||||||
<StatusBadge phase={phase} />
|
<StatusBadge phase={phase} />
|
||||||
</div>
|
</div>
|
||||||
{conditions.map((c, i) => (
|
{/* Setup progress. The operator reports readiness as a list of
|
||||||
<div
|
internal K8s conditions (OpenBao policy, LiteLLM key, network
|
||||||
key={i}
|
policy, …) — meaningful to operators, jargon to customers.
|
||||||
className="flex items-center justify-between bg-surface-2 border border-border rounded-lg px-4 py-2"
|
We surface the *shape* of that progress (how many steps are
|
||||||
>
|
done) without leaking the internal names. */}
|
||||||
<span className="text-xs text-text-muted">{c.type}</span>
|
{conditions.length > 0 &&
|
||||||
<span
|
(() => {
|
||||||
className={`text-xs font-mono ${
|
const done = conditions.filter((c) => c.status === "True").length;
|
||||||
c.status === "True"
|
const total = conditions.length;
|
||||||
? "text-emerald-400"
|
const pct = Math.round((done / total) * 100);
|
||||||
: c.status === "False"
|
return (
|
||||||
? "text-red-400"
|
<div className="bg-surface-2 border border-border rounded-lg px-4 py-3">
|
||||||
: "text-text-muted"
|
<div className="flex items-center justify-between mb-2">
|
||||||
}`}
|
<span className="text-xs text-text-muted">
|
||||||
>
|
{t("setupProgress")}
|
||||||
{c.reason || c.status}
|
</span>
|
||||||
</span>
|
<span className="text-xs font-medium text-text-secondary tabular-nums">
|
||||||
</div>
|
{t("setupStepsComplete", { done, total })}
|
||||||
))}
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-1.5 w-full rounded-full bg-surface-3 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-accent transition-all duration-500"
|
||||||
|
style={{ width: `${pct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
@@ -487,12 +497,27 @@ export function ProvisioningStatus({ requestId, canAct }: Props) {
|
|||||||
<p className="text-sm text-text-secondary max-w-sm mx-auto mb-4">
|
<p className="text-sm text-text-secondary max-w-sm mx-auto mb-4">
|
||||||
{t("readyDescription")}
|
{t("readyDescription")}
|
||||||
</p>
|
</p>
|
||||||
<button
|
{(() => {
|
||||||
onClick={() => window.location.reload()}
|
// Prefer deep-linking straight to the tenant page, where the
|
||||||
className="py-2 px-6 bg-accent text-white text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors"
|
// ConnectPanel shows how to start chatting. Fall back to a
|
||||||
>
|
// reload only if we somehow don't have a tenant name yet.
|
||||||
{t("goToDashboard")}
|
const tenantName = data.tenant?.name || data.request.tenantName;
|
||||||
</button>
|
return tenantName ? (
|
||||||
|
<Link
|
||||||
|
href={`/tenants/${tenantName}`}
|
||||||
|
className="inline-block py-2 px-6 bg-accent text-surface-0 text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors"
|
||||||
|
>
|
||||||
|
{t("connectCta")}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
className="py-2 px-6 bg-accent text-surface-0 text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors"
|
||||||
|
>
|
||||||
|
{t("goToDashboard")}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -117,6 +117,13 @@ interface WizardProps {
|
|||||||
* the order skips the Checkout redirect (handled server-side).
|
* the order skips the Checkout redirect (handled server-side).
|
||||||
*/
|
*/
|
||||||
setupFeeChf?: number | null;
|
setupFeeChf?: number | null;
|
||||||
|
/**
|
||||||
|
* The platform's recurring per-tenant monthly fee (net CHF, before
|
||||||
|
* VAT). Shown on the review step alongside the setup fee so the
|
||||||
|
* customer sees the ongoing commitment — not just the one-time
|
||||||
|
* charge — before submitting. Null/0 hides the monthly line.
|
||||||
|
*/
|
||||||
|
monthlyFeeChf?: number | null;
|
||||||
/**
|
/**
|
||||||
* Bug 6: when present, the wizard renders in "edit" mode — fields
|
* Bug 6: when present, the wizard renders in "edit" mode — fields
|
||||||
* are pre-populated from the request, the SOUL.md auto-fetch is
|
* are pre-populated from the request, the SOUL.md auto-fetch is
|
||||||
@@ -157,6 +164,7 @@ export function OnboardingWizard({
|
|||||||
hasOrgBilling,
|
hasOrgBilling,
|
||||||
existingOrgBilling,
|
existingOrgBilling,
|
||||||
setupFeeChf,
|
setupFeeChf,
|
||||||
|
monthlyFeeChf,
|
||||||
editingRequest,
|
editingRequest,
|
||||||
onComplete,
|
onComplete,
|
||||||
}: WizardProps) {
|
}: WizardProps) {
|
||||||
@@ -420,18 +428,51 @@ export function OnboardingWizard({
|
|||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Validate that all secret-requiring enabled packages have complete credentials
|
// Enabled packages that still need something from the user before the
|
||||||
const packageCredentialsValid = (): boolean => {
|
// configure step can advance — a missing credential field or an
|
||||||
|
// unaccepted disclaimer. Returns the package defs so the UI can name
|
||||||
|
// exactly what's blocking the (otherwise silently disabled) Next
|
||||||
|
// button instead of greying it out with no explanation.
|
||||||
|
const incompletePackages = (): PackageDef[] => {
|
||||||
|
const out: PackageDef[] = [];
|
||||||
for (const pkgId of config.packages) {
|
for (const pkgId of config.packages) {
|
||||||
const def = PACKAGE_CATALOG.find((p) => p.id === pkgId);
|
const def = PACKAGE_CATALOG.find((p) => p.id === pkgId);
|
||||||
if (!def?.requiresSecrets) continue;
|
if (!def) continue;
|
||||||
const secrets = packageSecrets[pkgId] || {};
|
let incomplete = false;
|
||||||
for (const field of def.secrets || []) {
|
if (def.requiresSecrets) {
|
||||||
if (!secrets[field.key]?.trim()) return false;
|
const secrets = packageSecrets[pkgId] || {};
|
||||||
|
for (const field of def.secrets || []) {
|
||||||
|
if (!secrets[field.key]?.trim()) {
|
||||||
|
incomplete = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (def.disclaimerKey && !disclaimerAccepted[pkgId]) return false;
|
if (def.disclaimerKey && !disclaimerAccepted[pkgId]) incomplete = true;
|
||||||
|
if (incomplete) out.push(def);
|
||||||
}
|
}
|
||||||
return true;
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
const packageCredentialsValid = (): boolean =>
|
||||||
|
incompletePackages().length === 0;
|
||||||
|
|
||||||
|
// Map zod field paths to human labels for the confirm-step error
|
||||||
|
// summary, so a stray validation failure reads "Postal code" rather
|
||||||
|
// than "billingAddress.postalCode". Unknown paths fall back to the
|
||||||
|
// raw path (this defence-in-depth list should rarely render at all).
|
||||||
|
const fieldLabel = (path: string): string => {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
instanceName: t("instanceName"),
|
||||||
|
agentName: t("agentName"),
|
||||||
|
"billingAddress.company": t("billingCompany"),
|
||||||
|
"billingAddress.street": t("billingStreet"),
|
||||||
|
"billingAddress.postalCode": t("billingPostalCode"),
|
||||||
|
"billingAddress.city": t("billingCity"),
|
||||||
|
"billingAddress.country": t("billingCountry"),
|
||||||
|
"billingAddress.vatNumber": t("billingVatNumber"),
|
||||||
|
};
|
||||||
|
return map[path] ?? path;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
@@ -606,7 +647,7 @@ export function OnboardingWizard({
|
|||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<button
|
<button
|
||||||
onClick={goNext}
|
onClick={goNext}
|
||||||
className="py-2 px-6 bg-accent text-white text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors"
|
className="py-2 px-6 bg-accent text-surface-0 text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors"
|
||||||
>
|
>
|
||||||
{t("getStarted")}
|
{t("getStarted")}
|
||||||
</button>
|
</button>
|
||||||
@@ -984,20 +1025,33 @@ export function OnboardingWizard({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-between mt-6">
|
<div className="mt-6">
|
||||||
<button
|
{(() => {
|
||||||
onClick={goBack}
|
const blocking = incompletePackages();
|
||||||
className="py-2 px-4 text-sm text-text-secondary hover:text-text-primary transition-colors"
|
if (blocking.length === 0) return null;
|
||||||
>
|
return (
|
||||||
{t("back")}
|
<p className="text-xs text-amber-400/90 mb-3 text-right">
|
||||||
</button>
|
{t("packagesIncompleteHint", {
|
||||||
<button
|
packages: blocking.map((p) => p.name).join(", "),
|
||||||
onClick={goNext}
|
})}
|
||||||
disabled={!packageCredentialsValid()}
|
</p>
|
||||||
className="py-2 px-6 bg-accent text-white text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
);
|
||||||
>
|
})()}
|
||||||
{t("next")}
|
<div className="flex justify-between">
|
||||||
</button>
|
<button
|
||||||
|
onClick={goBack}
|
||||||
|
className="py-2 px-4 text-sm text-text-secondary hover:text-text-primary transition-colors"
|
||||||
|
>
|
||||||
|
{t("back")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={goNext}
|
||||||
|
disabled={!packageCredentialsValid()}
|
||||||
|
className="py-2 px-6 bg-accent text-surface-0 text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{t("next")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
@@ -1182,7 +1236,7 @@ export function OnboardingWizard({
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={goNext}
|
onClick={goNext}
|
||||||
className="py-2 px-6 bg-accent text-white text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors"
|
className="py-2 px-6 bg-accent text-surface-0 text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors"
|
||||||
>
|
>
|
||||||
{t("next")}
|
{t("next")}
|
||||||
</button>
|
</button>
|
||||||
@@ -1336,28 +1390,46 @@ export function OnboardingWizard({
|
|||||||
|
|
||||||
<p className="text-xs text-text-muted">{t("confirmNote")}</p>
|
<p className="text-xs text-text-muted">{t("confirmNote")}</p>
|
||||||
|
|
||||||
{/* Phase 9b: order-time setup-fee notice + amount. The
|
{/* Cost summary. Surfaces the full commitment before
|
||||||
figure shown is the net platform fee (before VAT);
|
submitting — not just the one-time setup fee but the
|
||||||
VAT is added server-side based on the billing
|
recurring monthly per-assistant fee and the fact that
|
||||||
country. We show "+ VAT" rather than a computed
|
AI usage is billed by consumption (with the budget-cap
|
||||||
gross to avoid mis-displaying a country-dependent
|
control as the reassurance). All figures are net (before
|
||||||
total. If setupFeeChf is null/0, no charge happens
|
VAT); VAT is added server-side per billing country, so
|
||||||
and the whole block is suppressed. */}
|
we show "+ VAT" rather than a country-dependent gross.
|
||||||
{typeof setupFeeChf === "number" && setupFeeChf > 0 && (
|
The block is suppressed only when there are no fixed
|
||||||
|
fees at all. */}
|
||||||
|
{((typeof setupFeeChf === "number" && setupFeeChf > 0) ||
|
||||||
|
(typeof monthlyFeeChf === "number" && monthlyFeeChf > 0)) && (
|
||||||
<div className="text-xs rounded-md border border-accent/30 bg-accent/10 text-text-secondary px-3 py-3 mt-4">
|
<div className="text-xs rounded-md border border-accent/30 bg-accent/10 text-text-secondary px-3 py-3 mt-4">
|
||||||
<strong className="block text-text-primary mb-1">
|
<strong className="block text-text-primary mb-2">
|
||||||
{t("setupFeeNoticeHeading")}
|
{t("costSummaryHeading")}
|
||||||
</strong>
|
</strong>
|
||||||
<div className="flex items-baseline justify-between mb-2 pb-2 border-b border-accent/20">
|
{typeof setupFeeChf === "number" && setupFeeChf > 0 && (
|
||||||
<span>{t("setupFeeAmountLabel")}</span>
|
<div className="flex items-baseline justify-between mb-1.5">
|
||||||
<span className="text-sm font-semibold text-text-primary">
|
<span>{t("costSetupLabel")}</span>
|
||||||
CHF {setupFeeChf.toFixed(2)}{" "}
|
<span className="text-sm font-semibold text-text-primary">
|
||||||
<span className="text-[10px] font-normal text-text-muted">
|
CHF {setupFeeChf.toFixed(2)}{" "}
|
||||||
{t("setupFeePlusVat")}
|
<span className="text-[10px] font-normal text-text-muted">
|
||||||
|
{t("setupFeePlusVat")}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</div>
|
||||||
|
)}
|
||||||
|
{typeof monthlyFeeChf === "number" && monthlyFeeChf > 0 && (
|
||||||
|
<div className="flex items-baseline justify-between mb-1.5">
|
||||||
|
<span>{t("costMonthlyLabel")}</span>
|
||||||
|
<span className="text-sm font-semibold text-text-primary">
|
||||||
|
CHF {monthlyFeeChf.toFixed(2)}{" "}
|
||||||
|
<span className="text-[10px] font-normal text-text-muted">
|
||||||
|
{t("setupFeePlusVat")}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="mt-2 pt-2 border-t border-accent/20 leading-relaxed">
|
||||||
|
{t("costUsageNote")}
|
||||||
</div>
|
</div>
|
||||||
{t("setupFeeNoticeBody")}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1380,7 +1452,8 @@ export function OnboardingWizard({
|
|||||||
<ul className="list-disc list-inside space-y-0.5">
|
<ul className="list-disc list-inside space-y-0.5">
|
||||||
{Object.entries(errors).map(([path, msg]) => (
|
{Object.entries(errors).map(([path, msg]) => (
|
||||||
<li key={path}>
|
<li key={path}>
|
||||||
<span className="font-mono">{path}</span>: {msg}
|
<span className="font-medium">{fieldLabel(path)}</span>:{" "}
|
||||||
|
{msg}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -1397,7 +1470,7 @@ export function OnboardingWizard({
|
|||||||
<button
|
<button
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
disabled={submitting}
|
disabled={submitting}
|
||||||
className="py-2.5 px-6 bg-accent text-white text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
className="py-2.5 px-6 bg-accent text-surface-0 text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
{submitting
|
{submitting
|
||||||
? tCommon("loading")
|
? tCommon("loading")
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ export function SkillCostDialog({
|
|||||||
<button
|
<button
|
||||||
onClick={onConfirm}
|
onClick={onConfirm}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
className="px-4 py-2 rounded-md bg-accent text-white text-sm disabled:opacity-50"
|
className="px-4 py-2 rounded-md bg-accent text-surface-0 text-sm disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{busy ? t("confirming") : t("confirm")}
|
{busy ? t("confirming") : t("confirm")}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -227,7 +227,7 @@ export function BillingSettingsForm({ initial, isPersonal }: Props) {
|
|||||||
<button
|
<button
|
||||||
onClick={submit}
|
onClick={submit}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
className="px-4 py-2 rounded-md bg-accent text-white text-sm font-medium hover:bg-accent-dim transition-colors disabled:opacity-50 cursor-pointer"
|
className="px-4 py-2 rounded-md bg-accent text-surface-0 text-sm font-medium hover:bg-accent-dim transition-colors disabled:opacity-50 cursor-pointer"
|
||||||
>
|
>
|
||||||
{busy ? t("saving") : initial ? t("saveChanges") : t("createBilling")}
|
{busy ? t("saving") : initial ? t("saveChanges") : t("createBilling")}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -268,7 +268,7 @@ export function BillingSettingsForm({
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={submitting}
|
disabled={submitting}
|
||||||
className="ml-auto text-sm font-medium px-4 py-2 rounded-lg bg-accent text-white hover:bg-accent/90 transition-colors disabled:opacity-50"
|
className="ml-auto text-sm font-medium px-4 py-2 rounded-lg bg-accent text-surface-0 hover:bg-accent/90 transition-colors disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{submitting ? tCommon("loading") : t("save")}
|
{submitting ? tCommon("loading") : t("save")}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -153,7 +175,7 @@ export function ProfileSettingsForm({ initial, isPersonal, orgName }: Props) {
|
|||||||
<button
|
<button
|
||||||
onClick={submit}
|
onClick={submit}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
className="px-4 py-2 rounded-md bg-accent text-white text-sm font-medium hover:bg-accent-dim transition-colors disabled:opacity-50 cursor-pointer"
|
className="px-4 py-2 rounded-md bg-accent text-surface-0 text-sm font-medium hover:bg-accent-dim transition-colors disabled:opacity-50 cursor-pointer"
|
||||||
>
|
>
|
||||||
{busy ? t("saving") : t("saveChanges")}
|
{busy ? t("saving") : t("saveChanges")}
|
||||||
</button>
|
</button>
|
||||||
@@ -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,7 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useSearchParams } from "next/navigation";
|
||||||
|
import { useRouter } from "@/i18n/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { Card, CardHeader } from "@/components/ui/card";
|
import { Card, CardHeader } from "@/components/ui/card";
|
||||||
import type { OrgBillingConfig } from "@/types";
|
import type { OrgBillingConfig } from "@/types";
|
||||||
@@ -136,7 +137,7 @@ export function SavedCardSection({
|
|||||||
<button
|
<button
|
||||||
onClick={startSetup}
|
onClick={startSetup}
|
||||||
disabled={busy !== null}
|
disabled={busy !== null}
|
||||||
className="px-4 py-2 rounded-md bg-accent text-white text-sm disabled:opacity-50"
|
className="px-4 py-2 rounded-md bg-accent text-surface-0 text-sm disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{busy === "setup" ? t("savedCardRedirecting") : t("savedCardSetupBtn")}
|
{busy === "setup" ? t("savedCardRedirecting") : t("savedCardSetupBtn")}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ export function TicketCreateForm() {
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={submitting}
|
disabled={submitting}
|
||||||
className="text-sm font-medium px-4 py-2 rounded-lg bg-accent text-white hover:bg-accent/90 transition-colors disabled:opacity-50"
|
className="text-sm font-medium px-4 py-2 rounded-lg bg-accent text-surface-0 hover:bg-accent/90 transition-colors disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{submitting ? tCommon("loading") : t("submitTicket")}
|
{submitting ? tCommon("loading") : t("submitTicket")}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -186,7 +186,7 @@ export function TicketThread({
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={submitting || closing || body.trim().length === 0}
|
disabled={submitting || closing || body.trim().length === 0}
|
||||||
className="text-sm font-medium px-4 py-2 rounded-lg bg-accent text-white hover:bg-accent/90 transition-colors disabled:opacity-50"
|
className="text-sm font-medium px-4 py-2 rounded-lg bg-accent text-surface-0 hover:bg-accent/90 transition-colors disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{submitting ? tCommon("loading") : t("sendReply")}
|
{submitting ? tCommon("loading") : t("sendReply")}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
219
src/components/team/access-overview.tsx
Normal file
219
src/components/team/access-overview.tsx
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AccessOverview
|
||||||
|
*
|
||||||
|
* Read-only "who can reach which assistant" matrix for owners. Access
|
||||||
|
* was previously only visible per-tenant (the AssignedUsersPanel on each
|
||||||
|
* tenant page) and per-member (the team roster) — with no single place
|
||||||
|
* to see the whole picture, which made it easy to lose track across
|
||||||
|
* several tenants and members.
|
||||||
|
*
|
||||||
|
* This composes existing endpoints only (no new API surface):
|
||||||
|
* - GET /api/team → org members
|
||||||
|
* - GET /api/tenants → the org's tenants
|
||||||
|
* - GET /api/tenants/{name}/assignments → per-tenant assignees
|
||||||
|
*
|
||||||
|
* Owners implicitly see every tenant, so their row is marked
|
||||||
|
* "all assistants" rather than per-cell.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface Member {
|
||||||
|
userId: string;
|
||||||
|
email: string;
|
||||||
|
displayName?: string;
|
||||||
|
roles: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TenantLite {
|
||||||
|
name: string;
|
||||||
|
displayName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AccessOverview() {
|
||||||
|
const t = useTranslations("team");
|
||||||
|
|
||||||
|
const [members, setMembers] = useState<Member[] | null>(null);
|
||||||
|
const [tenants, setTenants] = useState<TenantLite[] | null>(null);
|
||||||
|
// tenant name → set of assigned userIds
|
||||||
|
const [assignments, setAssignments] = useState<Record<string, Set<string>>>(
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const [teamRes, tenantsRes] = await Promise.all([
|
||||||
|
fetch("/api/team"),
|
||||||
|
fetch("/api/tenants"),
|
||||||
|
]);
|
||||||
|
if (!teamRes.ok || !tenantsRes.ok) throw new Error("load");
|
||||||
|
|
||||||
|
const teamData = await teamRes.json();
|
||||||
|
const tenantsData = await tenantsRes.json();
|
||||||
|
|
||||||
|
const mem: Member[] = teamData.members ?? [];
|
||||||
|
const ten: TenantLite[] = (tenantsData ?? []).map((x: any) => ({
|
||||||
|
name: x.metadata.name,
|
||||||
|
displayName: x.spec?.displayName || x.metadata.name,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Per-tenant assignment lookups in parallel. A failed lookup
|
||||||
|
// degrades to "no assignees" for that tenant rather than
|
||||||
|
// failing the whole view.
|
||||||
|
const entries = await Promise.all(
|
||||||
|
ten.map(async (tn) => {
|
||||||
|
try {
|
||||||
|
const r = await fetch(
|
||||||
|
`/api/tenants/${encodeURIComponent(tn.name)}/assignments`
|
||||||
|
);
|
||||||
|
if (!r.ok) return [tn.name, new Set<string>()] as const;
|
||||||
|
const data = await r.json();
|
||||||
|
const ids = new Set<string>(
|
||||||
|
(data.assignments ?? data ?? []).map((a: any) => a.userId)
|
||||||
|
);
|
||||||
|
return [tn.name, ids] as const;
|
||||||
|
} catch {
|
||||||
|
return [tn.name, new Set<string>()] as const;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
if (cancelled) return;
|
||||||
|
setMembers(mem);
|
||||||
|
setTenants(ten);
|
||||||
|
setAssignments(Object.fromEntries(entries));
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) setError(t("accessLoadFailed"));
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="bg-surface-1 border border-border rounded-xl p-6 animate-pulse">
|
||||||
|
<div className="h-4 w-40 bg-surface-3 rounded mb-4" />
|
||||||
|
<div className="h-24 bg-surface-2 rounded" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="bg-surface-1 border border-border rounded-xl p-6">
|
||||||
|
<p className="text-sm text-text-secondary">{error}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tenants || tenants.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="bg-surface-1 border border-border rounded-xl p-6">
|
||||||
|
<p className="text-sm text-text-secondary">{t("accessNoTenants")}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isOwner = (m: Member) => m.roles?.includes("owner");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-surface-1 border border-border rounded-xl overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border">
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-text-muted sticky left-0 bg-surface-1">
|
||||||
|
{t("accessMemberCol")}
|
||||||
|
</th>
|
||||||
|
{tenants.map((tn) => (
|
||||||
|
<th
|
||||||
|
key={tn.name}
|
||||||
|
className="px-3 py-3 text-center text-xs font-semibold text-text-secondary min-w-[7rem]"
|
||||||
|
title={tn.name}
|
||||||
|
>
|
||||||
|
{tn.displayName}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{(members ?? []).map((m) => (
|
||||||
|
<tr
|
||||||
|
key={m.userId}
|
||||||
|
className="border-b border-border last:border-0 hover:bg-surface-2/50 transition-colors"
|
||||||
|
>
|
||||||
|
<td className="px-4 py-3 sticky left-0 bg-surface-1">
|
||||||
|
<div className="text-sm text-text-primary truncate max-w-[14rem]">
|
||||||
|
{m.displayName || m.email}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-text-muted truncate max-w-[14rem]">
|
||||||
|
{m.email}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
{tenants.map((tn) => {
|
||||||
|
const owner = isOwner(m);
|
||||||
|
const has = owner || assignments[tn.name]?.has(m.userId);
|
||||||
|
const label = owner
|
||||||
|
? t("accessOwnerAll")
|
||||||
|
: has
|
||||||
|
? t("accessHasLabel")
|
||||||
|
: t("accessHasNotLabel");
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
key={tn.name}
|
||||||
|
className="px-3 py-3 text-center"
|
||||||
|
title={label}
|
||||||
|
>
|
||||||
|
<span className="sr-only">{label}</span>
|
||||||
|
{owner ? (
|
||||||
|
<span aria-hidden="true" className="text-accent">
|
||||||
|
●
|
||||||
|
</span>
|
||||||
|
) : has ? (
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="text-emerald-400 font-semibold"
|
||||||
|
>
|
||||||
|
✓
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span aria-hidden="true" className="text-text-muted/50">
|
||||||
|
–
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div className="px-4 py-2.5 border-t border-border flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-text-muted">
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<span className="text-accent">●</span> {t("accessOwnerAll")}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<span className="text-emerald-400 font-semibold">✓</span>{" "}
|
||||||
|
{t("accessHasLabel")}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<span className="text-text-muted/50">–</span> {t("accessHasNotLabel")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -141,7 +141,7 @@ export function InviteForm() {
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={state === "submitting"}
|
disabled={state === "submitting"}
|
||||||
className="w-full py-2.5 px-4 bg-accent text-white text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
className="w-full py-2.5 px-4 bg-accent text-surface-0 text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
{state === "submitting" ? tCommon("loading") : t("inviteButton")}
|
{state === "submitting" ? tCommon("loading") : t("inviteButton")}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -179,7 +179,7 @@ export function TeamList({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => saveEdit(m)}
|
onClick={() => saveEdit(m)}
|
||||||
disabled={submitting || !m.authorizationId}
|
disabled={submitting || !m.authorizationId}
|
||||||
className="text-xs px-2.5 py-1 rounded-md bg-accent text-white hover:bg-accent-dim transition-colors disabled:opacity-50"
|
className="text-xs px-2.5 py-1 rounded-md bg-accent text-surface-0 hover:bg-accent-dim transition-colors disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{t("save")}
|
{t("save")}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -218,7 +218,7 @@ export function AssignedUsersPanel({ tenantName, canEdit }: Props) {
|
|||||||
<button
|
<button
|
||||||
onClick={handleAssign}
|
onClick={handleAssign}
|
||||||
disabled={busy || !pickedUserId}
|
disabled={busy || !pickedUserId}
|
||||||
className="px-4 py-2 text-sm font-medium bg-accent text-white rounded-lg hover:bg-accent-dim transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
className="px-4 py-2 text-sm font-medium bg-accent text-surface-0 rounded-lg hover:bg-accent-dim transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
{busy ? "…" : t("assign")}
|
{busy ? "…" : t("assign")}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
234
src/components/tenants/connect-panel.tsx
Normal file
234
src/components/tenants/connect-panel.tsx
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { THREEMA_GATEWAY } from "@/lib/threema-gateway-config";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ConnectPanel
|
||||||
|
*
|
||||||
|
* The portal is a *management* console — config, billing, usage — but
|
||||||
|
* the assistant itself lives in the customer's messaging app. Nothing
|
||||||
|
* previously told the customer how to actually start talking to the
|
||||||
|
* thing they just provisioned ("Your assistant is ready… now what?").
|
||||||
|
*
|
||||||
|
* This panel closes that gap on the tenant-detail page: for each
|
||||||
|
* enabled channel it shows the concrete first-contact steps, and when
|
||||||
|
* NO channel is enabled it says so explicitly (a running assistant with
|
||||||
|
* 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:
|
||||||
|
* - ConnectPanel → "how do *I* reach the assistant"
|
||||||
|
* - ChannelUsers → "*who* is allowed to reach it"
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Render order is fixed (not the order packages happen to appear in
|
||||||
|
// spec.packages) so the panel layout is stable across tenants.
|
||||||
|
const CHANNEL_ORDER = ["threema", "telegram", "discord"] as const;
|
||||||
|
|
||||||
|
const CHANNEL_NAMES: Record<string, string> = {
|
||||||
|
threema: "Threema",
|
||||||
|
telegram: "Telegram",
|
||||||
|
discord: "Discord",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Per-channel instruction key in the `connect` message namespace.
|
||||||
|
const CHANNEL_STEPS_KEY: Record<string, string> = {
|
||||||
|
threema: "threemaSteps",
|
||||||
|
telegram: "telegramSteps",
|
||||||
|
discord: "discordSteps",
|
||||||
|
};
|
||||||
|
|
||||||
|
const dismissKey = (tenantName: string) =>
|
||||||
|
`pieced:connect-hidden:${tenantName}`;
|
||||||
|
|
||||||
|
export function ConnectPanel({
|
||||||
|
tenantName,
|
||||||
|
enabledChannels,
|
||||||
|
phase,
|
||||||
|
}: {
|
||||||
|
tenantName: string;
|
||||||
|
enabledChannels: string[];
|
||||||
|
/** Tenant phase — connection details only "work" once it's Ready. */
|
||||||
|
phase: string;
|
||||||
|
}) {
|
||||||
|
const t = useTranslations("connect");
|
||||||
|
|
||||||
|
const channels = CHANNEL_ORDER.filter((c) => enabledChannels.includes(c));
|
||||||
|
const ready = phase === "Ready" || phase === "Running" || phase === "Active";
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-amber-500/30 bg-amber-500/10 p-5">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<svg
|
||||||
|
className="h-5 w-5 text-amber-400 shrink-0 mt-0.5"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zM12 15.75h.008v.008H12v-.008z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-sm font-semibold text-amber-300">
|
||||||
|
{t("noChannelsTitle")}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-text-secondary mt-1 leading-relaxed">
|
||||||
|
{t("noChannelsBody")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<div className="rounded-xl border border-accent/30 bg-accent/5 p-5">
|
||||||
|
<div className="flex items-start justify-between gap-3 mb-1">
|
||||||
|
<h2 className="font-display text-base font-semibold text-text-primary">
|
||||||
|
{t("title")}
|
||||||
|
</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">
|
||||||
|
{t("description")}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{!ready && (
|
||||||
|
<p className="text-xs text-amber-300 bg-amber-500/10 border border-amber-500/20 rounded-lg px-3 py-2 mb-4 leading-relaxed">
|
||||||
|
{t("notReadyNote")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{channels.map((c) => (
|
||||||
|
<div
|
||||||
|
key={c}
|
||||||
|
className="rounded-lg border border-border bg-surface-1 p-3"
|
||||||
|
>
|
||||||
|
<div className="text-sm font-medium text-text-primary mb-1.5">
|
||||||
|
{CHANNEL_NAMES[c]}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{c === "threema" ? (
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="bg-white p-1.5 rounded-md shrink-0">
|
||||||
|
{/* Shared gateway QR — identical for every tenant, so
|
||||||
|
it can render before/after provisioning alike.
|
||||||
|
eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={THREEMA_GATEWAY.qrCodePath}
|
||||||
|
alt={`QR code for ${THREEMA_GATEWAY.displayName}`}
|
||||||
|
width={88}
|
||||||
|
height={88}
|
||||||
|
style={{ display: "block" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-text-secondary leading-relaxed">
|
||||||
|
<div className="mb-1.5">
|
||||||
|
<span className="text-text-muted">
|
||||||
|
{t("threemaBotIdLabel")}:{" "}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-sm text-accent">
|
||||||
|
{THREEMA_GATEWAY.displayName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="whitespace-pre-line">{t("threemaSteps")}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-text-secondary leading-relaxed whitespace-pre-line">
|
||||||
|
{t(CHANNEL_STEPS_KEY[c])}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
58
src/components/ui/button.tsx
Normal file
58
src/components/ui/button.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { forwardRef } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared button primitive.
|
||||||
|
*
|
||||||
|
* Why this exists
|
||||||
|
* ---------------
|
||||||
|
* The accent fill (#00d4aa) is bright; white text on it measures ~1.9:1,
|
||||||
|
* which fails WCAG even for large/UI text. Dark text (surface-0) on the
|
||||||
|
* same accent is ~10:1. The codebase had ~40 hand-rolled accent buttons,
|
||||||
|
* most using `text-white`. This component centralises the correct token
|
||||||
|
* (`text-surface-0` on accent) so the contrast can't drift again — reach
|
||||||
|
* for `<Button>` instead of re-deriving the class string.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type Variant = "primary" | "secondary" | "ghost" | "danger";
|
||||||
|
type Size = "sm" | "md";
|
||||||
|
|
||||||
|
const BASE =
|
||||||
|
"inline-flex items-center justify-center gap-1.5 font-medium rounded-lg " +
|
||||||
|
"transition-colors cursor-pointer focus:outline-none focus-visible:ring-2 " +
|
||||||
|
"focus-visible:ring-accent/50 disabled:opacity-50 disabled:cursor-not-allowed";
|
||||||
|
|
||||||
|
const VARIANTS: Record<Variant, string> = {
|
||||||
|
// surface-0 (dark) text — the contrast-correct pairing for the accent.
|
||||||
|
primary: "bg-accent text-surface-0 hover:bg-accent-dim shadow-sm shadow-accent/20",
|
||||||
|
secondary:
|
||||||
|
"bg-surface-2 text-text-primary border border-border hover:bg-surface-3 hover:border-border-active",
|
||||||
|
ghost: "text-text-secondary hover:text-text-primary hover:bg-surface-2",
|
||||||
|
danger: "bg-error text-surface-0 hover:opacity-90",
|
||||||
|
};
|
||||||
|
|
||||||
|
const SIZES: Record<Size, string> = {
|
||||||
|
sm: "text-xs px-3 py-1.5",
|
||||||
|
md: "text-sm px-4 py-2",
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
variant?: Variant;
|
||||||
|
size?: Size;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
function Button(
|
||||||
|
{ variant = "primary", size = "md", className = "", type = "button", ...rest },
|
||||||
|
ref
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
ref={ref}
|
||||||
|
type={type}
|
||||||
|
className={`${BASE} ${VARIANTS[variant]} ${SIZES[size]} ${className}`}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,6 +16,9 @@ interface Props {
|
|||||||
ariaLabel?: string;
|
ariaLabel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const FOCUSABLE =
|
||||||
|
'a[href],button:not([disabled]),textarea:not([disabled]),input:not([disabled]),select:not([disabled]),[tabindex]:not([tabindex="-1"])';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Portal-based modal.
|
* Portal-based modal.
|
||||||
*
|
*
|
||||||
@@ -25,45 +28,86 @@ interface Props {
|
|||||||
* ancestor's containing block, not the viewport, when ANY ancestor
|
* ancestor's containing block, not the viewport, when ANY ancestor
|
||||||
* has a `transform`, `perspective`, or `filter` applied. Our
|
* has a `transform`, `perspective`, or `filter` applied. Our
|
||||||
* `animate-in` utility sets `transform: translateY(0)` on a lot of
|
* `animate-in` utility sets `transform: translateY(0)` on a lot of
|
||||||
* dashboard/tenant-detail containers (because of the fade-up
|
* dashboard/tenant-detail containers, which broke modals rendered as
|
||||||
* animation, which uses `animation-fill-mode: both` to keep the
|
* in-place children — they centred to the panel they lived in, not to
|
||||||
* transform on after the animation finishes). That broke modals
|
* the page. Rendering at `document.body` via `createPortal` escapes
|
||||||
* rendered as in-place children — they centred to the panel they
|
* every containing-block ancestor and gives us true viewport coords.
|
||||||
* lived in, not to the page.
|
|
||||||
*
|
*
|
||||||
* Rendering at `document.body` via `createPortal` escapes every
|
* UX / a11y details
|
||||||
* containing-block ancestor and gives us true viewport coordinates.
|
* -----------------
|
||||||
*
|
* - Backdrop click triggers `onClose` (only when the click target IS
|
||||||
* UX details
|
* the backdrop, not the panel inside).
|
||||||
* ----------
|
* - Escape triggers `onClose`.
|
||||||
* - Backdrop click triggers `onClose`. (Bubbling check: only fires
|
* - `body` overflow is locked while open so background content doesn't
|
||||||
* when the click target IS the backdrop, not the panel inside.)
|
* scroll behind the modal.
|
||||||
* - Escape key triggers `onClose`. Standard modal expectation.
|
* - Focus is moved into the panel on open, trapped within it while open
|
||||||
* - `body` overflow is locked while open so background content
|
* (Tab / Shift+Tab cycle), and restored to the previously focused
|
||||||
* doesn't scroll behind the modal.
|
* element on close — so keyboard and screen-reader users can't tab
|
||||||
* - Renders nothing on first paint server-side, then mounts on
|
* out to the inert page behind the dialog.
|
||||||
* client. `useEffect` gating ensures `document.body` is available;
|
|
||||||
* without it Next.js SSR would throw on `document` reference.
|
|
||||||
*/
|
*/
|
||||||
export function Modal({ open, onClose, children, ariaLabel }: Props) {
|
export function Modal({ open, onClose, children, ariaLabel }: Props) {
|
||||||
const closeRef = useRef(onClose);
|
const closeRef = useRef(onClose);
|
||||||
closeRef.current = onClose;
|
closeRef.current = onClose;
|
||||||
|
const panelRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
|
|
||||||
// Lock background scroll. Restore on unmount/close.
|
// Remember what had focus so we can restore it on close.
|
||||||
|
const previouslyFocused = document.activeElement as HTMLElement | null;
|
||||||
|
|
||||||
|
// Lock background scroll.
|
||||||
const previousOverflow = document.body.style.overflow;
|
const previousOverflow = document.body.style.overflow;
|
||||||
document.body.style.overflow = "hidden";
|
document.body.style.overflow = "hidden";
|
||||||
|
|
||||||
|
// Move focus into the dialog — first focusable element, else the
|
||||||
|
// panel itself (it carries tabIndex={-1}).
|
||||||
|
const panel = panelRef.current;
|
||||||
|
const focusables = panel
|
||||||
|
? Array.from(panel.querySelectorAll<HTMLElement>(FOCUSABLE))
|
||||||
|
: [];
|
||||||
|
(focusables[0] ?? panel)?.focus();
|
||||||
|
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
if (e.key === "Escape") closeRef.current();
|
if (e.key === "Escape") {
|
||||||
|
closeRef.current();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key !== "Tab" || !panel) return;
|
||||||
|
|
||||||
|
// Re-query each time — modal content can change between tabs.
|
||||||
|
const items = Array.from(
|
||||||
|
panel.querySelectorAll<HTMLElement>(FOCUSABLE)
|
||||||
|
).filter((el) => el.offsetParent !== null || el === document.activeElement);
|
||||||
|
if (items.length === 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
panel.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const first = items[0];
|
||||||
|
const last = items[items.length - 1];
|
||||||
|
const active = document.activeElement;
|
||||||
|
|
||||||
|
if (e.shiftKey) {
|
||||||
|
if (active === first || active === panel) {
|
||||||
|
e.preventDefault();
|
||||||
|
last.focus();
|
||||||
|
}
|
||||||
|
} else if (active === last) {
|
||||||
|
e.preventDefault();
|
||||||
|
first.focus();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener("keydown", onKey);
|
window.addEventListener("keydown", onKey);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
document.body.style.overflow = previousOverflow;
|
document.body.style.overflow = previousOverflow;
|
||||||
window.removeEventListener("keydown", onKey);
|
window.removeEventListener("keydown", onKey);
|
||||||
|
// Restore focus to the trigger (if it's still in the document).
|
||||||
|
if (previouslyFocused && document.contains(previouslyFocused)) {
|
||||||
|
previouslyFocused.focus();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
@@ -72,15 +116,19 @@ export function Modal({ open, onClose, children, ariaLabel }: Props) {
|
|||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div
|
<div
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-label={ariaLabel}
|
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
|
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
if (e.target === e.currentTarget) onClose();
|
if (e.target === e.currentTarget) onClose();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="bg-surface-1 border border-border rounded-xl p-6 max-w-md w-full max-h-[90vh] overflow-y-auto">
|
<div
|
||||||
|
ref={panelRef}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
tabIndex={-1}
|
||||||
|
className="bg-surface-1 border border-border rounded-xl p-6 max-w-md w-full max-h-[90vh] overflow-y-auto focus:outline-none"
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</div>,
|
</div>,
|
||||||
|
|||||||
@@ -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.
|
||||||
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 ?? "",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"tagline": "KI-Plattform",
|
"tagline": "KI-Plattform",
|
||||||
"login": "Anmelden",
|
"login": "Anmelden",
|
||||||
"logout": "Abmelden",
|
"logout": "Abmelden",
|
||||||
|
"menu": "Menü",
|
||||||
"dashboard": "Dashboard",
|
"dashboard": "Dashboard",
|
||||||
"admin": "Admin",
|
"admin": "Admin",
|
||||||
"loading": "Laden…",
|
"loading": "Laden…",
|
||||||
@@ -47,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…",
|
||||||
@@ -93,7 +95,7 @@
|
|||||||
"provisioningDescription": "Ihr KI-Assistent wird bereitgestellt. Dies dauert in der Regel wenige Minuten.",
|
"provisioningDescription": "Ihr KI-Assistent wird bereitgestellt. Dies dauert in der Regel wenige Minuten.",
|
||||||
"phase": "Phase",
|
"phase": "Phase",
|
||||||
"readyTitle": "Ihr Assistent ist bereit!",
|
"readyTitle": "Ihr Assistent ist bereit!",
|
||||||
"readyDescription": "Ihr KI-Assistent wurde bereitgestellt und ist aktiv. Sie können ihn nun über das Dashboard verwalten.",
|
"readyDescription": "Ihr KI-Assistent wurde bereitgestellt und läuft. Verbinden Sie ihn als Nächstes mit Ihrer Messaging-App, um den Chat zu starten.",
|
||||||
"goToDashboard": "Zum Dashboard",
|
"goToDashboard": "Zum Dashboard",
|
||||||
"submittedAt": "Eingereicht",
|
"submittedAt": "Eingereicht",
|
||||||
"instanceName": "Instanzname",
|
"instanceName": "Instanzname",
|
||||||
@@ -142,7 +144,15 @@
|
|||||||
"telegram": "Öffnen Sie Telegram, schreiben Sie an @userinfobot und fügen Sie die zurückgegebene numerische ID hier ein. Weitere Benutzer können Sie später auf der Mandantenseite hinzufügen.",
|
"telegram": "Öffnen Sie Telegram, schreiben Sie an @userinfobot und fügen Sie die zurückgegebene numerische ID hier ein. Weitere Benutzer können Sie später auf der Mandantenseite hinzufügen.",
|
||||||
"discord": "Aktivieren Sie den Entwicklermodus in Discord (Erweiterte Einstellungen), Rechtsklick auf Ihren Namen → Benutzer-ID kopieren, und hier einfügen. Weitere Benutzer können Sie später auf der Mandantenseite hinzufügen.",
|
"discord": "Aktivieren Sie den Entwicklermodus in Discord (Erweiterte Einstellungen), Rechtsklick auf Ihren Namen → Benutzer-ID kopieren, und hier einfügen. Weitere Benutzer können Sie später auf der Mandantenseite hinzufügen.",
|
||||||
"threema": "Die 8 Zeichen, die in Ihrer Threema-App unter Einstellungen → Meine Threema-ID angezeigt werden. Sobald Ihr Mandant freigegeben ist und Threema aktiviert wurde, können Sie aus diesem Account heraus mit dem Assistenten chatten. Weitere autorisierte IDs können später auf der Mandantenseite hinzugefügt werden."
|
"threema": "Die 8 Zeichen, die in Ihrer Threema-App unter Einstellungen → Meine Threema-ID angezeigt werden. Sobald Ihr Mandant freigegeben ist und Threema aktiviert wurde, können Sie aus diesem Account heraus mit dem Assistenten chatten. Weitere autorisierte IDs können später auf der Mandantenseite hinzugefügt werden."
|
||||||
}
|
},
|
||||||
|
"connectCta": "Assistenten verbinden",
|
||||||
|
"packagesIncompleteHint": "Bitte ergänzen Sie die erforderlichen Angaben für: {packages}",
|
||||||
|
"setupProgress": "Einrichtungsfortschritt",
|
||||||
|
"setupStepsComplete": "{done} von {total} Schritten",
|
||||||
|
"costSummaryHeading": "Was Sie bezahlen",
|
||||||
|
"costSetupLabel": "Einmalige Einrichtung",
|
||||||
|
"costMonthlyLabel": "Monatlich, pro Assistent",
|
||||||
|
"costUsageNote": "Zuzüglich nutzungsabhängiger KI-Kosten, monatlich in CHF abgerechnet. Sie können jederzeit ein Ausgabenlimit pro Assistent festlegen."
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Dashboard",
|
"title": "Dashboard",
|
||||||
@@ -225,7 +235,10 @@
|
|||||||
"budgetCadence_1mo": "Monatlich",
|
"budgetCadence_1mo": "Monatlich",
|
||||||
"budgetCadence_1y": "Jährlich",
|
"budgetCadence_1y": "Jährlich",
|
||||||
"budgetInvalid": "Bitte einen positiven Betrag eingeben.",
|
"budgetInvalid": "Bitte einen positiven Betrag eingeben.",
|
||||||
"budgetSaveFailed": "Budget konnte nicht gespeichert werden. Bitte erneut versuchen."
|
"budgetSaveFailed": "Budget konnte nicht gespeichert werden. Bitte erneut versuchen.",
|
||||||
|
"legendInput": "Input",
|
||||||
|
"legendOutput": "Output",
|
||||||
|
"chartHint": "Für Details auf einen Balken tippen"
|
||||||
},
|
},
|
||||||
"workspace": {
|
"workspace": {
|
||||||
"save": "Speichern",
|
"save": "Speichern",
|
||||||
@@ -420,7 +433,18 @@
|
|||||||
"openclawTool": "OpenClaw-Versionen",
|
"openclawTool": "OpenClaw-Versionen",
|
||||||
"billingTool": "Abrechnung →",
|
"billingTool": "Abrechnung →",
|
||||||
"skillsQueueTool": "Aktivierungs-Warteschlange",
|
"skillsQueueTool": "Aktivierungs-Warteschlange",
|
||||||
"cronTool": "Automatisierung"
|
"cronTool": "Automatisierung",
|
||||||
|
"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.",
|
||||||
|
"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",
|
||||||
|
"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",
|
||||||
@@ -467,7 +491,15 @@
|
|||||||
"roleUpdateFailed": "Rolle konnte nicht aktualisiert werden.",
|
"roleUpdateFailed": "Rolle konnte nicht aktualisiert werden.",
|
||||||
"cancel": "Abbrechen",
|
"cancel": "Abbrechen",
|
||||||
"save": "Speichern",
|
"save": "Speichern",
|
||||||
"selfChangeBlocked": "Sie können Ihre eigene Rolle nicht ändern."
|
"selfChangeBlocked": "Sie können Ihre eigene Rolle nicht ändern.",
|
||||||
|
"accessTitle": "Zugriffsübersicht",
|
||||||
|
"accessDescription": "Welches Mitglied auf welchen Assistenten zugreifen kann.",
|
||||||
|
"accessMemberCol": "Mitglied",
|
||||||
|
"accessOwnerAll": "Alle Assistenten (Eigentümer)",
|
||||||
|
"accessHasLabel": "Zugriff",
|
||||||
|
"accessHasNotLabel": "Kein Zugriff",
|
||||||
|
"accessNoTenants": "Noch keine Assistenten.",
|
||||||
|
"accessLoadFailed": "Zugriffsübersicht konnte nicht geladen werden."
|
||||||
},
|
},
|
||||||
"assignments": {
|
"assignments": {
|
||||||
"loading": "Zuweisungen werden geladen…",
|
"loading": "Zuweisungen werden geladen…",
|
||||||
@@ -823,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",
|
||||||
@@ -962,6 +995,29 @@
|
|||||||
"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": {
|
||||||
|
"title": "Etwas ist schiefgelaufen",
|
||||||
|
"description": "Beim Laden dieser Seite ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut.",
|
||||||
|
"retry": "Erneut versuchen",
|
||||||
|
"backToDashboard": "Zurück zum Dashboard",
|
||||||
|
"notFoundTitle": "Seite nicht gefunden",
|
||||||
|
"notFoundDescription": "Die angeforderte Seite existiert nicht oder wurde verschoben."
|
||||||
|
},
|
||||||
|
"connect": {
|
||||||
|
"title": "Mit Ihrem Assistenten verbinden",
|
||||||
|
"description": "Ihr Assistent läuft in Ihrer Messaging-App. So beginnen Sie den Chat mit ihm.",
|
||||||
|
"notReadyNote": "Ihr Assistent wird noch eingerichtet. Diese Verbindungsdetails funktionieren, sobald er bereit ist.",
|
||||||
|
"noChannelsTitle": "Noch kein Messaging-Kanal",
|
||||||
|
"noChannelsBody": "Ihr Assistent läuft, hat aber keinen Kanal zum Chatten. Aktivieren Sie unten im Bereich Pakete einen Kanal – Threema, Telegram oder Discord –, um ihn zu nutzen.",
|
||||||
|
"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.",
|
||||||
|
"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.",
|
||||||
|
"dismiss": "Verbunden",
|
||||||
|
"show": "Verbindungsdetails anzeigen"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"tagline": "AI Platform",
|
"tagline": "AI Platform",
|
||||||
"login": "Sign In",
|
"login": "Sign In",
|
||||||
"logout": "Sign Out",
|
"logout": "Sign Out",
|
||||||
|
"menu": "Menu",
|
||||||
"dashboard": "Dashboard",
|
"dashboard": "Dashboard",
|
||||||
"admin": "Admin",
|
"admin": "Admin",
|
||||||
"loading": "Loading…",
|
"loading": "Loading…",
|
||||||
@@ -47,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…",
|
||||||
@@ -93,7 +95,7 @@
|
|||||||
"provisioningDescription": "Your AI assistant is being provisioned. This usually takes a few minutes.",
|
"provisioningDescription": "Your AI assistant is being provisioned. This usually takes a few minutes.",
|
||||||
"phase": "Phase",
|
"phase": "Phase",
|
||||||
"readyTitle": "Your assistant is ready!",
|
"readyTitle": "Your assistant is ready!",
|
||||||
"readyDescription": "Your AI assistant has been provisioned and is running. You can now manage it from the dashboard.",
|
"readyDescription": "Your AI assistant has been provisioned and is running. Next, connect it to your messaging app to start chatting.",
|
||||||
"goToDashboard": "Go to Dashboard",
|
"goToDashboard": "Go to Dashboard",
|
||||||
"submittedAt": "Submitted",
|
"submittedAt": "Submitted",
|
||||||
"instanceName": "Instance name",
|
"instanceName": "Instance name",
|
||||||
@@ -142,7 +144,15 @@
|
|||||||
"telegram": "Open Telegram, message @userinfobot, and paste the numeric id it returns. You can add more users later from the tenant page.",
|
"telegram": "Open Telegram, message @userinfobot, and paste the numeric id it returns. You can add more users later from the tenant page.",
|
||||||
"discord": "Enable Developer Mode in Discord (Advanced settings), right-click your name → Copy User ID, and paste it here. You can add more users later from the tenant page.",
|
"discord": "Enable Developer Mode in Discord (Advanced settings), right-click your name → Copy User ID, and paste it here. You can add more users later from the tenant page.",
|
||||||
"threema": "The 8 characters shown in your Threema app under Settings → My Threema ID. Once your tenant is approved and Threema is enabled, you'll be able to chat with the assistant from this account. More authorized IDs can be added later from the tenant page."
|
"threema": "The 8 characters shown in your Threema app under Settings → My Threema ID. Once your tenant is approved and Threema is enabled, you'll be able to chat with the assistant from this account. More authorized IDs can be added later from the tenant page."
|
||||||
}
|
},
|
||||||
|
"connectCta": "Connect your assistant",
|
||||||
|
"packagesIncompleteHint": "Add the required details for: {packages}",
|
||||||
|
"setupProgress": "Setup progress",
|
||||||
|
"setupStepsComplete": "{done} of {total} steps",
|
||||||
|
"costSummaryHeading": "What you'll pay",
|
||||||
|
"costSetupLabel": "One-time setup",
|
||||||
|
"costMonthlyLabel": "Monthly, per assistant",
|
||||||
|
"costUsageNote": "Plus usage-based AI costs, billed monthly in CHF. You can set a spending cap per assistant at any time."
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Dashboard",
|
"title": "Dashboard",
|
||||||
@@ -225,7 +235,10 @@
|
|||||||
"budgetCadence_1mo": "Monthly",
|
"budgetCadence_1mo": "Monthly",
|
||||||
"budgetCadence_1y": "Yearly",
|
"budgetCadence_1y": "Yearly",
|
||||||
"budgetInvalid": "Please enter a positive amount.",
|
"budgetInvalid": "Please enter a positive amount.",
|
||||||
"budgetSaveFailed": "Could not save budget. Please try again."
|
"budgetSaveFailed": "Could not save budget. Please try again.",
|
||||||
|
"legendInput": "Input",
|
||||||
|
"legendOutput": "Output",
|
||||||
|
"chartHint": "Tap a bar for that day"
|
||||||
},
|
},
|
||||||
"workspace": {
|
"workspace": {
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
@@ -420,7 +433,18 @@
|
|||||||
"openclawTool": "OpenClaw versions",
|
"openclawTool": "OpenClaw versions",
|
||||||
"billingTool": "Billing →",
|
"billingTool": "Billing →",
|
||||||
"skillsQueueTool": "Activation Queue",
|
"skillsQueueTool": "Activation Queue",
|
||||||
"cronTool": "Automation"
|
"cronTool": "Automation",
|
||||||
|
"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.",
|
||||||
|
"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",
|
||||||
|
"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",
|
||||||
@@ -467,7 +491,15 @@
|
|||||||
"roleUpdateFailed": "Could not update role.",
|
"roleUpdateFailed": "Could not update role.",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"selfChangeBlocked": "You cannot change your own role."
|
"selfChangeBlocked": "You cannot change your own role.",
|
||||||
|
"accessTitle": "Access overview",
|
||||||
|
"accessDescription": "Which member can reach which assistant.",
|
||||||
|
"accessMemberCol": "Member",
|
||||||
|
"accessOwnerAll": "All assistants (owner)",
|
||||||
|
"accessHasLabel": "Has access",
|
||||||
|
"accessHasNotLabel": "No access",
|
||||||
|
"accessNoTenants": "No assistants yet.",
|
||||||
|
"accessLoadFailed": "Couldn't load the access overview."
|
||||||
},
|
},
|
||||||
"assignments": {
|
"assignments": {
|
||||||
"loading": "Loading assignments…",
|
"loading": "Loading assignments…",
|
||||||
@@ -823,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",
|
||||||
@@ -962,6 +995,29 @@
|
|||||||
"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": {
|
||||||
|
"title": "Something went wrong",
|
||||||
|
"description": "An error occurred while loading this page. Please try again.",
|
||||||
|
"retry": "Try again",
|
||||||
|
"backToDashboard": "Back to dashboard",
|
||||||
|
"notFoundTitle": "Page not found",
|
||||||
|
"notFoundDescription": "The page you're looking for doesn't exist or has moved."
|
||||||
|
},
|
||||||
|
"connect": {
|
||||||
|
"title": "Connect to your assistant",
|
||||||
|
"description": "Your assistant runs inside your messaging app. Here's how to start chatting with it.",
|
||||||
|
"notReadyNote": "Your assistant is still being set up. These connection details will work as soon as it's ready.",
|
||||||
|
"noChannelsTitle": "No messaging channel yet",
|
||||||
|
"noChannelsBody": "Your assistant is running but has no channel to chat through. Enable a channel — Threema, Telegram, or Discord — in the Packages section below to start using it.",
|
||||||
|
"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.",
|
||||||
|
"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.",
|
||||||
|
"dismiss": "I've connected",
|
||||||
|
"show": "Show connection details"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"tagline": "Plateforme IA",
|
"tagline": "Plateforme IA",
|
||||||
"login": "Connexion",
|
"login": "Connexion",
|
||||||
"logout": "Déconnexion",
|
"logout": "Déconnexion",
|
||||||
|
"menu": "Menu",
|
||||||
"dashboard": "Tableau de bord",
|
"dashboard": "Tableau de bord",
|
||||||
"admin": "Admin",
|
"admin": "Admin",
|
||||||
"loading": "Chargement…",
|
"loading": "Chargement…",
|
||||||
@@ -47,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…",
|
||||||
@@ -93,7 +95,7 @@
|
|||||||
"provisioningDescription": "Votre assistant IA est en cours de mise en service. Cela prend généralement quelques minutes.",
|
"provisioningDescription": "Votre assistant IA est en cours de mise en service. Cela prend généralement quelques minutes.",
|
||||||
"phase": "Phase",
|
"phase": "Phase",
|
||||||
"readyTitle": "Votre assistant est prêt !",
|
"readyTitle": "Votre assistant est prêt !",
|
||||||
"readyDescription": "Votre assistant IA a été mis en service et est actif. Vous pouvez maintenant le gérer depuis le tableau de bord.",
|
"readyDescription": "Votre assistant IA a été provisionné et fonctionne. Connectez-le maintenant à votre application de messagerie pour commencer à discuter.",
|
||||||
"goToDashboard": "Aller au tableau de bord",
|
"goToDashboard": "Aller au tableau de bord",
|
||||||
"submittedAt": "Soumis",
|
"submittedAt": "Soumis",
|
||||||
"instanceName": "Nom de l'instance",
|
"instanceName": "Nom de l'instance",
|
||||||
@@ -142,7 +144,15 @@
|
|||||||
"telegram": "Ouvrez Telegram, écrivez à @userinfobot et collez l'ID numérique qu'il retourne. Vous pourrez ajouter d'autres utilisateurs plus tard depuis la page du tenant.",
|
"telegram": "Ouvrez Telegram, écrivez à @userinfobot et collez l'ID numérique qu'il retourne. Vous pourrez ajouter d'autres utilisateurs plus tard depuis la page du tenant.",
|
||||||
"discord": "Activez le mode développeur dans Discord (paramètres avancés), clic-droit sur votre nom → Copier l'ID utilisateur, puis collez-le ici. Vous pourrez ajouter d'autres utilisateurs plus tard depuis la page du tenant.",
|
"discord": "Activez le mode développeur dans Discord (paramètres avancés), clic-droit sur votre nom → Copier l'ID utilisateur, puis collez-le ici. Vous pourrez ajouter d'autres utilisateurs plus tard depuis la page du tenant.",
|
||||||
"threema": "Les 8 caractères affichés dans votre app Threema sous Réglages → Mon identifiant Threema. Une fois votre tenant approuvé et Threema activé, vous pourrez discuter avec l'assistant depuis ce compte. D'autres ID autorisés peuvent être ajoutés plus tard depuis la page du tenant."
|
"threema": "Les 8 caractères affichés dans votre app Threema sous Réglages → Mon identifiant Threema. Une fois votre tenant approuvé et Threema activé, vous pourrez discuter avec l'assistant depuis ce compte. D'autres ID autorisés peuvent être ajoutés plus tard depuis la page du tenant."
|
||||||
}
|
},
|
||||||
|
"connectCta": "Connecter votre assistant",
|
||||||
|
"packagesIncompleteHint": "Complétez les informations requises pour : {packages}",
|
||||||
|
"setupProgress": "Progression de la configuration",
|
||||||
|
"setupStepsComplete": "{done} sur {total} étapes",
|
||||||
|
"costSummaryHeading": "Ce que vous paierez",
|
||||||
|
"costSetupLabel": "Installation unique",
|
||||||
|
"costMonthlyLabel": "Mensuel, par assistant",
|
||||||
|
"costUsageNote": "Plus les coûts d'IA à l'usage, facturés mensuellement en CHF. Vous pouvez définir un plafond de dépenses par assistant à tout moment."
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Tableau de bord",
|
"title": "Tableau de bord",
|
||||||
@@ -225,7 +235,10 @@
|
|||||||
"budgetCadence_1mo": "Mensuelle",
|
"budgetCadence_1mo": "Mensuelle",
|
||||||
"budgetCadence_1y": "Annuelle",
|
"budgetCadence_1y": "Annuelle",
|
||||||
"budgetInvalid": "Veuillez saisir un montant positif.",
|
"budgetInvalid": "Veuillez saisir un montant positif.",
|
||||||
"budgetSaveFailed": "Impossible d'enregistrer le budget. Veuillez réessayer."
|
"budgetSaveFailed": "Impossible d'enregistrer le budget. Veuillez réessayer.",
|
||||||
|
"legendInput": "Entrée",
|
||||||
|
"legendOutput": "Sortie",
|
||||||
|
"chartHint": "Touchez une barre pour le détail"
|
||||||
},
|
},
|
||||||
"workspace": {
|
"workspace": {
|
||||||
"save": "Enregistrer",
|
"save": "Enregistrer",
|
||||||
@@ -420,7 +433,18 @@
|
|||||||
"openclawTool": "Versions OpenClaw",
|
"openclawTool": "Versions OpenClaw",
|
||||||
"billingTool": "Facturation →",
|
"billingTool": "Facturation →",
|
||||||
"skillsQueueTool": "File d'activation",
|
"skillsQueueTool": "File d'activation",
|
||||||
"cronTool": "Automatisation"
|
"cronTool": "Automatisation",
|
||||||
|
"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.",
|
||||||
|
"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",
|
||||||
|
"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",
|
||||||
@@ -467,7 +491,15 @@
|
|||||||
"roleUpdateFailed": "Impossible de mettre à jour le rôle.",
|
"roleUpdateFailed": "Impossible de mettre à jour le rôle.",
|
||||||
"cancel": "Annuler",
|
"cancel": "Annuler",
|
||||||
"save": "Enregistrer",
|
"save": "Enregistrer",
|
||||||
"selfChangeBlocked": "Vous ne pouvez pas modifier votre propre rôle."
|
"selfChangeBlocked": "Vous ne pouvez pas modifier votre propre rôle.",
|
||||||
|
"accessTitle": "Aperçu des accès",
|
||||||
|
"accessDescription": "Quel membre peut accéder à quel assistant.",
|
||||||
|
"accessMemberCol": "Membre",
|
||||||
|
"accessOwnerAll": "Tous les assistants (propriétaire)",
|
||||||
|
"accessHasLabel": "Accès",
|
||||||
|
"accessHasNotLabel": "Aucun accès",
|
||||||
|
"accessNoTenants": "Aucun assistant pour l'instant.",
|
||||||
|
"accessLoadFailed": "Impossible de charger l'aperçu des accès."
|
||||||
},
|
},
|
||||||
"assignments": {
|
"assignments": {
|
||||||
"loading": "Chargement des attributions…",
|
"loading": "Chargement des attributions…",
|
||||||
@@ -823,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",
|
||||||
@@ -962,6 +995,29 @@
|
|||||||
"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": {
|
||||||
|
"title": "Une erreur est survenue",
|
||||||
|
"description": "Une erreur s'est produite lors du chargement de cette page. Veuillez réessayer.",
|
||||||
|
"retry": "Réessayer",
|
||||||
|
"backToDashboard": "Retour au tableau de bord",
|
||||||
|
"notFoundTitle": "Page introuvable",
|
||||||
|
"notFoundDescription": "La page que vous recherchez n'existe pas ou a été déplacée."
|
||||||
|
},
|
||||||
|
"connect": {
|
||||||
|
"title": "Connectez-vous à votre assistant",
|
||||||
|
"description": "Votre assistant fonctionne dans votre application de messagerie. Voici comment commencer à discuter avec lui.",
|
||||||
|
"notReadyNote": "Votre assistant est encore en cours de configuration. Ces informations de connexion fonctionneront dès qu'il sera prêt.",
|
||||||
|
"noChannelsTitle": "Aucun canal de messagerie",
|
||||||
|
"noChannelsBody": "Votre assistant fonctionne mais n'a aucun canal pour discuter. Activez un canal — Threema, Telegram ou Discord — dans la section Forfaits ci-dessous pour commencer à l'utiliser.",
|
||||||
|
"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.",
|
||||||
|
"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.",
|
||||||
|
"dismiss": "Je suis connecté",
|
||||||
|
"show": "Afficher les détails de connexion"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"tagline": "Piattaforma IA",
|
"tagline": "Piattaforma IA",
|
||||||
"login": "Acceda",
|
"login": "Acceda",
|
||||||
"logout": "Esci",
|
"logout": "Esci",
|
||||||
|
"menu": "Menu",
|
||||||
"dashboard": "Dashboard",
|
"dashboard": "Dashboard",
|
||||||
"admin": "Admin",
|
"admin": "Admin",
|
||||||
"loading": "Caricamento…",
|
"loading": "Caricamento…",
|
||||||
@@ -47,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…",
|
||||||
@@ -93,7 +95,7 @@
|
|||||||
"provisioningDescription": "Il suo assistente IA è in fase di attivazione. Di solito richiede pochi minuti.",
|
"provisioningDescription": "Il suo assistente IA è in fase di attivazione. Di solito richiede pochi minuti.",
|
||||||
"phase": "Fase",
|
"phase": "Fase",
|
||||||
"readyTitle": "Il suo assistente è pronto!",
|
"readyTitle": "Il suo assistente è pronto!",
|
||||||
"readyDescription": "Il suo assistente IA è stato attivato ed è operativo. Ora può gestirlo dalla dashboard.",
|
"readyDescription": "Il tuo assistente IA è stato provisionato ed è in funzione. Ora collegalo alla tua app di messaggistica per iniziare a chattare.",
|
||||||
"goToDashboard": "Vada alla dashboard",
|
"goToDashboard": "Vada alla dashboard",
|
||||||
"submittedAt": "Inviato",
|
"submittedAt": "Inviato",
|
||||||
"instanceName": "Nome istanza",
|
"instanceName": "Nome istanza",
|
||||||
@@ -142,7 +144,15 @@
|
|||||||
"telegram": "Apra Telegram, scriva a @userinfobot e incolli qui l'ID numerico restituito. Potrà aggiungere altri utenti in seguito dalla pagina del tenant.",
|
"telegram": "Apra Telegram, scriva a @userinfobot e incolli qui l'ID numerico restituito. Potrà aggiungere altri utenti in seguito dalla pagina del tenant.",
|
||||||
"discord": "Attivi la Modalità sviluppatore in Discord (Impostazioni avanzate), clic destro sul suo nome → Copia ID utente, poi incolli qui. Potrà aggiungere altri utenti in seguito dalla pagina del tenant.",
|
"discord": "Attivi la Modalità sviluppatore in Discord (Impostazioni avanzate), clic destro sul suo nome → Copia ID utente, poi incolli qui. Potrà aggiungere altri utenti in seguito dalla pagina del tenant.",
|
||||||
"threema": "Gli 8 caratteri mostrati nella sua app Threema in Impostazioni → Il mio ID Threema. Una volta approvato il suo tenant e attivato Threema, potrà chattare con l'assistente da questo account. Altri ID autorizzati possono essere aggiunti in seguito dalla pagina del tenant."
|
"threema": "Gli 8 caratteri mostrati nella sua app Threema in Impostazioni → Il mio ID Threema. Una volta approvato il suo tenant e attivato Threema, potrà chattare con l'assistente da questo account. Altri ID autorizzati possono essere aggiunti in seguito dalla pagina del tenant."
|
||||||
}
|
},
|
||||||
|
"connectCta": "Collega il tuo assistente",
|
||||||
|
"packagesIncompleteHint": "Completa i dettagli richiesti per: {packages}",
|
||||||
|
"setupProgress": "Avanzamento configurazione",
|
||||||
|
"setupStepsComplete": "{done} di {total} passaggi",
|
||||||
|
"costSummaryHeading": "Quanto pagherai",
|
||||||
|
"costSetupLabel": "Attivazione una tantum",
|
||||||
|
"costMonthlyLabel": "Mensile, per assistente",
|
||||||
|
"costUsageNote": "Più i costi dell'IA in base all'utilizzo, fatturati mensilmente in CHF. Puoi impostare un limite di spesa per assistente in qualsiasi momento."
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Dashboard",
|
"title": "Dashboard",
|
||||||
@@ -225,7 +235,10 @@
|
|||||||
"budgetCadence_1mo": "Mensile",
|
"budgetCadence_1mo": "Mensile",
|
||||||
"budgetCadence_1y": "Annuale",
|
"budgetCadence_1y": "Annuale",
|
||||||
"budgetInvalid": "Inserisca un importo positivo.",
|
"budgetInvalid": "Inserisca un importo positivo.",
|
||||||
"budgetSaveFailed": "Impossibile salvare il budget. Riprova."
|
"budgetSaveFailed": "Impossibile salvare il budget. Riprova.",
|
||||||
|
"legendInput": "Input",
|
||||||
|
"legendOutput": "Output",
|
||||||
|
"chartHint": "Tocca una barra per i dettagli"
|
||||||
},
|
},
|
||||||
"workspace": {
|
"workspace": {
|
||||||
"save": "Salvi",
|
"save": "Salvi",
|
||||||
@@ -420,7 +433,18 @@
|
|||||||
"openclawTool": "Versioni OpenClaw",
|
"openclawTool": "Versioni OpenClaw",
|
||||||
"billingTool": "Fatturazione →",
|
"billingTool": "Fatturazione →",
|
||||||
"skillsQueueTool": "Coda di attivazione",
|
"skillsQueueTool": "Coda di attivazione",
|
||||||
"cronTool": "Automazione"
|
"cronTool": "Automazione",
|
||||||
|
"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.",
|
||||||
|
"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",
|
||||||
|
"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",
|
||||||
@@ -467,7 +491,15 @@
|
|||||||
"roleUpdateFailed": "Impossibile aggiornare il ruolo.",
|
"roleUpdateFailed": "Impossibile aggiornare il ruolo.",
|
||||||
"cancel": "Annulli",
|
"cancel": "Annulli",
|
||||||
"save": "Salvi",
|
"save": "Salvi",
|
||||||
"selfChangeBlocked": "Non può modificare il suo ruolo."
|
"selfChangeBlocked": "Non può modificare il suo ruolo.",
|
||||||
|
"accessTitle": "Panoramica accessi",
|
||||||
|
"accessDescription": "Quale membro può accedere a quale assistente.",
|
||||||
|
"accessMemberCol": "Membro",
|
||||||
|
"accessOwnerAll": "Tutti gli assistenti (proprietario)",
|
||||||
|
"accessHasLabel": "Accesso",
|
||||||
|
"accessHasNotLabel": "Nessun accesso",
|
||||||
|
"accessNoTenants": "Ancora nessun assistente.",
|
||||||
|
"accessLoadFailed": "Impossibile caricare la panoramica degli accessi."
|
||||||
},
|
},
|
||||||
"assignments": {
|
"assignments": {
|
||||||
"loading": "Caricamento assegnazioni…",
|
"loading": "Caricamento assegnazioni…",
|
||||||
@@ -823,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",
|
||||||
@@ -962,6 +995,29 @@
|
|||||||
"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": {
|
||||||
|
"title": "Si è verificato un errore",
|
||||||
|
"description": "Si è verificato un errore durante il caricamento di questa pagina. Riprova.",
|
||||||
|
"retry": "Riprova",
|
||||||
|
"backToDashboard": "Torna alla dashboard",
|
||||||
|
"notFoundTitle": "Pagina non trovata",
|
||||||
|
"notFoundDescription": "La pagina che stai cercando non esiste o è stata spostata."
|
||||||
|
},
|
||||||
|
"connect": {
|
||||||
|
"title": "Collegati al tuo assistente",
|
||||||
|
"description": "Il tuo assistente funziona all'interno della tua app di messaggistica. Ecco come iniziare a chattare con lui.",
|
||||||
|
"notReadyNote": "Il tuo assistente è ancora in fase di configurazione. Questi dettagli di connessione funzioneranno non appena sarà pronto.",
|
||||||
|
"noChannelsTitle": "Nessun canale di messaggistica",
|
||||||
|
"noChannelsBody": "Il tuo assistente è in funzione ma non ha alcun canale per chattare. Attiva un canale — Threema, Telegram o Discord — nella sezione Pacchetti qui sotto per iniziare a usarlo.",
|
||||||
|
"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.",
|
||||||
|
"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.",
|
||||||
|
"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