public commit

This commit is contained in:
dswbx
2024-11-16 12:01:47 +01:00
commit 90f80c4280
582 changed files with 49291 additions and 0 deletions

View File

@@ -0,0 +1,60 @@
import { IconFingerprint } from "@tabler/icons-react";
import { TbSettings } from "react-icons/tb";
import { useBknd } from "ui/client";
import { IconButton } from "ui/components/buttons/IconButton";
import { Empty } from "ui/components/display/Empty";
import { Link } from "ui/components/wouter/Link";
import { useBrowserTitle } from "ui/hooks/use-browser-title";
import * as AppShell from "ui/layouts/AppShell/AppShell";
import { routes, useNavigate } from "ui/lib/routes";
export function AuthRoot({ children }) {
const { app, config } = useBknd();
const users_entity = config.auth.entity_name;
return (
<>
<AppShell.Sidebar>
<AppShell.SectionHeader
right={
<Link href={app.getSettingsPath(["auth"])}>
<IconButton Icon={TbSettings} />
</Link>
}
>
Auth
</AppShell.SectionHeader>
<AppShell.Scrollable initialOffset={96}>
<div className="flex flex-col flex-grow p-3 gap-3">
<nav className="flex flex-col flex-1 gap-1">
<AppShell.SidebarLink as={Link} href={"/"}>
Overview
</AppShell.SidebarLink>
<AppShell.SidebarLink
as={Link}
href={app.getAbsolutePath("/data/" + routes.data.entity.list(users_entity))}
>
Users
</AppShell.SidebarLink>
<AppShell.SidebarLink as={Link} href={routes.auth.roles.list()}>
Roles & Permissions
</AppShell.SidebarLink>
<AppShell.SidebarLink as={Link} href={routes.auth.strategies()} disabled>
Strategies
</AppShell.SidebarLink>
<AppShell.SidebarLink as={Link} href={routes.auth.settings()}>
Settings
</AppShell.SidebarLink>
</nav>
</div>
</AppShell.Scrollable>
</AppShell.Sidebar>
<AppShell.Main>{children}</AppShell.Main>
</>
);
}
export function AuthEmpty() {
useBrowserTitle(["Auth"]);
return <Empty Icon={IconFingerprint} title="Not implemented yet" />;
}

View File

@@ -0,0 +1,92 @@
import { useBknd, useClient } from "ui/client";
import { routes } from "ui/lib/routes";
import {
Button,
ButtonLink,
type ButtonLinkProps,
type ButtonProps
} from "../../components/buttons/Button";
import * as AppShell from "../../layouts/AppShell/AppShell";
export function AuthIndex() {
const client = useClient();
const { app, config } = useBknd();
const users_entity = config.auth.entity_name;
const query = client.query().data.entity("users").count();
const usersTotal = query.data?.body.count ?? 0;
const {
config: {
auth: { roles, strategies }
}
} = useBknd();
const rolesTotal = Object.keys(roles ?? {}).length ?? 0;
const strategiesTotal = Object.keys(strategies ?? {}).length ?? 0;
const usersLink = app.getAbsolutePath("/data/" + routes.data.entity.list(users_entity));
const rolesLink = routes.auth.roles.list();
const strategiesLink = app.getSettingsPath(["auth", "strategies"]);
return (
<>
<AppShell.SectionHeader>Overview</AppShell.SectionHeader>
<AppShell.Scrollable>
<div className="flex flex-col flex-grow p-3 gap-3">
<div className="grid xs:grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-5">
<KpiCard
title="Users registered"
value={usersTotal}
actions={[
{
label: "View all",
href: usersLink
},
{ label: "Add new", variant: "default", href: usersLink }
]}
/>
<KpiCard
title="Roles"
value={rolesTotal}
actions={[
{ label: "View all", href: rolesLink },
{ label: "Add new", variant: "default", href: rolesLink }
]}
/>
<KpiCard
title="Strategies enabled"
value={strategiesTotal}
actions={[
{ label: "View all", href: strategiesLink },
{ label: "Add new", variant: "default", href: strategiesLink }
]}
/>
</div>
</div>
</AppShell.Scrollable>
</>
);
}
type KpiCardProps = {
title: string;
value: number;
actions: (Omit<ButtonLinkProps, "href"> & { label: string; href?: string })[];
};
const KpiCard: React.FC<KpiCardProps> = ({ title, value, actions }) => (
<div className="flex flex-col border border-muted">
<div className="flex flex-col gap-2 px-5 pt-3.5 pb-4 border-b border-b-muted">
<div>
<span className="opacity-50">{title}</span>
{/*<span>+6.1%</span>*/}
</div>
<div className="text-4xl font-medium">{value}</div>
</div>
<div className="flex flex-row gap-3 p-3 justify-between">
{actions.map((action, i) => (
<ButtonLink key={i} size="small" variant="ghost" href="#" {...action}>
{action.label}
</ButtonLink>
))}
</div>
</div>
);

