From 17ab35e2459e89244916a9f2dbf75e2c6b305bf0 Mon Sep 17 00:00:00 2001 From: dswbx Date: Tue, 27 May 2025 13:09:24 +0200 Subject: [PATCH] api: added custom storage option (#174) --- .gitignore | 3 +- app/src/Api.ts | 94 ++++++++++++++++----- app/src/auth/api/AuthApi.ts | 12 ++- app/src/modules/server/AdminController.tsx | 2 +- app/src/ui/client/ClientProvider.tsx | 96 +++++++++++++--------- app/src/ui/client/schema/auth/use-auth.ts | 28 ++----- app/src/ui/components/code/CodeEditor.tsx | 8 +- app/src/ui/components/overlay/Dropdown.tsx | 2 + app/src/ui/layouts/AppShell/Header.tsx | 3 +- app/src/ui/styles.css | 1 + 10 files changed, 159 insertions(+), 90 deletions(-) diff --git a/.gitignore b/.gitignore index 1bda749..f151cbf 100644 --- a/.gitignore +++ b/.gitignore @@ -30,4 +30,5 @@ packages/media/.env .vscode .git_old docker/tmp -.debug \ No newline at end of file +.debug +.history \ No newline at end of file diff --git a/app/src/Api.ts b/app/src/Api.ts index 3b356f6..8c93d97 100644 --- a/app/src/Api.ts +++ b/app/src/Api.ts @@ -1,10 +1,11 @@ import type { SafeUser } from "auth"; -import { AuthApi } from "auth/api/AuthApi"; -import { DataApi } from "data/api/DataApi"; +import { AuthApi, type AuthApiOptions } from "auth/api/AuthApi"; +import { DataApi, type DataApiOptions } from "data/api/DataApi"; import { decode } from "hono/jwt"; -import { MediaApi } from "media/api/MediaApi"; +import { MediaApi, type MediaApiOptions } from "media/api/MediaApi"; import { SystemApi } from "modules/SystemApi"; import { omitKeys } from "core/utils"; +import type { BaseModuleApiOptions } from "modules"; export type TApiUser = SafeUser; @@ -21,14 +22,24 @@ declare global { } } +type SubApiOptions = Omit; + export type ApiOptions = { host?: string; headers?: Headers; key?: string; - localStorage?: boolean; + storage?: { + getItem: (key: string) => string | undefined | null | Promise; + setItem: (key: string, value: string) => void | Promise; + removeItem: (key: string) => void | Promise; + }; + onAuthStateChange?: (state: AuthState) => void; fetcher?: ApiFetcher; verbose?: boolean; verified?: boolean; + data?: SubApiOptions; + auth?: SubApiOptions; + media?: SubApiOptions; } & ( | { token?: string; @@ -61,18 +72,18 @@ export class Api { this.verified = options.verified === true; // prefer request if given - if ("request" in options) { + if ("request" in options && options.request) { this.options.host = options.host ?? new URL(options.request.url).origin; this.options.headers = options.headers ?? options.request.headers; this.extractToken(); // then check for a token - } else if ("token" in options) { + } else if ("token" in options && options.token) { this.token_transport = "header"; - this.updateToken(options.token); + this.updateToken(options.token, { trigger: false }); // then check for an user object - } else if ("user" in options) { + } else if ("user" in options && options.user) { this.token_transport = "none"; this.user = options.user; this.verified = options.verified !== false; @@ -115,16 +126,30 @@ export class Api { this.updateToken(headerToken); return; } - } else if (this.options.localStorage) { - const token = localStorage.getItem(this.tokenKey); - if (token) { + } else if (this.storage) { + this.storage.getItem(this.tokenKey).then((token) => { this.token_transport = "header"; - this.updateToken(token); - } + this.updateToken(token ? String(token) : undefined); + }); } } - updateToken(token?: string, rebuild?: boolean) { + private get storage() { + if (!this.options.storage) return null; + return { + getItem: async (key: string) => { + return await this.options.storage!.getItem(key); + }, + setItem: async (key: string, value: string) => { + return await this.options.storage!.setItem(key, value); + }, + removeItem: async (key: string) => { + return await this.options.storage!.removeItem(key); + }, + }; + } + + updateToken(token?: string, opts?: { rebuild?: boolean; trigger?: boolean }) { this.token = token; this.verified = false; @@ -134,17 +159,25 @@ export class Api { this.user = undefined; } - if (this.options.localStorage) { + if (this.storage) { const key = this.tokenKey; if (token) { - localStorage.setItem(key, token); + this.storage.setItem(key, token).then(() => { + this.options.onAuthStateChange?.(this.getAuthState()); + }); } else { - localStorage.removeItem(key); + this.storage.removeItem(key).then(() => { + this.options.onAuthStateChange?.(this.getAuthState()); + }); + } + } else { + if (opts?.trigger !== false) { + this.options.onAuthStateChange?.(this.getAuthState()); } } - if (rebuild) this.buildApis(); + if (opts?.rebuild) this.buildApis(); } private markAuthVerified(verfied: boolean) { @@ -214,15 +247,32 @@ export class Api { const fetcher = this.options.fetcher; this.system = new SystemApi(baseParams, fetcher); - this.data = new DataApi(baseParams, fetcher); - this.auth = new AuthApi( + this.data = new DataApi( { ...baseParams, - onTokenUpdate: (token) => this.updateToken(token, true), + ...this.options.data, + }, + fetcher, + ); + this.auth = new AuthApi( + { + ...baseParams, + credentials: this.options.storage ? "omit" : "include", + ...this.options.auth, + onTokenUpdate: (token) => { + this.updateToken(token, { rebuild: true }); + this.options.auth?.onTokenUpdate?.(token); + }, + }, + fetcher, + ); + this.media = new MediaApi( + { + ...baseParams, + ...this.options.media, }, fetcher, ); - this.media = new MediaApi(baseParams, fetcher); } } diff --git a/app/src/auth/api/AuthApi.ts b/app/src/auth/api/AuthApi.ts index d4cc3d2..91b3c17 100644 --- a/app/src/auth/api/AuthApi.ts +++ b/app/src/auth/api/AuthApi.ts @@ -4,19 +4,21 @@ import type { AuthResponse, SafeUser, Strategy } from "auth/authenticate/Authent import { type BaseModuleApiOptions, ModuleApi } from "modules/ModuleApi"; export type AuthApiOptions = BaseModuleApiOptions & { - onTokenUpdate?: (token: string) => void | Promise; + onTokenUpdate?: (token?: string) => void | Promise; + credentials?: "include" | "same-origin" | "omit"; }; export class AuthApi extends ModuleApi { protected override getDefaultOptions(): Partial { return { basepath: "/api/auth", + credentials: "include", }; } async login(strategy: string, input: any) { const res = await this.post([strategy, "login"], input, { - credentials: "include", + credentials: this.options.credentials, }); if (res.ok && res.body.token) { @@ -27,7 +29,7 @@ export class AuthApi extends ModuleApi { async register(strategy: string, input: any) { const res = await this.post([strategy, "register"], input, { - credentials: "include", + credentials: this.options.credentials, }); if (res.ok && res.body.token) { @@ -68,5 +70,7 @@ export class AuthApi extends ModuleApi { return this.get>(["strategies"]); } - async logout() {} + async logout() { + await this.options.onTokenUpdate?.(undefined); + } } diff --git a/app/src/modules/server/AdminController.tsx b/app/src/modules/server/AdminController.tsx index 6105a4b..baa36d6 100644 --- a/app/src/modules/server/AdminController.tsx +++ b/app/src/modules/server/AdminController.tsx @@ -86,7 +86,7 @@ export class AdminController extends Controller { hono.use("*", async (c, next) => { const obj = { user: c.get("auth")?.user, - logout_route: this.withAdminBasePath(authRoutes.logout), + logout_route: authRoutes.logout, admin_basepath: this.options.adminBasepath, }; const html = await this.getHtml(obj); diff --git a/app/src/ui/client/ClientProvider.tsx b/app/src/ui/client/ClientProvider.tsx index 18926eb..37fe534 100644 --- a/app/src/ui/client/ClientProvider.tsx +++ b/app/src/ui/client/ClientProvider.tsx @@ -1,52 +1,64 @@ -import { Api, type ApiOptions, type TApiUser } from "Api"; +import { Api, type ApiOptions, type AuthState } from "Api"; import { isDebug } from "core"; -import { createContext, type ReactNode, useContext } from "react"; +import { createContext, type ReactNode, useContext, useMemo, useState } from "react"; import type { AdminBkndWindowContext } from "modules/server/AdminController"; -const ClientContext = createContext<{ baseUrl: string; api: Api }>({ - baseUrl: undefined, -} as any); +export type BkndClientContext = { + baseUrl: string; + api: Api; + authState?: Partial; +}; + +const ClientContext = createContext(undefined!); export type ClientProviderProps = { children?: ReactNode; -} & ( - | { baseUrl?: string; user?: TApiUser | null | undefined } - | { - api: Api; - } -); + baseUrl?: string; +} & ApiOptions; -export const ClientProvider = ({ children, ...props }: ClientProviderProps) => { - let api: Api; +export const ClientProvider = ({ + children, + host, + baseUrl: _baseUrl = host, + ...props +}: ClientProviderProps) => { + const winCtx = useBkndWindowContext(); + const _ctx = useClientContext(); + let actualBaseUrl = _baseUrl ?? _ctx?.baseUrl ?? ""; + let user: any = undefined; - if (props && "api" in props) { - api = props.api; - } else { - const winCtx = useBkndWindowContext(); - const _ctx_baseUrl = useBaseUrl(); - const { baseUrl, user } = props; - let actualBaseUrl = baseUrl ?? _ctx_baseUrl ?? ""; - - try { - if (!baseUrl) { - if (_ctx_baseUrl) { - actualBaseUrl = _ctx_baseUrl; - console.warn("wrapped many times, take from context", actualBaseUrl); - } else if (typeof window !== "undefined") { - actualBaseUrl = window.location.origin; - //console.log("setting from window", actualBaseUrl); - } - } - } catch (e) { - console.error("Error in ClientProvider", e); - } - - //console.log("api init", { host: actualBaseUrl, user: user ?? winCtx.user }); - api = new Api({ host: actualBaseUrl, user: user ?? winCtx.user, verbose: isDebug() }); + if (winCtx) { + user = winCtx.user; } + if (!actualBaseUrl) { + try { + actualBaseUrl = window.location.origin; + } catch (e) {} + } + + const apiProps = { user, ...props, host: actualBaseUrl }; + const api = useMemo( + () => + new Api({ + ...apiProps, + verbose: isDebug(), + onAuthStateChange: (state) => { + props.onAuthStateChange?.(state); + if (!authState?.token || state.token !== authState?.token) { + setAuthState(state); + } + }, + }), + [JSON.stringify(apiProps)], + ); + + const [authState, setAuthState] = useState | undefined>( + apiProps.user ? api.getAuthState() : undefined, + ); + return ( - + {children} ); @@ -61,12 +73,16 @@ export const useApi = (host?: ApiOptions["host"]): Api => { return context.api; }; +export const useClientContext = () => { + return useContext(ClientContext); +}; + /** * @deprecated use useApi().baseUrl instead */ export const useBaseUrl = () => { - const context = useContext(ClientContext); - return context.baseUrl; + const context = useClientContext(); + return context?.baseUrl; }; export function useBkndWindowContext(): AdminBkndWindowContext { diff --git a/app/src/ui/client/schema/auth/use-auth.ts b/app/src/ui/client/schema/auth/use-auth.ts index 85681b3..e932d78 100644 --- a/app/src/ui/client/schema/auth/use-auth.ts +++ b/app/src/ui/client/schema/auth/use-auth.ts @@ -1,7 +1,7 @@ import type { AuthState } from "Api"; import type { AuthResponse } from "auth"; -import { useState } from "react"; import { useApi, useInvalidate } from "ui/client"; +import { useClientContext } from "ui/client/ClientProvider"; type LoginData = { email: string; @@ -10,7 +10,7 @@ type LoginData = { }; type UseAuth = { - data: AuthState | undefined; + data: Partial | undefined; user: AuthState["user"] | undefined; token: AuthState["token"] | undefined; verified: boolean; @@ -24,46 +24,36 @@ type UseAuth = { export const useAuth = (options?: { baseUrl?: string }): UseAuth => { const api = useApi(options?.baseUrl); const invalidate = useInvalidate(); - const authState = api.getAuthState(); - const [authData, setAuthData] = useState(authState); + const { authState } = useClientContext(); const verified = authState?.verified ?? false; - function updateAuthState() { - setAuthData(api.getAuthState()); - } - async function login(input: LoginData) { - const res = await api.auth.loginWithPassword(input); - updateAuthState(); + const res = await api.auth.login("password", input); return res.data; } async function register(input: LoginData) { - const res = await api.auth.registerWithPassword(input); - updateAuthState(); + const res = await api.auth.register("password", input); return res.data; } function setToken(token: string) { api.updateToken(token); - updateAuthState(); } async function logout() { - await api.updateToken(undefined); - setAuthData(undefined); + api.updateToken(undefined); invalidate(); } async function verify() { await api.verifyAuth(); - updateAuthState(); } return { - data: authData, - user: authData?.user, - token: authData?.token, + data: authState, + user: authState?.user, + token: authState?.token, verified, login, register, diff --git a/app/src/ui/components/code/CodeEditor.tsx b/app/src/ui/components/code/CodeEditor.tsx index 6c81434..4c38fda 100644 --- a/app/src/ui/components/code/CodeEditor.tsx +++ b/app/src/ui/components/code/CodeEditor.tsx @@ -1,4 +1,8 @@ -import { default as CodeMirror, type ReactCodeMirrorProps } from "@uiw/react-codemirror"; +import { + default as CodeMirror, + type ReactCodeMirrorProps, + EditorView, +} from "@uiw/react-codemirror"; import { json } from "@codemirror/lang-json"; import { html } from "@codemirror/lang-html"; import { useTheme } from "ui/client/use-theme"; @@ -43,7 +47,7 @@ export default function CodeEditor({ theme={theme === "dark" ? "dark" : "light"} editable={editable} basicSetup={_basicSetup} - extensions={extensions} + extensions={[...extensions, EditorView.lineWrapping]} {...props} /> ); diff --git a/app/src/ui/components/overlay/Dropdown.tsx b/app/src/ui/components/overlay/Dropdown.tsx index 78ed2ff..2c65989 100644 --- a/app/src/ui/components/overlay/Dropdown.tsx +++ b/app/src/ui/components/overlay/Dropdown.tsx @@ -19,6 +19,7 @@ export type DropdownItem = onClick?: () => void; destructive?: boolean; disabled?: boolean; + title?: string; [key: string]: any; }; @@ -142,6 +143,7 @@ export function Dropdown({ item.destructive && "text-red-500 hover:bg-red-600 hover:text-white", )} onClick={onClick} + title={item.title} > {space_for_icon && (
diff --git a/app/src/ui/layouts/AppShell/Header.tsx b/app/src/ui/layouts/AppShell/Header.tsx index 54a9753..55d93d5 100644 --- a/app/src/ui/layouts/AppShell/Header.tsx +++ b/app/src/ui/layouts/AppShell/Header.tsx @@ -171,7 +171,8 @@ function UserMenu() { items.push({ label: "Login", onClick: handleLogin, icon: IconUser }); } else { items.push({ - label: `Logout ${auth.user.email}`, + label: "Logout", + title: `Logout ${auth.user.email}`, onClick: handleLogout, icon: IconKeyOff, }); diff --git a/app/src/ui/styles.css b/app/src/ui/styles.css index 77a0645..723e2dc 100644 --- a/app/src/ui/styles.css +++ b/app/src/ui/styles.css @@ -49,6 +49,7 @@ input[type="date"]::-webkit-calendar-picker-indicator { .cm-editor { display: flex; flex: 1; + max-width: 100%; } .animate-fade-in {