feat(team): add access overview matrix for owners
This commit is contained in:
@@ -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.
|
||||||
@@ -70,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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -147,7 +147,11 @@
|
|||||||
"connectCta": "Assistenten verbinden",
|
"connectCta": "Assistenten verbinden",
|
||||||
"packagesIncompleteHint": "Bitte ergänzen Sie die erforderlichen Angaben für: {packages}",
|
"packagesIncompleteHint": "Bitte ergänzen Sie die erforderlichen Angaben für: {packages}",
|
||||||
"setupProgress": "Einrichtungsfortschritt",
|
"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": {
|
"dashboard": {
|
||||||
"title": "Dashboard",
|
"title": "Dashboard",
|
||||||
@@ -479,7 +483,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…",
|
||||||
|
|||||||
@@ -147,7 +147,11 @@
|
|||||||
"connectCta": "Connect your assistant",
|
"connectCta": "Connect your assistant",
|
||||||
"packagesIncompleteHint": "Add the required details for: {packages}",
|
"packagesIncompleteHint": "Add the required details for: {packages}",
|
||||||
"setupProgress": "Setup progress",
|
"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": {
|
"dashboard": {
|
||||||
"title": "Dashboard",
|
"title": "Dashboard",
|
||||||
@@ -479,7 +483,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…",
|
||||||
|
|||||||
@@ -147,7 +147,11 @@
|
|||||||
"connectCta": "Connecter votre assistant",
|
"connectCta": "Connecter votre assistant",
|
||||||
"packagesIncompleteHint": "Complétez les informations requises pour : {packages}",
|
"packagesIncompleteHint": "Complétez les informations requises pour : {packages}",
|
||||||
"setupProgress": "Progression de la configuration",
|
"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": {
|
"dashboard": {
|
||||||
"title": "Tableau de bord",
|
"title": "Tableau de bord",
|
||||||
@@ -479,7 +483,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…",
|
||||||
|
|||||||
@@ -147,7 +147,11 @@
|
|||||||
"connectCta": "Collega il tuo assistente",
|
"connectCta": "Collega il tuo assistente",
|
||||||
"packagesIncompleteHint": "Completa i dettagli richiesti per: {packages}",
|
"packagesIncompleteHint": "Completa i dettagli richiesti per: {packages}",
|
||||||
"setupProgress": "Avanzamento configurazione",
|
"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": {
|
"dashboard": {
|
||||||
"title": "Dashboard",
|
"title": "Dashboard",
|
||||||
@@ -479,7 +483,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…",
|
||||||
|
|||||||
Reference in New Issue
Block a user