Compare commits

..

5 Commits

Author SHA1 Message Date
c46f27edef Fix bugs
All checks were successful
Build and Push / build (push) Successful in 1m30s
2026-04-29 12:16:00 +02:00
542a607b53 Fix zitadel role issues
All checks were successful
Build and Push / build (push) Successful in 1m20s
2026-04-29 09:36:36 +02:00
a31d05b7c2 Team UI
All checks were successful
Build and Push / build (push) Successful in 1m26s
2026-04-26 23:07:47 +02:00
22fd5fb2cc TenantAssignment and readside filtering
All checks were successful
Build and Push / build (push) Successful in 1m23s
2026-04-26 22:58:30 +02:00
7c4e20099d Role split and owner gating
All checks were successful
Build and Push / build (push) Successful in 1m24s
2026-04-26 22:45:38 +02:00
40 changed files with 3425 additions and 154 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);

98
scripts/verify-team.mjs Normal file
View File

@@ -0,0 +1,98 @@
// Standalone JS port of `lib/team.ts::isValidInviteRole` and the
// org-membership decision used by POST /api/tenants/[name]/assignments.
function isValidInviteRole(role) {
return role === "owner" || role === "user";
}
// Mirrors the assignment-time check: target user must exist in the
// org's member list. Returns true if assign should proceed.
function canAssign(targetUserId, orgMembers) {
return orgMembers.some((m) => m.userId === targetUserId);
}
// Mirrors the dropdown candidate-filter on the AssignedUsersPanel:
// only `user`-role members who aren't already assigned, excluding
// owners (who have implicit access).
function pickCandidates(orgMembers, alreadyAssigned) {
const assigned = new Set(alreadyAssigned);
return orgMembers.filter(
(m) =>
!assigned.has(m.userId) &&
m.roles.includes("user") &&
!m.roles.includes("owner")
);
}
// ---------------------------------------------------------------------------
// Test fixtures
// ---------------------------------------------------------------------------
const orgMembers = [
{ userId: "u-1", roles: ["owner"] },
{ userId: "u-2", roles: ["user"] },
{ userId: "u-3", roles: ["user"] },
{ userId: "u-4", roles: [] }, // member with no role yet
{ userId: "u-5", roles: ["owner", "user"] }, // dual-role
];
let pass = 0, fail = 0;
console.log("--- isValidInviteRole ---");
const inviteCases = [
["owner", true, "owner is valid"],
["user", true, "user is valid"],
["viewer", false, "viewer rejected (dropped in Slice 5)"],
["platform_admin", false, "platform_admin not invitable"],
["platform_operator", false, "platform_operator not invitable"],
["", false, "empty rejected"],
["OWNER", false, "case-sensitive"],
];
for (const [role, expected, note] of inviteCases) {
const got = isValidInviteRole(role);
const ok = got === expected;
console.log(`${ok ? "PASS" : "FAIL"} got=${got} want=${expected} [${note}]`);
if (ok) pass++; else fail++;
}
console.log("\n--- canAssign (membership check) ---");
const assignCases = [
["u-1", true, "owner can be assigned (idempotent for owners)"],
["u-2", true, "user-role member can be assigned"],
["u-99", false, "non-member rejected"],
["", false, "empty userId rejected"],
];
for (const [targetId, expected, note] of assignCases) {
const got = canAssign(targetId, orgMembers);
const ok = got === expected;
console.log(`${ok ? "PASS" : "FAIL"} got=${got} want=${expected} [${note}]`);
if (ok) pass++; else fail++;
}
console.log("\n--- pickCandidates (assign dropdown) ---");
const candidateCases = [
{
assigned: [],
expected: ["u-2", "u-3"],
note: "user-role members minus owners (u-5 is owner+user, excluded)",
},
{
assigned: ["u-2"],
expected: ["u-3"],
note: "u-2 already assigned, only u-3 remains",
},
{
assigned: ["u-2", "u-3"],
expected: [],
note: "everyone assigned",
},
];
for (const c of candidateCases) {
const got = pickCandidates(orgMembers, c.assigned).map((m) => m.userId);
const ok = JSON.stringify(got) === JSON.stringify(c.expected);
console.log(`${ok ? "PASS" : "FAIL"} got=${JSON.stringify(got)} want=${JSON.stringify(c.expected)} [${c.note}]`);
if (ok) pass++; else fail++;
}
console.log(`\n${pass} pass, ${fail} fail`);
process.exit(fail === 0 ? 0 : 1);

View File

@@ -0,0 +1,120 @@
// Standalone JS port of `lib/visibility.ts` for offline verification.
// Mirrors the synchronous decision logic — DB call (assignments) is
// faked as an array param.
function scopeFor(user) {
if (user.isPlatform) return "all";
if (user.roles.includes("owner")) return "org";
return "assigned";
}
function listVisibleTenants(user, all, assignments = []) {
const scope = scopeFor(user);
if (scope === "all") return all;
const orgScoped = all.filter(
(t) => t.metadata.labels?.["pieced.ch/zitadel-org-id"] === user.orgId
);
if (scope === "org") return orgScoped;
const allowed = new Set(assignments);
return orgScoped.filter((t) => allowed.has(t.metadata.name));
}
function canUserSeeTenant(user, tenant, assignments = []) {
const scope = scopeFor(user);
if (scope === "all") return true;
if (tenant.metadata.labels?.["pieced.ch/zitadel-org-id"] !== user.orgId) {
return false;
}
if (scope === "org") return true;
return assignments.includes(tenant.metadata.name);
}
function canSeeInflightRequests(user) {
return scopeFor(user) !== "assigned";
}
// ---------------------------------------------------------------------------
// Test fixtures
// ---------------------------------------------------------------------------
const platformAdmin = { isPlatform: true, roles: ["platform_admin"], orgId: "platform-org", id: "u-admin" };
const owner = { isPlatform: false, roles: ["owner"], orgId: "org-acme", id: "u-owner" };
const userOnly = { isPlatform: false, roles: ["user"], orgId: "org-acme", id: "u-alice" };
const noRoles = { isPlatform: false, roles: [], orgId: "org-acme", id: "u-bob" };
const tenantA = { metadata: { name: "acme-prod-12345678", labels: { "pieced.ch/zitadel-org-id": "org-acme" } } };
const tenantB = { metadata: { name: "acme-dev-87654321", labels: { "pieced.ch/zitadel-org-id": "org-acme" } } };
const tenantC = { metadata: { name: "other-corp-aaaa", labels: { "pieced.ch/zitadel-org-id": "org-other" } } };
const allTenants = [tenantA, tenantB, tenantC];
// ---------------------------------------------------------------------------
// listVisibleTenants
// ---------------------------------------------------------------------------
const listCases = [
{ user: platformAdmin, assignments: [], expected: ["acme-prod-12345678", "acme-dev-87654321", "other-corp-aaaa"], note: "platform sees all" },
{ user: owner, assignments: [], expected: ["acme-prod-12345678", "acme-dev-87654321"], note: "owner sees all org tenants" },
{ user: owner, assignments: ["acme-prod-12345678"], expected: ["acme-prod-12345678", "acme-dev-87654321"], note: "owner ignores assignment table even if rows exist" },
{ user: userOnly, assignments: [], expected: [], note: "user with no assignments sees nothing" },
{ user: userOnly, assignments: ["acme-prod-12345678"], expected: ["acme-prod-12345678"], note: "user sees only assigned tenants" },
{ user: userOnly, assignments: ["acme-prod-12345678", "acme-dev-87654321"], expected: ["acme-prod-12345678", "acme-dev-87654321"], note: "user sees multiple assigned tenants" },
{ user: userOnly, assignments: ["other-corp-aaaa"], expected: [], note: "stale assignment to other-org tenant doesn't leak" },
{ user: noRoles, assignments: [], expected: [], note: "no roles is treated as user-scope (empty)" },
];
let pass = 0, fail = 0;
console.log("--- listVisibleTenants ---");
for (const c of listCases) {
const got = listVisibleTenants(c.user, allTenants, c.assignments).map((t) => t.metadata.name);
const ok = JSON.stringify(got) === JSON.stringify(c.expected);
console.log(`${ok ? "PASS" : "FAIL"} got=${JSON.stringify(got)} want=${JSON.stringify(c.expected)} [${c.note}]`);
if (ok) pass++; else fail++;
}
// ---------------------------------------------------------------------------
// canUserSeeTenant
// ---------------------------------------------------------------------------
console.log("\n--- canUserSeeTenant ---");
const seeCases = [
{ user: platformAdmin, tenant: tenantA, assignments: [], expected: true, note: "platform sees same-cluster tenant" },
{ user: platformAdmin, tenant: tenantC, assignments: [], expected: true, note: "platform sees other-org tenant" },
{ user: owner, tenant: tenantA, assignments: [], expected: true, note: "owner sees own-org tenant" },
{ user: owner, tenant: tenantC, assignments: [], expected: false, note: "owner does NOT see other-org tenant" },
{ user: userOnly, tenant: tenantA, assignments: ["acme-prod-12345678"], expected: true, note: "user sees assigned tenant" },
{ user: userOnly, tenant: tenantA, assignments: [], expected: false, note: "user does NOT see un-assigned own-org tenant" },
{ user: userOnly, tenant: tenantC, assignments: ["other-corp-aaaa"], expected: false, note: "user does NOT see other-org tenant even with stale assignment" },
];
for (const c of seeCases) {
const got = canUserSeeTenant(c.user, c.tenant, c.assignments);
const ok = got === c.expected;
console.log(`${ok ? "PASS" : "FAIL"} got=${got} want=${c.expected} [${c.note}]`);
if (ok) pass++; else fail++;
}
// ---------------------------------------------------------------------------
// canSeeInflightRequests
// ---------------------------------------------------------------------------
console.log("\n--- canSeeInflightRequests ---");
const requestCases = [
{ user: platformAdmin, expected: true, note: "platform sees in-flight" },
{ user: owner, expected: true, note: "owner sees in-flight" },
{ user: userOnly, expected: false, note: "user-role does NOT see in-flight" },
{ user: noRoles, expected: false, note: "no-roles does NOT see in-flight" },
];
for (const c of requestCases) {
const got = canSeeInflightRequests(c.user);
const ok = got === c.expected;
console.log(`${ok ? "PASS" : "FAIL"} got=${got} want=${c.expected} [${c.note}]`);
if (ok) pass++; else fail++;
}
console.log(`\n${pass} pass, ${fail} fail`);
process.exit(fail === 0 ? 0 : 1);

506
scripts/zitadel-roles.mjs Normal file
View File

