reworked admin auth to use form and cookie + adjusted oauth to support API and cookie-based auth

This commit is contained in:
dswbx
2024-11-23 18:12:19 +01:00
parent f70e2b2e10
commit 824ff40133
30 changed files with 630 additions and 483 deletions

View File

@@ -1,15 +1,19 @@
import { MantineProvider } from "@mantine/core";
import { Notifications } from "@mantine/notifications";
import React from "react";
import { FlashMessage } from "ui/modules/server/FlashMessage";
import { BkndProvider, ClientProvider, useBknd } from "./client";
import { createMantineTheme } from "./lib/mantine/theme";
import { BkndModalsProvider } from "./modals";
import { Routes } from "./routes";
export default function Admin({
baseUrl: baseUrlOverride,
withProvider = false
}: { baseUrl?: string; withProvider?: boolean }) {
export type BkndAdminProps = {
baseUrl?: string;
withProvider?: boolean;
// @todo: add admin config override
};
export default function Admin({ baseUrl: baseUrlOverride, withProvider = false }: BkndAdminProps) {
const Component = (
<BkndProvider>
<AdminInternal />
@@ -25,9 +29,11 @@ export default function Admin({
function AdminInternal() {
const b = useBknd();
const theme = b.app.getAdminConfig().color_scheme;
return (
<MantineProvider {...createMantineTheme(theme ?? "light")}>
<Notifications />
<FlashMessage />
<BkndModalsProvider>
<Routes />
</BkndModalsProvider>

View File

@@ -40,7 +40,7 @@ export function BkndProvider({
if (!res.ok) {
if (errorShown.current) return;
errorShown.current = true;
notifications.show({
/*notifications.show({
title: "Failed to fetch schema",
// @ts-ignore
message: body.error,
@@ -48,7 +48,7 @@ export function BkndProvider({
position: "top-right",
autoClose: false,
withCloseButton: true
});
});*/
}
const schema = res.ok

View File

@@ -1,5 +1,6 @@
import { type NotificationData, notifications } from "@mantine/notifications";
import type { ModuleConfigs } from "../../../modules";
import { ucFirst } from "core/utils";
import type { ApiResponse, ModuleConfigs } from "../../../modules";
import type { AppQueryClient } from "../utils/AppQueryClient";
export type SchemaActionsProps = {
@@ -10,25 +11,53 @@ export type SchemaActionsProps = {
export type TSchemaActions = ReturnType<typeof getSchemaActions>;
export function getSchemaActions({ client, setSchema }: SchemaActionsProps) {
const baseUrl = client.baseUrl;
const token = client.auth().state()?.token;
const api = client.api;
async function displayError(action: string, module: string, res: Response, path?: string) {
const notification_data: NotificationData = {
id: "schema-error-" + [action, module, path].join("-"),
title: `Config update failed${path ? ": " + path : ""}`,
message: "Failed to complete config update",
color: "red",
async function handleConfigUpdate(
action: string,
module: string,
res: ApiResponse,
path?: string
): Promise<boolean> {
const base: Partial<NotificationData> = {
id: "schema-" + [action, module, path].join("-"),
position: "top-right",
withCloseButton: true,
autoClose: false
autoClose: 3000
};
try {
const { error } = (await res.json()) as any;
notifications.show({ ...notification_data, message: error });
} catch (e) {
notifications.show(notification_data);
if (res.res.ok && res.body.success) {
console.log("update config", action, module, path, res.body);
if (res.body.success) {
setSchema((prev) => {
if (!prev) return prev;
return {
...prev,
config: {
...prev.config,
[module]: res.body.config
}
};
});
}
notifications.show({
...base,
title: `Config updated: ${ucFirst(module)}`,
color: "blue",
message: `Operation ${action.toUpperCase()} at ${module}${path ? "." + path : ""}`
});
return true;
}
notifications.show({
...base,
title: `Config Update failed: ${ucFirst(module)}${path ? "." + path : ""}`,
color: "red",
withCloseButton: true,
autoClose: false,
message: res.body.error ?? "Failed to complete config update"
});
return false;
}
return {
@@ -37,183 +66,39 @@ export function getSchemaActions({ client, setSchema }: SchemaActionsProps) {
value: ModuleConfigs[Module],
force?: boolean
) => {
const res = await fetch(
`${baseUrl}/api/system/config/set/${module}?force=${force ? 1 : 0}`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`
},
body: JSON.stringify(value)
}
);
if (res.ok) {
const data = (await res.json()) as any;
console.log("update config set", module, data);
if (data.success) {
setSchema((prev) => {
if (!prev) return prev;
return {
...prev,
config: {
...prev.config,
[module]: data.config
}
};
});
}
return data.success;
} else {
await displayError("set", module, res);
}
return false;
const res = await api.system.setConfig(module, value, force);
return await handleConfigUpdate("set", module, res);
},
patch: async <Module extends keyof ModuleConfigs>(
module: keyof ModuleConfigs,
path: string,
value: any
): Promise<boolean> => {
const res = await fetch(`${baseUrl}/api/system/config/patch/${module}/${path}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`
},
body: JSON.stringify(value)
});
if (res.ok) {
const data = (await res.json()) as any;
console.log("update config patch", module, path, data);
if (data.success) {
setSchema((prev) => {
if (!prev) return prev;
return {
...prev,
config: {
...prev.config,
[module]: data.config
}
};
});
}
return data.success;
} else {
await displayError("patch", module, res, path);
}
return false;
const res = await api.system.patchConfig(module, path, value);
return await handleConfigUpdate("patch", module, res, path);
},
overwrite: async <Module extends keyof ModuleConfigs>(
module: keyof ModuleConfigs,
path: string,
value: any
) => {
const res = await fetch(`${baseUrl}/api/system/config/overwrite/${module}/${path}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`
},
body: JSON.stringify(value)
});
if (res.ok) {
const data = (await res.json()) as any;
console.log("update config overwrite", module, path, data);
if (data.success) {
setSchema((prev) => {
if (!prev) return prev;
return {
...prev,
config: {
...prev.config,
[module]: data.config
}
};
});
}
return data.success;
} else {
await displayError("overwrite", module, res, path);
}
return false;
const res = await api.system.overwriteConfig(module, path, value);
return await handleConfigUpdate("overwrite", module, res, path);
},
add: async <Module extends keyof ModuleConfigs>(
module: keyof ModuleConfigs,
path: string,
value: any
) => {
const res = await fetch(`${baseUrl}/api/system/config/add/${module}/${path}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`
},
body: JSON.stringify(value)
});
if (res.ok) {
const data = (await res.json()) as any;
console.log("update config add", module, data);
if (data.success) {
setSchema((prev) => {
if (!prev) return prev;
return {
...prev,
config: {
...prev.config,
[module]: data.config
}
};
});
}
return data.success;
} else {
await displayError("add", module, res, path);
}
return false;
const res = await api.system.addConfig(module, path, value);
return await handleConfigUpdate("add", module, res, path);
},
remove: async <Module extends keyof ModuleConfigs>(
module: keyof ModuleConfigs,
path: string
) => {
const res = await fetch(`${baseUrl}/api/system/config/remove/${module}/${path}`, {
method: "DELETE",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`
}
});
if (res.ok) {
const data = (await res.json()) as any;
console.log("update config remove", module, data);
if (data.success) {
setSchema((prev) => {
if (!prev) return prev;
return {
...prev,
config: {
...prev.config,
[module]: data.config
}
};
});
}
return data.success;
} else {
await displayError("remove", module, res, path);
}
return false;
const res = await api.system.removeConfig(module, path);
return await handleConfigUpdate("remove", module, res, path);
}
};
}

View File

@@ -89,14 +89,13 @@ export const useAuthStrategies = (options?: { baseUrl?: string }): Partial<AuthS
const [data, setData] = useState<AuthStrategyData>();
const ctxBaseUrl = useBaseUrl();
const api = new Api({
host: options?.baseUrl ? options?.baseUrl : ctxBaseUrl,
tokenStorage: "localStorage"
host: options?.baseUrl ? options?.baseUrl : ctxBaseUrl
});
useEffect(() => {
(async () => {
const res = await api.auth.strategies();
console.log("res", res);
//console.log("res", res);
if (res.res.ok) {
setData(res.body);
}

View File

@@ -19,6 +19,7 @@ export function useBkndAuth() {
if (window.confirm(`Are you sure you want to delete the role "${name}"?`)) {
return await bkndActions.remove("auth", `roles.${name}`);
}
return false;
}
}
};

View File

@@ -15,8 +15,7 @@ export class AppQueryClient {
api: Api;
constructor(public baseUrl: string) {
this.api = new Api({
host: baseUrl,
tokenStorage: "localStorage"
host: baseUrl
});
}

View File

@@ -14,22 +14,30 @@ const Base: React.FC<AlertProps> = ({ visible = true, title, message, className,
{...props}
className={twMerge("flex flex-row dark:bg-amber-300/20 bg-amber-200 p-4", className)}
>
<div>
{title && <b className="mr-2">{title}:</b>}
{message}
</div>
{title && <b className="mr-2">{title}:</b>}
{message}
</div>
) : null;
const Warning: React.FC<AlertProps> = (props) => (
<Base {...props} className="dark:bg-amber-300/20 bg-amber-200" />
const Warning: React.FC<AlertProps> = ({ className, ...props }) => (
<Base {...props} className={twMerge("dark:bg-amber-300/20 bg-amber-200", className)} />
);
const Exception: React.FC<AlertProps> = (props) => (
<Base {...props} className="dark:bg-red-950 bg-red-100" />
const Exception: React.FC<AlertProps> = ({ className, ...props }) => (
<Base {...props} className={twMerge("dark:bg-red-950 bg-red-100", className)} />
);
const Success: React.FC<AlertProps> = ({ className, ...props }) => (
<Base {...props} className={twMerge("dark:bg-green-950 bg-green-100", className)} />
);
const Info: React.FC<AlertProps> = ({ className, ...props }) => (
<Base {...props} className={twMerge("dark:bg-blue-950 bg-blue-100", className)} />
);
export const Alert = {
Warning,
Exception
Exception,
Success,
Info
};

View File

@@ -86,7 +86,7 @@ export function DataTable<Data extends Record<string, any> = Record<string, any>
</div>
)}
<div className="border-muted border rounded-md shadow-sm w-full max-w-full overflow-x-scroll overflow-y-hidden">
<table className="w-full">
<table className="w-full text-md">
{select.length > 0 ? (
<thead className="sticky top-0 bg-muted/10">
<tr>

View File

@@ -150,7 +150,8 @@ function UserMenu() {
async function handleLogout() {
await auth.logout();
navigate("/auth/login", { replace: true });
// @todo: grab from somewhere constant
window.location.href = "/auth/logout";
}
async function handleLogin() {

View File

@@ -1,117 +1,55 @@
import { type FieldApi, useForm } from "@tanstack/react-form";
import { Type, type TypeInvalidError, parse } from "core/utils";
import { Button } from "ui/components/buttons/Button";
import { typeboxResolver } from "@hookform/resolvers/typebox";
import { Type } from "core/utils";
import type { ComponentPropsWithoutRef } from "react";
import { useForm } from "react-hook-form";
import { twMerge } from "tailwind-merge";
import { Button } from "ui";
import * as Formy from "ui/components/form/Formy";
type LoginFormProps = {
onSubmitted?: (value: { email: string; password: string }) => Promise<void>;
export type LoginFormProps = Omit<ComponentPropsWithoutRef<"form">, "onSubmit"> & {
className?: string;
formData?: any;
};
export function LoginForm({ onSubmitted }: LoginFormProps) {
const form = useForm({
defaultValues: {
email: "",
password: ""
},
onSubmit: async ({ value }) => {
onSubmitted?.(value);
},
defaultState: {
canSubmit: false,
isValid: false
},
validatorAdapter: () => {
function validate(
{ value, fieldApi }: { value: any; fieldApi: FieldApi<any, any> },
fn: any
): any {
if (fieldApi.form.state.submissionAttempts === 0) return;
const schema = Type.Object({
email: Type.String({
pattern: "^[\\w-\\.]+@([\\w-]+\\.)+[\\w-]{2,4}$"
}),
password: Type.String({
minLength: 8 // @todo: this should be configurable
})
});
try {
parse(fn, value);
} catch (e) {
return (e as TypeInvalidError).errors
.map((error) => error.schema.error ?? error.message)
.join(", ");
}
}
return { validate, validateAsync: validate };
}
export function LoginForm({ formData, className, method = "POST", ...props }: LoginFormProps) {
const {
register,
formState: { isValid, errors }
} = useForm({
mode: "onChange",
defaultValues: formData,
resolver: typeboxResolver(schema)
});
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
e.stopPropagation();
void form.handleSubmit();
}
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-3 w-full" noValidate>
<form.Field
name="email"
validators={{
onChange: Type.String({
pattern: "^[\\w-\\.]+@([\\w-]+\\.)+[\\w-]{2,4}$"
})
}}
children={(field) => (
<Formy.Group error={field.state.meta.errors.length > 0}>
<Formy.Label htmlFor={field.name}>Email address</Formy.Label>
<Formy.Input
type="email"
id={field.name}
name={field.name}
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
/>
</Formy.Group>
)}
/>
<form.Field
name="password"
validators={{
onChange: Type.String({
minLength: 8
})
}}
children={(field) => (
<Formy.Group error={field.state.meta.errors.length > 0}>
<Formy.Label htmlFor={field.name}>Password</Formy.Label>
<Formy.Input
type="password"
id={field.name}
name={field.name}
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
/>
</Formy.Group>
)}
/>
<form.Subscribe
selector={(state) => {
//console.log("state", state, Object.values(state.fieldMeta));
const fieldMeta = Object.values(state.fieldMeta).map((f) => f.isDirty);
const allDirty = fieldMeta.length > 0 ? fieldMeta.reduce((a, b) => a && b) : false;
return [allDirty, state.isSubmitting];
}}
children={([allDirty, isSubmitting]) => {
return (
<Button
type="submit"
variant="primary"
size="large"
className="w-full mt-2 justify-center"
disabled={!allDirty || isSubmitting}
>
Sign in
</Button>
);
}}
/>
<form {...props} method={method} className={twMerge("flex flex-col gap-3 w-full", className)}>
<Formy.Group>
<Formy.Label htmlFor="email">Email address</Formy.Label>
<Formy.Input type="email" {...register("email")} />
</Formy.Group>
<Formy.Group>
<Formy.Label htmlFor="password">Password</Formy.Label>
<Formy.Input type="password" {...register("password")} />
</Formy.Group>
<Button
type="submit"
variant="primary"
size="large"
className="w-full mt-2 justify-center"
disabled={!isValid}
>
Sign in
</Button>
</form>
);
}

View 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;
}

View File

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

View File

@@ -29,13 +29,13 @@ function AuthRolesEditInternal({ params }) {
const data = formRef.current?.getData();
const success = await actions.roles.patch(roleName, data);
notifications.show({
/*notifications.show({
id: `role-${roleName}-update`,
position: "top-right",
title: success ? "Update success" : "Update failed",
message: success ? "Role updated successfully" : "Failed to update role",
color: !success ? "red" : undefined
});
});*/
}
async function handleDelete() {

View File

@@ -90,14 +90,18 @@ const renderValue = ({ value, property }) => {
}
if (property === "permissions") {
return [...(value || [])].map((p, i) => (
<span
key={i}
className="inline-block px-2 py-1.5 text-sm bg-primary/5 rounded font-mono leading-none"
>
{p}
</span>
));
return (
<div className="flex flex-row gap-1">
{[...(value || [])].map((p, i) => (
<span
key={i}
className="inline-block px-2 py-1.5 text-sm bg-primary/5 rounded font-mono leading-none"
>
{p}
</span>
))}
</div>
);
}
return <CellValue value={value} property={property} />;

View File

@@ -6,10 +6,9 @@ import {
} from "@tabler/icons-react";
import { isDebug } from "core";
import type { Entity } from "data";
import { cloneDeep, omit } from "lodash-es";
import { forwardRef, useImperativeHandle, useRef, useState } from "react";
import { TbArrowLeft, TbDots } from "react-icons/tb";
import { useBknd } from "ui/client";
import { cloneDeep } from "lodash-es";
import { useRef, useState } from "react";
import { TbDots } from "react-icons/tb";
import { useBkndData } from "ui/client/schema/data/use-bknd-data";
import { Button } from "ui/components/buttons/Button";
import { IconButton } from "ui/components/buttons/IconButton";
@@ -20,9 +19,8 @@ import {
} from "ui/components/form/json-schema/JsonSchemaForm";
import { Dropdown } from "ui/components/overlay/Dropdown";
import * as AppShell from "ui/layouts/AppShell/AppShell";
import { SectionHeaderAccordionItem } from "ui/layouts/AppShell/AppShell";
import { Breadcrumbs2 } from "ui/layouts/AppShell/Breadcrumbs2";
import { routes, useGoBack, useNavigate } from "ui/lib/routes";
import { routes, useNavigate } from "ui/lib/routes";
import { extractSchema } from "../settings/utils/schema";
import { EntityFieldsForm, type EntityFieldsFormRef } from "./forms/entity.fields.form";

View File

@@ -1,6 +1,6 @@
import { Suspense, lazy } from "react";
import { useBknd } from "ui/client";
import { Route, Router, Switch } from "wouter";
import { useBknd } from "../client/BkndProvider";
import { AuthLogin } from "./auth/auth.login";
import { Root, RootEmpty } from "./root";

View File

@@ -47,6 +47,10 @@ html.fixed, html.fixed body {
@mixin dark {
--mantine-color-body: rgb(9 9 11);
}
table {
font-size: inherit;
}
}
html, body {