Skip to content

Commit 0f87377

Browse files
feat(web): implement authentication flow (#35)
- Add AuthProvider context (login/register/logout/token refresh) - Add ProtectedRoute wrapper guarding the dashboard - Add use-auth hook and wire auth into the query provider - Connect login/register pages to the API with Zod validation - Show user info and logout in the sidebar Uses the shared api-client and veridion_access_token key so auth state is consistent with the rest of the app.
1 parent b91fbbd commit 0f87377

8 files changed

Lines changed: 396 additions & 72 deletions

File tree

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
1+
import { ProtectedRoute } from '@/components/auth/protected-route';
12
import { Sidebar } from '@/components/sidebar';
23

34
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
45
return (
5-
<div className="flex h-screen overflow-hidden">
6-
<Sidebar />
7-
<main className="bg-muted/10 flex-1 overflow-y-auto p-6 lg:p-8">{children}</main>
8-
</div>
6+
<ProtectedRoute>
7+
<div className="flex h-screen overflow-hidden">
8+
<Sidebar />
9+
<main className="bg-muted/10 flex-1 overflow-y-auto p-6 lg:p-8">{children}</main>
10+
</div>
11+
</ProtectedRoute>
912
);
1013
}

apps/web/src/app/login/page.tsx

Lines changed: 60 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,53 @@
11
'use client';
22

3+
import { zodResolver } from '@hookform/resolvers/zod';
34
import { Eye, EyeOff, Lock, Mail, Shield } from 'lucide-react';
45
import Link from 'next/link';
56
import { useRouter } from 'next/navigation';
67
import { useState } from 'react';
8+
import { useForm } from 'react-hook-form';
79
import { toast } from 'sonner';
10+
import { z } from 'zod';
811

9-
import { apiFetch, storeAccessToken } from '@/lib/api-client';
12+
import { useAuth } from '@/lib/auth';
13+
14+
const loginSchema = z.object({
15+
email: z.string().email('Invalid email address'),
16+
password: z.string().min(1, 'Password is required'),
17+
});
18+
19+
type LoginForm = z.infer<typeof loginSchema>;
1020

