跳到主要内容

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