Role split and owner gating
All checks were successful
Build and Push / build (push) Successful in 1m24s

This commit is contained in:
2026-04-26 22:45:38 +02:00
parent 3521a0ff4f
commit 7c4e20099d
18 changed files with 347 additions and 91 deletions

View File

@@ -0,0 +1,38 @@
// Standalone JS port of `lib/session.ts::canMutate` and `isCustomerOwner`
// for offline verification.
//
// SessionUser shape mirrors the TypeScript interface:
// { roles: Role[], isPlatform: boolean, ... }
function canMutate(user) {
return user.isPlatform || user.roles.includes("owner");
}
function isCustomerOwner(user) {
return !user.isPlatform && user.roles.includes("owner");
}
const cases = [
// [user, fn, expected, note]
[{ isPlatform: true, roles: ["platform_admin"] }, canMutate, true, "platform admin can mutate"],
[{ isPlatform: true, roles: ["platform_operator"] }, canMutate, true, "platform operator can mutate"],
[{ isPlatform: false, roles: ["owner"] }, canMutate, true, "customer owner can mutate"],
[{ isPlatform: false, roles: ["user"] }, canMutate, false, "customer user cannot mutate"],
[{ isPlatform: false, roles: [] }, canMutate, false, "no roles cannot mutate"],
[{ isPlatform: false, roles: ["owner", "user"] }, canMutate, true, "owner+user (owner wins)"],
[{ isPlatform: true, roles: ["platform_admin", "owner"] }, isCustomerOwner, false, "platform user with owner role is NOT customerOwner"],
[{ isPlatform: false, roles: ["owner"] }, isCustomerOwner, true, "pure customer owner"],
[{ isPlatform: false, roles: ["user"] }, isCustomerOwner, false, "customer user is not customerOwner"],
[{ isPlatform: false, roles: [] }, isCustomerOwner, false, "empty roles is not customerOwner"],
];
let pass = 0, fail = 0;
for (const [user, fn, expected, note] of cases) {
const got = fn(user);
const ok = got === expected;
console.log(`${ok ? "PASS" : "FAIL"} got=${got} want=${expected} [${note}]`);
if (ok) pass++; else fail++;
}
console.log(`\n${pass} pass, ${fail} fail`);
process.exit(fail === 0 ? 0 : 1);

View File

