This commit is contained in:
@@ -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,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>
|
||||
);
|
||||
|
||||
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