Backend: add refresh JWT (30d, token_type claim), POST /auth/refresh endpoint (rotates token pair), remember_me on login, JWT_REFRESH_EXPIRY_DAYS env var. Extractors now reject refresh tokens on protected routes. Frontend: sessionStorage for non-remembered sessions, localStorage + refresh token for remembered sessions. Transparent 401 recovery in api.ts (retry once after refresh). Remember me checkbox on login page with security note when checked.
97 lines
3.4 KiB
TypeScript
97 lines
3.4 KiB
TypeScript
"use client";
|
|
|
|
import Link from "next/link";
|
|
import { useState } from "react";
|
|
import { useLogin } from "@/hooks/use-auth";
|
|
import { useConfig } from "@/hooks/use-channels";
|
|
|
|
export default function LoginPage() {
|
|
const [email, setEmail] = useState("");
|
|
const [password, setPassword] = useState("");
|
|
const [rememberMe, setRememberMe] = useState(false);
|
|
const { mutate: login, isPending, error } = useLogin();
|
|
const { data: config } = useConfig();
|
|
|
|
const handleSubmit = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
login({ email, password, rememberMe });
|
|
};
|
|
|
|
return (
|
|
<div className="w-full max-w-sm space-y-6">
|
|
<div className="space-y-1 text-center">
|
|
<h1 className="text-xl font-semibold text-zinc-100">Sign in</h1>
|
|
<p className="text-sm text-zinc-500">to manage your channels</p>
|
|
</div>
|
|
|
|
<form onSubmit={handleSubmit} className="space-y-4">
|
|
<div className="space-y-1.5">
|
|
<label className="block text-xs font-medium text-zinc-400">
|
|
Email
|
|
</label>
|
|
<input
|
|
type="email"
|
|
required
|
|
autoComplete="email"
|
|
value={email}
|
|
onChange={(e) => setEmail(e.target.value)}
|
|
placeholder="you@example.com"
|
|
className="w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 placeholder:text-zinc-600 focus:border-zinc-500 focus:outline-none"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-1.5">
|
|
<label className="block text-xs font-medium text-zinc-400">
|
|
Password
|
|
</label>
|
|
<input
|
|
type="password"
|
|
required
|
|
autoComplete="current-password"
|
|
value={password}
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
placeholder="••••••••"
|
|
className="w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 placeholder:text-zinc-600 focus:border-zinc-500 focus:outline-none"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-1">
|
|
<label className="flex cursor-pointer items-center gap-2">
|
|
<input
|
|
type="checkbox"
|
|
checked={rememberMe}
|
|
onChange={(e) => setRememberMe(e.target.checked)}
|
|
className="h-3.5 w-3.5 rounded border-zinc-600 bg-zinc-900 accent-white"
|
|
/>
|
|
<span className="text-xs text-zinc-400">Remember me</span>
|
|
</label>
|
|
{rememberMe && (
|
|
<p className="pl-5 text-xs text-amber-500/80">
|
|
A refresh token will be stored locally — don't share it.
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{error && <p className="text-xs text-red-400">{error.message}</p>}
|
|
|
|
<button
|
|
type="submit"
|
|
disabled={isPending}
|
|
className="w-full rounded-md bg-white px-3 py-2 text-sm font-medium text-zinc-900 transition-colors hover:bg-zinc-200 disabled:cursor-not-allowed disabled:opacity-50"
|
|
>
|
|
{isPending ? "Signing in…" : "Sign in"}
|
|
</button>
|
|
</form>
|
|
|
|
{config?.allow_registration !== false && (
|
|
<p className="text-center text-xs text-zinc-500">
|
|
No account?{" "}
|
|
<Link href="/register" className="text-zinc-300 hover:text-white">
|
|
Create one
|
|
</Link>
|
|
</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|