refactor: DifferencePage 컴포넌트에 로그인 링크 추가 및 상태 관리 개선

This commit is contained in:
monoid 2025-10-08 16:22:47 +09:00
parent d19bb520ed
commit 6f02f21c7c
4 changed files with 123 additions and 82 deletions

View file

@ -4,16 +4,18 @@ import { Separator } from "@/components/ui/separator";
import { useDifferenceDoc, commit, commitAll } from "@/hook/useDifference"; import { useDifferenceDoc, commit, commitAll } from "@/hook/useDifference";
import { useLogin } from "@/state/user"; import { useLogin } from "@/state/user";
import { Fragment } from "react/jsx-runtime"; import { Fragment } from "react/jsx-runtime";
import { Link } from "wouter";
export function DifferencePage() { export function DifferencePage() {
const { data, isLoading, error } = useDifferenceDoc(); const { data, isLoading, error } = useDifferenceDoc();
const userInfo = useLogin(); const userInfo = useLogin();
if (!userInfo) { if (!userInfo) {
return <div className="p-4"> return <div className="p-4 w-full flex flex-col items-center">
<h2 className="text-3xl"> <h2 className="text-3xl">
Not logged in Not logged in
</h2> </h2>
<Link className="text-primary underline" href="/login">Go to Login</Link>
</div> </div>
} }
@ -26,7 +28,7 @@ export function DifferencePage() {
<Card> <Card>
<CardHeader className="relative"> <CardHeader className="relative">
<Button className="absolute right-2 top-8" variant="ghost" <Button className="absolute right-2 top-8" variant="ghost"
onClick={() => {commitAll("comic")}} onClick={() => { commitAll("comic") }}
>Commit All</Button> >Commit All</Button>
<CardTitle className="text-2xl">Difference</CardTitle> <CardTitle className="text-2xl">Difference</CardTitle>
<CardDescription>Scanned Files List</CardDescription> <CardDescription>Scanned Files List</CardDescription>
@ -41,11 +43,11 @@ export function DifferencePage() {
{x.map((y) => ( {x.map((y) => (
<div key={y.path} className="flex items-center mt-2"> <div key={y.path} className="flex items-center mt-2">
<p <p
className="flex-1 text-sm text-wrap">{y.path}</p> className="flex-1 text-sm text-wrap">{y.path}</p>
<Button <Button
className="flex-none ml-2" className="flex-none ml-2"
variant="outline" variant="outline"
onClick={() => {commit(y.path, y.type)}}> onClick={() => { commit(y.path, y.type) }}>
Commit Commit
</Button> </Button>
</div> </div>

View file

@ -14,7 +14,7 @@ export type LogoutResponse = {
}; };
export type RefreshResponse = LoginResponse & { export type RefreshResponse = LoginResponse & {
refresh: boolean; refresh: true;
}; };
export type ErrorFormat = { export type ErrorFormat = {

View file

@ -1,5 +1,3 @@
import { atom, useAtomValue } from "../lib/atom.ts";
import { createStore } from 'jotai';
import { LoginRequest } from "dbtype/mod.ts"; import { LoginRequest } from "dbtype/mod.ts";
import { import {
ApiError, ApiError,
@ -9,41 +7,63 @@ import {
refreshService, refreshService,
resetPasswordService, resetPasswordService,
} from "./api.ts"; } from "./api.ts";
import { useSyncExternalStore } from "react";
// Create a store for setting atom values outside components
const store = createStore();
let localObj: LoginResponse | null = null; let localObj: LoginResponse | null = null;
function getUserSessions() { function getUserSessions() {
if (localObj === null) { if (!localObj) {
const storagestr = localStorage.getItem("UserLoginContext") as string | null; const storagestr = localStorage.getItem("UserLoginContext");
const storage = storagestr !== null ? (JSON.parse(storagestr) as LoginResponse | null) : null; const storage = storagestr ? (JSON.parse(storagestr) as LoginResponse | null) : null;
// update localObj from storage
localObj = storage; localObj = storage;
} }
if (localObj !== null && localObj.accessExpired > Math.floor(Date.now() / 1000)) { if (localObj && localObj.accessExpired > Math.floor(Date.now())) {
return { return {
username: localObj.username, username: localObj.username,
permission: localObj.permission, permission: localObj.permission,
}; accessExpired: localObj.accessExpired,
} };
return null; }
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<{
type: "login" | "logout" | "refresh";
user: { username: string; permission: string[] } | null;
}> {
constructor(type: "login" | "logout" | "refresh", user: { username: string; permission: string[] } | null) {
super("auth", { detail: { type, user } });
}
}
declare global {
interface WindowEventMap {
auth: AuthEvent;
}
function addEventListener(
type: "auth",
listener: (this: Window, ev: AuthEvent) => void,
options?: boolean | AddEventListenerOptions
): void;
function removeEventListener(
type: "auth", listener: (this: Window, ev: AuthEvent) => void, options?: boolean | EventListenerOptions): void;
} }
export async function refresh() { export async function refresh() {
try { try {
const r = await refreshService(); const r = await refreshService();
if (r.refresh) { setUserSessions(r);
localObj = { window.dispatchEvent(new AuthEvent("refresh", { username: r.username, permission: r.permission }));
...r,
};
} else {
localObj = {
accessExpired: 0,
username: "",
permission: r.permission,
};
}
localStorage.setItem("UserLoginContext", JSON.stringify(localObj));
return { return {
username: r.username, username: r.username,
permission: r.permission, permission: r.permission,
@ -52,51 +72,49 @@ export async function refresh() {
if (e instanceof ApiError) { if (e instanceof ApiError) {
console.error(`Refresh failed: ${e.detail}`); console.error(`Refresh failed: ${e.detail}`);
} }
localObj = { accessExpired: 0, username: "", permission: [] }; setUserSessions(null);
localStorage.setItem("UserLoginContext", JSON.stringify(localObj)); window.dispatchEvent(new AuthEvent("logout", null));
return { username: "", permission: [] }; return {
username: "",
permission: [],
};
} }
} }
export const doLogout = async () => { export const doLogout = async () => {
try { try {
const res = await logoutService(); const res = await logoutService();
localObj = { setUserSessions(null);
accessExpired: 0, window.dispatchEvent(new AuthEvent("logout", null));
username: "", return {
username: res.username,
permission: res.permission, permission: res.permission,
}; };
window.localStorage.setItem("UserLoginContext", JSON.stringify(localObj));
store.set(userLoginStateAtom, localObj);
return {
username: localObj.username,
permission: localObj.permission,
};
} catch (error) { } catch (error) {
console.error(`Server Error ${error}`); console.error(`Server Error ${error}`);
// Even if logout fails, clear client-side session setUserSessions(null);
localObj = { accessExpired: 0, username: "", permission: [] }; return { username: "", permission: [] };
window.localStorage.setItem("UserLoginContext", JSON.stringify(localObj));
store.set(userLoginStateAtom, localObj);
return {
username: "",
permission: [],
};
} }
}; };
export const doLogin = async (userLoginInfo: LoginRequest): Promise<string | LoginResponse> => { export const doLogin = async (userLoginInfo: LoginRequest): Promise<{
ok: true;
data: LoginResponse;
} | {
ok: false;
error: string;
}> => {
try { try {
const b = await loginService(userLoginInfo); const b = await loginService(userLoginInfo);
localObj = b; setUserSessions(b);
store.set(userLoginStateAtom, b); window.dispatchEvent(new AuthEvent("login", { username: b.username, permission: b.permission }));
window.localStorage.setItem("UserLoginContext", JSON.stringify(localObj)); return { ok: true, data: b };
return b;
} catch (e) { } catch (e) {
if (e instanceof ApiError) { if (e instanceof ApiError) {
return e.detail ?? e.message; return { ok: false, error: e.detail ?? e.message };
} }
return "An unknown error occurred."; return { ok: false, error: "An unknown error occurred." };
} }
}; };
@ -111,23 +129,40 @@ export const doResetPassword = async (username: string, oldpassword: string, new
return await resetPasswordService(username, oldpassword, newpassword); return await resetPasswordService(username, oldpassword, newpassword);
} catch (e) { } catch (e) {
if (e instanceof ApiError) { if (e instanceof ApiError) {
return e.detail ?? e.message; return { ok: false as const, error: e.detail ?? e.message };
} }
return "An unknown error occurred."; return { ok: false as const, error: "An unknown error occurred." };
} }
}; };
export async function getInitialValue() {
const user = getUserSessions();
if (user) {
return user;
}
return refresh();
}
export const userLoginStateAtom = atom(getUserSessions());
export function useLogin() { export function useLogin() {
const val = useAtomValue(userLoginStateAtom); const hook = useSyncExternalStore(
return val; (onChange) => {
} const listener = () => {
onChange();
};
window.addEventListener("auth", listener);
return () => window.removeEventListener("auth", listener);
},
getUserSessions,
);
return hook;
}
document.addEventListener("visibilitychange", async () => {
if (document.visibilityState === "visible") {
const session = getUserSessions();
if (!session) {
await refresh();
}
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

@ -25,7 +25,7 @@ type LoginResponse = {
type RefreshResponse = { type RefreshResponse = {
accessExpired: number; accessExpired: number;
refresh: boolean; refresh: true;
} & PayloadInfo; } & PayloadInfo;
type RefreshPayloadInfo = { username: string }; type RefreshPayloadInfo = { username: string };
@ -45,8 +45,8 @@ const ResetBodySchema = t.Object({
const SettingsBodySchema = t.Record(t.String(), t.Unknown()); const SettingsBodySchema = t.Record(t.String(), t.Unknown());
const accessExpiredTime = 60 * 60 * 2; // 2 hours const accessExpiredTime = 60 * 60 * 2 * 1000; // 2 hours
const refreshExpiredTime = 60 * 60 * 24 * 14; // 14 days const refreshExpiredTime = 60 * 60 * 24 * 14 * 1000; // 14 days
async function createAccessToken(payload: PayloadInfo, secret: string) { async function createAccessToken(payload: PayloadInfo, secret: string) {
return await new SignJWT(payload) return await new SignJWT(payload)
@ -83,7 +83,7 @@ async function verifyToken<T>(token: string, secret: string): Promise<T> {
export const accessTokenName = "access_token"; export const accessTokenName = "access_token";
export const refreshTokenName = "refresh_token"; export const refreshTokenName = "refresh_token";
function setToken(cookie: CookieJar, token_name: string, token_payload: string | null, expiredSeconds: number) { function setToken(cookie: CookieJar, token_name: string, token_payload: string | null, expiredMilliseconds: number) {
if (token_payload === null) { if (token_payload === null) {
cookie[token_name]?.remove(); cookie[token_name]?.remove();
return; return;
@ -94,10 +94,14 @@ function setToken(cookie: CookieJar, token_name: string, token_payload: string |
httpOnly: true, httpOnly: true,
secure: setting.secure, secure: setting.secure,
sameSite: "strict", sameSite: "strict",
expires: new Date(Date.now() + expiredSeconds * 1000), expires: new Date(Date.now() + expiredMilliseconds),
}); });
} }
function removeToken(cookie: CookieJar, token_name: string) {
cookie[token_name]?.remove();
}
const isUserState = (obj: unknown): obj is PayloadInfo => { const isUserState = (obj: unknown): obj is PayloadInfo => {
if (typeof obj !== "object" || obj === null) { if (typeof obj !== "object" || obj === null) {
return false; return false;
@ -237,15 +241,15 @@ export const createLoginRouter = (userController: UserAccessor) => {
return { return {
username: user.username, username: user.username,
permission, permission,
accessExpired: Math.floor(Date.now() / 1000) + accessExpiredTime, accessExpired: Math.floor(Date.now() ) + accessExpiredTime,
} satisfies LoginResponse; } satisfies LoginResponse;
}, { }, {
body: LoginBodySchema, body: LoginBodySchema,
}) })
.post("/logout", ({ cookie, set }) => { .post("/logout", ({ cookie, set }) => {
const setting = get_setting(); const setting = get_setting();
setToken(cookie, accessTokenName, null, 0); removeToken(cookie, accessTokenName);
setToken(cookie, refreshTokenName, null, 0); removeToken(cookie, refreshTokenName);
set.status = 200; set.status = 200;
return { return {
ok: true, ok: true,
@ -261,7 +265,7 @@ export const createLoginRouter = (userController: UserAccessor) => {
return { return {
...auth.user, ...auth.user,
refresh: true, refresh: true,
accessExpired: Math.floor(Date.now() / 1000) + accessExpiredTime, accessExpired: Math.floor(Date.now()) + accessExpiredTime,
} satisfies RefreshResponse; } satisfies RefreshResponse;
}) })
.post("/reset", async ({ body }) => { .post("/reset", async ({ body }) => {