diff --git a/scripts/verify-team.mjs b/scripts/verify-team.mjs new file mode 100644 index 0000000..7086469 --- /dev/null +++ b/scripts/verify-team.mjs @@ -0,0 +1,98 @@ +// Standalone JS port of `lib/team.ts::isValidInviteRole` and the +// org-membership decision used by POST /api/tenants/[name]/assignments. + +function isValidInviteRole(role) { + return role === "owner" || role === "user"; +} + +// Mirrors the assignment-time check: target user must exist in the +// org's member list. Returns true if assign should proceed. +function canAssign(targetUserId, orgMembers) { + return orgMembers.some((m) => m.userId === targetUserId); +} + +// Mirrors the dropdown candidate-filter on the AssignedUsersPanel: +// only `user`-role members who aren't already assigned, excluding +// owners (who have implicit access). +function pickCandidates(orgMembers, alreadyAssigned) { + const assigned = new Set(alreadyAssigned); + return orgMembers.filter( + (m) => + !assigned.has(m.userId) && + m.roles.includes("user") && + !m.roles.includes("owner") + ); +} + +// --------------------------------------------------------------------------- +// Test fixtures +// --------------------------------------------------------------------------- + +const orgMembers = [ + { userId: "u-1", roles: ["owner"] }, + { userId: "u-2", roles: ["user"] }, + { userId: "u-3", roles: ["user"] }, + { userId: "u-4", roles: [] }, // member with no role yet + { userId: "u-5", roles: ["owner", "user"] }, // dual-role +]; + +let pass = 0, fail = 0; + +console.log("--- isValidInviteRole ---"); +const inviteCases = [ + ["owner", true, "owner is valid"], + ["user", true, "user is valid"], + ["viewer", false, "viewer rejected (dropped in Slice 5)"], + ["platform_admin", false, "platform_admin not invitable"], + ["platform_operator", false, "platform_operator not invitable"], + ["", false, "empty rejected"], + ["OWNER", false, "case-sensitive"], +]; +for (const [role, expected, note] of inviteCases) { + const got = isValidInviteRole(role); + const ok = got === expected; + console.log(`${ok ? "PASS" : "FAIL"} got=${got} want=${expected} [${note}]`); + if (ok) pass++; else fail++; +} + +console.log("\n--- canAssign (membership check) ---"); +const assignCases = [ + ["u-1", true, "owner can be assigned (idempotent for owners)"], + ["u-2", true, "user-role member can be assigned"], + ["u-99", false, "non-member rejected"], + ["", false, "empty userId rejected"], +]; +for (const [targetId, expected, note] of assignCases) { + const got = canAssign(targetId, orgMembers); + const ok = got === expected; + console.log(`${ok ? "PASS" : "FAIL"} got=${got} want=${expected} [${note}]`); + if (ok) pass++; else fail++; +} + +console.log("\n--- pickCandidates (assign dropdown) ---"); +const candidateCases = [ + { + assigned: [], + expected: ["u-2", "u-3"], + note: "user-role members minus owners (u-5 is owner+user, excluded)", + }, + { + assigned: ["u-2"], + expected: ["u-3"], + note: "u-2 already assigned, only u-3 remains", + }, + { + assigned: ["u-2", "u-3"], + expected: [], + note: "everyone assigned", + }, +]; +for (const c of candidateCases) { + const got = pickCandidates(orgMembers, c.assigned).map((m) => m.userId); + const ok = JSON.stringify(got) === JSON.stringify(c.expected); + console.log(`${ok ? "PASS" : "FAIL"} got=${JSON.stringify(got)} want=${JSON.stringify(c.expected)} [${c.note}]`); + if (ok) pass++; else fail++; +} + +console.log(`\n${pass} pass, ${fail} fail`); +process.exit(fail === 0 ? 0 : 1); diff --git a/src/app/[locale]/team/page.tsx b/src/app/[locale]/team/page.tsx new file mode 100644 index 0000000..6d26020 --- /dev/null +++ b/src/app/[locale]/team/page.tsx @@ -0,0 +1,65 @@ +import { getSessionUser, canMutate } 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 { TeamList } from "@/components/team/team-list"; +import { InviteForm } from "@/components/team/invite-form"; +import Link from "next/link"; + +/** + * /team — manage org members. + * + * Visible to owners and platform users only (`canMutate`). User-role + * members are redirected away — they shouldn't browse the roster. + * + * The page loads members server-side for the initial render. The + * `` and `` client components handle live + * updates after invites and refreshes. + */ +export default async function TeamPage() { + const user = await getSessionUser(); + if (!user) redirect("/login"); + if (!canMutate(user)) redirect("/dashboard"); + + const t = await getTranslations("team"); + const tDashboard = await getTranslations("dashboard"); + + const members = await getOrgMembers(user.orgId); + + return ( +
+
+ + {tDashboard("title")} + +

+ {t("title")} +

+

{t("description")}

+
+ +
+

+ {t("inviteSectionTitle")} +

+ + + +
+ +
+

+ {t("membersSectionTitle")}{" "} + + ({members.length}) + +

