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.
105 lines
3.0 KiB
TypeScript
105 lines
3.0 KiB
TypeScript
"use client";
|
|
|
|
import {
|
|
createContext,
|
|
useContext,
|
|
useState,
|
|
useEffect,
|
|
type ReactNode,
|
|
} from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import { api, setRefreshCallback } from "@/lib/api";
|
|
|
|
const ACCESS_KEY_LOCAL = "k-tv-token";
|
|
const ACCESS_KEY_SESSION = "k-tv-token-session";
|
|
const REFRESH_KEY = "k-tv-refresh-token";
|
|
|
|
interface AuthContextValue {
|
|
token: string | null;
|
|
refreshToken: string | null;
|
|
/** Always true (lazy init reads storage synchronously) */
|
|
isLoaded: boolean;
|
|
setTokens: (access: string | null, refresh: string | null, remember: boolean) => void;
|
|
}
|
|
|
|
const AuthContext = createContext<AuthContextValue | null>(null);
|
|
|
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
|
const router = useRouter();
|
|
|
|
const [token, setTokenState] = useState<string | null>(() => {
|
|
try {
|
|
return sessionStorage.getItem(ACCESS_KEY_SESSION) ?? localStorage.getItem(ACCESS_KEY_LOCAL);
|
|
} catch {
|
|
return null;
|
|
}
|
|
});
|
|
|
|
const [refreshToken, setRefreshTokenState] = useState<string | null>(() => {
|
|
try {
|
|
return localStorage.getItem(REFRESH_KEY);
|
|
} catch {
|
|
return null;
|
|
}
|
|
});
|
|
|
|
const setTokens = (access: string | null, refresh: string | null, remember: boolean) => {
|
|
try {
|
|
if (access === null) {
|
|
sessionStorage.removeItem(ACCESS_KEY_SESSION);
|
|
localStorage.removeItem(ACCESS_KEY_LOCAL);
|
|
localStorage.removeItem(REFRESH_KEY);
|
|
} else if (remember) {
|
|
localStorage.setItem(ACCESS_KEY_LOCAL, access);
|
|
sessionStorage.removeItem(ACCESS_KEY_SESSION);
|
|
if (refresh) {
|
|
localStorage.setItem(REFRESH_KEY, refresh);
|
|
} else {
|
|
localStorage.removeItem(REFRESH_KEY);
|
|
}
|
|
} else {
|
|
sessionStorage.setItem(ACCESS_KEY_SESSION, access);
|
|
localStorage.removeItem(ACCESS_KEY_LOCAL);
|
|
localStorage.removeItem(REFRESH_KEY);
|
|
}
|
|
} catch {
|
|
// storage unavailable — state-only fallback
|
|
}
|
|
setTokenState(access);
|
|
setRefreshTokenState(refresh);
|
|
};
|
|
|
|
// Wire up the transparent refresh callback in api.ts
|
|
useEffect(() => {
|
|
if (refreshToken) {
|
|
setRefreshCallback(async () => {
|
|
try {
|
|
const data = await api.auth.refresh(refreshToken);
|
|
const newRefresh = data.refresh_token ?? null;
|
|
setTokens(data.access_token, newRefresh, true);
|
|
return data.access_token;
|
|
} catch {
|
|
setTokens(null, null, false);
|
|
router.push("/login");
|
|
return null;
|
|
}
|
|
});
|
|
} else {
|
|
setRefreshCallback(null);
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [refreshToken]);
|
|
|
|
return (
|
|
<AuthContext.Provider value={{ token, refreshToken, isLoaded: true, setTokens }}>
|
|
{children}
|
|
</AuthContext.Provider>
|
|
);
|
|
}
|
|
|
|
export function useAuthContext() {
|
|
const ctx = useContext(AuthContext);
|
|
if (!ctx) throw new Error("useAuthContext must be used within AuthProvider");
|
|
return ctx;
|
|
}
|