Skip to main content

Auth UI

Implement authentication-related UI components and pages.


Overview

AttributeValue
Task IDT7
DependenciesT3 (UI Shell), T4 (User API)
Effort3 points
PriorityP0

Objectives

  1. Create login page
  2. Create registration page
  3. Implement OAuth buttons
  4. Add form validation
  5. 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