1121
export default function LoginPage() {
22+
const { login, isAuthenticated } = useAuth();
1223
const router = useRouter();
1324
const [showPassword, setShowPassword] = useState(false);
14-
const [loading, setLoading] = useState(false);
25+
const [serverError, setServerError] = useState<string | null>(null);
1526

16-
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
17-
e.preventDefault();
18-
setLoading(true);
27+
const {
28+
register,
29+
handleSubmit,
30+
formState: { errors, isSubmitting },
31+
} = useForm<LoginForm>({
32+
resolver: zodResolver(loginSchema),
33+
});
1934

20-
const formData = new FormData(e.currentTarget);
21-
const email = formData.get('email') as string;
22-
const password = formData.get('password') as string;
35+
// Redirect if already authenticated
36+
if (isAuthenticated) {
37+
router.replace('/dashboard');
38+
return null;
39+
}
2340

41+
async function onSubmit(data: LoginForm) {
42+
setServerError(null);
2443
try {
25-
const response = await apiFetch<{ accessToken: string }>('/auth/login', {
26-
method: 'POST',
27-
body: JSON.stringify({ email, password }),
28-
});
29-
storeAccessToken(response.accessToken);
44+
await login(data.email, data.password);
3045
toast.success('Welcome back!');
3146
router.push('/dashboard');
32-
} catch (error) {
33-
toast.error(error instanceof Error ? error.message : 'Unable to sign in');
34-
} finally {
35-
setLoading(false);
47+
} catch (err) {
48+
const message = err instanceof Error ? err.message : 'Login failed';
49+
setServerError(message);
50+
toast.error(message);
3651
}
3752
}
3853

@@ -49,12 +64,20 @@ export default function LoginPage() {
4964
</div>
5065

5166
<div className="bg-card rounded-xl border p-6 shadow-sm">
67+
{/* Server error */}
68+
{serverError && (
69+
<div className="bg-destructive/10 text-destructive mb-4 rounded-lg px-4 py-2.5 text-sm">
70+
{serverError}
71+
</div>
72+
)}
73+
5274
<form
5375
onSubmit={(e) => {
54-
void handleSubmit(e);
76+
void handleSubmit(onSubmit)(e);
5577
}}
5678
className="space-y-5"
5779
>
80+
{/* Email */}
5881
<div>
5982
<label htmlFor="email" className="text-sm font-medium">
6083
Email address
@@ -63,16 +86,21 @@ export default function LoginPage() {
6386
<Mail className="text-muted-foreground absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2" />
6487
<input
6588
id="email"
66-
name="email"
6789
type="email"
6890
autoComplete="email"
69-
required
7091
placeholder="you@example.com"
71-
className="bg-background placeholder:text-muted-foreground focus:ring-ring w-full rounded-lg border py-2 pl-10 pr-3 text-sm focus:outline-none focus:ring-2"
92+
className={`bg-background placeholder:text-muted-foreground focus:ring-ring w-full rounded-lg border py-2 pl-10 pr-3 text-sm focus:outline-none focus:ring-2 ${
93+
errors.email ? 'border-destructive' : ''
94+
}`}
95+
{...register('email')}
7296
/>
7397
</div>
98+
{errors.email && (
99+
<p className="text-destructive mt-1 text-xs">{errors.email.message}</p>
100+
)}
74101
</div>
75102

103+
{/* Password */}
76104
<div>
77105
<div className="flex items-center justify-between">
78106
<label htmlFor="password" className="text-sm font-medium">
@@ -89,30 +117,35 @@ export default function LoginPage() {
89117
<Lock className="text-muted-foreground absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2" />
90118
<input
91119
id="password"
92-
name="password"
93120
type={showPassword ? 'text' : 'password'}
94121
autoComplete="current-password"
95-
required
96-
minLength={8}
97122
placeholder="Enter your password"
98-
className="bg-background placeholder:text-muted-foreground focus:ring-ring w-full rounded-lg border py-2 pl-10 pr-10 text-sm focus:outline-none focus:ring-2"
123+
className={`bg-background placeholder:text-muted-foreground focus:ring-ring w-full rounded-lg border py-2 pl-10 pr-10 text-sm focus:outline-none focus:ring-2 ${
124+
errors.password ? 'border-destructive' : ''
125+
}`}
126+
{...register('password')}
99127
/>
100128
<button
101129
type="button"
102130
onClick={() => setShowPassword(!showPassword)}
103131
className="text-muted-foreground hover:text-foreground absolute right-3 top-1/2 -translate-y-1/2"
132+
aria-label={showPassword ? 'Hide password' : 'Show password'}
104133
>
105134
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
106135
</button>
107136
</div>
137+
{errors.password && (
138+
<p className="text-destructive mt-1 text-xs">{errors.password.message}</p>
139+
)}
108140
</div>
109141

142+
{/* Submit */}
110143
<button
111144
type="submit"
112-
disabled={loading}
145+
disabled={isSubmitting}
113146
className="bg-primary text-primary-foreground hover:bg-primary/90 w-full rounded-lg px-4 py-2.5 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50"
114147
>
115-
{loading ? 'Signing in...' : 'Sign in'}
148+
{isSubmitting ? 'Signing in...' : 'Sign in'}
116149
</button>
117150
</form>
118151

apps/web/src/app/register/page.tsx

Lines changed: 64 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,49 @@
11
'use client';
22

3+
import { zodResolver } from '@hookform/resolvers/zod';
4+
import { registerSchema } from '@veridion/shared';
35
import { Eye, EyeOff, Lock, Mail, Shield, User } from 'lucide-react';
46
import Link from 'next/link';
57
import { useRouter } from 'next/navigation';
68
import { useState } from 'react';
9+
import { useForm } from 'react-hook-form';
710
import { toast } from 'sonner';
11+
import { type z } from 'zod';
812

9-
import { apiFetch, storeAccessToken } from '@/lib/api-client';
13+
import { useAuth } from '@/lib/auth';
14+
15+
type RegisterForm = z.infer<typeof registerSchema>;
1016

1117
export default function RegisterPage() {
18+
const { register: registerUser, isAuthenticated } = useAuth();
1219
const router = useRouter();
1320
const [showPassword, setShowPassword] = useState(false);
14-
const [loading, setLoading] = useState(false);
15-
16-
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
17-
e.preventDefault();
18-
setLoading(true);
21+
const [serverError, setServerError] = useState<string | null>(null);
1922

20-
const formData = new FormData(e.currentTarget);
21-
const displayName = formData.get('displayName') as string;
22-
const email = formData.get('email') as string;
23-
const password = formData.get('password') as string;
23+
const {
24+
register,
25+
handleSubmit,
26+
formState: { errors, isSubmitting },
27+
} = useForm<RegisterForm>({
28+
resolver: zodResolver(registerSchema),
29+
});
2430

25-
if (!/[A-Z]/.test(password) || !/[a-z]/.test(password) || !/[0-9]/.test(password)) {
26-
toast.error('Password must contain uppercase, lowercase, and a number');
27-
setLoading(false);
28-
return;
29-
}
31+
// Redirect if already authenticated
32+
if (isAuthenticated) {
33+
router.replace('/dashboard');
34+
return null;
35+
}
3036

37+
async function onSubmit(data: RegisterForm) {
38+
setServerError(null);
3139
try {
32-
const response = await apiFetch<{ accessToken: string }>('/auth/register', {
33-
method: 'POST',
34-
body: JSON.stringify({ displayName, email, password }),
35-
});
36-
storeAccessToken(response.accessToken);
40+
await registerUser(data.email, data.password, data.displayName);
3741
toast.success('Account created successfully!');
3842
router.push('/dashboard');
39-
} catch (error) {
40-
toast.error(error instanceof Error ? error.message : 'Unable to create account');
41-
} finally {
42-
setLoading(false);
43+
} catch (err) {
44+
const message = err instanceof Error ? err.message : 'Registration failed';
45+
setServerError(message);
46+
toast.error(message);
4347
}
4448
}
4549

@@ -58,12 +62,20 @@ export default function RegisterPage() {
5862
</div>
5963

6064
<div className="bg-card rounded-xl border p-6 shadow-sm">
65+
{/* Server error */}
66+
{serverError && (
67+
<div className="bg-destructive/10 text-destructive mb-4 rounded-lg px-4 py-2.5 text-sm">
68+
{serverError}
69+
</div>
70+
)}
71+
6172
<form
6273
onSubmit={(e) => {
63-
void handleSubmit(e);
74+
void handleSubmit(onSubmit)(e);
6475
}}
6576
className="space-y-5"
6677
>
78+
{/* Display name */}
6779
<div>
6880
<label htmlFor="displayName" className="text-sm font-medium">
6981
Display name
@@ -72,17 +84,20 @@ export default function RegisterPage() {
7284
<User className="text-muted-foreground absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2" />
7385
<input
7486
id="displayName"
75-
name="displayName"
7687
type="text"
77-
required
78-
minLength={2}
79-
maxLength={50}
8088
placeholder="John Doe"
81-
className="bg-background placeholder:text-muted-foreground focus:ring-ring w-full rounded-lg border py-2 pl-10 pr-3 text-sm focus:outline-none focus:ring-2"
89+
className={`bg-background placeholder:text-muted-foreground focus:ring-ring w-full rounded-lg border py-2 pl-10 pr-3 text-sm focus:outline-none focus:ring-2 ${
90+
errors.displayName ? 'border-destructive' : ''
91+
}`}
92+
{...register('displayName')}
8293
/>
8394
</div>
95+
{errors.displayName && (
96+
<p className="text-destructive mt-1 text-xs">{errors.displayName.message}</p>
97+
)}
8498
</div>
8599

100+
{/* Email */}
86101
<div>
87102
<label htmlFor="email" className="text-sm font-medium">
88103
Email address
@@ -91,16 +106,21 @@ export default function RegisterPage() {
91106
<Mail className="text-muted-foreground absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2" />
92107
<input
93108
id="email"
94-
name="email"
95109
type="email"
96110
autoComplete="email"
97-
required
98111
placeholder="you@example.com"
99-
className="bg-background placeholder:text-muted-foreground focus:ring-ring w-full rounded-lg border py-2 pl-10 pr-3 text-sm focus:outline-none focus:ring-2"
112+
className={`bg-background placeholder:text-muted-foreground focus:ring-ring w-full rounded-lg border py-2 pl-10 pr-3 text-sm focus:outline-none focus:ring-2 ${
113+
errors.email ? 'border-destructive' : ''
114+
}`}
115+
{...register('email')}
100116
/>
101117
</div>
118+
{errors.email && (
119+
<p className="text-destructive mt-1 text-xs">{errors.email.message}</p>
120+
)}
102121
</div>
103122

123+
{/* Password */}
104124
<div>
105125
<label htmlFor="password" className="text-sm font-medium">
106126
Password
@@ -109,34 +129,39 @@ export default function RegisterPage() {
109129
<Lock className="text-muted-foreground absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2" />
110130
<input
111131
id="password"
112-
name="password"
113132
type={showPassword ? 'text' : 'password'}
114133
autoComplete="new-password"
115-
required
116-
minLength={8}
117134
placeholder="Min. 8 characters, uppercase, lowercase, number"
118-
className="bg-background placeholder:text-muted-foreground focus:ring-ring w-full rounded-lg border py-2 pl-10 pr-10 text-sm focus:outline-none focus:ring-2"
135+
className={`bg-background placeholder:text-muted-foreground focus:ring-ring w-full rounded-lg border py-2 pl-10 pr-10 text-sm focus:outline-none focus:ring-2 ${
136+
errors.password ? 'border-destructive' : ''
137+
}`}
138+
{...register('password')}
119139
/>
120140
<button
121141
type="button"
122142
onClick={() => setShowPassword(!showPassword)}
123143
className="text-muted-foreground hover:text-foreground absolute right-3 top-1/2 -translate-y-1/2"
144+
aria-label={showPassword ? 'Hide password' : 'Show password'}
124145
>
125146
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
126147
</button>
127148
</div>
149+
{errors.password && (
150+
<p className="text-destructive mt-1 text-xs">{errors.password.message}</p>
151+
)}
128152
<p className="text-muted-foreground mt-1.5 text-xs">
129153
Must contain at least 8 characters, one uppercase letter, one lowercase letter, and
130154
one number.
131155
</p>
132156
</div>
133157

158+
{/* Submit */}
134159
<button
135160
type="submit"
136-
disabled={loading}
161+
disabled={isSubmitting}
137162
className="bg-primary text-primary-foreground hover:bg-primary/90 w-full rounded-lg px-4 py-2.5 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50"
138163
>
139-
{loading ? 'Creating account...' : 'Create account'}
164+
{isSubmitting ? 'Creating account...' : 'Create account'}
140165
</button>
141166
</form>
142167

0 commit comments

Comments
 (0)