@@ -1,4 +1,4 @@
import { getSessionUser } from "@/lib/session";
import { getSessionUser, canMutate } from "@/lib/session";
import { getTranslations } from "next-intl/server";
import { redirect } from "next/navigation";
import { OnboardingFlow } from "@/components/onboarding/onboarding-flow";
@@ -16,11 +16,17 @@ import Link from "next/link";
*
* Platform admins are redirected to /dashboard — they shouldn't be
* creating tenant instances under their own org.
*
* Slice 5: customer-side `user` role is also redirected — only owners
* may create new instances. The server-side POST handler enforces the
* same; this redirect is purely UX so /user-role members don't land on
* a wizard that will 403 on submit.
*/
export default async function NewInstancePage() {
const user = await getSessionUser();
if (!user) redirect("/login");
if (user.isPlatform) redirect("/dashboard");
if (!canMutate(user)) redirect("/dashboard");
const t = await getTranslations("dashboard");

View File

@@ -1,4 +1,4 @@
import { getSessionUser } from "@/lib/session";
import { getSessionUser, canMutate } from "@/lib/session";
import { getTranslations, getFormatter } from "next-intl/server";
import { redirect } from "next/navigation";
import { listTenants } from "@/lib/k8s";
@@ -149,8 +149,39 @@ export default async function DashboardPage() {
(r) => !r.tenantName || !orgTenants.some((t) => t.metadata.name === r.tenantName)
);
// Slice 5: only owners (and platform users, who'd typically be using
// the admin panel anyway) see the "Create new instance" link. A
// `user`-role member sees the dashboard but not the create flow —
// they need to ask an owner.
const canCreate = canMutate(user);
// First-time user: empty company. Show the onboarding wizard inline.
// Note: the registering user is always granted `owner` on their new
// org by registerCustomer, so this branch is only reachable by an
// owner — no role check needed here. But a customer-side `user`
// promoted into a fresh empty org (Slice 7 invites) would also land
// here without permission to submit. Belt-and-braces gate.
if (orgTenants.length === 0 && inflightRequests.length === 0) {
if (!canCreate) {
return (
<div>
<div className="mb-8 animate-in">
<h1 className="font-display text-2xl font-semibold accent-rule mb-2">
{t("title")}
</h1>
<p className="text-text-secondary text-sm mt-4">
{t("welcome", { name: user.name || user.email })}
</p>
</div>
<Card className="animate-in animate-in-delay-1">
<p className="text-sm text-text-secondary text-center py-6">
{t("noAccessNoInstances")}
</p>
</Card>
</div>
);
}
return (
<div>
<div className="mb-8 animate-in">
@@ -170,7 +201,7 @@ export default async function DashboardPage() {
}
// Returning customer: list of tenants + in-flight requests, plus
// a button to add another instance.
// a button to add another instance (owners only).
return (
<div>
<div className="mb-8 animate-in flex items-start justify-between gap-4">
@@ -183,12 +214,14 @@ export default async function DashboardPage() {
</p>
</div>
<Link
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"
>
<span>+</span> {t("createInstance")}
</Link>
{canCreate && (
<Link
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"
>
<span>+</span> {t("createInstance")}
</Link>
)}
</div>
{/* In-flight (pending/approved/provisioning/rejected) requests */}

View File

@@ -1,4 +1,4 @@
import { getSessionUser } from "@/lib/session";
import { getSessionUser, canMutate } from "@/lib/session";
import { getTranslations, getFormatter } from "next-intl/server";
import { redirect, notFound } from "next/navigation";
import { getTenant } from "@/lib/k8s";
@@ -34,6 +34,11 @@ export default async function TenantDetailPage({
notFound();
}
// Slice 5: editable surface gated on owner role. Platform users always
// can edit; customer-side, only `owner` may. `user`-role members see
// the same page but with edit controls hidden / fields read-only.
const canEdit = canMutate(user);
const enabledPackages = tenant.spec.packages || [];
const workspaceFiles = tenant.spec.workspaceFiles || {};
const enabledChannels = enabledPackages.filter((pkg) =>
@@ -100,6 +105,7 @@ export default async function TenantDetailPage({
tenantName={name}
enabledPackages={enabledPackages}
conditions={tenant.status?.conditions}
canEdit={canEdit}
/>
</section>
@@ -110,6 +116,7 @@ export default async function TenantDetailPage({
tenantName={name}
enabledChannels={enabledChannels}
initialChannelUsers={channelUsers}
canEdit={canEdit}
/>
</section>
)}
@@ -119,7 +126,7 @@ export default async function TenantDetailPage({
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
{t("workspaceFiles")}
</h2>
<WorkspaceEditor tenantName={name} files={workspaceFiles} />
<WorkspaceEditor tenantName={name} files={workspaceFiles} canEdit={canEdit} />
</section>
</div>
);

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
import { getSessionUser } from "@/lib/session";
import { getSessionUser, canMutate } from "@/lib/session";
import {
createTenantRequest,
getTenantRequestById,
@@ -157,6 +157,15 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Slice 5: only owners (or platform users) may create new instances.
// A `user`-role member of an existing org cannot self-provision.
if (!canMutate(user)) {
return NextResponse.json(
{ error: "Only the organization owner can create new instances." },
{ status: 403 }
);
}
const body = await request.json();
const parsed = onboardingSchema.safeParse(body);
if (!parsed.success) {

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
import { getSessionUser } from "@/lib/session";
import { getSessionUser, canMutate } from "@/lib/session";
import { getTenant, patchTenantSpec } from "@/lib/k8s";
import { getPackageDef } from "@/lib/packages";
import { safeError } from "@/lib/errors";
@@ -46,7 +46,7 @@ export async function PATCH(
if (!user)
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if (!user.isPlatform && !user.roles.includes("owner")) {
if (!canMutate(user)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
import { getSessionUser } from "@/lib/session";
import { getSessionUser, canMutate } from "@/lib/session";
import { getTenant } from "@/lib/k8s";
import { writePackageSecrets } from "@/lib/openbao";
import { getPackageDef } from "@/lib/packages";
@@ -12,7 +12,7 @@ export async function POST(
if (!user)
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if (!user.isPlatform && !user.roles.includes("owner")) {
if (!canMutate(user)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}

View File

@@ -17,12 +17,15 @@ interface ChannelUsersProps {
enabledChannels: string[];
/** Current channelUsers from the PiecedTenant spec */
initialChannelUsers: Record<string, string[]>;
/** Slice 5: when false, add inputs and remove ✕ buttons are hidden. */
canEdit?: boolean;
}
export function ChannelUsers({
tenantName,
enabledChannels,
initialChannelUsers,
canEdit = true,
}: ChannelUsersProps) {
const t = useTranslations("channelUsers");
const router = useRouter();
@@ -146,44 +149,48 @@ export function ChannelUsers({
className="inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-mono bg-accent/10 text-accent border border-accent/20 rounded-full"
>
{userId}
<button
onClick={() => handleRemove(channel, userId)}
disabled={saving}
className="text-accent/60 hover:text-red-400 transition-colors disabled:opacity-50"
title={t("remove")}
>
</button>
{canEdit && (
<button
onClick={() => handleRemove(channel, userId)}
disabled={saving}
className="text-accent/60 hover:text-red-400 transition-colors disabled:opacity-50"
title={t("remove")}
>
</button>
)}
</span>
))}
</div>
)}
{/* Add user */}
<div className="flex gap-2">
<input
type="text"
value={inputValues[channel] || ""}
onChange={(e) =>
setInputValues((prev) => ({
...prev,
[channel]: e.target.value,
}))
}
onKeyDown={(e) => {
if (e.key === "Enter") handleAdd(channel);
}}
placeholder={t("placeholder")}
className="flex-1 px-3 py-2 bg-surface-1 border border-border rounded-lg text-sm text-text-primary font-mono placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-colors"
/>
<button
onClick={() => handleAdd(channel)}
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"
>
{saving ? "…" : t("add")}
</button>
</div>
{/* Add user — hidden in read-only mode */}
{canEdit && (
<div className="flex gap-2">
<input
type="text"
value={inputValues[channel] || ""}
onChange={(e) =>
setInputValues((prev) => ({
...prev,
[channel]: e.target.value,
}))
}
onKeyDown={(e) => {
if (e.key === "Enter") handleAdd(channel);
}}
placeholder={t("placeholder")}
className="flex-1 px-3 py-2 bg-surface-1 border border-border rounded-lg text-sm text-text-primary font-mono placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-colors"
/>
<button
onClick={() => handleAdd(channel)}
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"
>
{saving ? "…" : t("add")}
</button>
</div>
)}
</div>
);
})}

View File

@@ -10,9 +10,18 @@ interface Props {
status?: "pending" | "active" | "error";
tenantName: string;
onToggled: () => void;
/** Slice 5: when false, the enable/disable button is hidden. */
canEdit?: boolean;
}
export function PackageCard({ pkg, enabled, status, tenantName, onToggled }: Props) {
export function PackageCard({
pkg,
enabled,
status,
tenantName,
onToggled,
canEdit = true,
}: Props) {
const t = useTranslations();
const [showModal, setShowModal] = useState(false);
const [secrets, setSecrets] = useState<Record<string, string>>({});
@@ -113,17 +122,27 @@ export function PackageCard({ pkg, enabled, status, tenantName, onToggled }: Pro
{pkg.requiresSecrets && (
<span className="text-[10px] text-text-muted">{t("packages.requiresApiKey")}</span>
)}
<button
onClick={enabled ? () => togglePackage(false) : handleEnable}
disabled={saving}
className={`ml-auto rounded-lg px-3 py-1.5 text-xs font-medium transition-all cursor-pointer ${
enabled
? "bg-surface-3 text-text-secondary hover:text-text-primary hover:bg-surface-2"
: "bg-accent text-surface-0 hover:bg-accent-dim shadow-lg shadow-accent/20"
} disabled:opacity-50`}
>
{saving ? "…" : enabled ? t("packages.disable") : t("packages.enable")}
</button>
{canEdit ? (
<button
onClick={enabled ? () => togglePackage(false) : handleEnable}
disabled={saving}
className={`ml-auto rounded-lg px-3 py-1.5 text-xs font-medium transition-all cursor-pointer ${
enabled
? "bg-surface-3 text-text-secondary hover:text-text-primary hover:bg-surface-2"
: "bg-accent text-surface-0 hover:bg-accent-dim shadow-lg shadow-accent/20"
} disabled:opacity-50`}
>
{saving ? "…" : enabled ? t("packages.disable") : t("packages.enable")}
</button>
) : (
// Slice 5: read-only viewers see a static badge instead of a
// toggle. The status badge above the divider already conveys
// "active/pending/error"; this just clarifies "you can't change
// it" without duplicating the status colour.
<span className="ml-auto text-[10px] text-text-muted italic">
{enabled ? t("packages.statusEnabled") : t("packages.statusDisabled")}
</span>
)}
</div>
</div>

View File

@@ -10,6 +10,8 @@ interface Props {
enabledPackages: string[];
conditions?: Array<{ type: string; status: string; reason?: string }>;
onRefresh?: () => void;
/** Slice 5: when false, package toggles and edit affordances are hidden. */
canEdit?: boolean;
}
const CATEGORIES = [
@@ -30,7 +32,13 @@ function getPackageStatus(
return "error";
}
export function PackageList({ tenantName, enabledPackages, conditions, onRefresh }: Props) {
export function PackageList({
tenantName,
enabledPackages,
conditions,
onRefresh,
canEdit = true,
}: Props) {
const t = useTranslations("packages");
const router = useRouter();
const handleRefresh = onRefresh || (() => router.refresh());
@@ -55,6 +63,7 @@ export function PackageList({ tenantName, enabledPackages, conditions, onRefresh
status={getPackageStatus(pkg.id, enabledPackages.includes(pkg.id), conditions)}
tenantName={tenantName}
onToggled={handleRefresh}
canEdit={canEdit}
/>
))}
</div>

View File

@@ -8,9 +8,11 @@ const FILE_TABS = ["SOUL.md", "AGENTS.md", "TOOLS.md"] as const;
interface Props {
tenantName: string;
files: Record<string, string>;
/** Slice 5: when false, save button hidden and textarea is read-only. */
canEdit?: boolean;
}
export function WorkspaceEditor({ tenantName, files }: Props) {
export function WorkspaceEditor({ tenantName, files, canEdit = true }: Props) {
const t = useTranslations("workspace");
const [activeTab, setActiveTab] = useState<string>("SOUL.md");
const [localFiles, setLocalFiles] = useState<Record<string, string>>(files);
@@ -19,6 +21,7 @@ export function WorkspaceEditor({ tenantName, files }: Props) {
const [error, setError] = useState<string | null>(null);
function handleChange(content: string) {
if (!canEdit) return;
setLocalFiles((prev) => ({ ...prev, [activeTab]: content }));
setDirty(true);
}
@@ -62,20 +65,25 @@ export function WorkspaceEditor({ tenantName, files }: Props) {
</button>
))}
</div>
<button
onClick={handleSave}
disabled={!dirty || saving}
className="rounded-lg bg-accent px-3 py-1 text-xs font-medium text-surface-0 hover:bg-accent-dim disabled:opacity-40 cursor-pointer"
>
{saving ? "…" : t("save")}
</button>
{canEdit && (
<button
onClick={handleSave}
disabled={!dirty || saving}
className="rounded-lg bg-accent px-3 py-1 text-xs font-medium text-surface-0 hover:bg-accent-dim disabled:opacity-40 cursor-pointer"
>
{saving ? "…" : t("save")}
</button>
)}
</div>
<textarea
value={localFiles[activeTab] || ""}
onChange={(e) => handleChange(e.target.value)}
readOnly={!canEdit}
spellCheck={false}
className="w-full min-h-[300px] resize-y bg-transparent p-4 font-mono text-sm text-text-secondary placeholder:text-text-muted focus:outline-none"
className={`w-full min-h-[300px] resize-y bg-transparent p-4 font-mono text-sm text-text-secondary placeholder:text-text-muted focus:outline-none ${
!canEdit ? "cursor-default" : ""
}`}
placeholder={t("placeholder", { file: activeTab })}
/>

View File

@@ -1,14 +1,25 @@
import NextAuth from "next-auth";
import type { NextAuthConfig } from "next-auth";
import type { PlatformRole, SessionUser, ZitadelClaims } from "@/types";
import type { PlatformRole, Role, SessionUser, ZitadelClaims } from "@/types";
const PLATFORM_ROLES: PlatformRole[] = ["platform_admin", "platform_operator"];
/**
* Pull the role keys from the ZITADEL `urn:zitadel:iam:org:project:roles`
* claim. The claim is shaped as { roleKey: { orgId: orgName } } — we only
* need the keys.
*
* Slice 5: returns Role[] (the union) rather than PlatformRole[]. The
* keys can be either platform or customer roles depending on what the
* project authorization granted; the SessionUser carries them all and
* downstream helpers (canMutate, isCustomerOwner, requirePlatformRole)
* decide what each subset means.
*/
function extractRoles(
rolesObj?: Record<string, Record<string, string>>
): PlatformRole[] {
): Role[] {
if (!rolesObj) return [];
return Object.keys(rolesObj) as PlatformRole[];
return Object.keys(rolesObj) as Role[];
}
export const authConfig: NextAuthConfig = {
@@ -50,7 +61,7 @@ export const authConfig: NextAuthConfig = {
return token;
},
async session({ session, token }) {
const roles = (token.roles as PlatformRole[]) ?? [];
const roles = (token.roles as Role[]) ?? [];
const sessionUser: SessionUser = {
id: token.sub!,
name: session.user?.name ?? "",
@@ -58,7 +69,9 @@ export const authConfig: NextAuthConfig = {
orgId: token.orgId as string,
orgName: token.orgName as string,
roles,
isPlatform: roles.some((r) => PLATFORM_ROLES.includes(r)),
isPlatform: roles.some((r) =>
PLATFORM_ROLES.includes(r as PlatformRole)
),
};
(session as any).platformUser = sessionUser;
return session;

View File

@@ -1,19 +1,87 @@
import { auth } from "@/lib/auth";
import type { SessionUser } from "@/types";
/**
* Read-only session lookup. Returns the SessionUser stashed on the
* NextAuth session by `auth.ts::callbacks.session`, or null if there
* is no authenticated session.
*/
export async function getSessionUser(): Promise<SessionUser | null> {
const session = await auth();
return (session as any)?.platformUser ?? null;
}
/**
* Throws if there is no authenticated session. Otherwise returns the
* SessionUser. Use at the top of any handler that requires a logged-in
* user regardless of role.
*/
export async function requireSession(): Promise<SessionUser> {
const user = await getSessionUser();
if (!user) throw new Error("Unauthorized");
return user;
}
/**
* Throws unless the caller has a platform-level role
* (platform_admin or platform_operator). Use to gate /api/admin/*
* routes — these handle ANY customer's org and must not be accessible
* to customer-role users.
*/
export async function requirePlatformRole(): Promise<SessionUser> {
const user = await requireSession();
if (!user.isPlatform) throw new Error("Forbidden: platform role required");
return user;
}
// ---------------------------------------------------------------------------
// Slice 5: role predicates and gates
// ---------------------------------------------------------------------------
//
// Naming convention: `is*` are pure predicates over a SessionUser,
// safe to call inline in JSX/server components. `require*` throw on
// failure and are meant for the top of route handlers.
/**
* True when the user is a platform admin/operator OR holds the
* `owner` customer role on their org.
*
* This is the single check for "can mutate". Platform users always
* win because they administer all orgs cross-cut. Customer-side, only
* `owner` may mutate; `user` (and any future read-only customer role)
* cannot.
*/
export function canMutate(user: SessionUser): boolean {
return user.isPlatform || user.roles.includes("owner");
}
/**
* True when the user holds the customer `owner` role on their org.
* Excludes platform users — use {@link canMutate} when both should
* be allowed.
*
* Useful for permissions that are specifically about "this customer's
* own owner", e.g. "owner can invite users into their own org" — a
* platform user shouldn't be casually inviting users into a customer
* org, that's an admin-console action and goes through different
* tooling.
*/
export function isCustomerOwner(user: SessionUser): boolean {
return !user.isPlatform && user.roles.includes("owner");
}
/**
* Throws unless `canMutate(user) === true`. Use at the top of any
* mutating customer-side handler.
*
* The thrown error message is intentionally generic — handlers
* should catch and translate to a 403 JSON response so the client
* doesn't see a stack trace.
*/
export async function requireOwnerRole(): Promise<SessionUser> {
const user = await requireSession();
if (!canMutate(user)) {
throw new Error("Forbidden: owner role required");
}
return user;
}

View File

@@ -103,7 +103,8 @@
"instances": "Ihre Instanzen",
"inflightRequests": "Laufende Anfragen",
"createInstance": "Neue Instanz erstellen",
"createInstanceDescription": "Eine weitere KI-Assistent-Instanz für Ihre Organisation bereitstellen. Die Anfrage wird von einem Administrator geprüft, bevor die Instanz erstellt wird."
"createInstanceDescription": "Eine weitere KI-Assistent-Instanz für Ihre Organisation bereitstellen. Die Anfrage wird von einem Administrator geprüft, bevor die Instanz erstellt wird.",
"noAccessNoInstances": "Ihre Organisation hat noch keine Instanzen. Bitte bitten Sie den Eigentümer der Organisation, eine einzurichten."
},
"tenantDetail": {
"agent": "Agent",
@@ -179,7 +180,9 @@
},
"documentProcessing": {
"description": "Aktivieren Sie Dokumentenverarbeitung, Zusammenfassung und Extraktion."
}
},
"statusEnabled": "aktiviert",
"statusDisabled": "deaktiviert"
},
"admin": {
"title": "Plattform-Admin",

View File

@@ -103,7 +103,8 @@
"instances": "Your instances",
"inflightRequests": "In-flight requests",
"createInstance": "Create new instance",
"createInstanceDescription": "Provision an additional AI assistant instance for your organization. The request will be reviewed by an administrator before the instance is created."
"createInstanceDescription": "Provision an additional AI assistant instance for your organization. The request will be reviewed by an administrator before the instance is created.",
"noAccessNoInstances": "Your organization doesn't have any instances yet. Please ask the organization owner to set one up."
},
"tenantDetail": {
"agent": "Agent",
@@ -179,7 +180,9 @@
},
"documentProcessing": {
"description": "Enable document parsing, summarization, and extraction."
}
},
"statusEnabled": "enabled",
"statusDisabled": "disabled"
},
"admin": {
"title": "Platform Admin",

View File

@@ -103,7 +103,8 @@
"instances": "Vos instances",
"inflightRequests": "Demandes en cours",
"createInstance": "Créer une nouvelle instance",
"createInstanceDescription": "Provisionner une instance supplémentaire d'assistant IA pour votre organisation. La demande sera examinée par un administrateur avant la création de l'instance."
"createInstanceDescription": "Provisionner une instance supplémentaire d'assistant IA pour votre organisation. La demande sera examinée par un administrateur avant la création de l'instance.",
"noAccessNoInstances": "Votre organisation n'a pas encore d'instances. Demandez au propriétaire de l'organisation d'en configurer une."
},
"tenantDetail": {
"agent": "Agent",
@@ -179,7 +180,9 @@
},
"documentProcessing": {
"description": "Activez l'analyse, le résumé et l'extraction de documents."
}
},
"statusEnabled": "activé",
"statusDisabled": "désactivé"
},
"admin": {
"title": "Admin plateforme",

View File

@@ -103,7 +103,8 @@
"instances": "Le tue istanze",
"inflightRequests": "Richieste in corso",
"createInstance": "Crea nuova istanza",
"createInstanceDescription": "Effettua il provisioning di un'ulteriore istanza dell'assistente IA per la tua organizzazione. La richiesta sarà esaminata da un amministratore prima della creazione dell'istanza."
"createInstanceDescription": "Effettua il provisioning di un'ulteriore istanza dell'assistente IA per la tua organizzazione. La richiesta sarà esaminata da un amministratore prima della creazione dell'istanza.",
"noAccessNoInstances": "La tua organizzazione non ha ancora istanze. Chiedi al proprietario dell'organizzazione di configurarne una."
},
"tenantDetail": {
"agent": "Agente",
@@ -179,7 +180,9 @@
},
"documentProcessing": {
"description": "Attiva l'analisi, il riassunto e l'estrazione di documenti."
}
},
"statusEnabled": "abilitato",
"statusDisabled": "disabilitato"
},
"admin": {
"title": "Admin piattaforma",

View File

@@ -5,12 +5,39 @@ export interface ZitadelClaims {
"urn:zitadel:iam:org:project:roles"?: Record<string, Record<string, string>>;
}
export type PlatformRole =
| "platform_admin"
| "platform_operator"
| "owner"
| "user"
| "viewer";
/**
* Platform-level roles, granted to PieCed staff only. Hold the IAM-level
* authority to administer the entire installation regardless of which
* customer org a request lands on.
*/
export type PlatformRole = "platform_admin" | "platform_operator";
/**
* Customer-level roles, granted by ZITADEL project authorizations on
* each customer org's "OpenClaw Platform" project grant.
*
* Slice 5 dropped the previously-defined `viewer` role. With the portal
* acting purely as a control plane (the assistant itself runs at
* separate URLs with their own auth), `user` and `viewer` collapsed
* to the same surface — read-only access to instance state and usage.
*
* - `owner` can mutate (packages, workspace files, channel users,
* instance creation, member invites in Slice 7).
* - `user` is read-only in the portal. From Slice 6 onwards `user`
* visibility is also narrowed to assigned tenants only.
*/
export type CustomerRole = "owner" | "user";
/** Union of all roles a JWT can carry. */
export type Role = PlatformRole | CustomerRole;
/**
* @deprecated Use {@link Role} for the union, or {@link PlatformRole}
* / {@link CustomerRole} when you mean a specific subset.
* Kept as a re-export only so existing imports don't
* explode in mid-migration commits.
*/
export type LegacyPlatformRole = Role;
export interface SessionUser {
id: string;
@@ -18,7 +45,7 @@ export interface SessionUser {
email: string;
orgId: string;
orgName: string;
roles: PlatformRole[];
roles: Role[];
isPlatform: boolean;
}