mirror of
https://github.com/shishantbiswas/bknd.git
synced 2026-03-16 04:27:21 +00:00
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`.
This commit is contained in:
@@ -40,6 +40,7 @@ export type ApiOptions = {
|
|||||||
data?: SubApiOptions<DataApiOptions>;
|
data?: SubApiOptions<DataApiOptions>;
|
||||||
auth?: SubApiOptions<AuthApiOptions>;
|
auth?: SubApiOptions<AuthApiOptions>;
|
||||||
media?: SubApiOptions<MediaApiOptions>;
|
media?: SubApiOptions<MediaApiOptions>;
|
||||||
|
credentials?: RequestCredentials;
|
||||||
} & (
|
} & (
|
||||||
| {
|
| {
|
||||||
token?: string;
|
token?: string;
|
||||||
@@ -67,7 +68,7 @@ export class Api {
|
|||||||
public auth!: AuthApi;
|
public auth!: AuthApi;
|
||||||
public media!: MediaApi;
|
public media!: MediaApi;
|
||||||
|
|
||||||
constructor(private options: ApiOptions = {}) {
|
constructor(public options: ApiOptions = {}) {
|
||||||
// only mark verified if forced
|
// only mark verified if forced
|
||||||
this.verified = options.verified === true;
|
this.verified = options.verified === true;
|
||||||
|
|
||||||
@@ -129,29 +130,45 @@ export class Api {
|
|||||||
} else if (this.storage) {
|
} else if (this.storage) {
|
||||||
this.storage.getItem(this.tokenKey).then((token) => {
|
this.storage.getItem(this.tokenKey).then((token) => {
|
||||||
this.token_transport = "header";
|
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() {
|
private get storage() {
|
||||||
if (!this.options.storage) return null;
|
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 {
|
return {
|
||||||
getItem: async (key: string) => {
|
// biome-ignore lint/suspicious/noThenProperty: it's a promise :)
|
||||||
return await this.options.storage!.getItem(key);
|
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.token = token;
|
||||||
this.verified = false;
|
this.verified = opts?.verified === true;
|
||||||
|
|
||||||
if (token) {
|
if (token) {
|
||||||
this.user = omitKeys(decode(token).payload as any, ["iat", "iss", "exp"]) as any;
|
this.user = omitKeys(decode(token).payload as any, ["iat", "iss", "exp"]) as any;
|
||||||
@@ -159,21 +176,22 @@ export class Api {
|
|||||||
this.user = undefined;
|
this.user = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const emit = () => {
|
||||||
|
if (opts?.trigger !== false) {
|
||||||
|
this.options.onAuthStateChange?.(this.getAuthState());
|
||||||
|
}
|
||||||
|
};
|
||||||
if (this.storage) {
|
if (this.storage) {
|
||||||
const key = this.tokenKey;
|
const key = this.tokenKey;
|
||||||
|
|
||||||
if (token) {
|
if (token) {
|
||||||
this.storage.setItem(key, token).then(() => {
|
this.storage.setItem(key, token).then(emit);
|
||||||
this.options.onAuthStateChange?.(this.getAuthState());
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
this.storage.removeItem(key).then(() => {
|
this.storage.removeItem(key).then(emit);
|
||||||
this.options.onAuthStateChange?.(this.getAuthState());
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (opts?.trigger !== false) {
|
if (opts?.trigger !== false) {
|
||||||
this.options.onAuthStateChange?.(this.getAuthState());
|
emit();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,6 +200,7 @@ export class Api {
|
|||||||
|
|
||||||
private markAuthVerified(verfied: boolean) {
|
private markAuthVerified(verfied: boolean) {
|
||||||
this.verified = verfied;
|
this.verified = verfied;
|
||||||
|
this.options.onAuthStateChange?.(this.getAuthState());
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,11 +227,6 @@ export class Api {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async verifyAuth() {
|
async verifyAuth() {
|
||||||
if (!this.token) {
|
|
||||||
this.markAuthVerified(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { ok, data } = await this.auth.me();
|
const { ok, data } = await this.auth.me();
|
||||||
const user = data?.user;
|
const user = data?.user;
|
||||||
@@ -221,10 +235,10 @@ export class Api {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.user = user;
|
this.user = user;
|
||||||
this.markAuthVerified(true);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.markAuthVerified(false);
|
|
||||||
this.updateToken(undefined);
|
this.updateToken(undefined);
|
||||||
|
} finally {
|
||||||
|
this.markAuthVerified(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,6 +253,7 @@ export class Api {
|
|||||||
headers: this.options.headers,
|
headers: this.options.headers,
|
||||||
token_transport: this.token_transport,
|
token_transport: this.token_transport,
|
||||||
verbose: this.options.verbose,
|
verbose: this.options.verbose,
|
||||||
|
credentials: this.options.credentials,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,10 +272,9 @@ export class Api {
|
|||||||
this.auth = new AuthApi(
|
this.auth = new AuthApi(
|
||||||
{
|
{
|
||||||
...baseParams,
|
...baseParams,
|
||||||
credentials: this.options.storage ? "omit" : "include",
|
|
||||||
...this.options.auth,
|
...this.options.auth,
|
||||||
onTokenUpdate: (token) => {
|
onTokenUpdate: (token, verified) => {
|
||||||
this.updateToken(token, { rebuild: true });
|
this.updateToken(token, { rebuild: true, verified, trigger: true });
|
||||||
this.options.auth?.onTokenUpdate?.(token);
|
this.options.auth?.onTokenUpdate?.(token);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type { AuthResponse, SafeUser, AuthStrategy } from "bknd";
|
|||||||
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, verified?: boolean) => void | Promise<void>;
|
||||||
credentials?: "include" | "same-origin" | "omit";
|
credentials?: "include" | "same-origin" | "omit";
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -17,23 +17,19 @@ export class AuthApi extends ModuleApi<AuthApiOptions> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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: this.options.credentials,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.ok && res.body.token) {
|
if (res.ok && res.body.token) {
|
||||||
await this.options.onTokenUpdate?.(res.body.token);
|
await this.options.onTokenUpdate?.(res.body.token, true);
|
||||||
}
|
}
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
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: this.options.credentials,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.ok && res.body.token) {
|
if (res.ok && res.body.token) {
|
||||||
await this.options.onTokenUpdate?.(res.body.token);
|
await this.options.onTokenUpdate?.(res.body.token, true);
|
||||||
}
|
}
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
@@ -71,6 +67,11 @@ export class AuthApi extends ModuleApi<AuthApiOptions> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async logout() {
|
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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export interface UserPool {
|
|||||||
const defaultCookieExpires = 60 * 60 * 24 * 7; // 1 week in seconds
|
const defaultCookieExpires = 60 * 60 * 24 * 7; // 1 week in seconds
|
||||||
export const cookieConfig = s
|
export const cookieConfig = s
|
||||||
.strictObject({
|
.strictObject({
|
||||||
|
domain: s.string().optional(),
|
||||||
path: s.string({ default: "/" }),
|
path: s.string({ default: "/" }),
|
||||||
sameSite: s.string({ enum: ["strict", "lax", "none"], default: "lax" }),
|
sameSite: s.string({ enum: ["strict", "lax", "none"], default: "lax" }),
|
||||||
secure: s.boolean({ default: true }),
|
secure: s.boolean({ default: true }),
|
||||||
@@ -290,6 +291,7 @@ export class Authenticator<
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
...cookieConfig,
|
...cookieConfig,
|
||||||
|
domain: cookieConfig.domain ?? undefined,
|
||||||
expires: new Date(Date.now() + expires * 1000),
|
expires: new Date(Date.now() + expires * 1000),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -354,7 +356,10 @@ export class Authenticator<
|
|||||||
|
|
||||||
// @todo: move this to a server helper
|
// @todo: move this to a server helper
|
||||||
isJsonRequest(c: Context): boolean {
|
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) {
|
async getBody(c: Context) {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export type BaseModuleApiOptions = {
|
|||||||
host: string;
|
host: string;
|
||||||
basepath?: string;
|
basepath?: string;
|
||||||
token?: string;
|
token?: string;
|
||||||
|
credentials?: RequestCredentials;
|
||||||
headers?: Headers;
|
headers?: Headers;
|
||||||
token_transport?: "header" | "cookie" | "none";
|
token_transport?: "header" | "cookie" | "none";
|
||||||
verbose?: boolean;
|
verbose?: boolean;
|
||||||
@@ -106,6 +107,7 @@ export abstract class ModuleApi<Options extends BaseModuleApiOptions = BaseModul
|
|||||||
|
|
||||||
const request = new Request(url, {
|
const request = new Request(url, {
|
||||||
..._init,
|
..._init,
|
||||||
|
credentials: this.options.credentials,
|
||||||
method,
|
method,
|
||||||
body,
|
body,
|
||||||
headers,
|
headers,
|
||||||
|
|||||||
@@ -52,11 +52,16 @@ export class AppServer extends Module<AppServerConfig> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override async build() {
|
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(
|
this.client.use(
|
||||||
"*",
|
"*",
|
||||||
cors({
|
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,
|
allowMethods: this.config.cors.allow_methods,
|
||||||
allowHeaders: this.config.cors.allow_headers,
|
allowHeaders: this.config.cors.allow_headers,
|
||||||
credentials: this.config.cors.allow_credentials,
|
credentials: this.config.cors.allow_credentials,
|
||||||
|
|||||||
@@ -44,18 +44,17 @@ export const ClientProvider = ({
|
|||||||
...apiProps,
|
...apiProps,
|
||||||
verbose: isDebug(),
|
verbose: isDebug(),
|
||||||
onAuthStateChange: (state) => {
|
onAuthStateChange: (state) => {
|
||||||
|
const { token, ...rest } = state;
|
||||||
props.onAuthStateChange?.(state);
|
props.onAuthStateChange?.(state);
|
||||||
if (!authState?.token || state.token !== authState?.token) {
|
if (!authState?.token || token !== authState?.token) {
|
||||||
setAuthState(state);
|
setAuthState(rest);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
[JSON.stringify(apiProps)],
|
[JSON.stringify(apiProps)],
|
||||||
);
|
);
|
||||||
|
|
||||||
const [authState, setAuthState] = useState<Partial<AuthState> | undefined>(
|
const [authState, setAuthState] = useState<Partial<AuthState> | undefined>(api.getAuthState());
|
||||||
apiProps.user ? api.getAuthState() : undefined,
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ClientContext.Provider value={{ baseUrl: api.baseUrl, api, authState }}>
|
<ClientContext.Provider value={{ baseUrl: api.baseUrl, api, authState }}>
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ type UseAuth = {
|
|||||||
verified: boolean;
|
verified: boolean;
|
||||||
login: (data: LoginData) => Promise<AuthResponse>;
|
login: (data: LoginData) => Promise<AuthResponse>;
|
||||||
register: (data: LoginData) => Promise<AuthResponse>;
|
register: (data: LoginData) => Promise<AuthResponse>;
|
||||||
logout: () => void;
|
logout: () => Promise<void>;
|
||||||
verify: () => void;
|
verify: () => Promise<void>;
|
||||||
setToken: (token: string) => void;
|
setToken: (token: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -42,12 +42,13 @@ export const useAuth = (options?: { baseUrl?: string }): UseAuth => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function logout() {
|
async function logout() {
|
||||||
api.updateToken(undefined);
|
await api.auth.logout();
|
||||||
invalidate();
|
await invalidate();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function verify() {
|
async function verify() {
|
||||||
await api.verifyAuth();
|
await api.verifyAuth();
|
||||||
|
await invalidate();
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ export function useNavigate() {
|
|||||||
window.location.href = url;
|
window.location.href = url;
|
||||||
return;
|
return;
|
||||||
} else if ("target" in options) {
|
} 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);
|
window.open(_url, options.target);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -215,7 +215,9 @@ const EntityContextMenu = ({
|
|||||||
href && {
|
href && {
|
||||||
icon: IconExternalLink,
|
icon: IconExternalLink,
|
||||||
label: "Open in tab",
|
label: "Open in tab",
|
||||||
onClick: () => navigate(href, { target: "_blank" }),
|
onClick: () => {
|
||||||
|
navigate(href, { target: "_blank", absolute: true });
|
||||||
|
},
|
||||||
},
|
},
|
||||||
separator,
|
separator,
|
||||||
!$data.system(entity.name).any && {
|
!$data.system(entity.name).any && {
|
||||||
|
|||||||
Reference in New Issue
Block a user