This commit is contained in:
65
src/app/[locale]/team/page.tsx
Normal file
65
src/app/[locale]/team/page.tsx
Normal file
@@ -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
|
||||
* `<TeamList>` and `<InviteForm>` 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 (
|
||||
<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>
|
||||
<h1 className="font-display text-2xl font-semibold accent-rule mb-2">
|
||||
{t("title")}
|
||||
</h1>
|
||||
<p className="text-text-secondary text-sm mt-4">{t("description")}</p>
|
||||
</div>
|
||||
|
||||
<section className="mb-8 animate-in animate-in-delay-1">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
|
||||
{t("inviteSectionTitle")}
|
||||
</h2>
|
||||
<Card>
|
||||
<InviteForm />
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<section className="animate-in animate-in-delay-2">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
|
||||
{t("membersSectionTitle")}{" "}
|
||||
<span className="text-text-muted/60 tabular-nums">
|
||||
({members.length})
|
||||
</span>
|
||||
</h2>
|
||||
<TeamList initialMembers={members} currentUserId={user.id} />
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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({
|
||||
</h2>
|
||||
<WorkspaceEditor tenantName={name} files={workspaceFiles} canEdit={canEdit} />
|
||||
</section>
|
||||
|
||||
{/* 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. */}
|
||||
<section className="mt-8 animate-in animate-in-delay-4">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
|
||||
{t("assignedUsers")}
|
||||
</h2>
|
||||
<AssignedUsersPanel tenantName={name} canEdit={canEdit} />
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
95
src/app/api/team/invite/route.ts
Normal file
95
src/app/api/team/invite/route.ts
Normal file
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
38
src/app/api/team/route.ts
Normal file
38
src/app/api/team/route.ts
Normal file
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
57
src/app/api/tenants/[name]/assignments/[userId]/route.ts
Normal file
57
src/app/api/tenants/[name]/assignments/[userId]/route.ts
Normal file
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
176
src/app/api/tenants/[name]/assignments/route.ts
Normal file
176
src/app/api/tenants/[name]/assignments/route.ts
Normal file
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user