From 521398b0fc90dafa51c8837ef917a0f21d18e67f Mon Sep 17 00:00:00 2001 From: admin Date: Fri, 29 May 2026 23:37:56 +0200 Subject: [PATCH] feat(team): add access overview matrix for owners --- src/app/[locale]/team/page.tsx | 11 ++ src/components/team/access-overview.tsx | 219 ++++++++++++++++++++++++ src/messages/de.json | 16 +- src/messages/en.json | 16 +- src/messages/fr.json | 16 +- src/messages/it.json | 16 +- 6 files changed, 286 insertions(+), 8 deletions(-) create mode 100644 src/components/team/access-overview.tsx diff --git a/src/app/[locale]/team/page.tsx b/src/app/[locale]/team/page.tsx index ae2d013..f1c19ce 100644 --- a/src/app/[locale]/team/page.tsx +++ b/src/app/[locale]/team/page.tsx @@ -6,6 +6,7 @@ import { Card } from "@/components/ui/card"; import { BackLink } from "@/components/ui/back-link"; import { TeamList } from "@/components/team/team-list"; import { InviteForm } from "@/components/team/invite-form"; +import { AccessOverview } from "@/components/team/access-overview"; /** * /team — manage org members. @@ -70,6 +71,16 @@ export default async function TeamPage() { canEditRoles={isCustomerOwner(user)} /> + + {/* Access overview — single place to see which member can reach + which assistant, instead of checking each tenant page. */} +
+

+ {t("accessTitle")} +

+

{t("accessDescription")}

+ +
); } diff --git a/src/components/team/access-overview.tsx b/src/components/team/access-overview.tsx new file mode 100644 index 0000000..a78b507 --- /dev/null +++ b/src/components/team/access-overview.tsx @@ -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(null); + const [tenants, setTenants] = useState(null); + // tenant name → set of assigned userIds + const [assignments, setAssignments] = useState>>( + {} + ); + 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()] as const; + const data = await r.json(); + const ids = new Set( + (data.assignments ?? data ?? []).map((a: any) => a.userId) + ); + return [tn.name, ids] as const; + } catch { + return [tn.name, new Set()] 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 ( +
+
+
+
+ ); + } + + if (error) { + return ( +
+

{error}

+
+ ); + } + + if (!tenants || tenants.length === 0) { + return ( +
+

{t("accessNoTenants")}

+
+ ); + } + + const isOwner = (m: Member) => m.roles?.includes("owner"); + + return ( +
+
+ + + + + {tenants.map((tn) => ( + + ))} + + + + {(members ?? []).map((m) => ( + + + {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 ( + + ); + })} + + ))} + +
+ {t("accessMemberCol")} + + {tn.displayName} +
+
+ {m.displayName || m.email} +
+
+ {m.email} +
+
+ {label} + {owner ? ( + + ) : has ? ( + + ) : ( + + )} +
+
+
+ + {t("accessOwnerAll")} + + + {" "} + {t("accessHasLabel")} + + + {t("accessHasNotLabel")} + +
+
+ ); +} diff --git a/src/messages/de.json b/src/messages/de.json index fc09704..86cb207 100644 --- a/src/messages/de.json +++ b/src/messages/de.json @@ -147,7 +147,11 @@ "connectCta": "Assistenten verbinden", "packagesIncompleteHint": "Bitte ergänzen Sie die erforderlichen Angaben für: {packages}", "setupProgress": "Einrichtungsfortschritt", - "setupStepsComplete": "{done} von {total} Schritten" + "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": { "title": "Dashboard", @@ -479,7 +483,15 @@ "roleUpdateFailed": "Rolle konnte nicht aktualisiert werden.", "cancel": "Abbrechen", "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": { "loading": "Zuweisungen werden geladen…", diff --git a/src/messages/en.json b/src/messages/en.json index 7f3b549..3f9a20c 100644 --- a/src/messages/en.json +++ b/src/messages/en.json @@ -147,7 +147,11 @@ "connectCta": "Connect your assistant", "packagesIncompleteHint": "Add the required details for: {packages}", "setupProgress": "Setup progress", - "setupStepsComplete": "{done} of {total} steps" + "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": { "title": "Dashboard", @@ -479,7 +483,15 @@ "roleUpdateFailed": "Could not update role.", "cancel": "Cancel", "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": { "loading": "Loading assignments…", diff --git a/src/messages/fr.json b/src/messages/fr.json index f11a0cf..32c40c3 100644 --- a/src/messages/fr.json +++ b/src/messages/fr.json @@ -147,7 +147,11 @@ "connectCta": "Connecter votre assistant", "packagesIncompleteHint": "Complétez les informations requises pour : {packages}", "setupProgress": "Progression de la configuration", - "setupStepsComplete": "{done} sur {total} étapes" + "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": { "title": "Tableau de bord", @@ -479,7 +483,15 @@ "roleUpdateFailed": "Impossible de mettre à jour le rôle.", "cancel": "Annuler", "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": { "loading": "Chargement des attributions…", diff --git a/src/messages/it.json b/src/messages/it.json index 2c4ad05..80bb215 100644 --- a/src/messages/it.json +++ b/src/messages/it.json @@ -147,7 +147,11 @@ "connectCta": "Collega il tuo assistente", "packagesIncompleteHint": "Completa i dettagli richiesti per: {packages}", "setupProgress": "Avanzamento configurazione", - "setupStepsComplete": "{done} di {total} passaggi" + "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": { "title": "Dashboard", @@ -479,7 +483,15 @@ "roleUpdateFailed": "Impossibile aggiornare il ruolo.", "cancel": "Annulli", "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": { "loading": "Caricamento assegnazioni…",