feat(web): /login page with username + password form

Server-rendered card-style login. Form posts to loginAction; on
failure the client renders the generic 'Invalid username or
password' error. Centred, mobile-first, autocomplete-friendly so
phone PWAs autofill from the keychain on subsequent logins.
This commit is contained in:
yiekheng 2026-05-10 17:52:35 +08:00
parent cedd623466
commit 4f1056cdcd
2 changed files with 95 additions and 0 deletions

View File

@ -0,0 +1,67 @@
"use client";
import { useState, useTransition } from "react";
import { Loader2Icon, LockIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { loginAction } from "@/actions/auth";
export function LoginFormClient({ next }: { next: string }) {
const [pending, start] = useTransition();
const [error, setError] = useState<string | null>(null);
function handle(formData: FormData) {
formData.append("next", next);
start(async () => {
setError(null);
const r = await loginAction(formData);
// On success, the action redirects (no return). If we land here,
// something failed and `r` is the error shape.
if (r && !r.ok) setError(r.error);
});
}
return (
<form action={handle} className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="username">Username</Label>
<Input
id="username"
name="username"
type="text"
autoComplete="username"
autoFocus
required
maxLength={256}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="password">Password</Label>
<Input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
maxLength={256}
/>
</div>
{error && (
<div className="text-xs text-destructive">{error}</div>
)}
<Button type="submit" disabled={pending} className="w-full gap-2">
{pending ? (
<Loader2Icon className="size-4 animate-spin" />
) : (
<LockIcon className="size-4" />
)}
Sign in
</Button>
<p className="text-xs text-muted-foreground text-center">
First time? Run <code>./scripts/set-password.sh &lt;username&gt;</code>{" "}
in your tools container.
</p>
</form>
);
}

View File

@ -0,0 +1,28 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { LoginFormClient } from "./login-form-client";
export const metadata = {
title: "Sign in",
};
interface PageProps {
searchParams: Promise<{ next?: string }>;
}
export default async function LoginPage({ searchParams }: PageProps) {
const sp = await searchParams;
const next = sp.next ?? "/";
return (
<div className="min-h-dvh flex items-center justify-center px-4 py-8">
<Card className="w-full max-w-sm">
<CardHeader>
<CardTitle>Sign in</CardTitle>
</CardHeader>
<CardContent>
<LoginFormClient next={next} />
</CardContent>
</Card>
</div>
);
}