View File

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

View File

@@ -0,0 +1,90 @@
import { notifications } from "@mantine/notifications";
import { useRef } from "react";
import { TbDots } from "react-icons/tb";
import { useBknd } from "ui/client";
import { useBkndAuth } from "ui/client/schema/auth/use-bknd-auth";
import { Button } from "ui/components/buttons/Button";
import { IconButton } from "ui/components/buttons/IconButton";
import { Dropdown } from "ui/components/overlay/Dropdown";
import * as AppShell from "ui/layouts/AppShell/AppShell";
import { Breadcrumbs2 } from "ui/layouts/AppShell/Breadcrumbs2";
import { routes, useNavigate } from "ui/lib/routes";
import { AuthRoleForm, type AuthRoleFormRef } from "ui/routes/auth/forms/role.form";
export function AuthRolesEdit(props) {
useBknd({ withSecrets: true });
return <AuthRolesEditInternal {...props} />;
}
function AuthRolesEditInternal({ params }) {
const [navigate] = useNavigate();
const { config, actions } = useBkndAuth();
const roleName = params.role;
const role = config.roles?.[roleName];
const formRef = useRef<AuthRoleFormRef>(null);
async function handleUpdate() {
console.log("data", formRef.current?.isValid());
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
});
}
async function handleDelete() {
if (await actions.roles.delete(roleName)) {
navigate(routes.auth.roles.list());
}
}
return (
<>
<AppShell.SectionHeader
right={
<>
<Dropdown
items={[
{
label: "Advanced Settings",
onClick: () =>
navigate(routes.settings.path(["auth", "roles", roleName]), {
absolute: true
})
},
{
label: "Delete",
onClick: handleDelete,
destructive: true
}
]}
position="bottom-end"
>
<IconButton Icon={TbDots} />
</Dropdown>
<Button variant="primary" onClick={handleUpdate}>
Update
</Button>
</>
}
className="pl-3"
>
<Breadcrumbs2
path={[
{ label: "Roles & Permissions", href: routes.auth.roles.list() },
{ label: roleName }
]}
/>
</AppShell.SectionHeader>
<AppShell.Scrollable>
<AuthRoleForm ref={formRef} role={role} />
</AppShell.Scrollable>
</>
);
}

View File

