Merge pull request #50 from bknd-io/feat/admin-user-create

Create users in Admin UI
This commit is contained in:
dswbx
2025-01-17 10:28:39 +01:00
committed by GitHub
23 changed files with 464 additions and 108 deletions

View File

@@ -1,11 +1,16 @@
import { type AuthAction, Authenticator, type ProfileExchange, Role, type Strategy } from "auth"; import {
type AuthAction,
AuthPermissions,
Authenticator,
type ProfileExchange,
Role,
type Strategy
} from "auth";
import type { PasswordStrategy } from "auth/authenticate/strategies"; import type { PasswordStrategy } from "auth/authenticate/strategies";
import { auth } from "auth/middlewares";
import { type DB, Exception, type PrimaryFieldType } from "core"; import { type DB, Exception, type PrimaryFieldType } from "core";
import { type Static, secureRandomString, transformObject } from "core/utils"; import { type Static, secureRandomString, transformObject } from "core/utils";
import { type Entity, EntityIndex, type EntityManager } from "data"; import type { Entity, EntityManager } from "data";
import { type FieldSchema, em, entity, enumm, make, text } from "data/prototype"; import { type FieldSchema, em, entity, enumm, make, text } from "data/prototype";
import type { Hono } from "hono";
import { pick } from "lodash-es"; import { pick } from "lodash-es";
import { Module } from "modules/Module"; import { Module } from "modules/Module";
import { AuthController } from "./api/AuthController"; import { AuthController } from "./api/AuthController";
@@ -79,8 +84,8 @@ export class AppAuth extends Module<typeof authConfigSchema> {
super.setBuilt(); super.setBuilt();
this._controller = new AuthController(this); this._controller = new AuthController(this);
//this.ctx.server.use(controller.getMiddleware);
this.ctx.server.route(this.config.basepath, this._controller.getController()); this.ctx.server.route(this.config.basepath, this._controller.getController());
this.ctx.guard.registerPermissions(Object.values(AuthPermissions));
} }
get controller(): AuthController { get controller(): AuthController {
@@ -260,14 +265,12 @@ export class AppAuth extends Module<typeof authConfigSchema> {
try { try {
const roles = Object.keys(this.config.roles ?? {}); const roles = Object.keys(this.config.roles ?? {});
const field = make("role", enumm({ enum: roles })); this.replaceEntityField(users, "role", enumm({ enum: roles }));
users.__replaceField("role", field);
} catch (e) {} } catch (e) {}
try { try {
const strategies = Object.keys(this.config.strategies ?? {}); const strategies = Object.keys(this.config.strategies ?? {});
const field = make("strategy", enumm({ enum: strategies })); this.replaceEntityField(users, "strategy", enumm({ enum: strategies }));
users.__replaceField("strategy", field);
} catch (e) {} } catch (e) {}
} }

View File

@@ -1,3 +1,4 @@
import type { AuthActionResponse } from "auth/api/AuthController";
import type { AppAuthSchema, AppAuthStrategies } from "auth/auth-schema"; import type { AppAuthSchema, AppAuthStrategies } from "auth/auth-schema";
import type { AuthResponse, SafeUser, Strategy } from "auth/authenticate/Authenticator"; import type { AuthResponse, SafeUser, Strategy } from "auth/authenticate/Authenticator";
import { type BaseModuleApiOptions, ModuleApi } from "modules/ModuleApi"; import { type BaseModuleApiOptions, ModuleApi } from "modules/ModuleApi";
@@ -13,22 +14,46 @@ export class AuthApi extends ModuleApi<AuthApiOptions> {
}; };
} }
async loginWithPassword(input: any) { async login(strategy: string, input: any) {
const res = await this.post<AuthResponse>(["password", "login"], input); const res = await this.post<AuthResponse>([strategy, "login"], input);
if (res.ok && res.body.token) { if (res.ok && res.body.token) {
await this.options.onTokenUpdate?.(res.body.token); await this.options.onTokenUpdate?.(res.body.token);
} }
return res; return res;
} }
async registerWithPassword(input: any) { async register(strategy: string, input: any) {
const res = await this.post<AuthResponse>(["password", "register"], input); const res = await this.post<AuthResponse>([strategy, "register"], input);
if (res.ok && res.body.token) { if (res.ok && res.body.token) {
await this.options.onTokenUpdate?.(res.body.token); await this.options.onTokenUpdate?.(res.body.token);
} }
return res; return res;
} }
async actionSchema(strategy: string, action: string) {
return this.get<Strategy>([strategy, "actions", action, "schema.json"]);
}
async action(strategy: string, action: string, input: any) {
return this.post<AuthActionResponse>([strategy, "actions", action], input);
}
/**
* @deprecated use login("password", ...) instead
* @param input
*/
async loginWithPassword(input: any) {
return this.login("password", input);
}
/**
* @deprecated use register("password", ...) instead
* @param input
*/
async registerWithPassword(input: any) {
return this.register("password", input);
}
me() { me() {
return this.get<{ user: SafeUser | null }>(["me"]); return this.get<{ user: SafeUser | null }>(["me"]);
} }

