From 6f02f21c7c16c0c578d1c679bc0074e17298092b Mon Sep 17 00:00:00 2001 From: monoid Date: Wed, 8 Oct 2025 16:22:47 +0900 Subject: [PATCH] =?UTF-8?q?refactor:=20DifferencePage=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=EC=97=90=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EB=A7=81=ED=81=AC=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EA=B4=80=EB=A6=AC=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/client/src/page/differencePage.tsx | 10 +- packages/client/src/state/api.ts | 2 +- packages/client/src/state/user.ts | 171 ++++++++++++-------- packages/server/src/login.ts | 22 +-- 4 files changed, 123 insertions(+), 82 deletions(-) diff --git a/packages/client/src/page/differencePage.tsx b/packages/client/src/page/differencePage.tsx index 0d49d9b..1d4ae70 100644 --- a/packages/client/src/page/differencePage.tsx +++ b/packages/client/src/page/differencePage.tsx @@ -4,16 +4,18 @@ import { Separator } from "@/components/ui/separator"; import { useDifferenceDoc, commit, commitAll } from "@/hook/useDifference"; import { useLogin } from "@/state/user"; import { Fragment } from "react/jsx-runtime"; +import { Link } from "wouter"; export function DifferencePage() { const { data, isLoading, error } = useDifferenceDoc(); const userInfo = useLogin(); if (!userInfo) { - return
+ return

Not logged in

+ Go to Login
} @@ -26,7 +28,7 @@ export function DifferencePage() { Difference Scanned Files List @@ -41,11 +43,11 @@ export function DifferencePage() { {x.map((y) => (

{y.path}

+ className="flex-1 text-sm text-wrap">{y.path}

diff --git a/packages/client/src/state/api.ts b/packages/client/src/state/api.ts index cc994f0..369b5fc 100644 --- a/packages/client/src/state/api.ts +++ b/packages/client/src/state/api.ts @@ -14,7 +14,7 @@ export type LogoutResponse = { }; export type RefreshResponse = LoginResponse & { - refresh: boolean; + refresh: true; }; export type ErrorFormat = { diff --git a/packages/client/src/state/user.ts b/packages/client/src/state/user.ts index 69ad2bb..1c13051 100644 --- a/packages/client/src/state/user.ts +++ b/packages/client/src/state/user.ts @@ -1,5 +1,3 @@ -import { atom, useAtomValue } from "../lib/atom.ts"; -import { createStore } from 'jotai'; import { LoginRequest } from "dbtype/mod.ts"; import { ApiError, @@ -9,41 +7,63 @@ import { refreshService, resetPasswordService, } from "./api.ts"; - -// Create a store for setting atom values outside components -const store = createStore(); +import { useSyncExternalStore } from "react"; let localObj: LoginResponse | null = null; + function getUserSessions() { - if (localObj === null) { - const storagestr = localStorage.getItem("UserLoginContext") as string | null; - const storage = storagestr !== null ? (JSON.parse(storagestr) as LoginResponse | null) : null; + 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 !== null && localObj.accessExpired > Math.floor(Date.now() / 1000)) { - return { - username: localObj.username, - permission: localObj.permission, - }; - } - return null; + 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<{ + 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() { try { const r = await refreshService(); - if (r.refresh) { - localObj = { - ...r, - }; - } else { - localObj = { - accessExpired: 0, - username: "", - permission: r.permission, - }; - } - localStorage.setItem("UserLoginContext", JSON.stringify(localObj)); + setUserSessions(r); + window.dispatchEvent(new AuthEvent("refresh", { username: r.username, permission: r.permission })); return { username: r.username, permission: r.permission, @@ -52,51 +72,49 @@ export async function refresh() { if (e instanceof ApiError) { console.error(`Refresh failed: ${e.detail}`); } - localObj = { accessExpired: 0, username: "", permission: [] }; - localStorage.setItem("UserLoginContext", JSON.stringify(localObj)); - return { username: "", permission: [] }; + setUserSessions(null); + window.dispatchEvent(new AuthEvent("logout", null)); + return { + username: "", + permission: [], + }; } } export const doLogout = async () => { try { const res = await logoutService(); - localObj = { - accessExpired: 0, - username: "", + setUserSessions(null); + window.dispatchEvent(new AuthEvent("logout", null)); + return { + username: res.username, permission: res.permission, }; - window.localStorage.setItem("UserLoginContext", JSON.stringify(localObj)); - store.set(userLoginStateAtom, localObj); - return { - username: localObj.username, - permission: localObj.permission, - }; + } catch (error) { console.error(`Server Error ${error}`); - // Even if logout fails, clear client-side session - localObj = { accessExpired: 0, username: "", permission: [] }; - window.localStorage.setItem("UserLoginContext", JSON.stringify(localObj)); - store.set(userLoginStateAtom, localObj); - return { - username: "", - permission: [], - }; + setUserSessions(null); + return { username: "", permission: [] }; } }; -export const doLogin = async (userLoginInfo: LoginRequest): Promise => { +export const doLogin = async (userLoginInfo: LoginRequest): Promise<{ + ok: true; + data: LoginResponse; +} | { + ok: false; + error: string; +}> => { try { const b = await loginService(userLoginInfo); - localObj = b; - store.set(userLoginStateAtom, b); - window.localStorage.setItem("UserLoginContext", JSON.stringify(localObj)); - return b; + setUserSessions(b); + window.dispatchEvent(new AuthEvent("login", { username: b.username, permission: b.permission })); + return { ok: true, data: b }; } catch (e) { 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); } catch (e) { 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() { - const val = useAtomValue(userLoginStateAtom); - return val; -} \ No newline at end of file + const hook = useSyncExternalStore( + (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 + } +}); \ No newline at end of file diff --git a/packages/server/src/login.ts b/packages/server/src/login.ts index 5f1fc2e..9e3fc3e 100644 --- a/packages/server/src/login.ts +++ b/packages/server/src/login.ts @@ -25,7 +25,7 @@ type LoginResponse = { type RefreshResponse = { accessExpired: number; - refresh: boolean; + refresh: true; } & PayloadInfo; type RefreshPayloadInfo = { username: string }; @@ -45,8 +45,8 @@ const ResetBodySchema = t.Object({ const SettingsBodySchema = t.Record(t.String(), t.Unknown()); -const accessExpiredTime = 60 * 60 * 2; // 2 hours -const refreshExpiredTime = 60 * 60 * 24 * 14; // 14 days +const accessExpiredTime = 60 * 60 * 2 * 1000; // 2 hours +const refreshExpiredTime = 60 * 60 * 24 * 14 * 1000; // 14 days async function createAccessToken(payload: PayloadInfo, secret: string) { return await new SignJWT(payload) @@ -83,7 +83,7 @@ async function verifyToken(token: string, secret: string): Promise { export const accessTokenName = "access_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) { cookie[token_name]?.remove(); return; @@ -94,10 +94,14 @@ function setToken(cookie: CookieJar, token_name: string, token_payload: string | httpOnly: true, secure: setting.secure, 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 => { if (typeof obj !== "object" || obj === null) { return false; @@ -237,15 +241,15 @@ export const createLoginRouter = (userController: UserAccessor) => { return { username: user.username, permission, - accessExpired: Math.floor(Date.now() / 1000) + accessExpiredTime, + accessExpired: Math.floor(Date.now() ) + accessExpiredTime, } satisfies LoginResponse; }, { body: LoginBodySchema, }) .post("/logout", ({ cookie, set }) => { const setting = get_setting(); - setToken(cookie, accessTokenName, null, 0); - setToken(cookie, refreshTokenName, null, 0); + removeToken(cookie, accessTokenName); + removeToken(cookie, refreshTokenName); set.status = 200; return { ok: true, @@ -261,7 +265,7 @@ export const createLoginRouter = (userController: UserAccessor) => { return { ...auth.user, refresh: true, - accessExpired: Math.floor(Date.now() / 1000) + accessExpiredTime, + accessExpired: Math.floor(Date.now()) + accessExpiredTime, } satisfies RefreshResponse; }) .post("/reset", async ({ body }) => {