@@ -0,0 +1,128 @@
import { Modal, TextInput } from "@mantine/core";
import { useDisclosure, useFocusTrap } from "@mantine/hooks";
import { StringIdentifier, transformObject, ucFirstAllSnakeToPascalWithSpaces } from "core/utils";
import { useRef } from "react";
import { useBkndAuth } from "ui/client/schema/auth/use-bknd-auth";
import { JsonSchemaForm } from "ui/components/form/json-schema/JsonSchemaForm";
import { bkndModals } from "ui/modals";
import { SchemaFormModal } from "ui/modals/debug/SchemaFormModal";
import { useBknd } from "../../client/BkndProvider";
import { Button } from "../../components/buttons/Button";
import { CellValue, DataTable } from "../../components/table/DataTable";
import * as AppShell from "../../layouts/AppShell/AppShell";
import { routes, useNavigate } from "../../lib/routes";
export function AuthRolesList() {
const [navigate] = useNavigate();
const [modalOpen, modalHandler] = useDisclosure(false);
const focusRef = useFocusTrap();
const inputRef = useRef<HTMLInputElement>(null);
const { config, actions } = useBkndAuth();
const data = Object.values(
transformObject(config.roles ?? {}, (role, name) => ({
role: name,
permissions: role.permissions,
is_default: role.is_default ?? false,
implicit_allow: role.implicit_allow ?? false
}))
);
function handleClick(row) {
navigate(routes.auth.roles.edit(row.role));
}
function openCreateModal() {
bkndModals.open(
"form",
{
schema: {
type: "object",
properties: {
name: StringIdentifier
},
required: ["name"]
},
uiSchema: {
name: {
"ui:title": "Role name"
}
},
onSubmit: async (data) => {
if (data.name.length > 0) {
if (await actions.roles.add(data.name)) {
navigate(routes.auth.roles.edit(data.name));
}
}
}
},
{
title: "New Role"
}
);
}
return (
<>
{/*<Modal
ref={focusRef}
opened={modalOpen}
onClose={modalHandler.close}
title={"New Role"}
classNames={{
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",
body: "pt-3 pb-3 px-3 gap-4 flex flex-col"
}}
>
<TextInput ref={inputRef} data-autofocus size="md" placeholder="Enter role name" />
<div className="flex flex-row justify-end gap-2">
<Button onClick={() => modalHandler.close()}>Cancel</Button>
<Button variant="primary" onClick={handleClickAdd}>
Create
</Button>
</div>
</Modal>*/}
<AppShell.SectionHeader
right={
<Button variant="primary" onClick={openCreateModal}>
Create new
</Button>
}
>
Roles & Permissions
</AppShell.SectionHeader>
<AppShell.Scrollable>
<div className="flex flex-col flex-grow p-3 gap-3">
<DataTable
data={data}
renderValue={renderValue}
renderHeader={ucFirstAllSnakeToPascalWithSpaces}
onClickRow={handleClick}
/>
</div>
</AppShell.Scrollable>
</>
);
}
const renderValue = ({ value, property }) => {
if (["is_default", "implicit_allow"].includes(property)) {
return value ? <span>Yes</span> : <span className="opacity-50">No</span>;
}
if (property === "permissions") {
return [...(value || [])].map((p, i) => (
<span
key={i}
className="inline-block px-2 py-1.5 text-sm bg-primary/5 rounded font-mono leading-none"
>
{p}
</span>
));
}
return <CellValue value={value} property={property} />;
};

View File

@@ -0,0 +1,79 @@
import { cloneDeep, omit } from "lodash-es";
import { useBknd } from "ui/client";
import { Button } from "ui/components/buttons/Button";
import { JsonSchemaForm } from "ui/components/form/json-schema/JsonSchemaForm";
import * as AppShell from "ui/layouts/AppShell/AppShell";
import { extractSchema } from "../settings/utils/schema";
export function AuthSettingsList() {
useBknd({ withSecrets: true });
return <AuthSettingsListInternal />;
}
const uiSchema = {
jwt: {
fields: {
"ui:options": {
orderable: false
}
}
}
};
function AuthSettingsListInternal() {
const s = useBknd();
const config = s.config.auth;
const schema = cloneDeep(omit(s.schema.auth, ["title"]));
const [generalSchema, generalConfig, extracted] = extractSchema(schema as any, config, [
"jwt",
"roles",
"guard",
"strategies"
]);
try {
const user_entity = config.entity_name ?? "users";
const entities = s.config.data.entities ?? {};
const user_fields = Object.entries(entities[user_entity]?.fields ?? {})
.map(([name, field]) => (!field.config?.virtual ? name : undefined))
.filter(Boolean);
if (user_fields) {
console.log("user_fields", user_fields);
extracted.jwt.schema.properties.fields.items.enum = user_fields;
extracted.jwt.schema.properties.fields.uniqueItems = true;
uiSchema.jwt.fields["ui:widget"] = "checkboxes";
}
} catch (e) {
console.error(e);
}
console.log({ generalSchema, generalConfig, extracted });
return (
<>
<AppShell.SectionHeader right={<Button variant="primary">Update</Button>}>
Settings
</AppShell.SectionHeader>
<AppShell.Scrollable>
<div className="flex flex-col flex-grow px-5 py-4 gap-8">
<div>
<JsonSchemaForm
schema={generalSchema}
className="legacy hide-required-mark fieldset-alternative mute-root"
/>
</div>
<div className="flex flex-col gap-3">
<h3 className="font-bold">JWT Settings</h3>
<JsonSchemaForm
schema={extracted.jwt.schema}
uiSchema={uiSchema.jwt}
className="legacy hide-required-mark fieldset-alternative mute-root"
/>
</div>
</div>
</AppShell.Scrollable>
</>
);
}
function AuthJwtSettings() {}

