diff --git a/app/src/Api.ts b/app/src/Api.ts index 28d2ef0..fd4394a 100644 --- a/app/src/Api.ts +++ b/app/src/Api.ts @@ -40,6 +40,7 @@ export type ApiOptions = { data?: SubApiOptions; auth?: SubApiOptions; media?: SubApiOptions; + credentials?: RequestCredentials; } & ( | { token?: string; @@ -67,7 +68,7 @@ export class Api { public auth!: AuthApi; public media!: MediaApi; - constructor(private options: ApiOptions = {}) { + constructor(public options: ApiOptions = {}) { // only mark verified if forced this.verified = options.verified === true; @@ -129,29 +130,45 @@ export class Api { } else if (this.storage) { this.storage.getItem(this.tokenKey).then((token) => { this.token_transport = "header"; - this.updateToken(token ? String(token) : undefined); + this.updateToken(token ? String(token) : undefined, { + verified: true, + trigger: false, + }); }); } } + /** + * Make storage async to allow async storages even if sync given + * @private + */ private get storage() { - if (!this.options.storage) return null; - return { - getItem: async (key: string) => { - return await this.options.storage!.getItem(key); + const storage = this.options.storage; + return new Proxy( + {}, + { + get(_, prop) { + return (...args: any[]) => { + const response = storage ? storage[prop](...args) : undefined; + if (response instanceof Promise) { + return response; + } + return { + // biome-ignore lint/suspicious/noThenProperty: it's a promise :) + then: (fn) => fn(response), + }; + }; + }, }, - 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); - }, - }; + ) as any; } - updateToken(token?: string, opts?: { rebuild?: boolean; trigger?: boolean }) { + updateToken( + token?: string, + opts?: { rebuild?: boolean; verified?: boolean; trigger?: boolean }, + ) { this.token = token; - this.verified = false; + this.verified = opts?.verified === true; if (token) { this.user = omitKeys(decode(token).payload as any, ["iat", "iss", "exp"]) as any; @@ -159,21 +176,22 @@ export class Api { this.user = undefined; } + const emit = () => { + if (opts?.trigger !== false) { + this.options.onAuthStateChange?.(this.getAuthState()); + } + }; if (this.storage) { const key = this.tokenKey; if (token) { - this.storage.setItem(key, token).then(() => { - this.options.onAuthStateChange?.(this.getAuthState()); - }); + this.storage.setItem(key, token).then(emit); } else { - this.storage.removeItem(key).then(() => { - this.options.onAuthStateChange?.(this.getAuthState()); - }); + this.storage.removeItem(key).then(emit); } } else { if (opts?.trigger !== false) { - this.options.onAuthStateChange?.(this.getAuthState()); + emit(); } } @@ -182,6 +200,7 @@ export class Api { private markAuthVerified(verfied: boolean) { this.verified = verfied; + this.options.onAuthStateChange?.(this.getAuthState()); return this; } @@ -208,11 +227,6 @@ export class Api { } async verifyAuth() { - if (!this.token) { - this.markAuthVerified(false); - return; - } - try { const { ok, data } = await this.auth.me(); const user = data?.user; @@ -221,10 +235,10 @@ export class Api { } this.user = user; - this.markAuthVerified(true); } catch (e) { - this.markAuthVerified(false); this.updateToken(undefined); + } finally { + this.markAuthVerified(true); } } @@ -239,6 +253,7 @@ export class Api { headers: this.options.headers, token_transport: this.token_transport, verbose: this.options.verbose, + credentials: this.options.credentials, }); } @@ -257,10 +272,9 @@ export class Api { this.auth = new AuthApi( { ...baseParams, - credentials: this.options.storage ? "omit" : "include", ...this.options.auth, - onTokenUpdate: (token) => { - this.updateToken(token, { rebuild: true }); + onTokenUpdate: (token, verified) => { + this.updateToken(token, { rebuild: true, verified, trigger: true }); this.options.auth?.onTokenUpdate?.(token); }, }, diff --git a/app/src/auth/api/AuthApi.ts b/app/src/auth/api/AuthApi.ts index cd22ada..e3c0843 100644 --- a/app/src/auth/api/AuthApi.ts +++ b/app/src/auth/api/AuthApi.ts @@ -4,7 +4,7 @@ import type { AuthResponse, SafeUser, AuthStrategy } from "bknd"; import { type BaseModuleApiOptions, ModuleApi } from "modules/ModuleApi"; export type AuthApiOptions = BaseModuleApiOptions & { - onTokenUpdate?: (token?: string) => void | Promise; + onTokenUpdate?: (token?: string, verified?: boolean) => void | Promise; credentials?: "include" | "same-origin" | "omit"; }; @@ -17,23 +17,19 @@ export class AuthApi extends ModuleApi { } async login(strategy: string, input: any) { - const res = await this.post([strategy, "login"], input, { - credentials: this.options.credentials, - }); + const res = await this.post([strategy, "login"], input); if (res.ok && res.body.token) { - await this.options.onTokenUpdate?.(res.body.token); + await this.options.onTokenUpdate?.(res.body.token, true); } return res; } async register(strategy: string, input: any) { - const res = await this.post([strategy, "register"], input, { - credentials: this.options.credentials, - }); + const res = await this.post([strategy, "register"], input); if (res.ok && res.body.token) { - await this.options.onTokenUpdate?.(res.body.token); + await this.options.onTokenUpdate?.(res.body.token, true); } return res; } @@ -71,6 +67,11 @@ export class AuthApi extends ModuleApi { } async logout() { - await this.options.onTokenUpdate?.(undefined); + return this.get(["logout"], undefined, { + headers: { + // this way bknd detects a json request and doesn't redirect back + Accept: "application/json", + }, + }).then(() => this.options.onTokenUpdate?.(undefined, true)); } } diff --git a/app/src/auth/authenticate/Authenticator.ts b/app/src/auth/authenticate/Authenticator.ts index 52a2e42..465cbd1 100644 --- a/app/src/auth/authenticate/Authenticator.ts +++ b/app/src/auth/authenticate/Authenticator.ts @@ -44,6 +44,7 @@ export interface UserPool { const defaultCookieExpires = 60 * 60 * 24 * 7; // 1 week in seconds export const cookieConfig = s .strictObject({ + domain: s.string().optional(), path: s.string({ default: "/" }), sameSite: s.string({ enum: ["strict", "lax", "none"], default: "lax" }), secure: s.boolean({ default: true }), @@ -290,6 +291,7 @@ export class Authenticator< return { ...cookieConfig, + domain: cookieConfig.domain ?? undefined, expires: new Date(Date.now() + expires * 1000), }; } @@ -354,7 +356,10 @@ export class Authenticator< // @todo: move this to a server helper isJsonRequest(c: Context): boolean { - return c.req.header("Content-Type") === "application/json"; + return ( + c.req.header("Content-Type") === "application/json" || + c.req.header("Accept") === "application/json" + ); } async getBody(c: Context) { diff --git a/app/src/modules/ModuleApi.ts b/app/src/modules/ModuleApi.ts index f89fb99..9b9ebb7 100644 --- a/app/src/modules/ModuleApi.ts +++ b/app/src/modules/ModuleApi.ts @@ -8,6 +8,7 @@ export type BaseModuleApiOptions = { host: string; basepath?: string; token?: string; + credentials?: RequestCredentials; headers?: Headers; token_transport?: "header" | "cookie" | "none"; verbose?: boolean; @@ -106,6 +107,7 @@ export abstract class ModuleApi { } override async build() { - const origin = this.config.cors.origin ?? ""; + const origin = this.config.cors.origin ?? "*"; + const origins = origin.includes(",") ? origin.split(",").map((o) => o.trim()) : [origin]; + const all_origins = origins.includes("*"); this.client.use( "*", cors({ - origin: origin.includes(",") ? origin.split(",").map((o) => o.trim()) : origin, + origin: (origin: string) => { + if (all_origins) return origin; + return origins.includes(origin) ? origin : undefined; + }, allowMethods: this.config.cors.allow_methods, allowHeaders: this.config.cors.allow_headers, credentials: this.config.cors.allow_credentials, diff --git a/app/src/ui/client/ClientProvider.tsx b/app/src/ui/client/ClientProvider.tsx index 13352d1..31dbae6 100644 --- a/app/src/ui/client/ClientProvider.tsx +++ b/app/src/ui/client/ClientProvider.tsx @@ -44,18 +44,17 @@ export const ClientProvider = ({ ...apiProps, verbose: isDebug(), onAuthStateChange: (state) => { + const { token, ...rest } = state; props.onAuthStateChange?.(state); - if (!authState?.token || state.token !== authState?.token) { - setAuthState(state); + if (!authState?.token || token !== authState?.token) { + setAuthState(rest); } }, }), [JSON.stringify(apiProps)], ); - const [authState, setAuthState] = useState | undefined>( - apiProps.user ? api.getAuthState() : undefined, - ); + const [authState, setAuthState] = useState | undefined>(api.getAuthState()); return ( diff --git a/app/src/ui/client/schema/auth/use-auth.ts b/app/src/ui/client/schema/auth/use-auth.ts index e3fb4a6..291c963 100644 --- a/app/src/ui/client/schema/auth/use-auth.ts +++ b/app/src/ui/client/schema/auth/use-auth.ts @@ -16,8 +16,8 @@ type UseAuth = { verified: boolean; login: (data: LoginData) => Promise; register: (data: LoginData) => Promise; - logout: () => void; - verify: () => void; + logout: () => Promise; + verify: () => Promise; setToken: (token: string) => void; }; @@ -42,12 +42,13 @@ export const useAuth = (options?: { baseUrl?: string }): UseAuth => { } async function logout() { - api.updateToken(undefined); - invalidate(); + await api.auth.logout(); + await invalidate(); } async function verify() { await api.verifyAuth(); + await invalidate(); } return { diff --git a/app/src/ui/lib/routes.ts b/app/src/ui/lib/routes.ts index 7243099..46ed4fb 100644 --- a/app/src/ui/lib/routes.ts +++ b/app/src/ui/lib/routes.ts @@ -95,7 +95,7 @@ export function useNavigate() { window.location.href = url; return; } else if ("target" in options) { - const _url = window.location.origin + basepath + router.base + url; + const _url = window.location.origin + router.base + url; window.open(_url, options.target); return; } diff --git a/app/src/ui/routes/data/_data.root.tsx b/app/src/ui/routes/data/_data.root.tsx index fb4bc2f..ba27bf9 100644 --- a/app/src/ui/routes/data/_data.root.tsx +++ b/app/src/ui/routes/data/_data.root.tsx @@ -215,7 +215,9 @@ const EntityContextMenu = ({ href && { icon: IconExternalLink, label: "Open in tab", - onClick: () => navigate(href, { target: "_blank" }), + onClick: () => { + navigate(href, { target: "_blank", absolute: true }); + }, }, separator, !$data.system(entity.name).any && {