reworked admin auth to use form and cookie + adjusted oauth to support API and cookie-based auth

This commit is contained in:
dswbx
2024-11-23 18:12:19 +01:00
parent f70e2b2e10
commit 824ff40133
30 changed files with 630 additions and 483 deletions

View File

@@ -4,14 +4,20 @@ import { decodeJwt } from "jose";
import { MediaApi } from "media/api/MediaApi"; import { MediaApi } from "media/api/MediaApi";
import { SystemApi } from "modules/SystemApi"; import { SystemApi } from "modules/SystemApi";
declare global {
interface Window {
__BKND__: {
user?: any;
};
}
}
export type ApiOptions = { export type ApiOptions = {
host: string; host: string;
token?: string; token?: string;
tokenStorage?: "localStorage"; storage?: "localStorage" | "manual";
localStorage?: {
key?: string; key?: string;
}; };
};
export class Api { export class Api {
private token?: string; private token?: string;
@@ -33,16 +39,25 @@ export class Api {
this.buildApis(); this.buildApis();
} }
private extractToken() { get tokenStorage() {
if (this.options.tokenStorage === "localStorage") { return this.options.storage ?? "manual";
const key = this.options.localStorage?.key ?? "auth"; }
const raw = localStorage.getItem(key); get tokenKey() {
return this.options.key ?? "auth";
}
if (raw) { private extractToken() {
const { token } = JSON.parse(raw); if (this.tokenStorage === "localStorage") {
const token = localStorage.getItem(this.tokenKey);
if (token) {
this.token = token; this.token = token;
this.user = decodeJwt(token) as any; this.user = decodeJwt(token) as any;
} }
} else {
if (typeof window !== "undefined" && "__BKND__" in window) {
this.user = window.__BKND__.user;
this.verified = true;
}
} }
} }
@@ -50,11 +65,11 @@ export class Api {
this.token = token; this.token = token;
this.user = token ? (decodeJwt(token) as any) : undefined; this.user = token ? (decodeJwt(token) as any) : undefined;
if (this.options.tokenStorage === "localStorage") { if (this.tokenStorage === "localStorage") {
const key = this.options.localStorage?.key ?? "auth"; const key = this.tokenKey;
if (token) { if (token) {
localStorage.setItem(key, JSON.stringify({ token })); localStorage.setItem(key, token);
} else { } else {
localStorage.removeItem(key); localStorage.removeItem(key);
} }
@@ -69,8 +84,6 @@ export class Api {
} }
getAuthState() { getAuthState() {
if (!this.token) return;
return { return {
token: this.token, token: this.token,
user: this.user, user: this.user,

View File

@@ -20,6 +20,7 @@ type AuthSchema = Static<typeof authConfigSchema>;
export class AppAuth extends Module<typeof authConfigSchema> { export class AppAuth extends Module<typeof authConfigSchema> {
private _authenticator?: Authenticator; private _authenticator?: Authenticator;
cache: Record<string, any> = {}; cache: Record<string, any> = {};
_controller!: AuthController;
override async onBeforeUpdate(from: AuthSchema, to: AuthSchema) { override async onBeforeUpdate(from: AuthSchema, to: AuthSchema) {
const defaultSecret = authConfigSchema.properties.jwt.properties.secret.default; const defaultSecret = authConfigSchema.properties.jwt.properties.secret.default;
@@ -68,9 +69,17 @@ export class AppAuth extends Module<typeof authConfigSchema> {
this.registerEntities(); this.registerEntities();
super.setBuilt(); super.setBuilt();
const controller = new AuthController(this); this._controller = new AuthController(this);
//this.ctx.server.use(controller.getMiddleware); //this.ctx.server.use(controller.getMiddleware);
this.ctx.server.route(this.config.basepath, controller.getController()); this.ctx.server.route(this.config.basepath, this._controller.getController());
}
get controller(): AuthController {
if (!this.isBuilt()) {
throw new Error("Can't access controller, AppAuth not built yet");
}
return this._controller;
} }
getMiddleware() { getMiddleware() {

View File

@@ -1,7 +1,6 @@
import type { AppAuth } from "auth"; import type { AppAuth } from "auth";
import type { ClassController } from "core"; import type { ClassController } from "core";
import { Hono, type MiddlewareHandler } from "hono"; import { Hono, type MiddlewareHandler } from "hono";
import * as SystemPermissions from "modules/permissions";
export class AuthController implements ClassController { export class AuthController implements ClassController {
constructor(private auth: AppAuth) {} constructor(private auth: AppAuth) {}
@@ -11,19 +10,8 @@ export class AuthController implements ClassController {
} }
getMiddleware: MiddlewareHandler = async (c, next) => { getMiddleware: MiddlewareHandler = async (c, next) => {
let token: string | undefined; const user = await this.auth.authenticator.resolveAuthFromRequest(c);
if (c.req.raw.headers.has("Authorization")) { this.auth.ctx.guard.setUserContext(user);
const bearerHeader = String(c.req.header("Authorization"));
token = bearerHeader.replace("Bearer ", "");
}
if (token) {
// @todo: don't extract user from token, but from the database or cache
await this.auth.authenticator.verify(token);
this.auth.ctx.guard.setUserContext(this.auth.authenticator.getUser());
} else {
this.auth.authenticator.__setUserNull();
}
await next(); await next();
}; };
@@ -31,7 +19,6 @@ export class AuthController implements ClassController {
getController(): Hono<any> { getController(): Hono<any> {
const hono = new Hono(); const hono = new Hono();
const strategies = this.auth.authenticator.getStrategies(); const strategies = this.auth.authenticator.getStrategies();
//console.log("strategies", strategies);
for (const [name, strategy] of Object.entries(strategies)) { for (const [name, strategy] of Object.entries(strategies)) {
//console.log("registering", name, "at", `/${name}`); //console.log("registering", name, "at", `/${name}`);
@@ -46,6 +33,11 @@ export class AuthController implements ClassController {
return c.json({ user: null }, 403); return c.json({ user: null }, 403);
}); });
hono.get("/logout", async (c) => {
await this.auth.authenticator.logout(c);
return c.json({ ok: true });
});
hono.get("/strategies", async (c) => { hono.get("/strategies", async (c) => {
const { strategies, basepath } = this.auth.toJSON(false); const { strategies, basepath } = this.auth.toJSON(false);
return c.json({ strategies, basepath }); return c.json({ strategies, basepath });

View File

@@ -1,5 +1,8 @@
import { Exception } from "core";
import { addFlashMessage } from "core/server/flash";
import { type Static, type TSchema, Type, parse, randomString, transformObject } from "core/utils"; import { type Static, type TSchema, Type, parse, randomString, transformObject } from "core/utils";
import type { Hono } from "hono"; import type { Context, Hono } from "hono";
import { deleteCookie, getSignedCookie, setSignedCookie } from "hono/cookie";
import { type JWTVerifyOptions, SignJWT, jwtVerify } from "jose"; import { type JWTVerifyOptions, SignJWT, jwtVerify } from "jose";
type Input = any; // workaround type Input = any; // workaround
@@ -82,7 +85,7 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
strategy: Strategy, strategy: Strategy,
identifier: string, identifier: string,
profile: ProfileExchange profile: ProfileExchange
) { ): Promise<AuthResponse> {
//console.log("resolve", { action, strategy: strategy.getName(), profile }); //console.log("resolve", { action, strategy: strategy.getName(), profile });
const user = await this.userResolver(action, strategy, identifier, profile); const user = await this.userResolver(action, strategy, identifier, profile);
@@ -176,6 +179,84 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
return false; return false;
} }
// @todo: CookieOptions not exported from hono
private get cookieOptions(): any {
return {
path: "/",
sameSite: "lax",
httpOnly: true
};
}
private async getAuthCookie(c: Context): Promise<string | undefined> {
const secret = this.config.jwt.secret;
const token = await getSignedCookie(c, secret, "auth");
if (typeof token !== "string") {
await deleteCookie(c, "auth", this.cookieOptions);
return undefined;
}
return token;
}
private async setAuthCookie(c: Context, token: string) {
const secret = this.config.jwt.secret;
await setSignedCookie(c, "auth", token, secret, this.cookieOptions);
}
async logout(c: Context) {
const cookie = await this.getAuthCookie(c);
if (cookie) {
await deleteCookie(c, "auth", this.cookieOptions);
await addFlashMessage(c, "Signed out", "info");
}
}
isJsonRequest(c: Context): boolean {
//return c.req.header("Content-Type") === "application/x-www-form-urlencoded";
return c.req.header("Content-Type") === "application/json";
}
async respond(c: Context, data: AuthResponse | Error | any, redirect?: string) {
if (this.isJsonRequest(c)) {
return c.json(data);
}
const referer = new URL(redirect ?? c.req.header("Referer") ?? "/");
if ("token" in data) {
await this.setAuthCookie(c, data.token);
return c.redirect("/");
}
let message = "An error occured";
if (data instanceof Exception) {
message = data.message;
}
await addFlashMessage(c, message, "error");
return c.redirect(referer);
}
// @todo: don't extract user from token, but from the database or cache
async resolveAuthFromRequest(c: Context): Promise<SafeUser | undefined> {
let token: string | undefined;
if (c.req.raw.headers.has("Authorization")) {
const bearerHeader = String(c.req.header("Authorization"));
token = bearerHeader.replace("Bearer ", "");
} else {
token = await this.getAuthCookie(c);
}
if (token) {
await this.verify(token);
return this._user;
}
return undefined;
}
toJSON(secrets?: boolean) { toJSON(secrets?: boolean) {
return { return {
...this.config, ...this.config,

View File

@@ -1,7 +1,7 @@
import type { Authenticator, Strategy } from "auth"; import type { Authenticator, Strategy } from "auth";
import { type Static, StringEnum, Type, parse } from "core/utils"; import { type Static, StringEnum, Type, parse } from "core/utils";
import { hash } from "core/utils"; import { hash } from "core/utils";
import { Hono } from "hono"; import { type Context, Hono } from "hono";
type LoginSchema = { username: string; password: string } | { email: string; password: string }; type LoginSchema = { username: string; password: string } | { email: string; password: string };
type RegisterSchema = { email: string; password: string; [key: string]: any }; type RegisterSchema = { email: string; password: string; [key: string]: any };
@@ -54,22 +54,34 @@ export class PasswordStrategy implements Strategy {
getController(authenticator: Authenticator): Hono<any> { getController(authenticator: Authenticator): Hono<any> {
const hono = new Hono(); const hono = new Hono();
async function getBody(c: Context) {
if (authenticator.isJsonRequest(c)) {
return await c.req.json();
} else {
return Object.fromEntries((await c.req.formData()).entries());
}
}
return hono return hono
.post("/login", async (c) => { .post("/login", async (c) => {
const body = (await c.req.json()) ?? {}; const body = await getBody(c);
try {
const payload = await this.login(body); const payload = await this.login(body);
const data = await authenticator.resolve("login", this, payload.password, payload); const data = await authenticator.resolve("login", this, payload.password, payload);
return c.json(data); return await authenticator.respond(c, data);
} catch (e) {
return await authenticator.respond(c, e);
}
}) })
.post("/register", async (c) => { .post("/register", async (c) => {
const body = (await c.req.json()) ?? {}; const body = await getBody(c);
const payload = await this.register(body); const payload = await this.register(body);
const data = await authenticator.resolve("register", this, payload.password, payload); const data = await authenticator.resolve("register", this, payload.password, payload);
return c.json(data); return await authenticator.respond(c, data);
}); });
} }

View File

@@ -1,5 +1,5 @@
import type { AuthAction, Authenticator, Strategy } from "auth"; import type { AuthAction, Authenticator, Strategy } from "auth";
import { Exception } from "core"; import { Exception, isDebug } from "core";
import { type Static, StringEnum, type TSchema, Type, filterKeys, parse } from "core/utils"; import { type Static, StringEnum, type TSchema, Type, filterKeys, parse } from "core/utils";
import { type Context, Hono } from "hono"; import { type Context, Hono } from "hono";
import { getSignedCookie, setSignedCookie } from "hono/cookie"; import { getSignedCookie, setSignedCookie } from "hono/cookie";
@@ -173,7 +173,7 @@ export class OAuthStrategy implements Strategy {
const config = await this.getConfig(); const config = await this.getConfig();
const { client, as, type } = config; const { client, as, type } = config;
//console.log("config", config); //console.log("config", config);
//console.log("callbackParams", callbackParams, options); console.log("callbackParams", callbackParams, options);
const parameters = oauth.validateAuthResponse( const parameters = oauth.validateAuthResponse(
as, as,
client, // no client_secret required client, // no client_secret required
@@ -216,7 +216,7 @@ export class OAuthStrategy implements Strategy {
expectedNonce expectedNonce
); );
if (oauth.isOAuth2Error(result)) { if (oauth.isOAuth2Error(result)) {
//console.log("callback.error", result); console.log("callback.error", result);
// @todo: Handle OAuth 2.0 response body error // @todo: Handle OAuth 2.0 response body error
throw new OAuthCallbackException(result, "processAuthorizationCodeOpenIDResponse"); throw new OAuthCallbackException(result, "processAuthorizationCodeOpenIDResponse");
} }
@@ -317,10 +317,15 @@ export class OAuthStrategy implements Strategy {
const secret = "secret"; const secret = "secret";
const cookie_name = "_challenge"; const cookie_name = "_challenge";
const setState = async ( type TState = {
c: Context, state: string;
config: { state: string; action: AuthAction; redirect?: string } action: AuthAction;
): Promise<void> => { redirect?: string;
mode: "token" | "cookie";
};
const setState = async (c: Context, config: TState): Promise<void> => {
console.log("--- setting state", config);
await setSignedCookie(c, cookie_name, JSON.stringify(config), secret, { await setSignedCookie(c, cookie_name, JSON.stringify(config), secret, {
secure: true, secure: true,
httpOnly: true, httpOnly: true,
@@ -329,12 +334,18 @@ export class OAuthStrategy implements Strategy {
}); });
}; };
const getState = async ( const getState = async (c: Context): Promise<TState> => {
c: Context if (c.req.header("X-State-Challenge")) {
): Promise<{ state: string; action: AuthAction; redirect?: string }> => { return {
const state = await getSignedCookie(c, secret, cookie_name); state: c.req.header("X-State-Challenge"),
action: c.req.header("X-State-Action"),
mode: "token"
} as any;
}
const value = await getSignedCookie(c, secret, cookie_name);
try { try {
return JSON.parse(state as string); return JSON.parse(value as string);
} catch (e) { } catch (e) {
throw new Error("Invalid state"); throw new Error("Invalid state");
} }
@@ -345,22 +356,68 @@ export class OAuthStrategy implements Strategy {
const params = new URLSearchParams(url.search); const params = new URLSearchParams(url.search);
const state = await getState(c); const state = await getState(c);
console.log("url", url); console.log("state", state);
// @todo: add config option to determine if state.action is allowed
const redirect_uri =
state.mode === "cookie"
? url.origin + url.pathname
: url.origin + url.pathname.replace("/callback", "/token");
const profile = await this.callback(params, { const profile = await this.callback(params, {
redirect_uri: url.origin + url.pathname, redirect_uri,
state: state.state state: state.state
}); });
const { user, token } = await auth.resolve(state.action, this, profile.sub, profile); try {
console.log("******** RESOLVED ********", { user, token }); const data = await auth.resolve(state.action, this, profile.sub, profile);
console.log("******** RESOLVED ********", data);
if (state.redirect) { if (state.mode === "cookie") {
console.log("redirect to", state.redirect + "?token=" + token); return await auth.respond(c, data, state.redirect);
return c.redirect(state.redirect + "?token=" + token);
} }
return c.json({ user, token }); return c.json(data);
} catch (e) {
if (state.mode === "cookie") {
return await auth.respond(c, e, state.redirect);
}
throw e;
}
});
hono.get("/token", async (c) => {
const url = new URL(c.req.url);
const params = new URLSearchParams(url.search);
return c.json({
code: params.get("code") ?? null
});
});
hono.post("/:action", async (c) => {
const action = c.req.param("action") as AuthAction;
if (!["login", "register"].includes(action)) {
return c.notFound();
}
const url = new URL(c.req.url);
const path = url.pathname.replace(`/${action}`, "");
const redirect_uri = url.origin + path + "/callback";
const referer = new URL(c.req.header("Referer") ?? "/");
const state = oauth.generateRandomCodeVerifier();
const response = await this.request({
redirect_uri,
state
});
//console.log("_state", state);
await setState(c, { state, action, redirect: referer.toString(), mode: "cookie" });
console.log("--redirecting to", response.url);
return c.redirect(response.url);
}); });
hono.get("/:action", async (c) => { hono.get("/:action", async (c) => {
@@ -371,31 +428,29 @@ export class OAuthStrategy implements Strategy {
const url = new URL(c.req.url); const url = new URL(c.req.url);
const path = url.pathname.replace(`/${action}`, ""); const path = url.pathname.replace(`/${action}`, "");
const redirect_uri = url.origin + path + "/callback"; const redirect_uri = url.origin + path + "/token";
const q_redirect = (c.req.query("redirect") as string) ?? undefined;
const state = await oauth.generateRandomCodeVerifier(); const state = oauth.generateRandomCodeVerifier();
const response = await this.request({ const response = await this.request({
redirect_uri, redirect_uri,
state state
}); });
//console.log("_state", state);
await setState(c, { state, action, redirect: q_redirect }); if (isDebug()) {
if (c.req.header("Accept") === "application/json") {
return c.json({ return c.json({
url: response.url, url: response.url,
redirect_uri, redirect_uri,
challenge: state, challenge: state,
action,
params: response.params params: response.params
}); });
} }
//return c.text(response.url); return c.json({
console.log("--redirecting to", response.url); url: response.url,
challenge: state,
return c.redirect(response.url); action
});
}); });
return hono; return hono;

View File

@@ -1,5 +1,5 @@
import { Exception, Permission } from "core"; import { Exception, Permission } from "core";
import { type Static, Type, objectTransform } from "core/utils"; import { objectTransform } from "core/utils";
import { Role } from "./Role"; import { Role } from "./Role";
export type GuardUserContext = { export type GuardUserContext = {

View File

@@ -0,0 +1,40 @@
import type { Context } from "hono";
import { setCookie } from "hono/cookie";
const flash_key = "__bknd_flash";
export type FlashMessageType = "error" | "warning" | "success" | "info";
export async function addFlashMessage(
c: Context,
message: string,
type: FlashMessageType = "info"
) {
setCookie(c, flash_key, JSON.stringify({ type, message }), {
path: "/"
});
}
function getCookieValue(name) {
const cookies = document.cookie.split("; ");
for (const cookie of cookies) {
const [key, value] = cookie.split("=");
if (key === name) {
try {
return decodeURIComponent(value as any);
} catch (e) {
return null;
}
}
}
return null; // Return null if the cookie is not found
}
export function getFlashMessage(
clear = true
): { type: FlashMessageType; message: string } | undefined {
const flash = getCookieValue(flash_key);
if (flash && clear) {
document.cookie = `${flash_key}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`;
}
return flash ? JSON.parse(flash) : undefined;
}

View File

@@ -64,7 +64,7 @@ export abstract class ModuleApi<Options extends BaseModuleApiOptions> {
} }
let body: any = _init?.body; let body: any = _init?.body;
if (_init && "body" in _init && ["POST", "PATCH"].includes(method)) { if (_init && "body" in _init && ["POST", "PATCH", "PUT"].includes(method)) {
const requestContentType = (headers.get("Content-Type") as string) ?? undefined; const requestContentType = (headers.get("Content-Type") as string) ?? undefined;
if (!requestContentType || requestContentType.startsWith("application/json")) { if (!requestContentType || requestContentType.startsWith("application/json")) {
body = JSON.stringify(_init.body); body = JSON.stringify(_init.body);
@@ -137,6 +137,18 @@ export abstract class ModuleApi<Options extends BaseModuleApiOptions> {
}); });
} }
protected async put<Data = any>(
_input: string | (string | number | PrimaryFieldType)[],
body?: any,
_init?: RequestInit
) {
return this.request<Data>(_input, undefined, {
..._init,
body,
method: "PUT"
});
}
protected async delete<Data = any>( protected async delete<Data = any>(
_input: string | (string | number | PrimaryFieldType)[], _input: string | (string | number | PrimaryFieldType)[],
_init?: RequestInit _init?: RequestInit

View File

@@ -1,5 +1,5 @@
import { ModuleApi } from "./ModuleApi"; import { ModuleApi } from "./ModuleApi";
import type { ModuleConfigs, ModuleSchemas } from "./ModuleManager"; import type { ModuleConfigs, ModuleKey, ModuleSchemas } from "./ModuleManager";
export type ApiSchemaResponse = { export type ApiSchemaResponse = {
version: number; version: number;
@@ -21,4 +21,28 @@ export class SystemApi extends ModuleApi<any> {
secrets: options?.secrets ? 1 : 0 secrets: options?.secrets ? 1 : 0
}); });
} }
async setConfig<Module extends ModuleKey>(
module: Module,
value: ModuleConfigs[Module],
force?: boolean
) {
return await this.post<any>(["config", "set", module, `?force=${force ? 1 : 0}`], value);
}
async addConfig<Module extends ModuleKey>(module: Module, path: string, value: any) {
return await this.post<any>(["config", "add", module, path], value);
}
async patchConfig<Module extends ModuleKey>(module: Module, path: string, value: any) {
return await this.patch<any>(["config", "patch", module, path], value);
}
async overwriteConfig<Module extends ModuleKey>(module: Module, path: string, value: any) {
return await this.put<any>(["config", "overwrite", module, path], value);
}
async removeConfig<Module extends ModuleKey>(module: Module, path: string) {
return await this.delete<any>(["config", "remove", module, path]);
}
} }

View File

@@ -2,8 +2,9 @@
import type { App } from "App"; import type { App } from "App";
import { type ClassController, isDebug } from "core"; import { type ClassController, isDebug } from "core";
import { addFlashMessage } from "core/server/flash";
import { Hono } from "hono"; import { Hono } from "hono";
import { html, raw } from "hono/html"; import { html } from "hono/html";
import { Fragment } from "hono/jsx"; import { Fragment } from "hono/jsx";
import * as SystemPermissions from "modules/permissions"; import * as SystemPermissions from "modules/permissions";
import type { Manifest } from "vite"; import type { Manifest } from "vite";
@@ -21,6 +22,12 @@ export type AdminControllerOptions = {
viteManifest?: Manifest; viteManifest?: Manifest;
}; };
const authRoutes = {
root: "/",
login: "/auth/login",
logout: "/auth/logout"
};
export class AdminController implements ClassController { export class AdminController implements ClassController {
constructor( constructor(
private readonly app: App, private readonly app: App,
@@ -31,15 +38,67 @@ export class AdminController implements ClassController {
return this.app.modules.ctx(); return this.app.modules.ctx();
} }
getController(): Hono { getController(): Hono<any> {
const hono = new Hono(); const auth = this.app.module.auth;
const configs = this.app.modules.configs(); const configs = this.app.modules.configs();
const auth_enabled = configs.auth.enabled;
const basepath = (String(configs.server.admin.basepath) + "/").replace(/\/+$/, "/"); const basepath = (String(configs.server.admin.basepath) + "/").replace(/\/+$/, "/");
const hono = new Hono<{
Variables: {
html: string;
};
}>().basePath(basepath);
this.ctx.server.get(basepath + "*", async (c) => { hono.use("*", async (c, next) => {
if (this.options.html) { const obj = { user: auth.authenticator.getUser() };
return c.html(this.options.html); const html = await this.getHtml(obj);
if (!html) {
console.warn("Couldn't generate HTML for admin UI");
// re-casting to void as a return is not required
return c.notFound() as unknown as void;
} }
c.set("html", html);
await next();
});
hono.get(authRoutes.login, async (c) => {
if (
this.app.module.auth.authenticator.isUserLoggedIn() &&
this.ctx.guard.granted(SystemPermissions.admin)
) {
return c.redirect(authRoutes.root);
}
const html = c.get("html");
return c.html(html);
});
hono.get(authRoutes.logout, async (c) => {
await auth.authenticator.logout(c);
return c.redirect(authRoutes.login);
});
hono.get("*", async (c) => {
console.log("admin", c.req.url);
if (!this.ctx.guard.granted(SystemPermissions.admin)) {
await addFlashMessage(c, "You are not authorized to access the Admin UI", "error");
return c.redirect(authRoutes.login);
}
const html = c.get("html");
return c.html(html);
});
return hono;
}
private async getHtml(obj: any = {}) {
if (this.options.html) {
// @todo: add __BKND__ global
return this.options.html as string;
}
const configs = this.app.modules.configs();
// @todo: implement guard redirect once cookie sessions arrive // @todo: implement guard redirect once cookie sessions arrive
@@ -67,7 +126,9 @@ export class AdminController implements ClassController {
css = entry.css?.map((c: string) => "/" + c) ?? []; css = entry.css?.map((c: string) => "/" + c) ?? [];
} }
return c.html( return (
<Fragment>
{html`<!doctype html>`}
<html lang="en" class={configs.server.admin.color_scheme ?? "light"}> <html lang="en" class={configs.server.admin.color_scheme ?? "light"}>
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
@@ -85,20 +146,22 @@ export class AdminController implements ClassController {
</Fragment> </Fragment>
) : ( ) : (
<Fragment> <Fragment>
{/* biome-ignore lint/security/noDangerouslySetInnerHtml: I know what I do here :) */}
<script type="module" dangerouslySetInnerHTML={{ __html: viteInject }} /> <script type="module" dangerouslySetInnerHTML={{ __html: viteInject }} />
<script type="module" src="/@vite/client" /> <script type="module" src={"/@vite/client"} />
</Fragment> </Fragment>
)} )}
</head> </head>
<body> <body>
<div id="app" /> <div id="app" />
<script
dangerouslySetInnerHTML={{
__html: `window.__BKND__ = JSON.parse('${JSON.stringify(obj)}');`
}}
/>
{!isProd && <script type="module" src="/src/ui/main.tsx" />} {!isProd && <script type="module" src="/src/ui/main.tsx" />}
</body> </body>
</html> </html>
</Fragment>
); );
});
return hono;
} }
} }

View File

@@ -43,6 +43,7 @@ export class SystemController implements ClassController {
const { secrets } = c.req.valid("query"); const { secrets } = c.req.valid("query");
const { module } = c.req.valid("param"); const { module } = c.req.valid("param");
this.ctx.guard.throwUnlessGranted(SystemPermissions.configRead);
secrets && this.ctx.guard.throwUnlessGranted(SystemPermissions.configReadSecrets); secrets && this.ctx.guard.throwUnlessGranted(SystemPermissions.configReadSecrets);
const config = this.app.toJSON(secrets); const config = this.app.toJSON(secrets);

View File

@@ -1,15 +1,19 @@
import { MantineProvider } from "@mantine/core"; import { MantineProvider } from "@mantine/core";
import { Notifications } from "@mantine/notifications"; import { Notifications } from "@mantine/notifications";
import React from "react"; import React from "react";
import { FlashMessage } from "ui/modules/server/FlashMessage";
import { BkndProvider, ClientProvider, useBknd } from "./client"; import { BkndProvider, ClientProvider, useBknd } from "./client";
import { createMantineTheme } from "./lib/mantine/theme"; import { createMantineTheme } from "./lib/mantine/theme";
import { BkndModalsProvider } from "./modals"; import { BkndModalsProvider } from "./modals";
import { Routes } from "./routes"; import { Routes } from "./routes";
export default function Admin({ export type BkndAdminProps = {
baseUrl: baseUrlOverride, baseUrl?: string;
withProvider = false withProvider?: boolean;
}: { baseUrl?: string; withProvider?: boolean }) { // @todo: add admin config override
};
export default function Admin({ baseUrl: baseUrlOverride, withProvider = false }: BkndAdminProps) {
const Component = ( const Component = (
<BkndProvider> <BkndProvider>
<AdminInternal /> <AdminInternal />
@@ -25,9 +29,11 @@ export default function Admin({
function AdminInternal() { function AdminInternal() {
const b = useBknd(); const b = useBknd();
const theme = b.app.getAdminConfig().color_scheme; const theme = b.app.getAdminConfig().color_scheme;
return ( return (
<MantineProvider {...createMantineTheme(theme ?? "light")}> <MantineProvider {...createMantineTheme(theme ?? "light")}>
<Notifications /> <Notifications />
<FlashMessage />
<BkndModalsProvider> <BkndModalsProvider>
<Routes /> <Routes />
</BkndModalsProvider> </BkndModalsProvider>

View File

@@ -40,7 +40,7 @@ export function BkndProvider({
if (!res.ok) { if (!res.ok) {
if (errorShown.current) return; if (errorShown.current) return;
errorShown.current = true; errorShown.current = true;
notifications.show({ /*notifications.show({
title: "Failed to fetch schema", title: "Failed to fetch schema",
// @ts-ignore // @ts-ignore
message: body.error, message: body.error,
@@ -48,7 +48,7 @@ export function BkndProvider({
position: "top-right", position: "top-right",
autoClose: false, autoClose: false,
withCloseButton: true withCloseButton: true
}); });*/
} }
const schema = res.ok const schema = res.ok

View File

@@ -1,5 +1,6 @@
import { type NotificationData, notifications } from "@mantine/notifications"; import { type NotificationData, notifications } from "@mantine/notifications";
import type { ModuleConfigs } from "../../../modules"; import { ucFirst } from "core/utils";
import type { ApiResponse, ModuleConfigs } from "../../../modules";
import type { AppQueryClient } from "../utils/AppQueryClient"; import type { AppQueryClient } from "../utils/AppQueryClient";
export type SchemaActionsProps = { export type SchemaActionsProps = {
@@ -10,25 +11,53 @@ export type SchemaActionsProps = {
export type TSchemaActions = ReturnType<typeof getSchemaActions>; export type TSchemaActions = ReturnType<typeof getSchemaActions>;
export function getSchemaActions({ client, setSchema }: SchemaActionsProps) { export function getSchemaActions({ client, setSchema }: SchemaActionsProps) {
const baseUrl = client.baseUrl; const api = client.api;
const token = client.auth().state()?.token;
async function displayError(action: string, module: string, res: Response, path?: string) { async function handleConfigUpdate(
const notification_data: NotificationData = { action: string,
id: "schema-error-" + [action, module, path].join("-"), module: string,
title: `Config update failed${path ? ": " + path : ""}`, res: ApiResponse,
message: "Failed to complete config update", path?: string
color: "red", ): Promise<boolean> {
const base: Partial<NotificationData> = {
id: "schema-" + [action, module, path].join("-"),
position: "top-right", position: "top-right",
withCloseButton: true, autoClose: 3000
autoClose: false
}; };
try {
const { error } = (await res.json()) as any; if (res.res.ok && res.body.success) {
notifications.show({ ...notification_data, message: error }); console.log("update config", action, module, path, res.body);
} catch (e) { if (res.body.success) {
notifications.show(notification_data); setSchema((prev) => {
if (!prev) return prev;
return {
...prev,
config: {
...prev.config,
[module]: res.body.config
} }
};
});
}
notifications.show({
...base,
title: `Config updated: ${ucFirst(module)}`,
color: "blue",
message: `Operation ${action.toUpperCase()} at ${module}${path ? "." + path : ""}`
});
return true;
}
notifications.show({
...base,
title: `Config Update failed: ${ucFirst(module)}${path ? "." + path : ""}`,
color: "red",
withCloseButton: true,
autoClose: false,
message: res.body.error ?? "Failed to complete config update"
});
return false;
} }
return { return {
@@ -37,183 +66,39 @@ export function getSchemaActions({ client, setSchema }: SchemaActionsProps) {
value: ModuleConfigs[Module], value: ModuleConfigs[Module],
force?: boolean force?: boolean
) => { ) => {
const res = await fetch( const res = await api.system.setConfig(module, value, force);
`${baseUrl}/api/system/config/set/${module}?force=${force ? 1 : 0}`, return await handleConfigUpdate("set", module, res);
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`
},
body: JSON.stringify(value)
}
);
if (res.ok) {
const data = (await res.json()) as any;
console.log("update config set", module, data);
if (data.success) {
setSchema((prev) => {
if (!prev) return prev;
return {
...prev,
config: {
...prev.config,
[module]: data.config
}
};
});
}
return data.success;
} else {
await displayError("set", module, res);
}
return false;
}, },
patch: async <Module extends keyof ModuleConfigs>( patch: async <Module extends keyof ModuleConfigs>(
module: keyof ModuleConfigs, module: keyof ModuleConfigs,
path: string, path: string,
value: any value: any
): Promise<boolean> => { ): Promise<boolean> => {
const res = await fetch(`${baseUrl}/api/system/config/patch/${module}/${path}`, { const res = await api.system.patchConfig(module, path, value);
method: "PATCH", return await handleConfigUpdate("patch", module, res, path);
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`
},
body: JSON.stringify(value)
});
if (res.ok) {
const data = (await res.json()) as any;
console.log("update config patch", module, path, data);
if (data.success) {
setSchema((prev) => {
if (!prev) return prev;
return {
...prev,
config: {
...prev.config,
[module]: data.config
}
};
});
}
return data.success;
} else {
await displayError("patch", module, res, path);
}
return false;
}, },
overwrite: async <Module extends keyof ModuleConfigs>( overwrite: async <Module extends keyof ModuleConfigs>(
module: keyof ModuleConfigs, module: keyof ModuleConfigs,
path: string, path: string,
value: any value: any
) => { ) => {
const res = await fetch(`${baseUrl}/api/system/config/overwrite/${module}/${path}`, { const res = await api.system.overwriteConfig(module, path, value);
method: "PUT", return await handleConfigUpdate("overwrite", module, res, path);
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`
},
body: JSON.stringify(value)
});
if (res.ok) {
const data = (await res.json()) as any;
console.log("update config overwrite", module, path, data);
if (data.success) {
setSchema((prev) => {
if (!prev) return prev;
return {
...prev,
config: {
...prev.config,
[module]: data.config
}
};
});
}
return data.success;
} else {
await displayError("overwrite", module, res, path);
}
return false;
}, },
add: async <Module extends keyof ModuleConfigs>( add: async <Module extends keyof ModuleConfigs>(
module: keyof ModuleConfigs, module: keyof ModuleConfigs,
path: string, path: string,
value: any value: any
) => { ) => {
const res = await fetch(`${baseUrl}/api/system/config/add/${module}/${path}`, { const res = await api.system.addConfig(module, path, value);
method: "POST", return await handleConfigUpdate("add", module, res, path);
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`
},
body: JSON.stringify(value)
});
if (res.ok) {
const data = (await res.json()) as any;
console.log("update config add", module, data);
if (data.success) {
setSchema((prev) => {
if (!prev) return prev;
return {
...prev,
config: {
...prev.config,
[module]: data.config
}
};
});
}
return data.success;
} else {
await displayError("add", module, res, path);
}
return false;
}, },
remove: async <Module extends keyof ModuleConfigs>( remove: async <Module extends keyof ModuleConfigs>(
module: keyof ModuleConfigs, module: keyof ModuleConfigs,
path: string path: string
) => { ) => {
const res = await fetch(`${baseUrl}/api/system/config/remove/${module}/${path}`, { const res = await api.system.removeConfig(module, path);
method: "DELETE", return await handleConfigUpdate("remove", module, res, path);
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`
}
});
if (res.ok) {
const data = (await res.json()) as any;
console.log("update config remove", module, data);
if (data.success) {
setSchema((prev) => {
if (!prev) return prev;
return {
...prev,
config: {
...prev.config,
[module]: data.config
}
};
});
}
return data.success;
} else {
await displayError("remove", module, res, path);
}
return false;
} }
}; };
} }

View File

@@ -89,14 +89,13 @@ export const useAuthStrategies = (options?: { baseUrl?: string }): Partial<AuthS
const [data, setData] = useState<AuthStrategyData>(); const [data, setData] = useState<AuthStrategyData>();
const ctxBaseUrl = useBaseUrl(); const ctxBaseUrl = useBaseUrl();
const api = new Api({ const api = new Api({
host: options?.baseUrl ? options?.baseUrl : ctxBaseUrl, host: options?.baseUrl ? options?.baseUrl : ctxBaseUrl
tokenStorage: "localStorage"
}); });
useEffect(() => { useEffect(() => {
(async () => { (async () => {
const res = await api.auth.strategies(); const res = await api.auth.strategies();
console.log("res", res); //console.log("res", res);
if (res.res.ok) { if (res.res.ok) {
setData(res.body); setData(res.body);
} }

View File

@@ -19,6 +19,7 @@ export function useBkndAuth() {
if (window.confirm(`Are you sure you want to delete the role "${name}"?`)) { if (window.confirm(`Are you sure you want to delete the role "${name}"?`)) {
return await bkndActions.remove("auth", `roles.${name}`); return await bkndActions.remove("auth", `roles.${name}`);
} }
return false;
} }
} }
}; };

View File

@@ -15,8 +15,7 @@ export class AppQueryClient {
api: Api; api: Api;
constructor(public baseUrl: string) { constructor(public baseUrl: string) {
this.api = new Api({ this.api = new Api({
host: baseUrl, host: baseUrl
tokenStorage: "localStorage"
}); });
} }

View File

@@ -14,22 +14,30 @@ const Base: React.FC<AlertProps> = ({ visible = true, title, message, className,
{...props} {...props}
className={twMerge("flex flex-row dark:bg-amber-300/20 bg-amber-200 p-4", className)} className={twMerge("flex flex-row dark:bg-amber-300/20 bg-amber-200 p-4", className)}
> >
<div>
{title && <b className="mr-2">{title}:</b>} {title && <b className="mr-2">{title}:</b>}
{message} {message}
</div> </div>
</div>
) : null; ) : null;
const Warning: React.FC<AlertProps> = (props) => ( const Warning: React.FC<AlertProps> = ({ className, ...props }) => (
<Base {...props} className="dark:bg-amber-300/20 bg-amber-200" /> <Base {...props} className={twMerge("dark:bg-amber-300/20 bg-amber-200", className)} />
); );
const Exception: React.FC<AlertProps> = (props) => ( const Exception: React.FC<AlertProps> = ({ className, ...props }) => (
<Base {...props} className="dark:bg-red-950 bg-red-100" /> <Base {...props} className={twMerge("dark:bg-red-950 bg-red-100", className)} />
);
const Success: React.FC<AlertProps> = ({ className, ...props }) => (
<Base {...props} className={twMerge("dark:bg-green-950 bg-green-100", className)} />
);
const Info: React.FC<AlertProps> = ({ className, ...props }) => (
<Base {...props} className={twMerge("dark:bg-blue-950 bg-blue-100", className)} />
); );
export const Alert = { export const Alert = {
Warning, Warning,
Exception Exception,
Success,
Info
}; };

View File

@@ -86,7 +86,7 @@ export function DataTable<Data extends Record<string, any> = Record<string, any>
</div> </div>
)} )}
<div className="border-muted border rounded-md shadow-sm w-full max-w-full overflow-x-scroll overflow-y-hidden"> <div className="border-muted border rounded-md shadow-sm w-full max-w-full overflow-x-scroll overflow-y-hidden">
<table className="w-full"> <table className="w-full text-md">
{select.length > 0 ? ( {select.length > 0 ? (
<thead className="sticky top-0 bg-muted/10"> <thead className="sticky top-0 bg-muted/10">
<tr> <tr>

View File

@@ -150,7 +150,8 @@ function UserMenu() {
async function handleLogout() { async function handleLogout() {
await auth.logout(); await auth.logout();
navigate("/auth/login", { replace: true }); // @todo: grab from somewhere constant
window.location.href = "/auth/logout";
} }
async function handleLogin() { async function handleLogin() {

View File

@@ -1,117 +1,55 @@
import { type FieldApi, useForm } from "@tanstack/react-form"; import { typeboxResolver } from "@hookform/resolvers/typebox";
import { Type, type TypeInvalidError, parse } from "core/utils"; import { Type } from "core/utils";
import type { ComponentPropsWithoutRef } from "react";
import { Button } from "ui/components/buttons/Button"; import { useForm } from "react-hook-form";
import { twMerge } from "tailwind-merge";
import { Button } from "ui";
import * as Formy from "ui/components/form/Formy"; import * as Formy from "ui/components/form/Formy";
type LoginFormProps = { export type LoginFormProps = Omit<ComponentPropsWithoutRef<"form">, "onSubmit"> & {
onSubmitted?: (value: { email: string; password: string }) => Promise<void>; className?: string;
formData?: any;
}; };
export function LoginForm({ onSubmitted }: LoginFormProps) { const schema = Type.Object({
const form = useForm({ email: Type.String({
defaultValues: { pattern: "^[\\w-\\.]+@([\\w-]+\\.)+[\\w-]{2,4}$"
email: "", }),
password: "" password: Type.String({
}, minLength: 8 // @todo: this should be configurable
onSubmit: async ({ value }) => { })
onSubmitted?.(value);
},
defaultState: {
canSubmit: false,
isValid: false
},
validatorAdapter: () => {
function validate(
{ value, fieldApi }: { value: any; fieldApi: FieldApi<any, any> },
fn: any
): any {
if (fieldApi.form.state.submissionAttempts === 0) return;
try {
parse(fn, value);
} catch (e) {
return (e as TypeInvalidError).errors
.map((error) => error.schema.error ?? error.message)
.join(", ");
}
}
return { validate, validateAsync: validate };
}
}); });
function handleSubmit(e: React.FormEvent<HTMLFormElement>) { export function LoginForm({ formData, className, method = "POST", ...props }: LoginFormProps) {
e.preventDefault(); const {
e.stopPropagation(); register,
void form.handleSubmit(); formState: { isValid, errors }
} } = useForm({
mode: "onChange",
defaultValues: formData,
resolver: typeboxResolver(schema)
});
return ( return (
<form onSubmit={handleSubmit} className="flex flex-col gap-3 w-full" noValidate> <form {...props} method={method} className={twMerge("flex flex-col gap-3 w-full", className)}>
<form.Field <Formy.Group>
name="email" <Formy.Label htmlFor="email">Email address</Formy.Label>
validators={{ <Formy.Input type="email" {...register("email")} />
onChange: Type.String({
pattern: "^[\\w-\\.]+@([\\w-]+\\.)+[\\w-]{2,4}$"
})
}}
children={(field) => (
<Formy.Group error={field.state.meta.errors.length > 0}>
<Formy.Label htmlFor={field.name}>Email address</Formy.Label>
<Formy.Input
type="email"
id={field.name}
name={field.name}
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
/>
</Formy.Group> </Formy.Group>
)} <Formy.Group>
/> <Formy.Label htmlFor="password">Password</Formy.Label>
<form.Field <Formy.Input type="password" {...register("password")} />
name="password"
validators={{
onChange: Type.String({
minLength: 8
})
}}
children={(field) => (
<Formy.Group error={field.state.meta.errors.length > 0}>
<Formy.Label htmlFor={field.name}>Password</Formy.Label>
<Formy.Input
type="password"
id={field.name}
name={field.name}
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
/>
</Formy.Group> </Formy.Group>
)}
/>
<form.Subscribe
selector={(state) => {
//console.log("state", state, Object.values(state.fieldMeta));
const fieldMeta = Object.values(state.fieldMeta).map((f) => f.isDirty);
const allDirty = fieldMeta.length > 0 ? fieldMeta.reduce((a, b) => a && b) : false;
return [allDirty, state.isSubmitting];
}}
children={([allDirty, isSubmitting]) => {
return (
<Button <Button
type="submit" type="submit"
variant="primary" variant="primary"
size="large" size="large"
className="w-full mt-2 justify-center" className="w-full mt-2 justify-center"
disabled={!allDirty || isSubmitting} disabled={!isValid}
> >
Sign in Sign in
</Button> </Button>
);
}}
/>
</form> </form>
); );
} }

View File

@@ -0,0 +1,39 @@
import { getFlashMessage } from "core/server/flash";
import { useEffect, useState } from "react";
import { Alert } from "ui/components/display/Alert";
/**
* Handles flash message from server
* @constructor
*/
export function FlashMessage() {
const [flash, setFlash] = useState<any>();
useEffect(() => {
if (!flash) {
const content = getFlashMessage();
if (content) {
setFlash(content);
}
}
}, []);
if (flash) {
let Component = Alert.Info;
switch (flash.type) {
case "error":
Component = Alert.Exception;
break;
case "success":
Component = Alert.Success;
break;
case "warning":
Component = Alert.Warning;
break;
}
return <Component message={flash.message} className="justify-center" />;
}
return null;
}

View File

@@ -1,58 +1,17 @@
import type { AppAuthOAuthStrategy } from "auth/auth-schema"; import type { AppAuthOAuthStrategy } from "auth/auth-schema";
import { Type, ucFirst, ucFirstAllSnakeToPascalWithSpaces } from "core/utils"; import { ucFirstAllSnakeToPascalWithSpaces } from "core/utils";
import { transform } from "lodash-es"; import { transform } from "lodash-es";
import { useEffect, useState } from "react";
import { useAuth } from "ui/client";
import { useAuthStrategies } from "ui/client/schema/auth/use-auth"; import { useAuthStrategies } from "ui/client/schema/auth/use-auth";
import { Button } from "ui/components/buttons/Button"; import { Button } from "ui/components/buttons/Button";
import { Logo } from "ui/components/display/Logo"; import { Logo } from "ui/components/display/Logo";
import { Link } from "ui/components/wouter/Link"; import { Link } from "ui/components/wouter/Link";
import { useBrowserTitle } from "ui/hooks/use-browser-title"; import { useBrowserTitle } from "ui/hooks/use-browser-title";
import { useSearch } from "ui/hooks/use-search";
import { LoginForm } from "ui/modules/auth/LoginForm"; import { LoginForm } from "ui/modules/auth/LoginForm";
import { useLocation } from "wouter";
import * as AppShell from "../../layouts/AppShell/AppShell"; import * as AppShell from "../../layouts/AppShell/AppShell";
const schema = Type.Object({
token: Type.String()
});
export function AuthLogin() { export function AuthLogin() {
useBrowserTitle(["Login"]); useBrowserTitle(["Login"]);
const [, navigate] = useLocation();
const search = useSearch(schema);
const token = search.value.token;
//console.log("search", token, "/api/auth/google?redirect=" + window.location.href);
const auth = useAuth();
const { strategies, basepath, loading } = useAuthStrategies(); const { strategies, basepath, loading } = useAuthStrategies();
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (token) {
auth.setToken(token);
}
}, [token]);
async function handleSubmit(value: { email: string; password: string }) {
console.log("submit", value);
const { res, data } = await auth.login(value);
if (!res.ok) {
if (data && "error" in data) {
setError(data.error.message);
} else {
setError("An error occurred");
}
} else if (error) {
setError(null);
}
console.log("res:login", { res, data });
}
if (auth.user) {
console.log("user set", auth.user);
navigate("/", { replace: true });
}
const oauth = transform( const oauth = transform(
strategies ?? {}, strategies ?? {},
@@ -63,7 +22,7 @@ export function AuthLogin() {
}, },
{} {}
) as Record<string, AppAuthOAuthStrategy>; ) as Record<string, AppAuthOAuthStrategy>;
console.log("oauth", oauth, strategies); //console.log("oauth", oauth, strategies);
return ( return (
<AppShell.Root> <AppShell.Root>
@@ -77,26 +36,26 @@ export function AuthLogin() {
<h1 className="text-xl font-bold">Sign in to your admin panel</h1> <h1 className="text-xl font-bold">Sign in to your admin panel</h1>
<p className="text-primary/50">Enter your credentials below to get access.</p> <p className="text-primary/50">Enter your credentials below to get access.</p>
</div> </div>
{error && (
<div className="bg-red-500/40 p-3 w-full rounded font-bold mb-1">
<span>{error}</span>
</div>
)}
<div className="flex flex-col gap-4 w-full"> <div className="flex flex-col gap-4 w-full">
{Object.keys(oauth).length > 0 && ( {Object.keys(oauth).length > 0 && (
<> <>
{Object.entries(oauth)?.map(([name, oauth], key) => ( {Object.entries(oauth)?.map(([name, oauth], key) => (
<form
method="POST"
action={`${basepath}/${name}/login`}
key={key}
className="w-full"
>
<Button <Button
key={key} key={key}
type="submit"
size="large" size="large"
variant="outline" variant="outline"
className="justify-center" className="justify-center w-full"
onClick={() => {
window.location.href = `${basepath}/${name}/login?redirect=${window.location.href}`;
}}
> >
Continue with {ucFirstAllSnakeToPascalWithSpaces(oauth.name)} Continue with {ucFirstAllSnakeToPascalWithSpaces(oauth.name)}
</Button> </Button>
</form>
))} ))}
<div className="w-full flex flex-row items-center"> <div className="w-full flex flex-row items-center">
@@ -111,7 +70,8 @@ export function AuthLogin() {
</> </>
)} )}
<LoginForm onSubmitted={handleSubmit} /> <LoginForm action="/api/auth/password/login" />
{/*<a href="/auth/logout">Logout</a>*/}
</div> </div>
</div> </div>
)} )}

View File

@@ -29,13 +29,13 @@ function AuthRolesEditInternal({ params }) {
const data = formRef.current?.getData(); const data = formRef.current?.getData();
const success = await actions.roles.patch(roleName, data); const success = await actions.roles.patch(roleName, data);
notifications.show({ /*notifications.show({
id: `role-${roleName}-update`, id: `role-${roleName}-update`,
position: "top-right", position: "top-right",
title: success ? "Update success" : "Update failed", title: success ? "Update success" : "Update failed",
message: success ? "Role updated successfully" : "Failed to update role", message: success ? "Role updated successfully" : "Failed to update role",
color: !success ? "red" : undefined color: !success ? "red" : undefined
}); });*/
} }
async function handleDelete() { async function handleDelete() {

View File

@@ -90,14 +90,18 @@ const renderValue = ({ value, property }) => {
} }
if (property === "permissions") { if (property === "permissions") {
return [...(value || [])].map((p, i) => ( return (
<div className="flex flex-row gap-1">
{[...(value || [])].map((p, i) => (
<span <span
key={i} key={i}
className="inline-block px-2 py-1.5 text-sm bg-primary/5 rounded font-mono leading-none" className="inline-block px-2 py-1.5 text-sm bg-primary/5 rounded font-mono leading-none"
> >
{p} {p}
</span> </span>
)); ))}
</div>
);
} }
return <CellValue value={value} property={property} />; return <CellValue value={value} property={property} />;

View File

@@ -6,10 +6,9 @@ import {
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { isDebug } from "core"; import { isDebug } from "core";
import type { Entity } from "data"; import type { Entity } from "data";
import { cloneDeep, omit } from "lodash-es"; import { cloneDeep } from "lodash-es";
import { forwardRef, useImperativeHandle, useRef, useState } from "react"; import { useRef, useState } from "react";
import { TbArrowLeft, TbDots } from "react-icons/tb"; import { TbDots } from "react-icons/tb";
import { useBknd } from "ui/client";
import { useBkndData } from "ui/client/schema/data/use-bknd-data"; import { useBkndData } from "ui/client/schema/data/use-bknd-data";
import { Button } from "ui/components/buttons/Button"; import { Button } from "ui/components/buttons/Button";
import { IconButton } from "ui/components/buttons/IconButton"; import { IconButton } from "ui/components/buttons/IconButton";
@@ -20,9 +19,8 @@ import {
} from "ui/components/form/json-schema/JsonSchemaForm"; } from "ui/components/form/json-schema/JsonSchemaForm";
import { Dropdown } from "ui/components/overlay/Dropdown"; import { Dropdown } from "ui/components/overlay/Dropdown";
import * as AppShell from "ui/layouts/AppShell/AppShell"; import * as AppShell from "ui/layouts/AppShell/AppShell";
import { SectionHeaderAccordionItem } from "ui/layouts/AppShell/AppShell";
import { Breadcrumbs2 } from "ui/layouts/AppShell/Breadcrumbs2"; import { Breadcrumbs2 } from "ui/layouts/AppShell/Breadcrumbs2";
import { routes, useGoBack, useNavigate } from "ui/lib/routes"; import { routes, useNavigate } from "ui/lib/routes";
import { extractSchema } from "../settings/utils/schema"; import { extractSchema } from "../settings/utils/schema";
import { EntityFieldsForm, type EntityFieldsFormRef } from "./forms/entity.fields.form"; import { EntityFieldsForm, type EntityFieldsFormRef } from "./forms/entity.fields.form";

View File

@@ -1,6 +1,6 @@
import { Suspense, lazy } from "react"; import { Suspense, lazy } from "react";
import { useBknd } from "ui/client";
import { Route, Router, Switch } from "wouter"; import { Route, Router, Switch } from "wouter";
import { useBknd } from "../client/BkndProvider";
import { AuthLogin } from "./auth/auth.login"; import { AuthLogin } from "./auth/auth.login";
import { Root, RootEmpty } from "./root"; import { Root, RootEmpty } from "./root";

View File

@@ -47,6 +47,10 @@ html.fixed, html.fixed body {
@mixin dark { @mixin dark {
--mantine-color-body: rgb(9 9 11); --mantine-color-body: rgb(9 9 11);
} }
table {
font-size: inherit;
}
} }
html, body { html, body {

View File

@@ -59,6 +59,9 @@
"noImplicitAnyLet": "warn", "noImplicitAnyLet": "warn",
"noConfusingVoidType": "off" "noConfusingVoidType": "off"
}, },
"security": {
"noDangerouslySetInnerHtml": "off"
},
"style": { "style": {
"noNonNullAssertion": "off", "noNonNullAssertion": "off",
"noInferrableTypes": "off", "noInferrableTypes": "off",