diff --git a/packages/client/src/state/api.ts b/packages/client/src/state/api.ts new file mode 100644 index 0000000..cc994f0 --- /dev/null +++ b/packages/client/src/state/api.ts @@ -0,0 +1,92 @@ +import { makeApiUrl } from "../hook/fetcher.ts"; +import { LoginRequest } from "dbtype/mod.ts"; + +export type LoginResponse = { + username: string; + permission: string[]; + accessExpired: number; +}; + +export type LogoutResponse = { + ok: boolean; + username: string; + permission: string[]; +}; + +export type RefreshResponse = LoginResponse & { + refresh: boolean; +}; + +export type ErrorFormat = { + code: number; + message: string; + detail?: string; +}; + +export class ApiError extends Error { + public readonly code: number; + public readonly detail?: string; + + constructor(error: ErrorFormat) { + super(error.message); + this.name = "ApiError"; + this.code = error.code; + this.detail = error.detail; + } +} + +export async function refreshService(): Promise { + const u = makeApiUrl("/api/user/refresh"); + const res = await fetch(u, { + method: "POST", + credentials: "include", + }); + const b = await res.json(); + if (!res.ok) { + throw new ApiError(b as ErrorFormat); + } + return b as RefreshResponse; +} + +export async function logoutService(): Promise { + const u = makeApiUrl("/api/user/logout"); + const req = await fetch(u, { + method: "POST", + credentials: "include", + }); + const b = await req.json(); + if (!req.ok) { + throw new ApiError(b as ErrorFormat); + } + return b as LogoutResponse; +} + +export async function loginService(userLoginInfo: LoginRequest): Promise { + const u = makeApiUrl("/api/user/login"); + const res = await fetch(u, { + method: "POST", + body: JSON.stringify(userLoginInfo), + headers: { "content-type": "application/json" }, + credentials: "include", + }); + const b = await res.json(); + if (!res.ok) { + throw new ApiError(b as ErrorFormat); + } + return b as LoginResponse; +} + +export async function resetPasswordService(username: string, oldpassword: string, newpassword: string): Promise<{ ok: boolean }> { + const u = makeApiUrl("/api/user/reset"); + const res = await fetch(u, { + method: "POST", + body: JSON.stringify({ username, oldpassword, newpassword }), + headers: { "content-type": "application/json" }, + credentials: "include", + }); + const b = await res.json(); + if (!res.ok) { + throw new ApiError(b as ErrorFormat); + } + return b; +} diff --git a/packages/client/src/state/user.ts b/packages/client/src/state/user.ts index 02e71ae..88cf097 100644 --- a/packages/client/src/state/user.ts +++ b/packages/client/src/state/user.ts @@ -1,18 +1,19 @@ import { atom, useAtomValue, setAtomValue } from "../lib/atom.ts"; -import { makeApiUrl } from "../hook/fetcher.ts"; import { LoginRequest } from "dbtype/mod.ts"; +import { + ApiError, + loginService, + LoginResponse, + logoutService, + refreshService, + resetPasswordService, +} from "./api.ts"; -type LoginLocalStorage = { - username: string; - permission: string[]; - accessExpired: number; -}; - -let localObj: LoginLocalStorage | null = null; +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 LoginLocalStorage | null) : null; + const storage = storagestr !== null ? (JSON.parse(storagestr) as LoginResponse | null) : null; localObj = storage; } if (localObj !== null && localObj.accessExpired > Math.floor(Date.now() / 1000)) { @@ -25,76 +26,76 @@ function getUserSessions() { } export async function refresh() { - const u = makeApiUrl("/api/user/refresh"); - const res = await fetch(u, { - method: "POST", - credentials: "include", - }); - if (res.status !== 200) throw new Error("Maybe Network Error"); - const r = (await res.json()) as LoginLocalStorage & { refresh: boolean }; - if (r.refresh) { - localObj = { - ...r - }; - } else { - localObj = { - accessExpired: 0, - username: "", - permission: r.permission, - }; - } - localStorage.setItem("UserLoginContext", JSON.stringify(localObj)); - return { - username: r.username, - permission: r.permission, - }; + try { + const r = await refreshService(); + if (r.refresh) { + localObj = { + ...r, + }; + } else { + localObj = { + accessExpired: 0, + username: "", + permission: r.permission, + }; + } + localStorage.setItem("UserLoginContext", JSON.stringify(localObj)); + return { + username: r.username, + permission: r.permission, + }; + } catch (e) { + if (e instanceof ApiError) { + console.error(`Refresh failed: ${e.detail}`); + } + localObj = { accessExpired: 0, username: "", permission: [] }; + localStorage.setItem("UserLoginContext", JSON.stringify(localObj)); + return { username: "", permission: [] }; + } } export const doLogout = async () => { - const u = makeApiUrl("/api/user/logout"); - const req = await fetch(u, { - method: "POST", - credentials: "include", - }); - const setVal = setAtomValue(userLoginStateAtom); + const setVal = setAtomValue(userLoginStateAtom); try { - const res = await req.json(); + const res = await logoutService(); localObj = { accessExpired: 0, username: "", permission: res.permission, }; window.localStorage.setItem("UserLoginContext", JSON.stringify(localObj)); - setVal(localObj); + setVal(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)); + setVal(localObj); return { username: "", permission: [], }; } }; -export const doLogin = async (userLoginInfo: LoginRequest): Promise => { - const u = makeApiUrl("/api/user/login"); - const res = await fetch(u, { - method: "POST", - body: JSON.stringify(userLoginInfo), - headers: { "content-type": "application/json" }, - credentials: "include", - }); - const b = await res.json(); - if (res.status !== 200) { - return b.detail as string; + +export const doLogin = async (userLoginInfo: LoginRequest): Promise => { + try { + const b = await loginService(userLoginInfo); + const setVal = setAtomValue(userLoginStateAtom); + localObj = b; + setVal(b); + window.localStorage.setItem("UserLoginContext", JSON.stringify(localObj)); + return b; + } catch (e) { + if (e instanceof ApiError) { + return e.detail ?? e.message; + } + return "An unknown error occurred."; } - const setVal = setAtomValue(userLoginStateAtom); - localObj = b; - setVal(b); - window.localStorage.setItem("UserLoginContext", JSON.stringify(localObj)); - return b; }; Object.assign(window, { @@ -104,19 +105,15 @@ Object.assign(window, { }); export const doResetPassword = async (username: string, oldpassword: string, newpassword: string) => { - const u = makeApiUrl("/api/user/reset"); - const res = await fetch(u, { - method: "POST", - body: JSON.stringify({ username, oldpassword, newpassword }), - headers: { "content-type": "application/json" }, - credentials: "include", - }); - const b = await res.json(); - if (res.status !== 200) { - return b.detail as string; + try { + return await resetPasswordService(username, oldpassword, newpassword); + } catch (e) { + if (e instanceof ApiError) { + return e.detail ?? e.message; + } + return "An unknown error occurred."; } - return b; -} +}; export async function getInitialValue() { const user = getUserSessions(); diff --git a/packages/server/src/login.ts b/packages/server/src/login.ts index d38c9b3..3374529 100644 --- a/packages/server/src/login.ts +++ b/packages/server/src/login.ts @@ -6,6 +6,15 @@ import { sendError } from "./route/error_handler.ts"; import { get_setting } from "./SettingConfig.ts"; import { LoginRequestSchema, LoginResetRequestSchema } from "dbtype"; +type LoginResponse = { + accessExpired: number; +} & PayloadInfo; + +type RefreshResponse = { + accessExpired: number; + refresh: boolean; +} & PayloadInfo; + type PayloadInfo = { username: string; permission: string[]; @@ -110,7 +119,7 @@ export const createLoginHandler = (userController: UserAccessor) => async (ctx: username: user.username, permission: userPermission, accessExpired: Math.floor(Date.now() / 1000) + accessExpiredTime, - }; + } satisfies LoginResponse; console.log(`${username} logined`); return; }; @@ -206,17 +215,18 @@ export const createRefreshTokenMiddleware = (cntr: UserAccessor) => async (ctx: const user = ctx.state.user as PayloadInfo; ctx.body = { refresh: false, + accessExpired: 0, ...user, - }; + } satisfies RefreshResponse; ctx.type = "json"; - } + }; async function success() { const user = ctx.state.user as PayloadInfo; ctx.body = { ...user, refresh: true, - refreshExpired: Math.floor(Date.now() / 1000 + accessExpiredTime), - }; + accessExpired: Math.floor(Date.now() / 1000 + accessExpiredTime), + } satisfies RefreshResponse; ctx.type = "json"; } };