@@ -0,0 +1,506 @@
#!/usr/bin/env node
/**
* zitadel-roles.mjs — diagnose and repair the OpenClaw Platform project's
* role keys + customer authorizations. Group A of the bug triage.
*
* Subcommands
* -----------
* diagnose Print the project's current roles and a raw dump
* of all authorizations granted on the project.
* Read-only. Safe to run any time.
*
* apply Idempotently create the four canonical role keys
* (owner, user, platform_admin, platform_operator)
* if they are missing. Existing roles are left as
* they are; legacy keys (e.g. "customer") are NOT
* deleted by this command — see `migrate-auth`.
*
* migrate-auth <user> Drop every authorization the given user holds
* on the project and replace with a single
* authorization carrying ["owner"]. Use after
* `apply` to promote a legacy customer to the
* new role keys. Idempotent.
*
* migrate-grants Ensure every existing project grant on the
* OpenClaw Platform project includes both
* `owner` and `user` role keys. Without `user`
* in the grant, `CreateAuthorization` for an
* invited member returns Errors.Project.Role.NotFound
* (Bug 21). Idempotent: grants already containing
* both keys are skipped.
*
* Env vars (loaded from .env if you run with `node --env-file=.env`):
* ZITADEL_ISSUER e.g. https://auth.pieced.ch
* ZITADEL_SA_PAT PAT for pieced-sa (IAM_OWNER)
* ZITADEL_PROJECT_ID e.g. 367435120493199793
*
* Examples
* --------
* node --env-file=.env scripts/zitadel-roles.mjs diagnose
* node --env-file=.env scripts/zitadel-roles.mjs apply
* node --env-file=.env scripts/zitadel-roles.mjs migrate-auth 12345...
*
* The script does not import from src/ on purpose — it must be runnable
* even when the portal can't start (which is the failure mode we're
* here to repair).
*/
const ISSUER = process.env.ZITADEL_ISSUER;
const PAT = process.env.ZITADEL_SA_PAT;
const PROJECT_ID = process.env.ZITADEL_PROJECT_ID;
if (!ISSUER || !PAT || !PROJECT_ID) {
console.error(
"Missing env. Need ZITADEL_ISSUER, ZITADEL_SA_PAT, ZITADEL_PROJECT_ID."
);
console.error("Run with: node --env-file=.env scripts/zitadel-roles.mjs ...");
process.exit(2);
}
// Canonical role set — must match types/index.ts (CustomerRole + PlatformRole).
const CANONICAL = [
{ key: "owner", displayName: "Customer Owner", group: "Customer" },
{ key: "user", displayName: "Customer User", group: "Customer" },
{ key: "platform_admin", displayName: "Platform Admin", group: "Platform" },
{
key: "platform_operator",
displayName: "Platform Operator",
group: "Platform",
},
];
// ---------------------------------------------------------------------------
// HTTP plumbing — Connect RPC against ZITADEL v2 services.
// ---------------------------------------------------------------------------
async function rpc(service, method, body) {
const url = `${ISSUER}/${service}/${method}`;
const res = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
Authorization: `Bearer ${PAT}`,
"Connect-Protocol-Version": "1",
},
body: JSON.stringify(body),
});
const text = await res.text();
if (!res.ok) {
const err = new Error(`${service}/${method} -> ${res.status}: ${text}`);
err.status = res.status;
err.body = text;
throw err;
}
return text ? JSON.parse(text) : {};
}
const projectSvc = "zitadel.project.v2.ProjectService";
const authSvc = "zitadel.authorization.v2.AuthorizationService";
async function listProjectRoles() {
const data = await rpc(projectSvc, "ListProjectRoles", {
projectId: PROJECT_ID,
});
return Array.isArray(data?.projectRoles) ? data.projectRoles : [];
}
async function addProjectRole(roleKey, displayName, group) {
return rpc(projectSvc, "AddProjectRole", {
projectId: PROJECT_ID,
roleKey,
displayName,
...(group ? { group } : {}),
});
}
/**
* The Connect RPC filter shape for ListAuthorizations is a oneof variant
* map — each filter has a discriminator key matching one of the variants
* documented as `authorization_ids|in_user_ids|organization_id|project_id|
* role_key|...`. Different ZITADEL services and versions differ on the
* exact wrapper naming (e.g. `projectId` vs `projectIdFilter`) and on
* whether ID values are bare strings or wrapped in `{ id: "..." }`.
*
* Rather than guess, we probe candidate shapes until ZITADEL accepts one.
* The winner tells us exactly what to bake into `lib/zitadel.ts`. Each
* candidate is labelled so the diagnostic output makes the right choice
* obvious.
*/
const FILTER_CANDIDATES = [
// No filter at all — ZITADEL returns whatever the SA can see. Slowest
// but always works; useful as a control.
{
label: "no-filter",
build: () => ({}),
},
// Pattern from discussion #8831 (roleKey -> key+method). Plausible
// generalisation: project_id -> projectId.id
{
label: "projectId.id",
build: (projectId) => ({ filters: [{ projectId: { id: projectId } }] }),
},
// Pattern from ProjectService.ListProjects (organizationIdFilter -> organizationId).
{
label: "projectIdFilter.id",
build: (projectId) => ({
filters: [{ projectIdFilter: { id: projectId } }],
}),
},
// Same family but with the value field named after the filter, like the
// user search API uses (`organizationIdQuery: { organizationId: "..." }`).
{
label: "projectIdFilter.projectId",
build: (projectId) => ({
filters: [{ projectIdFilter: { projectId } }],
}),
},
// Bare-string variant — just in case.
{
label: "projectId (bare string)",
build: (projectId) => ({ filters: [{ projectId }] }),
},
];
const USER_FILTER_CANDIDATES = [
{ label: "userId.id", key: "userId", build: (id) => ({ id }) },
{ label: "userIdFilter.id", key: "userIdFilter", build: (id) => ({ id }) },
{ label: "userIdFilter.userId", key: "userIdFilter", build: (id) => ({ userId: id }) },
];
/**
* Try every candidate; return on the first one that returns 200. Logs each
* attempt so a reader can see which shape won.
*/
async function probeListAuthorizations(extraFilters = []) {
for (const c of FILTER_CANDIDATES) {
const body = c.build(PROJECT_ID);
if (extraFilters.length > 0) {
body.filters = (body.filters || []).concat(extraFilters);
}
body.pagination = { limit: 500 };
try {
const data = await rpc(authSvc, "ListAuthorizations", body);
const count = Array.isArray(data?.authorizations)
? data.authorizations.length
: 0;
console.log(` OK ${c.label.padEnd(28)} -> ${count} authorization(s)`);
return { label: c.label, body, data };
} catch (err) {
const oneLine = String(err.body || err.message)
.replace(/\s+/g, " ")
.slice(0, 110);
console.log(` FAIL ${c.label.padEnd(28)} -> ${oneLine}`);
}
}
return null;
}
async function listUserAuthorizations(userId) {
// Use the same project-filter shape that won the probe, plus a user-id
// filter probed independently.
const probed = await probeListAuthorizations();
if (!probed) throw new Error("No filter shape accepted by ZITADEL");
for (const u of USER_FILTER_CANDIDATES) {
const body = JSON.parse(JSON.stringify(probed.body));
body.filters = (body.filters || []).concat([
{ [u.key]: u.build(userId) },
]);
try {
const data = await rpc(authSvc, "ListAuthorizations", body);
console.log(` user filter ${u.label} accepted.`);
return data;
} catch (err) {
// Try next.
}
}
// Fallback: return all and filter client-side from the user dump.
return probed.data;
}
async function deleteAuthorization(authorizationId) {
return rpc(authSvc, "DeleteAuthorization", { id: authorizationId });
}
async function createAuthorization(userId, organizationId, roleKeys) {
return rpc(authSvc, "CreateAuthorization", {
userId,
projectId: PROJECT_ID,
organizationId,
roleKeys,
});
}
async function listProjectGrants() {
// Same approach as authorizations: skip server-side filters, narrow
// client-side by projectId. Pilot scale; cheap.
const data = await rpc(projectSvc, "ListProjectGrants", {
pagination: { limit: 500 },
});
const all = Array.isArray(data?.projectGrants) ? data.projectGrants : [];
return all.filter((g) => g?.projectId === PROJECT_ID);
}
async function updateProjectGrant(grantedOrganizationId, roleKeys) {
return rpc(projectSvc, "UpdateProjectGrant", {
projectId: PROJECT_ID,
grantedOrganizationId,
roleKeys,
});
}
// ---------------------------------------------------------------------------
// Subcommands
// ---------------------------------------------------------------------------
async function diagnose() {
console.log(`Project: ${PROJECT_ID}`);
console.log(`Issuer: ${ISSUER}\n`);
console.log("--- Project roles ---");
const roles = await listProjectRoles();
if (roles.length === 0) {
console.log(" (none)");
} else {
for (const r of roles) {
console.log(` key=${r.key.padEnd(20)} displayName=${r.displayName ?? ""} group=${r.group ?? ""}`);
}
}
const present = new Set(roles.map((r) => r.key));
const missing = CANONICAL.filter((c) => !present.has(c.key));
const legacy = roles.filter((r) => !CANONICAL.some((c) => c.key === r.key));
console.log("\n--- Canonical key check ---");
for (const c of CANONICAL) {
console.log(` ${present.has(c.key) ? "OK " : "MISS"} ${c.key}`);
}
if (legacy.length > 0) {
console.log("\n Non-canonical keys still on the project:");
for (const r of legacy) console.log(` ${r.key}`);
console.log(" (consider migrating any authorizations off these.)");
}
console.log("\n--- Authorizations on project (probing filter shape) ---");
const probed = await probeListAuthorizations();
if (!probed) {
console.log(
"\nNo filter shape was accepted. Cannot enumerate authorizations."
);
process.exitCode = 1;
return;
}
console.log(`\nWinning filter shape: ${probed.label}`);
console.log("Raw response (first 2 entries):");
const trimmed = {
...probed.data,
authorizations: (probed.data.authorizations || []).slice(0, 2),
};
console.log(JSON.stringify(trimmed, null, 2));
// Parsed view — what `lib/zitadel.ts::listOrgAuthorizations` SHOULD return
// once the parser is fixed. Useful for confirming the response field
// names without wading through the raw blob.
const auths = probed.data.authorizations || [];
console.log(`\nParsed (${auths.length} authorization(s)):`);
for (const a of auths) {
const userId = a.user?.id ?? "?";
const userName = a.user?.displayName ?? a.user?.preferredLoginName ?? "";
const orgId = a.organization?.id ?? "?";
const orgName = a.organization?.name ?? "";
const roleKeys = Array.isArray(a.roles)
? a.roles.map((r) => r.key).join(",")
: "(none)";
console.log(
` ${a.id?.slice(0, 12) ?? "?"}… user=${userName} (${userId.slice(0, 10)}…) org=${orgName} roles=[${roleKeys}]`
);
}
if (missing.length > 0) {
console.log(
`\nNext step: run \`apply\` to create ${missing.length} missing role(s).`
);
process.exitCode = 1;
} else {
console.log("\nAll canonical roles present.");
}
}
async function apply() {
const existing = await listProjectRoles();
const present = new Set(existing.map((r) => r.key));
let created = 0;
for (const c of CANONICAL) {
if (present.has(c.key)) {
console.log(`SKIP ${c.key} (already exists)`);
continue;
}
try {
await addProjectRole(c.key, c.displayName, c.group);
console.log(`ADD ${c.key}`);
created++;
} catch (err) {
// ZITADEL returns AlreadyExists if a role with the same key was
// created in a race; treat as success so the script stays idempotent.
if (
err.body &&
/already.*exist/i.test(err.body)
) {
console.log(`SKIP ${c.key} (already exists, race)`);
continue;
}
console.error(`FAIL ${c.key}: ${err.message}`);
throw err;
}
}
console.log(`\nDone. ${created} role(s) created.`);
}
async function migrateAuth(userId) {
if (!userId) {
console.error("Usage: migrate-auth <userId>");
process.exit(2);
}
// Verify owner role exists before we touch anything; otherwise we'd
// delete authorizations and fail to recreate them.
const roles = await listProjectRoles();
if (!roles.some((r) => r.key === "owner")) {
console.error("Project has no `owner` role. Run `apply` first.");
process.exit(1);
}
console.log(`Listing authorizations for user ${userId} on project ${PROJECT_ID}...`);
const auths = await listUserAuthorizations(userId);
const list = Array.isArray(auths?.authorizations) ? auths.authorizations : [];
// Filter client-side to the requested user, in case the user filter probe
// didn't narrow things down.
const userAuths = list.filter((a) => a.user?.id === userId);
if (userAuths.length === 0) {
console.log("No existing authorizations found. Cannot infer organizationId.");
console.log("Pass it explicitly via the env: ORG_ID=... or use the portal flow.");
process.exit(1);
}
// Pick the organizationId from any of the existing authorizations — it
// should be the same across all of them for a single user/project pair.
const orgIds = [...new Set(userAuths.map((a) => a.organization?.id).filter(Boolean))];
if (orgIds.length !== 1) {
console.error(`Expected exactly 1 organizationId, got ${orgIds.length}: ${orgIds.join(", ")}`);
process.exit(1);
}
const orgId = orgIds[0];
console.log(`Found ${userAuths.length} authorization(s) in org ${orgId}:`);
for (const a of userAuths) {
const id = a.id ?? "?";
const keys = Array.isArray(a.roles) ? a.roles.map((r) => r.key).join(",") : "(none)";
console.log(` ${id} roles=[${keys}]`);
}
// Already correct?
if (
userAuths.length === 1 &&
Array.isArray(userAuths[0].roles) &&
userAuths[0].roles.length === 1 &&
userAuths[0].roles[0].key === "owner"
) {
console.log("Already correct — no changes needed.");
return;
}
console.log("\nDeleting existing authorizations...");
for (const a of userAuths) {
if (!a.id) continue;
await deleteAuthorization(a.id);
console.log(` deleted ${a.id}`);
}
console.log("Creating fresh owner authorization...");
const created = await createAuthorization(userId, orgId, ["owner"]);
console.log(` created ${JSON.stringify(created)}`);
console.log("Done.");
}
async function migrateGrants() {
// Ensure every existing project grant for the OpenClaw Platform project
// includes the `user` role alongside `owner`. Without `user` in the
// grant, the granted org cannot invite members in `user` role —
// `CreateAuthorization` returns `Errors.Project.Role.NotFound`.
//
// Idempotent: grants already containing both keys are skipped.
// Per UpdateProjectGrant docs, `roleKeys` is REPLACE not MERGE — we
// re-send the full desired set every time.
const desired = ["owner", "user"];
const grants = await listProjectGrants();
if (grants.length === 0) {
console.log("No project grants found on this project.");
return;
}
console.log(`Found ${grants.length} grant(s) on project ${PROJECT_ID}:`);
for (const g of grants) {
const current = Array.isArray(g.grantedRoleKeys)
? g.grantedRoleKeys
: [];
const hasAll = desired.every((k) => current.includes(k));
const action = hasAll ? "SKIP" : "FIX ";
console.log(
` ${action} ${g.grantedOrganizationName.padEnd(30)} current=[${current.join(",")}]`
);
}
let fixed = 0;
for (const g of grants) {
const current = Array.isArray(g.grantedRoleKeys)
? g.grantedRoleKeys
: [];
if (desired.every((k) => current.includes(k))) continue;
// Preserve any extra roles the grant already has on top of the
// desired set (e.g. someone manually added `viewer` for a special
// case). Set semantics: union.
const merged = [...new Set([...current, ...desired])];
try {
await updateProjectGrant(g.grantedOrganizationId, merged);
console.log(
` updated ${g.grantedOrganizationName} -> [${merged.join(",")}]`
);
fixed++;
} catch (err) {
console.error(
` FAIL ${g.grantedOrganizationName}: ${err.message}`
);
throw err;
}
}
console.log(`\nDone. ${fixed} grant(s) updated.`);
}
const [, , cmd, ...rest] = process.argv;
const commands = {
diagnose,
apply,
"migrate-auth": () => migrateAuth(rest[0]),
"migrate-grants": migrateGrants,
};
const fn = commands[cmd];
if (!fn) {
console.error(
"Usage: zitadel-roles.mjs <diagnose|apply|migrate-auth <userId>|migrate-grants>"
);
process.exit(2);
}
fn().catch((err) => {
console.error(err.message ?? err);
if (err.body) console.error("body:", err.body);
process.exit(1);
});

View File

@@ -1,8 +1,8 @@
import { getSessionUser } from "@/lib/session"; import { getSessionUser, canMutate } from "@/lib/session";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { OnboardingFlow } from "@/components/onboarding/onboarding-flow"; import { OnboardingFlow } from "@/components/onboarding/onboarding-flow";
import Link from "next/link"; import { BackLink } from "@/components/ui/back-link";
/** /**
* /dashboard/new — wizard for creating an additional instance for an * /dashboard/new — wizard for creating an additional instance for an
@@ -16,23 +16,24 @@ import Link from "next/link";
* *
* Platform admins are redirected to /dashboard — they shouldn't be * Platform admins are redirected to /dashboard — they shouldn't be
* creating tenant instances under their own org. * 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() { export default async function NewInstancePage() {
const user = await getSessionUser(); const user = await getSessionUser();
if (!user) redirect("/login"); if (!user) redirect("/login");
if (user.isPlatform) redirect("/dashboard"); if (user.isPlatform) redirect("/dashboard");
if (!canMutate(user)) redirect("/dashboard");
const t = await getTranslations("dashboard"); const t = await getTranslations("dashboard");
return ( return (
<div> <div>
<div className="mb-8 animate-in"> <div className="mb-8 animate-in">
<Link <BackLink href="/dashboard" label={t("title")} />
href="/dashboard"
className="inline-flex items-center gap-1.5 mb-4 text-xs font-medium text-text-muted hover:text-text-primary transition-colors"
>
<span></span> {t("title")}
</Link>
<h1 className="font-display text-2xl font-semibold accent-rule mb-2"> <h1 className="font-display text-2xl font-semibold accent-rule mb-2">
{t("createInstance")} {t("createInstance")}
</h1> </h1>

View File

@@ -1,8 +1,13 @@
import { getSessionUser } from "@/lib/session"; import { getSessionUser, canMutate } from "@/lib/session";
import { getTranslations, getFormatter } from "next-intl/server"; import { getTranslations, getFormatter } from "next-intl/server";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { listTenants } from "@/lib/k8s"; import { listTenants } from "@/lib/k8s";
import { listActiveTenantRequestsByOrgId } from "@/lib/db"; import { listActiveTenantRequestsByOrgId } from "@/lib/db";
import {
listVisibleTenants,
canSeeInflightRequests,
isUserScoped,
} from "@/lib/visibility";
import { Card, CardHeader } from "@/components/ui/card"; import { Card, CardHeader } from "@/components/ui/card";
import { StatusBadge } from "@/components/ui/status-badge"; import { StatusBadge } from "@/components/ui/status-badge";
import { OnboardingFlow } from "@/components/onboarding/onboarding-flow"; import { OnboardingFlow } from "@/components/onboarding/onboarding-flow";
@@ -134,23 +139,117 @@ export default async function DashboardPage() {
} }
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
// Customer view (Slice 3 multi-tenant) // Customer view (Slice 3 multi-tenant + Slice 6 visibility scoping)
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
const orgTenants = allTenants.filter( // Slice 6: orgTenants becomes "visible tenants for this user". For an
// owner that's all of the org's tenants; for a `user`-role member
// it's only the tenants they've been assigned to via
// tenant_user_assignments. The dashboard renders fewer cards in the
// user-role case but otherwise uses the same template.
const orgTenants = await listVisibleTenants(user, allTenants);
// For the "no instances yet" empty state, we want to know whether
// this user is being scoped down. A `user`-role with 0 visible
// tenants gets a different message than an owner with 0 tenants
// (the user might just need an assignment; the owner needs to
// create one).
const userScoped = isUserScoped(user);
// Pending/in-flight requests are only shown to roles that can act on
// them. `user`-role customers see no request cards.
const orgRequests = canSeeInflightRequests(user)
? await listActiveTenantRequestsByOrgId(user.orgId)
: [];
// Pending requests that don't yet have a tenant CR. Once the CR
// exists, the tenant card carries the live phase, so a separate
// "request" card would just duplicate it. We compare against
// *all* org tenants here (not just visible ones) — otherwise a
// request whose tenant is invisible to the caller would erroneously
// show as in-flight.
const orgScopedTenants = allTenants.filter(
(t) => t.metadata.labels?.["pieced.ch/zitadel-org-id"] === user.orgId (t) => t.metadata.labels?.["pieced.ch/zitadel-org-id"] === user.orgId
); );
const orgRequests = await listActiveTenantRequestsByOrgId(user.orgId);
// Pending/in-flight requests that don't yet have a tenant CR. Once the
// CR exists, the tenant card carries the live phase, so a separate
// "request" card would just duplicate it.
const inflightRequests = orgRequests.filter( const inflightRequests = orgRequests.filter(
(r) => !r.tenantName || !orgTenants.some((t) => t.metadata.name === r.tenantName) (r) => !r.tenantName || !orgScopedTenants.some((t) => t.metadata.name === r.tenantName)
); );
// First-time user: empty company. Show the onboarding wizard inline. // 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 / no-visibility branch.
//
// Three sub-cases:
// 1. owner / platform with 0 tenants and 0 requests → show wizard.
// 2. owner / platform with 0 visibility but the org HAS tenants →
// shouldn't happen (owners see all org tenants). Defensive
// fall-through to the wizard.
// 3. user-role with 0 visible tenants → show "ask your owner"
// message, with copy distinguishing whether the org has any
// tenants at all.
if (orgTenants.length === 0 && inflightRequests.length === 0) { if (orgTenants.length === 0 && inflightRequests.length === 0) {
if (userScoped) {
// Slice 6 empty state for `user` role. The org might or might
// not have tenants — either way this user has none assigned.
// The two messages are subtly different: "no instances exist"
// means owner needs to create one; "you're not assigned" means
// owner needs to grant access.
const orgHasTenants = orgScopedTenants.length > 0;
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">
<div className="text-center py-6">
<h2 className="font-display text-base font-semibold text-text-primary mb-2">
{orgHasTenants
? t("noAssignmentsTitle")
: t("noInstancesYetTitle")}
</h2>
<p className="text-sm text-text-secondary max-w-sm mx-auto">
{orgHasTenants
? t("noAssignmentsDescription")
: t("noInstancesYetDescription")}
</p>
</div>
</Card>
</div>
);
}
if (!canCreate) {
// Belt-and-braces: any role that's neither owner-with-create nor
// user-scope ends up here (e.g. weird cases like a session with
// no roles at all). Same generic message as before.
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 ( return (
<div> <div>
<div className="mb-8 animate-in"> <div className="mb-8 animate-in">
@@ -170,7 +269,7 @@ export default async function DashboardPage() {
} }
// Returning customer: list of tenants + in-flight requests, plus // Returning customer: list of tenants + in-flight requests, plus
// a button to add another instance. // a button to add another instance (owners only).
return ( return (
<div> <div>
<div className="mb-8 animate-in flex items-start justify-between gap-4"> <div className="mb-8 animate-in flex items-start justify-between gap-4">
@@ -183,12 +282,14 @@ export default async function DashboardPage() {
</p> </p>
</div> </div>
<Link {canCreate && (
href="/dashboard/new" <Link
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" 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> <span>+</span> {t("createInstance")}
</Link>
)}
</div> </div>
{/* In-flight (pending/approved/provisioning/rejected) requests */} {/* In-flight (pending/approved/provisioning/rejected) requests */}

