Fix bugs
All checks were successful
Build and Push / build (push) Successful in 1m30s

This commit is contained in:
2026-04-29 12:16:00 +02:00
parent 542a607b53
commit c46f27edef
13 changed files with 1017 additions and 61 deletions

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

@@ -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 (
<div>
<div className="mb-8 animate-in">
<Link
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>
<BackLink href="/dashboard" label={t("title")} />
<h1 className="font-display text-2xl font-semibold accent-rule mb-2">
{t("createInstance")}
</h1>

View File

@@ -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 (
<div>
<div className="mb-8 animate-in">
<Link
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> {tDashboard("title")}
</Link>
<BackLink href="/dashboard" label={tDashboard("title")} />
<h1 className="font-display text-2xl font-semibold accent-rule mb-2">
{t("title")}
</h1>
@@ -58,7 +53,11 @@ export default async function TeamPage() {
({members.length})
</span>
</h2>
<TeamList initialMembers={members} currentUserId={user.id} />
<TeamList
initialMembers={members}
currentUserId={user.id}
canEditRoles={isCustomerOwner(user)}
/>
</section>
</div>
);

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

@@ -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<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")
@@ -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 (
<div className="text-sm text-text-secondary text-center py-6 border border-border rounded-xl bg-surface-1">
@@ -51,47 +128,107 @@ export function TeamList({ initialMembers, currentUserId }: Props) {
return (
<div className="bg-surface-1 border border-border rounded-xl overflow-hidden">
<ul className="divide-y divide-border">
{members.map((m) => (
<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>
{m.userId === currentUserId && (
<span className="text-[10px] uppercase tracking-wider text-accent">
{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 (
<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="text-xs text-text-muted truncate font-mono">
{m.email}
<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>
</div>
<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>
</li>
))}
</li>
);
})}
</ul>
</div>
);

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

@@ -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<OrgMember[]> {
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<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) => ({
@@ -78,6 +98,7 @@ export async function getOrgMembers(orgId: string): Promise<OrgMember[]> {
givenName: u.givenName,
familyName: u.familyName,
roles: Array.from(rolesByUser.get(u.userId) ?? []),
authorizationId: authIdByUser.get(u.userId) ?? "",
}));
}

View File

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

View File

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

View File

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

View File

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

View File

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