View File

@@ -0,0 +1,57 @@
import { cloneDeep, omit } from "lodash-es";
import { useBknd } from "ui/client";
import { Button } from "ui/components/buttons/Button";
import { JsonSchemaForm } from "ui/components/form/json-schema/JsonSchemaForm";
import * as AppShell from "../../layouts/AppShell/AppShell";
import { extractSchema } from "../settings/utils/schema";
export function AuthStrategiesList() {
useBknd({ withSecrets: true });
return <AuthStrategiesListInternal />;
}
const uiSchema = {
jwt: {
fields: {
"ui:options": {
orderable: false
}
}
}
};
function AuthStrategiesListInternal() {
const s = useBknd();
const config = s.config.auth.strategies;
const schema = cloneDeep(omit(s.schema.auth.properties.strategies, ["title"]));
console.log("strategies", { config, schema });
return (
<>
<AppShell.SectionHeader right={<Button variant="primary">Update</Button>}>
Strategies
</AppShell.SectionHeader>
<AppShell.Scrollable>
strat
{/*<div className="flex flex-col flex-grow px-5 py-4 gap-8">
<div>
<JsonSchemaForm
schema={generalSchema}
className="legacy hide-required-mark fieldset-alternative mute-root"
/>
</div>
<div className="flex flex-col gap-3">
<h3 className="font-bold">JWT Settings</h3>
<JsonSchemaForm
schema={extracted.jwt.schema}
uiSchema={uiSchema.jwt}
className="legacy hide-required-mark fieldset-alternative mute-root"
/>
</div>
</div>*/}
</AppShell.Scrollable>
</>
);
}

View File

@@ -0,0 +1,3 @@
export function AuthUsersList() {
return null;
}

View File