View File

@@ -0,0 +1,64 @@
import { getSessionUser, canMutate, isCustomerOwner } from "@/lib/session";
import { getTranslations } from "next-intl/server";
import { redirect } from "next/navigation";
import { getOrgMembers } from "@/lib/team";
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";
/**
* /team — manage org members.
*
* Visible to owners and platform users only (`canMutate`). User-role
* members are redirected away — they shouldn't browse the roster.
*
* The page loads members server-side for the initial render. The
* `<TeamList>` and `<InviteForm>` client components handle live
* updates after invites and refreshes.
*/
export default async function TeamPage() {
const user = await getSessionUser();
if (!user) redirect("/login");
if (!canMutate(user)) redirect("/dashboard");
const t = await getTranslations("team");
const tDashboard = await getTranslations("dashboard");
const members = await getOrgMembers(user.orgId);
return (
<div>
<div className="mb-8 animate-in">
<BackLink href="/dashboard" label={tDashboard("title")} />
<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("description")}</p>
</div>
<section className="mb-8 animate-in animate-in-delay-1">
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
{t("inviteSectionTitle")}
</h2>
<Card>
<InviteForm />
</Card>
</section>
<section className="animate-in animate-in-delay-2">
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
{t("membersSectionTitle")}{" "}
<span className="text-text-muted/60 tabular-nums">
({members.length})
</span>
</h2>
<TeamList
initialMembers={members}
currentUserId={user.id}
canEditRoles={isCustomerOwner(user)}
/>
</section>
</div>
);
}

View File

@@ -1,12 +1,14 @@
import { getSessionUser } from "@/lib/session"; import { getSessionUser, canMutate } from "@/lib/session";
import { getTranslations, getFormatter } from "next-intl/server"; import { getTranslations, getFormatter } from "next-intl/server";
import { redirect, notFound } from "next/navigation"; import { redirect, notFound } from "next/navigation";
import { getTenant } from "@/lib/k8s"; import { getTenant } from "@/lib/k8s";
import { canUserSeeTenant } from "@/lib/visibility";
import { StatusBadge } from "@/components/ui/status-badge"; import { StatusBadge } from "@/components/ui/status-badge";
import { UsageDisplay } from "@/components/dashboard/usage-display"; import { UsageDisplay } from "@/components/dashboard/usage-display";
import { PackageList } from "@/components/packages/package-list"; import { PackageList } from "@/components/packages/package-list";
import { WorkspaceEditor } from "@/components/packages/workspace-editor"; 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 { formatDateTime, formatRelative } from "@/lib/format"; import { formatDateTime, formatRelative } from "@/lib/format";
const CHANNEL_PACKAGES = ["telegram", "discord", "email"]; const CHANNEL_PACKAGES = ["telegram", "discord", "email"];
@@ -26,14 +28,18 @@ export default async function TenantDetailPage({
const tenant = await getTenant(name); const tenant = await getTenant(name);
if (!tenant) notFound(); if (!tenant) notFound();
// Scope check // Slice 6: visibility check encompasses org membership AND, for
if ( // user-role members, the tenant_user_assignments check. notFound()
!user.isPlatform && // (404) rather than redirect/403 to avoid leaking tenant existence.
tenant.metadata.labels?.["pieced.ch/zitadel-org-id"] !== user.orgId if (!(await canUserSeeTenant(user, tenant))) {
) {
notFound(); 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 enabledPackages = tenant.spec.packages || [];
const workspaceFiles = tenant.spec.workspaceFiles || {}; const workspaceFiles = tenant.spec.workspaceFiles || {};
const enabledChannels = enabledPackages.filter((pkg) => const enabledChannels = enabledPackages.filter((pkg) =>
@@ -100,6 +106,7 @@ export default async function TenantDetailPage({
tenantName={name} tenantName={name}
enabledPackages={enabledPackages} enabledPackages={enabledPackages}
conditions={tenant.status?.conditions} conditions={tenant.status?.conditions}
canEdit={canEdit}
/> />
</section> </section>
@@ -110,6 +117,7 @@ export default async function TenantDetailPage({
tenantName={name} tenantName={name}
enabledChannels={enabledChannels} enabledChannels={enabledChannels}
initialChannelUsers={channelUsers} initialChannelUsers={channelUsers}
canEdit={canEdit}
/> />
</section> </section>
)} )}
@@ -119,7 +127,17 @@ export default async function TenantDetailPage({
<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">
{t("workspaceFiles")} {t("workspaceFiles")}
</h2> </h2>
<WorkspaceEditor tenantName={name} files={workspaceFiles} /> <WorkspaceEditor tenantName={name} files={workspaceFiles} canEdit={canEdit} />
</section>
{/* Slice 7: Assigned users — visible to anyone who can see the
tenant, editable only by owners/platform users. The component
fetches its own data so the page doesn't need to await. */}
<section className="mt-8 animate-in animate-in-delay-4">
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
{t("assignedUsers")}
</h2>
<AssignedUsersPanel tenantName={name} canEdit={canEdit} />
</section> </section>
</div> </div>
); );

View File

@@ -1,13 +1,21 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { requirePlatformRole } from "@/lib/session"; import { requirePlatformRole } from "@/lib/session";
import { getTenant, deleteTenant } from "@/lib/k8s"; import { getTenant, deleteTenant } from "@/lib/k8s";
import { markTenantRequestDeletedByTenantName } from "@/lib/db"; import {
markTenantRequestDeletedByTenantName,
removeAllAssignmentsForTenant,
} from "@/lib/db";
import { safeError } from "@/lib/errors"; import { safeError } from "@/lib/errors";
/** /**
* POST /api/admin/tenants/[name]/delete * POST /api/admin/tenants/[name]/delete
* Delete a PiecedTenant CR. The operator handles cleanup * Delete a PiecedTenant CR. The operator handles cleanup
* (namespace, vault, litellm team, etc.). * (namespace, vault, litellm team, etc.).
*
* Slice 6: also cascades the tenant_user_assignments rows so a
* future tenant with the same name (won't happen given UUID-suffix
* naming, but defense in depth) doesn't inherit stale assignments.
*
* Also marks the associated tenant_request as "deleted" so the * Also marks the associated tenant_request as "deleted" so the
* customer can re-submit the onboarding wizard. * customer can re-submit the onboarding wizard.
*/ */
@@ -31,10 +39,14 @@ export async function POST(
try { try {
await deleteTenant(name); await deleteTenant(name);
// Mark the associated tenant_request as "deleted" so the customer // Best-effort DB cleanups. Both errors are logged but not surfaced —
// sees the wizard again instead of a stale "active" status // the K8s deletion has already started, and the row state is just
// for portal display.
await markTenantRequestDeletedByTenantName(name).catch((e) => await markTenantRequestDeletedByTenantName(name).catch((e) =>
console.error("Failed to update tenant request after delete:", e) console.error("Failed to mark tenant request deleted:", e)
);
await removeAllAssignmentsForTenant(name).catch((e) =>
console.error("Failed to clean up tenant assignments:", e)
); );
return NextResponse.json({ return NextResponse.json({

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { getSessionUser } from "@/lib/session"; import { getSessionUser, canMutate } from "@/lib/session";
import { import {
createTenantRequest, createTenantRequest,
getTenantRequestById, getTenantRequestById,
@@ -8,6 +8,11 @@ import {
getMostRecentApprovedRequestForOrg, getMostRecentApprovedRequestForOrg,
} from "@/lib/db"; } from "@/lib/db";
import { getTenant, listTenants } from "@/lib/k8s"; import { getTenant, listTenants } from "@/lib/k8s";
import {
listVisibleTenants,
canUserSeeTenant,
canSeeInflightRequests,
} from "@/lib/visibility";
import { sendAdminNotificationEmail } from "@/lib/email"; import { sendAdminNotificationEmail } from "@/lib/email";
import { encryptSecrets } from "@/lib/crypto"; import { encryptSecrets } from "@/lib/crypto";
import { isPersonalOrgName } from "@/lib/personal-org"; import { isPersonalOrgName } from "@/lib/personal-org";
@@ -106,10 +111,24 @@ export async function GET(req: NextRequest) {
if (!user.isPlatform && tr.zitadelOrgId !== user.orgId) { if (!user.isPlatform && tr.zitadelOrgId !== user.orgId) {
return NextResponse.json({ error: "Not found" }, { status: 404 }); return NextResponse.json({ error: "Not found" }, { status: 404 });
} }
// Slice 6: a `user`-role customer doesn't see in-flight requests
// even within their own org — they can't act on them and showing
// the row would be a permanent "pending" state with no exit. Owner
// and platform skip this gate.
if (!canSeeInflightRequests(user)) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
let tenant: PiecedTenant | null = null; let tenant: PiecedTenant | null = null;
if (tr.tenantName) { if (tr.tenantName) {
tenant = (await getTenant(tr.tenantName)) ?? null; tenant = (await getTenant(tr.tenantName)) ?? null;
// If a request is already linked to a tenant CR and the caller
// can't see that tenant (assignment scope), don't expose it via
// the request endpoint either. canSeeInflightRequests above
// already shortcuts this for `user`-role, but defense in depth.
if (tenant && !(await canUserSeeTenant(user, tenant))) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
} }
return NextResponse.json({ return NextResponse.json({
request: publicRequestShape(tr), request: publicRequestShape(tr),
@@ -117,19 +136,21 @@ export async function GET(req: NextRequest) {
}); });
} }
// List view: requests + tenants for this org // List view: requests + tenants for this org, filtered by visibility.
// For owner/platform, this returns the same data as pre-Slice-6.
// For user-role, requests is forced to [] and tenants is narrowed to
// assignments.
const [requests, allTenants] = await Promise.all([ const [requests, allTenants] = await Promise.all([
listActiveTenantRequestsByOrgId(user.orgId), listActiveTenantRequestsByOrgId(user.orgId),
listTenants(), listTenants(),
]); ]);
const orgTenants = allTenants.filter( const visibleTenants = await listVisibleTenants(user, allTenants);
(t) => t.metadata.labels?.["pieced.ch/zitadel-org-id"] === user.orgId const visibleRequests = canSeeInflightRequests(user) ? requests : [];
);
return NextResponse.json({ return NextResponse.json({
requests: requests.map(publicRequestShape), requests: visibleRequests.map(publicRequestShape),
tenants: orgTenants.map(publicTenantShape), tenants: visibleTenants.map(publicTenantShape),
}); });
} }
@@ -157,6 +178,15 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); 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 body = await request.json();
const parsed = onboardingSchema.safeParse(body); const parsed = onboardingSchema.safeParse(body);
if (!parsed.success) { if (!parsed.success) {

View File

@@ -0,0 +1,148 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { getSessionUser, isCustomerOwner } from "@/lib/session";
import { getOrgMembers, isValidInviteRole } from "@/lib/team";
import { updateAuthorizationRoles } from "@/lib/zitadel";
import { safeError } from "@/lib/errors";
const patchSchema = z.object({
role: z.enum(["owner", "user"]),
});
/**
* PATCH /api/team/[userId]/role
*
* Change the role of an existing member of the caller's org.
*
* Body: { role: "owner" | "user" }
*
* Authorization
* -------------
* Customer-side: only an `owner` of the caller's org may change roles.
* `isCustomerOwner` is the right gate — `canMutate` would also accept
* platform users, but cross-org role mutation by platform staff
* belongs in ZITADEL Console with audited admin tooling, not here.
*
* Safety guards
* -------------
* 1. Self-demotion is blocked. An owner demoting themself to `user`
* could lose access to /team and never come back. If the user
* genuinely wants to step down they should promote a colleague to
* `owner` first, then ask that colleague to demote them.
* 2. Last-owner demotion is blocked. Demoting the org's only owner
* to `user` would lock the org out of all future role changes,
* invites, and tenant requests. We count owners across the whole
* member list and refuse if this change would leave zero.
* 3. The target must already have an authorization on the project.
* A member without one — orphan, mid-invite race — has nothing
* for `UpdateAuthorization` to update; we return a clear 409.
*
* The mutation itself is replace-not-merge: see
* `lib/zitadel.ts::updateAuthorizationRoles`. Passing `[role]` revokes
* any other roles the member happened to hold.
*/
export async function PATCH(
req: NextRequest,
{ params }: { params: Promise<{ userId: string }> }
) {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Only customer owners — platform staff use Console.
if (!isCustomerOwner(user)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const { userId } = await params;
if (userId === user.id) {
return NextResponse.json(
{
error:
"You cannot change your own role. Ask another owner, or promote a colleague to owner first.",
code: "self_change_blocked",
},
{ status: 403 }
);
}
const body = await req.json().catch(() => null);
const parsed = patchSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: "Invalid input", details: parsed.error.flatten() },
{ status: 400 }
);
}
const { role } = parsed.data;
// Defensive — the Zod enum already enforces this.
if (!isValidInviteRole(role)) {
return NextResponse.json(
{ error: "Role must be 'owner' or 'user'." },
{ status: 400 }
);
}
try {
const members = await getOrgMembers(user.orgId);
const target = members.find((m) => m.userId === userId);
if (!target) {
return NextResponse.json(
{ error: "Target user is not a member of this organization." },
{ status: 404 }
);
}
if (!target.authorizationId) {
// Should be very rare — implies the row was created out-of-band
// (e.g. directly in Console) without an authorization. Surface a
// clear message rather than a confusing 500 from ZITADEL.
return NextResponse.json(
{
error:
"Member has no authorization record on the project. Re-invite them or contact support.",
code: "no_authorization",
},
{ status: 409 }
);
}
// Last-owner protection: this matters when the target is currently
// an owner AND the new role is something other than owner. We could
// narrow the count to "before this change" but the simpler form is
// equivalent: if there's only one owner and that owner is the
// target, refuse.
const currentlyOwner = target.roles.includes("owner");
if (currentlyOwner && role !== "owner") {
const ownerCount = members.filter((m) => m.roles.includes("owner")).length;
if (ownerCount <= 1) {
return NextResponse.json(
{
error:
"This is the only owner. Promote another member to owner before demoting this one.",
code: "last_owner",
},
{ status: 409 }
);
}
}
// No-op: target already has the requested role and ONLY that role.
if (target.roles.length === 1 && target.roles[0] === role) {
return NextResponse.json({ message: "No change.", role }, { status: 200 });
}
await updateAuthorizationRoles(target.authorizationId, [role]);
return NextResponse.json(
{ message: "Role updated.", userId, role },
{ status: 200 }
);
} catch (e: any) {
console.error("Role update failed:", e);
return NextResponse.json(
{ error: safeError(e, "Failed to update role") },
{ status: e.statusCode || 500 }
);
}
}

View File

@@ -0,0 +1,95 @@
import { NextResponse } from "next/server";
import { getSessionUser, canMutate } from "@/lib/session";
import { inviteOrgMember, isValidInviteRole } from "@/lib/team";
import { z } from "zod";
import { safeError } from "@/lib/errors";
const inviteSchema = z.object({
email: z.string().email(),
givenName: z.string().min(1).max(100),
familyName: z.string().min(1).max(100),
role: z.enum(["owner", "user"]),
preferredLanguage: z.enum(["en", "de", "fr", "it"]).optional(),
});
/**
* POST /api/team/invite
*
* Invite a new member into the caller's org. Body shape:
* { email, givenName, familyName, role: "owner" | "user" }
*
* Allowed roles are explicitly only the customer-side ones —
* `isValidInviteRole` enforces this server-side too as a belt
* alongside the Zod enum (the Zod enum is the primary check; the
* helper exists because future callers in admin tooling may want the
* same predicate).
*
* Platform users can also call this — they'd be inviting members
* into their own platform org, which is uncommon but legal.
*/
export async function POST(req: Request) {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (!canMutate(user)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const body = await req.json().catch(() => null);
const parsed = inviteSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: "Invalid input", details: parsed.error.flatten() },
{ status: 400 }
);
}
const input = parsed.data;
// Defensive recheck — the Zod enum already guarantees this, but it
// makes the intent explicit at the call site.
if (!isValidInviteRole(input.role)) {
return NextResponse.json(
{ error: "Role must be 'owner' or 'user'." },
{ status: 400 }
);
}
try {
const result = await inviteOrgMember({
orgId: user.orgId,
email: input.email,
givenName: input.givenName,
familyName: input.familyName,
role: input.role,
preferredLanguage: input.preferredLanguage,
});
return NextResponse.json(
{
userId: result.userId,
message:
"Invitation sent. The user will receive an email with a link to set their password.",
},
{ status: 201 }
);
} catch (e: any) {
console.error("Invite failed:", e);
// ZITADEL "user already exists" surfaces as a 4xx error; pass it
// through with a clean message so the client can render localized
// text.
const msg = e?.message ?? "";
if (msg.includes("already exists") || msg.includes("9.User.AlreadyExisting")) {
return NextResponse.json(
{
error: "A user with this email already exists.",
code: "user_already_exists",
},
{ status: 409 }
);
}
return NextResponse.json(
{ error: safeError(e, "Failed to invite user") },
{ status: e.statusCode || 500 }
);
}
}

38
src/app/api/team/route.ts Normal file
View File

@@ -0,0 +1,38 @@
import { NextResponse } from "next/server";
import { getSessionUser, canMutate } from "@/lib/session";
import { getOrgMembers } from "@/lib/team";
import { safeError } from "@/lib/errors";
/**
* GET /api/team
*
* Returns the joined members-with-roles view for the caller's org.
* Gated on `canMutate` — only owners and platform users can see the
* full member list. A `user`-role member shouldn't be browsing the
* roster.
*
* Platform admins viewing this endpoint see members of their OWN
* platform org. To inspect customer org membership cross-cut, use
* ZITADEL Console — that's the deliberate boundary between portal
* (customer self-service) and console (full IAM).
*/
export async function GET() {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (!canMutate(user)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
try {
const members = await getOrgMembers(user.orgId);
return NextResponse.json({ members });
} catch (e: any) {
console.error("Failed to list team members:", e);
return NextResponse.json(
{ error: safeError(e, "Failed to list team members") },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,57 @@
import { NextRequest, NextResponse } from "next/server";
import { getSessionUser, canMutate } from "@/lib/session";
import { getTenant } from "@/lib/k8s";
import { removeTenantAssignment } from "@/lib/db";
import { safeError } from "@/lib/errors";
/**
* DELETE /api/tenants/[name]/assignments/[userId]
*
* Revoke a user's assignment to a tenant. Owner+platform only.
*
* No-op if the assignment didn't exist (delete is idempotent at the
* DB layer). We don't surface "not found" because that would let a
* caller probe for assignment existence — the boolean response is
* just "you're authorized to do this".
*
* Note on self-revocation: an owner can revoke their own row even
* though it has no practical effect (owners see all tenants). A
* `user`-role member cannot revoke their own assignment because
* they're already gated out by canMutate.
*/
export async function DELETE(
_req: NextRequest,
{ params }: { params: Promise<{ name: string; userId: string }> }
) {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (!canMutate(user)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const { name, userId } = await params;
const tenant = await getTenant(name);
if (!tenant) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
// Same cross-org boundary as assign: customer owners can only manage
// their own org's tenants; platform users can manage anywhere.
const tenantOrgId = tenant.metadata.labels?.["pieced.ch/zitadel-org-id"];
if (!user.isPlatform && tenantOrgId !== user.orgId) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
try {
await removeTenantAssignment(name, userId);
return NextResponse.json({ message: "Assignment revoked." });
} catch (e: any) {
console.error("Failed to remove tenant assignment:", e);
return NextResponse.json(
{ error: safeError(e, "Failed to revoke assignment") },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,176 @@
import { NextRequest, NextResponse } from "next/server";
import { getSessionUser, canMutate } from "@/lib/session";
import { canUserSeeTenant } from "@/lib/visibility";
import { getTenant } from "@/lib/k8s";
import {
listAssignmentsForTenant,
addTenantAssignment,
} from "@/lib/db";
import { getOrgMembers } from "@/lib/team";
import { safeError } from "@/lib/errors";
import { z } from "zod";
const assignSchema = z.object({
userId: z.string().min(1).max(200),
});
/**
* GET /api/tenants/[name]/assignments
*
* Returns the list of users assigned to a tenant, joined with their
* ZITADEL profile (display name, email, role) so the UI can render
* a useful list without an extra round-trip.
*
* Visibility: any caller who can see the tenant can see its
* assignments. This includes user-role members who are themselves
* assigned — they see their fellow assignees, which is intentional
* (so they know who else has access).
*/
export async function GET(
_req: NextRequest,
{ params }: { params: Promise<{ name: string }> }
) {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { name } = await params;
const tenant = await getTenant(name);
if (!tenant) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
if (!(await canUserSeeTenant(user, tenant))) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
try {
const orgId = tenant.metadata.labels?.["pieced.ch/zitadel-org-id"];
const [rows, members] = await Promise.all([
listAssignmentsForTenant(name),
orgId ? getOrgMembers(orgId) : Promise.resolve([]),
]);
const memberById = new Map(members.map((m) => [m.userId, m]));
// Enrich assignments with member metadata. If the member can't be
// found in ZITADEL (stale row, e.g. user was removed from the org
// outside the portal), surface the orphan with a placeholder name
// so admins can clean it up.
const assignments = rows.map((r) => {
const m = memberById.get(r.zitadelUserId);
return {
userId: r.zitadelUserId,
displayName: m?.displayName ?? "(removed user)",
email: m?.email ?? "",
roles: m?.roles ?? [],
assignedAt: r.assignedAt,
assignedBy: r.assignedBy,
orphan: !m,
};
});
return NextResponse.json({ assignments });
} catch (e: any) {
console.error("Failed to list tenant assignments:", e);
return NextResponse.json(
{ error: safeError(e, "Failed to list assignments") },
{ status: 500 }
);
}
}
/**
* POST /api/tenants/[name]/assignments
*
* Body: { userId }
*
* Assign a user to a tenant. Owner+platform only. The target user must
* already be a member of the tenant's org (we verify via the team list)
* — to add a brand-new user, the owner first invites them via
* POST /api/team/invite, then assigns them here.
*
* Idempotent: re-assigning is a no-op (DB INSERT ... ON CONFLICT DO
* NOTHING). The original `assignedAt`/`assignedBy` are preserved.
*
* Owners technically don't need to be assigned (they see all of their
* org's tenants anyway) but we don't reject the operation — just lets
* future bookkeeping work consistently.
*/
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ name: string }> }
) {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (!canMutate(user)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const { name } = await params;
const tenant = await getTenant(name);
if (!tenant) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
// Customer owners can only assign within their own org. Platform
// users can assign anywhere (rare, but consistent with admin scope).
const tenantOrgId = tenant.metadata.labels?.["pieced.ch/zitadel-org-id"];
if (!user.isPlatform && tenantOrgId !== user.orgId) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
if (!tenantOrgId) {
return NextResponse.json(
{ error: "Tenant is missing the org-id label; cannot assign." },
{ status: 500 }
);
}
const body = await req.json().catch(() => null);
const parsed = assignSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: "Invalid input", details: parsed.error.flatten() },
{ status: 400 }
);
}
// Verify the target user is actually a member of the tenant's org.
// This is the audit boundary — without it, an owner could grant
// access to arbitrary user IDs they made up.
try {
const members = await getOrgMembers(tenantOrgId);
const target = members.find((m) => m.userId === parsed.data.userId);
if (!target) {
return NextResponse.json(
{
error:
"Target user is not a member of this organization. Invite them first.",
code: "user_not_in_org",
},
{ status: 400 }
);
}
await addTenantAssignment({
tenantName: name,
orgId: tenantOrgId,
userId: parsed.data.userId,
assignedBy: user.id,
});
return NextResponse.json(
{ message: "User assigned.", userId: parsed.data.userId },
{ status: 201 }
);
} catch (e: any) {
console.error("Failed to add tenant assignment:", e);
return NextResponse.json(
{ error: safeError(e, "Failed to assign user") },
{ status: 500 }
);
}
}

View File

@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { getSessionUser } from "@/lib/session"; import { getSessionUser, canMutate } from "@/lib/session";
import { canUserSeeTenant } from "@/lib/visibility";
import { getTenant, patchTenantSpec } from "@/lib/k8s"; import { getTenant, patchTenantSpec } from "@/lib/k8s";
import { getPackageDef } from "@/lib/packages"; import { getPackageDef } from "@/lib/packages";
import { safeError } from "@/lib/errors"; import { safeError } from "@/lib/errors";
@@ -22,11 +23,11 @@ export async function GET(
if (!tenant) if (!tenant)
return NextResponse.json({ error: "Not found" }, { status: 404 }); return NextResponse.json({ error: "Not found" }, { status: 404 });
if ( // Slice 6: visibility now includes assignment-table check for
!user.isPlatform && // user-role members. We return 404 (not 403) to avoid leaking
tenant.metadata.labels?.["pieced.ch/zitadel-org-id"] !== user.orgId // tenant existence — same as cross-org reads.
) { if (!(await canUserSeeTenant(user, tenant))) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 }); return NextResponse.json({ error: "Not found" }, { status: 404 });
} }
return NextResponse.json(tenant); return NextResponse.json(tenant);
@@ -46,7 +47,7 @@ export async function PATCH(
if (!user) if (!user)
return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if (!user.isPlatform && !user.roles.includes("owner")) { if (!canMutate(user)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 }); return NextResponse.json({ error: "Forbidden" }, { status: 403 });
} }

View File

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

View File

@@ -1,21 +1,14 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { getSessionUser } from "@/lib/session"; import { getSessionUser } from "@/lib/session";
import { listTenants } from "@/lib/k8s"; import { listTenants } from "@/lib/k8s";
import { listVisibleTenants } from "@/lib/visibility";
export async function GET() { export async function GET() {
const user = await getSessionUser(); const user = await getSessionUser();
if (!user) if (!user)
return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const tenants = await listTenants(); const all = await listTenants();
const visible = await listVisibleTenants(user, all);
if (user.isPlatform) { return NextResponse.json(visible);
return NextResponse.json(tenants);
}
// Customers see only their own tenant
const own = tenants.filter(
(t) => t.metadata.labels?.["pieced.ch/zitadel-org-id"] === user.orgId
);
return NextResponse.json(own);
} }

View File

@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { getSessionUser } from "@/lib/session"; import { getSessionUser } from "@/lib/session";
import { listTenants } from "@/lib/k8s"; import { listTenants } from "@/lib/k8s";
import { listVisibleTenants } from "@/lib/visibility";
import { getTeamInfo, getTeamSpendLogsV2 } from "@/lib/litellm"; import { getTeamInfo, getTeamSpendLogsV2 } from "@/lib/litellm";
import { safeError } from "@/lib/errors"; import { safeError } from "@/lib/errors";
@@ -36,12 +37,17 @@ export async function GET(req: NextRequest) {
keyAlias = req.nextUrl.searchParams.get("keyAlias") ?? null; keyAlias = req.nextUrl.searchParams.get("keyAlias") ?? null;
} }
// For customers (or admins without explicit params): resolve from their tenant. // For customers (or admins without explicit params): resolve from
// the user's *visible* tenants. With Slice 6, a `user`-role member
// can only see usage for tenants they're assigned to — a non-assigned
// user defaults to "no active tenant" (404).
//
// Owner and platform get the full org-scoped list and pick the first
// tenant, matching the dashboard's "current instance" semantics.
if (!teamId) { if (!teamId) {
const tenants = await listTenants(); const allTenants = await listTenants();
const orgTenant = tenants.find( const visible = await listVisibleTenants(user, allTenants);
(t) => t.metadata.labels?.["pieced.ch/zitadel-org-id"] === user.orgId const orgTenant = visible.find((t) => !!t.status?.litellmTeamId);
);
if (!orgTenant?.status?.litellmTeamId) { if (!orgTenant?.status?.litellmTeamId) {
return NextResponse.json( return NextResponse.json(

View File

@@ -17,12 +17,15 @@ interface ChannelUsersProps {
enabledChannels: string[]; enabledChannels: string[];
/** Current channelUsers from the PiecedTenant spec */ /** Current channelUsers from the PiecedTenant spec */
initialChannelUsers: Record<string, string[]>; initialChannelUsers: Record<string, string[]>;
/** Slice 5: when false, add inputs and remove ✕ buttons are hidden. */
canEdit?: boolean;
} }
export function ChannelUsers({ export function ChannelUsers({
tenantName, tenantName,
enabledChannels, enabledChannels,
initialChannelUsers, initialChannelUsers,
canEdit = true,
}: ChannelUsersProps) { }: ChannelUsersProps) {
const t = useTranslations("channelUsers"); const t = useTranslations("channelUsers");
const router = useRouter(); 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" 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} {userId}
<button {canEdit && (
onClick={() => handleRemove(channel, userId)} <button
disabled={saving} onClick={() => handleRemove(channel, userId)}
className="text-accent/60 hover:text-red-400 transition-colors disabled:opacity-50" disabled={saving}
title={t("remove")} className="text-accent/60 hover:text-red-400 transition-colors disabled:opacity-50"
> title={t("remove")}
>
</button>
</button>
)}
</span> </span>
))} ))}
</div> </div>
)} )}
{/* Add user */} {/* Add user — hidden in read-only mode */}
<div className="flex gap-2"> {canEdit && (
<input <div className="flex gap-2">
type="text" <input
value={inputValues[channel] || ""} type="text"
onChange={(e) => value={inputValues[channel] || ""}
setInputValues((prev) => ({ onChange={(e) =>
...prev, setInputValues((prev) => ({
[channel]: e.target.value, ...prev,
})) [channel]: e.target.value,
} }))
onKeyDown={(e) => { }
if (e.key === "Enter") handleAdd(channel); 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" 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)} <button
disabled={saving || !inputValues[channel]?.trim()} onClick={() => handleAdd(channel)}
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" 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> {saving ? "…" : t("add")}
</div> </button>
</div>
)}
</div> </div>
); );
})} })}

