feat(team): add access overview matrix for owners

This commit is contained in:
2026-05-29 23:37:56 +02:00
parent 74d276b656
commit 521398b0fc
6 changed files with 286 additions and 8 deletions

View File

@@ -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)}
/>
</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>
);
}

View 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>
);
}

View File

@@ -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…",

View File

@@ -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…",

View File

@@ -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…",

View File

@@ -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…",