Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions src/app/(auth)/auth/constants/auth-error-codes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest';
import { getPrNamespaceIssue } from './auth-error-codes';

/**
* Pins the seam between the SaaS backend error codes and the signup form. The
* two PR-namespace failures must map to distinct issues so the form can say
* plainly what is wrong — namespace missing (404) versus not READY (409) — and
* every other response must map to nothing so the normal toast path still runs.
*/
describe('getPrNamespaceIssue', () => {
it('maps PR_NAMESPACE_NOT_FOUND to "missing"', () => {
expect(getPrNamespaceIssue({ status: 404, data: { code: 'PR_NAMESPACE_NOT_FOUND' } })).toBe('missing');
});

it('maps PR_NAMESPACE_UNAVAILABLE to "not-ready"', () => {
expect(getPrNamespaceIssue({ status: 409, data: { code: 'PR_NAMESPACE_UNAVAILABLE' } })).toBe('not-ready');
});

it('returns undefined for other error codes', () => {
expect(getPrNamespaceIssue({ status: 409, data: { code: 'TENANT_REGISTRATION_BLOCKED' } })).toBeUndefined();
});

it('returns undefined when the body has no code', () => {
expect(getPrNamespaceIssue({ status: 500, data: undefined })).toBeUndefined();
});
});
20 changes: 20 additions & 0 deletions src/app/(auth)/auth/constants/auth-error-codes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,26 @@
export const AUTH_ERROR_CODE = {
INVALID_ARGUMENT: 'INVALID_ARGUMENT',
TENANT_REGISTRATION_BLOCKED: 'TENANT_REGISTRATION_BLOCKED',
// Returned only when a signup carries a `prNumber` (dev/QA only): the PR
// environment it points at cannot be claimed. Missing = no tenant-<prNumber>-*
// namespace exists (404); not-ready = one exists but is not READY (409).
PR_NAMESPACE_NOT_FOUND: 'PR_NAMESPACE_NOT_FOUND',
PR_NAMESPACE_UNAVAILABLE: 'PR_NAMESPACE_UNAVAILABLE',
} as const;

export type AuthErrorCode = (typeof AUTH_ERROR_CODE)[keyof typeof AUTH_ERROR_CODE];

/** Which part of the PR-namespace claim failed. */
export type PrNamespaceIssue = 'missing' | 'not-ready';

/**
* Detects the PR-namespace registration failures. Returns the issue when the
* response is one of them, so the signup form can show a persistent inline notice
* and offer recovery instead of a fading toast with the raw backend string.
*/
export function getPrNamespaceIssue(response: { status: number; data?: unknown }): PrNamespaceIssue | undefined {
const code = (response.data as { code?: string } | undefined)?.code;
if (code === AUTH_ERROR_CODE.PR_NAMESPACE_NOT_FOUND) return 'missing';
if (code === AUTH_ERROR_CODE.PR_NAMESPACE_UNAVAILABLE) return 'not-ready';
return undefined;
}
22 changes: 20 additions & 2 deletions src/app/(auth)/auth/hooks/use-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { collectRegistrationAttribution } from '@/lib/registration-attribution';
import { routes } from '@/lib/routes';
import { runtimeEnv } from '@/lib/runtime-config';
import { isBearerAuthMode } from '@/lib/token-store';
import { AUTH_ERROR_CODE } from '../constants/auth-error-codes';
import { AUTH_ERROR_CODE, getPrNamespaceIssue, type PrNamespaceIssue } from '../constants/auth-error-codes';
import { useAuthStore } from '../stores/auth-store';
import { authSessionQueryKey } from './use-auth-session';
import { useTokenStorage } from './use-token-storage';
Expand Down Expand Up @@ -43,6 +43,15 @@ interface RegisterRequest {
prNumber?: number;
}

/**
* Outcome of {@link useAuth.registerOrganization}. A PR-namespace failure is
* returned (not toasted) so the caller can hold it inline; every other outcome —
* success, generic failure, network error — is handled here and returns empty.
*/
interface RegisterResult {
prNamespaceIssue?: PrNamespaceIssue;
}

interface SsoRegisterRequest {
tenantName: string;
tenantDomain: string;
Expand Down Expand Up @@ -159,7 +168,7 @@ export function useAuth() {
}
};

