feat(auth): refresh tokens + remember me

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.
This commit is contained in:
2026-03-19 22:24:26 +01:00
parent 8bdd5e2277
commit d2412da057
13 changed files with 307 additions and 35 deletions

View File

@@ -8,12 +8,13 @@ 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 });
login({ email, password, rememberMe });
};
return (
@@ -54,6 +55,23 @@ export default function LoginPage() {
/>
</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&apos;t share it.
</p>
)}
</div>
{error && <p className="text-xs text-red-400">{error.message}</p>}
<button

View File

@@ -15,7 +15,7 @@ import { Toaster } from "@/components/ui/sonner";
import { ApiRequestError } from "@/lib/api";
function QueryProvider({ children }: { children: React.ReactNode }) {
const { token, setToken } = useAuthContext();
const { token, setTokens } = useAuthContext();
const router = useRouter();
const tokenRef = useRef(token);
useEffect(() => { tokenRef.current = token; }, [token]);
@@ -29,7 +29,7 @@ function QueryProvider({ children }: { children: React.ReactNode }) {
// Guests hitting 401 on restricted content should not be redirected.
if (error instanceof ApiRequestError && error.status === 401 && tokenRef.current) {
toast.warning("Session expired, please log in again.");
setToken(null);
setTokens(null, null, false);
router.push("/login");
}
},
@@ -39,7 +39,7 @@ function QueryProvider({ children }: { children: React.ReactNode }) {
// Mutations always require auth — redirect on 401 regardless.
if (error instanceof ApiRequestError && error.status === 401) {
toast.warning("Session expired, please log in again.");
setToken(null);
setTokens(null, null, false);
router.push("/login");
}
},