diff --git a/app/src/Api.ts b/app/src/Api.ts index 0595557..f54ccdf 100644 --- a/app/src/Api.ts +++ b/app/src/Api.ts @@ -1,6 +1,7 @@ import { AuthApi } from "auth/api/AuthApi"; import { DataApi } from "data/api/DataApi"; import { decode } from "hono/jwt"; +import { omit } from "lodash-es"; import { MediaApi } from "media/api/MediaApi"; import { SystemApi } from "modules/SystemApi"; @@ -14,15 +15,18 @@ declare global { export type ApiOptions = { host: string; + user?: object; token?: string; - storage?: "localStorage" | "manual"; + headers?: Headers; key?: string; + localStorage?: boolean; }; export class Api { private token?: string; private user?: object; private verified = false; + private token_transport: "header" | "cookie" | "none" = "header"; public system!: SystemApi; public data!: DataApi; @@ -30,7 +34,12 @@ export class Api { public media!: MediaApi; constructor(private readonly options: ApiOptions) { - if (options.token) { + if (options.user) { + this.user = options.user; + this.token_transport = "none"; + this.verified = true; + } else if (options.token) { + this.token_transport = "header"; this.updateToken(options.token); } else { this.extractToken(); @@ -39,33 +48,44 @@ export class Api { this.buildApis(); } - get tokenStorage() { - return this.options.storage ?? "manual"; - } get tokenKey() { return this.options.key ?? "auth"; } private extractToken() { - if (this.tokenStorage === "localStorage") { + if (this.options.headers) { + // try cookies + const cookieToken = getCookieValue(this.options.headers.get("cookie"), "auth"); + if (cookieToken) { + this.updateToken(cookieToken); + this.token_transport = "cookie"; + this.verified = true; + 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.options.localStorage) { const token = localStorage.getItem(this.tokenKey); if (token) { - this.token = token; - this.user = decode(token).payload as any; - } - } else { - if (typeof window !== "undefined" && "__BKND__" in window) { - this.user = window.__BKND__.user; - this.verified = true; + this.token_transport = "header"; + this.updateToken(token); } } + + //console.warn("Couldn't extract token"); } updateToken(token?: string, rebuild?: boolean) { this.token = token; - this.user = token ? (decode(token).payload as any) : undefined; + this.user = token ? omit(decode(token).payload as any, ["iat", "iss", "exp"]) : undefined; - if (this.tokenStorage === "localStorage") { + if (this.options.localStorage) { const key = this.tokenKey; if (token) { @@ -94,7 +114,9 @@ export class Api { private buildApis() { const baseParams = { host: this.options.host, - token: this.token + token: this.token, + headers: this.options.headers, + token_transport: this.token_transport }; this.system = new SystemApi(baseParams); @@ -106,3 +128,15 @@ export class Api { this.media = new MediaApi(baseParams); } } + +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; +} diff --git a/app/src/auth/api/AuthController.ts b/app/src/auth/api/AuthController.ts index ea07960..bd4ef63 100644 --- a/app/src/auth/api/AuthController.ts +++ b/app/src/auth/api/AuthController.ts @@ -35,7 +35,16 @@ export class AuthController implements ClassController { hono.get("/logout", async (c) => { await this.auth.authenticator.logout(c); - return c.json({ ok: true }); + if (this.auth.authenticator.isJsonRequest(c)) { + return c.json({ ok: true }); + } + + const referer = c.req.header("referer"); + if (referer) { + return c.redirect(referer); + } + + return c.redirect("/"); }); hono.get("/strategies", async (c) => { diff --git a/app/src/modules/ModuleApi.ts b/app/src/modules/ModuleApi.ts index 3454a8d..541505b 100644 --- a/app/src/modules/ModuleApi.ts +++ b/app/src/modules/ModuleApi.ts @@ -6,6 +6,8 @@ export type BaseModuleApiOptions = { host: string; basepath?: string; token?: string; + headers?: Headers; + token_transport?: "header" | "cookie" | "none"; }; export type ApiResponse = { @@ -53,14 +55,18 @@ export abstract class ModuleApi { } } - const headers = new Headers(_init?.headers ?? {}); + const headers = new Headers(this.options.headers ?? {}); + // add init headers + for (const [key, value] of Object.entries(_init?.headers ?? {})) { + headers.set(key, value as string); + } + headers.set("Accept", "application/json"); - if (this.options.token) { + // only add token if initial headers not provided + if (this.options.token && this.options.token_transport === "header") { //console.log("setting token", this.options.token); headers.set("Authorization", `Bearer ${this.options.token}`); - } else { - //console.log("no token"); } let body: any = _init?.body; diff --git a/app/src/modules/server/AdminController.tsx b/app/src/modules/server/AdminController.tsx index 8cb3670..1cffa11 100644 --- a/app/src/modules/server/AdminController.tsx +++ b/app/src/modules/server/AdminController.tsx @@ -38,20 +38,26 @@ export class AdminController implements ClassController { return this.app.modules.ctx(); } + private withBasePath(route: string = "") { + return (this.app.modules.configs().server.admin.basepath + route).replace(/\/+$/, "/"); + } + getController(): Hono { const auth = this.app.module.auth; const configs = this.app.modules.configs(); // if auth is not enabled, authenticator is undefined const auth_enabled = configs.auth.enabled; - const basepath = (String(configs.server.admin.basepath) + "/").replace(/\/+$/, "/"); const hono = new Hono<{ Variables: { html: string; }; - }>().basePath(basepath); + }>().basePath(this.withBasePath()); hono.use("*", async (c, next) => { - const obj = { user: auth.authenticator?.getUser() }; + const obj = { + user: auth.authenticator?.getUser(), + logout_route: this.withBasePath(authRoutes.logout) + }; const html = await this.getHtml(obj); if (!html) { console.warn("Couldn't generate HTML for admin UI"); @@ -85,7 +91,6 @@ export class AdminController implements ClassController { } hono.get("*", async (c) => { - console.log("admin", c.req.url); if (!this.ctx.guard.granted(SystemPermissions.accessAdmin)) { await addFlashMessage(c, "You are not authorized to access the Admin UI", "error"); return c.redirect(authRoutes.login); diff --git a/app/src/ui/client/BkndProvider.tsx b/app/src/ui/client/BkndProvider.tsx index 89ab0f0..14e704a 100644 --- a/app/src/ui/client/BkndProvider.tsx +++ b/app/src/ui/client/BkndProvider.tsx @@ -1,4 +1,4 @@ -import { notifications } from "@mantine/notifications"; +//import { notifications } from "@mantine/notifications"; import { getDefaultConfig, getDefaultSchema } from "modules/ModuleManager"; import { createContext, startTransition, useContext, useEffect, useRef, useState } from "react"; import type { ModuleConfigs, ModuleSchemas } from "../../modules"; @@ -83,7 +83,6 @@ export function BkndProvider({ if (!fetched || !schema) return null; const app = new AppReduced(schema?.config as any); - const actions = getSchemaActions({ client, setSchema, reloadSchema }); return ( @@ -93,6 +92,20 @@ export function BkndProvider({ ); } +type BkndWindowContext = { + user?: object; + logout_route: string; +}; +export function useBkndWindowContext(): BkndWindowContext { + if (typeof window !== "undefined" && window.__BKND__) { + return window.__BKND__ as any; + } else { + return { + logout_route: "/api/auth/logout" + }; + } +} + export function useBknd({ withSecrets }: { withSecrets?: boolean } = {}): BkndContext { const ctx = useContext(BkndContext); if (withSecrets) ctx.requireSecrets(); diff --git a/app/src/ui/client/ClientProvider.tsx b/app/src/ui/client/ClientProvider.tsx index cd31f42..4cca06b 100644 --- a/app/src/ui/client/ClientProvider.tsx +++ b/app/src/ui/client/ClientProvider.tsx @@ -1,5 +1,6 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { createContext, useContext, useEffect, useState } from "react"; +import { useBkndWindowContext } from "ui/client/BkndProvider"; import { AppQueryClient } from "./utils/AppQueryClient"; const ClientContext = createContext<{ baseUrl: string; client: AppQueryClient }>({ @@ -15,8 +16,13 @@ export const queryClient = new QueryClient({ } }); -export const ClientProvider = ({ children, baseUrl }: { children?: any; baseUrl?: string }) => { +export const ClientProvider = ({ + children, + baseUrl, + user +}: { children?: any; baseUrl?: string; user?: object }) => { const [actualBaseUrl, setActualBaseUrl] = useState(null); + const winCtx = useBkndWindowContext(); try { const _ctx_baseUrl = useBaseUrl(); @@ -40,8 +46,8 @@ export const ClientProvider = ({ children, baseUrl }: { children?: any; baseUrl? return null; // or a loader/spinner if desired } - console.log("client provider11 with", { baseUrl, fallback: actualBaseUrl }); - const client = createClient(actualBaseUrl); + //console.log("client provider11 with", { baseUrl, fallback: actualBaseUrl, user }); + const client = createClient(actualBaseUrl, user ?? winCtx.user); return ( @@ -52,11 +58,11 @@ export const ClientProvider = ({ children, baseUrl }: { children?: any; baseUrl? ); }; -export function createClient(baseUrl: string = window.location.origin) { - return new AppQueryClient(baseUrl); +export function createClient(baseUrl: string, user?: object) { + return new AppQueryClient(baseUrl, user); } -export function createOrUseClient(baseUrl: string = window.location.origin) { +export function createOrUseClient(baseUrl: string) { const context = useContext(ClientContext); if (!context) { console.warn("createOrUseClient returned a new client"); diff --git a/app/src/ui/client/utils/AppQueryClient.ts b/app/src/ui/client/utils/AppQueryClient.ts index 5f06704..8eecf9e 100644 --- a/app/src/ui/client/utils/AppQueryClient.ts +++ b/app/src/ui/client/utils/AppQueryClient.ts @@ -13,9 +13,13 @@ import { queryClient } from "../ClientProvider"; export class AppQueryClient { api: Api; - constructor(public baseUrl: string) { + constructor( + public baseUrl: string, + user?: object + ) { this.api = new Api({ - host: baseUrl + host: baseUrl, + user }); } diff --git a/app/src/ui/layouts/AppShell/Header.tsx b/app/src/ui/layouts/AppShell/Header.tsx index fd3f20c..4082391 100644 --- a/app/src/ui/layouts/AppShell/Header.tsx +++ b/app/src/ui/layouts/AppShell/Header.tsx @@ -14,6 +14,7 @@ import { } from "react-icons/tb"; import { Button } from "ui"; import { useAuth, useBknd } from "ui/client"; +import { useBkndWindowContext } from "ui/client/BkndProvider"; import { useBkndSystemTheme } from "ui/client/schema/system/use-bknd-system"; import { IconButton } from "ui/components/buttons/IconButton"; import { Logo } from "ui/components/display/Logo"; @@ -147,11 +148,12 @@ export function Header({ hasSidebar = true }) { function UserMenu() { const auth = useAuth(); const [navigate] = useNavigate(); + const { logout_route } = useBkndWindowContext(); async function handleLogout() { await auth.logout(); // @todo: grab from somewhere constant - window.location.href = "/auth/logout"; + navigate(logout_route, { reload: true }); } async function handleLogin() { diff --git a/app/src/ui/lib/routes.ts b/app/src/ui/lib/routes.ts index 2588403..1b3779c 100644 --- a/app/src/ui/lib/routes.ts +++ b/app/src/ui/lib/routes.ts @@ -63,8 +63,15 @@ export function useNavigate() { return [ ( url: string, - options?: { query?: object; absolute?: boolean; replace?: boolean; state?: any } + options?: + | { query?: object; absolute?: boolean; replace?: boolean; state?: any } + | { reload: true } ) => { + if (options && "reload" in options) { + window.location.href = url; + return; + } + const _url = options?.absolute ? `~/${basepath}${url}`.replace(/\/+/g, "/") : url; navigate(options?.query ? withQuery(_url, options?.query) : _url, { replace: options?.replace, diff --git a/bun.lockb b/bun.lockb index 565dd37..ec74acc 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/examples/remix/app/root.tsx b/examples/remix/app/root.tsx index 39665ff..e65961c 100644 --- a/examples/remix/app/root.tsx +++ b/examples/remix/app/root.tsx @@ -1,5 +1,5 @@ import type { LoaderFunctionArgs } from "@remix-run/node"; -import { Links, Meta, Outlet, Scripts, ScrollRestoration } from "@remix-run/react"; +import { Links, Meta, Outlet, Scripts, ScrollRestoration, useLoaderData } from "@remix-run/react"; import { Api } from "bknd"; import { ClientProvider } from "bknd/ui"; @@ -22,15 +22,26 @@ export function Layout({ children }: { children: React.ReactNode }) { } export const loader = async (args: LoaderFunctionArgs) => { - args.context.api = new Api({ - host: new URL(args.request.url).origin + const api = new Api({ + host: new URL(args.request.url).origin, + headers: args.request.headers }); - return null; + + // add api to the context + args.context.api = api; + + return { + user: api.getAuthState().user + }; }; export default function App() { + const data = useLoaderData(); + + // add user to the client provider to indicate + // that you're authed using cookie return ( - + ); diff --git a/examples/remix/app/routes/_index.tsx b/examples/remix/app/routes/_index.tsx index 37c3c55..8bdc1f3 100644 --- a/examples/remix/app/routes/_index.tsx +++ b/examples/remix/app/routes/_index.tsx @@ -9,8 +9,9 @@ export const meta: MetaFunction = () => { export const loader = async (args: LoaderFunctionArgs) => { const api = args.context.api as Api; + const user = api.getAuthState().user; const { data } = await api.data.readMany("todos"); - return { data }; + return { data, user }; }; export default function Index() { diff --git a/examples/remix/app/routes/admin.$.tsx b/examples/remix/app/routes/admin.$.tsx index d73a208..811d13b 100644 --- a/examples/remix/app/routes/admin.$.tsx +++ b/examples/remix/app/routes/admin.$.tsx @@ -6,6 +6,7 @@ import "bknd/dist/styles.css"; export default function AdminPage() { const [loaded, setLoaded] = useState(false); useEffect(() => { + if (typeof window === "undefined") return; setLoaded(true); }, []); if (!loaded) return null; diff --git a/examples/remix/package.json b/examples/remix/package.json index f9c1602..5de343f 100644 --- a/examples/remix/package.json +++ b/examples/remix/package.json @@ -18,7 +18,8 @@ "bknd": "workspace:*", "isbot": "^4.1.0", "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "remix-utils": "^7.7.0" }, "devDependencies": { "@remix-run/dev": "^2.14.0", diff --git a/examples/remix/test.db b/examples/remix/test.db index 1125cc2..34f6d06 100644 Binary files a/examples/remix/test.db and b/examples/remix/test.db differ