const registerOrganization = async (data: RegisterRequest) => {
const registerOrganization = async (data: RegisterRequest): Promise<RegisterResult> => {
setIsLoading(true);

try {
Expand All @@ -177,6 +186,13 @@ export function useAuth() {
});

if (!response.ok) {
// A `prNumber` that points at an unprovisioned or not-READY PR
// environment (dev/QA only). Return it so the signup form can hold a
// persistent inline notice and offer recovery, rather than a toast that
// fades and leaves the form looking submittable.
const prNamespaceIssue = getPrNamespaceIssue(response);
if (prNamespaceIssue) return { prNamespaceIssue };

const code = (response.data as any)?.code;
const message = (response.data as any)?.message || response.error || 'Registration failed';
let userMessage = 'Registration failed';
Expand Down Expand Up @@ -211,12 +227,14 @@ export function useAuth() {
// Client-side replace (not window.location.href) so the success toast
// survives the transition; replace keeps signup out of the back stack.
router.replace(routes.auth.checkEmail);
return {};
} catch (error: any) {
toast({
title: 'Registration Failed',
description: error instanceof Error ? error.message : 'Unable to create organization',
variant: 'destructive',
});
return {};
} finally {
setIsLoading(false);
}
Expand Down
65 changes: 53 additions & 12 deletions src/app/(auth)/auth/pages/signup-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
import { Input, TabSelector } from '@flamingo-stack/openframe-frontend-core/components/ui';
import { useRouter } from 'next/navigation';
import { useEffect, useRef, useState } from 'react';
import { type PrNamespaceIssue } from '@/app/(auth)/auth/constants/auth-error-codes';
import { useAuth } from '@/app/(auth)/auth/hooks/use-auth';
import { useRegistrationProviders } from '@/app/(auth)/auth/hooks/use-registration-providers';
import { useAuthStore } from '@/app/(auth)/auth/stores/auth-store';
Expand All @@ -19,6 +20,14 @@ import { runtimeEnv } from '@/lib/runtime-config';

const MIN_PASSWORD_LENGTH = 8;

/** Plain, per-cause copy for a PR environment that cannot be claimed (dev/QA only). */
function prNamespaceNoticeText(issue: PrNamespaceIssue, prNumber: string): string {
const env = `PR environment #${prNumber}`;
return issue === 'missing'
? `${env} is not provisioned. No namespace exists for this PR number yet.`
: `${env} is not ready. Its namespace exists but is not available yet.`;
}

/**
* "Complete your Account" step: name + password for the organization collected
* on the Create Organization step, or an external SSO provider shortcut.
Expand All @@ -35,6 +44,10 @@ export default function SignupPage() {
const [confirmPassword, setConfirmPassword] = useState('');
const [prNumber, setPrNumber] = useState('');
const showPrNumber = runtimeEnv.prNumberEnabled();
// Set when a submit is refused because the PR environment cannot be claimed.
// Held as persistent inline state (not a toast) so the notice stays and submit
// stays disabled until the user edits or clears the PR number.
const [prNamespaceNotice, setPrNamespaceNotice] = useState<string | undefined>(undefined);

// "Continue with Apple" is offered on Apple devices only.
const isApple = useIsApplePlatform();
Expand Down Expand Up @@ -76,11 +89,23 @@ export default function SignupPage() {
const isTooShort = !!password && password.length < MIN_PASSWORD_LENGTH;
const isMismatch = !!confirmPassword && password !== confirmPassword;
const isValid =
!!firstName.trim() && !!lastName.trim() && password.length >= MIN_PASSWORD_LENGTH && password === confirmPassword;
!!firstName.trim() &&
!!lastName.trim() &&
password.length >= MIN_PASSWORD_LENGTH &&
password === confirmPassword &&
// A standing PR-namespace notice blocks submit until the user resolves it.
!prNamespaceNotice;

// Clearing the PR number sends the signup down the normal "claim any READY
// cluster" path — the recovery from an unclaimable PR environment.
const clearPrNumber = () => {
setPrNumber('');
setPrNamespaceNotice(undefined);
};

const handleSubmit = () => {
const handleSubmit = async () => {
if (!isValid) return;
registerOrganization({
const { prNamespaceIssue } = await registerOrganization({
tenantName: storedOrgName,
tenantDomain: storedDomain,
email: storedEmail,
Expand All @@ -89,6 +114,7 @@ export default function SignupPage() {
password,
...(showPrNumber && prNumber ? { prNumber: Number(prNumber) } : {}),
});
if (prNamespaceIssue) setPrNamespaceNotice(prNamespaceNoticeText(prNamespaceIssue, prNumber));
};

// External providers offered by the backend for registration; Apple only on Apple devices.
Expand Down Expand Up @@ -145,15 +171,30 @@ export default function SignupPage() {
}}
>
{showPrNumber && (
<Input
label="PR Number (optional)"
placeholder="Enter PR Number"
inputMode="numeric"
value={prNumber}
disabled={isLoading || loadingProviders}
// Digits only — typing or pasting anything else (minus sign included) is stripped.
onChange={event => setPrNumber(event.target.value.replace(/\D/g, ''))}
/>
<div className="flex flex-col gap-[var(--spacing-system-s)]">
<Input
label="PR Number (optional)"
placeholder="Enter PR Number"
inputMode="numeric"
value={prNumber}
disabled={isLoading || loadingProviders}
// The notice describes the PR environment, not a malformed value, so it
// shows as a warning rather than a validation error.
error={prNamespaceNotice}
errorVariant="warning"
// Digits only — typing or pasting anything else (minus sign included) is stripped.
// Editing clears the notice so the new value can be tried.
onChange={event => {
setPrNumber(event.target.value.replace(/\D/g, ''));
setPrNamespaceNotice(undefined);
}}
/>
{prNamespaceNotice && (
<button type="button" className="self-start text-ods-accent text-h6 underline" onClick={clearPrNumber}>
Clear the PR number to use a shared dev cluster
</button>
)}
</div>
)}
</CompleteAccountForm>
</AuthShell>
Expand Down