Skip to content

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.tsx
  • src/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:

  1. Renders<Auth /> mounts and renders the heading
  2. Default tab — Sign In tab is active by default
  3. Tab switching — clicking Sign Up changes the tab and updates ?tab=signup
  4. URL-driven tab/auth?tab=signup mounts with Sign Up active
  5. Sign In submit — mocked useSignIn().signIn.create() resolves with status: 'complete', navigates to /app
  6. Sign In error — mocked rejection shows err.errors[0].longMessage inline
  7. Sign Up flow — submit transitions to verify panel
  8. Verify code — submit with code calls attemptEmailAddressVerification
  9. Google button — click triggers authenticateWithRedirect with strategy: 'oauth_google'
  10. Voice rule check — scan rendered DOM for forbidden substrings, fail if any present

Playwright (e2e)

e2e/auth.spec.ts:

  1. /sign-in redirects to /auth?tab=signin
  2. /sign-up redirects to /auth?tab=signup
  3. The page renders inside StorefrontLayout (Navigation + Footer present)
  4. Tab switch updates the URL query param without a page reload
  5. Mobile (375px) — card centered, footer stacks
  6. Voice rule scan on rendered page text — same forbidden list as the vitest test, second line of defense

Manual

  1. Sign in with a real Clerk email account → /app
  2. Sign up with a fresh email → verification code → /app
  3. Google OAuth (in staging Clerk org) → /sso-callback/app
  4. Bad password → inline error
  5. Wrong code → inline error, can re-enter
  6. Mobile Safari iOS — keyboard doesn't cover the submit button
  7. Tab through every focusable element with the keyboard — focus ring visible everywhere

5. Implementation Checklist

  • [ ] Branch feat/auth-pages-redesign off main
  • [ ] Create src/pages/Auth.tsx with the form-state machine
  • [ ] Create src/pages/SsoCallback.tsx
  • [ ] Wire <EmailField> and <PasswordField> inline (shadcn Input + 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_HEADLESS flag check; fall back to prebuilt <SignIn> / <SignUp> when off
  • [ ] Update App.tsx routes (4 changes)
  • [ ] Update Navigation.tsx button 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.yaml forbidden 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