Auth UI
Implement authentication-related UI components and pages.
Overview
| Attribute | Value |
|---|---|
| Task ID | T7 |
| Dependencies | T3 (UI Shell), T4 (User API) |
| Effort | 3 points |
| Priority | P0 |
Objectives
- Create login page
- Create registration page
- Implement OAuth buttons
- Add form validation
- Handle auth errors
Deliverables
1. Login Page (apps/web/app/(auth)/login/page.tsx)
import { LoginForm } from '@/components/auth/login-form';
import { OAuthButtons } from '@/components/auth/oauth-buttons';
import Link from 'next/link';
export default function LoginPage() {
return (
<div className="mx-auto w-full max-w-md space-y-6">
<div className="space-y-2 text-center">
<h1 className="text-3xl font-bold">Welcome back</h1>
<p className="text-muted-foreground">
Sign in to your account to continue
</p>
</div>
<OAuthButtons />
<div className="relative">
<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 continue with email
</span>
</div>
</div>
<LoginForm />
<p className="text-center text-sm text-muted-foreground">
Don't have an account?{' '}
<Link href="/register" className="text-primary hover:underline">
Sign up
</Link>
</p>
</div>
);
}
2. Register Page (apps/web/app/(auth)/register/page.tsx)
import { RegisterForm } from '@/components/auth/register-form';
import { OAuthButtons } from '@/components/auth/oauth-buttons';
import Link from 'next/link';
export default function RegisterPage() {
return (
<div className="mx-auto w-full max-w-md space-y-6">
<div className="space-y-2 text-center">
<h1 className="text-3xl font-bold">Create an account</h1>
<p className="text-muted-foreground">
Get started with Viben
</p>
</div>
<OAuthButtons />
<div className="relative">
<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 continue with email
</span>
</div>
</div>
<RegisterForm />
<p className="text-center text-sm text-muted-foreground">
Already have an account?{' '}
<Link href="/login" className="text-primary hover:underline">
Sign in
</Link>
</p>
</div>
);
}
3. Auth Layout (apps/web/app/(auth)/layout.tsx)
import Link from 'next/link';
import { Package } from 'lucide-react';
export default function AuthLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="flex min-h-screen flex-col items-center justify-center p-4">
<Link href="/" className="mb-8 flex items-center gap-2">
<Package className="h-8 w-8 text-primary" />
<span className="font-serif text-2xl font-semibold">Viben</span>
</Link>
{children}
</div>
);
}
4. Login Form (apps/web/components/auth/login-form.tsx)
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { z } from 'zod';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { toast } from 'sonner';
import { Loader2 } from 'lucide-react';
const loginSchema = z.object({
email: z.string().email('Please enter a valid email'),
password: z.string().min(1, 'Password is required'),
});
type LoginValues = z.infer<typeof loginSchema>;
export function LoginForm() {
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
const form = useForm<LoginValues>({
resolver: zodResolver(loginSchema),
defaultValues: {
email: '',
password: '',
},
});
async function onSubmit(data: LoginValues) {
setIsLoading(true);
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.error || 'Login failed');
}
toast.success('Welcome back!');
router.push('/mcp');
router.refresh();
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Login failed');
} finally {
setIsLoading(false);
}
}
return (
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="you@example.com"
{...form.register('email')}
/>
{form.formState.errors.email && (
<p className="text-sm text-destructive">
{form.formState.errors.email.message}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
{...form.register('password')}
/>
{form.formState.errors.password && (
<p className="text-sm text-destructive">
{form.formState.errors.password.message}
</p>
)}
</div>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Sign in
</Button>
</form>
);
}
5. Register Form (apps/web/components/auth/register-form.tsx)
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { z } from 'zod';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { toast } from 'sonner';
import { Loader2 } from 'lucide-react';
const registerSchema = z.object({
email: z.string().email('Please enter a valid email'),
username: z
.string()
.min(3, 'Username must be at least 3 characters')
.max(30, 'Username must be at most 30 characters')
.regex(
/^[a-z0-9_-]+$/i,
'Username can only contain letters, numbers, underscores, and hyphens'
),
displayName: z.string().min(1, 'Display name is required').max(100),
password: z.string().min(8, 'Password must be at least 8 characters'),
confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
message: 'Passwords do not match',
path: ['confirmPassword'],
});
type RegisterValues = z.infer<typeof registerSchema>;
export function RegisterForm() {
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
const form = useForm<RegisterValues>({
resolver: zodResolver(registerSchema),
defaultValues: {
email: '',
username: '',
displayName: '',
password: '',
confirmPassword: '',
},
});
async function onSubmit(data: RegisterValues) {
setIsLoading(true);
try {
const response = await fetch('/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: data.email,
username: data.username,
displayName: data.displayName,
password: data.password,
}),
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.error || 'Registration failed');
}
toast.success('Account created! Welcome to Viben.');
router.push('/mcp');
router.refresh();
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Registration failed');
} finally {
setIsLoading(false);
}
}
return (
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="you@example.com"
{...form.register('email')}
/>
{form.formState.errors.email && (
<p className="text-sm text-destructive">
{form.formState.errors.email.message}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="username">Username</Label>
<Input
id="username"
placeholder="cooldev42"
{...form.register('username')}
/>
{form.formState.errors.username && (
<p className="text-sm text-destructive">
{form.formState.errors.username.message}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="displayName">Display Name</Label>
<Input
id="displayName"
placeholder="Your Name"
{...form.register('displayName')}
/>
{form.formState.errors.displayName && (
<p className="text-sm text-destructive">
{form.formState.errors.displayName.message}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
{...form.register('password')}
/>
{form.formState.errors.password && (
<p className="text-sm text-destructive">
{form.formState.errors.password.message}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword">Confirm Password</Label>
<Input
id="confirmPassword"
type="password"
{...form.register('confirmPassword')}
/>
{form.formState.errors.confirmPassword && (
<p className="text-sm text-destructive">
{form.formState.errors.confirmPassword.message}
</p>
)}
</div>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Create account
</Button>
</form>
);
}
6. OAuth Buttons (apps/web/components/auth/oauth-buttons.tsx)
'use client';
import { Button } from '@/components/ui/button';
import { Github } from 'lucide-react';
export function OAuthButtons() {
return (
<div className="grid gap-2">
<Button
variant="outline"
onClick={() => window.location.href = '/api/auth/github'}
>
<Github className="mr-2 h-4 w-4" />
Continue with GitHub
</Button>
</div>
);
}
Required shadcn/ui Components
pnpm dlx shadcn@latest add input
pnpm dlx shadcn@latest add label
pnpm dlx shadcn@latest add form
Required Dependencies
pnpm add @hookform/resolvers react-hook-form
Acceptance Criteria
- Login form validates inputs
- Login redirects to /mcp on success
- Login shows error on failure
- Register form validates all fields
- Password confirmation works
- Register redirects on success
- GitHub OAuth button works
- Loading states shown during submission
- Toast notifications for success/error
Notes
- Forms use react-hook-form + zod
- Client components for interactivity
- Server redirects handled via router.push
- Toast notifications via sonner