From c46f27edef59c9dccdafa9e2c4f0dfea44646220 Mon Sep 17 00:00:00 2001 From: admin Date: Wed, 29 Apr 2026 12:16:00 +0200 Subject: [PATCH] Fix bugs --- scripts/zitadel-roles.mjs | 506 ++++++++++++++++++++++++ src/app/[locale]/dashboard/new/page.tsx | 9 +- src/app/[locale]/team/page.tsx | 17 +- src/app/api/team/[userId]/role/route.ts | 148 +++++++ src/components/team/team-list.tsx | 213 ++++++++-- src/components/ui/back-link.tsx | 43 ++ src/instrumentation.ts | 54 +++ src/lib/team.ts | 27 +- src/lib/zitadel.ts | 29 ++ src/messages/de.json | 8 +- src/messages/en.json | 8 +- src/messages/fr.json | 8 +- src/messages/it.json | 8 +- 13 files changed, 1017 insertions(+), 61 deletions(-) create mode 100644 scripts/zitadel-roles.mjs create mode 100644 src/app/api/team/[userId]/role/route.ts create mode 100644 src/components/ui/back-link.tsx create mode 100644 src/instrumentation.ts diff --git a/scripts/zitadel-roles.mjs b/scripts/zitadel-roles.mjs new file mode 100644 index 0000000..760990a --- /dev/null +++ b/scripts/zitadel-roles.mjs @@ -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 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 "); + 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 |migrate-grants>" + ); + process.exit(2); +} + +fn().catch((err) => { + console.error(err.message ?? err); + if (err.body) console.error("body:", err.body); + process.exit(1); +}); diff --git a/src/app/[locale]/dashboard/new/page.tsx b/src/app/[locale]/dashboard/new/page.tsx index 97d8d0b..1f1fd46 100644 --- a/src/app/[locale]/dashboard/new/page.tsx +++ b/src/app/[locale]/dashboard/new/page.tsx @@ -2,7 +2,7 @@ import { getSessionUser, canMutate } from "@/lib/session"; import { getTranslations } from "next-intl/server"; import { redirect } from "next/navigation"; 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 @@ -33,12 +33,7 @@ export default async function NewInstancePage() { return (
- - {t("title")} - +

{t("createInstance")}

diff --git a/src/app/[locale]/team/page.tsx b/src/app/[locale]/team/page.tsx index 6d26020..c8d4b41 100644 --- a/src/app/[locale]/team/page.tsx +++ b/src/app/[locale]/team/page.tsx @@ -1,11 +1,11 @@ -import { getSessionUser, canMutate } from "@/lib/session"; +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"; -import Link from "next/link"; /** * /team — manage org members. @@ -30,12 +30,7 @@ export default async function TeamPage() { return (
- - {tDashboard("title")} - +

{t("title")}

@@ -58,7 +53,11 @@ export default async function TeamPage() { ({members.length}) - +
); diff --git a/src/app/api/team/[userId]/role/route.ts b/src/app/api/team/[userId]/role/route.ts new file mode 100644 index 0000000..2372ac0 --- /dev/null +++ b/src/app/api/team/[userId]/role/route.ts @@ -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 } + ); + } +} diff --git a/src/components/team/team-list.tsx b/src/components/team/team-list.tsx index 227c7c4..9097208 100644 --- a/src/components/team/team-list.tsx +++ b/src/components/team/team-list.tsx @@ -10,23 +10,56 @@ interface OrgMember { 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 }: Props) { +export function TeamList({ + initialMembers, + currentUserId, + canEditRoles, +}: Props) { const t = useTranslations("team"); const [members, setMembers] = useState(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(null); + const [pendingRole, setPendingRole] = useState("user"); + const [submitting, setSubmitting] = useState(false); + const [rowError, setRowError] = useState>({}); + useEffect(() => { function refresh() { fetch("/api/team") @@ -40,6 +73,50 @@ export function TeamList({ initialMembers, currentUserId }: Props) { 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 (
@@ -51,47 +128,107 @@ export function TeamList({ initialMembers, currentUserId }: Props) { return (
    - {members.map((m) => ( -
  • -
    -
    - - {m.displayName || m.email} - - {m.userId === currentUserId && ( - - {t("you")} + {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 ( +
  • +
    +
    + + {m.displayName || m.email} + {isSelf && ( + + {t("you")} + + )} +
    +
    + {m.email} +
    + {err && ( +
    {err}
    )}
    -
    - {m.email} + +
    + {isEditing ? ( + <> + + + + + ) : ( + <> +
    + {m.roles.length === 0 && ( + + {t("noRole")} + + )} + {m.roles.map((r) => ( + + {r} + + ))} +
    + {showEditor && ( + + )} + + )}
    -
    -
    - {m.roles.length === 0 && ( - - {t("noRole")} - - )} - {m.roles.map((r) => ( - - {r} - - ))} -
    -
  • - ))} + + ); + })}
); diff --git a/src/components/ui/back-link.tsx b/src/components/ui/back-link.tsx new file mode 100644 index 0000000..bc8216f --- /dev/null +++ b/src/components/ui/back-link.tsx @@ -0,0 +1,43 @@ +import Link from "next/link"; + +/** + * BackLink — small "← Page" navigation cue that sits above a page's + * `

` heading. + * + * Why this exists + * --------------- + * The pattern was originally written inline on /team and /dashboard/new + * as ` Title`. + * 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 ( + + + {label} + + ); +} diff --git a/src/instrumentation.ts b/src/instrumentation.ts new file mode 100644 index 0000000..743d659 --- /dev/null +++ b/src/instrumentation.ts @@ -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); + } +} diff --git a/src/lib/team.ts b/src/lib/team.ts index 404e8a9..00afe14 100644 --- a/src/lib/team.ts +++ b/src/lib/team.ts @@ -45,6 +45,18 @@ export interface OrgMember { * 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; } /** @@ -61,14 +73,22 @@ export async function getOrgMembers(orgId: string): Promise { listOrgAuthorizations(orgId), ]); - // Group authorizations by userId — one user could in principle hold - // multiple authorization rows (one per role assigned at different - // times). Flatten roleKeys. + // 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>(); + const authIdByUser = new Map(); for (const a of auths) { const set = rolesByUser.get(a.userId) ?? new Set(); 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) => ({ @@ -78,6 +98,7 @@ export async function getOrgMembers(orgId: string): Promise { givenName: u.givenName, familyName: u.familyName, roles: Array.from(rolesByUser.get(u.userId) ?? []), + authorizationId: authIdByUser.get(u.userId) ?? "", })); } diff --git a/src/lib/zitadel.ts b/src/lib/zitadel.ts index 35668ec..32886e2 100644 --- a/src/lib/zitadel.ts +++ b/src/lib/zitadel.ts @@ -250,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) // --------------------------------------------------------------------------- diff --git a/src/messages/de.json b/src/messages/de.json index 112801a..f7caed4 100644 --- a/src/messages/de.json +++ b/src/messages/de.json @@ -289,7 +289,13 @@ "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." + "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…", diff --git a/src/messages/en.json b/src/messages/en.json index eacbbfb..25fc5d7 100644 --- a/src/messages/en.json +++ b/src/messages/en.json @@ -289,7 +289,13 @@ "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." + "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…", diff --git a/src/messages/fr.json b/src/messages/fr.json index 1f8b97d..8c9a4d1 100644 --- a/src/messages/fr.json +++ b/src/messages/fr.json @@ -289,7 +289,13 @@ "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é." + "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…", diff --git a/src/messages/it.json b/src/messages/it.json index 21a8bf5..1e7cf85 100644 --- a/src/messages/it.json +++ b/src/messages/it.json @@ -289,7 +289,13 @@ "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." + "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…",