feat: useLogin 훅에서 사용자 정보를 구조 분해 할당하여 로딩 상태 처리 추가 및 프로필 API 엔드포인트 생성

This commit is contained in:
monoid 2025-11-06 01:23:37 +09:00
parent 358cb66780
commit 70930857a3
9 changed files with 88 additions and 58 deletions

View file

@ -41,7 +41,7 @@ const createNavItem = (key: NavLinkKey, name: string, className?: string): NavIt
}; };
function useNavItemsData() { function useNavItemsData() {
const loginInfo = useLogin(); const { user: loginInfo } = useLogin();
const isLoggedIn = Boolean(loginInfo); const isLoggedIn = Boolean(loginInfo);
return useMemo(() => { return useMemo(() => {

View file

@ -43,7 +43,7 @@ function applyTagFilters(tags: TagRecord[], filters: TagFilters) {
{ {
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
const _exhaustiveCheck: never = orderBy; const _exhaustiveCheck: never = orderBy;
return filtered; return _exhaustiveCheck;
} }
break; break;
} }

View file

@ -41,7 +41,7 @@ export function ContentInfoPage({ params }: ContentInfoPageProps) {
const rehashDoc = useRehashDoc(); const rehashDoc = useRehashDoc();
const rescanDoc = useRescanDoc(); const rescanDoc = useRescanDoc();
const deleteDoc = useDeleteDoc(); const deleteDoc = useDeleteDoc();
const user = useLogin(); const { user } = useLogin();
const username = user?.username; const username = user?.username;
const isAdmin = username === "admin"; const isAdmin = username === "admin";

View file

@ -8,7 +8,11 @@ import { Link } from "wouter";
export function DifferencePage() { export function DifferencePage() {
const { data, isLoading, error } = useDifferenceDoc(); const { data, isLoading, error } = useDifferenceDoc();
const userInfo = useLogin(); const { user: userInfo, isLoading: userLoading } = useLogin();
if (userLoading) {
return <div className="p-4">Loading...</div>;
}
if (!userInfo) { if (!userInfo) {
return <div className="p-4 w-full flex flex-col items-center"> return <div className="p-4 w-full flex flex-col items-center">

View file

@ -3,7 +3,12 @@ import { useLogin } from "@/state/user";
import { Redirect } from "wouter"; import { Redirect } from "wouter";
export function ProfilePage() { export function ProfilePage() {
const userInfo = useLogin(); const { user: userInfo, isLoading } = useLogin();
if (isLoading) {
return <div className="p-4">Loading...</div>;
}
if (!userInfo) { if (!userInfo) {
console.error("User session expired. Redirecting to login page."); console.error("User session expired. Redirecting to login page.");
return <Redirect to="/login" />; return <Redirect to="/login" />;

View file

@ -10,7 +10,7 @@ import { ServerSettingCard } from "@/components/ServerSettingCard";
export function SettingPage() { export function SettingPage() {
const login = useLogin(); const { user: login } = useLogin();
const isAdmin = login?.username === "admin"; const isAdmin = login?.username === "admin";
const { data: serverSetting, error: serverError, isLoading: serverLoading, mutate } = useServerSettings(isAdmin); const { data: serverSetting, error: serverError, isLoading: serverLoading, mutate } = useServerSettings(isAdmin);

View file

@ -90,3 +90,20 @@ export async function resetPasswordService(username: string, oldpassword: string
} }
return b; return b;
} }
export async function getProfileService(): Promise<{
username: string;
permission: string[];
authenticated: boolean
}> {
const u = makeApiUrl("/api/user/profile");
const res = await fetch(u, {
method: "GET",
credentials: "include",
});
const b = await res.json();
if (!res.ok) {
throw new ApiError(b as ErrorFormat);
}
return b;
}

View file

@ -6,35 +6,14 @@ import {
logoutService, logoutService,
refreshService, refreshService,
resetPasswordService, resetPasswordService,
getProfileService,
} from "./api.ts"; } from "./api.ts";
import { useEffect, useState } from "react"; import { useEffect } from "react";
import useSWR, { mutate as mutateSWR } from "swr";
let localObj: LoginResponse | null = null; type ProfileResponse = Awaited<ReturnType<typeof getProfileService>>;
function getUserSessions() { export const PROFILE_SWR_KEY = "/api/user/profile";
if (!localObj) {
const storagestr = localStorage.getItem("UserLoginContext");
const storage = storagestr ? (JSON.parse(storagestr) as LoginResponse | null) : null;
// update localObj from storage
localObj = storage;
}
if (localObj && localObj.accessExpired > Math.floor(Date.now())) {
return {
username: localObj.username,
permission: localObj.permission,
accessExpired: localObj.accessExpired,
};
}
return null;
}
function setUserSessions(user: LoginResponse | null) {
localObj = user;
if (user) {
localStorage.setItem("UserLoginContext", JSON.stringify(user));
} else {
localStorage.removeItem("UserLoginContext");
}
}
class AuthEvent extends CustomEvent<{ class AuthEvent extends CustomEvent<{
type: "login" | "logout" | "refresh"; type: "login" | "logout" | "refresh";
@ -62,8 +41,8 @@ declare global {
export async function refresh() { export async function refresh() {
try { try {
const r = await refreshService(); const r = await refreshService();
setUserSessions(r);
window.dispatchEvent(new AuthEvent("refresh", { username: r.username, permission: r.permission })); window.dispatchEvent(new AuthEvent("refresh", { username: r.username, permission: r.permission }));
void mutateSWR(PROFILE_SWR_KEY);
return { return {
username: r.username, username: r.username,
permission: r.permission, permission: r.permission,
@ -72,8 +51,8 @@ export async function refresh() {
if (e instanceof ApiError) { if (e instanceof ApiError) {
console.error(`Refresh failed: ${e.detail}`); console.error(`Refresh failed: ${e.detail}`);
} }
setUserSessions(null);
window.dispatchEvent(new AuthEvent("logout", null)); window.dispatchEvent(new AuthEvent("logout", null));
void mutateSWR(PROFILE_SWR_KEY, { username: "", permission: [], authenticated: false }, false);
return { return {
username: "", username: "",
permission: [], permission: [],
@ -84,8 +63,8 @@ export async function refresh() {
export const doLogout = async () => { export const doLogout = async () => {
try { try {
const res = await logoutService(); const res = await logoutService();
setUserSessions(null);
window.dispatchEvent(new AuthEvent("logout", null)); window.dispatchEvent(new AuthEvent("logout", null));
void mutateSWR(PROFILE_SWR_KEY, { username: "", permission: [], authenticated: false }, false);
return { return {
username: res.username, username: res.username,
permission: res.permission, permission: res.permission,
@ -93,7 +72,7 @@ export const doLogout = async () => {
} catch (error) { } catch (error) {
console.error(`Server Error ${error}`); console.error(`Server Error ${error}`);
setUserSessions(null); void mutateSWR(PROFILE_SWR_KEY, { username: "", permission: [], authenticated: false }, false);
return { username: "", permission: [] }; return { username: "", permission: [] };
} }
}; };
@ -107,8 +86,8 @@ export const doLogin = async (userLoginInfo: LoginRequest): Promise<{
}> => { }> => {
try { try {
const b = await loginService(userLoginInfo); const b = await loginService(userLoginInfo);
setUserSessions(b);
window.dispatchEvent(new AuthEvent("login", { username: b.username, permission: b.permission })); window.dispatchEvent(new AuthEvent("login", { username: b.username, permission: b.permission }));
void mutateSWR(PROFILE_SWR_KEY);
return { ok: true, data: b }; return { ok: true, data: b };
} catch (e) { } catch (e) {
if (e instanceof ApiError) { if (e instanceof ApiError) {
@ -135,34 +114,50 @@ export const doResetPassword = async (username: string, oldpassword: string, new
} }
}; };
export function useLogin() { export function useLogin() {
const [user, setUser] = useState(getUserSessions()); const { data, error, isLoading, mutate } = useSWR<ProfileResponse>(
PROFILE_SWR_KEY,
async () => {
try {
return await getProfileService();
} catch (err) {
if (err instanceof ApiError && err.code === 401) {
return {
authenticated: false,
permission: [],
username: "",
};
}
throw err;
}
},
{
revalidateOnFocus: true,
shouldRetryOnError: false,
},
);
useEffect(() => { useEffect(() => {
const listener = (_e: AuthEvent) => { const listener = () => {
setUser(getUserSessions()); void mutate();
}; };
window.addEventListener("auth", listener); window.addEventListener("auth", listener);
return () => { return () => {
window.removeEventListener("auth", listener); window.removeEventListener("auth", listener);
}; };
}, []); }, [mutate]);
return user;
}
const user = data?.authenticated ? {
permission: data.permission,
username: data.username,
} : null;
const resolvedError = error instanceof ApiError && error.code === 401 ? undefined : error;
document.addEventListener("visibilitychange", async () => { return {
if (document.visibilityState === "visible") { error: resolvedError,
const session = getUserSessions(); isLoading,
if (!session) { refreshProfile: mutate,
await refresh(); user,
};
} }
if (session && session.accessExpired - Date.now() < 5 * 60 * 1000) {
// access token will expire in 5 minutes, refresh it
await refresh();
}
// If the session is still valid, do nothing
}
});

View file

@ -238,6 +238,15 @@ async function authenticate(
export const createLoginRouter = (userController: UserAccessor) => { export const createLoginRouter = (userController: UserAccessor) => {
const router = new Hono<AppEnv>(); const router = new Hono<AppEnv>();
router.get("/profile", async (c) => {
const auth = c.get("auth");
return c.json({
username: auth.user.username,
permission: auth.user.permission,
authenticated: auth.authenticated,
});
});
router.post( router.post(
"/login", "/login",
sValidator("json", LoginBodySchema), sValidator("json", LoginBodySchema),