api: added custom storage option (#174)

This commit is contained in:
dswbx
2025-05-27 13:09:24 +02:00
committed by GitHub
parent db795ec050
commit 17ab35e245
10 changed files with 159 additions and 90 deletions

1
.gitignore vendored
View File

@@ -31,3 +31,4 @@ packages/media/.env
.git_old .git_old
docker/tmp docker/tmp
.debug .debug
.history

View File

@@ -1,10 +1,11 @@
import type { SafeUser } from "auth"; import type { SafeUser } from "auth";
import { AuthApi } from "auth/api/AuthApi"; import { AuthApi, type AuthApiOptions } from "auth/api/AuthApi";
import { DataApi } from "data/api/DataApi"; import { DataApi, type DataApiOptions } from "data/api/DataApi";
import { decode } from "hono/jwt"; 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 { SystemApi } from "modules/SystemApi";
import { omitKeys } from "core/utils"; import { omitKeys } from "core/utils";
import type { BaseModuleApiOptions } from "modules";
export type TApiUser = SafeUser; export type TApiUser = SafeUser;
@@ -21,14 +22,24 @@ declare global {
} }
} }
type SubApiOptions<T extends BaseModuleApiOptions> = Omit<T, keyof BaseModuleApiOptions>;
export type ApiOptions = { export type ApiOptions = {
host?: string; host?: string;
headers?: Headers; headers?: Headers;
key?: string; key?: string;
localStorage?: boolean; storage?: {
getItem: (key: string) => string | undefined | null | Promise<string | undefined | null>;
setItem: (key: string, value: string) => void | Promise<void>;
removeItem: (key: string) => void | Promise<void>;
};
onAuthStateChange?: (state: AuthState) => void;
fetcher?: ApiFetcher; fetcher?: ApiFetcher;
verbose?: boolean; verbose?: boolean;
verified?: boolean; verified?: boolean;
data?: SubApiOptions<DataApiOptions>;
auth?: SubApiOptions<AuthApiOptions>;
media?: SubApiOptions<MediaApiOptions>;
} & ( } & (
| { | {
token?: string; token?: string;
@@ -61,18 +72,18 @@ export class Api {
this.verified = options.verified === true; this.verified = options.verified === true;
// prefer request if given // 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.host = options.host ?? new URL(options.request.url).origin;
this.options.headers = options.headers ?? options.request.headers; this.options.headers = options.headers ?? options.request.headers;
this.extractToken(); this.extractToken();
// then check for a token // then check for a token
} else if ("token" in options) { } else if ("token" in options && options.token) {
this.token_transport = "header"; this.token_transport = "header";
this.updateToken(options.token); this.updateToken(options.token, { trigger: false });
// then check for an user object // then check for an user object
} else if ("user" in options) { } else if ("user" in options && options.user) {
this.token_transport = "none"; this.token_transport = "none";
this.user = options.user; this.user = options.user;
this.verified = options.verified !== false; this.verified = options.verified !== false;
@@ -115,16 +126,30 @@ export class Api {
this.updateToken(headerToken); this.updateToken(headerToken);
return; return;
} }
} else if (this.options.localStorage) { } else if (this.storage) {
const token = localStorage.getItem(this.tokenKey); this.storage.getItem(this.tokenKey).then((token) => {
if (token) {
this.token_transport = "header"; 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.token = token;
this.verified = false; this.verified = false;
@@ -134,17 +159,25 @@ export class Api {
this.user = undefined; this.user = undefined;
} }
if (this.options.localStorage) { if (this.storage) {
const key = this.tokenKey; const key = this.tokenKey;
if (token) { if (token) {
localStorage.setItem(key, token); this.storage.setItem(key, token).then(() => {
this.options.onAuthStateChange?.(this.getAuthState());
});
} else { } 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) { private markAuthVerified(verfied: boolean) {
@@ -214,15 +247,32 @@ export class Api {
const fetcher = this.options.fetcher; const fetcher = this.options.fetcher;
this.system = new SystemApi(baseParams, fetcher); this.system = new SystemApi(baseParams, fetcher);
this.data = new DataApi(baseParams, fetcher); this.data = new DataApi(
this.auth = new AuthApi(
{ {
...baseParams, ...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, fetcher,
); );
this.media = new MediaApi(baseParams, fetcher);
} }
} }

View File

@@ -4,19 +4,21 @@ import type { AuthResponse, SafeUser, Strategy } from "auth/authenticate/Authent
import { type BaseModuleApiOptions, ModuleApi } from "modules/ModuleApi"; import { type BaseModuleApiOptions, ModuleApi } from "modules/ModuleApi";
export type AuthApiOptions = BaseModuleApiOptions & { export type AuthApiOptions = BaseModuleApiOptions & {
onTokenUpdate?: (token: string) => void | Promise<void>; onTokenUpdate?: (token?: string) => void | Promise<void>;
credentials?: "include" | "same-origin" | "omit";
}; };
export class AuthApi extends ModuleApi<AuthApiOptions> { export class AuthApi extends ModuleApi<AuthApiOptions> {
protected override getDefaultOptions(): Partial<AuthApiOptions> { protected override getDefaultOptions(): Partial<AuthApiOptions> {
return { return {
basepath: "/api/auth", basepath: "/api/auth",
credentials: "include",
}; };
} }
async login(strategy: string, input: any) { async login(strategy: string, input: any) {
const res = await this.post<AuthResponse>([strategy, "login"], input, { const res = await this.post<AuthResponse>([strategy, "login"], input, {
credentials: "include", credentials: this.options.credentials,
}); });
if (res.ok && res.body.token) { if (res.ok && res.body.token) {
@@ -27,7 +29,7 @@ export class AuthApi extends ModuleApi<AuthApiOptions> {
async register(strategy: string, input: any) { async register(strategy: string, input: any) {
const res = await this.post<AuthResponse>([strategy, "register"], input, { const res = await this.post<AuthResponse>([strategy, "register"], input, {
credentials: "include", credentials: this.options.credentials,
}); });
if (res.ok && res.body.token) { if (res.ok && res.body.token) {
@@ -68,5 +70,7 @@ export class AuthApi extends ModuleApi<AuthApiOptions> {
return this.get<Pick<AppAuthSchema, "strategies" | "basepath">>(["strategies"]); return this.get<Pick<AppAuthSchema, "strategies" | "basepath">>(["strategies"]);
} }
async logout() {} async logout() {
await this.options.onTokenUpdate?.(undefined);
}
} }

View File

@@ -86,7 +86,7 @@ export class AdminController extends Controller {
hono.use("*", async (c, next) => { hono.use("*", async (c, next) => {
const obj = { const obj = {
user: c.get("auth")?.user, user: c.get("auth")?.user,
logout_route: this.withAdminBasePath(authRoutes.logout), logout_route: authRoutes.logout,
admin_basepath: this.options.adminBasepath, admin_basepath: this.options.adminBasepath,
}; };
const html = await this.getHtml(obj); const html = await this.getHtml(obj);

View File

@@ -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 { 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"; import type { AdminBkndWindowContext } from "modules/server/AdminController";
const ClientContext = createContext<{ baseUrl: string; api: Api }>({ export type BkndClientContext = {
baseUrl: undefined, baseUrl: string;
} as any); api: Api;
authState?: Partial<AuthState>;
};
const ClientContext = createContext<BkndClientContext>(undefined!);
export type ClientProviderProps = { export type ClientProviderProps = {
children?: ReactNode; children?: ReactNode;
} & ( baseUrl?: string;
| { baseUrl?: string; user?: TApiUser | null | undefined } } & ApiOptions;
| {
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 (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)],
); );
export const ClientProvider = ({ children, ...props }: ClientProviderProps) => { const [authState, setAuthState] = useState<Partial<AuthState> | undefined>(
let api: Api; apiProps.user ? api.getAuthState() : 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() });
}
return ( return (
<ClientContext.Provider value={{ baseUrl: api.baseUrl, api }}> <ClientContext.Provider value={{ baseUrl: api.baseUrl, api, authState }}>
{children} {children}
</ClientContext.Provider> </ClientContext.Provider>
); );
@@ -61,12 +73,16 @@ export const useApi = (host?: ApiOptions["host"]): Api => {
return context.api; return context.api;
}; };
export const useClientContext = () => {
return useContext(ClientContext);
};
/** /**
* @deprecated use useApi().baseUrl instead * @deprecated use useApi().baseUrl instead
*/ */
export const useBaseUrl = () => { export const useBaseUrl = () => {
const context = useContext(ClientContext); const context = useClientContext();
return context.baseUrl; return context?.baseUrl;
}; };
export function useBkndWindowContext(): AdminBkndWindowContext { export function useBkndWindowContext(): AdminBkndWindowContext {

View File

@@ -1,7 +1,7 @@
import type { AuthState } from "Api"; import type { AuthState } from "Api";
import type { AuthResponse } from "auth"; import type { AuthResponse } from "auth";
import { useState } from "react";
import { useApi, useInvalidate } from "ui/client"; import { useApi, useInvalidate } from "ui/client";
import { useClientContext } from "ui/client/ClientProvider";
type LoginData = { type LoginData = {
email: string; email: string;
@@ -10,7 +10,7 @@ type LoginData = {
}; };
type UseAuth = { type UseAuth = {
data: AuthState | undefined; data: Partial<AuthState> | undefined;
user: AuthState["user"] | undefined; user: AuthState["user"] | undefined;
token: AuthState["token"] | undefined; token: AuthState["token"] | undefined;
verified: boolean; verified: boolean;
@@ -24,46 +24,36 @@ type UseAuth = {
export const useAuth = (options?: { baseUrl?: string }): UseAuth => { export const useAuth = (options?: { baseUrl?: string }): UseAuth => {
const api = useApi(options?.baseUrl); const api = useApi(options?.baseUrl);
const invalidate = useInvalidate(); const invalidate = useInvalidate();
const authState = api.getAuthState(); const { authState } = useClientContext();
const [authData, setAuthData] = useState<UseAuth["data"]>(authState);
const verified = authState?.verified ?? false; const verified = authState?.verified ?? false;
function updateAuthState() {
setAuthData(api.getAuthState());
}
async function login(input: LoginData) { async function login(input: LoginData) {
const res = await api.auth.loginWithPassword(input); const res = await api.auth.login("password", input);
updateAuthState();
return res.data; return res.data;
} }
async function register(input: LoginData) { async function register(input: LoginData) {
const res = await api.auth.registerWithPassword(input); const res = await api.auth.register("password", input);
updateAuthState();
return res.data; return res.data;
} }
function setToken(token: string) { function setToken(token: string) {
api.updateToken(token); api.updateToken(token);
updateAuthState();
} }
async function logout() { async function logout() {
await api.updateToken(undefined); api.updateToken(undefined);
setAuthData(undefined);
invalidate(); invalidate();
} }
async function verify() { async function verify() {
await api.verifyAuth(); await api.verifyAuth();
updateAuthState();
} }
return { return {
data: authData, data: authState,
user: authData?.user, user: authState?.user,
token: authData?.token, token: authState?.token,
verified, verified,
login, login,
register, register,

View File

@@ -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 { json } from "@codemirror/lang-json";
import { html } from "@codemirror/lang-html"; import { html } from "@codemirror/lang-html";
import { useTheme } from "ui/client/use-theme"; import { useTheme } from "ui/client/use-theme";
@@ -43,7 +47,7 @@ export default function CodeEditor({
theme={theme === "dark" ? "dark" : "light"} theme={theme === "dark" ? "dark" : "light"}
editable={editable} editable={editable}
basicSetup={_basicSetup} basicSetup={_basicSetup}
extensions={extensions} extensions={[...extensions, EditorView.lineWrapping]}
{...props} {...props}
/> />
); );

View File

@@ -19,6 +19,7 @@ export type DropdownItem =
onClick?: () => void; onClick?: () => void;
destructive?: boolean; destructive?: boolean;
disabled?: boolean; disabled?: boolean;
title?: string;
[key: string]: any; [key: string]: any;
}; };
@@ -142,6 +143,7 @@ export function Dropdown({
item.destructive && "text-red-500 hover:bg-red-600 hover:text-white", item.destructive && "text-red-500 hover:bg-red-600 hover:text-white",
)} )}
onClick={onClick} onClick={onClick}
title={item.title}
> >
{space_for_icon && ( {space_for_icon && (
<div className="size-[16px] text-left mr-1.5 opacity-80"> <div className="size-[16px] text-left mr-1.5 opacity-80">

View File

@@ -171,7 +171,8 @@ function UserMenu() {
items.push({ label: "Login", onClick: handleLogin, icon: IconUser }); items.push({ label: "Login", onClick: handleLogin, icon: IconUser });
} else { } else {
items.push({ items.push({
label: `Logout ${auth.user.email}`, label: "Logout",
title: `Logout ${auth.user.email}`,
onClick: handleLogout, onClick: handleLogout,
icon: IconKeyOff, icon: IconKeyOff,
}); });

View File

@@ -49,6 +49,7 @@ input[type="date"]::-webkit-calendar-picker-indicator {
.cm-editor { .cm-editor {
display: flex; display: flex;
flex: 1; flex: 1;
max-width: 100%;
} }
.animate-fade-in { .animate-fade-in {