oneie
Version:
Build apps, websites, and AI agents in English. Zero-interaction setup for AI agents (Claude Code, Cursor, Windsurf). Download to your computer, run in the cloud, deploy to the edge. Open source and free forever.
1,627 lines (1,381 loc) • 62.2 kB
Markdown
---
title: 2 5 Auth Migration
dimension: things
category: features
tags: auth, backend, frontend
related_dimensions: connections, events, people
scope: global
created: 2025-11-03
updated: 2025-11-03
version: 1.0.0
ai_context: |
This document is part of the things dimension in the features category.
Location: one/things/features/2-5-auth-migration.md
Purpose: Documents feature 2-5: auth component migration to dataprovider
Related dimensions: connections, events, people
For AI agents: Read this to understand 2 5 auth migration.
---
# Feature 2-5: Auth Component Migration to DataProvider
**Feature ID:** `feature_2_5_auth_migration`
**Plan:** `plan_2_backend_agnostic_frontend`
**Owner:** Frontend Specialist
**Status:** ✅ **COMPLETE SPECIFICATION** - Ready for Implementation
**Priority:** **P0 (CRITICAL)** - All 50+ auth tests MUST pass
**Effort:** 1 week (5-8 days)
**Risk Level:** **HIGHEST** - Authentication is critical infrastructure
**Dependencies:** Feature 2-1 (DataProvider), Feature 2-4 (React Hooks)
---
## Executive Summary
This feature migrates **6 critical authentication components** from direct Better Auth + Convex integration to backend-agnostic DataProvider hooks. This is the **HIGHEST RISK** feature in Plan 2 because:
1. Authentication affects 100% of users
2. 50+ existing tests MUST continue passing
3. Security cannot be compromised
4. Session management must work identically
5. Zero downtime deployment required
**Success Criteria:** All 50+ auth tests pass with ZERO functionality regression.
**Migration Strategy:** Incremental component-by-component with test validation after EACH component (<5 tests fail = immediate rollback).
---
## 1. Components to Migrate (6 Total)
### Component Matrix
| # | Component | Current API | New Hook | Lines | Risk | Test File |
| --- | ---------------------- | ----------------------------------------------- | ---------------------------- | ----- | ---------- | ------------------------ |
| 1 | **SimpleSignInForm** | `authClient.signIn.email()` | `useLogin()` | 163 | **HIGH** | `email-password.test.ts` |
| 2 | **SimpleSignUpForm** | `authClient.signUp.email()` | `useSignup()` | 157 | **HIGH** | `email-password.test.ts` |
| 3 | **MagicLinkAuth** | `convex.mutation(api.auth.signInWithMagicLink)` | `useMagicLinkAuth()` | 151 | **MEDIUM** | `magic-link.test.ts` |
| 4 | **TwoFactorSettings** | Multiple `convex.mutation(api.auth.*)` | `use2FA()` | 293 | **HIGH** | `auth.test.ts` |
| 5 | **VerifyEmailForm** | `convex.mutation(api.auth.verifyEmail)` | `useVerifyEmail()` | 134 | **LOW** | `auth.test.ts` |
| 6 | **ForgotPasswordForm** | `fetch("/api/auth/forgot-password")` | `usePasswordReset()` | 144 | **MEDIUM** | `password-reset.test.ts` |
| 7 | **ResetPasswordForm** | `fetch("/api/auth/reset-password")` | `usePasswordResetComplete()` | 206 | **MEDIUM** | `password-reset.test.ts` |
**Total:** 1,248 lines of code to migrate
**Estimated Effort:** 8-12 hours of careful migration + testing
---
## 2. Complete BEFORE/AFTER Code
### 2.1 SimpleSignInForm Migration
**BEFORE (Current - 163 lines):**
```typescript
// frontend/src/components/auth/SimpleSignInForm.tsx (BEFORE)
import { useState } from "react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { authClient } from "@/lib/auth-client" // ❌ REMOVE
import { toast } from "sonner"
import { AuthCard } from "./AuthCard"
import { SocialLoginButtons } from "./SocialLoginButtons"
export function SimpleSignInForm() {
const [email, setEmail] = useState("")
const [password, setPassword] = useState("")
const [loading, setLoading] = useState(false) // ❌ Manual state management
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true) // ❌ Manual loading state
try {
// ❌ Direct authClient call
const result = await authClient.signIn.email({
email,
password,
})
if (result.error) {
const errorMessage = result.error.message || "Unable to sign in"
let title = "Unable to sign in"
let description = `Error: ${errorMessage}. Please check your credentials and try again.`
// ❌ String-based error handling (brittle)
if (errorMessage.toLowerCase().includes("not found") || errorMessage.toLowerCase().includes("no user")) {
title = "Account not found"
description = "No account exists with this email. Please sign up first."
} else if (errorMessage.toLowerCase().includes("password") || errorMessage.toLowerCase().includes("incorrect")) {
title = "Incorrect password"
description = "The password you entered is incorrect."
}
// ... more string-based error checks
toast.error(title, { description })
setLoading(false) // ❌ Manual state reset
return
}
toast.success("Welcome back!", {
description: "Successfully signed in. Redirecting to your dashboard..."
})
setTimeout(() => {
window.location.href = "/account"
}, 1000)
} catch (err: any) {
// ❌ Generic error handling
toast.error("Sign in error", {
description: `Error: ${err.message}. Please try again later.`
})
setLoading(false) // ❌ Manual state reset
}
}
return (
<AuthCard title="Welcome Back" description="Sign in to your account">
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="your@email.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="password">Password</Label>
<a href="/account/forgot-password" className="text-xs text-primary hover:underline">
Forgot password?
</a>
</div>
<Input
id="password"
type="password"
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
<Button type="submit" className="w-full" disabled={loading}>
{loading ? "Signing in..." : "Sign In"}
</Button>
<div className="relative my-4">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">Or</span>
</div>
</div>
<Button variant="outline" className="w-full" asChild>
<a href="/account/magic-link">Sign in with magic link</a>
</Button>
</form>
</AuthCard>
)
}
```
**AFTER (Migrated - 120 lines, cleaner):**
```typescript
// frontend/src/components/auth/SimpleSignInForm.tsx (AFTER)
import { useState } from "react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { useLogin } from "@/providers/hooks" // ✅ DataProvider hook
import { toast } from "sonner"
import { AuthCard } from "./AuthCard"
import { SocialLoginButtons } from "./SocialLoginButtons"
export function SimpleSignInForm() {
const [email, setEmail] = useState("")
const [password, setPassword] = useState("")
const { mutate: login, loading, error } = useLogin() // ✅ Hook manages state
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
try {
const result = await login({ email, password }) // ✅ Simple hook call
if (result.success) {
toast.success("Welcome back!", {
description: "Successfully signed in. Redirecting to your dashboard..."
})
setTimeout(() => {
window.location.href = "/account"
}, 1000)
}
} catch (err: any) {
// ✅ Typed error handling
let title = "Unable to sign in"
let description = err.message || "Please check your credentials and try again."
// ✅ Type-safe error checking
if (err._tag === "UserNotFound") {
title = "Account not found"
description = "No account exists with this email. Please sign up first."
} else if (err._tag === "InvalidCredentials") {
title = "Incorrect password"
description = "The password you entered is incorrect. Try again or use 'Forgot password'."
} else if (err._tag === "NetworkError") {
title = "Network error"
description = "Unable to connect to the server. Check your internet connection."
} else if (err._tag === "EmailNotVerified") {
title = "Email not verified"
description = "Please verify your email address before signing in."
} else if (err._tag === "TwoFactorRequired") {
title = "2FA required"
description = "Please enter your two-factor authentication code."
// Redirect to 2FA page
window.location.href = `/account/signin/2fa?email=${encodeURIComponent(email)}`
return
}
toast.error(title, { description })
}
}
return (
<AuthCard title="Welcome Back" description="Sign in to your account">
<SocialLoginButtons mode="signin" />
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="your@email.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="password">Password</Label>
<a href="/account/forgot-password" className="text-xs text-primary hover:underline">
Forgot password?
</a>
</div>
<Input
id="password"
type="password"
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
<Button type="submit" className="w-full" disabled={loading}>
{loading ? "Signing in..." : "Sign In"}
</Button>
<div className="relative my-4">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">Or</span>
</div>
</div>
<Button variant="outline" className="w-full" asChild>
<a href="/account/magic-link">Sign in with magic link</a>
</Button>
</form>
</AuthCard>
)
}
```
**Key Changes:**
- ❌ Removed `authClient` import
- ✅ Added `useLogin()` hook import
- ❌ Removed manual `loading` state
- ✅ Hook manages `loading`, `error` state
- ❌ Removed string-based error detection
- ✅ Type-safe `_tag` error checking
- 🔥 **43 fewer lines** (163 → 120)
- 🎯 **Better error handling** with typed errors
- ⚡ **Cleaner code** with hook pattern
---
### 2.2 SimpleSignUpForm Migration
**BEFORE (Current - 157 lines):**
```typescript
// frontend/src/components/auth/SimpleSignUpForm.tsx (BEFORE)
import { useState } from "react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { authClient } from "@/lib/auth-client" // ❌ REMOVE
import { toast } from "sonner"
import { AuthCard } from "./AuthCard"
import { SocialLoginButtons } from "./SocialLoginButtons"
import { PasswordStrengthIndicator } from "./PasswordStrengthIndicator"
export function SimpleSignUpForm() {
const [email, setEmail] = useState("")
const [password, setPassword] = useState("")
const [name, setName] = useState("")
const [loading, setLoading] = useState(false) // ❌ Manual state
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true) // ❌ Manual loading
try {
// ❌ Direct authClient call
const result = await authClient.signUp.email({
email,
password,
name,
})
if (result.error) {
const errorMessage = result.error.message || "Unable to create account"
let title = "Unable to create account"
let description = `Error: ${errorMessage}. Please verify your information and try again.`
// ❌ String-based error detection
if (errorMessage.toLowerCase().includes("already exists")) {
title = "Email already registered"
description = "This email is already in use. Please sign in instead."
} else if (errorMessage.toLowerCase().includes("password")) {
title = "Invalid password"
description = "Password must be at least 8 characters long."
}
toast.error(title, { description })
setLoading(false) // ❌ Manual state reset
return
}
toast.success("Account created successfully!", {
description: `Welcome ${name}! Redirecting to your dashboard...`
})
setTimeout(() => {
window.location.href = "/account"
}, 1000)
} catch (err: any) {
toast.error("Sign up error", {
description: `Error: ${err.message}. Please try again later.`
})
setLoading(false) // ❌ Manual state reset
}
}
return (
<AuthCard title="Create Account" description="Sign up to get started">
<SocialLoginButtons mode="signup" />
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
type="text"
placeholder="Your name"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="your@email.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
/>
<PasswordStrengthIndicator password={password} />
</div>
<Button type="submit" className="w-full" disabled={loading}>
{loading ? "Signing up..." : "Sign Up"}
</Button>
</form>
</AuthCard>
)
}
```
**AFTER (Migrated - 115 lines):**
```typescript
// frontend/src/components/auth/SimpleSignUpForm.tsx (AFTER)
import { useState } from "react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { useSignup } from "@/providers/hooks" // ✅ DataProvider hook
import { toast } from "sonner"
import { AuthCard } from "./AuthCard"
import { SocialLoginButtons } from "./SocialLoginButtons"
import { PasswordStrengthIndicator } from "./PasswordStrengthIndicator"
export function SimpleSignUpForm() {
const [email, setEmail] = useState("")
const [password, setPassword] = useState("")
const [name, setName] = useState("")
const { mutate: signup, loading, error } = useSignup() // ✅ Hook manages state
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
try {
const result = await signup({ email, password, name }) // ✅ Simple call
if (result.success) {
toast.success("Account created successfully!", {
description: `Welcome ${name}! Redirecting to your dashboard...`
})
setTimeout(() => {
window.location.href = "/account"
}, 1000)
}
} catch (err: any) {
let title = "Unable to create account"
let description = err.message || "Please verify your information and try again."
// ✅ Typed error handling
if (err._tag === "EmailAlreadyExists") {
title = "Email already registered"
description = "This email is already in use. Please sign in or use a different email."
} else if (err._tag === "WeakPassword") {
title = "Password too weak"
description = "Password must be at least 8 characters with letters and numbers."
} else if (err._tag === "NetworkError") {
title = "Network error"
description = "Unable to connect to the server. Check your internet connection."
}
toast.error(title, { description })
}
}
return (
<AuthCard title="Create Account" description="Sign up to get started">
<SocialLoginButtons mode="signup" />
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
type="text"
placeholder="Your name"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="your@email.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
/>
<PasswordStrengthIndicator password={password} />
</div>
<Button type="submit" className="w-full" disabled={loading}>
{loading ? "Signing up..." : "Sign Up"}
</Button>
</form>
</AuthCard>
)
}
```
**Key Changes:**
- 🔥 **42 fewer lines** (157 → 115)
- ✅ Cleaner error handling with typed errors
- ✅ Password strength indicator preserved
- ✅ Same UX, better code
---
### 2.3 MagicLinkAuth Migration
**BEFORE (Current - 151 lines):**
```typescript
// frontend/src/components/auth/MagicLinkAuth.tsx (BEFORE)
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
import { AuthCard } from "./AuthCard";
import { CheckCircle2, Mail } from "lucide-react";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { ConvexHttpClient } from "convex/browser"; // ❌ REMOVE
import { api } from "../../../convex/_generated/api"; // ❌ REMOVE
const convex = new ConvexHttpClient( // ❌ REMOVE
import.meta.env.PUBLIC_CONVEX_URL || import.meta.env.NEXT_PUBLIC_CONVEX_URL,
);
interface MagicLinkAuthProps {
token: string;
}
export function MagicLinkAuth({ token }: MagicLinkAuthProps) {
const [loading, setLoading] = useState(false); // ❌ Manual state
const [authSuccess, setAuthSuccess] = useState(false);
const [tokenValid, setTokenValid] = useState(true);
const [authenticating, setAuthenticating] = useState(true);
useEffect(() => {
const authenticateWithMagicLink = async () => {
if (!token) {
setTokenValid(false);
setAuthenticating(false);
toast.error("No magic link token", {
description: "This link is missing a token.",
});
return;
}
setLoading(true);
try {
// ❌ Direct Convex mutation call
const result = await convex.mutation(api.auth.signInWithMagicLink, {
token,
});
if (result?.token) {
// ❌ Manual cookie setting via API
const response = await fetch("/api/auth/set-cookie", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token: result.token }),
});
if (response.ok) {
setAuthSuccess(true);
toast.success("Signed in successfully!", {
description: "Redirecting to your dashboard...",
});
setTimeout(() => {
window.location.href = "/account";
}, 1500);
} else {
throw new Error("Failed to set authentication cookie");
}
}
} catch (err: any) {
setTokenValid(false);
toast.error("Authentication failed", {
description: err.message || "Invalid or expired link",
});
} finally {
setLoading(false);
setAuthenticating(false);
}
};
authenticateWithMagicLink();
}, [token]);
// ... render logic (unchanged)
}
```
**AFTER (Migrated - 105 lines):**
```typescript
// frontend/src/components/auth/MagicLinkAuth.tsx (AFTER)
import { useState, useEffect } from "react"
import { Button } from "@/components/ui/button"
import { toast } from "sonner"
import { AuthCard } from "./AuthCard"
import { CheckCircle2, Mail } from "lucide-react"
import { Alert, AlertDescription } from "@/components/ui/alert"
import { useMagicLinkAuth } from "@/providers/hooks" // ✅ DataProvider hook
interface MagicLinkAuthProps {
token: string
}
export function MagicLinkAuth({ token }: MagicLinkAuthProps) {
const { mutate: authenticate, loading, error } = useMagicLinkAuth() // ✅ Hook
const [authSuccess, setAuthSuccess] = useState(false)
const [tokenValid, setTokenValid] = useState(true)
useEffect(() => {
const authenticateWithToken = async () => {
if (!token) {
setTokenValid(false)
toast.error("No magic link token", {
description: "This link is missing a token."
})
return
}
try {
const result = await authenticate({ token }) // ✅ Simple hook call
if (result.success) {
setAuthSuccess(true)
toast.success("Signed in successfully!", {
description: "Redirecting to your dashboard..."
})
setTimeout(() => {
window.location.href = "/account"
}, 1500)
}
} catch (err: any) {
setTokenValid(false)
let title = "Authentication failed"
let description = err.message
// ✅ Typed error handling
if (err._tag === "InvalidToken" || err._tag === "TokenExpired") {
title = "Invalid or expired link"
description = "This magic link has expired or is invalid. Magic links expire after 15 minutes."
}
toast.error(title, { description })
}
}
authenticateWithToken()
}, [token])
if (loading) {
return (
<AuthCard
title="Signing you in"
description="Please wait while we authenticate your magic link"
>
<div className="flex justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
</AuthCard>
)
}
if (!tokenValid) {
return (
<AuthCard
title="Authentication failed"
description="This magic link is no longer valid"
>
<Alert variant="destructive">
<Mail className="h-4 w-4" />
<AlertDescription>
This magic link has expired or is invalid. Magic links expire after 15 minutes.
</AlertDescription>
</Alert>
<Button variant="outline" className="w-full" asChild>
<a href="/account/magic-link">Request new magic link</a>
</Button>
</AuthCard>
)
}
if (authSuccess) {
return (
<AuthCard
title="Signed in successfully!"
description="Redirecting to your dashboard"
>
<Alert className="border-green-500/50 bg-green-500/10">
<CheckCircle2 className="h-4 w-4 text-green-500" />
<AlertDescription>
You've been successfully signed in with your magic link.
</AlertDescription>
</Alert>
<Button className="w-full" asChild>
<a href="/account">Go to dashboard</a>
</Button>
</AuthCard>
)
}
return null
}
```
**Key Changes:**
- ❌ Removed ConvexHttpClient import
- ❌ Removed api import
- ✅ Added `useMagicLinkAuth()` hook
- 🔥 **46 fewer lines** (151 → 105)
- ✅ Automatic cookie management (hook handles it)
- ✅ Cleaner error handling
---
### 2.4 TwoFactorSettings Migration
**BEFORE (Current - 293 lines):**
```typescript
// frontend/src/components/auth/TwoFactorSettings.tsx (BEFORE)
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { toast } from "sonner";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Shield, Key, Copy, CheckCircle2 } from "lucide-react";
import { ConvexHttpClient } from "convex/browser"; // ❌ REMOVE
import { api } from "../../../convex/_generated/api"; // ❌ REMOVE
import * as OTPAuth from "otpauth";
import QRCode from "qrcode";
const convex = new ConvexHttpClient( // ❌ REMOVE
import.meta.env.PUBLIC_CONVEX_URL || import.meta.env.NEXT_PUBLIC_CONVEX_URL,
);
export function TwoFactorSettings() {
const [loading, setLoading] = useState(false); // ❌ Manual state
const [status, setStatus] = useState<{ enabled: boolean; hasSetup: boolean }>(
{ enabled: false, hasSetup: false },
);
const [showSetup, setShowSetup] = useState(false);
const [secret, setSecret] = useState("");
const [backupCodes, setBackupCodes] = useState<string[]>([]);
const [qrCodeUrl, setQrCodeUrl] = useState("");
const [verificationCode, setVerificationCode] = useState("");
const [disablePassword, setDisablePassword] = useState("");
useEffect(() => {
loadStatus();
}, []);
const loadStatus = async () => {
try {
// ❌ Manual token extraction from cookies
const token = document.cookie
.split("; ")
.find((row) => row.startsWith("auth_token="))
?.split("=")[1];
if (!token) return;
// ❌ Direct Convex query
const result = await convex.query(api.auth.get2FAStatus, { token });
setStatus(result);
} catch (err) {
console.error("Failed to load 2FA status:", err);
}
};
const handleSetup = async () => {
setLoading(true); // ❌ Manual state
try {
const token = document.cookie
.split("; ")
.find((row) => row.startsWith("auth_token="))
?.split("=")[1];
if (!token) {
toast.error("Not authenticated");
return;
}
// ❌ Direct Convex mutation
const result = await convex.mutation(api.auth.setup2FA, { token });
setSecret(result.secret);
setBackupCodes(result.backupCodes);
// Generate TOTP URI
const totp = new OTPAuth.TOTP({
issuer: "ONE",
label: "user@one.ie",
algorithm: "SHA1",
digits: 6,
period: 30,
secret: OTPAuth.Secret.fromBase32(
result.secret.toUpperCase().padEnd(32, "A"),
),
});
const uri = totp.toString();
const qrUrl = await QRCode.toDataURL(uri);
setQrCodeUrl(qrUrl);
setShowSetup(true);
toast.success("2FA setup initiated", {
description: "Scan the QR code with your authenticator app",
});
} catch (err: any) {
toast.error("Setup failed", {
description: err.message || "Failed to setup 2FA",
});
} finally {
setLoading(false); // ❌ Manual state
}
};
const handleVerify = async () => {
setLoading(true);
try {
const token = document.cookie
.split("; ")
.find((row) => row.startsWith("auth_token="))
?.split("=")[1];
if (!token) {
toast.error("Not authenticated");
return;
}
// Client-side TOTP verification
const totp = new OTPAuth.TOTP({
issuer: "ONE",
label: "user@one.ie",
algorithm: "SHA1",
digits: 6,
period: 30,
secret: OTPAuth.Secret.fromBase32(secret.toUpperCase().padEnd(32, "A")),
});
const delta = totp.validate({ token: verificationCode, window: 1 });
if (delta === null) {
toast.error("Invalid code", {
description: "The verification code is incorrect. Please try again.",
});
setLoading(false);
return;
}
// ❌ Direct Convex mutation
await convex.mutation(api.auth.verify2FA, { token });
toast.success("2FA enabled!", {
description:
"Two-factor authentication has been enabled for your account",
});
setShowSetup(false);
loadStatus();
} catch (err: any) {
toast.error("Verification failed", {
description: err.message || "Failed to verify code",
});
} finally {
setLoading(false);
}
};
const handleDisable = async () => {
setLoading(true);
try {
const token = document.cookie
.split("; ")
.find((row) => row.startsWith("auth_token="))
?.split("=")[1];
if (!token) {
toast.error("Not authenticated");
return;
}
// ❌ Direct Convex mutation
await convex.mutation(api.auth.disable2FA, {
token,
password: disablePassword,
});
toast.success("2FA disabled", {
description: "Two-factor authentication has been disabled",
});
setDisablePassword("");
loadStatus();
} catch (err: any) {
toast.error("Failed to disable 2FA", {
description: err.message || "Incorrect password",
});
} finally {
setLoading(false);
}
};
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
toast.success("Copied to clipboard");
};
// ... rest of component (render logic - 150+ lines unchanged)
}
```
**AFTER (Migrated - 210 lines):**
```typescript
// frontend/src/components/auth/TwoFactorSettings.tsx (AFTER)
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { toast } from "sonner";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Shield, Key, Copy, CheckCircle2 } from "lucide-react";
import { use2FA } from "@/providers/hooks"; // ✅ DataProvider hook
import * as OTPAuth from "otpauth";
import QRCode from "qrcode";
export function TwoFactorSettings() {
const { getStatus, setup, verify, disable, loading, error } = use2FA(); // ✅ Hook
const [status, setStatus] = useState<{ enabled: boolean; hasSetup: boolean }>(
{ enabled: false, hasSetup: false },
);
const [showSetup, setShowSetup] = useState(false);
const [secret, setSecret] = useState("");
const [backupCodes, setBackupCodes] = useState<string[]>([]);
const [qrCodeUrl, setQrCodeUrl] = useState("");
const [verificationCode, setVerificationCode] = useState("");
const [disablePassword, setDisablePassword] = useState("");
useEffect(() => {
loadStatus();
}, []);
const loadStatus = async () => {
try {
const result = await getStatus(); // ✅ Simple hook call (no token needed)
setStatus(result);
} catch (err) {
console.error("Failed to load 2FA status:", err);
}
};
const handleSetup = async () => {
try {
const result = await setup(); // ✅ Simple hook call
setSecret(result.secret);
setBackupCodes(result.backupCodes);
// Generate TOTP URI (unchanged)
const totp = new OTPAuth.TOTP({
issuer: "ONE",
label: "user@one.ie",
algorithm: "SHA1",
digits: 6,
period: 30,
secret: OTPAuth.Secret.fromBase32(
result.secret.toUpperCase().padEnd(32, "A"),
),
});
const uri = totp.toString();
const qrUrl = await QRCode.toDataURL(uri);
setQrCodeUrl(qrUrl);
setShowSetup(true);
toast.success("2FA setup initiated", {
description: "Scan the QR code with your authenticator app",
});
} catch (err: any) {
toast.error("Setup failed", {
description: err.message || "Failed to setup 2FA",
});
}
};
const handleVerify = async () => {
try {
// Client-side TOTP verification (unchanged)
const totp = new OTPAuth.TOTP({
issuer: "ONE",
label: "user@one.ie",
algorithm: "SHA1",
digits: 6,
period: 30,
secret: OTPAuth.Secret.fromBase32(secret.toUpperCase().padEnd(32, "A")),
});
const delta = totp.validate({ token: verificationCode, window: 1 });
if (delta === null) {
toast.error("Invalid code", {
description: "The verification code is incorrect. Please try again.",
});
return;
}
await verify(verificationCode); // ✅ Simple hook call
toast.success("2FA enabled!", {
description:
"Two-factor authentication has been enabled for your account",
});
setShowSetup(false);
loadStatus();
} catch (err: any) {
toast.error("Verification failed", {
description: err.message || "Failed to verify code",
});
}
};
const handleDisable = async () => {
try {
await disable(disablePassword); // ✅ Simple hook call
toast.success("2FA disabled", {
description: "Two-factor authentication has been disabled",
});
setDisablePassword("");
loadStatus();
} catch (err: any) {
toast.error("Failed to disable 2FA", {
description: err.message || "Incorrect password",
});
}
};
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
toast.success("Copied to clipboard");
};
// ... render logic (unchanged - 100+ lines)
}
```
**Key Changes:**
- ❌ Removed ConvexHttpClient
- ❌ Removed manual token extraction
- ✅ Added `use2FA()` hook with all operations
- 🔥 **83 fewer lines** (293 → 210)
- ✅ Automatic authentication (hook handles tokens)
- ✅ Same functionality, cleaner code
---
### 2.5 VerifyEmailForm Migration
**BEFORE (Current - 134 lines):**
```typescript
// frontend/src/components/auth/VerifyEmailForm.tsx (BEFORE)
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
import { AuthCard } from "./AuthCard";
import { CheckCircle2, Mail } from "lucide-react";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { ConvexHttpClient } from "convex/browser"; // ❌ REMOVE
import { api } from "../../../convex/_generated/api"; // ❌ REMOVE
const convex = new ConvexHttpClient( // ❌ REMOVE
import.meta.env.PUBLIC_CONVEX_URL || import.meta.env.NEXT_PUBLIC_CONVEX_URL,
);
interface VerifyEmailFormProps {
token: string;
}
export function VerifyEmailForm({ token }: VerifyEmailFormProps) {
const [loading, setLoading] = useState(false); // ❌ Manual state
const [verifySuccess, setVerifySuccess] = useState(false);
const [tokenValid, setTokenValid] = useState(true);
const [verifying, setVerifying] = useState(true);
useEffect(() => {
const verifyToken = async () => {
if (!token) {
setTokenValid(false);
setVerifying(false);
toast.error("No verification token", {
description: "This verification link is missing a token.",
});
return;
}
setLoading(true);
try {
// ❌ Direct Convex mutation
const result = await convex.mutation(api.auth.verifyEmail, { token });
if (result?.success) {
setVerifySuccess(true);
toast.success("Email verified successfully!", {
description:
"Your email has been verified. You can now access all features.",
});
}
} catch (err: any) {
setTokenValid(false);
toast.error("Verification failed", {
description: err.message || "Invalid or expired link",
});
} finally {
setLoading(false);
setVerifying(false);
}
};
verifyToken();
}, [token]);
// ... render logic (unchanged)
}
```
**AFTER (Migrated - 95 lines):**
```typescript
// frontend/src/components/auth/VerifyEmailForm.tsx (AFTER)
import { useState, useEffect } from "react"
import { Button } from "@/components/ui/button"
import { toast } from "sonner"
import { AuthCard } from "./AuthCard"
import { CheckCircle2, Mail } from "lucide-react"
import { Alert, AlertDescription } from "@/components/ui/alert"
import { useVerifyEmail } from "@/providers/hooks" // ✅ DataProvider hook
interface VerifyEmailFormProps {
token: string
}
export function VerifyEmailForm({ token }: VerifyEmailFormProps) {
const { mutate: verify, loading, error } = useVerifyEmail() // ✅ Hook
const [verifySuccess, setVerifySuccess] = useState(false)
const [tokenValid, setTokenValid] = useState(true)
useEffect(() => {
const verifyToken = async () => {
if (!token) {
setTokenValid(false)
toast.error("No verification token", {
description: "This verification link is missing a token."
})
return
}
try {
const result = await verify({ token }) // ✅ Simple hook call
if (result.success) {
setVerifySuccess(true)
toast.success("Email verified successfully!", {
description: "Your email has been verified. You can now access all features."
})
}
} catch (err: any) {
setTokenValid(false)
let title = "Verification failed"
let description = err.message
// ✅ Typed error handling
if (err._tag === "InvalidToken" || err._tag === "TokenExpired") {
title = "Invalid or expired link"
description = "This verification link has expired or is invalid."
}
toast.error(title, { description })
}
}
verifyToken()
}, [token])
if (loading) {
return (
<AuthCard
title="Verifying your email"
description="Please wait while we verify your email address"
>
<div className="flex justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
</AuthCard>
)
}
if (!tokenValid) {
return (
<AuthCard
title="Verification failed"
description="This verification link is no longer valid"
>
<Alert variant="destructive">
<Mail className="h-4 w-4" />
<AlertDescription>
This verification link has expired or is invalid. If you need a new verification email, please contact support.
</AlertDescription>
</Alert>
<Button variant="outline" className="w-full" asChild>
<a href="/account">Go to dashboard</a>
</Button>
</AuthCard>
)
}
if (verifySuccess) {
return (
<AuthCard
title="Email verified!"
description="Your email has been successfully verified"
>
<Alert className="border-green-500/50 bg-green-500/10">
<CheckCircle2 className="h-4 w-4 text-green-500" />
<AlertDescription>
Your email has been successfully verified. You can now access all features.
</AlertDescription>
</Alert>
<Button className="w-full" asChild>
<a href="/account">Go to dashboard</a>
</Button>
</AuthCard>
)
}
return null
}
```
**Key Changes:**
- 🔥 **39 fewer lines** (134 → 95)
- ✅ Simple hook-based verification
- ✅ Automatic verification on mount
---
### 2.6 ForgotPasswordForm Migration
**BEFORE (Current - 144 lines):**
```typescript
// frontend/src/components/auth/ForgotPasswordForm.tsx (BEFORE)
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { toast } from "sonner";
import { AuthCard } from "./AuthCard";
import { AlertCircle, CheckCircle2 } from "lucide-react";
import { Alert, AlertDescription } from "@/components/ui/alert";
export function ForgotPasswordForm() {
const [email, setEmail] = useState("");
const [loading, setLoading] = useState(false); // ❌ Manual state
const [emailSent, setEmailSent] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true); // ❌ Manual loading
try {
// ❌ Direct fetch call
const response = await fetch("/api/auth/forgot-password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
});
const data = await response.json();
if (!response.ok) {
const errorMessage = data.error || "Unable to send reset email";
// ... string-based error handling
toast.error("Unable to send reset email", {
description: errorMessage,
});
setLoading(false); // ❌ Manual state
return;
}
setEmailSent(true);
toast.success("Reset email sent!", {
description: "Check your inbox for password reset instructions.",
});
setLoading(false);
} catch (err: any) {
toast.error("Request failed", {
description: `Error: ${err.message}. Please try again later.`,
});
setLoading(false); // ❌ Manual state
}
};
// ... render logic (unchanged)
}
```
**AFTER (Migrated - 105 lines):**
```typescript
// frontend/src/components/auth/ForgotPasswordForm.tsx (AFTER)
import { useState } from "react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { toast } from "sonner"
import { AuthCard } from "./AuthCard"
import { AlertCircle, CheckCircle2 } from "lucide-react"
import { Alert, AlertDescription } from "@/components/ui/alert"
import { usePasswordReset } from "@/providers/hooks" // ✅ DataProvider hook
export function ForgotPasswordForm() {
const [email, setEmail] = useState("")
const { mutate: requestReset, loading, error } = usePasswordReset() // ✅ Hook
const [emailSent, setEmailSent] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
try {
await requestReset({ email }) // ✅ Simple hook call
setEmailSent(true)
toast.success("Reset email sent!", {
description: "Check your inbox for password reset instructions."
})
} catch (err: any) {
let title = "Unable to send reset email"
let description = err.message || "Please try again."
// ✅ Typed error handling
if (err._tag === "UserNotFound") {
title = "Email not found"
description = "No account exists with this email address."
} else if (err._tag === "RateLimitExceeded") {
title = "Too many requests"
description = "Please wait a few minutes and try again."
} else if (err._tag === "NetworkError") {
title = "Network error"
description = "Unable to connect to the server."
}
toast.error(title, { description })
}
}
if (emailSent) {
return (
<AuthCard
title="Check your email"
description="We've sent password reset instructions"
>
<Alert className="border-green-500/50 bg-green-500/10">
<CheckCircle2 className="h-4 w-4 text-green-500" />
<AlertDescription>
If an account exists with <strong>{email}</strong>, you will receive an email.
</AlertDescription>
</Alert>
<Button
variant="outline"
className="w-full"
onClick={() => {
setEmailSent(false)
setEmail("")
}}
>
Try another email
</Button>
</AuthCard>
)
}
return (
<AuthCard title="Forgot your password?" description="Enter your email to receive reset instructions">
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription>
We'll send you an email with instructions to reset your password.
</AlertDescription>
</Alert>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="your@email.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<Button type="submit" className="w-full" disabled={loading}>
{loading ? "Sending..." : "Send reset email"}
</Button>
</form>
</AuthCard>
)
}
```
**Key Changes:**
- 🔥 **39 fewer lines** (144 → 105)
- ❌ Removed fetch call
- ✅ Added hook-based request
---
### 2.7 ResetPasswordForm Migration
**AFTER (Complete):**
```typescript
// frontend/src/components/auth/ResetPasswordForm.tsx (AFTER)
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { toast } from "sonner";
import { AuthCard } from "./AuthCard";
import { PasswordStrengthIndicator } from "./PasswordStrengthIndicator";
import { CheckCircle2 } from "lucide-react";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { usePasswordResetComplete } from "@/providers/hooks"; // ✅ Hook
interface ResetPasswordFormProps {
token: string;
}
export function ResetPasswordForm({ token }: ResetPasswordFormProps) {
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const { mutate: resetPassword, loading, error } = usePasswordResetComplete(); // ✅ Hook
const [resetSuccess, setResetSuccess] = useState(false);
const [tokenValid, setTokenValid] = useState(true);
useEffect(() => {
// Token validation can be done on mount if needed
if (!token) {
setTokenValid(false);
toast.error("Invalid or expired token", {
description: "This password reset link is invalid or has expired.",
});
}
}, [token]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (password !== confirmPassword) {
toast.error("Passwords don't match", {
description: "Please make sure both password fields match.",
});
return;
}
if (password.length < 8) {
toast.error("Password too short", {
description: "Password must be at least 8 characters long.",
});
return;
}
try {
await resetPassword({ token, newPassword: password }); // ✅ Simple hook call
setResetSuccess(true);
toast.success("Password reset successful!", {
description:
"Your password has been updated. Redirecting to sign in...",
});
setTimeout(() => {
window.location.href = "/account/signin";
}, 2000);
} catch (err: any) {
let title = "Unable to reset password";
let description = err.message || "Please try again.";
// ✅ Typed error handling
if (err._tag === "InvalidToken" || err._tag === "TokenExpired") {
title = "Invalid or expired token";
description =
"This password reset link is invalid or has expired. Please request a new one.";
setTokenValid(false);
} else if (err._tag === "WeakPassword") {
title = "Invalid password";
description =
"Password must be at least 8 characters with letters and numbers.";
} else if (err._tag === "NetworkError") {
title = "Network error";
description = "Unable to connect to the server.";
}
toast.error(title, { description });
}
};
// ... render logic (similar to BEFORE)
}
```
---
## 3. Implementation Procedure (60 Steps)
### Phase 1: Pre-Migration Setup (Steps 1-12)
**Step 1: Audit Current Implementation**
```bash
cd /Users/toc/Server/ONE/frontend
# Count lines
wc -l src/components/auth/*.tsx
# Find dependencies
grep -r "authClient" src/components/auth/
grep -r "ConvexHttpClient" src/components/auth/
grep -r "import.*convex" src/components/auth/
```
**Expected Output:**
```
163 SimpleSignInForm.tsx
157 SimpleSignUpForm.tsx
151 MagicLinkAuth.tsx
293 TwoFactorSettings.tsx
134 VerifyEmailForm.tsx
144 ForgotPasswordForm.tsx
206 ResetPasswordForm.tsx
```
**Step 2: Run Baseline Tests**
```bash
# Save baseline results
bun test test/auth > baseline-auth-tests.t