Group B fixes
All checks were successful
Build and Push / build (push) Successful in 1m24s

This commit is contained in:
2026-04-29 15:43:12 +02:00
parent c7df5c83a4
commit eeef108f7e
18 changed files with 387 additions and 82 deletions

View File

@@ -3,6 +3,9 @@ import { getTranslations } from "next-intl/server";
import { redirect } from "next/navigation";
import { OnboardingFlow } from "@/components/onboarding/onboarding-flow";
import { BackLink } from "@/components/ui/back-link";
import { listTenants } from "@/lib/k8s";
import { listActiveTenantRequestsByOrgId } from "@/lib/db";
import { personalAccountAtCapacity } from "@/lib/personal-org";
/**
* /dashboard/new — wizard for creating an additional instance for an
@@ -21,6 +24,10 @@ import { BackLink } from "@/components/ui/back-link";
* may create new instances. The server-side POST handler enforces the
* same; this redirect is purely UX so /user-role members don't land on
* a wizard that will 403 on submit.
*
* Bug 5: personal accounts that already hold a tenant or have one
* in-flight are sent back to the dashboard with the same UX rationale.
* Matching API guard lives in `/api/onboarding`.
*/
export default async function NewInstancePage() {
const user = await getSessionUser();
@@ -28,6 +35,25 @@ export default async function NewInstancePage() {
if (user.isPlatform) redirect("/dashboard");
if (!canMutate(user)) redirect("/dashboard");
if (user.isPersonal) {
const [allTenants, activeRequests] = await Promise.all([
listTenants(),
listActiveTenantRequestsByOrgId(user.orgId),
]);
const ownTenants = allTenants.filter(
(t) => t.metadata.labels?.["pieced.ch/zitadel-org-id"] === user.orgId
);
if (
personalAccountAtCapacity(
user.isPersonal,
ownTenants.length,
activeRequests.length
)
) {
redirect("/dashboard");
}
}
const t = await getTranslations("dashboard");
return (
@@ -43,7 +69,11 @@ export default async function NewInstancePage() {
</div>
<div className="animate-in animate-in-delay-1">
<OnboardingFlow orgName={user.orgName} />
<OnboardingFlow
orgName={user.orgName}
userName={user.name}
userEmail={user.email}
/>
</div>
</div>
);

View File

@@ -8,6 +8,7 @@ import {
canSeeInflightRequests,
isUserScoped,
} from "@/lib/visibility";
import { personalAccountAtCapacity } from "@/lib/personal-org";
import { Card, CardHeader } from "@/components/ui/card";
import { StatusBadge } from "@/components/ui/status-badge";
import { OnboardingFlow } from "@/components/onboarding/onboarding-flow";
@@ -179,7 +180,17 @@ export default async function DashboardPage() {
// the admin panel anyway) see the "Create new instance" link. A
// `user`-role member sees the dashboard but not the create flow —
// they need to ask an owner.
const canCreate = canMutate(user);
//
// Bug 5: personal accounts are 1-instance by design. Once a personal
// account has either an active tenant OR an in-flight request, the
// create button must disappear. The matching server-side guard is
// in `/api/onboarding` so direct POSTs are also rejected.
const personalAtCapacity = personalAccountAtCapacity(
user.isPersonal,
orgScopedTenants.length,
inflightRequests.length
);
const canCreate = canMutate(user) && !personalAtCapacity;
// First-time / no-visibility branch.
//
@@ -262,7 +273,11 @@ export default async function DashboardPage() {
</div>
<div className="animate-in animate-in-delay-1">
<OnboardingFlow orgName={user.orgName} />
<OnboardingFlow
orgName={user.orgName}
userName={user.name}
userEmail={user.email}
/>
</div>
</div>
);

View File

@@ -8,11 +8,13 @@ import { Card } from "@/components/ui/card";
type FormState = "idle" | "submitting" | "success" | "error";
/**
* Slice 4: a "Register as individual" toggle distinguishes personal
* accounts from company registrations. When the toggle is on:
* Slice 4 + Bug 9: a "Register as individual" toggle distinguishes
* personal accounts from company registrations. When the toggle is on:
* - the company name field is hidden (and not sent)
* - the server skips the duplicate-domain check
* - the ZITADEL org is named "{givenName} {familyName} (Personal)"
* - the ZITADEL org is named `personal-{8hex}` (opaque, collision-free)
* - the user's display name lives only on the user record; the GUI
* shows it instead of the opaque org name everywhere
*/
export default function RegisterPage() {
const t = useTranslations("register");
@@ -40,8 +42,8 @@ export default function RegisterPage() {
try {
// Build the request body explicitly. For personals we omit
// companyName so the server knows to derive the org name from
// the user's full name. The Zod schema accepts the omission.
// companyName so the server generates an opaque ZITADEL org name
// (`personal-{8hex}`); the Zod schema accepts the omission.
const body: Record<string, unknown> = {
givenName: form.givenName,
familyName: form.familyName,

View File

@@ -21,6 +21,12 @@ export default async function TeamPage() {
const user = await getSessionUser();
if (!user) redirect("/login");
if (!canMutate(user)) redirect("/dashboard");
// Bug 8: personal accounts have no team to manage. The page is
// structurally meaningless and the invite form would create extra
// ZITADEL users in a single-user org. Redirect cleanly. The matching
// API guards in `/api/team` and `/api/team/invite` enforce the same
// rule on direct calls.
if (user.isPersonal) redirect("/dashboard");
const t = await getTranslations("team");
const tDashboard = await getTranslations("dashboard");

View File

@@ -40,6 +40,19 @@ export default async function TenantDetailPage({
// the same page but with edit controls hidden / fields read-only.
const canEdit = canMutate(user);
// Bug 7: assigned-users panel is meaningless for personal tenants
// (sole-owner by definition; the only "assignee" is the owner
// themselves). We hide the panel when EITHER the CR carries the
// `pieced.ch/personal=true` label (set at approve time for new
// personal tenants) OR the viewer is on a personal account (covers
// legacy tenants approved before the label was added; the customer
// sees their own personal tenant). Platform admins viewing a legacy
// unlabeled personal tenant are the only case where this falls
// through to "show panel" — operators can `kubectl label` to fix.
const isPersonalTenant =
tenant.metadata.labels?.["pieced.ch/personal"] === "true" ||
user.isPersonal;
const enabledPackages = tenant.spec.packages || [];
const workspaceFiles = tenant.spec.workspaceFiles || {};
const enabledChannels = enabledPackages.filter((pkg) =>
@@ -132,13 +145,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. */}
<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>
fetches its own data so the page doesn't need to await.
Bug 7: hidden entirely for personal tenants. */}
{!isPersonalTenant && (
<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>
);
}

View File

@@ -123,6 +123,15 @@ export async function POST(
},
{
"pieced.ch/zitadel-org-id": tenantRequest.zitadelOrgId,
// Bug 7: stamp the personal flag on the CR so callers (notably
// the tenant detail page) can hide assignment-related UI
// without an extra DB join. Slice 4 already tracks this on the
// request row; the CR label is the same fact at the K8s layer.
// Legacy tenants approved before this change won't carry the
// label — operators can backfill with `kubectl label`.
...(tenantRequest.isPersonal
? { "pieced.ch/personal": "true" }
: {}),
}
);

View File

@@ -217,6 +217,31 @@ export async function POST(request: Request) {
// the org-name check should agree.)
const isPersonal = prior?.isPersonal ?? isPersonalOrgName(user.orgName);
// Bug 5: personal accounts are 1-instance by design. If there's
// already an active tenant or an in-flight request for this user's
// org, reject the submission outright. Server-side only check;
// matching UI guards live on /dashboard (button hidden) and
// /dashboard/new (server-redirect to /dashboard).
if (isPersonal) {
const [allTenants, activeRequests] = await Promise.all([
listTenants(),
listActiveTenantRequestsByOrgId(user.orgId),
]);
const ownTenants = allTenants.filter(
(t) => t.metadata.labels?.["pieced.ch/zitadel-org-id"] === user.orgId
);
if (ownTenants.length > 0 || activeRequests.length > 0) {
return NextResponse.json(
{
error:
"Personal accounts are limited to one instance. Cancel your existing request or contact support to change plan.",
code: "personal_account_at_capacity",
},
{ status: 403 }
);
}
}
// Encrypt package secrets if provided
let encryptedSecrets: Buffer | undefined;
if (input.packageSecrets && Object.keys(input.packageSecrets).length > 0) {

View File

@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { registerCustomer } from "@/lib/zitadel";
import { rateLimit } from "@/lib/rate-limit";
import { checkDuplicateDomain } from "@/lib/db";
import { generatePersonalOrgName } from "@/lib/personal-org";
import type { RegistrationInput } from "@/types";
import { z } from "zod";
@@ -13,11 +14,10 @@ import { z } from "zod";
* - `companyName` is no longer always required. It's required when
* `isPersonal` is false/absent, ignored when `isPersonal` is true.
* - `isPersonal` flag distinguishes personal accounts. The server
* derives the ZITADEL org name from `${givenName} ${familyName}
* (Personal)` for personals — the suffix is the canonical marker
* that downstream code (onboarding POST, admin views) uses to
* distinguish personal orgs from companies. Customers cannot rename
* their own org, so the suffix is stable.
* derives the ZITADEL org name from a generated opaque ID
* (`personal-{8hex}`) — see `lib/personal-org.ts` for the format
* spec. Customers cannot rename their own org, so the marker is
* stable.
* - Personal accounts skip the duplicate-domain check entirely. Their
* row is also excluded from future domain checks (see
* `lib/domain-check.ts::findDuplicateInDb`).
@@ -44,15 +44,6 @@ const registrationSchema = z
const RATE_LIMIT = 3;
const RATE_WINDOW_MS = 3_600_000; // 1 hour
/**
* Suffix appended to personal-account ZITADEL org names. Used here to
* build the org name and elsewhere (session.orgName check) to detect
* whether the current user is on a personal org.
*
* Keep this in sync with `isPersonalOrgName()` in `lib/personal-org.ts`.
*/
const PERSONAL_ORG_SUFFIX = " (Personal)";
export async function POST(request: NextRequest) {
// --- Rate limiting ---
const ip =
@@ -116,14 +107,13 @@ export async function POST(request: NextRequest) {
//
// For company: use the customer-supplied companyName (already
// validated to be present + ≥2 chars by the schema refinement).
// For personal: synthesise from full name + " (Personal)" suffix.
// The suffix is the canonical marker for personal orgs.
//
// ZITADEL does NOT enforce org-name uniqueness, so two "Hans Müller
// (Personal)" orgs can coexist; the org id is what matters for our
// labelling and lookups, the name is human-readable only.
// For personal: a fresh opaque ID like "personal-3f2a8b1c". The
// user's actual display name is per-user (`session.user.name`),
// so the GUI shows that instead — see `displayOrgNameFor()`.
// This keeps personal orgs collision-free (Bug 9: two people
// named "Eva Müller" both being able to register).
const orgName = isPersonal
? `${input.givenName.trim()} ${input.familyName.trim()}${PERSONAL_ORG_SUFFIX}`
? generatePersonalOrgName()
: input.companyName!.trim();
const result = await registerCustomer({

View File

@@ -53,6 +53,12 @@ export async function PATCH(
if (!isCustomerOwner(user)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
if (user.isPersonal) {
return NextResponse.json(
{ error: "Personal accounts have no team roles to change." },
{ status: 403 }
);
}
const { userId } = await params;

View File

@@ -35,6 +35,16 @@ export async function POST(req: Request) {
if (!canMutate(user)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
if (user.isPersonal) {
return NextResponse.json(
{
error:
"Personal accounts cannot invite additional members. Upgrade to a company account to add a team.",
code: "personal_account",
},
{ status: 403 }
);
}
const body = await req.json().catch(() => null);
const parsed = inviteSchema.safeParse(body);

View File

@@ -24,6 +24,12 @@ export async function GET() {
if (!canMutate(user)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
if (user.isPersonal) {
return NextResponse.json(
{ error: "Personal accounts do not have a team." },
{ status: 403 }
);
}
try {
const members = await getOrgMembers(user.orgId);

View File

@@ -128,6 +128,23 @@ export async function POST(
{ status: 500 }
);
}
// Bug 7 server-side counterpart: personal tenants are sole-owner
// by definition. Reject any assignment attempt — this matches the
// hidden panel on the detail page and stops a determined client
// (or platform user with a legacy unlabeled personal tenant) from
// creating spurious rows.
if (
tenant.metadata.labels?.["pieced.ch/personal"] === "true" ||
(!user.isPlatform && user.isPersonal)
) {
return NextResponse.json(
{
error: "Personal tenants do not support additional assignments.",
code: "personal_tenant",
},
{ status: 403 }
);
}
const body = await req.json().catch(() => null);
const parsed = assignSchema.safeParse(body);