From b61634e261b82a5a2d5f4e391346bd23d1a5e034 Mon Sep 17 00:00:00 2001 From: dswbx Date: Fri, 17 Jan 2025 10:19:26 +0100 Subject: [PATCH] introduced auth strategy actions to allow user creation in UI --- app/src/auth/AppAuth.ts | 21 ++--- app/src/auth/api/AuthApi.ts | 33 +++++++- app/src/auth/api/AuthController.ts | 78 ++++++++++++++++++- app/src/auth/auth-permissions.ts | 4 + app/src/auth/authenticate/Authenticator.ts | 39 +++++++++- .../strategies/PasswordStrategy.ts | 34 +++++--- app/src/auth/index.ts | 2 + app/src/modules/Module.ts | 22 +++++- app/src/modules/ModuleApi.ts | 7 ++ app/src/ui/client/api/use-api.ts | 17 ++-- app/src/ui/client/api/use-entity.ts | 9 --- app/src/ui/components/display/Message.tsx | 4 +- .../form/json-schema/JsonSchemaForm.tsx | 8 +- app/src/ui/modals/debug/OverlayModal.tsx | 22 ++++++ app/src/ui/modals/debug/SchemaFormModal.tsx | 74 ++++++++++++------ app/src/ui/modals/index.tsx | 19 +++-- .../auth/hooks/use-create-user-modal.ts | 53 +++++++++++++ .../modules/data/components/EntityTable2.tsx | 6 +- .../ui/routes/auth/auth.roles.edit.$role.tsx | 11 +-- app/src/ui/routes/auth/auth.roles.tsx | 9 ++- app/src/ui/routes/data/data.$entity.$id.tsx | 18 ++++- .../ui/routes/data/data.$entity.create.tsx | 15 +++- app/src/ui/routes/data/data.$entity.index.tsx | 67 +++++++++++----- 23 files changed, 464 insertions(+), 108 deletions(-) create mode 100644 app/src/auth/auth-permissions.ts create mode 100644 app/src/ui/modals/debug/OverlayModal.tsx create mode 100644 app/src/ui/modules/auth/hooks/use-create-user-modal.ts diff --git a/app/src/auth/AppAuth.ts b/app/src/auth/AppAuth.ts index dfbe1f3..14a0dea 100644 --- a/app/src/auth/AppAuth.ts +++ b/app/src/auth/AppAuth.ts @@ -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 { auth } from "auth/middlewares"; import { type DB, Exception, type PrimaryFieldType } from "core"; 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 { Hono } from "hono"; import { pick } from "lodash-es"; import { Module } from "modules/Module"; import { AuthController } from "./api/AuthController"; @@ -79,8 +84,8 @@ export class AppAuth extends Module { super.setBuilt(); this._controller = new AuthController(this); - //this.ctx.server.use(controller.getMiddleware); this.ctx.server.route(this.config.basepath, this._controller.getController()); + this.ctx.guard.registerPermissions(Object.values(AuthPermissions)); } get controller(): AuthController { @@ -260,14 +265,12 @@ export class AppAuth extends Module { try { const roles = Object.keys(this.config.roles ?? {}); - const field = make("role", enumm({ enum: roles })); - users.__replaceField("role", field); + this.replaceEntityField(users, "role", enumm({ enum: roles })); } catch (e) {} try { const strategies = Object.keys(this.config.strategies ?? {}); - const field = make("strategy", enumm({ enum: strategies })); - users.__replaceField("strategy", field); + this.replaceEntityField(users, "strategy", enumm({ enum: strategies })); } catch (e) {} } diff --git a/app/src/auth/api/AuthApi.ts b/app/src/auth/api/AuthApi.ts index 7b43d6d..869103c 100644 --- a/app/src/auth/api/AuthApi.ts +++ b/app/src/auth/api/AuthApi.ts @@ -1,3 +1,4 @@ +import type { AuthActionResponse } from "auth/api/AuthController"; import type { AppAuthSchema, AppAuthStrategies } from "auth/auth-schema"; import type { AuthResponse, SafeUser, Strategy } from "auth/authenticate/Authenticator"; import { type BaseModuleApiOptions, ModuleApi } from "modules/ModuleApi"; @@ -13,22 +14,46 @@ export class AuthApi extends ModuleApi { }; } - async loginWithPassword(input: any) { - const res = await this.post(["password", "login"], input); + async login(strategy: string, input: any) { + const res = await this.post([strategy, "login"], input); if (res.ok && res.body.token) { await this.options.onTokenUpdate?.(res.body.token); } return res; } - async registerWithPassword(input: any) { - const res = await this.post(["password", "register"], input); + async register(strategy: string, input: any) { + const res = await this.post([strategy, "register"], input); if (res.ok && res.body.token) { await this.options.onTokenUpdate?.(res.body.token); } return res; } + async actionSchema(strategy: string, action: string) { + return this.get([strategy, "actions", action, "schema.json"]); + } + + async action(strategy: string, action: string, input: any) { + return this.post([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() { return this.get<{ user: SafeUser | null }>(["me"]); } diff --git a/app/src/auth/api/AuthController.ts b/app/src/auth/api/AuthController.ts index 553c477..265d8bc 100644 --- a/app/src/auth/api/AuthController.ts +++ b/app/src/auth/api/AuthController.ts @@ -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 type { ServerEnv } from "modules/Module"; + +export type AuthActionResponse = { + success: boolean; + action: string; + data?: SafeUser; + errors?: any; +}; export class AuthController extends Controller { constructor(private auth: AppAuth) { @@ -10,6 +21,70 @@ export class AuthController extends Controller { return this.auth.ctx.guard; } + private registerStrategyActions(strategy: Strategy, mainHono: Hono) { + 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() { const { auth } = this.middlewares; const hono = this.create(); @@ -18,6 +93,7 @@ export class AuthController extends Controller { for (const [name, strategy] of Object.entries(strategies)) { //console.log("registering", name, "at", `/${name}`); hono.route(`/${name}`, strategy.getController(this.auth.authenticator)); + this.registerStrategyActions(strategy, hono); } hono.get("/me", auth(), async (c) => { diff --git a/app/src/auth/auth-permissions.ts b/app/src/auth/auth-permissions.ts new file mode 100644 index 0000000..ed71992 --- /dev/null +++ b/app/src/auth/auth-permissions.ts @@ -0,0 +1,4 @@ +import { Permission } from "core"; + +export const createUser = new Permission("auth.user.create"); +//export const updateUser = new Permission("auth.user.update"); diff --git a/app/src/auth/authenticate/Authenticator.ts b/app/src/auth/authenticate/Authenticator.ts index 0dc479d..7b81ed7 100644 --- a/app/src/auth/authenticate/Authenticator.ts +++ b/app/src/auth/authenticate/Authenticator.ts @@ -1,6 +1,14 @@ -import { Exception } from "core"; +import { type DB, Exception } from "core"; 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 { deleteCookie, getSignedCookie, setSignedCookie } from "hono/cookie"; import { sign, verify } from "hono/jwt"; @@ -10,6 +18,14 @@ import type { ServerEnv } from "modules/Module"; type Input = any; // workaround export type JWTPayload = Parameters[0]; +export const strategyActions = ["create", "change"] as const; +export type StrategyActionName = (typeof strategyActions)[number]; +export type StrategyAction = { + schema: S; + preprocess: (input: unknown) => Promise>; +}; +export type StrategyActions = Partial>; + // @todo: add schema to interface to ensure proper inference export interface Strategy { getController: (auth: Authenticator) => Hono; @@ -17,6 +33,7 @@ export interface Strategy { getMode: () => "form" | "external"; getName: () => string; toJSON: (secrets?: boolean) => any; + getActions?: () => StrategyActions; } export type User = { @@ -274,6 +291,14 @@ export class Authenticator = Record< 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) { const p = (this.config.cookie.pathSuccess ?? "/").replace(/\/+$/, "/"); @@ -338,3 +363,13 @@ export class Authenticator = Record< }; } } + +export function createStrategyAction( + schema: S, + preprocess: (input: Static) => Promise> +) { + return { + schema, + preprocess + } as StrategyAction; +} diff --git a/app/src/auth/authenticate/strategies/PasswordStrategy.ts b/app/src/auth/authenticate/strategies/PasswordStrategy.ts index ef940d7..d8f8a23 100644 --- a/app/src/auth/authenticate/strategies/PasswordStrategy.ts +++ b/app/src/auth/authenticate/strategies/PasswordStrategy.ts @@ -2,6 +2,7 @@ import type { Authenticator, Strategy } from "auth"; import { type Static, StringEnum, Type, parse } from "core/utils"; import { hash } from "core/utils"; 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 RegisterSchema = { email: string; password: string; [key: string]: any }; @@ -54,17 +55,9 @@ export class PasswordStrategy implements Strategy { getController(authenticator: Authenticator): 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 .post("/login", async (c) => { - const body = await getBody(c); + const body = await authenticator.getBody(c); try { const payload = await this.login(body); @@ -76,7 +69,7 @@ export class PasswordStrategy implements Strategy { } }) .post("/register", async (c) => { - const body = await getBody(c); + const body = await authenticator.getBody(c); const payload = await this.register(body); 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() { return schema; } diff --git a/app/src/auth/index.ts b/app/src/auth/index.ts index fbb47fb..11c3367 100644 --- a/app/src/auth/index.ts +++ b/app/src/auth/index.ts @@ -19,3 +19,5 @@ export { AppAuth, type UserFieldSchema } from "./AppAuth"; export { Guard, type GuardUserContext, type GuardConfig } from "./authorize/Guard"; export { Role } from "./authorize/Role"; + +export * as AuthPermissions from "./auth-permissions"; diff --git a/app/src/modules/Module.ts b/app/src/modules/Module.ts index 838e964..546db48 100644 --- a/app/src/modules/Module.ts +++ b/app/src/modules/Module.ts @@ -3,7 +3,15 @@ import type { Guard } from "auth"; import { SchemaObject } from "core"; import type { EventManager } from "core/events"; 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 type { Hono } from "hono"; @@ -184,4 +192,16 @@ export abstract class Module { +export const useInvalidate = (options?: { exact?: boolean }) => { const mutate = useSWRConfig().mutate; const api = useApi(); - return async (arg?: string | ((api: Api) => FetchPromise)) => { - if (!arg) return async () => mutate(""); - return mutate(typeof arg === "string" ? arg : arg(api).key()); + return async (arg?: string | ((api: Api) => FetchPromise | ModuleApi)) => { + let 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)); }; }; diff --git a/app/src/ui/client/api/use-entity.ts b/app/src/ui/client/api/use-entity.ts index 9b721c1..85a44bb 100644 --- a/app/src/ui/client/api/use-entity.ts +++ b/app/src/ui/client/api/use-entity.ts @@ -22,15 +22,6 @@ export class UseEntityApiError extends Error { } } -function Test() { - const { read } = useEntity("users"); - async () => { - const data = await read(); - }; - - return null; -} - export const useEntity = < Entity extends keyof DB | string, Id extends PrimaryFieldType | undefined = undefined, diff --git a/app/src/ui/components/display/Message.tsx b/app/src/ui/components/display/Message.tsx index 34069dd..da44346 100644 --- a/app/src/ui/components/display/Message.tsx +++ b/app/src/ui/components/display/Message.tsx @@ -1,7 +1,9 @@ import { Empty, type EmptyProps } from "./Empty"; const NotFound = (props: Partial) => ; +const NotAllowed = (props: Partial) => ; export const Message = { - NotFound + NotFound, + NotAllowed }; diff --git a/app/src/ui/components/form/json-schema/JsonSchemaForm.tsx b/app/src/ui/components/form/json-schema/JsonSchemaForm.tsx index d722dde..8b79f70 100644 --- a/app/src/ui/components/form/json-schema/JsonSchemaForm.tsx +++ b/app/src/ui/components/form/json-schema/JsonSchemaForm.tsx @@ -15,12 +15,13 @@ export type JsonSchemaFormProps = any & { schema: RJSFSchema | Schema; uiSchema?: any; direction?: "horizontal" | "vertical"; - onChange?: (value: any) => void; + onChange?: (value: any, isValid: () => boolean) => void; }; export type JsonSchemaFormRef = { formData: () => any; validateForm: () => boolean; + silentValidate: () => boolean; cancel: () => void; }; @@ -52,15 +53,18 @@ export const JsonSchemaForm = forwardRef const handleChange = ({ formData }: any, e) => { const clean = JSON.parse(JSON.stringify(formData)); //console.log("Data changed: ", clean, JSON.stringify(formData, null, 2)); - onChange?.(clean); setValue(clean); + onChange?.(clean, () => isValid(clean)); }; + const isValid = (data: any) => validator.validateFormData(data, schema).errors.length === 0; + useImperativeHandle( ref, () => ({ formData: () => value, validateForm: () => formRef.current!.validateForm(), + silentValidate: () => isValid(value), cancel: () => formRef.current!.reset() }), [value] diff --git a/app/src/ui/modals/debug/OverlayModal.tsx b/app/src/ui/modals/debug/OverlayModal.tsx new file mode 100644 index 0000000..2dca9a3 --- /dev/null +++ b/app/src/ui/modals/debug/OverlayModal.tsx @@ -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" + } +}; diff --git a/app/src/ui/modals/debug/SchemaFormModal.tsx b/app/src/ui/modals/debug/SchemaFormModal.tsx index 72c1c89..fd9c304 100644 --- a/app/src/ui/modals/debug/SchemaFormModal.tsx +++ b/app/src/ui/modals/debug/SchemaFormModal.tsx @@ -7,21 +7,31 @@ import { } from "ui/components/form/json-schema"; import type { ContextModalProps } from "@mantine/modals"; +import { Alert } from "ui/components/display/Alert"; type Props = JsonSchemaFormProps & { - onSubmit?: (data: any) => void | Promise; + autoCloseAfterSubmit?: boolean; + onSubmit?: ( + data: any, + context: { + close: () => void; + } + ) => void | Promise; }; export function SchemaFormModal({ context, id, - innerProps: { schema, uiSchema, onSubmit } + innerProps: { schema, uiSchema, onSubmit, autoCloseAfterSubmit } }: ContextModalProps) { const [valid, setValid] = useState(false); const formRef = useRef(null); + const [submitting, setSubmitting] = useState(false); + const was_submitted = useRef(false); + const [error, setError] = useState(); - function handleChange(data) { - const valid = formRef.current?.validateForm() ?? false; + function handleChange(data, isValid) { + const valid = isValid(); console.log("Data changed", data, valid); setValid(valid); } @@ -30,29 +40,45 @@ export function SchemaFormModal({ context.closeModal(id); } - async function handleClickAdd() { - await onSubmit?.(formRef.current?.formData()); - handleClose(); + async function handleSubmit() { + was_submitted.current = true; + if (!formRef.current?.validateForm()) { + return; + } + + setSubmitting(true); + await onSubmit?.(formRef.current?.formData(), { + close: handleClose, + setError + }); + setSubmitting(false); + + if (autoCloseAfterSubmit !== false) { + handleClose(); + } } return ( -
- -
- - + <> + {error && } +
+ +
+ + +
-
+ ); } @@ -63,7 +89,7 @@ SchemaFormModal.modalProps = { root: "bknd-admin", header: "!bg-primary/5 border-b border-b-muted !py-3 px-5 !h-auto !min-h-px", content: "rounded-lg select-none", - title: "font-bold !text-md", + title: "!font-bold !text-md", body: "!p-0" } }; diff --git a/app/src/ui/modals/index.tsx b/app/src/ui/modals/index.tsx index 7a69560..3ea2143 100644 --- a/app/src/ui/modals/index.tsx +++ b/app/src/ui/modals/index.tsx @@ -1,7 +1,8 @@ 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 type { ComponentProps } from "react"; +import { OverlayModal } from "ui/modals/debug/OverlayModal"; import { DebugModal } from "./debug/DebugModal"; import { SchemaFormModal } from "./debug/SchemaFormModal"; import { TestModal } from "./debug/TestModal"; @@ -9,7 +10,8 @@ import { TestModal } from "./debug/TestModal"; const modals = { test: TestModal, debug: DebugModal, - form: SchemaFormModal + form: SchemaFormModal, + overlay: OverlayModal }; declare module "@mantine/modals" { @@ -33,17 +35,22 @@ function open( ) { const title = _title ?? modals[modal].defaultTitle ?? undefined; const cmpModalProps = modals[modal].modalProps ?? {}; - return mantineModals.openContextModal({ + const props = { title, ...modalProps, ...cmpModalProps, modal, innerProps - }); + }; + openContextModal(props); + return { + close: () => close(modal), + closeAll: $modals.closeAll + }; } function close(modal: Modal) { - return mantineModals.close(modal); + return closeModal(modal); } export const bkndModals = { @@ -53,5 +60,5 @@ export const bkndModals = { >, open, close, - closeAll: mantineModals.closeAll + closeAll: $modals.closeAll }; diff --git a/app/src/ui/modules/auth/hooks/use-create-user-modal.ts b/app/src/ui/modules/auth/hooks/use-create-user-modal.ts new file mode 100644 index 0000000..ce08b6b --- /dev/null +++ b/app/src/ui/modules/auth/hooks/use-create-user-modal.ts @@ -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 }; +} diff --git a/app/src/ui/modules/data/components/EntityTable2.tsx b/app/src/ui/modules/data/components/EntityTable2.tsx index 05cbcb4..b9f516c 100644 --- a/app/src/ui/modules/data/components/EntityTable2.tsx +++ b/app/src/ui/modules/data/components/EntityTable2.tsx @@ -34,7 +34,11 @@ export function EntityTable2({ entity, select, ...props }: EntityTableProps) { const field = getField(property)!; _value = field.getValue(value, "table"); } 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 ; diff --git a/app/src/ui/routes/auth/auth.roles.edit.$role.tsx b/app/src/ui/routes/auth/auth.roles.edit.$role.tsx index 6500bc3..cb7de81 100644 --- a/app/src/ui/routes/auth/auth.roles.edit.$role.tsx +++ b/app/src/ui/routes/auth/auth.roles.edit.$role.tsx @@ -28,14 +28,9 @@ function AuthRolesEditInternal({ params }) { if (!formRef.current?.isValid()) return; const data = formRef.current?.getData(); const success = await actions.roles.patch(roleName, data); - - /*notifications.show({ - id: `role-${roleName}-update`, - position: "top-right", - title: success ? "Update success" : "Update failed", - message: success ? "Role updated successfully" : "Failed to update role", - color: !success ? "red" : undefined - });*/ + if (success) { + navigate(routes.auth.roles.list()); + } } async function handleDelete() { diff --git a/app/src/ui/routes/auth/auth.roles.tsx b/app/src/ui/routes/auth/auth.roles.tsx index c240fe0..616856a 100644 --- a/app/src/ui/routes/auth/auth.roles.tsx +++ b/app/src/ui/routes/auth/auth.roles.tsx @@ -90,9 +90,16 @@ const renderValue = ({ value, property }) => { } if (property === "permissions") { + const max = 3; + let permissions = value || []; + const count = permissions.length; + if (count > max) { + permissions = [...permissions.slice(0, max), `+${count - max}`]; + } + return (
- {[...(value || [])].map((p, i) => ( + {permissions.map((p, i) => ( ; + } + const entityId = Number.parseInt(params.id as string); const [error, setError] = useState(null); const [navigate] = useNavigate(); @@ -36,7 +41,8 @@ export function DataEntityUpdate({ params }) { with: local_relation_refs }, { - revalidateOnFocus: false + revalidateOnFocus: false, + shouldRetryOnError: false } ); @@ -81,6 +87,14 @@ export function DataEntityUpdate({ params }) { onSubmitted }); + if (!data && !$q.isLoading) { + return ( + + ); + } + const makeKey = (key: string | number = "") => `${params.entity.name}_${entityId}_${String(key)}`; diff --git a/app/src/ui/routes/data/data.$entity.create.tsx b/app/src/ui/routes/data/data.$entity.create.tsx index 5b16b64..be37370 100644 --- a/app/src/ui/routes/data/data.$entity.create.tsx +++ b/app/src/ui/routes/data/data.$entity.create.tsx @@ -2,8 +2,9 @@ import { Type } from "core/utils"; import type { EntityData } from "data"; import { useState } from "react"; 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 { Message } from "ui/components/display/Message"; import { useBrowserTitle } from "ui/hooks/use-browser-title"; import { useSearch } from "ui/hooks/use-search"; 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"; export function DataEntityCreate({ params }) { - const { app } = useBknd(); - const entity = app.entity(params.entity as string)!; + const { $data } = useBkndData(); + const entity = $data.entity(params.entity as string); + if (!entity) { + return ; + } else if (entity.type !== "regular") { + return ; + } + const [error, setError] = useState(null); useBrowserTitle(["Data", entity.label, "Create"]); @@ -43,7 +50,7 @@ export function DataEntityCreate({ params }) { const { Form, handleSubmit } = useEntityForm({ action: "create", - entity, + entity: entity, initialData: search.value, onSubmitted }); diff --git a/app/src/ui/routes/data/data.$entity.index.tsx b/app/src/ui/routes/data/data.$entity.index.tsx index 831e5ff..e92a21f 100644 --- a/app/src/ui/routes/data/data.$entity.index.tsx +++ b/app/src/ui/routes/data/data.$entity.index.tsx @@ -1,7 +1,9 @@ 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 { 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 { Button } from "ui/components/buttons/Button"; 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 * as AppShell from "ui/layouts/AppShell/AppShell"; 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"; // @todo: migrate to Typebox @@ -29,7 +32,11 @@ const PER_PAGE_OPTIONS = [5, 10, 25]; export function DataEntityList({ params }) { const { $data } = useBkndData(); - const entity = $data.entity(params.entity as string)!; + const entity = $data.entity(params.entity as string); + if (!entity) { + return ; + } + useBrowserTitle(["Data", entity?.label ?? params.entity]); const [navigate] = useNavigate(); const search = useSearch(searchSchema, { @@ -39,13 +46,14 @@ export function DataEntityList({ params }) { const $q = useApiQuery( (api) => - api.data.readMany(entity.name, { + api.data.readMany(entity?.name as any, { select: search.value.select, limit: search.value.perPage, offset: (search.value.page - 1) * search.value.perPage, sort: search.value.sort }), { + enabled: !!entity, revalidateOnFocus: true, keepPreviousData: true } @@ -75,14 +83,10 @@ export function DataEntityList({ params }) { search.set("perPage", perPage); } - if (!entity) { - return ; - } - const isUpdating = $q.isLoading && $q.isValidating; return ( - <> + @@ -100,14 +104,7 @@ export function DataEntityList({ params }) { > - + } > @@ -140,6 +137,40 @@ export function DataEntityList({ params }) {
- + + ); +} + +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 ( + + ); + } + + return null; + } + + return ( + ); }