This commit is contained in:
506
scripts/zitadel-roles.mjs
Normal file
506
scripts/zitadel-roles.mjs
Normal 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);
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
148
src/app/api/team/[userId]/role/route.ts
Normal file
148
src/app/api/team/[userId]/role/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,7 +128,16 @@ 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) => (
|
||||
{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"
|
||||
@@ -61,7 +147,7 @@ export function TeamList({ initialMembers, currentUserId }: Props) {
|
||||
<span className="text-sm font-medium text-text-primary truncate">
|
||||
{m.displayName || m.email}
|
||||
</span>
|
||||
{m.userId === currentUserId && (
|
||||
{isSelf && (
|
||||
<span className="text-[10px] uppercase tracking-wider text-accent">
|
||||
{t("you")}
|
||||
</span>
|
||||
@@ -70,7 +156,44 @@ export function TeamList({ initialMembers, currentUserId }: Props) {
|
||||
<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">
|
||||
@@ -90,8 +213,22 @@ export function TeamList({ initialMembers, currentUserId }: Props) {
|
||||
</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>
|
||||
);
|
||||
|
||||
43
src/components/ui/back-link.tsx
Normal file
43
src/components/ui/back-link.tsx
Normal 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
54
src/instrumentation.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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) ?? "",
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -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…",
|
||||
|
||||
Reference in New Issue
Block a user