The existing authentication system is spread across 3 monolithic pages (login: 557 lines, register: 914 lines) with 3 redundant Zustand stores, 0% test coverage, a known passkey early-return bug, 220 lines of copy-pasted code between login and register, and a completely unused auth-flow-store (186 lines) that was designed as a proper state machine but was never wired up.
- State management is the root problem, not UI layout. Untangling 3 stores from 1,471 lines of inline state is harder than writing clean code from scratch.
- auth-flow-store already exists and is well-designed — rebuilds would actually use it.
- 0% test coverage means no regression harness to protect during refactoring.
- Fixing the passkey early-return bug alone requires touching every flow; might as well do it in clean code.
- 220 deduplicatable lines is only 15% savings; the remaining 85% is tangled conditional rendering.
- Rebuild eliminates 1,471 lines of auth page code into approximately 230 lines across 2 pages.
- Effort: 2 days either way (rebuild vs refactor). Rebuild produces cleaner output with less risk.
- login/page.tsx: ~80 lines (3 steps orchestrated by components)
- register/page.tsx: ~150 lines (4 steps orchestrated by components)
- 12 reusable components in src/components/auth/
- auth-flow-store properly wired as the single source of truth
- 100% test coverage on all auth flows (unit + integration + e2e)
- Email verification before passkey creation
- Wallet connection working for all adapter types (Freighter, Rabet, xBull, Albedo, WalletConnect, Ledger, Passkey)
- Playwright e2e tests running against live https://moistello.com
src/app/(auth)/
├── layout.tsx # Shared layout (redirect if authenticated)
├── login/
│ └── page.tsx # ~80 lines — orchestrates 3 steps
├── register/
│ └── page.tsx # ~150 lines — orchestrates 4 steps
├── verify-email/
│ └── page.tsx # Standalone email verification page
└── passkey-setup/
└── page.tsx # Passkey creation after email verified
src/components/auth/
├── index.ts # Barrel export
├── auth-layout.tsx # Shared card wrapper, logo, footer, nav links
├── auth-step-indicator.tsx # Step progress bar (already exists, will reuse)
├── auth-error-boundary.tsx # Error boundary (already exists)
├── auth-flow-context.tsx # React context wrapper around auth-flow-store
│
├── choose-wallet-step.tsx # Step 1 for login + register — wallet drawer
│ ├── components/
│ │ ├── wallet-grid.tsx # Reusable wallet button grid
│ │ ├── wallet-card.tsx # Individual wallet button (already exists: auth-wallet-card.tsx)
│ │ ├── passkey-shortcut.tsx # Quick passkey login button
│ │ └── wc2-connection-state.tsx # QR code / deep link for WalletConnect (already exists: auth-connection-state.tsx)
│ └── hooks/
│ └── use-wallet-connection.ts # Encapsulates connect/disconnect/WC2 logic
│
├── verify-email-step.tsx # Step 1 for register passkey path — email input + verification code
│ ├── components/
│ │ ├── email-input.tsx # Email field with validation
│ │ └── verification-code-input.tsx # 6-digit code input
│ └── hooks/
│ └── use-email-verification.ts # API calls for sending + verifying code
│
├── profile-step.tsx # Step 2 (register) or step 1 (login alternative) — display name, email, country, language
│ └── hooks/
│ └── use-profile-form.ts # Validation + field error tracking
│
├── sign-step.tsx # Step 3 for register / step 2 for login — review, sign message, submit
│ └── hooks/
│ └── use-sign-message.ts # Nonce fetch, sign, submit to /auth/verify or /auth/register
│
├── connected-badge.tsx # Small "Connected ✓" badge with wallet name + shortened address
├── error-display.tsx # Consistent error display with retry
└── loading-overlay.tsx # Full-page loading state
src/hooks/
├── use-auth-flow.ts # Existing hook (will be rewritten to use auth-flow-store)
└── use-redirect-if-authenticated.ts # Redirect to dashboard if already logged in
auth-flow-store.ts ←── THE ONLY store used by auth pages
│
├── mode: "login" | "register"
├── status: AuthFlowStatus (discriminated union)
├── connection: { walletId, address, pairingUri, protocol, relayStatus }
├── profile: { displayName, email, countryCode, language, fieldErrors }
├── auth: { nonce, signature }
├── emailVerification: { email, codeSent, codeVerified, verificationId }
│
├── Actions:
│ ├── setMode, reset
│ ├── connectStart, connectSuccess, awaitingApproval, connected
│ ├── signStart, signSuccess, authenticated
│ ├── updateProfileField, setFieldError
│ ├── setPairingUri, setRelayStatus
│ ├── setEmail, setCodeSent, setCodeVerified
│ └── setError, clearError
└── Middleware:
├── devtools (Redux DevTools integration)
└── persist (sessionStorage for recovery on refresh)
multi-wallet-store and auth-store continue to exist for non-auth pages (dashboard, wallet, etc.), but auth pages interact ONLY with auth-flow-store. A sync layer in auth-flow-context.tsx bridges auth-flow-store → multi-wallet-store when wallet connections succeed.
User Action → Component → auth-flow-store Action → API Call / Wallet Adapter
│ │
│ ▼
│ Response / Event
│ │
▼ ▼
Re-render ←──── auth-flow-store State Update
Authentication flow:
Component calls auth-flow-store.connect(walletId)
→ auth-flow-store sets status: "connecting"
→ multi-wallet-store.connect(walletId) is called internally
→ Wallet adapter scans / opens QR / requests approval
→ On success: auth-flow-store sets status: "connected", saves address
→ On error: auth-flow-store sets status: "error", saves error message
→ Component re-renders based on new status
Step 1: User clicks "Passkey" on register page
Step 2: Email input screen appears
┌─────────────────────────────────────┐
│ │
│ ◀ Back to wallet options │
│ │
│ 🔒 [fingerprint icon] │
│ │
│ Create Your Account │
│ Enter your email to get started │
│ │
│ ┌─────────────────────────────┐ │
│ │ Email address [✉] │ │
│ └─────────────────────────────┘ │
│ │
│ [ Confirm Email ] │
│ │
│ ┌─────────────────────────────┐ │
│ │ Already have an account? │ │
│ │ Sign in │ │
│ └─────────────────────────────┘ │
└─────────────────────────────────────┘
Step 3: User enters email, clicks "Confirm Email"
→ POST /auth/verification/send { email }
→ Server sends 6-digit code to email
→ Input morphs to show code entry
Step 4: Verification code input appears
┌─────────────────────────────────────┐
│ │
│ ◀ Back to email │
│ │
│ Check your inbox │
│ We sent a code to user@email.com │
│ │
│ ┌─┐ ┌─┐ ┌─┐ ┌─┐ ┌─┐ ┌─┐ │
│ │ │ │ │ │ │ │ │ │ │ │ │ │
│ └─┘ └─┘ └─┘ └─┘ └─┘ └─┘ │
│ │
│ [ Verify Code ] │
│ │
│ Didn't receive it? [Resend] │
│ │
└─────────────────────────────────────┘
Step 5: Code verified → proceed to passkey creation
→ WebAuthn navigator.credentials.create()
→ Browser prompts for biometric / PIN
→ On success: derive Stellar keypair from passkey
→ Save public key in auth-flow-store
→ Proceed to profile step
| Method | Path | Request | Response | Error Codes |
|---|---|---|---|---|
| POST | /auth/verification/send | { email } |
{ verificationId, expiresIn: 600 } |
400 (invalid email), 429 (rate limited) |
| POST | /auth/verification/verify | { verificationId, code } |
{ verified: true } |
400 (wrong code), 410 (expired) |
| POST | /auth/verification/resend | { verificationId } |
{ expiresIn: 600 } |
429 (rate limited), 404 (not found) |
- Max 3 code sends per email per 10 minutes
- Max 5 verification attempts per verificationId
- Code expires after 10 minutes
- Cooldown of 60 seconds between resends
email + deviceCredentialID → PBKDF2 → seed → Ed25519 keypair
│ │
│ ▼
│ Stellar public key
│ │
▼ ▼
Stored in Stored in
localStorage auth-flow-store
(for recovery) (for current session)
Derivation algorithm:
// pseudocode
async function deriveStellarKeypair(email: string, credentialId: string): Promise<Keypair> {
const encoder = new TextEncoder()
const keyMaterial = await crypto.subtle.importKey(
"raw",
encoder.encode(email),
"PBKDF2",
false,
["deriveBits", "deriveKey"]
)
const salt = encoder.encode(credentialId.slice(0, 16))
const derivedBits = await crypto.subtle.deriveBits(
{ name: "PBKDF2", salt, iterations: 100000, hash: "SHA-256" },
keyMaterial,
256
)
return Keypair.fromRawEd25519Seed(new Uint8Array(derivedBits))
}| # | Issue | Location | Root Cause | Fix |
|---|---|---|---|---|
| 1 | Passkey early return | login/page.tsx:151-152 | if (walletId === "passkey") { return } exits without connecting |
Remove early return; passkey flows through handlePasskeyConnect instead |
| 2 | WC2 state race condition | login/page.tsx:172 | setWc2PairingState("approved") called before user actually approves |
Wait for WC2 session event session_update before setting state |
| 3 | Missing loading states | register/page.tsx | No visual feedback during nonce fetch or signing | Use auth-flow-store status for precise loading states |
| 4 | Inconsistent error handling | Both pages | signError vs submitError vs loginError vs registerError all tracked separately |
Single auth-flow-store.error for all auth errors |
| 5 | No retry for WC2 timeout | Both pages | No timeout handler; QR sits forever | Add 120s QR expiry countdown with auto-retry button |
| 6 | Hardcoded testnet Horizon URL | multi-wallet-store.ts:369 | horizon-testnet.stellar.org hardcoded |
Read network from env or wallet adapter |
| 7 | Dead network mismatch detection | Both pages | Network mismatch creates silent failures | Add explicit network check before connection |
handleSelectWallet(walletId)
│
├── walletId === "passkey"
│ ├── login: call handlePasskeyConnect (skip email, use stored passkey)
│ └── register: setStep("verify-email") → after verification → call handlePasskeyCreate
│
├── wallet.category === "hardware" (Ledger)
│ └── setShowLedgerPrompt(true) — no change needed
│
├── walletId === "walletconnect"
│ ├── setIsWc2Active(true)
│ ├── Dynamically import WC2 adapter
│ ├── Register pairing URI callback BEFORE calling connect
│ ├── Call connect("walletconnect")
│ ├── WAIT for session_update event (NOT set state immediately)
│ ├── On session_update → setWc2PairingState("approved")
│ ├── On 120s timeout → setWc2PairingState("timeout") + show retry button
│ └── On user reject → setWc2PairingState("rejected")
│
└── wallet.category === "extension" (Freighter, Rabet, xBull, Albedo)
├── Call connect(walletId)
├── On success → auto-advance to sign step
├── On error → show error, enable retry
└── POST-connect: verify network matches expected
Problem: Original code at login/page.tsx:172 calls setWc2PairingState("approved") immediately after connect("walletconnect") without waiting for the actual wallet approval.
Fix:
// Before calling connect, register a one-time session listener
const sessionHandler = (session: SessionTypes.Struct) => {
setWc2PairingState("approved")
setWc2PairingUri(null) // QR no longer needed
// Remove listener after first event
web3wallet?.core?.pairing?.events?.off("pairing:approve", sessionHandler)
}
// Register listener BEFORE connect
web3wallet?.on("session_proposal", sessionHandler) // or session_approve
// Then call connect
await connect("walletconnect")
// Set timeout
const timeout = setTimeout(() => {
setWc2PairingState("timeout")
sessionHandler = null
}, 120000)// Keep existing detection but extract to a utility
export function isMobileDevice(): boolean {
if (typeof navigator === "undefined") return false
return /Android|iPhone|iPad|iPod/i.test(navigator.userAgent)
}
export function isDesktopDevice(): boolean {
return !isMobileDevice()
}"use client"
import { useAuthFlow } from "@/components/auth/auth-flow-context"
import { AuthLayout } from "@/components/auth/auth-layout"
import { ChooseWalletStep } from "@/components/auth/choose-wallet-step"
import { SignStep } from "@/components/auth/sign-step"
import { LoadingOverlay } from "@/components/auth/loading-overlay"
import { useRedirectIfAuthenticated } from "@/hooks/use-redirect-if-authenticated"
export default function LoginPage() {
const { mode, status, startLoginFlow } = useAuthFlow()
useRedirectIfAuthenticated()
// Initialize on mount
useEffect(() => {
mode !== "login" && startLoginFlow()
}, [mode, startLoginFlow])
return (
<AuthLayout title="Moistello">
{status.status === "connecting" || status.status === "signing" ? (
<LoadingOverlay message="Signing you in..." />
) : status.status === "connected" ? (
<SignStep mode="login" />
) : status.status === "authenticated" ? (
<RedirectingToDashboard />
) : (
<ChooseWalletStep mode="login" />
)}
</AuthLayout>
)
}"use client"
import { useAuthFlow } from "@/components/auth/auth-flow-context"
import { AuthLayout } from "@/components/auth/auth-layout"
import { AuthStepIndicator } from "@/components/auth/auth-step-indicator"
import { ChooseWalletStep } from "@/components/auth/choose-wallet-step"
import { VerifyEmailStep } from "@/components/auth/verify-email-step"
import { ProfileStep } from "@/components/auth/profile-step"
import { SignStep } from "@/components/auth/sign-step"
import { LoadingOverlay } from "@/components/auth/loading-overlay"
import { useRedirectIfAuthenticated } from "@/hooks/use-redirect-if-authenticated"
const STEPS = [
{ num: 1, label: "Choose", key: "choose" },
{ num: 2, label: "Verify", key: "verify-email" },
{ num: 3, label: "Profile", key: "profile" },
{ num: 4, label: "Verify", key: "sign" },
]
export default function RegisterPage() {
const { mode, status, step, startRegisterFlow } = useAuthFlow()
useRedirectIfAuthenticated()
useEffect(() => {
mode !== "register" && startRegisterFlow()
}, [mode, startRegisterFlow])
return (
<AuthLayout title="Moistello">
<AuthStepIndicator steps={STEPS} currentStep={step} />
{step === "choose" && <ChooseWalletStep mode="register" />}
{step === "verify-email" && <VerifyEmailStep />}
{step === "profile" && <ProfileStep />}
{step === "sign" && <SignStep mode="register" />}
{status.status === "authenticated" && <RedirectingToDashboard />}
</AuthLayout>
)
}Purpose: Shared card wrapper used by both login and register pages. Eliminates the 30-line duplicated card markup found in both pages.
Props:
interface AuthLayoutProps {
title: string // Brand title (always "Moistello")
children: ReactNode
footerLinks?: { label: string; href: string; text: string }[]
}States:
- Default: Card centered on screen with gradient/glass background
- The footer links vary:
- Login: "Don't have an account? Create one" + "← Back to home"
- Register: "Already have an account? Sign in" + "← Back to home"
Purpose: Wallet/detection and selection for both login and register.
Props:
interface ChooseWalletStepProps {
mode: "login" | "register"
}Sub-components:
WalletGrid— Grid of detected walletsWalletCard— Individual wallet button (existing, renamed from auth-wallet-card)PasskeyShortcut— Quick passkey button (login only shows if passkey exists)Wc2ConnectionState— QR code / deep link for WalletConnect (existing: auth-connection-state)LedgerPrompt— Ledger connection modal (existing)
Wallet detection order (by priority):
- Passkey (if stored)
- Freighter
- xBull
- Rabet
- Albedo
- WalletConnect
- Ledger
States:
- Scanning:
useMultiWalletStore.isScanning→ show loader - Wallets detected: Show grid with detected/not_detected status
- No wallets: Fallback message "Install a Stellar wallet like Freighter"
- Connecting: Overlay loader on the selected wallet card
- WC2 active: Show QR code or deep link component
- WC2 timeout: Show error with retry button
- Ledger active: Show LedgerPrompt modal
Edge cases:
- User opens page, closes wallet extension without approving → error state
- WC2 QR expires → show retry
- User switches browser tab during WC2 → state preserved
- Extension not installed → greyed out with "Not installed" label
Purpose: Email input + verification code entry for passkey registration.
Sub-states:
type EmailVerificationState =
| { phase: "input" }
| { phase: "sending" }
| { phase: "sent"; verificationId: string; expiresAt: number }
| { phase: "verifying" }
| { phase: "verified" }
| { phase: "error"; message: string; canRetry: boolean }Phase: input
┌─────────────────────────────────────┐
│ │
│ [back button] │
│ │
│ 🔒 [fingerprint icon] │
│ │
│ Create Your Account │
│ Enter your email to get started │
│ │
│ ┌─────────────────────────────┐ │
│ │ [email input field] │ │
│ └─────────────────────────────┘ │
│ [error message if validation] │
│ │
│ [ Confirm Email ] │
│ │
│ Security note about key │
│ derivation and device storage │
└─────────────────────────────────────┘
Phase: sent
┌─────────────────────────────────────┐
│ │
│ [back button] │
│ │
│ Check your inbox │
│ We sent a code to {email} │
│ │
│ ┌─┐ ┌─┐ ┌─┐ ┌─┐ ┌─┐ ┌─┐ │
│ │0│ │0│ │0│ │0│ │0│ │0│ │
│ └─┘ └─┘ └─┘ └─┘ └─┘ └─┘ │
│ [error message if wrong code] │
│ │
│ [ Verify Code ] │
│ │
│ Didn't receive it? [Resend] │
│ Code expires in 8:32 │
└─────────────────────────────────────┘
Phase: verifying
- All inputs disabled
- Button shows spinning loader
- "Verifying code..."
Phase: verified
- Auto-advance to passkey creation
- "Email verified! Creating your passkey..."
- Triggers WebAuthn navigator.credentials.create()
Phase: error
- Show error message
- If canRetry is true: show "Try Again" button
- If expired: return to phase "input" with email pre-filled
Validation rules:
- Email must match /^[^\s@]+@[^\s@]+.[^\s@]+$/
- Max length 254 characters
- 6-digit code must be exactly 6 digits
- Code must be entered within 10 minutes
- Max 3 sends per 10 minutes
Purpose: Display name, email (optional), country, language selection.
Already exists in register page — will extract to component.
Fields:
| Field | Type | Required | Validation | Default |
|---|---|---|---|---|
| displayName | text | Yes | 2-64 chars, trimmed | "" |
| No | valid format if provided | "" | ||
| countryCode | select | No | from allowed list | "" |
| language | select | No | from allowed list | "en" |
States:
- Editing: All fields enabled, validation on blur
- Submitting: Fields disabled, button shows loader
- Error: Field-level errors highlighted, form-level error displayed
Purpose: Review connection, sign nonce, submit to server.
Flow:
- Show connected wallet badge (name + shortened address)
- Show profile summary (if register)
- Fetch nonce from /auth/nonce
- Sign nonce via wallet adapter
- Submit signature + profile to /auth/verify or /auth/register
- On success: store tokens, set authenticated, redirect to dashboard
States:
idle → fetching_nonce → signing → submitting → success (redirect)
│ │ │
│ │ └── error → show error + retry
│ └── error → show error + retry
└── error → show error + retry
Already signed state: If user refreshes at this step, nonce and signature are preserved (stored in auth-flow-store, persisted to sessionStorage) so they don't need to re-sign.
| State | multi-wallet-store | auth-store | auth-flow-store | Conflict? |
|---|---|---|---|---|
| Wallet connection | ✓ | ✗ | ✓ | auth-flow duplicates multi-wallet |
| isConnected | ✓ | ✗ | ✓ | auth-flow duplicates multi-wallet |
| address | ✓ | ✗ | ✓ | auth-flow duplicates multi-wallet |
| JWT token | ✗ | ✓ | ✗ | No conflict |
| isAuthenticated | ✗ | ✓ | ✓ | auth-flow duplicates auth-store |
| user profile | ✗ | ✓ | ✓ | auth-flow duplicates auth-store |
| WC2 state | ✓ | ✗ | ✓ | auth-flow duplicates multi-wallet |
| Passkey state | ✓ | ✗ | ✓ | auth-flow duplicates multi-wallet |
| Error state | ✓ | ✗ | ✓ | auth-flow duplicates multi-wallet |
| Step/flow progress | ✗ | ✗ | ✓ | Only in auth-flow |
auth-flow-store.ts (PAGES LAYER — auth pages ONLY)
│
├── Reads from: multi-wallet-store (login/register read wallet state through here)
├── Writes to: multi-wallet-store (on successful connect, delegates wallet state)
├── Reads from: auth-store (reads isAuthenticated, token)
├── Writes to: auth-store (on successful auth, sets tokens)
└── Owns: step progression, email verification, profile form
multi-wallet-store.ts (WALLET LAYER — wallet management for all pages)
│
├── Owns: wallet connections, WC2 state, Ledger state, passkey state
├── Unchanged from current state
└── Consumed by: auth pages (through bridge), dashboard, wallet page
auth-store.ts (SESSION LAYER — authentication for all pages)
│
├── Owns: JWT tokens, user profile, isAuthenticated, checkAuth, logout
├── Unchanged from current state
└── Consumed by: auth pages (through bridge), dashboard, API client
// This is the GLUE CODE that syncs the 3 stores.
// auth pages call ONLY this context; never access stores directly.
function AuthFlowProvider({ children }) {
// Proxy reads through bridge
const address = useMultiWalletStore(s => s.address)
const isConnected = useMultiWalletStore(s => s.isConnected)
const isAuthenticated = useAuthStore(s => s.isAuthenticated)
const [flowState, flowActions] = useAuthFlowStore()
// Sync actions: when flow says connect, delegate to multi-wallet
const connect = async (walletId: string) => {
flowActions.connectStart(walletId)
try {
await useMultiWalletStore.getState().connect(walletId)
const addr = useMultiWalletStore.getState().address
flowActions.connectSuccess(walletId, addr!)
} catch (err) {
flowActions.setError("connection_rejected", err.message)
}
}
// Sync auth: when flow says sign success, delegate to auth-store
const authenticate = (token: string, refreshToken: string) => {
useAuthStore.getState().setTokens(token, refreshToken)
useAuthStore.setState({ isAuthenticated: true })
flowActions.authenticated()
}
return (
<AuthFlowContext.Provider value={{ ...flowState, ...flowActions, connect, authenticate }}>
{children}
</AuthFlowContext.Provider>
)
} ╱╲
╱ ╲
╱ E2E ╲ 3 Playwright tests (live site)
╱────────╲
╱ ╲
╱ Integration ╲ 8 API + store + component interaction tests
╱────────────────╲
╱ ╲
╱ Unit Tests ╲ 35+ tests for individual components, hooks, utils
╱────────────────────────╲
Directory structure:
src/components/auth/__tests__/
├── auth-layout.test.tsx
├── choose-wallet-step.test.tsx
├── verify-email-step.test.tsx
├── profile-step.test.tsx
├── sign-step.test.tsx
├── wallet-grid.test.tsx
├── wallet-card.test.tsx
├── passkey-shortcut.test.tsx
├── wc2-connection-state.test.tsx
├── connected-badge.test.tsx
├── error-display.test.tsx
├── loading-overlay.test.tsx
src/hooks/__tests__/
├── use-auth-flow.test.ts
├── use-email-verification.test.ts
├── use-redirect-if-authenticated.test.ts
├── use-wallet-connection.test.ts
├── use-sign-message.test.ts
├── use-profile-form.test.ts
src/stores/__tests__/
├── auth-flow-store.test.ts
├── multi-wallet-store.test.ts (existing)
└── auth-store.test.ts
Test categories and count:
| Category | Count | What We Test |
|---|---|---|
| Rendering | 10 | Each component renders correctly in all states |
| User interaction | 8 | Button clicks, input changes, form submits |
| State transitions | 6 | auth-flow-store transitions are valid |
| Edge cases | 6 | Empty states, error states, timeouts, network failures |
| Validation | 5 | Email validation, code validation, display name validation |
Coverage targets per component:
| Component | Unit Tests | Lines | Coverage Target |
|---|---|---|---|
| auth-layout | 2 | ~40 | 100% |
| choose-wallet-step | 5 | ~120 | 95% |
| verify-email-step | 6 | ~180 | 95% |
| profile-step | 4 | ~100 | 95% |
| sign-step | 4 | ~100 | 95% |
| wallet-grid | 3 | ~60 | 100% |
| wallet-card | 3 | ~90 | 100% |
| auth-flow-store | 4 | ~190 | 90% |
| Hooks (6 hooks) | 8 | ~200 total | 85% |
| Test | What It Validates | Key Assertions |
|---|---|---|
| 1. Login with Freighter | Full login flow | 1. Wallet detected → 2. Connected → 3. Nonce fetched → 4. Signed → 5. Token stored → 6. Redirected |
| 2. Register with Freighter | Full register flow | 1. Wallet detected → 2. Connected → 3. Profile filled → 4. Nonce signed → 5. Account created → 6. Redirected |
| 3. Passkey login | Passkey auth flow | 1. Passkey exists → 2. Biometric prompt → 3. Signed → 4. Token stored |
| 4. Passkey registration with email verification | Full passkey reg | 1. Email entered → 2. Code sent → 3. Code verified → 4. Passkey created → 5. Profile → 6. Account created |
| 5. WC2 login | WalletConnect on desktop | 1. QR code displayed → 2. Awaiting approval → 3. Approved → 4. Signed |
| 6. WC2 login on mobile | WalletConnect on mobile | 1. Deep link triggered → 2. Awaiting approval → 3. Approved → 4. Signed |
| 7. Login error handling | All error paths | 1. Connection rejected → 2. Sign rejected → 3. Network mismatch → 4. Timeout → each shows correct error |
| 8. Registration validation | Form validation | 1. Empty display name → 2. Invalid email → 3. Too long name → each shows field error |
Test environment:
- Playwright with Chromium, Firefox, WebKit
- Headless mode for CI, headed mode for debugging
- Run against https://moistello.com (LIVE PRODUCTION)
- Mobile emulation via Playwright's device profiles (iPhone 14, Pixel 7)
- Desktop viewport: 1440×900, 1024×768 (iPad)
- Slow network simulation: 3G throttling for timeout tests
Test configuration:
// playwright.config.ts (e2e/)
import { defineConfig, devices } from "@playwright/test"
export default defineConfig({
testDir: "./tests",
timeout: 60000, // 60s per test (WC2 QR can be slow)
retries: 2,
use: {
baseURL: "https://moistello.com",
screenshot: "only-on-failure",
video: "retain-on-failure",
trace: "retain-on-failure",
},
projects: [
{
name: "chromium-desktop",
use: { ...devices["Desktop Chrome"] },
},
{
name: "firefox-desktop",
use: { ...devices["Desktop Firefox"] },
},
{
name: "webkit-desktop",
use: { ...devices["Desktop Safari"] },
},
{
name: "chromium-mobile",
use: { ...devices["Pixel 7"] },
},
{
name: "safari-mobile",
use: { ...devices["iPhone 14"] },
},
],
webServer: {
command: "npm run build && npm run start", // Only if testing local build
port: 1110,
reuseExistingServer: !process.env.CI,
},
})E2E Test 1: Login Page Loads Correctly (all browsers)
test("login page renders wallet grid on desktop, detects QR on mobile", async ({ page, isMobile }) => {
await page.goto("/login")
// Common: page loaded
await expect(page).toHaveTitle(/Moistello/)
await expect(page.locator("text=Moistello")).toBeVisible()
if (isMobile) {
// Mobile: should see WalletConnect as primary option
await expect(page.locator("text=WalletConnect")).toBeVisible()
await expect(page.locator("text=Mobile wallets")).toBeVisible()
} else {
// Desktop: should see extension wallets
await expect(page.locator("text=Freighter")).toBeVisible()
await expect(page.locator("text=WalletConnect")).toBeVisible()
}
// Navigation links present
await expect(page.locator('a[href="/register"]')).toBeVisible()
await expect(page.locator('a[href="/"]')).toBeVisible()
})E2E Test 2: Register Page Shows Passkey First
test("register page shows passkey as recommended option", async ({ page }) => {
await page.goto("/register")
// Passkey should be prominently displayed
await expect(page.locator("text=Passkey")).toBeVisible()
await expect(page.locator("text=Recommended")).toBeVisible()
await expect(page.locator("text=Email + Biometric")).toBeVisible()
// Wallet grid visible
await expect(page.locator("text=Freighter")).toBeVisible()
// Click passkey should navigate to email step
await page.click("text=Passkey")
await expect(page.locator("text=Create Your Account")).toBeVisible()
await expect(page.locator('input[type="email"]')).toBeVisible()
})E2E Test 3: Email Verification Flow (Full Integration with Backend)
test("email verification sends code, accepts code, creates passkey", async ({ page }) => {
await page.goto("/register")
await page.click("text=Passkey")
// Phase: input
const emailInput = page.locator('input[type="email"]')
await emailInput.fill("test-user@moistello-test.com")
await page.click("text=Confirm Email")
// Phase: sending (brief)
await expect(page.locator("text=Sending verification code...")).toBeVisible({ timeout: 1000 })
// Phase: sent — verification code input appears
await expect(page.locator("text=Check your inbox")).toBeVisible({ timeout: 10000 })
await expect(page.locator("text=Enter the 6-digit code")).toBeVisible()
// Fill verification code (get from API or test DB)
const code = await getVerificationCodeForEmail("test-user@moistello-test.com")
const codeInputs = page.locator('input[inputmode="numeric"]')
for (let i = 0; i < 6; i++) {
await codeInputs.nth(i).fill(code[i])
}
await page.click("text=Verify Code")
// Phase: verified → passkey creation
await expect(page.locator("text=Email verified!")).toBeVisible()
// WebAuthn will fail in headless, but we verify the WebAuthn API is called
// by checking mock or if browser supports it
})E2E Test 4: WalletConnect QR Code Appears on Desktop
test("WC2 QR code renders on desktop when WalletConnect is selected", async ({ page }) => {
test.skip(!!page.context()._options?.isMobile, "Desktop-only test")
await page.goto("/login")
await page.click("text=WalletConnect")
// Should show QR code container
await expect(page.locator("text=Connect with WalletConnect")).toBeVisible()
await expect(page.locator("text=Scan the QR code with your mobile wallet")).toBeVisible()
// QR code image or canvas should be rendered
await expect(page.locator("canvas, img[alt*='QR']")).toBeVisible({ timeout: 15000 })
// Cancel button should work
await page.click("text=Cancel")
await expect(page.locator("text=Welcome back")).toBeVisible()
})E2E Test 5: WalletConnect Deep Link Appears on Mobile
test("WC2 deep link renders on mobile when WalletConnect is selected", async ({ page }) => {
test.skip(!page.context()._options?.isMobile, "Mobile-only test")
await page.goto("/login")
await page.click("text=WalletConnect")
// Should NOT show QR instructions
await expect(page.locator("text=Scan the QR code")).not.toBeVisible()
// Should show deep link instructions
await expect(page.locator("text=Open your wallet app to connect")).toBeVisible()
// Should show deep link button (vessel:// or walletconnect://)
await expect(page.locator('a[href*="walletconnect"], a[href*="vessel"]')).toBeVisible()
})E2E Test 6: NO Wallets Available State
test("shows no-wallets message when no wallets detected", async ({ page }) => {
// Block extension injection via CDP
await page.route("**/webextension/**", (route) => route.abort())
await page.goto("/login")
await expect(page.locator("text=No wallets available")).toBeVisible()
await expect(page.locator("text=Install a Stellar wallet like Freighter")).toBeVisible()
})E2E Test 7: WC2 QR Expiry and Retry
test("WC2 QR expires after timeout and shows retry", async ({ page }) => {
await page.goto("/login")
await page.click("text=WalletConnect")
// Wait for QR
await expect(page.locator("canvas, img[alt*='QR']")).toBeVisible({ timeout: 15000 })
// Fast-forward 130 seconds (QR expires at 120s)
await page.clock.fastForward(130000)
// Should show timeout state
await expect(page.locator("text=Connection expired")).toBeVisible()
await expect(page.locator("text=Retry")).toBeVisible()
// Click retry → should generate new QR
await page.click("text=Retry")
await expect(page.locator("canvas, img[alt*='QR']")).toBeVisible({ timeout: 15000 })
})E2E Test 8: Network Error Handling
test("shows error when API is unreachable during login", async ({ page }) => {
// Block API calls
await page.route("**/api/**", (route) => route.abort("connectionrefused"))
await page.goto("/login")
// If a wallet is detected, clicking it should attempt API call and fail
const walletButton = page.locator("button:has-text('Freighter')")
if (await walletButton.isVisible()) {
await walletButton.click()
// Should eventually show error toast
await expect(page.locator("text=Connection Failed").or(page.locator("text=Authentication failed"))).toBeVisible({ timeout: 15000 })
}
})Directory:
e2e/
├── playwright.config.ts
├── tests/
│ ├── login.spec.ts
│ ├── register.spec.ts
│ ├── passkey.spec.ts
│ ├── walletconnect.spec.ts
│ └── email-verification.spec.ts
├── fixtures/
│ └── test-accounts.ts # Test wallet addresses / emails
├── helpers/
│ ├── api.ts # Helper to call Moistello API
│ ├── email.ts # Helper to read verification codes from test inbox
│ └── wallet.ts # Helper to mock wallet extensions
├── global-setup.ts # Global beforeAll — create test accounts
└── global-teardown.ts # Global afterAll — clean up test accounts
Test accounts fixture:
export const TEST_ACCOUNTS = {
freighter: {
publicKey: "GB...", // Known test account on testnet
secretKey: "S...", // ONLY for test — never commit real keys
},
email: {
address: "auth-test-001@moistello-test.com", // Catch-all test domain
inboxUrl: "https://api.moistello-test.com/inbox/auth-test-001", // Test inbox API
},
passkey: {
// Passkey is device-bound, so we mock WebAuthn API
mockCredentialId: "test-credential-id-001",
},
}CI Configuration:
# .github/workflows/e2e.yml
name: Auth E2E Tests
on: [deployment_status]
jobs:
e2e:
if: github.event.deployment_status.state == 'success'
runs-on: ubuntu-latest
strategy:
matrix:
browser: [chromium, firefox, webkit]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- run: npm ci
- run: npx playwright install --with-deps ${{ matrix.browser }}
- run: npx playwright test --project=${{ matrix.browser }}-desktop
env:
BASE_URL: ${{ github.event.deployment_status.environment_url }}
- run: npx playwright test --project=chromium-mobile
env:
BASE_URL: ${{ github.event.deployment_status.environment_url }}| Step | Task | Output | Dependencies |
|---|---|---|---|
| 1.1 | Refactor auth-flow-store: add email verification fields | Updated store interface | None |
| 1.2 | Create auth-flow-context.tsx with bridge to multi-wallet-store and auth-store | Context + Provider | 1.1 |
| 1.3 | Create auth-layout.tsx | Layout component | None |
| 1.4 | Create wallet-grid.tsx + extract wallet-card from existing code | 2 components | None |
| 1.5 | Create connected-badge.tsx, error-display.tsx, loading-overlay.tsx | 3 utility components | None |
| Step | Task | Output | Dependencies |
|---|---|---|---|
| 2.1 | Create choose-wallet-step.tsx with WalletConnect orchestration | Step component | 1.2, 1.3, 1.4 |
| 2.2 | Create verify-email-step.tsx with email + code UI + API calls | Step component | 1.2 |
| 2.3 | Create profile-step.tsx (extract from register/page.tsx) | Step component | None |
| 2.4 | Create sign-step.tsx with nonce fetch + sign + submit | Step component | 1.2, 1.4 |
| 2.5 | Create use-email-verification.ts, use-sign-message.ts, use-profile-form.ts hooks | 3 hooks | 2.2, 2.4 |
| Step | Task | Output | Dependencies |
|---|---|---|---|
| 3.1 | Rewrite login/page.tsx | ~80 lines | All Phase 1 + 2 |
| 3.2 | Rewrite register/page.tsx | ~150 lines | All Phase 1 + 2 |
| 3.3 | Delete unused/dead code: old inline auth UI paths in stores | Clean codebase | 3.1, 3.2 |
| 3.4 | Manual QA on all flows in dev environment | Sign-off | 3.1, 3.2 |
| Step | Task | Output | Dependencies |
|---|---|---|---|
| 4.1 | Write 35 unit tests for all components and hooks | Test suite | All Phase 2 + 3 |
| 4.2 | Write 8 integration tests for auth flows | Test suite | 4.1 |
| 4.3 | Write 8 Playwright E2E tests | Test suite | 4.2, live site access |
| 4.4 | Set up CI pipeline for E2E on deployment | CI workflow | 4.3 |
| Risk | Probability | Impact | Mitigation |
|---|---|---|---|
| WalletConnect API changes | Low | High | Pin SDK version, integration tests catch breakage |
| WebAuthn API not available (old browser) | Medium | Medium | Fallback to "browser not supported" message, suggest extension wallet |
| Stellar testnet downtime during CI | Low | Medium | Retry logic in tests, fallback to mocks for CI |
| Email delivery delays during E2E | Medium | Medium | Poll with timeout (30s max), use test inbox API |
| Passkey tests in headless browser | High | High | Mock WebAuthn API with page.evaluate() to inject mock credentials |
| WC2 requires real wallet to approve | Very High | High | Cannot fully automate WC2 approval in headless. Test QR renders (visual), test deep link (structural). Pairing approval requires manual step or sidecar wallet simulator. |
| Freighter/Rabet extensions not available in browser automation | High | Medium | Extensions cannot be loaded in CI Playwright. Test detection message rendering, not actual extension connection. |
| Rate limiting on live site during tests | Medium | Medium | Use dedicated test accounts, disable rate limiting for test API key |
| Sleep/await timing flakiness | Medium | Medium | Use waitForSelector, not fixed timeouts; Playwright auto-waits |
Since WC2 cannot be fully automated (requires a real wallet to approve QR scans), the test strategy is:
- Test that QR code canvas renders → visual confirmation that pairing is initiated
- Test that QR expiry triggers timeout state → state machine correctness
- Test that retry button generates new QR → recovery path correctness
- Test that deep link URL is correct on mobile → URL format validation
- Manual approval: document manual test script for QA
For the flows that cannot be automated (WC2 pairing, actual passkey biometric, hardware wallet), this manual script must be followed before deployment:
- Branch deployed to staging environment
- Backend API running on staging
- Test Stellar accounts funded with test XLM
- Open https://staging.moistello.com/login in Chrome with Freighter installed
- Verify Freighter appears in wallet grid as "detected"
- Verify Rabet appears as "not detected" (if not installed)
- Click Freighter — verify Freighter popup opens
- Approve in Freighter — verify advances to "Signing you in..."
- Verify toast "Welcome back!" appears
- Verify redirected to dashboard
- Repeat steps 1-7 on Firefox
- Repeat steps 1-7 on Safari (WebKit)
- Open https://staging.moistello.com/login in Chrome
- Click "Sign in with Passkey"
- Verify browser biometric prompt appears
- Authenticate via fingerprint/face
- Verify signed in and redirected
- Open https://staging.moistello.com/register in Chrome with Freighter
- Verify passkey is shown as "Recommended"
- Click Freighter — verify Freighter popup opens
- Approve — verify advances to Profile step
- Fill display name, email, country, language
- Click "Continue" — verify advances to Verify step
- Review profile summary
- Click "Create Account" — verify Freighter requests signature
- Sign — verify toast "Welcome to Moistello!"
- Verify redirected to dashboard
- Open https://staging.moistello.com/register in Chrome
- Click "Passkey"
- Enter email "test@example.com"
- Click "Confirm Email"
- Check test inbox for verification code
- Enter 6-digit code
- Click "Verify Code"
- Verify browser biometric prompt appears
- Authenticate via biometric
- Verify advances to Profile step with "Connected ✓ Passkey"
- Fill profile details
- Click "Continue"
- Click "Create Account"
- Verify toast "Welcome to Moistello!"
- Verify redirected to dashboard
- Open https://staging.moistello.com/login in Chrome (desktop mode)
- Click "WalletConnect"
- Verify QR code renders
- Verify "Scan the QR code with your mobile wallet" text appears
- Click "Cancel" — verify returns to wallet selection
- Open https://staging.moistello.com/login in Chrome DevTools (mobile mode: Pixel 7)
- Click "WalletConnect"
- Verify QR code does NOT render
- Verify "Open your wallet app to connect" text appears
- Verify deep link URL is present (hidden or as link)
- Open https://staging.moistello.com/login, click Freighter → reject in Freighter
- Verify error message "User rejected the request" appears
- Open https://staging.moistello.com/login, click WalletConnect
- Wait 2 minutes — verify "Connection expired" appears
- Click "Retry" — verify new QR code generates
- Open https://staging.moistello.com/register, click "Create Account" without filling display name
- Verify field error "Display name is required"
- Dashboard still loads after login
- Circles page still loads
- Wallet page still shows balance
- Settings page still loads
- Logout works
- Refresh token still works (close tab, reopen → still logged in)
- login/page.tsx ≤ 100 lines
- register/page.tsx ≤ 200 lines
- All 35 unit tests passing
- All 8 integration tests passing
- All 8 Playwright E2E tests passing on desktop Chrome
- Playwright E2E tests passing on mobile Chrome (emulated)
- Manual QA script fully signed off by developer
- No regressions in dashboard, wallet, circles, settings pages
- Passkey login works end-to-end
- Passkey registration with email verification works end-to-end
- Freighter login works end-to-end
- WC2 QR code renders on desktop
- WC2 deep link triggers on mobile
- All error states display correctly
- Login and register each handle all 8 conditional states without crash
- 95%+ code coverage on auth components
- auth-flow-store used as single source of truth (no direct useState in pages)
- No duplicate code between login and register pages
- WalletConnect adapter refactored to fix race condition
- All UI states render without layout shift
- No console errors during any flow
- Lighthouse performance score ≥ 90 on login and register pages
- Playwright E2E tests passing on Firefox and WebKit
- Passkey tests with actual WebAuthn (not mocked)
- WC2 pairing fully automated with wallet simulator sidecar
- Visual regression tests (screenshot diffing)
- Accessibility score ≥ 95 (axe-core audit)
After the rebuild, the following old code must be deleted to prevent confusion:
-
login/page.tsx— Replace with new 80-line version -
register/page.tsx— Replace with new 150-line version -
stores/auth-flow-store.ts— Keep it (this IS the new store); do NOT delete -
hooks/use-auth-flow.ts— Rewrite to use new context (or delete if context replaces it) -
components/auth/auth-connection-state.tsx— Keep if reused by choose-wallet-step - Old WC2 inline code in login/register — Deleted automatically when pages are replaced
Files that remain untouched:
- multi-wallet-store.ts
- auth-store.ts
- All wallet adapters in lib/wallet/
- All tests in lib/wallet/tests/
// ==== auth-flow-store.ts — SINGLE SOURCE OF TRUTH ====
export type AuthFlowMode = "login" | "register"
export type AuthFlowStatus =
| { status: "idle" }
| { status: "detecting_wallets" }
| { status: "connecting"; walletId: string | null }
| { status: "awaiting_approval"; pairingUri: string | null; protocol: "qr" | "deeplink" | null }
| { status: "connected"; walletId: string; address: string }
| { status: "signing"; address: string }
| { status: "signed"; signature: string; nonce: string }
| { status: "error"; code: AuthErrorCode; message: string; canRetry: boolean }
| { status: "authenticated" }
export type AuthErrorCode =
| "connection_timeout"
| "connection_rejected"
| "relay_down"
| "network_mismatch"
| "auth_server_error"
| "validation_error"
| "email_send_failed"
| "email_code_expired"
| "email_code_invalid"
| "email_rate_limited"
| "internal_error"
export type AuthStep = "choose" | "verify-email" | "profile" | "sign"
interface EmailVerification {
email: string
verificationId: string | null
codeSent: boolean
codeVerified: boolean
expiresAt: number | null
remainingAttempts: number
}
interface AuthFlowState {
mode: AuthFlowMode
step: AuthStep
status: AuthFlowStatus
error: { code: AuthErrorCode | null; message: string | null } | null
connection: {
walletId: string | null
address: string | null
pairingUri: string | null
protocol: "qr" | "deeplink" | null
relayStatus: "healthy" | "degraded" | "down"
}
profile: {
displayName: string
email: string
countryCode: string
language: string
fieldErrors: Record<string, string>
}
auth: {
nonce: string | null
signature: string | null
}
emailVerification: EmailVerification
}
interface AuthFlowActions {
// Lifecycle
startLoginFlow: () => void
startRegisterFlow: () => void
reset: () => void
// Step navigation
setStep: (step: AuthStep) => void
goBack: () => void
// Error
setError: (code: AuthErrorCode, message: string) => void
clearError: () => void
// Wallet connection
connect: (walletId: string) => Promise<void>
connectStart: (walletId: string) => void
connectSuccess: (walletId: string, address: string) => void
awaitingApproval: (pairingUri: string, protocol: "qr" | "deeplink") => void
onConnectionTimeout: () => void
onConnectionRejected: () => void
setPairingUri: (uri: string | null) => void
setRelayStatus: (status: "healthy" | "degraded" | "down") => void
// Email verification
sendVerificationCode: (email: string) => Promise<void>
verifyCode: (code: string) => Promise<void>
resendCode: () => Promise<void>
clearEmailVerification: () => void
// Profile
updateProfileField: (field: keyof AuthFlowState["profile"], value: string) => void
setFieldError: (field: string, error: string | null) => void
validateProfile: () => boolean
// Signing
signAndSubmit: () => Promise<void>
signStart: (address: string) => void
signSuccess: (signature: string, nonce: string) => void
authenticated: () => void
// State queries
isEmailVerified: () => boolean
isWalletConnected: () => boolean
canProceed: () => boolean
currentStepIndex: () => number
totalSteps: () => number
}
type AuthFlowStore = AuthFlowState & AuthFlowActions// auth-flow-store uses sessionStorage persistence so that:
// 1. If user refreshes during sign step, nonce/signature are preserved (no re-sign)
// 2. If user refreshes during email verification, code state is preserved
// 3. If user navigates away and back, wallet connection state is preserved
// 4. Storage is cleared on logout or successful auth
export const useAuthFlowStore = create<AuthFlowStore>()(
persist(
devtools(
(set, get) => ({
// ... state and actions as defined in section 14
}),
{ name: "auth-flow-store" }
),
{
name: "moistello-auth-flow",
storage: createJSONStorage(() => sessionStorage),
partialize: (state) => ({
// Only persist what's needed for recovery
step: state.step,
emailVerification: state.emailVerification,
auth: state.auth,
profile: state.profile,
}),
}
)
)Derived from the 30 common agent mistakes identified during the deep audit.
| # | Rule | Violates Mistake |
|---|---|---|
| 1 | NEVER access useMultiWalletStore.getState() or useAuthStore.getState() directly in auth pages. Go through useAuthFlow() context only. |
2 |
| 2 | NEVER add a new useState in login/page.tsx or register/page.tsx. All state belongs in auth-flow-store. |
2 |
| 3 | NEVER leave a discriminated union variant unhandled. Every AuthFlowStatus variant must have an explicit render path. |
3 |
| 4 | NEVER forget to clear auth-flow-store on logout. Always call reset() before auth-store.logout(). |
4 |
| 5 | NEVER use localStorage for auth-flow persistence. Use sessionStorage only. |
5 |
| # | Rule | Violates Mistake |
|---|---|---|
| 6 | NEVER call setWc2PairingState("approved") before the session_proposal event fires. The fix is mandatory. |
6 |
| 7 | NEVER import WalletConnect adapter at module scope. Use dynamic import() inside the handler only. |
7 |
| 8 | NEVER assume only extension, hardware, walletconnect, and passkey categories exist. Default unknown categories to graceful fallback. |
8 |
| 9 | NEVER leave a WC2 session alive when user navigates back. Call disconnectWc() and resetWcState() before changing steps. |
9 |
| 10 | NEVER replace the existing QR canvas component without verifying it renders at non-zero dimensions. | 10 |
| # | Rule | Violates Mistake |
|---|---|---|
| 11 | NEVER use a single text input for verification codes. Always use 6 individual digit inputs with auto-focus. | 11 |
| 12 | NEVER call the send-code API before client-side email validation passes. | 12 |
| 13 | NEVER show a generic error on 429. Show "Try again in N seconds" with a countdown. | 13 |
| 14 | NEVER exclude emailVerification.verificationId from sessionStorage persist. Without it, refresh breaks the flow. |
14 |
| 15 | NEVER allow resend without a 60-second client-side cooldown. Disable the button and show countdown. | 15 |
| # | Rule | Violates Mistake |
|---|---|---|
| 16 | NEVER call navigator.credentials.create() before checking PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable(). |
16 |
| 17 | NEVER change salt, iterations, or hash parameters between register and login. Derivation must be deterministic. | 17 |
| 18 | NEVER store the raw 256-bit Ed25519 seed in localStorage. Store only the public key. | 18 |
| 19 | NEVER show "Sign in with Passkey" if the passkey was deleted from browser settings. Check existence first. | 19 |
| 20 | NEVER hardcode WebAuthn relying party ID. Read from NEXT_PUBLIC_PASSKEY_RP_ID env var. |
20 |
| # | Rule | Violates Mistake |
|---|---|---|
| 21 | NEVER write a Playwright test that expects a browser extension to be installed. Guard with test.skip(). |
21 |
| 22 | NEVER commit real API keys, secret keys, or test account private keys to the repository. Use env vars. | 22 |
| 23 | NEVER test passkey flows without mocking the WebAuthn API via page.evaluate(). |
23 |
| 24 | NEVER wait real time for WC2 timeout in tests. Use page.clock.fastForward(). |
24 |
| 25 | NEVER run tests without clearing sessionStorage/persisted state between each test. | 25 |
| # | Rule | Violates Mistake |
|---|---|---|
| 26 | NEVER use export with dynamic import() patterns that break Turbopack tree-shaking. |
26 |
| 27 | NEVER add a "use client" page without also ensuring its imported components have it. |
27 |
| 28 | NEVER leave old page files in the (auth) directory after rewrite. Remove them immediately. |
28 |
| 29 | NEVER add a new component to src/components/auth/ without updating the barrel index.ts. |
29 |
| 30 | NEVER create __tests__ directories without verifying tsconfig includes them. |
30 |
If a task is not explicitly listed in this plan file, do not implement it. If you are unsure, ask. Never add features, UI elements, API calls, or dependencies that are not specified here.
These standards apply to every file created or modified during this implementation.
- Every function must have explicit return type annotation (no inference for public APIs)
- Every React component must have explicit Props interface exported
- Every Zustand action must return void or Promise<void>
- Every API response must be typed via generics (never `as` casting)
- Discriminated unions must have exhaustiveness checked via `switch` with `default: assertNever()`
- `any` is forbidden. Use `unknown` with type guards.
- `!` non-null assertion is forbidden. Use early return or `if` guard.
- Every async function must have try/catch with typed error handling
- User-facing errors must be strings (never Error objects)
- Internal errors must be logged with context (never shown to user)
- API errors must be parsed from response envelope, not thrown as-is
- Network errors must be mapped to user-friendly messages before display
- Toast notifications must always accompany auth state failures
- Errors must never contain stack traces, internal paths, or secrets
- Every component must be a named export (no default exports except pages)
- Every component must have a single responsibility (if it does 2 things, split it)
- Conditional rendering must use ternary, not && (prevents rendering 0/false)
- Loading states must be explicit, not inferred
- Error states must be part of the component's normal rendering, not a separate wrapper
- Empty states must be handled (no blank pages)
- Components must not access stores directly — use context or props
- useEffect dependencies must be exhaustive (no eslint-disable comments)
- Every component must have at least one render test per state variant
- Every hook must have tests for success, error, loading, and edge cases
- Every Zustand store must have tests for every action
- Integration tests must mock API at network layer (MSW or route interception)
- E2E tests must run against the live production URL
- Test names must follow the pattern: "should [expected behavior] when [condition]"
- Coverage minimum: 95% for components, 90% for stores, 85% for hooks
- Flaky tests must be quarantined, not ignored
- No secrets, keys, or tokens in code — all from environment variables
- No raw seeds or private keys in storage or logs
- API keys for test accounts must be injected at CI runtime
- Email verification codes must never be logged or exposed in URLs
- Wallet signatures must never be stored beyond the current session
- Nonce must be single-use with 5-minute TTL
- Rate limiting must be enforced client-side AND server-side
- Wallet adapter imports must be dynamic (code-split) — never static imports
- Zustand selectors must select primitive values, not entire objects
- Re-renders must be scoped: use individual selectors, not the whole store
- Images must use Next.js Image component with explicit width/height
- Fonts must be preloaded with `next/font`
- No `useEffect` chains — prefer derived state or sync actions
Before submitting any code:
- Run `npm run lint` — zero warnings
- Run `npm run typecheck` — zero errors
- Run `npm run test` — all passing
- Check for console.log, debugger, TODO, FIXME — none committed
- Check for hardcoded URLs, keys, secrets — none in code
- Verify new files are in the correct directory with correct barrel exports
- Verify old files are deleted (not left orphaned)
All 19 upgrades specified below. Each includes: description, enterprise approach, dependencies, phase assignment, and agent batch size.
| # | Upgrade | Phase | Agent Batches | Depends On |
|---|---|---|---|---|
| 1 | Error Monitoring (Sentry) | P1 | 1 | None |
| 2 | Loading Skeleton | P1 | 1 | None |
| 3 | Password Manager Hints | P1 | 1 | None |
| 4 | Offline Detection | P1 | 1 | None |
| 5 | Multi-Tab Sync | P2 | 1 | P1.4 (auth-flow-store) |
| 6 | Error Recovery for Stale Nonce | P2 | 1 | P1.4 (auth-flow-store) |
| 7 | CSRF Protection | P2 | 1 | P1.1 (API client) |
| 8 | CAPTCHA for Email Send | P2 | 1 | P1.2 (email API) |
| 9 | Accessibility Audit | P2 | 1 | P1.3 (skeleton) |
| 10 | i18n for Auth Pages | P2 | 2 (structure + translations) | P1.3, P1.4 |
| 11 | Session Timeout UX | P3 | 1 | P2.1 (multi-tab sync) |
| 12 | Rate Limit UI for Login | P3 | 1 | P2.3 (CSRF) |
| 13 | WebAuthn Conditional Mediation | P3 | 1 | P2.5 (passkey flow) |
| 14 | Passkey Revocation List | P3 | 1 | P2.5 (passkey flow) |
| 15 | Account Recovery Flow | P4 | 2 (recovery page + email) | P3.1-3.4 |
| 16 | Native Share API for Deep Links | P4 | 1 | P3.5 (WC2 fix) |
| 17 | Analytics Events | P4 | 1 | P3, P4.1-4.3 |
| 18 | WalletConnect v1 Fallback | P5 | 1 | P4.5 (WC2 stable) |
| 19 | Error Monitoring Dashboard | P5 | 1 | P1.1 (Sentry) |
Phase 1 (Foundation): Upgrades 1-4 — 4 agent batches, 0 dependencies
Phase 2 (Infrastructure): Upgrades 5-10 — 7 agent batches, depends on P1
Phase 3 (Security+UX): Upgrades 11-14 — 4 agent batches, depends on P2
Phase 4 (Polish): Upgrades 15-17 — 4 agent batches, depends on P3
Phase 5 (Future-Ready): Upgrades 18-19 — 2 agent batches, depends on P4
──────────────────
Total: 21 agent batches
Phase: P1 | Agent batches: 1 | Dependencies: None
Enterprise approach:
// src/lib/monitoring.ts
// Sentry is initialized once at app root. Auth errors are breadcrumbed.
// No secrets, no PII, no wallet addresses in Sentry events.
// Error context includes: step name, auth flow mode, error code (never message).
// Rate-limited to 1 event per 10 seconds per user session.
import * as Sentry from "@sentry/nextjs"
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
environment: process.env.NODE_ENV,
tracesSampleRate: 0.1, // 10% sampling
beforeSend(event) {
// Strip PII before sending
if (event.request?.headers) event.request.headers = {}
if (event.user) event.user = { id: "redacted" }
return event
}
})
// Usage minimum in auth:
export function captureAuthError(code: string, step: string, mode: string) {
Sentry.addBreadcrumb({
category: "auth",
message: `auth.${mode}.${step}: ${code}`,
level: "error",
})
}Implementation: Add Sentry SDK to package.json, create src/lib/monitoring.ts, wrap root layout with Sentry provider, add captureAuthError calls in auth-flow-store error handler.
Agent must not: Log wallet addresses, signatures, nonces, or email addresses to Sentry.
Phase: P1 | Agent batches: 1 | Dependencies: None
Enterprise approach:
// src/components/auth/auth-skeleton.tsx
// Skeleton matches the exact dimensions of the auth card to prevent layout shift.
// Uses CSS-only shimmer animation (no JS).
// Three variants: card (default), qr-placeholder, wallet-grid.
export function AuthSkeleton({ variant = "card" }: { variant?: "card" | "qr" | "grid" }) {
// ... renders glass card with animated pulse bars
}Implementation: Create auth-skeleton.tsx, add to auth-layout.tsx as initial render before JS loads, use in loading-overlay.tsx instead of spinner.
Agent must not: Add JS-based animation libraries. Use pure CSS animate-pulse from Tailwind.
Phase: P1 | Agent batches: 1 | Dependencies: None
Enterprise approach:
Add proper autoComplete and name attributes to all form fields so browser password managers and autofill work correctly:
// email-input.tsx
<input
name="email"
autoComplete="email"
inputMode="email"
/>
// verification-code-input.tsx
<input
name="verification-code"
autoComplete="one-time-code"
inputMode="numeric"
pattern="[0-9]*"
/>
// profile-step.tsx
<input name="display-name" autoComplete="name" />Implementation: Add attributes to all 3 Input components and 6 code inputs.
Agent must not: Add autoComplete="off" anywhere. Use webauthn where appropriate.
Phase: P1 | Agent batches: 1 | Dependencies: None
Enterprise approach:
// src/hooks/use-online-status.ts
// Listens to window online/offline events.
// Shows persistent banner at top of auth card when offline.
// Blocks API calls when offline (prevents generic "network error").
// Clears banner when back online.
export function useOnlineStatus() {
const [isOnline, setIsOnline] = useState(true)
useEffect(() => {
const goOnline = () => setIsOnline(true)
const goOffline = () => setIsOnline(false)
window.addEventListener("online", goOnline)
window.addEventListener("offline", goOffline)
return () => {
window.removeEventListener("online", goOnline)
window.removeEventListener("offline", goOffline)
}
}, [])
return isOnline
}Implementation: Create hook, add to auth-layout.tsx, render banner when offline. Wrap all API calls in useAuthFlow with offline pre-check.
Agent must not: Poll the server to check connectivity. Use browser events only.
Phase: P2 | Agent batches: 1 | Dependencies: P1 auth-flow-store
Enterprise approach:
// In auth-flow-context.tsx or auth-store.ts
// When auth state changes in one tab, all other tabs must react.
useEffect(() => {
const handleStorage = (e: StorageEvent) => {
if (e.key === "moistello-auth-flow" && e.newValue) {
// Another tab changed auth state — rehydrate
const parsed = JSON.parse(e.newValue)
if (parsed.state?.status?.status === "authenticated") {
// Another tab logged in — follow
useAuthStore.getState().checkAuth()
}
if (e.newValue === null) {
// Another tab logged out — follow
useAuthStore.getState().logout()
}
}
}
window.addEventListener("storage", handleStorage)
return () => window.removeEventListener("storage", handleStorage)
}, [])Implementation: Add storage event listener in AuthFlowProvider. When a tab detects login from another tab, auto-redirect. When a tab detects logout, clear local state.
Agent must not: Poll storage every N seconds. Use the native storage event only.
Phase: P2 | Agent batches: 1 | Dependencies: P1 auth-flow-store
Enterprise approach:
// In use-sign-message.ts or sign-step.tsx
// Nonce has 5-minute TTL. If user spends too long on profile step, nonce may expire.
useEffect(() => {
if (status.status !== "signing") return
const nonceTimestamp = authFlowStore.getState().auth.nonceTimestamp
if (!nonceTimestamp) return
const FIVE_MINUTES = 5 * 60 * 1000
if (Date.now() - nonceTimestamp > FIVE_MINUTES) {
// Nonce expired — auto-refresh
fetchNewNonce(address)
}
}, [status.status])Implementation: Add nonceTimestamp to auth-flow-store auth object. Before signing, check if nonce is stale. If expired, fetch new nonce silently and re-attach. Show toast "Session refreshed" only if the user was idle.
Agent must not: Silently fail sign because of expired nonce. Always attempt refresh first.
Phase: P2 | Agent batches: 1 | Dependencies: P1 API client
Enterprise approach:
// In src/lib/api-client.ts
// Every state-changing request includes a CSRF token header.
// Token is generated once per session and stored in meta tag.
function getCsrfToken(): string {
const meta = document.querySelector('meta[name="csrf-token"]')
return meta?.getAttribute("content") ?? ""
}
// All POST/PATCH/DELETE requests include:
headers: {
"X-CSRF-Token": getCsrfToken(),
}
// Server validates token matches session. Rejects with 403 if mismatch.Implementation: Add CSRF meta tag to root layout. Add token extraction to api-client interceptor. All auth API calls automatically include the header.
Agent must not: Bypass CSRF for any endpoint. Every state-changing call needs it.
Phase: P2 | Agent batches: 1 | Dependencies: P1 email verification API
Enterprise approach:
// In verify-email-step.tsx
// Before sending verification code, show Cloudflare Turnstile or hCaptcha.
// Token is included in POST /auth/verification/send request body.
// Server verifies token before sending email.
import Turnstile from "react-turnstile"
function VerifyEmailStep() {
const [captchaToken, setCaptchaToken] = useState<string | null>(null)
const handleSend = async () => {
if (!captchaToken) return // prevent send without captcha
await sendVerificationCode(email, captchaToken)
}
return (
<>
<Turnstile
sitekey={process.env.NEXT_PUBLIC_TURNSTILE_SITEKEY!}
onVerify={setCaptchaToken}
theme="dark"
/>
<button onClick={handleSend} disabled={!captchaToken}>
Confirm Email
</button>
</>
)
}Implementation: Add Turnstile react package. Add widget before "Confirm Email" button. Wire captcha token into API call. Add env vars.
Agent must not: Disable CAPTCHA for any email flow. Always require it.
Phase: P2 | Agent batches: 1 | Dependencies: P1 skeleton, P1 layout
Enterprise approach:
Required ARIA attributes on every auth component:
- auth-layout: role="main" aria-label="Authentication"
- auth-step-indicator: role="progressbar" aria-valuenow aria-valuemin aria-valuemax aria-label="Step N of M"
- wallet-grid: role="listbox" aria-label="Select a wallet"
- wallet-card: role="option" aria-selected aria-label="Wallet name"
- passkey-shortcut: aria-label="Sign in with passkey using your device biometrics"
- wc2-connection-state: role="status" aria-live="polite"
- verify-email-step: aria-label="Email verification form"
- verification-code-input: aria-label="6-digit verification code" inputMode="numeric" autoComplete="one-time-code"
- error-display: role="alert" aria-live="assertive"
- loading-overlay: role="progressbar" aria-label="Loading"
- connected-badge: role="status" aria-label="Connected to {wallet name}"
Additionally:
- All interactive elements must be keyboard-navigable (tabIndex, onKeyDown for Enter/Escape)
- Focus trap inside modals (LedgerPrompt, WC2)
- Skip-to-content link at top of auth card
- Color contrast ratio ≥ 4.5:1 for all text (verify with axe-core)
- Screen reader announcements on step transitions
Implementation: Add ARIA attributes to all 12 components. Run axe-core audit. Fix all violations. Test with VoiceOver (macOS) and NVDA (Windows).
Agent must not: Use aria-hidden="true" on any interactive element. Add role="button" to non-interactive elements.
Phase: P2 | Agent batches: 2 (structure + translations) | Dependencies: P1 skeleton, P1 layout
Enterprise approach:
// Using next-intl for i18n. Auth strings are separated into auth-specific namespace.
// messages/en/auth.json
{
"login.title": "Welcome back",
"login.passkeyButton": "Sign in with Passkey",
"register.title": "Create your account",
"register.passkeyRecommended": "Recommended",
"email.placeholder": "Email address",
"email.confirmButton": "Confirm Email",
"code.placeholder": "Verification code",
"code.verifyButton": "Verify Code",
"code.resend": "Didn't receive it? Resend",
"error.generic": "Something went wrong",
// ... all auth strings
}Implementation: Install next-intl. Create auth namespace JSON per locale. Wrap all user-facing strings in t() calls. Include en, fr, es, pt, sw, hi (matching existing profile language options).
Agent must not: Hardcode any user-facing string. Every string must go through i18n. Only exception: technical error messages that users never see.
Phase: P3 | Agent batches: 1 | Dependencies: P2 multi-tab sync
Enterprise approach:
// In sign-step.tsx
// Access token expires in 15 minutes. Refresh token in 7 days.
// Show countdown badge when < 5 minutes remaining.
// Auto-refresh token silently when < 2 minutes remaining.
// If refresh fails, show warning "Your session will expire soon. Please save your progress."
// Save form state to sessionStorage before redirect to login.
// On return, restore form state (nonce must be re-fetched though).
function SessionTimeoutBanner() {
const { expiresAt } = useAuthStore(s => ({ expiresAt: s.tokenExpiresAt }))
const [timeLeft, setTimeLeft] = useState<number | null>(null)
useEffect(() => {
if (!expiresAt) return
const update = () => setTimeLeft(Math.max(0, Math.floor((expiresAt - Date.now()) / 1000)))
update()
const interval = setInterval(update, 10000) // every 10s, not every 1s
return () => clearInterval(interval)
}, [expiresAt])
if (timeLeft === null || timeLeft > 300) return null // >5min: no banner
if (timeLeft <= 0) return <div role="alert">Session expired. Please sign in again.</div>
return <div role="status">Session expires in {Math.floor(timeLeft / 60)}:{String(timeLeft % 60).padStart(2, "0")}</div>
}Implementation: Add tokenExpiresAt to auth-store. Create SessionTimeoutBanner component. Add to sign-step and profile-step. Auto-save form fields to sessionStorage on expiry.
Agent must not: Show timeout banner on login/register steps that don't have a session yet (choose-wallet, verify-email).
Phase: P3 | Agent batches: 1 | Dependencies: P2 CSRF
Enterprise approach:
// In sign-step.tsx
// After 3 failed sign attempts, server returns remainingAttempts in error response.
// Show countdown: "Too many attempts. Try again in 12:34."
// Disable submit button during cooldown.
// Reset count on successful sign.
interface RateLimitState {
remainingAttempts: number
cooldownUntil: number | null
}
// In auth-flow-store:
rateLimit: {
remainingAttempts: 5,
cooldownUntil: null,
lastAttemptAt: null,
}Implementation: Add rateLimit to auth-flow-store state. Parse Retry-After header from 429 responses. Show countdown timer. Disable button while cooling down.
Agent must not: Rely only on server-side rate limiting. Enforce client-side as well.
Phase: P3 | Agent batches: 1 | Dependencies: P2 passkey flow
Enterprise approach:
// On login page mount, attempt silent passkey authentication.
// Uses `mediation: "conditional"` so browser shows passkey autofill UI.
// Only for returning users who have a passkey stored.
useEffect(() => {
if (!PublicKeyCredential?.isConditionalMediationAvailable) return
PublicKeyCredential.isConditionalMediationAvailable().then(available => {
if (!available) return
// Browser supports conditional mediation
// Navigator.credentials.get() with mediation: "conditional"
// will show the passkey autofill dropdown without user clicking anything
})
}, [])Implementation: Add conditional mediation check on login page mount. If available, show passkey as an autofill suggestion in the email/username field.
Agent must not: Remove the explicit "Sign in with Passkey" button. Conditional mediation is additive.
Phase: P3 | Agent batches: 1 | Dependencies: P2 passkey flow
Enterprise approach:
On the server, maintain a passkey_version per user account.
Each passkey registration increments the version.
Client sends passkey_version with each authentication request.
Server rejects if version < expected (passkey was revoked).
If passkey compromised, admin can bump version to invalidate all existing passkeys.
User must re-register with email verification.
Implementation: Auth-flow-store stores passkeyVersion. Server returns expectedPasskeyVersion on /auth/verify. If mismatch, show "Your passkey has been revoked. Please set up a new one." and redirect to passkey re-registration.
Agent must not: Store passkey version in localStorage. Store in auth-flow-store sessionStorage only.
Phase: P4 | Agent batches: 2 (recovery page + email flow) | Dependencies: P3 passkey revocation
Enterprise approach:
When user clicks "Lost passkey?" on login page:
1. Show email input (same as verify-email-step)
2. Send verification code to email
3. User enters code
4. On verified: generate new passkey credential (WebAuthn create)
5. Derive new Stellar keypair from new credential + same email
6. Call POST /auth/passkey/recover with new public key + verificationId
7. Server verifies email ownership, updates public key, invalidates old passkey version
8. User proceeds to sign in with new passkey
Separate page: src/app/(auth)/recover/page.tsx (~60 lines)
Agent must not: Allow recovery without email verification. Never allow recovery if user has no verified email.
Phase: P4 | Agent batches: 1 | Dependencies: P3 WC2 fix
Enterprise approach:
// In wc2-connection-state.tsx
// On mobile, after generating WC2 URI, show a "Share" button.
// Uses navigator.share() API to share the deep link with wallet apps.
// Fallback: copy to clipboard with toast confirmation.
const handleShare = async () => {
if (navigator.share) {
await navigator.share({
title: "Connect to Moistello",
text: "Open this link in your Stellar wallet to connect:",
url: wc2PairingUri,
})
} else {
await navigator.clipboard.writeText(wc2PairingUri!)
addToast({ type: "success", title: "Link copied!" })
}
}Implementation: Add share button next to "Cancel" in WC2 mobile view. Test on iOS Safari and Android Chrome.
Agent must not: Show share button on desktop. Only on mobile where navigator.share makes sense.
Phase: P4 | Agent batches: 1 | Dependencies: P3 all auth flows stable
Enterprise approach:
// In auth-flow-context.tsx — fire event on every meaningful state transition.
// Using PostHog (already available in codebase or provided via env var).
const EVENTS = {
AUTH_STARTED: "auth.started",
WALLET_SELECTED: "auth.wallet.selected",
WALLET_CONNECTED: "auth.wallet.connected",
WALLET_CONNECTION_FAILED: "auth.wallet.connection_failed",
EMAIL_VERIFICATION_SENT: "auth.email.verification_sent",
EMAIL_VERIFIED: "auth.email.verified",
EMAIL_VERIFICATION_FAILED: "auth.email.verification_failed",
PROFILE_SUBMITTED: "auth.profile.submitted",
SIGN_STARTED: "auth.sign.started",
SIGN_COMPLETED: "auth.sign.completed",
SIGN_FAILED: "auth.sign.failed",
AUTH_SUCCESS: "auth.success",
AUTH_FAILED: "auth.failed",
}
function track(event: string, properties?: Record<string, unknown>) {
if (typeof window === "undefined") return
// Only track anonymous events: mode (login/register), step, wallet type
// NEVER track: email, address, signature, nonce, tokens
const safeProps = { ...properties }
delete safeProps.email
delete safeProps.address
delete safeProps.signature
delete safeProps.nonce
posthog.capture(event, safeProps)
}Implementation: Wire track() calls into auth-flow-store action handlers. Sanitize all properties before sending. Add PostHog env var.
Agent must not: Track PII (email, wallet address, signature, nonce, tokens, IP). Strip all identifying info before sending.
Phase: P5 | Agent batches: 1 | Dependencies: P4 WC2 stable
Enterprise approach:
If WalletConnect v2 relay is down (wc2RelayStatus === "down"), attempt fallback:
1. Show "WalletConnect relay unavailable" message with option to try alternative
2. If user has WC1-compatible wallet installed, offer direct wallet connect
3. If no fallback available, show clear instructions: "Try again later or use another wallet"
This is a low-priority upgrade — WC1 was deprecated in June 2023.
Most wallets have migrated to WC2. This is for edge cases only.
Implementation: Check wc2RelayStatus in choose-wallet-step. If "down", add a note below the WC2 button. Do not attempt to load WC1 SDK (deprecated).
Agent must not: Install or import WalletConnect v1 SDK. Only show informational fallback message.
Phase: P5 | Agent batches: 1 | Dependencies: P1 Sentry
Enterprise approach:
Configure Sentry performance monitoring for auth flows:
- Transaction: "auth.login" and "auth.register"
- Span for each step: wallet_connect, email_verify, profile, sign, submit
- Measure P50, P95, P99 latency per step
- Alert on P95 > 5 seconds for any step
- Alert on auth success rate < 80% in any 5-minute window
- Dashboard for monitoring auth health in production
Because we run on the live deployed server, this is critical for detecting
passkey registration failures, WC2 timeout spikes, and email delivery issues.
Implementation: Add Sentry transaction/spans to auth-flow-store actions. Create Sentry dashboard. Set up alert rules.
Agent must not: Create the Sentry dashboard itself (requires UI access). Only instrument the code with proper spans.
| Batch | Step | Task | Output | Dependencies | Agent Can Do Alone? |
|---|---|---|---|---|---|
| B1 | 1.1 | Refactor auth-flow-store: add email verification fields, nonceTimestamp, rateLimit | Updated store interface | None | Yes |
| B1 | 1.2 | Create auth-flow-context.tsx with bridge to multi-wallet-store and auth-store | Context + Provider | 1.1 | Yes |
| B1 | 1.3 | Create auth-layout.tsx + auth-skeleton.tsx | Layout + Skeleton | None | Yes |
| B2 | 1.4 | Create wallet-grid.tsx + extract wallet-card from existing code | 2 components | None | Yes |
| B2 | 1.5 | Create connected-badge.tsx, error-display.tsx | 2 utility components | None | Yes |
| B3 | 1.6 | Create loading-overlay.tsx + use-online-status.ts + offline banner | Overlay + hook | 1.3 | Yes |
| B3 | 1.7 | Add Sentry monitoring + captureAuthError helper | lib/monitoring.ts | None | Yes |
| B4 | 1.8 | Add password manager hints to all inputs + autoComplete attributes | Updated inputs | None | Yes |
| B4 | 1.9 | Add Turnstile CAPTCHA to verify-email-step | CAPTCHA widget | 1.1 | Yes |
| Batch | Step | Task | Output | Dependencies | Agent Can Do Alone? |
|---|---|---|---|---|---|
| B5 | 2.1 | Create choose-wallet-step.tsx with WalletConnect orchestration + WC2 fix | Step component | 1.2, 1.3, 1.4 | Yes |
| B5 | 2.2 | Create verify-email-step.tsx with email + code UI + CAPTCHA + API calls | Step component | 1.2, 1.9 | Yes |
| B6 | 2.3 | Create profile-step.tsx (extract from old register) | Step component | None | Yes |
| B6 | 2.4 | Create sign-step.tsx with nonce fetch + sign + submit + stale nonce recovery | Step component | 1.2, 1.4, 1.7 | Yes |
| B7 | 2.5 | Create use-email-verification.ts, use-sign-message.ts, use-profile-form.ts hooks | 3 hooks | 2.2, 2.4 | Yes |
| B7 | 2.6 | Wire multi-tab sync + storage event listener in AuthFlowProvider | Tab sync | 1.2 | Yes |
| B8 | 2.7 | Add CSRF token to api-client + meta tag to layout | CSRF | 1.2 | Yes |
| B8 | 2.8 | Add i18n setup + auth namespace JSON + wrap all strings in t() | i18n | 1.3, 1.4 | Yes |
| B9 | 2.9 | Add ARIA attributes + keyboard nav to all 12 components | Accessibility | 2.1-2.5 | Yes |
| B9 | 2.10 | Run axe-core audit, fix violations | Audit pass | 2.9 | Yes |
| Batch | Step | Task | Output | Dependencies | Agent Can Do Alone? |
|---|---|---|---|---|---|
| B10 | 3.1 | Rewrite login/page.tsx | ~80 lines | All Phase 1 + 2 | Yes |
| B10 | 3.2 | Rewrite register/page.tsx | ~150 lines | All Phase 1 + 2 | Yes |
| B10 | 3.3 | Delete old code: login/page.tsx, register/page.tsx remnants | Clean codebase | 3.1, 3.2 | Yes |
| B11 | 3.4 | Add SessionTimeoutBanner + token expiry tracking | Session UX | 2.6 | Yes |
| B11 | 3.5 | Add rate limit UI to sign-step + auth-flow-store rateLimit | Rate limit UX | 2.4 | Yes |
| B12 | 3.6 | Add WebAuthn conditional mediation on login page | Conditional passkey | 2.1 | Yes |
| B12 | 3.7 | Add passkey version tracking + revocation check | Passkey revocation | 2.4 | Yes |
| B13 | 3.8 | Manual QA on all flows | Sign-off | 3.1, 3.2 | No (requires human) |
| Batch | Step | Task | Output | Dependencies | Agent Can Do Alone? |
|---|---|---|---|---|---|
| B14 | 4.1 | Write 35 unit tests for all components and hooks | Test suite | All Phase 2 + 3 | Yes |
| B14 | 4.2 | Write 8 integration tests for auth flows (MSW) | Test suite | 4.1 | Yes |
| B15 | 4.3 | Write 8 Playwright E2E tests for live site | Test suite | 4.2 | Yes |
| B15 | 4.4 | Set up GitHub Actions CI pipeline for E2E | CI workflow | 4.3 | Yes |
| B16 | 4.5 | Add analytics events (PostHog) to all auth state transitions | Analytics | 3.1, 3.2 | Yes |
| B16 | 4.6 | Add native share API for WC2 deep links on mobile | Share button | 2.1 | Yes |
| B17 | 4.7 | Create account recovery page + email verification flow | Recovery | 2.2 | Yes |
| Batch | Step | Task | Output | Dependencies | Agent Can Do Alone? |
|---|---|---|---|---|---|
| B18 | 5.1 | Add WC2 relay status check + fallback message | WC2 fallback | 2.1 | Yes |
| B18 | 5.2 | Add Sentry performance spans to auth flow steps | Monitoring | 1.7 | Yes |
| B19 | 5.3 | Run final full test suite + fix flaky tests | Clean CI | All | Yes |
| B19 | 5.4 | Final manual QA + production deployment | Deploy | 5.3 | No (requires human) |
Each "agent batch" is a discrete unit that can be handed to one agent call.
Maximum per agent call: 1 batch (never assign multiple batches to one agent call).
Within a batch, the sequence of file creation/edits matters:
1. Types/interfaces first (no dependencies)
2. Store/state second (depends on types)
3. Hooks third (depends on store)
4. Components fourth (depends on hooks)
5. Tests last (depends on all above)
If a batch has sub-steps (e.g., B1 has 1.1, 1.2, 1.3), they must be done
in order within that single agent call. Never skip a sub-step.
Agent must report back after each batch with:
- Files created/modified
- Tests passing (if applicable)
- Any deviations from plan with justification
- Time taken against estimate
| Date | Version | Author | Changes |
|---|---|---|---|
| 2026-06-01 | 1.0 | Audit | Initial rebuild plan based on deep audit of login (557 lines) and register (914 lines) pages. |
| - | 1.1 | - | Added email verification flow with verification code. |
| - | 1.2 | - | Added Playwright E2E test spec for live site testing. |
| - | 1.3 | - | Added WC2 race condition fix section. |
| - | 1.4 | - | Added manual QA script for flows that cannot be automated. |
| - | 2.0 | - | Massive upgrade: added Agent Rules (Section 16), Enterprise Standards (Section 17), all 19 Enterprise Upgrades with phases and agent batch assignments (Section 18), and updated Implementation Timeline with 19 batches across 5 phases (Section 19). |