Auth Pages Redesign — Feature Spec¶
Date: 2026-04-09
Status: Implemented — frontend PR #21 merged
Repo: curaway-health-navigator (frontend)
Companion steer: ai-steer/auth-pages-redesign-steer.md
1. Summary¶
Replace SignInPage.tsx and SignUpPage.tsx (split-screen wrappers
around Clerk's prebuilt <SignIn> / <SignUp> components) with a
single Auth.tsx page mounted at /auth. The new page is wrapped
in StorefrontLayout, uses Clerk's headless hooks to drive the
form, and matches the SD-provided screenshot exactly.
2. File-by-File Changes¶
2.1 New files¶
src/pages/Auth.tsx (new — ~280 LOC)¶
The single auth page. Reads ?tab=signin|signup from URL, defaults
to signin. Uses useSignIn, useSignUp, useClerk, and
useUser hooks. Three internal states:
'form'— email/password + Google button (Sign In or Sign Up tab)'verify'— 6-digit code input or "check your email" magic link state'success'— brief "Signing you in..." flash before navigating
// Pseudo-structure
export default function Auth() {
const [searchParams, setSearchParams] = useSearchParams();
const tab = searchParams.get('tab') === 'signup' ? 'signup' : 'signin';
const [phase, setPhase] = useState<'form' | 'verify' | 'success'>('form');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [code, setCode] = useState('');
const [err, setErr] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const { signIn, isLoaded: signInLoaded } = useSignIn();
const { signUp, isLoaded: signUpLoaded, setActive } = useSignUp();
const navigate = useNavigate();
// ── Google OAuth ──
const handleGoogle = async () => {
if (tab === 'signin' && signInLoaded) {
await signIn.authenticateWithRedirect({
strategy: 'oauth_google',
redirectUrl: '/sso-callback',
redirectUrlComplete: '/app',
});
} else if (signUpLoaded) {
await signUp.authenticateWithRedirect({
strategy: 'oauth_google',
redirectUrl: '/sso-callback',
redirectUrlComplete: '/app',
});
}
};
// ── Email/password Sign In ──
const handleSignIn = async (e: FormEvent) => {
e.preventDefault();
if (!signInLoaded) return;
setBusy(true); setErr(null);
try {
const result = await signIn.create({ identifier: email, password });
if (result.status === 'complete') {
await setActive({ session: result.createdSessionId });
navigate('/app');
}
} catch (e: any) {
setErr(e.errors?.[0]?.longMessage ?? 'Could not sign in.');
} finally {
setBusy(false);
}
};
// ── Email/password Sign Up + verification ──
const handleSignUp = async (e: FormEvent) => {
e.preventDefault();
if (!signUpLoaded) return;
setBusy(true); setErr(null);
try {
await signUp.create({ emailAddress: email, password });
await signUp.prepareEmailAddressVerification({ strategy: 'email_code' });
setPhase('verify');
} catch (e: any) {
setErr(e.errors?.[0]?.longMessage ?? 'Could not create account.');
} finally {
setBusy(false);
}
};
const handleVerify = async (e: FormEvent) => {
e.preventDefault();
if (!signUpLoaded) return;
setBusy(true); setErr(null);
try {
const result = await signUp.attemptEmailAddressVerification({ code });
if (result.status === 'complete') {
await setActive({ session: result.createdSessionId });
navigate('/app');
}
} catch (e: any) {
setErr(e.errors?.[0]?.longMessage ?? 'Invalid verification code.');
} finally {
setBusy(false);
}
};
return (
<StorefrontLayout>
<div className="min-h-[calc(100vh-200px)] flex flex-col items-center justify-center px-4 py-12">
<h1 className="text-4xl font-bold text-deep-ocean text-center mb-3">
Start Your Healthcare Journey
</h1>
<p className="text-gray-600 text-center mb-10 max-w-md">
Get personalized treatment recommendations and connect with quality hospitals.
</p>
<Card className="w-full max-w-[420px] shadow-lg border-gray-100">
<CardContent className="p-8">
<h2 className="text-2xl font-bold text-deep-ocean text-center">
Welcome to Curaway
</h2>
<p className="text-sm text-gray-500 text-center mb-6 mt-2">
Sign in or create an account to get your free consultation
</p>
{phase === 'form' && (
<>
<Button onClick={handleGoogle} variant="outline" className="w-full mb-6">
<GoogleIcon className="w-4 h-4 mr-2" />
Continue with Google
</Button>
<div className="flex items-center gap-3 mb-6">
<div className="flex-1 h-px bg-gray-200" />
<span className="text-xs text-gray-400 uppercase tracking-wide">
Or continue with email
</span>
<div className="flex-1 h-px bg-gray-200" />
</div>
<Tabs value={tab} onValueChange={(v) => setSearchParams({ tab: v })}>
<TabsList className="grid grid-cols-2 mb-6">
<TabsTrigger value="signin">Sign In</TabsTrigger>
<TabsTrigger value="signup">Sign Up</TabsTrigger>
</TabsList>
<TabsContent value="signin">
<form onSubmit={handleSignIn} className="space-y-4">
<EmailField value={email} onChange={setEmail} error={err} />
<PasswordField value={password} onChange={setPassword} />
{err && <p className="text-sm text-red-600">{err}</p>}
<Button type="submit" disabled={busy} className="w-full bg-teal hover:bg-deep-ocean">
{busy ? <Spinner /> : <>Sign In <ArrowRight className="ml-2 w-4 h-4" /></>}
</Button>
</form>
</TabsContent>
<TabsContent value="signup">
<form onSubmit={handleSignUp} className="space-y-4">
<EmailField value={email} onChange={setEmail} error={err} />
<PasswordField value={password} onChange={setPassword} />
{err && <p className="text-sm text-red-600">{err}</p>}
<Button type="submit" disabled={busy} className="w-full bg-teal hover:bg-deep-ocean">
{busy ? <Spinner /> : <>Create Account <ArrowRight className="ml-2 w-4 h-4" /></>}
</Button>
</form>
</TabsContent>
</Tabs>
<p className="text-xs text-gray-400 text-center mt-6">
By continuing, you agree to our{' '}
<Link to="/legal/terms" className="underline">Terms of Service</Link>
{' '}and{' '}
<Link to="/legal/privacy" className="underline">Privacy Policy</Link>
</p>
</>
)}
{phase === 'verify' && (
<VerifyPanel
email={email}
code={code}
onCodeChange={setCode}
onSubmit={handleVerify}
onResend={() => signUp?.prepareEmailAddressVerification({ strategy: 'email_code' })}
busy={busy}
error={err}
/>
)}
</CardContent>
</Card>
</div>
</StorefrontLayout>
);
}
EmailField / PasswordField / VerifyPanel are small inline components in the same file (not separate files — keep it cohesive per the CLAUDE.md "minimum complexity" rule).
src/pages/SsoCallback.tsx (new — ~15 LOC)¶
import { AuthenticateWithRedirectCallback } from '@clerk/clerk-react';
export default function SsoCallback() {
return <AuthenticateWithRedirectCallback signInForceRedirectUrl="/app" />;
}
Mounted at /sso-callback. Clerk redirects to this URL after Google
OAuth completes; the callback component finishes the session and
routes to /app.
src/pages/__tests__/Auth.test.tsx (new — ~80 LOC)¶
Vitest tests:
1. Renders the Sign In tab by default
2. Switches to Sign Up tab on click
3. Reads ?tab=signup from URL on mount
4. Shows verification panel after Sign Up submit (mocked Clerk hook)
5. Renders Google button
6. Voice rule check: asserts none of these substrings appear in
the rendered DOM: world-class, peace of mind, perfect match,
we'll find you, take the complexity out, in good hands,
rest assured, stress-free. (Mirrors a subset of
voice_rules.yaml.)
2.2 Modified files¶
src/App.tsx¶
Routes update:
// Before
<Route path="/sign-in" element={<SignInPage />} />
<Route path="/sign-up" element={<SignUpPage />} />
// After
<Route path="/auth" element={<Auth />} />
<Route path="/sso-callback" element={<SsoCallback />} />
<Route path="/sign-in" element={<Navigate to="/auth?tab=signin" replace />} />
<Route path="/sign-up" element={<Navigate to="/auth?tab=signup" replace />} />
Navigate from react-router-dom. The two redirects keep any
existing inbound links (Clerk emails, marketing site, browser
bookmarks, deep links from app.curaway.ai) working.
src/components/Navigation.tsx¶
The "Sign In" / "Get Started" buttons in the storefront header now
link to /auth and /auth?tab=signup respectively. (One-line each.)
src/lib/clerk.ts (if exists) or wherever <ClerkProvider> is mounted¶
Update signInUrl and signUpUrl props to /auth?tab=signin and
/auth?tab=signup. Update signInFallbackRedirectUrl and
signUpFallbackRedirectUrl to /app.
2.3 Deleted files¶
src/pages/SignInPage.tsxsrc/pages/SignUpPage.tsx
(The redirect routes preserve the URL surface; the components themselves are removed.)
3. Public Surface¶
Routes¶
| Route | Component | Behavior |
|---|---|---|
/auth |
Auth |
Default tab: Sign In |
/auth?tab=signin |
Auth |
Sign In tab |
/auth?tab=signup |
Auth |
Sign Up tab |
/sso-callback |
SsoCallback |
Clerk OAuth redirect target |
/sign-in |
redirect | → /auth?tab=signin |
/sign-up |
redirect | → /auth?tab=signup |
Feature flag¶
VITE_AUTH_HEADLESS (Vercel env, default true)
When false, Auth.tsx renders Clerk's prebuilt <SignIn> /
<SignUp> inside the same StorefrontLayout instead of the
headless form. Instant rollback without a code redeploy.
4. Test Plan¶
Vitest (component)¶
src/pages/__tests__/Auth.test.tsx:
- Renders —
<Auth />mounts and renders the heading - Default tab — Sign In tab is active by default
- Tab switching — clicking Sign Up changes the tab and updates
?tab=signup - URL-driven tab —
/auth?tab=signupmounts with Sign Up active - Sign In submit — mocked
useSignIn().signIn.create()resolves withstatus: 'complete', navigates to/app - Sign In error — mocked rejection shows
err.errors[0].longMessageinline - Sign Up flow — submit transitions to verify panel
- Verify code — submit with code calls
attemptEmailAddressVerification - Google button — click triggers
authenticateWithRedirectwithstrategy: 'oauth_google' - Voice rule check — scan rendered DOM for forbidden substrings, fail if any present
Playwright (e2e)¶
e2e/auth.spec.ts:
/sign-inredirects to/auth?tab=signin/sign-upredirects to/auth?tab=signup- The page renders inside
StorefrontLayout(Navigation + Footer present) - Tab switch updates the URL query param without a page reload
- Mobile (375px) — card centered, footer stacks
- Voice rule scan on rendered page text — same forbidden list as the vitest test, second line of defense
Manual¶
- Sign in with a real Clerk email account →
/app - Sign up with a fresh email → verification code →
/app - Google OAuth (in staging Clerk org) →
/sso-callback→/app - Bad password → inline error
- Wrong code → inline error, can re-enter
- Mobile Safari iOS — keyboard doesn't cover the submit button
- Tab through every focusable element with the keyboard — focus ring visible everywhere
5. Implementation Checklist¶
- [ ] Branch
feat/auth-pages-redesignoffmain - [ ] Create
src/pages/Auth.tsxwith the form-state machine - [ ] Create
src/pages/SsoCallback.tsx - [ ] Wire
<EmailField>and<PasswordField>inline (shadcnInput+Label+ error styling) - [ ] Wire
<VerifyPanel>inline (6-digit input, resend link, error) - [ ] Wire Google OAuth via
authenticateWithRedirect - [ ] Wire email/password Sign In via
signIn.create - [ ] Wire email/password Sign Up + verification via
signUp.create+prepareEmailAddressVerification+attemptEmailAddressVerification - [ ] Add
VITE_AUTH_HEADLESSflag check; fall back to prebuilt<SignIn>/<SignUp>when off - [ ] Update
App.tsxroutes (4 changes) - [ ] Update
Navigation.tsxbutton hrefs - [ ] Update
<ClerkProvider>props (signInUrl,signUpUrl, fallback URLs) - [ ] Delete
SignInPage.tsx,SignUpPage.tsx - [ ] Write
Auth.test.tsx(10 vitest tests) - [ ] Write
e2e/auth.spec.ts(6 playwright tests) - [ ] Run
npm run lint && npm run typecheck && npm run test - [ ] Manual QA against the screenshot at
/auth,/auth?tab=signup, mobile 375px - [ ] Manual voice scan against
voice_rules.yamlforbidden phrases - [ ] Commit, push, open PR with screenshot vs after side-by-side
- [ ] Vercel preview deploy → SD review → merge
6. Out of Scope (deferred)¶
- Forgot-password flow (separate PR —
useSignIn().create({ strategy: 'reset_password_email_code' })) - Apple / Microsoft / Facebook social providers
- Magic-link-only sign-in (no password)
- MFA challenge UI
- Tenant selection on sign-up
- Backend changes (none required)
7. References¶
- Steer:
ai-steer/auth-pages-redesign-steer.md - Clerk headless overview: https://clerk.com/docs/custom-flows/overview
- Clerk
useSignIn: https://clerk.com/docs/references/react/use-sign-in - Clerk
useSignUp: https://clerk.com/docs/references/react/use-sign-up - Curaway brand voice rules:
config/voice_rules.yaml(backend repo) - Reference screenshot: SD-provided 2026-04-09