confirmed SSR support with Remix

This commit is contained in:
dswbx
2024-11-25 19:59:46 +01:00
parent 1c94777317
commit eea76ebc28
15 changed files with 144 additions and 44 deletions

View File

@@ -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;
}

View File

@@ -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) => {

View File

@@ -6,6 +6,8 @@ export type BaseModuleApiOptions = {
host: string;
basepath?: string;
token?: string;
headers?: Headers;
token_transport?: "header" | "cookie" | "none";
};
export type ApiResponse<Data = any> = {
@@ -53,14 +55,18 @@ export abstract class ModuleApi<Options extends BaseModuleApiOptions> {
}
}
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;

View File

@@ -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<any> {
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);

View File

@@ -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();

View File

@@ -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<string | null>(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 (
<QueryClientProvider client={queryClient}>
@@ -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");

View File

@@ -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
});
}

View File

@@ -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() {

View File

@@ -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,