- Rate-limit /api/flows/start to 10 req/min per user (in-memory, matches login pattern) - Add middleware.ts: validates x-csrf-token header against csrf_token cookie on all API POST requests; issues the cookie on GET if missing; sets CSP, X-Frame-Options, X-Content-Type-Options, and Referrer-Policy on all responses - Add lib/csrf.ts: client-side apiFetch() wrapper that injects the CSRF header - Update all client components (login, useFlowRun, docker, caddy, git, systemd) to use apiFetch() for POST requests - Cookie config in login route already correct (NODE_ENV check, httpOnly, sameSite=strict) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
98 lines
3.4 KiB
TypeScript
98 lines
3.4 KiB
TypeScript
'use client'
|
|
|
|
import { useState } from 'react'
|
|
import { useRouter } from 'next/navigation'
|
|
import { Button } from '@/components/ui/button'
|
|
import { apiFetch } from '@/lib/csrf'
|
|
|
|
export default function LoginPage() {
|
|
const router = useRouter()
|
|
const [email, setEmail] = useState('')
|
|
const [password, setPassword] = useState('')
|
|
const [error, setError] = useState('')
|
|
const [loading, setLoading] = useState(false)
|
|
|
|
async function handleSubmit(e: React.FormEvent) {
|
|
e.preventDefault()
|
|
setError('')
|
|
setLoading(true)
|
|
|
|
try {
|
|
const res = await apiFetch('/api/auth/login', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ email, password }),
|
|
})
|
|
|
|
if (res.ok) {
|
|
router.push('/')
|
|
router.refresh()
|
|
} else {
|
|
const data = await res.json()
|
|
setError(data.error ?? 'Login failed')
|
|
}
|
|
} catch {
|
|
setError('Network error, please try again')
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="flex min-h-full items-center justify-center bg-zinc-50 dark:bg-zinc-950">
|
|
<div className="w-full max-w-sm rounded-xl border border-zinc-200 bg-white p-8 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
|
|
<h1 className="mb-6 text-xl font-semibold text-zinc-900 dark:text-zinc-50">
|
|
Ops Dashboard
|
|
</h1>
|
|
|
|
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
|
<div className="flex flex-col gap-1.5">
|
|
<label
|
|
htmlFor="email"
|
|
className="text-sm font-medium text-zinc-700 dark:text-zinc-300"
|
|
>
|
|
Email
|
|
</label>
|
|
<input
|
|
id="email"
|
|
type="email"
|
|
autoComplete="email"
|
|
required
|
|
value={email}
|
|
onChange={(e) => setEmail(e.target.value)}
|
|
className="rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 outline-none placeholder:text-zinc-400 focus:border-zinc-500 focus:ring-2 focus:ring-zinc-500/20 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder:text-zinc-500 dark:focus:border-zinc-400"
|
|
placeholder="admin@example.com"
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-1.5">
|
|
<label
|
|
htmlFor="password"
|
|
className="text-sm font-medium text-zinc-700 dark:text-zinc-300"
|
|
>
|
|
Password
|
|
</label>
|
|
<input
|
|
id="password"
|
|
type="password"
|
|
autoComplete="current-password"
|
|
required
|
|
value={password}
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
className="rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 outline-none placeholder:text-zinc-400 focus:border-zinc-500 focus:ring-2 focus:ring-zinc-500/20 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder:text-zinc-500 dark:focus:border-zinc-400"
|
|
placeholder="••••••••"
|
|
/>
|
|
</div>
|
|
|
|
{error && (
|
|
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
|
|
)}
|
|
|
|
<Button type="submit" disabled={loading} className="mt-2 w-full">
|
|
{loading ? 'Signing in…' : 'Sign in'}
|
|
</Button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|