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,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,
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
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;
|
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
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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"
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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 { 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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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} />;
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user