From b34c179a7ce14f99c20d27103ad38722b35d8648 Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Sat, 29 Aug 2026 21:49:35 +0000 Subject: [PATCH] fix(auth): recover from an unclaimable PR environment on dev signup On dev/QA the signup form renders an optional PR Number field. A prNumber whose tenant--* namespace is missing (404 PR_NAMESPACE_NOT_FOUND) or not READY (409 PR_NAMESPACE_UNAVAILABLE) failed with a fading toast that showed the raw backend string, left submit enabled, and stacked a new toast per retry. Route those two codes to a persistent inline notice on the PR Number field that names the cause (missing vs not ready), disable submit while it stands, and offer "clear the PR number to use a shared dev cluster" as recovery. Editing or clearing the field clears the notice. Reuses the inline-notice pattern from PR #225. Dev/QA only: the field renders only when NEXT_PUBLIC_PR_NUMBER_ENABLED is set. paths: src/app/(auth)/auth/constants/auth-error-codes.ts src/app/(auth)/auth/constants/auth-error-codes.test.ts src/app/(auth)/auth/hooks/use-auth.ts src/app/(auth)/auth/pages/signup-page.tsx Generated-By: PostHog Desktop Task-Id: 35ac829f-6788-4788-ac14-1ff944e9cdbf --- .../auth/constants/auth-error-codes.test.ts | 26 ++++++++ .../(auth)/auth/constants/auth-error-codes.ts | 20 ++++++ src/app/(auth)/auth/hooks/use-auth.ts | 22 ++++++- src/app/(auth)/auth/pages/signup-page.tsx | 65 +++++++++++++++---- 4 files changed, 119 insertions(+), 14 deletions(-) create mode 100644 src/app/(auth)/auth/constants/auth-error-codes.test.ts diff --git a/src/app/(auth)/auth/constants/auth-error-codes.test.ts b/src/app/(auth)/auth/constants/auth-error-codes.test.ts new file mode 100644 index 00000000..fa6d3973 --- /dev/null +++ b/src/app/(auth)/auth/constants/auth-error-codes.test.ts @@ -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(); + }); +}); diff --git a/src/app/(auth)/auth/constants/auth-error-codes.ts b/src/app/(auth)/auth/constants/auth-error-codes.ts index fff76a69..1699ae0e 100644 --- a/src/app/(auth)/auth/constants/auth-error-codes.ts +++ b/src/app/(auth)/auth/constants/auth-error-codes.ts @@ -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--* + // 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; +} diff --git a/src/app/(auth)/auth/hooks/use-auth.ts b/src/app/(auth)/auth/hooks/use-auth.ts index 9d38ff3e..5b9cf69d 100644 --- a/src/app/(auth)/auth/hooks/use-auth.ts +++ b/src/app/(auth)/auth/hooks/use-auth.ts @@ -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'; @@ -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; @@ -159,7 +168,7 @@ export function useAuth() { } }; - const registerOrganization = async (data: RegisterRequest) => { + const registerOrganization = async (data: RegisterRequest): Promise => { setIsLoading(true); try { @@ -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'; @@ -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); } diff --git a/src/app/(auth)/auth/pages/signup-page.tsx b/src/app/(auth)/auth/pages/signup-page.tsx index c6401af9..401ec67b 100644 --- a/src/app/(auth)/auth/pages/signup-page.tsx +++ b/src/app/(auth)/auth/pages/signup-page.tsx @@ -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'; @@ -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. @@ -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(undefined); // "Continue with Apple" is offered on Apple devices only. const isApple = useIsApplePlatform(); @@ -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, @@ -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. @@ -145,15 +171,30 @@ export default function SignupPage() { }} > {showPrNumber && ( - setPrNumber(event.target.value.replace(/\D/g, ''))} - /> +
+ { + setPrNumber(event.target.value.replace(/\D/g, '')); + setPrNamespaceNotice(undefined); + }} + /> + {prNamespaceNotice && ( + + )} +
)}