View File

@@ -1,5 +1,16 @@
import type { AppAuth } from "auth"; import { type AppAuth, AuthPermissions, type SafeUser, type Strategy } from "auth";
import { TypeInvalidError, parse } from "core/utils";
import { DataPermissions } from "data";
import type { Hono } from "hono";
import { Controller } from "modules/Controller"; import { Controller } from "modules/Controller";
import type { ServerEnv } from "modules/Module";
export type AuthActionResponse = {
success: boolean;
action: string;
data?: SafeUser;
errors?: any;
};
export class AuthController extends Controller { export class AuthController extends Controller {
constructor(private auth: AppAuth) { constructor(private auth: AppAuth) {
@@ -10,6 +21,70 @@ export class AuthController extends Controller {
return this.auth.ctx.guard; return this.auth.ctx.guard;
} }
private registerStrategyActions(strategy: Strategy, mainHono: Hono<ServerEnv>) {
const actions = strategy.getActions?.();
if (!actions) {
return;
}
const { auth, permission } = this.middlewares;
const hono = this.create().use(auth());
const name = strategy.getName();
const { create, change } = actions;
const em = this.auth.em;
const mutator = em.mutator(this.auth.config.entity_name as "users");
if (create) {
hono.post(
"/create",
permission([AuthPermissions.createUser, DataPermissions.entityCreate]),
async (c) => {
try {
const body = await this.auth.authenticator.getBody(c);
const valid = parse(create.schema, body, {
skipMark: true
});
const processed = (await create.preprocess?.(valid)) ?? valid;
console.log("processed", processed);
// @todo: check processed for "role" and check permissions
mutator.__unstable_toggleSystemEntityCreation(false);
const { data: created } = await mutator.insertOne({
...processed,
strategy: name
});
mutator.__unstable_toggleSystemEntityCreation(true);
return c.json({
success: true,
action: "create",
strategy: name,
data: created as unknown as SafeUser
} as AuthActionResponse);
} catch (e) {
if (e instanceof TypeInvalidError) {
return c.json(
{
success: false,
errors: e.errors
},
400
);
}
throw e;
}
}
);
hono.get("create/schema.json", async (c) => {
return c.json(create.schema);
});
}
mainHono.route(`/${name}/actions`, hono);
}
override getController() { override getController() {
const { auth } = this.middlewares; const { auth } = this.middlewares;
const hono = this.create(); const hono = this.create();
@@ -18,6 +93,7 @@ export class AuthController extends Controller {
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}`);
hono.route(`/${name}`, strategy.getController(this.auth.authenticator)); hono.route(`/${name}`, strategy.getController(this.auth.authenticator));
this.registerStrategyActions(strategy, hono);
} }
hono.get("/me", auth(), async (c) => { hono.get("/me", auth(), async (c) => {

View File

@@ -0,0 +1,4 @@
import { Permission } from "core";
export const createUser = new Permission("auth.user.create");
//export const updateUser = new Permission("auth.user.update");

View File

@@ -1,6 +1,14 @@
import { Exception } from "core"; import { type DB, Exception } from "core";
import { addFlashMessage } from "core/server/flash"; import { addFlashMessage } from "core/server/flash";
import { type Static, StringEnum, Type, parse, runtimeSupports, transformObject } from "core/utils"; import {
type Static,
StringEnum,
type TObject,
Type,
parse,
runtimeSupports,
transformObject
} from "core/utils";
import type { Context, Hono } from "hono"; import type { Context, Hono } from "hono";
import { deleteCookie, getSignedCookie, setSignedCookie } from "hono/cookie"; import { deleteCookie, getSignedCookie, setSignedCookie } from "hono/cookie";
import { sign, verify } from "hono/jwt"; import { sign, verify } from "hono/jwt";
@@ -10,6 +18,14 @@ import type { ServerEnv } from "modules/Module";
type Input = any; // workaround type Input = any; // workaround
export type JWTPayload = Parameters<typeof sign>[0]; export type JWTPayload = Parameters<typeof sign>[0];
export const strategyActions = ["create", "change"] as const;
export type StrategyActionName = (typeof strategyActions)[number];
export type StrategyAction<S extends TObject = TObject> = {
schema: S;
preprocess: (input: unknown) => Promise<Omit<DB["users"], "id" | "strategy">>;
};
export type StrategyActions = Partial<Record<StrategyActionName, StrategyAction>>;
// @todo: add schema to interface to ensure proper inference // @todo: add schema to interface to ensure proper inference
export interface Strategy { export interface Strategy {
getController: (auth: Authenticator) => Hono<any>; getController: (auth: Authenticator) => Hono<any>;
@@ -17,6 +33,7 @@ export interface Strategy {
getMode: () => "form" | "external"; getMode: () => "form" | "external";
getName: () => string; getName: () => string;
toJSON: (secrets?: boolean) => any; toJSON: (secrets?: boolean) => any;
getActions?: () => StrategyActions;
} }
export type User = { export type User = {
@@ -274,6 +291,14 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
return c.req.header("Content-Type") === "application/json"; return c.req.header("Content-Type") === "application/json";
} }
async getBody(c: Context) {
if (this.isJsonRequest(c)) {
return await c.req.json();
} else {
return Object.fromEntries((await c.req.formData()).entries());
}
}
private getSuccessPath(c: Context) { private getSuccessPath(c: Context) {
const p = (this.config.cookie.pathSuccess ?? "/").replace(/\/+$/, "/"); const p = (this.config.cookie.pathSuccess ?? "/").replace(/\/+$/, "/");
@@ -338,3 +363,13 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
}; };
} }
} }
export function createStrategyAction<S extends TObject>(
schema: S,
preprocess: (input: Static<S>) => Promise<Partial<DB["users"]>>
) {
return {
schema,
preprocess
} as StrategyAction<S>;
}

View File

@@ -2,6 +2,7 @@ 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 { type Context, Hono } from "hono"; import { type Context, Hono } from "hono";
import { type StrategyAction, type StrategyActions, createStrategyAction } from "../Authenticator";
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,17 +55,9 @@ 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 getBody(c); const body = await authenticator.getBody(c);
try { try {
const payload = await this.login(body); const payload = await this.login(body);
@@ -76,7 +69,7 @@ export class PasswordStrategy implements Strategy {
} }
}) })
.post("/register", async (c) => { .post("/register", async (c) => {
const body = await getBody(c); const body = await authenticator.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);
@@ -85,6 +78,27 @@ export class PasswordStrategy implements Strategy {
}); });
} }
getActions(): StrategyActions {
return {
create: createStrategyAction(
Type.Object({
email: Type.String({
pattern: "^[\\w-\\.]+@([\\w-]+\\.)+[\\w-]{2,4}$"
}),
password: Type.String({
minLength: 8 // @todo: this should be configurable
})
}),
async ({ password, ...input }) => {
return {
...input,
strategy_value: await this.hash(password)
};
}
)
};
}
getSchema() { getSchema() {
return schema; return schema;
} }

View File

@@ -19,3 +19,5 @@ export { AppAuth, type UserFieldSchema } from "./AppAuth";
export { Guard, type GuardUserContext, type GuardConfig } from "./authorize/Guard"; export { Guard, type GuardUserContext, type GuardConfig } from "./authorize/Guard";
export { Role } from "./authorize/Role"; export { Role } from "./authorize/Role";
export * as AuthPermissions from "./auth-permissions";

View File

@@ -3,7 +3,15 @@ import type { Guard } from "auth";
import { SchemaObject } from "core"; import { SchemaObject } from "core";
import type { EventManager } from "core/events"; import type { EventManager } from "core/events";
import type { Static, TSchema } from "core/utils"; import type { Static, TSchema } from "core/utils";
import type { Connection, EntityIndex, EntityManager, em as prototypeEm } from "data"; import {
type Connection,
type EntityIndex,
type EntityManager,
type Field,
FieldPrototype,
make,
type em as prototypeEm
} from "data";
import { Entity } from "data"; import { Entity } from "data";
import type { Hono } from "hono"; import type { Hono } from "hono";
@@ -184,4 +192,16 @@ export abstract class Module<Schema extends TSchema = TSchema, ConfigSchema = St
return schema; return schema;
} }
protected replaceEntityField(
_entity: string | Entity,
field: Field | string,
_newField: Field | FieldPrototype
) {
const entity = this.ctx.em.entity(_entity);
const name = typeof field === "string" ? field : field.name;
const newField =
_newField instanceof FieldPrototype ? make(name, _newField as any) : _newField;
entity.__replaceField(name, newField);
}
} }

View File

@@ -39,6 +39,13 @@ export abstract class ModuleApi<Options extends BaseModuleApiOptions = BaseModul
} as Options; } as Options;
} }
/**
* used for SWR invalidation of basepath
*/
key(): string {
return this.options.basepath ?? "";
}
protected getUrl(path: string) { protected getUrl(path: string) {
const basepath = this.options.basepath ?? ""; const basepath = this.options.basepath ?? "";
return this.options.host + (basepath + "/" + path).replace(/\/{2,}/g, "/").replace(/\/$/, ""); return this.options.host + (basepath + "/" + path).replace(/\/{2,}/g, "/").replace(/\/$/, "");

View File

@@ -1,5 +1,5 @@
import type { Api } from "Api"; import type { Api } from "Api";
import type { FetchPromise, ResponseObject } from "modules/ModuleApi"; import type { FetchPromise, ModuleApi, ResponseObject } from "modules/ModuleApi";
import useSWR, { type SWRConfiguration, useSWRConfig } from "swr"; import useSWR, { type SWRConfiguration, useSWRConfig } from "swr";
import { useApi } from "ui/client"; import { useApi } from "ui/client";
@@ -27,12 +27,19 @@ export const useApiQuery = <
}; };
}; };
export const useInvalidate = () => { export const useInvalidate = (options?: { exact?: boolean }) => {
const mutate = useSWRConfig().mutate; const mutate = useSWRConfig().mutate;
const api = useApi(); const api = useApi();
return async (arg?: string | ((api: Api) => FetchPromise<any>)) => { return async (arg?: string | ((api: Api) => FetchPromise<any> | ModuleApi<any>)) => {
if (!arg) return async () => mutate(""); let key = "";
return mutate(typeof arg === "string" ? arg : arg(api).key()); if (typeof arg === "string") {
key = arg;
} else if (typeof arg === "function") {
key = arg(api).key();
}
if (options?.exact) return mutate(key);
return mutate((k) => typeof k === "string" && k.startsWith(key));
}; };
}; };

View File

@@ -22,15 +22,6 @@ export class UseEntityApiError<Payload = any> extends Error {
} }
} }
function Test() {
const { read } = useEntity("users");
async () => {
const data = await read();
};
return null;
}
export const useEntity = < export const useEntity = <
Entity extends keyof DB | string, Entity extends keyof DB | string,
Id extends PrimaryFieldType | undefined = undefined, Id extends PrimaryFieldType | undefined = undefined,

View File

@@ -1,7 +1,9 @@
import { Empty, type EmptyProps } from "./Empty"; import { Empty, type EmptyProps } from "./Empty";
const NotFound = (props: Partial<EmptyProps>) => <Empty title="Not Found" {...props} />; const NotFound = (props: Partial<EmptyProps>) => <Empty title="Not Found" {...props} />;
const NotAllowed = (props: Partial<EmptyProps>) => <Empty title="Not Allowed" {...props} />;
export const Message = { export const Message = {
NotFound NotFound,
NotAllowed
}; };

View File

@@ -15,12 +15,13 @@ export type JsonSchemaFormProps = any & {
schema: RJSFSchema | Schema; schema: RJSFSchema | Schema;
uiSchema?: any; uiSchema?: any;
direction?: "horizontal" | "vertical"; direction?: "horizontal" | "vertical";
onChange?: (value: any) => void; onChange?: (value: any, isValid: () => boolean) => void;
}; };
export type JsonSchemaFormRef = { export type JsonSchemaFormRef = {
formData: () => any; formData: () => any;
validateForm: () => boolean; validateForm: () => boolean;
silentValidate: () => boolean;
cancel: () => void; cancel: () => void;
}; };
@@ -52,15 +53,18 @@ export const JsonSchemaForm = forwardRef<JsonSchemaFormRef, JsonSchemaFormProps>
const handleChange = ({ formData }: any, e) => { const handleChange = ({ formData }: any, e) => {
const clean = JSON.parse(JSON.stringify(formData)); const clean = JSON.parse(JSON.stringify(formData));
//console.log("Data changed: ", clean, JSON.stringify(formData, null, 2)); //console.log("Data changed: ", clean, JSON.stringify(formData, null, 2));
onChange?.(clean);
setValue(clean); setValue(clean);
onChange?.(clean, () => isValid(clean));
}; };
const isValid = (data: any) => validator.validateFormData(data, schema).errors.length === 0;
useImperativeHandle( useImperativeHandle(
ref, ref,
() => ({ () => ({
formData: () => value, formData: () => value,
validateForm: () => formRef.current!.validateForm(), validateForm: () => formRef.current!.validateForm(),
silentValidate: () => isValid(value),
cancel: () => formRef.current!.reset() cancel: () => formRef.current!.reset()
}), }),
[value] [value]

View File

@@ -0,0 +1,22 @@
import type { ContextModalProps } from "@mantine/modals";
import type { ReactNode } from "react";
export function OverlayModal({
context,
id,
innerProps: { content }
}: ContextModalProps<{ content?: ReactNode }>) {
return content;
}
OverlayModal.defaultTitle = undefined;
OverlayModal.modalProps = {
withCloseButton: false,
classNames: {
size: "md",
root: "bknd-admin",
content: "text-center justify-center",
title: "font-bold !text-md",
body: "py-3 px-5 gap-4 flex flex-col"
}
};

View File

@@ -7,21 +7,31 @@ import {
} from "ui/components/form/json-schema"; } from "ui/components/form/json-schema";
import type { ContextModalProps } from "@mantine/modals"; import type { ContextModalProps } from "@mantine/modals";
import { Alert } from "ui/components/display/Alert";
type Props = JsonSchemaFormProps & { type Props = JsonSchemaFormProps & {
onSubmit?: (data: any) => void | Promise<void>; autoCloseAfterSubmit?: boolean;
onSubmit?: (
data: any,
context: {
close: () => void;
}
) => void | Promise<void>;
}; };
export function SchemaFormModal({ export function SchemaFormModal({
context, context,
id, id,
innerProps: { schema, uiSchema, onSubmit } innerProps: { schema, uiSchema, onSubmit, autoCloseAfterSubmit }
}: ContextModalProps<Props>) { }: ContextModalProps<Props>) {
const [valid, setValid] = useState(false); const [valid, setValid] = useState(false);
const formRef = useRef<JsonSchemaFormRef>(null); const formRef = useRef<JsonSchemaFormRef>(null);
const [submitting, setSubmitting] = useState(false);
const was_submitted = useRef(false);
const [error, setError] = useState<string>();
function handleChange(data) { function handleChange(data, isValid) {
const valid = formRef.current?.validateForm() ?? false; const valid = isValid();
console.log("Data changed", data, valid); console.log("Data changed", data, valid);
setValid(valid); setValid(valid);
} }
@@ -30,29 +40,45 @@ export function SchemaFormModal({
context.closeModal(id); context.closeModal(id);
} }
async function handleClickAdd() { async function handleSubmit() {
await onSubmit?.(formRef.current?.formData()); was_submitted.current = true;
handleClose(); if (!formRef.current?.validateForm()) {
return;
}
setSubmitting(true);
await onSubmit?.(formRef.current?.formData(), {
close: handleClose,
setError
});
setSubmitting(false);
if (autoCloseAfterSubmit !== false) {
handleClose();
}
} }
return ( return (
<div className="pt-3 pb-3 px-3 gap-4 flex flex-col"> <>
<JsonSchemaForm {error && <Alert.Exception message={error} />}
tagName="form" <div className="pt-3 pb-3 px-3 gap-4 flex flex-col">
ref={formRef} <JsonSchemaForm
schema={schema} tagName="form"
uiSchema={uiSchema} ref={formRef}
className="legacy hide-required-mark fieldset-alternative mute-root" schema={schema}
onChange={handleChange} uiSchema={uiSchema}
onSubmit={handleClickAdd} className="legacy hide-required-mark fieldset-alternative mute-root"
/> onChange={handleChange}
<div className="flex flex-row justify-end gap-2"> onSubmit={handleSubmit}
<Button onClick={handleClose}>Cancel</Button> />
<Button variant="primary" onClick={handleClickAdd} disabled={!valid}> <div className="flex flex-row justify-end gap-2">
Create <Button onClick={handleClose}>Cancel</Button>
</Button> <Button variant="primary" onClick={handleSubmit} disabled={!valid || submitting}>
Create
</Button>
</div>
</div> </div>
</div> </>
); );
} }
@@ -63,7 +89,7 @@ SchemaFormModal.modalProps = {
root: "bknd-admin", root: "bknd-admin",
header: "!bg-primary/5 border-b border-b-muted !py-3 px-5 !h-auto !min-h-px", header: "!bg-primary/5 border-b border-b-muted !py-3 px-5 !h-auto !min-h-px",
content: "rounded-lg select-none", content: "rounded-lg select-none",
title: "font-bold !text-md", title: "!font-bold !text-md",
body: "!p-0" body: "!p-0"
} }
}; };

View File

@@ -1,7 +1,8 @@
import type { ModalProps } from "@mantine/core"; import type { ModalProps } from "@mantine/core";
import { ModalsProvider, modals as mantineModals } from "@mantine/modals"; import { modals as $modals, ModalsProvider, closeModal, openContextModal } from "@mantine/modals";
import { transformObject } from "core/utils"; import { transformObject } from "core/utils";
import type { ComponentProps } from "react"; import type { ComponentProps } from "react";
import { OverlayModal } from "ui/modals/debug/OverlayModal";
import { DebugModal } from "./debug/DebugModal"; import { DebugModal } from "./debug/DebugModal";
import { SchemaFormModal } from "./debug/SchemaFormModal"; import { SchemaFormModal } from "./debug/SchemaFormModal";
import { TestModal } from "./debug/TestModal"; import { TestModal } from "./debug/TestModal";
@@ -9,7 +10,8 @@ import { TestModal } from "./debug/TestModal";
const modals = { const modals = {
test: TestModal, test: TestModal,
debug: DebugModal, debug: DebugModal,
form: SchemaFormModal form: SchemaFormModal,
overlay: OverlayModal
}; };
declare module "@mantine/modals" { declare module "@mantine/modals" {
@@ -33,17 +35,22 @@ function open<Modal extends keyof typeof modals>(
) { ) {
const title = _title ?? modals[modal].defaultTitle ?? undefined; const title = _title ?? modals[modal].defaultTitle ?? undefined;
const cmpModalProps = modals[modal].modalProps ?? {}; const cmpModalProps = modals[modal].modalProps ?? {};
return mantineModals.openContextModal({ const props = {
title, title,
...modalProps, ...modalProps,
...cmpModalProps, ...cmpModalProps,
modal, modal,
innerProps innerProps
}); };
openContextModal(props);
return {
close: () => close(modal),
closeAll: $modals.closeAll
};
} }
function close<Modal extends keyof typeof modals>(modal: Modal) { function close<Modal extends keyof typeof modals>(modal: Modal) {
return mantineModals.close(modal); return closeModal(modal);
} }
export const bkndModals = { export const bkndModals = {
@@ -53,5 +60,5 @@ export const bkndModals = {
>, >,
open, open,
close, close,
closeAll: mantineModals.closeAll closeAll: $modals.closeAll
}; };

View File

@@ -0,0 +1,53 @@
import { useApi, useInvalidate } from "ui/client";
import { useBkndAuth } from "ui/client/schema/auth/use-bknd-auth";
import { routes, useNavigate } from "ui/lib/routes";
import { bkndModals } from "ui/modals";
export function useCreateUserModal() {
const api = useApi();
const { config } = useBkndAuth();
const invalidate = useInvalidate();
const [navigate] = useNavigate();
const open = async () => {
const loading = bkndModals.open("overlay", {
content: "Loading..."
});
const schema = await api.auth.actionSchema("password", "create");
loading.closeAll(); // currently can't close by id...
bkndModals.open(
"form",
{
schema,
uiSchema: {
password: {
"ui:widget": "password"
}
},
autoCloseAfterSubmit: false,
onSubmit: async (data, ctx) => {
console.log("submitted:", data, ctx);
const res = await api.auth.action("password", "create", data);
console.log(res);
if (res.ok) {
// invalidate all data
invalidate();
navigate(routes.data.entity.edit(config.entity_name, res.data.id));
ctx.close();
} else if ("error" in res) {
ctx.setError(res.error);
} else {
ctx.setError("Unknown error");
}
}
},
{
title: "Create User"
}
);
};
return { open };
}

View File

@@ -34,7 +34,11 @@ export function EntityTable2({ entity, select, ...props }: EntityTableProps) {
const field = getField(property)!; const field = getField(property)!;
_value = field.getValue(value, "table"); _value = field.getValue(value, "table");
} catch (e) { } catch (e) {
console.warn("Couldn't render value", { value, property, entity, select, ...props }, e); console.warn(
"Couldn't render value",
{ value, property, entity, select, columns, ...props },
e
);
} }
return <CellValue value={_value} property={property} />; return <CellValue value={_value} property={property} />;

View File

@@ -28,14 +28,9 @@ function AuthRolesEditInternal({ params }) {
if (!formRef.current?.isValid()) return; if (!formRef.current?.isValid()) return;
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);
if (success) {
/*notifications.show({ navigate(routes.auth.roles.list());
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() { async function handleDelete() {

View File

@@ -90,9 +90,16 @@ const renderValue = ({ value, property }) => {
} }
if (property === "permissions") { if (property === "permissions") {
const max = 3;
let permissions = value || [];
const count = permissions.length;
if (count > max) {
permissions = [...permissions.slice(0, max), `+${count - max}`];
}
return ( return (
<div className="flex flex-row gap-1"> <div className="flex flex-row gap-1">
{[...(value || [])].map((p, i) => ( {permissions.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"

View File

@@ -6,6 +6,7 @@ import { useApiQuery, useEntityQuery } 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";
import { Message } from "ui/components/display/Message";
import { Dropdown } from "ui/components/overlay/Dropdown"; import { Dropdown } from "ui/components/overlay/Dropdown";
import { useBrowserTitle } from "ui/hooks/use-browser-title"; import { useBrowserTitle } from "ui/hooks/use-browser-title";
import * as AppShell from "ui/layouts/AppShell/AppShell"; import * as AppShell from "ui/layouts/AppShell/AppShell";
@@ -18,7 +19,11 @@ import { useEntityForm } from "ui/modules/data/hooks/useEntityForm";
export function DataEntityUpdate({ params }) { export function DataEntityUpdate({ params }) {
const { $data, relations } = useBkndData(); const { $data, relations } = useBkndData();
const entity = $data.entity(params.entity as string)!; const entity = $data.entity(params.entity as string);
if (!entity) {
return <Message.NotFound description={`Entity "${params.entity}" doesn't exist.`} />;
}
const entityId = Number.parseInt(params.id as string); const entityId = Number.parseInt(params.id as string);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [navigate] = useNavigate(); const [navigate] = useNavigate();
@@ -36,7 +41,8 @@ export function DataEntityUpdate({ params }) {
with: local_relation_refs with: local_relation_refs
}, },
{ {
revalidateOnFocus: false revalidateOnFocus: false,
shouldRetryOnError: false
} }
); );
@@ -81,6 +87,14 @@ export function DataEntityUpdate({ params }) {
onSubmitted onSubmitted
}); });
if (!data && !$q.isLoading) {
return (
<Message.NotFound
description={`Entity "${params.entity}" with ID "${entityId}" doesn't exist.`}
/>
);
}
const makeKey = (key: string | number = "") => const makeKey = (key: string | number = "") =>
`${params.entity.name}_${entityId}_${String(key)}`; `${params.entity.name}_${entityId}_${String(key)}`;

