Files
bknd/app/src/Api.ts
dswbx 9070f96571 feat: enhance API and AuthApi with credentials support and async storage handling
- Added `credentials` option to `ApiOptions` and `BaseModuleApiOptions` for better request handling.
- Updated `AuthApi` to pass `verified` status during token updates.
- Refactored storage handling in `Api` to support async operations using a Proxy.
- Improved `Authenticator` to handle cookie domain configuration and JSON request detection.
- Adjusted `useAuth` to ensure logout and verify methods return promises for better async handling.
- Fixed navigation URL construction in `useNavigate` and updated context menu actions in `_data.root.tsx`.
2025-10-15 18:41:04 +02:00

304 lines
7.9 KiB
TypeScript

import type { SafeUser } from "bknd";
import { AuthApi, type AuthApiOptions } from "auth/api/AuthApi";
import { DataApi, type DataApiOptions } from "data/api/DataApi";
import { decode } from "hono/jwt";
import { MediaApi, type MediaApiOptions } from "media/api/MediaApi";
import { SystemApi } from "modules/SystemApi";
import { omitKeys } from "bknd/utils";
import type { BaseModuleApiOptions } from "modules";
export type TApiUser = SafeUser;
export type ApiFetcher = (
input: RequestInfo | URL,
init?: RequestInit,
) => Response | Promise<Response>;
declare global {
interface Window {
__BKND__: {
user?: TApiUser;
};
}
}
type SubApiOptions<T extends BaseModuleApiOptions> = Omit<T, keyof BaseModuleApiOptions>;
export type ApiOptions = {
host?: string;
headers?: Headers;
key?: string;
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;
verbose?: boolean;
verified?: boolean;
data?: SubApiOptions<DataApiOptions>;
auth?: SubApiOptions<AuthApiOptions>;
media?: SubApiOptions<MediaApiOptions>;
credentials?: RequestCredentials;
} & (
| {
token?: string;
user?: TApiUser | null;
}
| {
request: Request;
}
);
export type AuthState = {
token?: string;
user?: TApiUser;
verified: boolean;
};
export class Api {
private token?: string;
private user?: TApiUser;
private verified = false;
private token_transport: "header" | "cookie" | "none" = "header";
public system!: SystemApi;
public data!: DataApi;
public auth!: AuthApi;
public media!: MediaApi;
constructor(public options: ApiOptions = {}) {
// only mark verified if forced
this.verified = options.verified === true;
// prefer request if given
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 && options.token) {
this.token_transport = "header";
this.updateToken(options.token, { trigger: false });
// then check for an user object
} else if ("user" in options && options.user) {
this.token_transport = "none";
this.user = options.user;
this.verified = options.verified !== false;
} else {
this.extractToken();
}
this.buildApis();
}
get fetcher() {
return this.options.fetcher ?? fetch;
}
get baseUrl() {
return this.options.host ?? "http://localhost";
}
get tokenKey() {
return this.options.key ?? "auth";
}
private extractToken() {
// if token has to be extracted, it's never verified
this.verified = false;
if (this.options.headers) {
// try cookies
const cookieToken = getCookieValue(this.options.headers.get("cookie"), "auth");
if (cookieToken) {
this.token_transport = "cookie";
this.updateToken(cookieToken);
return;
}
// try authorization header
const headerToken = this.options.headers.get("authorization")?.replace("Bearer ", "");
if (headerToken) {
this.token_transport = "header";
this.updateToken(headerToken);
return;
}
} else if (this.storage) {
this.storage.getItem(this.tokenKey).then((token) => {
this.token_transport = "header";
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() {
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),
};
};
},
},
) as any;
}
updateToken(
token?: string,
opts?: { rebuild?: boolean; verified?: boolean; trigger?: boolean },
) {
this.token = token;
this.verified = opts?.verified === true;
if (token) {
this.user = omitKeys(decode(token).payload as any, ["iat", "iss", "exp"]) as any;
} else {
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(emit);
} else {
this.storage.removeItem(key).then(emit);
}
} else {
if (opts?.trigger !== false) {
emit();
}
}
if (opts?.rebuild) this.buildApis();
}
private markAuthVerified(verfied: boolean) {
this.verified = verfied;
this.options.onAuthStateChange?.(this.getAuthState());
return this;
}
isAuthVerified(): boolean {
return this.verified;
}
getAuthState(): AuthState {
return {
token: this.token,
user: this.user,
verified: this.verified,
};
}
isAuthenticated(): boolean {
const { token, user } = this.getAuthState();
return !!token && !!user;
}
async getVerifiedAuthState(): Promise<AuthState> {
await this.verifyAuth();
return this.getAuthState();
}
async verifyAuth() {
try {
const { ok, data } = await this.auth.me();
const user = data?.user;
if (!ok || !user) {
throw new Error();
}
this.user = user;
} catch (e) {
this.updateToken(undefined);
} finally {
this.markAuthVerified(true);
}
}
getUser(): TApiUser | null {
return this.user || null;
}
getParams() {
return Object.freeze({
host: this.baseUrl,
token: this.token,
headers: this.options.headers,
token_transport: this.token_transport,
verbose: this.options.verbose,
credentials: this.options.credentials,
});
}
private buildApis() {
const baseParams = this.getParams();
const fetcher = this.options.fetcher;
this.system = new SystemApi(baseParams, fetcher);
this.data = new DataApi(
{
...baseParams,
...this.options.data,
},
fetcher,
);
this.auth = new AuthApi(
{
...baseParams,
...this.options.auth,
onTokenUpdate: (token, verified) => {
this.updateToken(token, { rebuild: true, verified, trigger: true });
this.options.auth?.onTokenUpdate?.(token);
},
},
fetcher,
);
this.media = new MediaApi(
{
...baseParams,
...this.options.media,
},
fetcher,
);
}
}
function getCookieValue(cookies: string | null, name: string) {
if (!cookies) return null;
for (const cookie of cookies.split("; ")) {
const [key, value] = cookie.split("=");
if (key === name && value) {
return decodeURIComponent(value);
}
}
return null;
}