+ +
+
+ ); +} diff --git a/src/app/[locale]/tenants/[name]/page.tsx b/src/app/[locale]/tenants/[name]/page.tsx index 4c9f765..3ed7415 100644 --- a/src/app/[locale]/tenants/[name]/page.tsx +++ b/src/app/[locale]/tenants/[name]/page.tsx @@ -8,6 +8,7 @@ import { UsageDisplay } from "@/components/dashboard/usage-display"; import { PackageList } from "@/components/packages/package-list"; import { WorkspaceEditor } from "@/components/packages/workspace-editor"; import { ChannelUsers } from "@/components/channel-users/channel-users"; +import { AssignedUsersPanel } from "@/components/tenants/assigned-users-panel"; import { formatDateTime, formatRelative } from "@/lib/format"; const CHANNEL_PACKAGES = ["telegram", "discord", "email"]; @@ -128,6 +129,16 @@ export default async function TenantDetailPage({ + + {/* Slice 7: Assigned users — visible to anyone who can see the + tenant, editable only by owners/platform users. The component + fetches its own data so the page doesn't need to await. */} +
+

+ {t("assignedUsers")} +

+ +
); } diff --git a/src/app/api/team/invite/route.ts b/src/app/api/team/invite/route.ts new file mode 100644 index 0000000..8e7504e --- /dev/null +++ b/src/app/api/team/invite/route.ts @@ -0,0 +1,95 @@ +import { NextResponse } from "next/server"; +import { getSessionUser, canMutate } from "@/lib/session"; +import { inviteOrgMember, isValidInviteRole } from "@/lib/team"; +import { z } from "zod"; +import { safeError } from "@/lib/errors"; + +const inviteSchema = z.object({ + email: z.string().email(), + givenName: z.string().min(1).max(100), + familyName: z.string().min(1).max(100), + role: z.enum(["owner", "user"]), + preferredLanguage: z.enum(["en", "de", "fr", "it"]).optional(), +}); + +/** + * POST /api/team/invite + * + * Invite a new member into the caller's org. Body shape: + * { email, givenName, familyName, role: "owner" | "user" } + * + * Allowed roles are explicitly only the customer-side ones — + * `isValidInviteRole` enforces this server-side too as a belt + * alongside the Zod enum (the Zod enum is the primary check; the + * helper exists because future callers in admin tooling may want the + * same predicate). + * + * Platform users can also call this — they'd be inviting members + * into their own platform org, which is uncommon but legal. + */ +export async function POST(req: Request) { + const user = await getSessionUser(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + if (!canMutate(user)) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + const body = await req.json().catch(() => null); + const parsed = inviteSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid input", details: parsed.error.flatten() }, + { status: 400 } + ); + } + const input = parsed.data; + + // Defensive recheck — the Zod enum already guarantees this, but it + // makes the intent explicit at the call site. + if (!isValidInviteRole(input.role)) { + return NextResponse.json( + { error: "Role must be 'owner' or 'user'." }, + { status: 400 } + ); + } + + try { + const result = await inviteOrgMember({ + orgId: user.orgId, + email: input.email, + givenName: input.givenName, + familyName: input.familyName, + role: input.role, + preferredLanguage: input.preferredLanguage, + }); + return NextResponse.json( + { + userId: result.userId, + message: + "Invitation sent. The user will receive an email with a link to set their password.", + }, + { status: 201 } + ); + } catch (e: any) { + console.error("Invite failed:", e); + // ZITADEL "user already exists" surfaces as a 4xx error; pass it + // through with a clean message so the client can render localized + // text. + const msg = e?.message ?? ""; + if (msg.includes("already exists") || msg.includes("9.User.AlreadyExisting")) { + return NextResponse.json( + { + error: "A user with this email already exists.", + code: "user_already_exists", + }, + { status: 409 } + ); + } + return NextResponse.json( + { error: safeError(e, "Failed to invite user") }, + { status: e.statusCode || 500 } + ); + } +} diff --git a/src/app/api/team/route.ts b/src/app/api/team/route.ts new file mode 100644 index 0000000..da2d643 --- /dev/null +++ b/src/app/api/team/route.ts @@ -0,0 +1,38 @@ +import { NextResponse } from "next/server"; +import { getSessionUser, canMutate } from "@/lib/session"; +import { getOrgMembers } from "@/lib/team"; +import { safeError } from "@/lib/errors"; + +/** + * GET /api/team + * + * Returns the joined members-with-roles view for the caller's org. + * Gated on `canMutate` — only owners and platform users can see the + * full member list. A `user`-role member shouldn't be browsing the + * roster. + * + * Platform admins viewing this endpoint see members of their OWN + * platform org. To inspect customer org membership cross-cut, use + * ZITADEL Console — that's the deliberate boundary between portal + * (customer self-service) and console (full IAM). + */ +export async function GET() { + const user = await getSessionUser(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + if (!canMutate(user)) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + try { + const members = await getOrgMembers(user.orgId); + return NextResponse.json({ members }); + } catch (e: any) { + console.error("Failed to list team members:", e); + return NextResponse.json( + { error: safeError(e, "Failed to list team members") }, + { status: 500 } + ); + } +} diff --git a/src/app/api/tenants/[name]/assignments/[userId]/route.ts b/src/app/api/tenants/[name]/assignments/[userId]/route.ts new file mode 100644 index 0000000..f523f63 --- /dev/null +++ b/src/app/api/tenants/[name]/assignments/[userId]/route.ts @@ -0,0 +1,57 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getSessionUser, canMutate } from "@/lib/session"; +import { getTenant } from "@/lib/k8s"; +import { removeTenantAssignment } from "@/lib/db"; +import { safeError } from "@/lib/errors"; + +/** + * DELETE /api/tenants/[name]/assignments/[userId] + * + * Revoke a user's assignment to a tenant. Owner+platform only. + * + * No-op if the assignment didn't exist (delete is idempotent at the + * DB layer). We don't surface "not found" because that would let a + * caller probe for assignment existence — the boolean response is + * just "you're authorized to do this". + * + * Note on self-revocation: an owner can revoke their own row even + * though it has no practical effect (owners see all tenants). A + * `user`-role member cannot revoke their own assignment because + * they're already gated out by canMutate. + */ +export async function DELETE( + _req: NextRequest, + { params }: { params: Promise<{ name: string; userId: string }> } +) { + const user = await getSessionUser(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + if (!canMutate(user)) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + const { name, userId } = await params; + + const tenant = await getTenant(name); + if (!tenant) { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } + // Same cross-org boundary as assign: customer owners can only manage + // their own org's tenants; platform users can manage anywhere. + const tenantOrgId = tenant.metadata.labels?.["pieced.ch/zitadel-org-id"]; + if (!user.isPlatform && tenantOrgId !== user.orgId) { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } + + try { + await removeTenantAssignment(name, userId); + return NextResponse.json({ message: "Assignment revoked." }); + } catch (e: any) { + console.error("Failed to remove tenant assignment:", e); + return NextResponse.json( + { error: safeError(e, "Failed to revoke assignment") }, + { status: 500 } + ); + } +} diff --git a/src/app/api/tenants/[name]/assignments/route.ts b/src/app/api/tenants/[name]/assignments/route.ts new file mode 100644 index 0000000..e6de5a4 --- /dev/null +++ b/src/app/api/tenants/[name]/assignments/route.ts @@ -0,0 +1,176 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getSessionUser, canMutate } from "@/lib/session"; +import { canUserSeeTenant } from "@/lib/visibility"; +import { getTenant } from "@/lib/k8s"; +import { + listAssignmentsForTenant, + addTenantAssignment, +} from "@/lib/db"; +import { getOrgMembers } from "@/lib/team"; +import { safeError } from "@/lib/errors"; +import { z } from "zod"; + +const assignSchema = z.object({ + userId: z.string().min(1).max(200), +}); + +/** + * GET /api/tenants/[name]/assignments + * + * Returns the list of users assigned to a tenant, joined with their + * ZITADEL profile (display name, email, role) so the UI can render + * a useful list without an extra round-trip. + * + * Visibility: any caller who can see the tenant can see its + * assignments. This includes user-role members who are themselves + * assigned — they see their fellow assignees, which is intentional + * (so they know who else has access). + */ +export async function GET( + _req: NextRequest, + { params }: { params: Promise<{ name: string }> } +) { + const user = await getSessionUser(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { name } = await params; + + const tenant = await getTenant(name); + if (!tenant) { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } + if (!(await canUserSeeTenant(user, tenant))) { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } + + try { + const orgId = tenant.metadata.labels?.["pieced.ch/zitadel-org-id"]; + const [rows, members] = await Promise.all([ + listAssignmentsForTenant(name), + orgId ? getOrgMembers(orgId) : Promise.resolve([]), + ]); + + const memberById = new Map(members.map((m) => [m.userId, m])); + + // Enrich assignments with member metadata. If the member can't be + // found in ZITADEL (stale row, e.g. user was removed from the org + // outside the portal), surface the orphan with a placeholder name + // so admins can clean it up. + const assignments = rows.map((r) => { + const m = memberById.get(r.zitadelUserId); + return { + userId: r.zitadelUserId, + displayName: m?.displayName ?? "(removed user)", + email: m?.email ?? "", + roles: m?.roles ?? [], + assignedAt: r.assignedAt, + assignedBy: r.assignedBy, + orphan: !m, + }; + }); + + return NextResponse.json({ assignments }); + } catch (e: any) { + console.error("Failed to list tenant assignments:", e); + return NextResponse.json( + { error: safeError(e, "Failed to list assignments") }, + { status: 500 } + ); + } +} + +/** + * POST /api/tenants/[name]/assignments + * + * Body: { userId } + * + * Assign a user to a tenant. Owner+platform only. The target user must + * already be a member of the tenant's org (we verify via the team list) + * — to add a brand-new user, the owner first invites them via + * POST /api/team/invite, then assigns them here. + * + * Idempotent: re-assigning is a no-op (DB INSERT ... ON CONFLICT DO + * NOTHING). The original `assignedAt`/`assignedBy` are preserved. + * + * Owners technically don't need to be assigned (they see all of their + * org's tenants anyway) but we don't reject the operation — just lets + * future bookkeeping work consistently. + */ +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ name: string }> } +) { + const user = await getSessionUser(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + if (!canMutate(user)) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + const { name } = await params; + + const tenant = await getTenant(name); + if (!tenant) { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } + // Customer owners can only assign within their own org. Platform + // users can assign anywhere (rare, but consistent with admin scope). + const tenantOrgId = tenant.metadata.labels?.["pieced.ch/zitadel-org-id"]; + if (!user.isPlatform && tenantOrgId !== user.orgId) { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } + if (!tenantOrgId) { + return NextResponse.json( + { error: "Tenant is missing the org-id label; cannot assign." }, + { status: 500 } + ); + } + + const body = await req.json().catch(() => null); + const parsed = assignSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid input", details: parsed.error.flatten() }, + { status: 400 } + ); + } + + // Verify the target user is actually a member of the tenant's org. + // This is the audit boundary — without it, an owner could grant + // access to arbitrary user IDs they made up. + try { + const members = await getOrgMembers(tenantOrgId); + const target = members.find((m) => m.userId === parsed.data.userId); + if (!target) { + return NextResponse.json( + { + error: + "Target user is not a member of this organization. Invite them first.", + code: "user_not_in_org", + }, + { status: 400 } + ); + } + + await addTenantAssignment({ + tenantName: name, + orgId: tenantOrgId, + userId: parsed.data.userId, + assignedBy: user.id, + }); + + return NextResponse.json( + { message: "User assigned.", userId: parsed.data.userId }, + { status: 201 } + ); + } catch (e: any) { + console.error("Failed to add tenant assignment:", e); + return NextResponse.json( + { error: safeError(e, "Failed to assign user") }, + { status: 500 } + ); + } +} diff --git a/src/components/layout/nav-shell.tsx b/src/components/layout/nav-shell.tsx index f499a1e..79508a8 100644 --- a/src/components/layout/nav-shell.tsx +++ b/src/components/layout/nav-shell.tsx @@ -40,6 +40,17 @@ function NavBar() { {t("dashboard")} + {/* Slice 7: /team is owner+platform only. Match server-side + gate (canMutate). The roles array carries either "owner" + or "user" for customer sessions; isPlatform covers the + platform side. */} + {user && + (user.isPlatform || + (Array.isArray(user.roles) && user.roles.includes("owner"))) && ( + + {t("team")} + + )} {user?.isPlatform && ( {t("admin")} diff --git a/src/components/team/invite-form.tsx b/src/components/team/invite-form.tsx new file mode 100644 index 0000000..905d13d --- /dev/null +++ b/src/components/team/invite-form.tsx @@ -0,0 +1,150 @@ +"use client"; + +import { useState } from "react"; +import { useTranslations } from "next-intl"; + +type FormState = "idle" | "submitting" | "success" | "error"; + +/** + * InviteForm — owner submits email + name + role to /api/team/invite. + * On success, broadcasts `team:refresh` so the sibling TeamList + * re-fetches the member list. + * + * Form fields mirror the POST body: + * { email, givenName, familyName, role: "owner" | "user" } + * + * Role defaults to "user" — the more conservative grant. Owner + * promotion happens in ZITADEL Console for now. + */ +export function InviteForm() { + const t = useTranslations("team"); + const tCommon = useTranslations("common"); + + const [form, setForm] = useState({ + email: "", + givenName: "", + familyName: "", + role: "user" as "owner" | "user", + }); + const [state, setState] = useState("idle"); + const [error, setError] = useState(""); + + function handleChange(e: React.ChangeEvent) { + setForm((prev) => ({ ...prev, [e.target.name]: e.target.value })); + } + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(""); + setState("submitting"); + + try { + const res = await fetch("/api/team/invite", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(form), + }); + if (!res.ok) { + const data = await res.json(); + if (data.code === "user_already_exists") { + throw new Error(t("inviteUserExists")); + } + throw new Error(data.error || "Invite failed"); + } + setState("success"); + setForm({ email: "", givenName: "", familyName: "", role: "user" }); + // Tell the TeamList sibling to refresh + window.dispatchEvent(new Event("team:refresh")); + + // Auto-clear the success banner after a moment so the form + // doesn't permanently look "done" + setTimeout(() => setState("idle"), 3500); + } catch (err: any) { + setError(err.message); + setState("error"); + } + } + + return ( +
+
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +

{t("roleHint")}

+
+ + {error && ( +
+ {error} +
+ )} + {state === "success" && ( +
+ {t("inviteSent")} +
+ )} + + +
+ ); +} diff --git a/src/components/team/team-list.tsx b/src/components/team/team-list.tsx new file mode 100644 index 0000000..227c7c4 --- /dev/null +++ b/src/components/team/team-list.tsx @@ -0,0 +1,98 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useTranslations } from "next-intl"; + +interface OrgMember { + userId: string; + email: string; + displayName: string; + givenName: string; + familyName: string; + roles: string[]; +} + +interface Props { + initialMembers: OrgMember[]; + currentUserId: string; +} + +/** + * 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. + */ +export function TeamList({ initialMembers, currentUserId }: Props) { + const t = useTranslations("team"); + const [members, setMembers] = useState(initialMembers); + + useEffect(() => { + function refresh() { + fetch("/api/team") + .then((r) => (r.ok ? r.json() : null)) + .then((data) => { + if (data?.members) setMembers(data.members); + }) + .catch(() => {}); + } + window.addEventListener("team:refresh", refresh); + return () => window.removeEventListener("team:refresh", refresh); + }, []); + + if (members.length === 0) { + return ( +
+ {t("noMembers")} +
+ ); + } + + return ( +
+
    + {members.map((m) => ( +
  • +
    +
    + + {m.displayName || m.email} + + {m.userId === currentUserId && ( + + {t("you")} + + )} +
    +
    + {m.email} +
    +
    +
    + {m.roles.length === 0 && ( + + {t("noRole")} + + )} + {m.roles.map((r) => ( + + {r} + + ))} +
    +
  • + ))} +
+
+ ); +} diff --git a/src/components/tenants/assigned-users-panel.tsx b/src/components/tenants/assigned-users-panel.tsx new file mode 100644 index 0000000..f642efa --- /dev/null +++ b/src/components/tenants/assigned-users-panel.tsx @@ -0,0 +1,231 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { useTranslations } from "next-intl"; +import { Card } from "@/components/ui/card"; + +interface Assignment { + userId: string; + displayName: string; + email: string; + roles: string[]; + assignedAt: string; + assignedBy: string; + orphan: boolean; +} + +interface OrgMember { + userId: string; + email: string; + displayName: string; + roles: string[]; +} + +interface Props { + tenantName: string; + /** + * When false, the panel renders read-only — assignments are visible + * but the add-user form and remove ✕ buttons are hidden. Pass + * `canEdit` from the parent server component (= canMutate(user)). + */ + canEdit: boolean; +} + +/** + * AssignedUsersPanel — manages the tenant_user_assignments rows for + * one tenant. Owner sees: + * - List of currently-assigned users with name, email, role, and + * an "X" button to revoke. + * - Dropdown of org members not yet assigned + "Assign" button. + * + * `user`-role members see the panel read-only (canEdit=false): they + * see who else has access to the tenant they're working with, but + * can't change anything. + */ +export function AssignedUsersPanel({ tenantName, canEdit }: Props) { + const t = useTranslations("assignments"); + const [assignments, setAssignments] = useState(null); + const [members, setMembers] = useState(null); + const [error, setError] = useState(""); + const [busy, setBusy] = useState(false); + const [pickedUserId, setPickedUserId] = useState(""); + + const refresh = useCallback(async () => { + setError(""); + try { + const [aRes, mRes] = await Promise.all([ + fetch(`/api/tenants/${tenantName}/assignments`), + canEdit + ? fetch(`/api/team`) + : Promise.resolve(null), + ]); + if (!aRes.ok) throw new Error("Failed to load assignments"); + const aData = await aRes.json(); + setAssignments(aData.assignments ?? []); + + if (mRes && mRes.ok) { + const mData = await mRes.json(); + setMembers(mData.members ?? []); + } + } catch (err: any) { + setError(err.message); + } + }, [tenantName, canEdit]); + + useEffect(() => { + refresh(); + }, [refresh]); + + async function handleAssign() { + if (!pickedUserId || busy) return; + setBusy(true); + setError(""); + try { + const res = await fetch(`/api/tenants/${tenantName}/assignments`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ userId: pickedUserId }), + }); + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error || "Assign failed"); + } + setPickedUserId(""); + await refresh(); + } catch (err: any) { + setError(err.message); + } finally { + setBusy(false); + } + } + + async function handleRevoke(userId: string) { + if (busy) return; + setBusy(true); + setError(""); + try { + const res = await fetch( + `/api/tenants/${tenantName}/assignments/${encodeURIComponent(userId)}`, + { method: "DELETE" } + ); + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error || "Revoke failed"); + } + await refresh(); + } catch (err: any) { + setError(err.message); + } finally { + setBusy(false); + } + } + + if (assignments === null) { + return ( + +
{t("loading")}
+
+ ); + } + + // Compute candidates for the assign dropdown: members of the org + // who hold the `user` role (not owners — they have implicit access) + // and aren't already assigned. + const assignedIds = new Set(assignments.map((a) => a.userId)); + const candidates = (members ?? []).filter( + (m) => + !assignedIds.has(m.userId) && + m.roles.includes("user") && + !m.roles.includes("owner") + ); + + return ( + + {error && ( +
+ {error} + +
+ )} + + {assignments.length === 0 ? ( +

+ {t("noneAssigned")} +

+ ) : ( +
    + {assignments.map((a) => ( +
  • +
    +
    + {a.orphan ? ( + + {a.displayName} + + ) : ( + a.displayName + )} +
    + {a.email && ( +
    + {a.email} +
    + )} +
    + {canEdit && ( + + )} +
  • + ))} +
+ )} + + {canEdit && ( +
+ {candidates.length === 0 ? ( +

+ {t("noCandidates")} +

+ ) : ( +
+ + +
+ )} +
+ )} +
+ ); +} diff --git a/src/lib/team.ts b/src/lib/team.ts new file mode 100644 index 0000000..404e8a9 --- /dev/null +++ b/src/lib/team.ts @@ -0,0 +1,168 @@ +/** + * Team management — high-level operations on top of `lib/zitadel.ts`. + * + * Two responsibilities: + * 1. Fetching the joined "members + roles" view for an org, used by + * the /team page and the assigned-users panel. + * 2. Inviting a new member end-to-end (create user + send invite + + * assign role) with rollback on partial failure, mirroring + * `registerCustomer` for new orgs. + * + * Allowed customer roles + * ---------------------- + * Slice 7 reduced scope: invitations may only set the customer roles + * `owner` or `user`. Platform roles cannot be granted via the portal — + * those are managed in ZITADEL Console with stricter access. The + * `viewer` role is gone since Slice 5. + */ + +import { + listOrgUsers, + listOrgAuthorizations, + createHumanUser, + createInviteCode, + createAuthorization, + type OrgUser, +} from "./zitadel"; +import type { CustomerRole } from "@/types"; + +const ALLOWED_INVITE_ROLES: CustomerRole[] = ["owner", "user"]; + +export function isValidInviteRole(role: string): role is CustomerRole { + return (ALLOWED_INVITE_ROLES as string[]).includes(role); +} + +export interface OrgMember { + userId: string; + email: string; + displayName: string; + givenName: string; + familyName: string; + /** + * Roles held by this member on the org's project grant. Usually a + * single-element array (one of "owner" / "user"). Could be empty + * if the user exists in the org but has no project authorization + * yet — appears as "no role" in the UI. + */ + roles: string[]; +} + +/** + * Fetch the joined members-with-roles view for an org. Two ZITADEL + * calls run in parallel (users + authorizations) then joined in memory. + * + * If either call fails, returns whatever the other one produced — + * users without roles render as "no role" badges; missing users are + * just absent. Better degraded than empty. + */ +export async function getOrgMembers(orgId: string): Promise { + const [users, auths] = await Promise.all([ + listOrgUsers(orgId), + listOrgAuthorizations(orgId), + ]); + + // Group authorizations by userId — one user could in principle hold + // multiple authorization rows (one per role assigned at different + // times). Flatten roleKeys. + const rolesByUser = new Map>(); + for (const a of auths) { + const set = rolesByUser.get(a.userId) ?? new Set(); + for (const r of a.roleKeys) set.add(r); + rolesByUser.set(a.userId, set); + } + + return users.map((u) => ({ + userId: u.userId, + email: u.email, + displayName: u.displayName, + givenName: u.givenName, + familyName: u.familyName, + roles: Array.from(rolesByUser.get(u.userId) ?? []), + })); +} + +/** + * Look up a single org member by userId. Convenience wrapper used to + * resolve a userId in an assignment row to a display name. Returns + * null if the user no longer exists in the org (stale assignment row). + */ +export async function getOrgMember( + orgId: string, + userId: string +): Promise { + const all = await getOrgMembers(orgId); + return all.find((m) => m.userId === userId) ?? null; +} + +export interface InviteResult { + userId: string; + emailAlreadyExists: boolean; +} + +/** + * Invite a new member into an existing customer org. + * + * Three steps: + * 1. createHumanUser — create the ZITADEL human, no password. + * 2. createInviteCode — send the invite email (set password + verify). + * 3. createAuthorization — assign the chosen customer role. + * + * If any step after (1) fails, the user is NOT rolled back. Reasoning: + * unlike registration where a half-created org is useless, a + * half-invited user can be cleaned up manually in ZITADEL Console and + * re-invited. The mid-failure cost of partial state is low; the cost of + * a wrong rollback is double-creation on retry. So we surface the + * error and let the operator decide. + * + * The invite-email step is best-effort — if SMTP is misconfigured the + * user is created and authorized but no email goes out. Owner can + * resend manually from ZITADEL Console. + * + * Note: ZITADEL rejects creating a user with an email that already + * exists in the same instance. The error is surfaced as-is from the + * `extractZitadelMessage`-aware caller. + */ +export async function inviteOrgMember(params: { + orgId: string; + email: string; + givenName: string; + familyName: string; + role: CustomerRole; + preferredLanguage?: string; +}): Promise { + // Step 1: create the user + const user = await createHumanUser({ + orgId: params.orgId, + email: params.email, + givenName: params.givenName, + familyName: params.familyName, + preferredLanguage: params.preferredLanguage, + }); + + // Step 2: send invite — best-effort + try { + await createInviteCode(user.id); + } catch (err) { + console.warn( + `Invite email could not be sent for user ${user.id} (SMTP may not be configured):`, + err + ); + } + + // Step 3: assign role + await createAuthorization({ + userId: user.id, + organizationId: params.orgId, + roleKeys: [params.role], + }); + + return { + userId: user.id, + emailAlreadyExists: false, + }; +} + +/** + * Re-export for convenience. + */ +export type { OrgUser }; diff --git a/src/lib/zitadel.ts b/src/lib/zitadel.ts index a9b35b4..2ae1439 100644 --- a/src/lib/zitadel.ts +++ b/src/lib/zitadel.ts @@ -213,6 +213,143 @@ export async function deleteOrganization(orgId: string): Promise { await zitadelFetch(`/v2/organizations/${orgId}`, "DELETE"); } +// --------------------------------------------------------------------------- +// Slice 7: search/list APIs for team management +// --------------------------------------------------------------------------- +// +// Two endpoints used by the Team UI: +// - listOrgUsers → POST /v2/users (search with organizationIdQuery) +// - listOrgAuthorizations → Connect RPC to AuthorizationService.ListAuthorizations +// +// Caveats +// ------- +// ZITADEL's v2 API surface evolves; the request/response shapes below were +// written against the v2 schema as documented at the time of authoring +// (organizationIdQuery filter on UserService.SearchUsers; ListAuthorizations +// with a ListQuery + filter pair). If your installed ZITADEL version uses +// slightly different field names, parsing here is intentionally tolerant — +// the helpers return [] rather than throwing on shape drift, log a warning, +// and the caller's UI shows an empty team list (which is recoverable). +// +// If you find a discrepancy, fix the request shape here and re-deploy; the +// rest of the team UI doesn't care about the on-the-wire format. + +export interface OrgUser { + userId: string; + email: string; + givenName: string; + familyName: string; + displayName: string; +} + +/** + * List all users belonging to a given ZITADEL organization. Paginated; + * we cap at 200 per call which is generous for the pilot scale. + */ +export async function listOrgUsers(orgId: string): Promise { + try { + const data = await zitadelFetch<{ result?: any[] }>( + "/v2/users", + "POST", + { + queries: [{ organizationIdQuery: { organizationId: orgId } }], + // Sort by username so the team list is deterministic across reloads + sortingColumn: "USER_FIELD_NAME_USERNAME", + query: { limit: 200, asc: true }, + } + ); + if (!data?.result || !Array.isArray(data.result)) return []; + + return data.result.flatMap((row: any) => { + // ZITADEL distinguishes human and machine users; we only want humans. + const human = row?.human; + if (!human) return []; + const profile = human.profile ?? {}; + const email = human.email?.email ?? ""; + const userId = row.userId ?? row.id ?? ""; + if (!userId) return []; + return [ + { + userId, + email, + givenName: profile.givenName ?? "", + familyName: profile.familyName ?? "", + displayName: + profile.displayName ?? + `${profile.givenName ?? ""} ${profile.familyName ?? ""}`.trim() ?? + email, + } as OrgUser, + ]; + }); + } catch (err) { + console.warn( + `Failed to list users for org ${orgId} (returning empty):`, + err + ); + return []; + } +} + +export interface OrgAuthorization { + authorizationId: string; + userId: string; + organizationId: string; + projectId: string; + roleKeys: string[]; +} + +/** + * List authorizations for the OpenClaw Platform project, filtered to a + * single organization. Used by the team UI to render each member's + * effective role. + * + * Connect RPC: zitadel.authorization.v2.AuthorizationService/ListAuthorizations + * + * Returns [] on any error so the team page can render a degraded view + * (members visible, roles blank) rather than blowing up entirely. + */ +export async function listOrgAuthorizations( + orgId: string +): Promise { + try { + const data = await connectRpc<{ authorizations?: any[] }>( + "zitadel.authorization.v2.AuthorizationService", + "ListAuthorizations", + { + filters: [ + { organizationId: orgId }, + { projectId: ZITADEL_PROJECT_ID }, + ], + // Cap at 500 — far more than a pilot org should ever need + pagination: { limit: 500 }, + } + ); + if (!data?.authorizations || !Array.isArray(data.authorizations)) { + return []; + } + + return data.authorizations.flatMap((row: any) => { + const userId = row?.userId ?? ""; + if (!userId) return []; + return [ + { + authorizationId: row.id ?? row.authorizationId ?? "", + userId, + organizationId: row.organizationId ?? orgId, + projectId: row.projectId ?? ZITADEL_PROJECT_ID, + roleKeys: Array.isArray(row.roleKeys) ? row.roleKeys : [], + } as OrgAuthorization, + ]; + }); + } catch (err) { + console.warn( + `Failed to list authorizations for org ${orgId} (returning empty):`, + err + ); + return []; + } +} + // --------------------------------------------------------------------------- // Full registration flow // --------------------------------------------------------------------------- diff --git a/src/messages/de.json b/src/messages/de.json index 168f292..112801a 100644 --- a/src/messages/de.json +++ b/src/messages/de.json @@ -11,7 +11,8 @@ "cancel": "Abbrechen", "save": "Speichern", "error": "Ein Fehler ist aufgetreten", - "register": "Registrieren" + "register": "Registrieren", + "team": "Team" }, "login": { "title": "PieCed Portal", @@ -116,7 +117,8 @@ "workspaceFiles": "Workspace-Dateien", "notFound": "Tenant nicht gefunden.", "usage": "Nutzung & Kosten", - "provisioned": "Bereitgestellt" + "provisioned": "Bereitgestellt", + "assignedUsers": "Zugewiesene Benutzer" }, "usage": { "inputTokens": "Input-Tokens", @@ -269,5 +271,32 @@ "telegramIdHelp": "So finden Sie Ihre Telegram-Benutzer-ID:\n1. Öffnen Sie Telegram und schreiben Sie @userinfobot\n2. Der Bot antwortet sofort mit Ihrer numerischen ID\n3. Geben Sie diese Nummer hier ein", "discordIdHelp": "So finden Sie Ihre Discord-Benutzer-ID:\n1. Aktivieren Sie den Entwicklermodus in den Discord-Einstellungen (Erweitert)\n2. Rechtsklick auf Ihren Namen → Benutzer-ID kopieren\n3. Geben Sie diese Nummer hier ein", "emailIdHelp": "Geben Sie die E-Mail-Adresse ein, die zur Interaktion mit dem Assistenten autorisiert werden soll." + }, + "team": { + "title": "Team", + "description": "Verwalten Sie die Mitglieder Ihrer Organisation. Laden Sie Kollegen ein und weisen Sie sie Instanzen zu.", + "inviteSectionTitle": "Mitglied einladen", + "membersSectionTitle": "Mitglieder", + "noMembers": "Noch keine Mitglieder.", + "you": "Sie", + "noRole": "keine Rolle", + "givenName": "Vorname", + "familyName": "Nachname", + "email": "E-Mail", + "role": "Rolle", + "roleUser": "Benutzer (nur Lesezugriff, muss Instanzen zugewiesen werden)", + "roleOwner": "Eigentümer (Vollzugriff auf alle Instanzen)", + "roleHint": "Eigentümer können Instanzen, Abrechnung und Teammitglieder verwalten. Benutzer können nur die ihnen zugewiesenen Instanzen anzeigen.", + "inviteButton": "Einladung senden", + "inviteSent": "Einladung gesendet. Der Benutzer erhält eine E-Mail mit einem Link zum Festlegen des Passworts.", + "inviteUserExists": "Ein Benutzer mit dieser E-Mail-Adresse ist bereits registriert." + }, + "assignments": { + "loading": "Zuweisungen werden geladen…", + "noneAssigned": "Dieser Instanz sind noch keine Benutzer zugewiesen.", + "noCandidates": "Keine Teammitglieder verfügbar zum Zuweisen. Laden Sie zuerst Benutzer auf der Team-Seite ein.", + "pickUser": "Benutzer auswählen…", + "assign": "Zuweisen", + "revoke": "Entfernen" } } diff --git a/src/messages/en.json b/src/messages/en.json index 2467d81..eacbbfb 100644 --- a/src/messages/en.json +++ b/src/messages/en.json @@ -11,7 +11,8 @@ "cancel": "Cancel", "save": "Save", "error": "An error occurred", - "register": "Register" + "register": "Register", + "team": "Team" }, "login": { "title": "PieCed Portal", @@ -116,7 +117,8 @@ "workspaceFiles": "Workspace Files", "notFound": "Tenant not found.", "usage": "Usage & Spend", - "provisioned": "Provisioned" + "provisioned": "Provisioned", + "assignedUsers": "Assigned users" }, "usage": { "inputTokens": "Input Tokens", @@ -269,5 +271,32 @@ "telegramIdHelp": "To find your Telegram user ID:\n1. Open Telegram and message @userinfobot\n2. It instantly replies with your numeric ID\n3. Enter that number here", "discordIdHelp": "To find your Discord user ID:\n1. Enable Developer Mode in Discord settings (Advanced)\n2. Right-click your name → Copy User ID\n3. Enter that number here", "emailIdHelp": "Enter the email address that should be authorized to interact with the assistant." + }, + "team": { + "title": "Team", + "description": "Manage members of your organization. Invite colleagues and assign them to instances.", + "inviteSectionTitle": "Invite a member", + "membersSectionTitle": "Members", + "noMembers": "No members yet.", + "you": "You", + "noRole": "no role", + "givenName": "First name", + "familyName": "Last name", + "email": "Email", + "role": "Role", + "roleUser": "User (read-only, must be assigned to instances)", + "roleOwner": "Owner (full access to all instances)", + "roleHint": "Owners can manage instances, billing, and team members. Users can only view instances they've been assigned to.", + "inviteButton": "Send invitation", + "inviteSent": "Invitation sent. The user will receive an email with a link to set their password.", + "inviteUserExists": "A user with this email is already registered." + }, + "assignments": { + "loading": "Loading assignments…", + "noneAssigned": "No users are assigned to this instance yet.", + "noCandidates": "No team members available to assign. Invite users from the Team page first.", + "pickUser": "Select a user…", + "assign": "Assign", + "revoke": "Remove" } } diff --git a/src/messages/fr.json b/src/messages/fr.json index 03b82f3..1f8b97d 100644 --- a/src/messages/fr.json +++ b/src/messages/fr.json @@ -11,7 +11,8 @@ "cancel": "Annuler", "save": "Enregistrer", "error": "Une erreur est survenue", - "register": "S'inscrire" + "register": "S'inscrire", + "team": "Équipe" }, "login": { "title": "Portail PieCed", @@ -116,7 +117,8 @@ "workspaceFiles": "Fichiers workspace", "notFound": "Locataire non trouvé.", "usage": "Utilisation et coûts", - "provisioned": "Provisionné" + "provisioned": "Provisionné", + "assignedUsers": "Utilisateurs attribués" }, "usage": { "inputTokens": "Tokens d'entrée", @@ -269,5 +271,32 @@ "telegramIdHelp": "Pour trouver votre identifiant Telegram :\n1. Ouvrez Telegram et envoyez un message à @userinfobot\n2. Il répond instantanément avec votre identifiant numérique\n3. Entrez ce numéro ici", "discordIdHelp": "Pour trouver votre identifiant Discord :\n1. Activez le mode développeur dans les paramètres Discord (Avancé)\n2. Clic droit sur votre nom → Copier l'identifiant\n3. Entrez ce numéro ici", "emailIdHelp": "Entrez l'adresse e-mail qui doit être autorisée à interagir avec l'assistant." + }, + "team": { + "title": "Équipe", + "description": "Gérez les membres de votre organisation. Invitez des collègues et attribuez-leur des instances.", + "inviteSectionTitle": "Inviter un membre", + "membersSectionTitle": "Membres", + "noMembers": "Aucun membre pour l'instant.", + "you": "Vous", + "noRole": "aucun rôle", + "givenName": "Prénom", + "familyName": "Nom de famille", + "email": "E-mail", + "role": "Rôle", + "roleUser": "Utilisateur (lecture seule, doit être affecté à des instances)", + "roleOwner": "Propriétaire (accès complet à toutes les instances)", + "roleHint": "Les propriétaires peuvent gérer les instances, la facturation et les membres de l'équipe. Les utilisateurs ne peuvent voir que les instances qui leur sont attribuées.", + "inviteButton": "Envoyer l'invitation", + "inviteSent": "Invitation envoyée. L'utilisateur recevra un e-mail avec un lien pour définir son mot de passe.", + "inviteUserExists": "Un utilisateur avec cette adresse e-mail est déjà enregistré." + }, + "assignments": { + "loading": "Chargement des attributions…", + "noneAssigned": "Aucun utilisateur n'est encore attribué à cette instance.", + "noCandidates": "Aucun membre de l'équipe disponible pour l'attribution. Invitez d'abord des utilisateurs depuis la page Équipe.", + "pickUser": "Sélectionner un utilisateur…", + "assign": "Attribuer", + "revoke": "Retirer" } } diff --git a/src/messages/it.json b/src/messages/it.json index 213a98f..21a8bf5 100644 --- a/src/messages/it.json +++ b/src/messages/it.json @@ -11,7 +11,8 @@ "cancel": "Annulla", "save": "Salva", "error": "Si è verificato un errore", - "register": "Registrati" + "register": "Registrati", + "team": "Team" }, "login": { "title": "Portale PieCed", @@ -116,7 +117,8 @@ "workspaceFiles": "File workspace", "notFound": "Tenant non trovato.", "usage": "Utilizzo e costi", - "provisioned": "Attivato" + "provisioned": "Attivato", + "assignedUsers": "Utenti assegnati" }, "usage": { "inputTokens": "Token di input", @@ -269,5 +271,32 @@ "telegramIdHelp": "Per trovare il tuo ID Telegram:\n1. Apri Telegram e invia un messaggio a @userinfobot\n2. Risponde istantaneamente con il tuo ID numerico\n3. Inserisci quel numero qui", "discordIdHelp": "Per trovare il tuo ID Discord:\n1. Attiva la Modalità sviluppatore nelle impostazioni Discord (Avanzate)\n2. Clic destro sul tuo nome → Copia ID utente\n3. Inserisci quel numero qui", "emailIdHelp": "Inserisci l'indirizzo e-mail che deve essere autorizzato a interagire con l'assistente." + }, + "team": { + "title": "Team", + "description": "Gestisci i membri della tua organizzazione. Invita colleghi e assegnali alle istanze.", + "inviteSectionTitle": "Invita un membro", + "membersSectionTitle": "Membri", + "noMembers": "Nessun membro ancora.", + "you": "Tu", + "noRole": "nessun ruolo", + "givenName": "Nome", + "familyName": "Cognome", + "email": "E-mail", + "role": "Ruolo", + "roleUser": "Utente (sola lettura, deve essere assegnato a istanze)", + "roleOwner": "Proprietario (accesso completo a tutte le istanze)", + "roleHint": "I proprietari possono gestire istanze, fatturazione e membri del team. Gli utenti possono solo visualizzare le istanze a loro assegnate.", + "inviteButton": "Invia invito", + "inviteSent": "Invito inviato. L'utente riceverà un'e-mail con un link per impostare la password.", + "inviteUserExists": "Un utente con questa e-mail è già registrato." + }, + "assignments": { + "loading": "Caricamento assegnazioni…", + "noneAssigned": "Nessun utente è ancora assegnato a questa istanza.", + "noCandidates": "Nessun membro del team disponibile per l'assegnazione. Invita prima gli utenti dalla pagina Team.", + "pickUser": "Seleziona un utente…", + "assign": "Assegna", + "revoke": "Rimuovi" } }