View File

@@ -2,8 +2,9 @@ import { Type } from "core/utils";
import type { EntityData } from "data"; import type { EntityData } from "data";
import { useState } from "react"; import { useState } from "react";
import { useEntityMutate } from "ui/client"; import { useEntityMutate } from "ui/client";
import { useBknd } from "ui/client/BkndProvider"; 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 { Message } from "ui/components/display/Message";
import { useBrowserTitle } from "ui/hooks/use-browser-title"; import { useBrowserTitle } from "ui/hooks/use-browser-title";
import { useSearch } from "ui/hooks/use-search"; import { useSearch } from "ui/hooks/use-search";
import * as AppShell from "ui/layouts/AppShell/AppShell"; import * as AppShell from "ui/layouts/AppShell/AppShell";
@@ -13,8 +14,14 @@ import { EntityForm } from "ui/modules/data/components/EntityForm";
import { useEntityForm } from "ui/modules/data/hooks/useEntityForm"; import { useEntityForm } from "ui/modules/data/hooks/useEntityForm";
export function DataEntityCreate({ params }) { export function DataEntityCreate({ params }) {
const { app } = useBknd(); const { $data } = useBkndData();
const entity = app.entity(params.entity as string)!; const entity = $data.entity(params.entity as string);
if (!entity) {
return <Message.NotFound description={`Entity "${params.entity}" doesn't exist.`} />;
} else if (entity.type !== "regular") {
return <Message.NotAllowed description={`Entity "${params.entity}" cannot be created.`} />;
}
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
useBrowserTitle(["Data", entity.label, "Create"]); useBrowserTitle(["Data", entity.label, "Create"]);
@@ -43,7 +50,7 @@ export function DataEntityCreate({ params }) {
const { Form, handleSubmit } = useEntityForm({ const { Form, handleSubmit } = useEntityForm({
action: "create", action: "create",
entity, entity: entity,
initialData: search.value, initialData: search.value,
onSubmitted onSubmitted
}); });

View File

@@ -1,7 +1,9 @@
import { Type } from "core/utils"; import { Type } from "core/utils";
import { querySchema } from "data"; import { type Entity, querySchema } from "data";
import { Fragment } from "react";
import { TbDots } from "react-icons/tb"; import { TbDots } from "react-icons/tb";
import { useApiQuery } from "ui/client"; import { useApi, useApiQuery } from "ui/client";
import { useBknd } from "ui/client/bknd";
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";
@@ -11,6 +13,7 @@ import { useBrowserTitle } from "ui/hooks/use-browser-title";
import { useSearch } from "ui/hooks/use-search"; import { useSearch } from "ui/hooks/use-search";
import * as AppShell from "ui/layouts/AppShell/AppShell"; import * as AppShell from "ui/layouts/AppShell/AppShell";
import { routes, useNavigate } from "ui/lib/routes"; import { routes, useNavigate } from "ui/lib/routes";
import { useCreateUserModal } from "ui/modules/auth/hooks/use-create-user-modal";
import { EntityTable2 } from "ui/modules/data/components/EntityTable2"; import { EntityTable2 } from "ui/modules/data/components/EntityTable2";
// @todo: migrate to Typebox // @todo: migrate to Typebox
@@ -29,7 +32,11 @@ const PER_PAGE_OPTIONS = [5, 10, 25];
export function DataEntityList({ params }) { export function DataEntityList({ params }) {
const { $data } = useBkndData(); const { $data } = useBkndData();
const entity = $data.entity(params.entity as string)!; const entity = $data.entity(params.entity as string);
if (!entity) {
return <Message.NotFound description={`Entity "${params.entity}" doesn't exist.`} />;
}
useBrowserTitle(["Data", entity?.label ?? params.entity]); useBrowserTitle(["Data", entity?.label ?? params.entity]);
const [navigate] = useNavigate(); const [navigate] = useNavigate();
const search = useSearch(searchSchema, { const search = useSearch(searchSchema, {
@@ -39,13 +46,14 @@ export function DataEntityList({ params }) {
const $q = useApiQuery( const $q = useApiQuery(
(api) => (api) =>
api.data.readMany(entity.name, { api.data.readMany(entity?.name as any, {
select: search.value.select, select: search.value.select,
limit: search.value.perPage, limit: search.value.perPage,
offset: (search.value.page - 1) * search.value.perPage, offset: (search.value.page - 1) * search.value.perPage,
sort: search.value.sort sort: search.value.sort
}), }),
{ {
enabled: !!entity,
revalidateOnFocus: true, revalidateOnFocus: true,
keepPreviousData: true keepPreviousData: true
} }
@@ -75,14 +83,10 @@ export function DataEntityList({ params }) {
search.set("perPage", perPage); search.set("perPage", perPage);
} }
if (!entity) {
return <Message.NotFound description={`Entity "${params.entity}" doesn't exist.`} />;
}
const isUpdating = $q.isLoading && $q.isValidating; const isUpdating = $q.isLoading && $q.isValidating;
return ( return (
<> <Fragment key={entity.name}>
<AppShell.SectionHeader <AppShell.SectionHeader
right={ right={
<> <>
@@ -100,14 +104,7 @@ export function DataEntityList({ params }) {
> >
<IconButton Icon={TbDots} /> <IconButton Icon={TbDots} />
</Dropdown> </Dropdown>
<Button <EntityCreateButton entity={entity} />
onClick={() => {
navigate(routes.data.entity.create(entity.name));
}}
variant="primary"
>
Create new
</Button>
</> </>
} }
> >
@@ -140,6 +137,40 @@ export function DataEntityList({ params }) {
</div> </div>
</div> </div>
</AppShell.Scrollable> </AppShell.Scrollable>
</> </Fragment>
);
}
function EntityCreateButton({ entity }: { entity: Entity }) {
const b = useBknd();
const createUserModal = useCreateUserModal();
const [navigate] = useNavigate();
if (!entity) return null;
if (entity.type !== "regular") {
const system = {
users: b.app.config.auth.entity_name,
media: b.app.config.media.entity_name
};
if (system.users === entity.name) {
return (
<Button onClick={createUserModal.open} variant="primary">
New User
</Button>
);
}
return null;
}
return (
<Button
onClick={() => {
navigate(routes.data.entity.create(entity.name));
}}
variant="primary"
>
Create new
</Button>
); );
} }