View File

@@ -40,6 +40,17 @@ function NavBar() {
<NavLink href="/dashboard" active={pathname === "/dashboard"}> <NavLink href="/dashboard" active={pathname === "/dashboard"}>
{t("dashboard")} {t("dashboard")}
</NavLink> </NavLink>
{/* Slice 7: /team is owner+platform only. Match server-side
gate (canMutate). The roles array carries either "owner"
or "user" for customer sessions; isPlatform covers the
platform side. */}
{user &&
(user.isPlatform ||
(Array.isArray(user.roles) && user.roles.includes("owner"))) && (
<NavLink href="/team" active={pathname === "/team"}>
{t("team")}
</NavLink>
)}
{user?.isPlatform && ( {user?.isPlatform && (
<NavLink href="/admin" active={pathname === "/admin"}> <NavLink href="/admin" active={pathname === "/admin"}>
{t("admin")} {t("admin")}

View File

@@ -10,9 +10,18 @@ interface Props {
status?: "pending" | "active" | "error"; status?: "pending" | "active" | "error";
tenantName: string; tenantName: string;
onToggled: () => void; 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 t = useTranslations();
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
const [secrets, setSecrets] = useState<Record<string, string>>({}); const [secrets, setSecrets] = useState<Record<string, string>>({});
@@ -113,17 +122,27 @@ export function PackageCard({ pkg, enabled, status, tenantName, onToggled }: Pro
{pkg.requiresSecrets && ( {pkg.requiresSecrets && (
<span className="text-[10px] text-text-muted">{t("packages.requiresApiKey")}</span> <span className="text-[10px] text-text-muted">{t("packages.requiresApiKey")}</span>
)} )}
<button {canEdit ? (
onClick={enabled ? () => togglePackage(false) : handleEnable} <button
disabled={saving} onClick={enabled ? () => togglePackage(false) : handleEnable}
className={`ml-auto rounded-lg px-3 py-1.5 text-xs font-medium transition-all cursor-pointer ${ disabled={saving}
enabled className={`ml-auto rounded-lg px-3 py-1.5 text-xs font-medium transition-all cursor-pointer ${
? "bg-surface-3 text-text-secondary hover:text-text-primary hover:bg-surface-2" enabled
: "bg-accent text-surface-0 hover:bg-accent-dim shadow-lg shadow-accent/20" ? "bg-surface-3 text-text-secondary hover:text-text-primary hover:bg-surface-2"
} disabled:opacity-50`} : "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> {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>
</div> </div>

View File

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

View File

@@ -8,9 +8,11 @@ const FILE_TABS = ["SOUL.md", "AGENTS.md", "TOOLS.md"] as const;
interface Props { interface Props {
tenantName: string; tenantName: string;
files: Record<string, 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 t = useTranslations("workspace");
const [activeTab, setActiveTab] = useState<string>("SOUL.md"); const [activeTab, setActiveTab] = useState<string>("SOUL.md");
const [localFiles, setLocalFiles] = useState<Record<string, string>>(files); 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); const [error, setError] = useState<string | null>(null);
function handleChange(content: string) { function handleChange(content: string) {
if (!canEdit) return;
setLocalFiles((prev) => ({ ...prev, [activeTab]: content })); setLocalFiles((prev) => ({ ...prev, [activeTab]: content }));
setDirty(true); setDirty(true);
} }
@@ -62,20 +65,25 @@ export function WorkspaceEditor({ tenantName, files }: Props) {
</button> </button>
))} ))}
</div> </div>
<button {canEdit && (
onClick={handleSave} <button
disabled={!dirty || saving} onClick={handleSave}
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" 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> {saving ? "…" : t("save")}
</button>
)}
</div> </div>
<textarea <textarea
value={localFiles[activeTab] || ""} value={localFiles[activeTab] || ""}
onChange={(e) => handleChange(e.target.value)} onChange={(e) => handleChange(e.target.value)}
readOnly={!canEdit}
spellCheck={false} 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 })} placeholder={t("placeholder", { file: activeTab })}
/> />

View File

@@ -0,0 +1,150 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
type FormState = "idle" | "submitting" | "success" | "error";
/**
* InviteForm — owner submits email + name + role to /api/team/invite.
* On success, broadcasts `team:refresh` so the sibling TeamList
* re-fetches the member list.
*
* Form fields mirror the POST body:
* { email, givenName, familyName, role: "owner" | "user" }
*
* Role defaults to "user" — the more conservative grant. Owner
* promotion happens in ZITADEL Console for now.
*/
export function InviteForm() {
const t = useTranslations("team");
const tCommon = useTranslations("common");
const [form, setForm] = useState({
email: "",
givenName: "",
familyName: "",
role: "user" as "owner" | "user",
});
const [state, setState] = useState<FormState>("idle");
const [error, setError] = useState("");
function handleChange(e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) {
setForm((prev) => ({ ...prev, [e.target.name]: e.target.value }));
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError("");
setState("submitting");
try {
const res = await fetch("/api/team/invite", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(form),
});
if (!res.ok) {
const data = await res.json();
if (data.code === "user_already_exists") {
throw new Error(t("inviteUserExists"));
}
throw new Error(data.error || "Invite failed");
}
setState("success");
setForm({ email: "", givenName: "", familyName: "", role: "user" });
// Tell the TeamList sibling to refresh
window.dispatchEvent(new Event("team:refresh"));
// Auto-clear the success banner after a moment so the form
// doesn't permanently look "done"
setTimeout(() => setState("idle"), 3500);
} catch (err: any) {
setError(err.message);
setState("error");
}
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-1.5">
{t("givenName")}
</label>
<input
name="givenName"
type="text"
required
value={form.givenName}
onChange={handleChange}
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"
/>
</div>
<div>
<label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-1.5">
{t("familyName")}
</label>
<input
name="familyName"
type="text"
required
value={form.familyName}
onChange={handleChange}
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"
/>
</div>
</div>
<div>
<label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-1.5">
{t("email")}
</label>
<input
name="email"
type="email"
required
value={form.email}
onChange={handleChange}
placeholder="colleague@company.ch"
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"
/>
</div>
<div>
<label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-1.5">
{t("role")}
</label>
<select
name="role"
value={form.role}
onChange={handleChange}
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="user">{t("roleUser")}</option>
<option value="owner">{t("roleOwner")}</option>
</select>
<p className="text-xs text-text-muted mt-1">{t("roleHint")}</p>
</div>
{error && (
<div className="text-xs text-red-400 bg-red-400/10 border border-red-400/20 rounded-lg px-3 py-2">
{error}
</div>
)}
{state === "success" && (
<div className="text-xs text-emerald-400 bg-emerald-400/10 border border-emerald-400/20 rounded-lg px-3 py-2">
{t("inviteSent")}
</div>
)}
<button
type="submit"
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"
>
{state === "submitting" ? tCommon("loading") : t("inviteButton")}
</button>
</form>
);
}

View File

@@ -0,0 +1,235 @@
"use client";
import { useState, useEffect } from "react";
import { useTranslations } from "next-intl";
interface OrgMember {
userId: string;
email: string;
displayName: string;
givenName: string;
familyName: string;
roles: string[];
authorizationId: string;
}
interface Props {
initialMembers: OrgMember[];
currentUserId: string;
/**
* Whether the viewing user can change other members' roles. True only
* for customer owners. Server enforces this independently — this prop
* is purely UX (don't render the control if the action would 403).
*/
canEditRoles: boolean;
}
type RoleOption = "owner" | "user";
/**
* TeamList — renders the org's members. Refreshes after invites by
* polling the API; the InviteForm broadcasts a `team:refresh` window
* event after a successful invite so the list updates immediately
* rather than waiting for the next reload.
*
* Slice 7 + Bug 25: owners can change other members' roles inline.
* Clicking the "Change role" button on a row swaps the badge for a
* dropdown + Save/Cancel pair. We deliberately don't use a modal —
* the change is a single-field edit and the user already sees the row
* context, so inline is faster.
*
* Self-row never shows the editor (server enforces too). Last-owner
* demotion is enforced server-side; we surface the resulting 409 as a
* row-local error rather than pre-validating client-side, because the
* client doesn't know the org's full owner count without an extra
* round trip.
*/
export function TeamList({
initialMembers,
currentUserId,
canEditRoles,
}: Props) {
const t = useTranslations("team");
const [members, setMembers] = useState<OrgMember[]>(initialMembers);
// Per-row editor state. `editingId` is the userId currently being
// edited (only one at a time). `pendingRole` is the dropdown value.
// `rowError` carries server-rejection messages keyed by userId.
const [editingId, setEditingId] = useState<string | null>(null);
const [pendingRole, setPendingRole] = useState<RoleOption>("user");
const [submitting, setSubmitting] = useState(false);
const [rowError, setRowError] = useState<Record<string, string>>({});
useEffect(() => {
function refresh() {
fetch("/api/team")
.then((r) => (r.ok ? r.json() : null))
.then((data) => {
if (data?.members) setMembers(data.members);
})
.catch(() => {});
}
window.addEventListener("team:refresh", refresh);
return () => window.removeEventListener("team:refresh", refresh);
}, []);
function startEdit(m: OrgMember) {
const current = (m.roles[0] === "owner" ? "owner" : "user") as RoleOption;
setEditingId(m.userId);
setPendingRole(current);
setRowError((e) => ({ ...e, [m.userId]: "" }));
}
function cancelEdit() {
setEditingId(null);
setSubmitting(false);
}
async function saveEdit(m: OrgMember) {
setSubmitting(true);
setRowError((e) => ({ ...e, [m.userId]: "" }));
try {
const res = await fetch(
`/api/team/${encodeURIComponent(m.userId)}/role`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ role: pendingRole }),
}
);
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || t("roleUpdateFailed"));
}
// Optimistic update — replace the row's roles locally rather than
// re-fetching the whole list. The list will eventually re-fetch
// on the next `team:refresh` event anyway.
setMembers((prev) =>
prev.map((x) =>
x.userId === m.userId ? { ...x, roles: [pendingRole] } : x
)
);
setEditingId(null);
} catch (err: any) {
setRowError((e) => ({ ...e, [m.userId]: err.message }));
} finally {
setSubmitting(false);
}
}
if (members.length === 0) {
return (
<div className="text-sm text-text-secondary text-center py-6 border border-border rounded-xl bg-surface-1">
{t("noMembers")}
</div>
);
}
return (
<div className="bg-surface-1 border border-border rounded-xl overflow-hidden">
<ul className="divide-y divide-border">
{members.map((m) => {
const isSelf = m.userId === currentUserId;
const isEditing = editingId === m.userId;
// Hide editor for self even when the viewer is an owner —
// self-demotion is server-blocked and offering it as a UI
// affordance would just produce errors.
const showEditor = canEditRoles && !isSelf;
const err = rowError[m.userId];
return (
<li
key={m.userId}
className="px-5 py-3 flex items-center justify-between gap-4"
>
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-text-primary truncate">
{m.displayName || m.email}
</span>
{isSelf && (
<span className="text-[10px] uppercase tracking-wider text-accent">
{t("you")}
</span>
)}
</div>
<div className="text-xs text-text-muted truncate font-mono">
{m.email}
</div>
{err && (
<div className="text-xs text-red-400 mt-1">{err}</div>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
{isEditing ? (
<>
<select
value={pendingRole}
onChange={(e) =>
setPendingRole(e.target.value as RoleOption)
}
disabled={submitting}
className="text-xs bg-surface-2 border border-border rounded-md px-2 py-1 text-text-primary focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent"
>
<option value="user">{t("roleUser")}</option>
<option value="owner">{t("roleOwner")}</option>
</select>
<button
type="button"
onClick={() => saveEdit(m)}
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"
>
{t("save")}
</button>
<button
type="button"
onClick={cancelEdit}
disabled={submitting}
className="text-xs px-2.5 py-1 rounded-md border border-border text-text-secondary hover:text-text-primary transition-colors"
>
{t("cancel")}
</button>
</>
) : (
<>
<div className="flex flex-wrap gap-1.5 justify-end">
{m.roles.length === 0 && (
<span className="text-[10px] uppercase tracking-wider text-text-muted bg-surface-3 px-2 py-0.5 rounded-full">
{t("noRole")}
</span>
)}
{m.roles.map((r) => (
<span
key={r}
className={`text-[10px] uppercase tracking-wider px-2 py-0.5 rounded-full ${
r === "owner"
? "bg-accent/15 text-accent border border-accent/20"
: "bg-surface-3 text-text-secondary border border-border"
}`}
>
{r}
</span>
))}
</div>
{showEditor && (
<button
type="button"
onClick={() => startEdit(m)}
title={t("changeRole")}
className="text-xs text-text-muted hover:text-text-primary px-2 py-1 rounded-md transition-colors"
>
{t("changeRole")}
</button>
)}
</>
)}
</div>
</li>
);
})}
</ul>
</div>
);
}

View File

@@ -0,0 +1,231 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useTranslations } from "next-intl";
import { Card } from "@/components/ui/card";
interface Assignment {
userId: string;
displayName: string;
email: string;
roles: string[];
assignedAt: string;
assignedBy: string;
orphan: boolean;
}
interface OrgMember {
userId: string;
email: string;
displayName: string;
roles: string[];
}
interface Props {
tenantName: string;
/**
* When false, the panel renders read-only — assignments are visible
* but the add-user form and remove ✕ buttons are hidden. Pass
* `canEdit` from the parent server component (= canMutate(user)).
*/
canEdit: boolean;
}
/**
* AssignedUsersPanel — manages the tenant_user_assignments rows for
* one tenant. Owner sees:
* - List of currently-assigned users with name, email, role, and
* an "X" button to revoke.
* - Dropdown of org members not yet assigned + "Assign" button.
*
* `user`-role members see the panel read-only (canEdit=false): they
* see who else has access to the tenant they're working with, but
* can't change anything.
*/
export function AssignedUsersPanel({ tenantName, canEdit }: Props) {
const t = useTranslations("assignments");
const [assignments, setAssignments] = useState<Assignment[] | null>(null);
const [members, setMembers] = useState<OrgMember[] | null>(null);
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const [pickedUserId, setPickedUserId] = useState("");
const refresh = useCallback(async () => {
setError("");
try {
const [aRes, mRes] = await Promise.all([
fetch(`/api/tenants/${tenantName}/assignments`),
canEdit
? fetch(`/api/team`)
: Promise.resolve(null),
]);
if (!aRes.ok) throw new Error("Failed to load assignments");
const aData = await aRes.json();
setAssignments(aData.assignments ?? []);
if (mRes && mRes.ok) {
const mData = await mRes.json();
setMembers(mData.members ?? []);
}
} catch (err: any) {
setError(err.message);
}
}, [tenantName, canEdit]);
useEffect(() => {
refresh();
}, [refresh]);
async function handleAssign() {
if (!pickedUserId || busy) return;
setBusy(true);
setError("");
try {
const res = await fetch(`/api/tenants/${tenantName}/assignments`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId: pickedUserId }),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "Assign failed");
}
setPickedUserId("");
await refresh();
} catch (err: any) {
setError(err.message);
} finally {
setBusy(false);
}
}
async function handleRevoke(userId: string) {
if (busy) return;
setBusy(true);
setError("");
try {
const res = await fetch(
`/api/tenants/${tenantName}/assignments/${encodeURIComponent(userId)}`,
{ method: "DELETE" }
);
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "Revoke failed");
}
await refresh();
} catch (err: any) {
setError(err.message);
} finally {
setBusy(false);
}
}
if (assignments === null) {
return (
<Card>
<div className="text-xs text-text-muted">{t("loading")}</div>
</Card>
);
}
// Compute candidates for the assign dropdown: members of the org
// who hold the `user` role (not owners — they have implicit access)
// and aren't already assigned.
const assignedIds = new Set(assignments.map((a) => a.userId));
const candidates = (members ?? []).filter(
(m) =>
!assignedIds.has(m.userId) &&
m.roles.includes("user") &&
!m.roles.includes("owner")
);
return (
<Card>
{error && (
<div className="text-xs text-red-400 bg-red-400/10 border border-red-400/20 rounded-lg px-3 py-2 mb-3">
{error}
<button
onClick={() => setError("")}
className="ml-2 text-red-300 hover:text-red-200"
>
</button>
</div>
)}
{assignments.length === 0 ? (
<p className="text-sm text-text-secondary text-center py-3">
{t("noneAssigned")}
</p>
) : (
<ul className="divide-y divide-border -mx-2">
{assignments.map((a) => (
<li
key={a.userId}
className="px-2 py-2 flex items-center justify-between gap-3"
>
<div className="min-w-0">
<div className="text-sm font-medium text-text-primary truncate">
{a.orphan ? (
<span className="text-text-muted italic">
{a.displayName}
</span>
) : (
a.displayName
)}
</div>
{a.email && (
<div className="text-xs text-text-muted truncate font-mono">
{a.email}
</div>
)}
</div>
{canEdit && (
<button
onClick={() => handleRevoke(a.userId)}
disabled={busy}
className="text-text-muted/60 hover:text-red-400 transition-colors disabled:opacity-50 text-sm px-2"
title={t("revoke")}
>
</button>
)}
</li>
))}
</ul>
)}
{canEdit && (
<div className="mt-4 pt-4 border-t border-border">
{candidates.length === 0 ? (
<p className="text-xs text-text-muted text-center py-2">
{t("noCandidates")}
</p>
) : (
<div className="flex gap-2">
<select
value={pickedUserId}
onChange={(e) => setPickedUserId(e.target.value)}
className="flex-1 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="">{t("pickUser")}</option>
{candidates.map((m) => (
<option key={m.userId} value={m.userId}>
{m.displayName || m.email}
</option>
))}
</select>
<button
onClick={handleAssign}
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"
>
{busy ? "…" : t("assign")}
</button>
</div>
)}
</div>
)}
</Card>
);
}

View File

@@ -0,0 +1,43 @@
import Link from "next/link";
/**
* BackLink — small "← Page" navigation cue that sits above a page's
* `<h1 className="accent-rule">` heading.
*
* Why this exists
* ---------------
* The pattern was originally written inline on /team and /dashboard/new
* as `<Link className="inline-flex …"><span>←</span> Title</Link>`.
* That's wrong because `.accent-rule` (defined in globals.css) sets
* `display: inline-block` on the H1 — so an inline-flex link followed by
* an inline-block H1 are both inline-level, and end up on the same
* baseline whenever there's horizontal room for them. The `mb-4` on the
* link does nothing because vertical margin between inline boxes
* doesn't push siblings to a new line.
*
* Solving it: this component renders the link as a block-level flex
* container with `w-fit` so it shrinks to its content (and its hover
* area doesn't span the gutter). The trailing block element below sits
* cleanly on its own line.
*
* Use it whenever a page has a back-link above an `accent-rule` H1.
* The two prior callsites (/team and /dashboard/new) have been
* migrated; new pages should just use this directly.
*/
export function BackLink({
href,
label,
}: {
href: string;
label: string;
}) {
return (
<Link
href={href}
className="flex w-fit items-center gap-1.5 mb-4 text-xs font-medium text-text-muted hover:text-text-primary transition-colors"
>
<span aria-hidden="true"></span>
<span>{label}</span>
</Link>
);
}

54
src/instrumentation.ts Normal file
View File

@@ -0,0 +1,54 @@
/**
* Next.js instrumentation hook — runs once when the server boots.
*
* Scope is intentionally narrow: warn early about ZITADEL misconfigurations
* that would otherwise cause silent feature failures (Bugs 20, 21, 23, 24
* from the test triage). The check is fire-and-forget — it must NEVER
* crash the server, even if ZITADEL is briefly unreachable at boot.
*
* Add new self-checks here only if they meet the same bar: cheap, side-effect
* free, and useful at the precise moment a misconfiguration would otherwise
* go unnoticed.
*
* Docs: https://nextjs.org/docs/app/building-your-application/optimizing/instrumentation
*/
const REQUIRED_ROLE_KEYS = [
"owner",
"user",
"platform_admin",
"platform_operator",
] as const;
export async function register() {
if (process.env.NEXT_RUNTIME !== "nodejs") return;
// Skip during `next build` — there's no need to talk to ZITADEL just to
// produce a static build, and we don't want CI builds to depend on it.
if (process.env.NEXT_PHASE === "phase-production-build") return;
// Lazy import: the instrumentation file runs in a constrained context
// before app code; importing at top-level would pull NextAuth/etc.
const { listProjectRoles } = await import("@/lib/zitadel");
try {
const present = new Set(await listProjectRoles());
const missing = REQUIRED_ROLE_KEYS.filter((k) => !present.has(k));
if (missing.length === 0) {
console.log(
`[startup] ZITADEL project roles OK (${REQUIRED_ROLE_KEYS.length} canonical keys present).`
);
return;
}
console.warn(
`[startup] ZITADEL project ${process.env.ZITADEL_PROJECT_ID} is missing canonical role key(s): ${missing.join(", ")}. ` +
`Customer invites and team-page badges will not work. ` +
`Run \`node --env-file=.env scripts/zitadel-roles.mjs apply\` to repair.`
);
} catch (err) {
// Never block startup. The portal can still serve unauthenticated
// pages and the operator can investigate at leisure.
console.warn("[startup] ZITADEL self-check failed (continuing):", err);
}
}

View File

@@ -1,14 +1,25 @@
import NextAuth from "next-auth"; import NextAuth from "next-auth";
import type { NextAuthConfig } 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"]; 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( function extractRoles(
rolesObj?: Record<string, Record<string, string>> rolesObj?: Record<string, Record<string, string>>
): PlatformRole[] { ): Role[] {
if (!rolesObj) return []; if (!rolesObj) return [];
return Object.keys(rolesObj) as PlatformRole[]; return Object.keys(rolesObj) as Role[];
} }
export const authConfig: NextAuthConfig = { export const authConfig: NextAuthConfig = {
@@ -50,7 +61,7 @@ export const authConfig: NextAuthConfig = {
return token; return token;
}, },
async session({ session, token }) { async session({ session, token }) {
const roles = (token.roles as PlatformRole[]) ?? []; const roles = (token.roles as Role[]) ?? [];
const sessionUser: SessionUser = { const sessionUser: SessionUser = {
id: token.sub!, id: token.sub!,
name: session.user?.name ?? "", name: session.user?.name ?? "",
@@ -58,7 +69,9 @@ export const authConfig: NextAuthConfig = {
orgId: token.orgId as string, orgId: token.orgId as string,
orgName: token.orgName as string, orgName: token.orgName as string,
roles, roles,
isPlatform: roles.some((r) => PLATFORM_ROLES.includes(r)), isPlatform: roles.some((r) =>
PLATFORM_ROLES.includes(r as PlatformRole)
),
}; };
(session as any).platformUser = sessionUser; (session as any).platformUser = sessionUser;
return session; return session;

View File

@@ -82,6 +82,39 @@ const MIGRATION_SQL = `
content TEXT NOT NULL, content TEXT NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now() updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
); );
-- ---------------------------------------------------------------------------
-- Slice 6: per-tenant user assignments
-- ---------------------------------------------------------------------------
--
-- Each row grants ONE user visibility into ONE tenant within their own
-- ZITADEL org. Used to narrow the customer 'user' role from "everything
-- in the org" to "only the tenants I've been assigned to". Owners and
-- platform users bypass this table entirely.
--
-- Composite PK is (tenant_name, zitadel_user_id) — a user is either
-- assigned to a tenant or not, no degree.
--
-- The zitadel_org_id column is denormalised onto every row so cascade
-- cleanups when a user leaves an org can be expressed as a single
-- DELETE WHERE zitadel_org_id=$1 AND zitadel_user_id=$2 — without
-- joining tenant_requests. The assigned_by column tracks which user
-- (the owner usually) granted the assignment, for audit.
--
-- Cascade on tenant deletion is enforced in application code (the
-- admin delete handler calls removeAllAssignmentsForTenant) rather
-- than via FK — there's no FK target, since K8s CRs aren't a Postgres
-- table.
CREATE TABLE IF NOT EXISTS tenant_user_assignments (
tenant_name TEXT NOT NULL,
zitadel_org_id TEXT NOT NULL,
zitadel_user_id TEXT NOT NULL,
assigned_at TIMESTAMPTZ NOT NULL DEFAULT now(),
assigned_by TEXT NOT NULL,
PRIMARY KEY (tenant_name, zitadel_user_id)
);
CREATE INDEX IF NOT EXISTS idx_tua_user ON tenant_user_assignments(zitadel_user_id);
CREATE INDEX IF NOT EXISTS idx_tua_org ON tenant_user_assignments(zitadel_org_id);
`; `;
let migrated = false; let migrated = false;
@@ -417,3 +450,150 @@ function mapRow(row: any): TenantRequest {
updatedAt: row.updated_at?.toISOString?.() ?? row.updated_at, updatedAt: row.updated_at?.toISOString?.() ?? row.updated_at,
}; };
} }
// ---------------------------------------------------------------------------
// Slice 6: tenant ↔ user assignments
// ---------------------------------------------------------------------------
/**
* One assignment grants one user visibility into one tenant. Returned
* shape is the camelCase mirror of the Postgres row.
*/
export interface TenantUserAssignment {
tenantName: string;
zitadelOrgId: string;
zitadelUserId: string;
assignedAt: string;
assignedBy: string;
}
function mapAssignmentRow(row: any): TenantUserAssignment {
return {
tenantName: row.tenant_name,
zitadelOrgId: row.zitadel_org_id,
zitadelUserId: row.zitadel_user_id,
assignedAt: row.assigned_at?.toISOString?.() ?? row.assigned_at,
assignedBy: row.assigned_by,
};
}
/**
* Returns the set of tenant CR names assigned to the given user.
*
* Hot path on every read for `user`-role customers, so it's intentionally
* a single indexed lookup. The returned array is small (a handful of
* tenants per user); callers usually wrap it in a Set.
*
* Note: this does NOT cross-check the org id — assignments are per-user,
* and a user's org context comes from their JWT. If a user's
* authorization is revoked at the ZITADEL level, their JWT ceases to
* carry the customer role and they can't reach the dashboard at all;
* the orphan rows are cleaned up the next time their org membership
* is re-evaluated (Slice 7's removeAllAssignmentsForUser).
*/
export async function listTenantAssignmentsForUser(
userId: string
): Promise<string[]> {
await ensureSchema();
const result = await getPool().query<{ tenant_name: string }>(
"SELECT tenant_name FROM tenant_user_assignments WHERE zitadel_user_id = $1",
[userId]
);
return result.rows.map((r) => r.tenant_name);
}
/**
* Returns all assignments for a single tenant. Used by the team UI
* (Slice 7) to render "who has access to this instance". Includes
* `assignedBy` and `assignedAt` for audit display.
*/
export async function listAssignmentsForTenant(
tenantName: string
): Promise<TenantUserAssignment[]> {
await ensureSchema();
const result = await getPool().query(
"SELECT * FROM tenant_user_assignments WHERE tenant_name = $1 ORDER BY assigned_at DESC",
[tenantName]
);
return result.rows.map(mapAssignmentRow);
}
/**
* Grant a user access to a tenant. Idempotent — a duplicate INSERT
* is silently ignored via ON CONFLICT, and the existing
* `assigned_at`/`assigned_by` are preserved (we don't update them on
* re-assign).
*
* Caller is responsible for verifying:
* - The actor (`assignedBy`) holds owner/platform role in `orgId`.
* - The target user (`userId`) is actually a member of the same
* ZITADEL org. We don't validate this here — the team UI fetches
* the org's user list from ZITADEL and selects from it.
* - The tenant CR exists and is labelled with the same `orgId`.
*/
export async function addTenantAssignment(params: {
tenantName: string;
orgId: string;
userId: string;
assignedBy: string;
}): Promise<void> {
await ensureSchema();
await getPool().query(
`INSERT INTO tenant_user_assignments
(tenant_name, zitadel_org_id, zitadel_user_id, assigned_by)
VALUES ($1, $2, $3, $4)
ON CONFLICT (tenant_name, zitadel_user_id) DO NOTHING`,
[params.tenantName, params.orgId, params.userId, params.assignedBy]
);
}
/**
* Revoke a user's access to a tenant. No-op if the row doesn't exist.
*/
export async function removeTenantAssignment(
tenantName: string,
userId: string
): Promise<void> {
await ensureSchema();
await getPool().query(
"DELETE FROM tenant_user_assignments WHERE tenant_name = $1 AND zitadel_user_id = $2",
[tenantName, userId]
);
}
/**
* Cascade cleanup: drop ALL assignments for a tenant when the tenant
* itself is deleted. Called from the admin delete handler.
*
* Without this, an orphan row would stick around forever — a future
* tenant with the same name (won't happen given Slice 1's UUID-suffix
* naming, but defense in depth) would inherit the old assignments.
*/
export async function removeAllAssignmentsForTenant(
tenantName: string
): Promise<void> {
await ensureSchema();
await getPool().query(
"DELETE FROM tenant_user_assignments WHERE tenant_name = $1",
[tenantName]
);
}
/**
* Cascade cleanup: drop ALL assignments for a user within a specific
* org. Used by Slice 7's "remove member" flow when an owner kicks a
* user out of the org. Scoped by `orgId` so a user with assignments in
* org A doesn't lose them when removed from org B (multi-org users
* exist when a person registers personally and is also invited to a
* company).
*/
export async function removeAllAssignmentsForUser(
orgId: string,
userId: string
): Promise<void> {
await ensureSchema();
await getPool().query(
"DELETE FROM tenant_user_assignments WHERE zitadel_org_id = $1 AND zitadel_user_id = $2",
[orgId, userId]
);
}

View File

@@ -1,19 +1,87 @@
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import type { SessionUser } from "@/types"; 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> { export async function getSessionUser(): Promise<SessionUser | null> {
const session = await auth(); const session = await auth();
return (session as any)?.platformUser ?? null; 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> { export async function requireSession(): Promise<SessionUser> {
const user = await getSessionUser(); const user = await getSessionUser();
if (!user) throw new Error("Unauthorized"); if (!user) throw new Error("Unauthorized");
return user; 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> { export async function requirePlatformRole(): Promise<SessionUser> {
const user = await requireSession(); const user = await requireSession();
if (!user.isPlatform) throw new Error("Forbidden: platform role required"); if (!user.isPlatform) throw new Error("Forbidden: platform role required");
return user; 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;
}

189
src/lib/team.ts Normal file
View File

@@ -0,0 +1,189 @@
/**
* Team management — high-level operations on top of `lib/zitadel.ts`.
*
* Two responsibilities:
* 1. Fetching the joined "members + roles" view for an org, used by
* the /team page and the assigned-users panel.
* 2. Inviting a new member end-to-end (create user + send invite +
* assign role) with rollback on partial failure, mirroring
* `registerCustomer` for new orgs.
*
* Allowed customer roles
* ----------------------
* Slice 7 reduced scope: invitations may only set the customer roles
* `owner` or `user`. Platform roles cannot be granted via the portal —
* those are managed in ZITADEL Console with stricter access. The
* `viewer` role is gone since Slice 5.
*/
import {
listOrgUsers,
listOrgAuthorizations,
createHumanUser,
createInviteCode,
createAuthorization,
type OrgUser,
} from "./zitadel";
import type { CustomerRole } from "@/types";
const ALLOWED_INVITE_ROLES: CustomerRole[] = ["owner", "user"];
export function isValidInviteRole(role: string): role is CustomerRole {
return (ALLOWED_INVITE_ROLES as string[]).includes(role);
}
export interface OrgMember {
userId: string;
email: string;
displayName: string;
givenName: string;
familyName: string;
/**
* Roles held by this member on the org's project grant. Usually a
* single-element array (one of "owner" / "user"). Could be empty
* if the user exists in the org but has no project authorization
* yet — appears as "no role" in the UI.
*/
roles: string[];
/**
* The ZITADEL authorization ID backing the role assignment, if any.
* Used by the team UI's role-change flow to call UpdateAuthorization.
* Empty string if the member has no authorization (orphan / pre-Slice-7
* legacy / mid-invite race).
*
* If a member somehow holds multiple authorization rows (not expected
* at our project-grant scope of [owner, user]), only the first is
* surfaced here. The team page joins per-user, so the UI sees one
* row per member; mutations target that authorization.
*/
authorizationId: string;
}
/**
* Fetch the joined members-with-roles view for an org. Two ZITADEL
* calls run in parallel (users + authorizations) then joined in memory.
*
* If either call fails, returns whatever the other one produced —
* users without roles render as "no role" badges; missing users are
* just absent. Better degraded than empty.
*/
export async function getOrgMembers(orgId: string): Promise<OrgMember[]> {
const [users, auths] = await Promise.all([
listOrgUsers(orgId),
listOrgAuthorizations(orgId),
]);
// Group authorizations by userId. We track BOTH the union of role
// keys (for display) and the first authorizationId we see (for the
// role-change flow). A user could in principle hold multiple
// authorization rows, but at our project-grant scope of [owner, user]
// each member ends up with exactly one. If a future config produces
// multi-row members the UI surfaces the first; cleanup belongs in
// ZITADEL Console.
const rolesByUser = new Map<string, Set<string>>();
const authIdByUser = new Map<string, string>();
for (const a of auths) {
const set = rolesByUser.get(a.userId) ?? new Set<string>();
for (const r of a.roleKeys) set.add(r);
rolesByUser.set(a.userId, set);
if (!authIdByUser.has(a.userId) && a.authorizationId) {
authIdByUser.set(a.userId, a.authorizationId);
}
}
return users.map((u) => ({
userId: u.userId,
email: u.email,
displayName: u.displayName,
givenName: u.givenName,
familyName: u.familyName,
roles: Array.from(rolesByUser.get(u.userId) ?? []),
authorizationId: authIdByUser.get(u.userId) ?? "",
}));
}
/**
* Look up a single org member by userId. Convenience wrapper used to
* resolve a userId in an assignment row to a display name. Returns
* null if the user no longer exists in the org (stale assignment row).
*/
export async function getOrgMember(
orgId: string,
userId: string
): Promise<OrgMember | null> {
const all = await getOrgMembers(orgId);
return all.find((m) => m.userId === userId) ?? null;
}
export interface InviteResult {
userId: string;
emailAlreadyExists: boolean;
}
/**
* Invite a new member into an existing customer org.
*
* Three steps:
* 1. createHumanUser — create the ZITADEL human, no password.
* 2. createInviteCode — send the invite email (set password + verify).
* 3. createAuthorization — assign the chosen customer role.
*
* If any step after (1) fails, the user is NOT rolled back. Reasoning:
* unlike registration where a half-created org is useless, a
* half-invited user can be cleaned up manually in ZITADEL Console and
* re-invited. The mid-failure cost of partial state is low; the cost of
* a wrong rollback is double-creation on retry. So we surface the
* error and let the operator decide.
*
* The invite-email step is best-effort — if SMTP is misconfigured the
* user is created and authorized but no email goes out. Owner can
* resend manually from ZITADEL Console.
*
* Note: ZITADEL rejects creating a user with an email that already
* exists in the same instance. The error is surfaced as-is from the
* `extractZitadelMessage`-aware caller.
*/
export async function inviteOrgMember(params: {
orgId: string;
email: string;
givenName: string;
familyName: string;
role: CustomerRole;
preferredLanguage?: string;
}): Promise<InviteResult> {
// Step 1: create the user
const user = await createHumanUser({
orgId: params.orgId,
email: params.email,
givenName: params.givenName,
familyName: params.familyName,
preferredLanguage: params.preferredLanguage,
});
// Step 2: send invite — best-effort
try {
await createInviteCode(user.id);
} catch (err) {
console.warn(
`Invite email could not be sent for user ${user.id} (SMTP may not be configured):`,
err
);
}
// Step 3: assign role
await createAuthorization({
userId: user.id,
organizationId: params.orgId,
roleKeys: [params.role],
});
return {
userId: user.id,
emailAlreadyExists: false,
};
}
/**
* Re-export for convenience.
*/
export type { OrgUser };

127
src/lib/visibility.ts Normal file
View File

@@ -0,0 +1,127 @@
/**
* Tenant visibility scoping for the customer-facing portal.
*
* Centralised here so every endpoint that lists or fetches tenants
* agrees on the same rules. A bug in any one of those — say, a stale
* inline filter that returned org-wide results to a `user`-role member
* — would leak siblings' workspace files and channel-user lists.
* One source of truth makes the audit easy.
*
* Visibility model
* ----------------
* platform_admin / platform_operator → all tenants in the cluster.
* owner (customer) → all tenants in their own org.
* user (customer, no owner role) → only tenants they've been
* assigned to via the
* tenant_user_assignments table.
*
* The narrowing for `user` is what turns the customer role into a
* meaningful access boundary. Without it, every member of an org
* would see every tenant — fine for a one-team SaaS, broken for a
* company with separate Production / Staging / Sales instances where
* the Sales team shouldn't see the Production workspace files.
*
* Owners do NOT get filtered against the assignment table even if
* they happen to have rows in it. The owner role beats user-level
* scoping — that's the point of being an owner.
*/
import type { SessionUser, PiecedTenant } from "@/types";
import { listTenantAssignmentsForUser } from "./db";
/** Internal classifier — "what's this caller's visibility scope?". */
type Scope = "all" | "org" | "assigned";
function scopeFor(user: SessionUser): Scope {
if (user.isPlatform) return "all";
if (user.roles.includes("owner")) return "org";
return "assigned";
}
/**
* Filter a list of tenants down to what `user` is allowed to see.
*
* Performs at most one DB query (only when scope is "assigned") and
* runs the K8s-side filter in memory. The K8s list is already small
* (≤100 tenants at pilot scale) so this is fine; if it grew we'd
* push the filter down to the K8s label selector instead.
*/
export async function listVisibleTenants(
user: SessionUser,
all: PiecedTenant[]
): Promise<PiecedTenant[]> {
const scope = scopeFor(user);
if (scope === "all") return all;
const orgScoped = all.filter(
(t) => t.metadata.labels?.["pieced.ch/zitadel-org-id"] === user.orgId
);
if (scope === "org") return orgScoped;
// scope === "assigned" — narrow to the user's assignment list
const assigned = await listTenantAssignmentsForUser(user.id);
if (assigned.length === 0) return [];
const allowed = new Set(assigned);
return orgScoped.filter((t) => allowed.has(t.metadata.name));
}
/**
* Single-tenant predicate. Returns true when `user` may see (and read
* from) `tenant`. Mutating endpoints additionally need
* `canMutate(user)` from `lib/session.ts` — visibility ≠ permission to
* change.
*
* Returns false (rather than throwing) so handlers can map to the
* status code that fits their semantics — usually 404 for read paths
* (don't leak existence) and 403 for mutation paths (caller already
* knew the tenant existed).
*/
export async function canUserSeeTenant(
user: SessionUser,
tenant: PiecedTenant
): Promise<boolean> {
const scope = scopeFor(user);
if (scope === "all") return true;
// org scope and assigned scope both require the tenant to belong
// to the user's org — different orgs are never visible.
if (tenant.metadata.labels?.["pieced.ch/zitadel-org-id"] !== user.orgId) {
return false;
}
if (scope === "org") return true;
// scope === "assigned"
const assigned = await listTenantAssignmentsForUser(user.id);
return assigned.includes(tenant.metadata.name);
}
/**
* "Should `user` see in-flight tenant requests on the dashboard?"
*
* Owners and platform users yes (they own the lifecycle); user-role
* members no (they can't act on requests, and a request that isn't
* yet a tenant has no assignment yet, so showing it would be a
* permanent "pending" with no action they can take).
*/
export function canSeeInflightRequests(user: SessionUser): boolean {
return scopeFor(user) !== "assigned";
}
/**
* Convenience predicate used by client-side empty states. For
* `user`-role members, the dashboard wants to distinguish between
* "your org has no instances" (very rare; ask owner to set one up)
* and "your org has instances but you're not assigned to any" (more
* common; ask owner to grant access).
*
* Callers compute this off the difference between visible and
* org-wide tenant lists; this helper just reifies the test.
*/
export function isUserScoped(user: SessionUser): boolean {
return scopeFor(user) === "assigned";
}

View File

@@ -156,6 +156,18 @@ export interface ProjectGrantResult {
/** /**
* Grant the "OpenClaw Platform" project to a customer organization. * Grant the "OpenClaw Platform" project to a customer organization.
*
* The grant's `roleKeys` whitelist what authorizations the customer org
* may self-manage: a grant containing only "owner" prevents the customer
* from inviting members in the `user` role, because ZITADEL rejects
* `CreateAuthorization` for any role outside the grant with
* `Errors.Project.Role.NotFound`.
*
* Default is therefore `["owner", "user"]` — the full set of customer
* roles defined in `types/index.ts::CustomerRole`. Platform roles are
* intentionally NOT granted; those are administered separately and
* should never be assignable from inside a customer org.
*
* Connect RPC: zitadel.project.v2.ProjectService/CreateProjectGrant * Connect RPC: zitadel.project.v2.ProjectService/CreateProjectGrant
*/ */
export async function createProjectGrant( export async function createProjectGrant(
@@ -168,11 +180,44 @@ export async function createProjectGrant(
{ {
projectId: ZITADEL_PROJECT_ID, projectId: ZITADEL_PROJECT_ID,
grantedOrganizationId: grantedOrgId, grantedOrganizationId: grantedOrgId,
roleKeys: roleKeys || ["owner"], roleKeys: roleKeys || ["owner", "user"],
} }
); );
} }
/**
* List the role keys defined on the OpenClaw Platform project.
*
* Used by the instrumentation self-check on startup to warn loudly if
* the canonical role keys (owner / user / platform_admin / platform_operator)
* are missing — a misconfiguration that silently breaks team management
* and customer registration. See `scripts/zitadel-roles.mjs` for repair.
*
* Returns [] on any error (network, auth, shape drift) so callers can
* decide what to do without inheriting a thrown exception during boot.
*
* Connect RPC: zitadel.project.v2.ProjectService/ListProjectRoles
*/
export async function listProjectRoles(): Promise<string[]> {
try {
const data = await connectRpc<{ projectRoles?: any[] }>(
"zitadel.project.v2.ProjectService",
"ListProjectRoles",
{ projectId: ZITADEL_PROJECT_ID }
);
if (!data?.projectRoles || !Array.isArray(data.projectRoles)) return [];
return data.projectRoles
.map((r: any) => (typeof r?.key === "string" ? r.key : ""))
.filter(Boolean);
} catch (err) {
console.warn(
`Failed to list project roles for ${ZITADEL_PROJECT_ID} (returning empty):`,
err
);
return [];
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// v2 Authorization API — Connect RPC // v2 Authorization API — Connect RPC
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -205,6 +250,35 @@ export async function createAuthorization(params: {
); );
} }
/**
* Replace the role keys on an existing authorization.
*
* Connect RPC: zitadel.authorization.v2.AuthorizationService/UpdateAuthorization
*
* Replace, not merge: any role keys previously held by this authorization
* that are NOT in the new list are revoked. Pass the complete desired
* role set every time. The authorization's user/org/project bindings
* are immutable — to move a user to a different org, delete and recreate.
*
* Used by the team UI's role change flow (Bug 25). For new role grants
* use {@link createAuthorization}; for revocations of an entire role
* assignment, delete the authorization (not yet exposed; not needed at
* the time of writing).
*/
export async function updateAuthorizationRoles(
authorizationId: string,
roleKeys: string[]
): Promise<{ changeDate?: string }> {
return connectRpc<{ changeDate?: string }>(
"zitadel.authorization.v2.AuthorizationService",
"UpdateAuthorization",
{
id: authorizationId,
roleKeys,
}
);
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Delete Organization (for rollback on partial failure) // Delete Organization (for rollback on partial failure)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -213,6 +287,158 @@ export async function deleteOrganization(orgId: string): Promise<void> {
await zitadelFetch(`/v2/organizations/${orgId}`, "DELETE"); await zitadelFetch(`/v2/organizations/${orgId}`, "DELETE");
} }
// ---------------------------------------------------------------------------
// Slice 7: search/list APIs for team management
// ---------------------------------------------------------------------------
//
// Two endpoints used by the Team UI:
// - listOrgUsers → POST /v2/users (search with organizationIdQuery)
// - listOrgAuthorizations → Connect RPC to AuthorizationService.ListAuthorizations
//
// Caveats
// -------
// ZITADEL's v2 API surface evolves; the request/response shapes below were
// written against the v2 schema as documented at the time of authoring
// (organizationIdQuery filter on UserService.SearchUsers; ListAuthorizations
// with a ListQuery + filter pair). If your installed ZITADEL version uses
// slightly different field names, parsing here is intentionally tolerant —
// the helpers return [] rather than throwing on shape drift, log a warning,
// and the caller's UI shows an empty team list (which is recoverable).
//
// If you find a discrepancy, fix the request shape here and re-deploy; the
// rest of the team UI doesn't care about the on-the-wire format.
export interface OrgUser {
userId: string;
email: string;
givenName: string;
familyName: string;
displayName: string;
}
/**
* List all users belonging to a given ZITADEL organization. Paginated;
* we cap at 200 per call which is generous for the pilot scale.
*/
export async function listOrgUsers(orgId: string): Promise<OrgUser[]> {
try {
const data = await zitadelFetch<{ result?: any[] }>(
"/v2/users",
"POST",
{
queries: [{ organizationIdQuery: { organizationId: orgId } }],
// Sort by username so the team list is deterministic across reloads
sortingColumn: "USER_FIELD_NAME_USERNAME",
query: { limit: 200, asc: true },
}
);
if (!data?.result || !Array.isArray(data.result)) return [];
return data.result.flatMap((row: any) => {
// ZITADEL distinguishes human and machine users; we only want humans.
const human = row?.human;
if (!human) return [];
const profile = human.profile ?? {};
const email = human.email?.email ?? "";
const userId = row.userId ?? row.id ?? "";
if (!userId) return [];
return [
{
userId,
email,
givenName: profile.givenName ?? "",
familyName: profile.familyName ?? "",
displayName:
profile.displayName ??
`${profile.givenName ?? ""} ${profile.familyName ?? ""}`.trim() ??
email,
} as OrgUser,
];
});
} catch (err) {
console.warn(
`Failed to list users for org ${orgId} (returning empty):`,
err
);
return [];
}
}
export interface OrgAuthorization {
authorizationId: string;
userId: string;
organizationId: string;
projectId: string;
roleKeys: string[];
}
/**
* List authorizations for the OpenClaw Platform project, filtered to a
* single organization. Used by the team UI to render each member's
* effective role.
*
* Connect RPC: zitadel.authorization.v2.AuthorizationService/ListAuthorizations
*
* Implementation note (filter shape & response parsing)
* -----------------------------------------------------
* The v2 AuthorizationService accepts a `filters` array of oneof variants
* (project_id, organization_id, role_key, …) but the JSON-over-Connect
* wrapper naming differs between ZITADEL versions and isn't well-documented
* for ID filters. Rather than chase a moving target, we fetch all
* authorizations the SA can see and narrow client-side by project+org.
* At pilot scale this is a single sub-100-row query — well within budget.
*
* Response shape (v2 stable, confirmed against ZITADEL v4.12):
* authorizations: [{
* id, state,
* project: { id, name, organizationId },
* organization: { id, name },
* user: { id, displayName, preferredLoginName, … },
* roles: [{ key, displayName, group }],
* }]
*
* Returns [] on any error so the team page can render a degraded view
* (members visible, roles blank) rather than blowing up entirely.
*/
export async function listOrgAuthorizations(
orgId: string
): Promise<OrgAuthorization[]> {
try {
const data = await connectRpc<{ authorizations?: any[] }>(
"zitadel.authorization.v2.AuthorizationService",
"ListAuthorizations",
{ pagination: { limit: 1000 } }
);
if (!data?.authorizations || !Array.isArray(data.authorizations)) {
return [];
}
return data.authorizations
.filter(
(row: any) =>
row?.project?.id === ZITADEL_PROJECT_ID &&
row?.organization?.id === orgId
)
.map((row: any) => ({
authorizationId: row.id ?? "",
userId: row.user?.id ?? "",
organizationId: row.organization?.id ?? orgId,
projectId: row.project?.id ?? ZITADEL_PROJECT_ID,
roleKeys: Array.isArray(row.roles)
? row.roles
.map((r: any) => (typeof r?.key === "string" ? r.key : ""))
.filter(Boolean)
: [],
}));
} catch (err) {
console.warn(
`Failed to list authorizations for org ${orgId} (returning empty):`,
err
);
return [];
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Full registration flow // Full registration flow
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -265,8 +491,12 @@ export async function registerCustomer(params: {
); );
} }
// 4. Grant project to org // 4. Grant project to org with both customer roles so the org's
const grant = await createProjectGrant(org.organizationId, ["owner"]); // owner can invite users in either `owner` or `user` role afterwards.
const grant = await createProjectGrant(org.organizationId, [
"owner",
"user",
]);
// 5. Assign "owner" role to user // 5. Assign "owner" role to user
await createAuthorization({ await createAuthorization({

View File

@@ -11,7 +11,8 @@
"cancel": "Abbrechen", "cancel": "Abbrechen",
"save": "Speichern", "save": "Speichern",
"error": "Ein Fehler ist aufgetreten", "error": "Ein Fehler ist aufgetreten",
"register": "Registrieren" "register": "Registrieren",
"team": "Team"
}, },
"login": { "login": {
"title": "PieCed Portal", "title": "PieCed Portal",
@@ -103,7 +104,12 @@
"instances": "Ihre Instanzen", "instances": "Ihre Instanzen",
"inflightRequests": "Laufende Anfragen", "inflightRequests": "Laufende Anfragen",
"createInstance": "Neue Instanz erstellen", "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.",
"noAssignmentsTitle": "Keine Instanzen zugewiesen",
"noAssignmentsDescription": "Ihre Organisation verfügt über Instanzen, aber Sie haben keinen Zugriff darauf erhalten. Bitten Sie den Eigentümer Ihrer Organisation, Sie einer Instanz zuzuweisen.",
"noInstancesYetTitle": "Noch keine Instanzen",
"noInstancesYetDescription": "Ihre Organisation verfügt noch über keine Instanzen. Bitten Sie den Eigentümer Ihrer Organisation, eine einzurichten."
}, },
"tenantDetail": { "tenantDetail": {
"agent": "Agent", "agent": "Agent",
@@ -111,7 +117,8 @@
"workspaceFiles": "Workspace-Dateien", "workspaceFiles": "Workspace-Dateien",
"notFound": "Tenant nicht gefunden.", "notFound": "Tenant nicht gefunden.",
"usage": "Nutzung & Kosten", "usage": "Nutzung & Kosten",
"provisioned": "Bereitgestellt" "provisioned": "Bereitgestellt",
"assignedUsers": "Zugewiesene Benutzer"
}, },
"usage": { "usage": {
"inputTokens": "Input-Tokens", "inputTokens": "Input-Tokens",
@@ -179,7 +186,9 @@
}, },
"documentProcessing": { "documentProcessing": {
"description": "Aktivieren Sie Dokumentenverarbeitung, Zusammenfassung und Extraktion." "description": "Aktivieren Sie Dokumentenverarbeitung, Zusammenfassung und Extraktion."
} },
"statusEnabled": "aktiviert",
"statusDisabled": "deaktiviert"
}, },
"admin": { "admin": {
"title": "Plattform-Admin", "title": "Plattform-Admin",
@@ -262,5 +271,38 @@
"telegramIdHelp": "So finden Sie Ihre Telegram-Benutzer-ID:\n1. Öffnen Sie Telegram und schreiben Sie @userinfobot\n2. Der Bot antwortet sofort mit Ihrer numerischen ID\n3. Geben Sie diese Nummer hier ein", "telegramIdHelp": "So finden Sie Ihre Telegram-Benutzer-ID:\n1. Öffnen Sie Telegram und schreiben Sie @userinfobot\n2. Der Bot antwortet sofort mit Ihrer numerischen ID\n3. Geben Sie diese Nummer hier ein",
"discordIdHelp": "So finden Sie Ihre Discord-Benutzer-ID:\n1. Aktivieren Sie den Entwicklermodus in den Discord-Einstellungen (Erweitert)\n2. Rechtsklick auf Ihren Namen → Benutzer-ID kopieren\n3. Geben Sie diese Nummer hier ein", "discordIdHelp": "So finden Sie Ihre Discord-Benutzer-ID:\n1. Aktivieren Sie den Entwicklermodus in den Discord-Einstellungen (Erweitert)\n2. Rechtsklick auf Ihren Namen → Benutzer-ID kopieren\n3. Geben Sie diese Nummer hier ein",
"emailIdHelp": "Geben Sie die E-Mail-Adresse ein, die zur Interaktion mit dem Assistenten autorisiert werden soll." "emailIdHelp": "Geben Sie die E-Mail-Adresse ein, die zur Interaktion mit dem Assistenten autorisiert werden soll."
},
"team": {
"title": "Team",
"description": "Verwalten Sie die Mitglieder Ihrer Organisation. Laden Sie Kollegen ein und weisen Sie sie Instanzen zu.",
"inviteSectionTitle": "Mitglied einladen",
"membersSectionTitle": "Mitglieder",
"noMembers": "Noch keine Mitglieder.",
"you": "Sie",
"noRole": "keine Rolle",
"givenName": "Vorname",
"familyName": "Nachname",
"email": "E-Mail",
"role": "Rolle",
"roleUser": "Benutzer (nur Lesezugriff, muss Instanzen zugewiesen werden)",
"roleOwner": "Eigentümer (Vollzugriff auf alle Instanzen)",
"roleHint": "Eigentümer können Instanzen, Abrechnung und Teammitglieder verwalten. Benutzer können nur die ihnen zugewiesenen Instanzen anzeigen.",
"inviteButton": "Einladung senden",
"inviteSent": "Einladung gesendet. Der Benutzer erhält eine E-Mail mit einem Link zum Festlegen des Passworts.",
"inviteUserExists": "Ein Benutzer mit dieser E-Mail-Adresse ist bereits registriert.",
"changeRole": "Rolle ändern",
"roleUpdated": "Rolle aktualisiert.",
"roleUpdateFailed": "Rolle konnte nicht aktualisiert werden.",
"cancel": "Abbrechen",
"save": "Speichern",
"selfChangeBlocked": "Sie können Ihre eigene Rolle nicht ändern."
},
"assignments": {
"loading": "Zuweisungen werden geladen…",
"noneAssigned": "Dieser Instanz sind noch keine Benutzer zugewiesen.",
"noCandidates": "Keine Teammitglieder verfügbar zum Zuweisen. Laden Sie zuerst Benutzer auf der Team-Seite ein.",
"pickUser": "Benutzer auswählen…",
"assign": "Zuweisen",
"revoke": "Entfernen"
} }
} }

View File

@@ -11,7 +11,8 @@
"cancel": "Cancel", "cancel": "Cancel",
"save": "Save", "save": "Save",
"error": "An error occurred", "error": "An error occurred",
"register": "Register" "register": "Register",
"team": "Team"
}, },
"login": { "login": {
"title": "PieCed Portal", "title": "PieCed Portal",
@@ -103,7 +104,12 @@
"instances": "Your instances", "instances": "Your instances",
"inflightRequests": "In-flight requests", "inflightRequests": "In-flight requests",
"createInstance": "Create new instance", "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.",
"noAssignmentsTitle": "No instances assigned",
"noAssignmentsDescription": "Your organization has instances, but you haven't been granted access to any of them. Please ask your organization owner to assign you to an instance.",
"noInstancesYetTitle": "No instances yet",
"noInstancesYetDescription": "Your organization doesn't have any instances yet. Please ask your organization owner to set one up."
}, },
"tenantDetail": { "tenantDetail": {
"agent": "Agent", "agent": "Agent",
@@ -111,7 +117,8 @@
"workspaceFiles": "Workspace Files", "workspaceFiles": "Workspace Files",
"notFound": "Tenant not found.", "notFound": "Tenant not found.",
"usage": "Usage & Spend", "usage": "Usage & Spend",
"provisioned": "Provisioned" "provisioned": "Provisioned",
"assignedUsers": "Assigned users"
}, },
"usage": { "usage": {
"inputTokens": "Input Tokens", "inputTokens": "Input Tokens",
@@ -179,7 +186,9 @@
}, },
"documentProcessing": { "documentProcessing": {
"description": "Enable document parsing, summarization, and extraction." "description": "Enable document parsing, summarization, and extraction."
} },
"statusEnabled": "enabled",
"statusDisabled": "disabled"
}, },
"admin": { "admin": {
"title": "Platform Admin", "title": "Platform Admin",
@@ -262,5 +271,38 @@
"telegramIdHelp": "To find your Telegram user ID:\n1. Open Telegram and message @userinfobot\n2. It instantly replies with your numeric ID\n3. Enter that number here", "telegramIdHelp": "To find your Telegram user ID:\n1. Open Telegram and message @userinfobot\n2. It instantly replies with your numeric ID\n3. Enter that number here",
"discordIdHelp": "To find your Discord user ID:\n1. Enable Developer Mode in Discord settings (Advanced)\n2. Right-click your name → Copy User ID\n3. Enter that number here", "discordIdHelp": "To find your Discord user ID:\n1. Enable Developer Mode in Discord settings (Advanced)\n2. Right-click your name → Copy User ID\n3. Enter that number here",
"emailIdHelp": "Enter the email address that should be authorized to interact with the assistant." "emailIdHelp": "Enter the email address that should be authorized to interact with the assistant."
},
"team": {
"title": "Team",
"description": "Manage members of your organization. Invite colleagues and assign them to instances.",
"inviteSectionTitle": "Invite a member",
"membersSectionTitle": "Members",
"noMembers": "No members yet.",
"you": "You",
"noRole": "no role",
"givenName": "First name",
"familyName": "Last name",
"email": "Email",
"role": "Role",
"roleUser": "User (read-only, must be assigned to instances)",
"roleOwner": "Owner (full access to all instances)",
"roleHint": "Owners can manage instances, billing, and team members. Users can only view instances they've been assigned to.",
"inviteButton": "Send invitation",
"inviteSent": "Invitation sent. The user will receive an email with a link to set their password.",
"inviteUserExists": "A user with this email is already registered.",
"changeRole": "Change role",
"roleUpdated": "Role updated.",
"roleUpdateFailed": "Could not update role.",
"cancel": "Cancel",
"save": "Save",
"selfChangeBlocked": "You cannot change your own role."
},
"assignments": {
"loading": "Loading assignments…",
"noneAssigned": "No users are assigned to this instance yet.",
"noCandidates": "No team members available to assign. Invite users from the Team page first.",
"pickUser": "Select a user…",
"assign": "Assign",
"revoke": "Remove"
} }
} }

View File

@@ -11,7 +11,8 @@
"cancel": "Annuler", "cancel": "Annuler",
"save": "Enregistrer", "save": "Enregistrer",
"error": "Une erreur est survenue", "error": "Une erreur est survenue",
"register": "S'inscrire" "register": "S'inscrire",
"team": "Équipe"
}, },
"login": { "login": {
"title": "Portail PieCed", "title": "Portail PieCed",
@@ -103,7 +104,12 @@
"instances": "Vos instances", "instances": "Vos instances",
"inflightRequests": "Demandes en cours", "inflightRequests": "Demandes en cours",
"createInstance": "Créer une nouvelle instance", "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.",
"noAssignmentsTitle": "Aucune instance attribuée",
"noAssignmentsDescription": "Votre organisation possède des instances, mais aucun accès ne vous a été accordé. Demandez au propriétaire de votre organisation de vous attribuer une instance.",
"noInstancesYetTitle": "Pas encore d'instances",
"noInstancesYetDescription": "Votre organisation ne possède pas encore d'instances. Demandez au propriétaire de votre organisation d'en configurer une."
}, },
"tenantDetail": { "tenantDetail": {
"agent": "Agent", "agent": "Agent",
@@ -111,7 +117,8 @@
"workspaceFiles": "Fichiers workspace", "workspaceFiles": "Fichiers workspace",
"notFound": "Locataire non trouvé.", "notFound": "Locataire non trouvé.",
"usage": "Utilisation et coûts", "usage": "Utilisation et coûts",
"provisioned": "Provisionné" "provisioned": "Provisionné",
"assignedUsers": "Utilisateurs attribués"
}, },
"usage": { "usage": {
"inputTokens": "Tokens d'entrée", "inputTokens": "Tokens d'entrée",
@@ -179,7 +186,9 @@
}, },
"documentProcessing": { "documentProcessing": {
"description": "Activez l'analyse, le résumé et l'extraction de documents." "description": "Activez l'analyse, le résumé et l'extraction de documents."
} },
"statusEnabled": "activé",
"statusDisabled": "désactivé"
}, },
"admin": { "admin": {
"title": "Admin plateforme", "title": "Admin plateforme",
@@ -262,5 +271,38 @@
"telegramIdHelp": "Pour trouver votre identifiant Telegram :\n1. Ouvrez Telegram et envoyez un message à @userinfobot\n2. Il répond instantanément avec votre identifiant numérique\n3. Entrez ce numéro ici", "telegramIdHelp": "Pour trouver votre identifiant Telegram :\n1. Ouvrez Telegram et envoyez un message à @userinfobot\n2. Il répond instantanément avec votre identifiant numérique\n3. Entrez ce numéro ici",
"discordIdHelp": "Pour trouver votre identifiant Discord :\n1. Activez le mode développeur dans les paramètres Discord (Avancé)\n2. Clic droit sur votre nom → Copier l'identifiant\n3. Entrez ce numéro ici", "discordIdHelp": "Pour trouver votre identifiant Discord :\n1. Activez le mode développeur dans les paramètres Discord (Avancé)\n2. Clic droit sur votre nom → Copier l'identifiant\n3. Entrez ce numéro ici",
"emailIdHelp": "Entrez l'adresse e-mail qui doit être autorisée à interagir avec l'assistant." "emailIdHelp": "Entrez l'adresse e-mail qui doit être autorisée à interagir avec l'assistant."
},
"team": {
"title": "Équipe",
"description": "Gérez les membres de votre organisation. Invitez des collègues et attribuez-leur des instances.",
"inviteSectionTitle": "Inviter un membre",
"membersSectionTitle": "Membres",
"noMembers": "Aucun membre pour l'instant.",
"you": "Vous",
"noRole": "aucun rôle",
"givenName": "Prénom",
"familyName": "Nom de famille",
"email": "E-mail",
"role": "Rôle",
"roleUser": "Utilisateur (lecture seule, doit être affecté à des instances)",
"roleOwner": "Propriétaire (accès complet à toutes les instances)",
"roleHint": "Les propriétaires peuvent gérer les instances, la facturation et les membres de l'équipe. Les utilisateurs ne peuvent voir que les instances qui leur sont attribuées.",
"inviteButton": "Envoyer l'invitation",
"inviteSent": "Invitation envoyée. L'utilisateur recevra un e-mail avec un lien pour définir son mot de passe.",
"inviteUserExists": "Un utilisateur avec cette adresse e-mail est déjà enregistré.",
"changeRole": "Modifier le rôle",
"roleUpdated": "Rôle mis à jour.",
"roleUpdateFailed": "Impossible de mettre à jour le rôle.",
"cancel": "Annuler",
"save": "Enregistrer",
"selfChangeBlocked": "Vous ne pouvez pas modifier votre propre rôle."
},
"assignments": {
"loading": "Chargement des attributions…",
"noneAssigned": "Aucun utilisateur n'est encore attribué à cette instance.",
"noCandidates": "Aucun membre de l'équipe disponible pour l'attribution. Invitez d'abord des utilisateurs depuis la page Équipe.",
"pickUser": "Sélectionner un utilisateur…",
"assign": "Attribuer",
"revoke": "Retirer"
} }
} }

View File

@@ -11,7 +11,8 @@
"cancel": "Annulla", "cancel": "Annulla",
"save": "Salva", "save": "Salva",
"error": "Si è verificato un errore", "error": "Si è verificato un errore",
"register": "Registrati" "register": "Registrati",
"team": "Team"
}, },
"login": { "login": {
"title": "Portale PieCed", "title": "Portale PieCed",
@@ -103,7 +104,12 @@
"instances": "Le tue istanze", "instances": "Le tue istanze",
"inflightRequests": "Richieste in corso", "inflightRequests": "Richieste in corso",
"createInstance": "Crea nuova istanza", "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.",
"noAssignmentsTitle": "Nessuna istanza assegnata",
"noAssignmentsDescription": "La tua organizzazione ha delle istanze, ma non ti è stato concesso l'accesso a nessuna di esse. Chiedi al proprietario della tua organizzazione di assegnarti a un'istanza.",
"noInstancesYetTitle": "Nessuna istanza ancora",
"noInstancesYetDescription": "La tua organizzazione non ha ancora istanze. Chiedi al proprietario della tua organizzazione di configurarne una."
}, },
"tenantDetail": { "tenantDetail": {
"agent": "Agente", "agent": "Agente",
@@ -111,7 +117,8 @@
"workspaceFiles": "File workspace", "workspaceFiles": "File workspace",
"notFound": "Tenant non trovato.", "notFound": "Tenant non trovato.",
"usage": "Utilizzo e costi", "usage": "Utilizzo e costi",
"provisioned": "Attivato" "provisioned": "Attivato",
"assignedUsers": "Utenti assegnati"
}, },
"usage": { "usage": {
"inputTokens": "Token di input", "inputTokens": "Token di input",
@@ -179,7 +186,9 @@
}, },
"documentProcessing": { "documentProcessing": {
"description": "Attiva l'analisi, il riassunto e l'estrazione di documenti." "description": "Attiva l'analisi, il riassunto e l'estrazione di documenti."
} },
"statusEnabled": "abilitato",
"statusDisabled": "disabilitato"
}, },
"admin": { "admin": {
"title": "Admin piattaforma", "title": "Admin piattaforma",
@@ -262,5 +271,38 @@
"telegramIdHelp": "Per trovare il tuo ID Telegram:\n1. Apri Telegram e invia un messaggio a @userinfobot\n2. Risponde istantaneamente con il tuo ID numerico\n3. Inserisci quel numero qui", "telegramIdHelp": "Per trovare il tuo ID Telegram:\n1. Apri Telegram e invia un messaggio a @userinfobot\n2. Risponde istantaneamente con il tuo ID numerico\n3. Inserisci quel numero qui",
"discordIdHelp": "Per trovare il tuo ID Discord:\n1. Attiva la Modalità sviluppatore nelle impostazioni Discord (Avanzate)\n2. Clic destro sul tuo nome → Copia ID utente\n3. Inserisci quel numero qui", "discordIdHelp": "Per trovare il tuo ID Discord:\n1. Attiva la Modalità sviluppatore nelle impostazioni Discord (Avanzate)\n2. Clic destro sul tuo nome → Copia ID utente\n3. Inserisci quel numero qui",
"emailIdHelp": "Inserisci l'indirizzo e-mail che deve essere autorizzato a interagire con l'assistente." "emailIdHelp": "Inserisci l'indirizzo e-mail che deve essere autorizzato a interagire con l'assistente."
},
"team": {
"title": "Team",
"description": "Gestisci i membri della tua organizzazione. Invita colleghi e assegnali alle istanze.",
"inviteSectionTitle": "Invita un membro",
"membersSectionTitle": "Membri",
"noMembers": "Nessun membro ancora.",
"you": "Tu",
"noRole": "nessun ruolo",
"givenName": "Nome",
"familyName": "Cognome",
"email": "E-mail",
"role": "Ruolo",
"roleUser": "Utente (sola lettura, deve essere assegnato a istanze)",
"roleOwner": "Proprietario (accesso completo a tutte le istanze)",
"roleHint": "I proprietari possono gestire istanze, fatturazione e membri del team. Gli utenti possono solo visualizzare le istanze a loro assegnate.",
"inviteButton": "Invia invito",
"inviteSent": "Invito inviato. L'utente riceverà un'e-mail con un link per impostare la password.",
"inviteUserExists": "Un utente con questa e-mail è già registrato.",
"changeRole": "Modifica ruolo",
"roleUpdated": "Ruolo aggiornato.",
"roleUpdateFailed": "Impossibile aggiornare il ruolo.",
"cancel": "Annulla",
"save": "Salva",
"selfChangeBlocked": "Non puoi modificare il tuo ruolo."
},
"assignments": {
"loading": "Caricamento assegnazioni…",
"noneAssigned": "Nessun utente è ancora assegnato a questa istanza.",
"noCandidates": "Nessun membro del team disponibile per l'assegnazione. Invita prima gli utenti dalla pagina Team.",
"pickUser": "Seleziona un utente…",
"assign": "Assegna",
"revoke": "Rimuovi"
} }
} }

View File

@@ -5,12 +5,39 @@ export interface ZitadelClaims {
"urn:zitadel:iam:org:project:roles"?: Record<string, Record<string, string>>; "urn:zitadel:iam:org:project:roles"?: Record<string, Record<string, string>>;
} }
export type PlatformRole = /**
| "platform_admin" * Platform-level roles, granted to PieCed staff only. Hold the IAM-level
| "platform_operator" * authority to administer the entire installation regardless of which
| "owner" * customer org a request lands on.
| "user" */
| "viewer"; 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 { export interface SessionUser {
id: string; id: string;
@@ -18,7 +45,7 @@ export interface SessionUser {
email: string; email: string;
orgId: string; orgId: string;
orgName: string; orgName: string;
roles: PlatformRole[]; roles: Role[];
isPlatform: boolean; isPlatform: boolean;
} }