mirror of
https://github.com/shishantbiswas/bknd.git
synced 2026-03-16 04:27:21 +00:00
reworked admin auth to use form and cookie + adjusted oauth to support API and cookie-based auth
This commit is contained in:
@@ -4,13 +4,19 @@ import { decodeJwt } from "jose";
|
||||
import { MediaApi } from "media/api/MediaApi";
|
||||
import { SystemApi } from "modules/SystemApi";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__BKND__: {
|
||||
user?: any;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export type ApiOptions = {
|
||||
host: string;
|
||||
token?: string;
|
||||
tokenStorage?: "localStorage";
|
||||
localStorage?: {
|
||||
key?: string;
|
||||
};
|
||||
storage?: "localStorage" | "manual";
|
||||
key?: string;
|
||||
};
|
||||
|
||||
export class Api {
|
||||
@@ -33,16 +39,25 @@ export class Api {
|
||||
this.buildApis();
|
||||
}
|
||||
|
||||
private extractToken() {
|
||||
if (this.options.tokenStorage === "localStorage") {
|
||||
const key = this.options.localStorage?.key ?? "auth";
|
||||
const raw = localStorage.getItem(key);
|
||||
get tokenStorage() {
|
||||
return this.options.storage ?? "manual";
|
||||
}
|
||||
get tokenKey() {
|
||||
return this.options.key ?? "auth";
|
||||
}
|
||||
|
||||
if (raw) {
|
||||
const { token } = JSON.parse(raw);
|
||||
private extractToken() {
|
||||
if (this.tokenStorage === "localStorage") {
|
||||
const token = localStorage.getItem(this.tokenKey);
|
||||
if (token) {
|
||||
this.token = token;
|
||||
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.user = token ? (decodeJwt(token) as any) : undefined;
|
||||
|
||||
if (this.options.tokenStorage === "localStorage") {
|
||||
const key = this.options.localStorage?.key ?? "auth";
|
||||
if (this.tokenStorage === "localStorage") {
|
||||
const key = this.tokenKey;
|
||||
|
||||
if (token) {
|
||||
localStorage.setItem(key, JSON.stringify({ token }));
|
||||
localStorage.setItem(key, token);
|
||||
} else {
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
@@ -69,8 +84,6 @@ export class Api {
|
||||
}
|
||||
|
||||
getAuthState() {
|
||||
if (!this.token) return;
|
||||
|
||||
return {
|
||||
token: this.token,
|
||||
user: this.user,
|
||||
|
||||
@@ -20,6 +20,7 @@ type AuthSchema = Static<typeof authConfigSchema>;
|
||||
export class AppAuth extends Module<typeof authConfigSchema> {
|
||||
private _authenticator?: Authenticator;
|
||||
cache: Record<string, any> = {};
|
||||
_controller!: AuthController;
|
||||
|
||||
override async onBeforeUpdate(from: AuthSchema, to: AuthSchema) {
|
||||
const defaultSecret = authConfigSchema.properties.jwt.properties.secret.default;
|
||||
@@ -68,9 +69,17 @@ export class AppAuth extends Module<typeof authConfigSchema> {
|
||||
this.registerEntities();
|
||||
super.setBuilt();
|
||||
|
||||
const controller = new AuthController(this);
|
||||
this._controller = new AuthController(this);
|
||||
//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() {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { AppAuth } from "auth";
|
||||
import type { ClassController } from "core";
|
||||
import { Hono, type MiddlewareHandler } from "hono";
|
||||
import * as SystemPermissions from "modules/permissions";
|
||||
|
||||
export class AuthController implements ClassController {
|
||||
constructor(private auth: AppAuth) {}
|
||||
@@ -11,19 +10,8 @@ export class AuthController implements ClassController {
|
||||
}
|
||||
|
||||
getMiddleware: MiddlewareHandler = async (c, next) => {
|
||||
let token: string | undefined;
|
||||
if (c.req.raw.headers.has("Authorization")) {
|
||||
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();
|
||||
}
|
||||
const user = await this.auth.authenticator.resolveAuthFromRequest(c);
|
||||
this.auth.ctx.guard.setUserContext(user);
|
||||
|
||||
await next();
|
||||
};
|
||||
@@ -31,7 +19,6 @@ export class AuthController implements ClassController {
|
||||
getController(): Hono<any> {
|
||||
const hono = new Hono();
|
||||
const strategies = this.auth.authenticator.getStrategies();
|
||||
//console.log("strategies", strategies);
|
||||
|
||||
for (const [name, strategy] of Object.entries(strategies)) {
|
||||
//console.log("registering", name, "at", `/${name}`);
|
||||
@@ -46,6 +33,11 @@ export class AuthController implements ClassController {
|
||||
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) => {
|
||||
const { strategies, basepath } = this.auth.toJSON(false);
|
||||
return c.json({ strategies, basepath });
|
||||
|
||||
@@ -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 { Hono } from "hono";
|
||||
import type { Context, Hono } from "hono";
|
||||
import { deleteCookie, getSignedCookie, setSignedCookie } from "hono/cookie";
|
||||
import { type JWTVerifyOptions, SignJWT, jwtVerify } from "jose";
|
||||
|
||||
type Input = any; // workaround
|
||||
@@ -82,7 +85,7 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
|
||||
strategy: Strategy,
|
||||
identifier: string,
|
||||
profile: ProfileExchange
|
||||
) {
|
||||
): Promise<AuthResponse> {
|
||||
//console.log("resolve", { action, strategy: strategy.getName(), 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;
|
||||
}
|
||||
|
||||
// @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) {
|
||||
return {
|
||||
...this.config,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Authenticator, Strategy } from "auth";
|
||||
import { type Static, StringEnum, Type, parse } 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 RegisterSchema = { email: string; password: string; [key: string]: any };
|
||||
@@ -54,22 +54,34 @@ export class PasswordStrategy implements Strategy {
|
||||
getController(authenticator: Authenticator): Hono<any> {
|
||||
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
|
||||
.post("/login", async (c) => {
|
||||
const body = (await c.req.json()) ?? {};
|
||||
const body = await getBody(c);
|
||||
|
||||
const payload = await this.login(body);
|
||||
const data = await authenticator.resolve("login", this, payload.password, payload);
|
||||
try {
|
||||
const payload = await this.login(body);
|
||||
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) => {
|
||||
const body = (await c.req.json()) ?? {};
|
||||
const body = await getBody(c);
|
||||
|
||||
const payload = await this.register(body);
|
||||
const data = await authenticator.resolve("register", this, payload.password, payload);
|
||||
|
||||
return c.json(data);
|
||||
return await authenticator.respond(c, data);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 Context, Hono } from "hono";
|
||||
import { getSignedCookie, setSignedCookie } from "hono/cookie";
|
||||
@@ -173,7 +173,7 @@ export class OAuthStrategy implements Strategy {
|
||||
const config = await this.getConfig();
|
||||
const { client, as, type } = config;
|
||||
//console.log("config", config);
|
||||
//console.log("callbackParams", callbackParams, options);
|
||||
console.log("callbackParams", callbackParams, options);
|
||||
const parameters = oauth.validateAuthResponse(
|
||||
as,
|
||||
client, // no client_secret required
|
||||
@@ -216,7 +216,7 @@ export class OAuthStrategy implements Strategy {
|
||||
expectedNonce
|
||||
);
|
||||
if (oauth.isOAuth2Error(result)) {
|
||||
//console.log("callback.error", result);
|
||||
console.log("callback.error", result);
|
||||
// @todo: Handle OAuth 2.0 response body error
|
||||
throw new OAuthCallbackException(result, "processAuthorizationCodeOpenIDResponse");
|
||||
}
|
||||
@@ -317,10 +317,15 @@ export class OAuthStrategy implements Strategy {
|
||||
const secret = "secret";
|
||||
const cookie_name = "_challenge";
|
||||
|
||||
const setState = async (
|
||||
c: Context,
|
||||
config: { state: string; action: AuthAction; redirect?: string }
|
||||
): Promise<void> => {
|
||||
type TState = {
|
||||
state: string;
|
||||
action: AuthAction;
|
||||
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, {
|
||||
secure: true,
|
||||
httpOnly: true,
|
||||
@@ -329,12 +334,18 @@ export class OAuthStrategy implements Strategy {
|
||||
});
|
||||
};
|
||||
|
||||
const getState = async (
|
||||
c: Context
|
||||
): Promise<{ state: string; action: AuthAction; redirect?: string }> => {
|
||||
const state = await getSignedCookie(c, secret, cookie_name);
|
||||
const getState = async (c: Context): Promise<TState> => {
|
||||
if (c.req.header("X-State-Challenge")) {
|
||||
return {
|
||||
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 {
|
||||
return JSON.parse(state as string);
|
||||
return JSON.parse(value as string);
|
||||
} catch (e) {
|
||||
throw new Error("Invalid state");
|
||||
}
|
||||
@@ -345,22 +356,68 @@ export class OAuthStrategy implements Strategy {
|
||||
const params = new URLSearchParams(url.search);
|
||||
|
||||
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, {
|
||||
redirect_uri: url.origin + url.pathname,
|
||||
redirect_uri,
|
||||
state: state.state
|
||||
});
|
||||
|
||||
const { user, token } = await auth.resolve(state.action, this, profile.sub, profile);
|
||||
console.log("******** RESOLVED ********", { user, token });
|
||||
try {
|
||||
const data = await auth.resolve(state.action, this, profile.sub, profile);
|
||||
console.log("******** RESOLVED ********", data);
|
||||
|
||||
if (state.redirect) {
|
||||
console.log("redirect to", state.redirect + "?token=" + token);
|
||||
return c.redirect(state.redirect + "?token=" + token);
|
||||
if (state.mode === "cookie") {
|
||||
return await auth.respond(c, data, state.redirect);
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
return c.json({ user, token });
|
||||
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) => {
|
||||
@@ -371,31 +428,29 @@ export class OAuthStrategy implements Strategy {
|
||||
|
||||
const url = new URL(c.req.url);
|
||||
const path = url.pathname.replace(`/${action}`, "");
|
||||
const redirect_uri = url.origin + path + "/callback";
|
||||
const q_redirect = (c.req.query("redirect") as string) ?? undefined;
|
||||
const redirect_uri = url.origin + path + "/token";
|
||||
|
||||
const state = await oauth.generateRandomCodeVerifier();
|
||||
const state = oauth.generateRandomCodeVerifier();
|
||||
const response = await this.request({
|
||||
redirect_uri,
|
||||
state
|
||||
});
|
||||
//console.log("_state", state);
|
||||
|
||||
await setState(c, { state, action, redirect: q_redirect });
|
||||
|
||||
if (c.req.header("Accept") === "application/json") {
|
||||
if (isDebug()) {
|
||||
return c.json({
|
||||
url: response.url,
|
||||
redirect_uri,
|
||||
challenge: state,
|
||||
action,
|
||||
params: response.params
|
||||
});
|
||||
}
|
||||
|
||||
//return c.text(response.url);
|
||||
console.log("--redirecting to", response.url);
|
||||
|
||||
return c.redirect(response.url);
|
||||
return c.json({
|
||||
url: response.url,
|
||||
challenge: state,
|
||||
action
|
||||
});
|
||||
});
|
||||
|
||||
return hono;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Exception, Permission } from "core";
|
||||
import { type Static, Type, objectTransform } from "core/utils";
|
||||
import { objectTransform } from "core/utils";
|
||||
import { Role } from "./Role";
|
||||
|
||||
export type GuardUserContext = {
|
||||
|
||||
40
app/src/core/server/flash.ts
Normal file
40
app/src/core/server/flash.ts
Normal 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;
|
||||
}
|
||||
@@ -64,7 +64,7 @@ export abstract class ModuleApi<Options extends BaseModuleApiOptions> {
|
||||
}
|
||||
|
||||
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;
|
||||
if (!requestContentType || requestContentType.startsWith("application/json")) {
|
||||
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>(
|
||||
_input: string | (string | number | PrimaryFieldType)[],
|
||||
_init?: RequestInit
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ModuleApi } from "./ModuleApi";
|
||||
import type { ModuleConfigs, ModuleSchemas } from "./ModuleManager";
|
||||
import type { ModuleConfigs, ModuleKey, ModuleSchemas } from "./ModuleManager";
|
||||
|
||||
export type ApiSchemaResponse = {
|
||||
version: number;
|
||||
@@ -21,4 +21,28 @@ export class SystemApi extends ModuleApi<any> {
|
||||
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]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
import type { App } from "App";
|
||||
import { type ClassController, isDebug } from "core";
|
||||
import { addFlashMessage } from "core/server/flash";
|
||||
import { Hono } from "hono";
|
||||
import { html, raw } from "hono/html";
|
||||
import { html } from "hono/html";
|
||||
import { Fragment } from "hono/jsx";
|
||||
import * as SystemPermissions from "modules/permissions";
|
||||
import type { Manifest } from "vite";
|
||||
@@ -21,6 +22,12 @@ export type AdminControllerOptions = {
|
||||
viteManifest?: Manifest;
|
||||
};
|
||||
|
||||
const authRoutes = {
|
||||
root: "/",
|
||||
login: "/auth/login",
|
||||
logout: "/auth/logout"
|
||||
};
|
||||
|
||||
export class AdminController implements ClassController {
|
||||
constructor(
|
||||
private readonly app: App,
|
||||
@@ -31,43 +38,97 @@ export class AdminController implements ClassController {
|
||||
return this.app.modules.ctx();
|
||||
}
|
||||
|
||||
getController(): Hono {
|
||||
const hono = new Hono();
|
||||
getController(): Hono<any> {
|
||||
const auth = this.app.module.auth;
|
||||
const configs = this.app.modules.configs();
|
||||
const auth_enabled = configs.auth.enabled;
|
||||
const basepath = (String(configs.server.admin.basepath) + "/").replace(/\/+$/, "/");
|
||||
const hono = new Hono<{
|
||||
Variables: {
|
||||
html: string;
|
||||
};
|
||||
}>().basePath(basepath);
|
||||
|
||||
this.ctx.server.get(basepath + "*", async (c) => {
|
||||
if (this.options.html) {
|
||||
return c.html(this.options.html);
|
||||
hono.use("*", async (c, next) => {
|
||||
const obj = { user: auth.authenticator.getUser() };
|
||||
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);
|
||||
}
|
||||
|
||||
// @todo: implement guard redirect once cookie sessions arrive
|
||||
const html = c.get("html");
|
||||
return c.html(html);
|
||||
});
|
||||
|
||||
const isProd = !isDebug();
|
||||
let script: string | undefined;
|
||||
let css: string[] = [];
|
||||
hono.get(authRoutes.logout, async (c) => {
|
||||
await auth.authenticator.logout(c);
|
||||
return c.redirect(authRoutes.login);
|
||||
});
|
||||
|
||||
if (isProd) {
|
||||
const manifest: Manifest = this.options.viteManifest
|
||||
? this.options.viteManifest
|
||||
: isProd
|
||||
? // @ts-ignore cases issues when building types
|
||||
await import("bknd/dist/manifest.json", { assert: { type: "json" } }).then(
|
||||
(m) => m.default
|
||||
)
|
||||
: {};
|
||||
//console.log("manifest", manifest, manifest["index.html"]);
|
||||
const entry = Object.values(manifest).find((f: any) => f.isEntry === true);
|
||||
if (!entry) {
|
||||
// do something smart
|
||||
return;
|
||||
}
|
||||
|
||||
script = "/" + entry.file;
|
||||
css = entry.css?.map((c: string) => "/" + c) ?? [];
|
||||
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);
|
||||
}
|
||||
|
||||
return c.html(
|
||||
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
|
||||
|
||||
const isProd = !isDebug();
|
||||
let script: string | undefined;
|
||||
let css: string[] = [];
|
||||
|
||||
if (isProd) {
|
||||
const manifest: Manifest = this.options.viteManifest
|
||||
? this.options.viteManifest
|
||||
: isProd
|
||||
? // @ts-ignore cases issues when building types
|
||||
await import("bknd/dist/manifest.json", { assert: { type: "json" } }).then(
|
||||
(m) => m.default
|
||||
)
|
||||
: {};
|
||||
//console.log("manifest", manifest, manifest["index.html"]);
|
||||
const entry = Object.values(manifest).find((f: any) => f.isEntry === true);
|
||||
if (!entry) {
|
||||
// do something smart
|
||||
return;
|
||||
}
|
||||
|
||||
script = "/" + entry.file;
|
||||
css = entry.css?.map((c: string) => "/" + c) ?? [];
|
||||
}
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
{html`<!doctype html>`}
|
||||
<html lang="en" class={configs.server.admin.color_scheme ?? "light"}>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
@@ -85,20 +146,22 @@ export class AdminController implements ClassController {
|
||||
</Fragment>
|
||||
) : (
|
||||
<Fragment>
|
||||
{/* biome-ignore lint/security/noDangerouslySetInnerHtml: I know what I do here :) */}
|
||||
<script type="module" dangerouslySetInnerHTML={{ __html: viteInject }} />
|
||||
<script type="module" src="/@vite/client" />
|
||||
<script type="module" src={"/@vite/client"} />
|
||||
</Fragment>
|
||||
)}
|
||||
</head>
|
||||
<body>
|
||||
<div id="app" />
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `window.__BKND__ = JSON.parse('${JSON.stringify(obj)}');`
|
||||
}}
|
||||
/>
|
||||
{!isProd && <script type="module" src="/src/ui/main.tsx" />}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
});
|
||||
|
||||
return hono;
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ export class SystemController implements ClassController {
|
||||
const { secrets } = c.req.valid("query");
|
||||
const { module } = c.req.valid("param");
|
||||
|
||||
this.ctx.guard.throwUnlessGranted(SystemPermissions.configRead);
|
||||
secrets && this.ctx.guard.throwUnlessGranted(SystemPermissions.configReadSecrets);
|
||||
|
||||
const config = this.app.toJSON(secrets);
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { Notifications } from "@mantine/notifications";
|
||||
import React from "react";
|
||||
import { FlashMessage } from "ui/modules/server/FlashMessage";
|
||||
import { BkndProvider, ClientProvider, useBknd } from "./client";
|
||||
import { createMantineTheme } from "./lib/mantine/theme";
|
||||
import { BkndModalsProvider } from "./modals";
|
||||
import { Routes } from "./routes";
|
||||
|
||||
export default function Admin({
|
||||
baseUrl: baseUrlOverride,
|
||||
withProvider = false
|
||||
}: { baseUrl?: string; withProvider?: boolean }) {
|
||||
export type BkndAdminProps = {
|
||||
baseUrl?: string;
|
||||
withProvider?: boolean;
|
||||
// @todo: add admin config override
|
||||
};
|
||||
|
||||
export default function Admin({ baseUrl: baseUrlOverride, withProvider = false }: BkndAdminProps) {
|
||||
const Component = (
|
||||
<BkndProvider>
|
||||
<AdminInternal />
|
||||
@@ -25,9 +29,11 @@ export default function Admin({
|
||||
function AdminInternal() {
|
||||
const b = useBknd();
|
||||
const theme = b.app.getAdminConfig().color_scheme;
|
||||
|
||||
return (
|
||||
<MantineProvider {...createMantineTheme(theme ?? "light")}>
|
||||
<Notifications />
|
||||
<FlashMessage />
|
||||
<BkndModalsProvider>
|
||||
<Routes />
|
||||
</BkndModalsProvider>
|
||||
|
||||
@@ -40,7 +40,7 @@ export function BkndProvider({
|
||||
if (!res.ok) {
|
||||
if (errorShown.current) return;
|
||||
errorShown.current = true;
|
||||
notifications.show({
|
||||
/*notifications.show({
|
||||
title: "Failed to fetch schema",
|
||||
// @ts-ignore
|
||||
message: body.error,
|
||||
@@ -48,7 +48,7 @@ export function BkndProvider({
|
||||
position: "top-right",
|
||||
autoClose: false,
|
||||
withCloseButton: true
|
||||
});
|
||||
});*/
|
||||
}
|
||||
|
||||
const schema = res.ok
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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";
|
||||
|
||||
export type SchemaActionsProps = {
|
||||
@@ -10,25 +11,53 @@ export type SchemaActionsProps = {
|
||||
export type TSchemaActions = ReturnType<typeof getSchemaActions>;
|
||||
|
||||
export function getSchemaActions({ client, setSchema }: SchemaActionsProps) {
|
||||
const baseUrl = client.baseUrl;
|
||||
const token = client.auth().state()?.token;
|
||||
const api = client.api;
|
||||
|
||||
async function displayError(action: string, module: string, res: Response, path?: string) {
|
||||
const notification_data: NotificationData = {
|
||||
id: "schema-error-" + [action, module, path].join("-"),
|
||||
title: `Config update failed${path ? ": " + path : ""}`,
|
||||
message: "Failed to complete config update",
|
||||
color: "red",
|
||||
async function handleConfigUpdate(
|
||||
action: string,
|
||||
module: string,
|
||||
res: ApiResponse,
|
||||
path?: string
|
||||
): Promise<boolean> {
|
||||
const base: Partial<NotificationData> = {
|
||||
id: "schema-" + [action, module, path].join("-"),
|
||||
position: "top-right",
|
||||
withCloseButton: true,
|
||||
autoClose: false
|
||||
autoClose: 3000
|
||||
};
|
||||
try {
|
||||
const { error } = (await res.json()) as any;
|
||||
notifications.show({ ...notification_data, message: error });
|
||||
} catch (e) {
|
||||
notifications.show(notification_data);
|
||||
|
||||
if (res.res.ok && res.body.success) {
|
||||
console.log("update config", action, module, path, res.body);
|
||||
if (res.body.success) {
|
||||
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 {
|
||||
@@ -37,183 +66,39 @@ export function getSchemaActions({ client, setSchema }: SchemaActionsProps) {
|
||||
value: ModuleConfigs[Module],
|
||||
force?: boolean
|
||||
) => {
|
||||
const res = await fetch(
|
||||
`${baseUrl}/api/system/config/set/${module}?force=${force ? 1 : 0}`,
|
||||
{
|
||||
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;
|
||||
const res = await api.system.setConfig(module, value, force);
|
||||
return await handleConfigUpdate("set", module, res);
|
||||
},
|
||||
patch: async <Module extends keyof ModuleConfigs>(
|
||||
module: keyof ModuleConfigs,
|
||||
path: string,
|
||||
value: any
|
||||
): Promise<boolean> => {
|
||||
const res = await fetch(`${baseUrl}/api/system/config/patch/${module}/${path}`, {
|
||||
method: "PATCH",
|
||||
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;
|
||||
const res = await api.system.patchConfig(module, path, value);
|
||||
return await handleConfigUpdate("patch", module, res, path);
|
||||
},
|
||||
overwrite: async <Module extends keyof ModuleConfigs>(
|
||||
module: keyof ModuleConfigs,
|
||||
path: string,
|
||||
value: any
|
||||
) => {
|
||||
const res = await fetch(`${baseUrl}/api/system/config/overwrite/${module}/${path}`, {
|
||||
method: "PUT",
|
||||
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;
|
||||
const res = await api.system.overwriteConfig(module, path, value);
|
||||
return await handleConfigUpdate("overwrite", module, res, path);
|
||||
},
|
||||
add: async <Module extends keyof ModuleConfigs>(
|
||||
module: keyof ModuleConfigs,
|
||||
path: string,
|
||||
value: any
|
||||
) => {
|
||||
const res = await fetch(`${baseUrl}/api/system/config/add/${module}/${path}`, {
|
||||
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 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;
|
||||
const res = await api.system.addConfig(module, path, value);
|
||||
return await handleConfigUpdate("add", module, res, path);
|
||||
},
|
||||
remove: async <Module extends keyof ModuleConfigs>(
|
||||
module: keyof ModuleConfigs,
|
||||
path: string
|
||||
) => {
|
||||
const res = await fetch(`${baseUrl}/api/system/config/remove/${module}/${path}`, {
|
||||
method: "DELETE",
|
||||
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;
|
||||
const res = await api.system.removeConfig(module, path);
|
||||
return await handleConfigUpdate("remove", module, res, path);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -89,14 +89,13 @@ export const useAuthStrategies = (options?: { baseUrl?: string }): Partial<AuthS
|
||||
const [data, setData] = useState<AuthStrategyData>();
|
||||
const ctxBaseUrl = useBaseUrl();
|
||||
const api = new Api({
|
||||
host: options?.baseUrl ? options?.baseUrl : ctxBaseUrl,
|
||||
tokenStorage: "localStorage"
|
||||
host: options?.baseUrl ? options?.baseUrl : ctxBaseUrl
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const res = await api.auth.strategies();
|
||||
console.log("res", res);
|
||||
//console.log("res", res);
|
||||
if (res.res.ok) {
|
||||
setData(res.body);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ export function useBkndAuth() {
|
||||
if (window.confirm(`Are you sure you want to delete the role "${name}"?`)) {
|
||||
return await bkndActions.remove("auth", `roles.${name}`);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -15,8 +15,7 @@ export class AppQueryClient {
|
||||
api: Api;
|
||||
constructor(public baseUrl: string) {
|
||||
this.api = new Api({
|
||||
host: baseUrl,
|
||||
tokenStorage: "localStorage"
|
||||
host: baseUrl
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -14,22 +14,30 @@ const Base: React.FC<AlertProps> = ({ visible = true, title, message, className,
|
||||
{...props}
|
||||
className={twMerge("flex flex-row dark:bg-amber-300/20 bg-amber-200 p-4", className)}
|
||||
>
|
||||
<div>
|
||||
{title && <b className="mr-2">{title}:</b>}
|
||||
{message}
|
||||
</div>
|
||||
{title && <b className="mr-2">{title}:</b>}
|
||||
{message}
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
const Warning: React.FC<AlertProps> = (props) => (
|
||||
<Base {...props} className="dark:bg-amber-300/20 bg-amber-200" />
|
||||
const Warning: React.FC<AlertProps> = ({ className, ...props }) => (
|
||||
<Base {...props} className={twMerge("dark:bg-amber-300/20 bg-amber-200", className)} />
|
||||
);
|
||||
|
||||
const Exception: React.FC<AlertProps> = (props) => (
|
||||
<Base {...props} className="dark:bg-red-950 bg-red-100" />
|
||||
const Exception: React.FC<AlertProps> = ({ className, ...props }) => (
|
||||
<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 = {
|
||||
Warning,
|
||||
Exception
|
||||
Exception,
|
||||
Success,
|
||||
Info
|
||||
};
|
||||
|
||||
@@ -86,7 +86,7 @@ export function DataTable<Data extends Record<string, any> = Record<string, any>
|
||||
</div>
|
||||
)}
|
||||
<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 ? (
|
||||
<thead className="sticky top-0 bg-muted/10">
|
||||
<tr>
|
||||
|
||||
@@ -150,7 +150,8 @@ function UserMenu() {
|
||||
|
||||
async function handleLogout() {
|
||||
await auth.logout();
|
||||
navigate("/auth/login", { replace: true });
|
||||
// @todo: grab from somewhere constant
|
||||
window.location.href = "/auth/logout";
|
||||
}
|
||||
|
||||
async function handleLogin() {
|
||||
|
||||
@@ -1,117 +1,55 @@
|
||||
import { type FieldApi, useForm } from "@tanstack/react-form";
|
||||
import { Type, type TypeInvalidError, parse } from "core/utils";
|
||||
|
||||
import { Button } from "ui/components/buttons/Button";
|
||||
import { typeboxResolver } from "@hookform/resolvers/typebox";
|
||||
import { Type } from "core/utils";
|
||||
import type { ComponentPropsWithoutRef } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { Button } from "ui";
|
||||
import * as Formy from "ui/components/form/Formy";
|
||||
|
||||
type LoginFormProps = {
|
||||
onSubmitted?: (value: { email: string; password: string }) => Promise<void>;
|
||||
export type LoginFormProps = Omit<ComponentPropsWithoutRef<"form">, "onSubmit"> & {
|
||||
className?: string;
|
||||
formData?: any;
|
||||
};
|
||||
|
||||
export function LoginForm({ onSubmitted }: LoginFormProps) {
|
||||
const form = useForm({
|
||||
defaultValues: {
|
||||
email: "",
|
||||
password: ""
|
||||
},
|
||||
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;
|
||||
const schema = Type.Object({
|
||||
email: Type.String({
|
||||
pattern: "^[\\w-\\.]+@([\\w-]+\\.)+[\\w-]{2,4}$"
|
||||
}),
|
||||
password: Type.String({
|
||||
minLength: 8 // @todo: this should be configurable
|
||||
})
|
||||
});
|
||||
|
||||
try {
|
||||
parse(fn, value);
|
||||
} catch (e) {
|
||||
return (e as TypeInvalidError).errors
|
||||
.map((error) => error.schema.error ?? error.message)
|
||||
.join(", ");
|
||||
}
|
||||
}
|
||||
|
||||
return { validate, validateAsync: validate };
|
||||
}
|
||||
export function LoginForm({ formData, className, method = "POST", ...props }: LoginFormProps) {
|
||||
const {
|
||||
register,
|
||||
formState: { isValid, errors }
|
||||
} = useForm({
|
||||
mode: "onChange",
|
||||
defaultValues: formData,
|
||||
resolver: typeboxResolver(schema)
|
||||
});
|
||||
|
||||
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
void form.handleSubmit();
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-3 w-full" noValidate>
|
||||
<form.Field
|
||||
name="email"
|
||||
validators={{
|
||||
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>
|
||||
)}
|
||||
/>
|
||||
<form.Field
|
||||
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>
|
||||
)}
|
||||
/>
|
||||
<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
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="large"
|
||||
className="w-full mt-2 justify-center"
|
||||
disabled={!allDirty || isSubmitting}
|
||||
>
|
||||
Sign in
|
||||
</Button>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<form {...props} method={method} className={twMerge("flex flex-col gap-3 w-full", className)}>
|
||||
<Formy.Group>
|
||||
<Formy.Label htmlFor="email">Email address</Formy.Label>
|
||||
<Formy.Input type="email" {...register("email")} />
|
||||
</Formy.Group>
|
||||
<Formy.Group>
|
||||
<Formy.Label htmlFor="password">Password</Formy.Label>
|
||||
<Formy.Input type="password" {...register("password")} />
|
||||
</Formy.Group>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="large"
|
||||
className="w-full mt-2 justify-center"
|
||||
disabled={!isValid}
|
||||
>
|
||||
Sign in
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
39
app/src/ui/modules/server/FlashMessage.tsx
Normal file
39
app/src/ui/modules/server/FlashMessage.tsx
Normal 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;
|
||||
}
|
||||
@@ -1,58 +1,17 @@
|
||||
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 { useEffect, useState } from "react";
|
||||
import { useAuth } from "ui/client";
|
||||
import { useAuthStrategies } from "ui/client/schema/auth/use-auth";
|
||||
import { Button } from "ui/components/buttons/Button";
|
||||
import { Logo } from "ui/components/display/Logo";
|
||||
import { Link } from "ui/components/wouter/Link";
|
||||
import { useBrowserTitle } from "ui/hooks/use-browser-title";
|
||||
import { useSearch } from "ui/hooks/use-search";
|
||||
import { LoginForm } from "ui/modules/auth/LoginForm";
|
||||
import { useLocation } from "wouter";
|
||||
import * as AppShell from "../../layouts/AppShell/AppShell";
|
||||
|
||||
const schema = Type.Object({
|
||||
token: Type.String()
|
||||
});
|
||||
|
||||
export function AuthLogin() {
|
||||
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 [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(
|
||||
strategies ?? {},
|
||||
@@ -63,7 +22,7 @@ export function AuthLogin() {
|
||||
},
|
||||
{}
|
||||
) as Record<string, AppAuthOAuthStrategy>;
|
||||
console.log("oauth", oauth, strategies);
|
||||
//console.log("oauth", oauth, strategies);
|
||||
|
||||
return (
|
||||
<AppShell.Root>
|
||||
@@ -77,26 +36,26 @@ export function AuthLogin() {
|
||||
<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>
|
||||
</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">
|
||||
{Object.keys(oauth).length > 0 && (
|
||||
<>
|
||||
{Object.entries(oauth)?.map(([name, oauth], key) => (
|
||||
<Button
|
||||
<form
|
||||
method="POST"
|
||||
action={`${basepath}/${name}/login`}
|
||||
key={key}
|
||||
size="large"
|
||||
variant="outline"
|
||||
className="justify-center"
|
||||
onClick={() => {
|
||||
window.location.href = `${basepath}/${name}/login?redirect=${window.location.href}`;
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
Continue with {ucFirstAllSnakeToPascalWithSpaces(oauth.name)}
|
||||
</Button>
|
||||
<Button
|
||||
key={key}
|
||||
type="submit"
|
||||
size="large"
|
||||
variant="outline"
|
||||
className="justify-center w-full"
|
||||
>
|
||||
Continue with {ucFirstAllSnakeToPascalWithSpaces(oauth.name)}
|
||||
</Button>
|
||||
</form>
|
||||
))}
|
||||
|
||||
<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>
|
||||
)}
|
||||
|
||||
@@ -29,13 +29,13 @@ function AuthRolesEditInternal({ params }) {
|
||||
const data = formRef.current?.getData();
|
||||
const success = await actions.roles.patch(roleName, data);
|
||||
|
||||
notifications.show({
|
||||
/*notifications.show({
|
||||
id: `role-${roleName}-update`,
|
||||
position: "top-right",
|
||||
title: success ? "Update success" : "Update failed",
|
||||
message: success ? "Role updated successfully" : "Failed to update role",
|
||||
color: !success ? "red" : undefined
|
||||
});
|
||||
});*/
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
|
||||
@@ -90,14 +90,18 @@ const renderValue = ({ value, property }) => {
|
||||
}
|
||||
|
||||
if (property === "permissions") {
|
||||
return [...(value || [])].map((p, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="inline-block px-2 py-1.5 text-sm bg-primary/5 rounded font-mono leading-none"
|
||||
>
|
||||
{p}
|
||||
</span>
|
||||
));
|
||||
return (
|
||||
<div className="flex flex-row gap-1">
|
||||
{[...(value || [])].map((p, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="inline-block px-2 py-1.5 text-sm bg-primary/5 rounded font-mono leading-none"
|
||||
>
|
||||
{p}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <CellValue value={value} property={property} />;
|
||||
|
||||
@@ -6,10 +6,9 @@ import {
|
||||
} from "@tabler/icons-react";
|
||||
import { isDebug } from "core";
|
||||
import type { Entity } from "data";
|
||||
import { cloneDeep, omit } from "lodash-es";
|
||||
import { forwardRef, useImperativeHandle, useRef, useState } from "react";
|
||||
import { TbArrowLeft, TbDots } from "react-icons/tb";
|
||||
import { useBknd } from "ui/client";
|
||||
import { cloneDeep } from "lodash-es";
|
||||
import { useRef, useState } from "react";
|
||||
import { TbDots } from "react-icons/tb";
|
||||
import { useBkndData } from "ui/client/schema/data/use-bknd-data";
|
||||
import { Button } from "ui/components/buttons/Button";
|
||||
import { IconButton } from "ui/components/buttons/IconButton";
|
||||
@@ -20,9 +19,8 @@ import {
|
||||
} from "ui/components/form/json-schema/JsonSchemaForm";
|
||||
import { Dropdown } from "ui/components/overlay/Dropdown";
|
||||
import * as AppShell from "ui/layouts/AppShell/AppShell";
|
||||
import { SectionHeaderAccordionItem } from "ui/layouts/AppShell/AppShell";
|
||||
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 { EntityFieldsForm, type EntityFieldsFormRef } from "./forms/entity.fields.form";
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Suspense, lazy } from "react";
|
||||
import { useBknd } from "ui/client";
|
||||
import { Route, Router, Switch } from "wouter";
|
||||
import { useBknd } from "../client/BkndProvider";
|
||||
import { AuthLogin } from "./auth/auth.login";
|
||||
import { Root, RootEmpty } from "./root";
|
||||
|
||||
|
||||
@@ -47,6 +47,10 @@ html.fixed, html.fixed body {
|
||||
@mixin dark {
|
||||
--mantine-color-body: rgb(9 9 11);
|
||||
}
|
||||
|
||||
table {
|
||||
font-size: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
html, body {
|
||||
|
||||
Reference in New Issue
Block a user