@@ -0,0 +1,152 @@
import { typeboxResolver } from "@hookform/resolvers/typebox";
import { Input, Switch, Tooltip } from "@mantine/core";
import { guardRoleSchema } from "auth/auth-schema";
import { type Static, ucFirst } from "core/utils";
import type { TAppDataEntityFields } from "data/data-schema";
import { forwardRef, useImperativeHandle } from "react";
import { type UseControllerProps, useController, useForm } from "react-hook-form";
import { Button, useBknd } from "ui";
import { MantineSwitch } from "ui/components/form/hook-form-mantine/MantineSwitch";
const schema = guardRoleSchema;
type Role = Static<typeof guardRoleSchema>;
export type AuthRoleFormRef = {
getData: () => Role;
isValid: () => boolean;
reset: () => void;
};
export const AuthRoleForm = forwardRef<
AuthRoleFormRef,
{
role?: Role;
debug?: boolean;
}
>(({ role, debug }, ref) => {
const { permissions } = useBknd();
const {
formState: { isValid },
watch,
control,
reset,
getValues
} = useForm({
resolver: typeboxResolver(schema),
defaultValues: role
});
useImperativeHandle(ref, () => ({
reset,
getData: () => getValues(),
isValid: () => isValid
}));
return (
<div className="flex flex-col flex-grow px-5 py-5 gap-8">
<div className="flex flex-col gap-2">
{/*<h3 className="font-semibold">Role Permissions</h3>*/}
<Permissions control={control} permissions={permissions} />
</div>
<div className="flex flex-col gap-4">
<Input.Wrapper
label="Should this role be the default?"
size="md"
description="In case an user is not assigned any role, this role will be assigned by default."
>
<div className="flex flex-row">
<MantineSwitch name="is_default" control={control} className="mt-2" />
</div>
</Input.Wrapper>
<Input.Wrapper
label="Implicit allow missing permissions?"
size="md"
description="This should be only used for admins. If a permission is not explicitly denied, it will be allowed."
>
<div className="flex flex-row">
<MantineSwitch name="implicit_allow" control={control} className="mt-2" />
</div>
</Input.Wrapper>
</div>
{debug && (
<div className="font-mono opacity-50">
<div>{JSON.stringify(role, null, 2)}</div>
<div>{JSON.stringify(watch(), null, 2)}</div>
</div>
)}
</div>
);
});
const Permissions = ({
control,
permissions
}: Omit<UseControllerProps, "name"> & { permissions: string[] }) => {
const {
field: { value, onChange: fieldOnChange, ...field },
fieldState
} = useController<Static<typeof schema>, "permissions">({
name: "permissions",
control
});
const data = value ?? [];
function handleChange(permission: string) {
return (e) => {
const checked = e.target.checked;
const newValue = checked ? [...data, permission] : data.filter((p) => p !== permission);
fieldOnChange(newValue);
};
}
const grouped = permissions.reduce(
(acc, permission) => {
const [group, name] = permission.split(".") as [string, string];
if (!acc[group]) acc[group] = [];
acc[group].push(permission);
return acc;
},
{} as Record<string, string[]>
);
console.log("grouped", grouped);
//console.log("fieldState", fieldState, value);
return (
<div className="flex flex-col gap-10">
{Object.entries(grouped).map(([group, permissions]) => {
return (
<div className="flex flex-col gap-2" key={group}>
<h3 className="font-semibold">{ucFirst(group)} Permissions</h3>
<div className="grid grid-cols-1 md:grid-cols-2 2xl:grid-cols-3 gap-3">
{permissions.map((permission) => {
const selected = data.includes(permission);
return (
<div key={permission} className="flex flex-col border border-muted">
<div className="flex flex-row gap-2 justify-between">
<div className="py-4 px-4 font-mono leading-none">
{permission}
</div>
<div className="flex flex-row gap-1 items-center px-2">
<Switch
checked={selected}
onChange={handleChange(permission)}
/>
<Tooltip label="Coming soon">
<Button size="small" variant="ghost" disabled>
<span className="font-normal italic font-mono">FX</span>
</Button>
</Tooltip>
</div>
</div>
</div>
);
})}
</div>
</div>
);
})}
</div>
);
};

View File

@@ -0,0 +1,21 @@
import { Route } from "wouter";
import { AuthRoot } from "./_auth.root";
import { AuthIndex } from "./auth.index";
import { AuthRolesList } from "./auth.roles";
import { AuthRolesEdit } from "./auth.roles.edit.$role";
import { AuthSettingsList } from "./auth.settings";
import { AuthStrategiesList } from "./auth.strategies";
import { AuthUsersList } from "./auth.users";
export default function AuthRoutes() {
return (
<AuthRoot>
<Route path="/" component={AuthIndex} />
<Route path="/users" component={AuthUsersList} />
<Route path="/roles" component={AuthRolesList} />
<Route path="/roles/edit/:role" component={AuthRolesEdit} />
<Route path="/strategies" component={AuthStrategiesList} />
<Route path="/settings" component={AuthSettingsList} />
</AuthRoot>
);
}