Remove the closed people pages, keep their user form in settings

This commit is contained in:
2026-10-19 17:43:07 +01:00
parent b9488623fc
commit 9293a55032
37 changed files with 10 additions and 791 deletions
@@ -0,0 +1,64 @@
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
import type useFormInit from "@/src/hooks/use-form-init";
import type { UserFormObject } from "@/src/types";
import type { BUN_SQLITE_COBBLE_USERS_JOIN } from "@/src/types/sql-joins";
type Params = ReturnType<typeof useFormInit<UserFormObject>> & {
onSaved?: (params: { person: BUN_SQLITE_COBBLE_USERS_JOIN; msg: string }) => void;
};
/**
* Saves the user, with a newly picked photo, through `users/save-user`,
* which checks every field and user type. Hands the saved user to
* `onSaved`, else reloads or opens their page.
*/
export default async function submitUserForm({
form,
image,
existing_full,
loading,
setLoading,
setStatus,
onSaved,
}: Params) {
if (loading) return;
if ((form.password || "") != (form.confirm_password || "")) {
setStatus({ error: true, msg: "The passwords don't match." });
return;
}
if (!window.confirm(existing_full?.id ? `Save changes to ${form.first_name || "this user"}?` : `Add ${form.first_name || "this user"}?`)) return;
setLoading(true);
const res = await postAdminApi<BUN_SQLITE_COBBLE_USERS_JOIN>({
path: "users/save-user",
body: {
user_id: existing_full?.id || null,
first_name: form.first_name,
last_name: form.last_name,
email: form.email,
username: form.username,
password: form.password,
confirm_password: form.confirm_password,
user_types: form.user_types,
image: image?.imageBase64Full,
},
});
setLoading(false);
if (!res.success || !res.singleRes?.id) {
setStatus({ error: true, msg: res.msg || "Couldn't save the user." });
return;
}
if (onSaved) {
onSaved({ person: res.singleRes, msg: res.msg || "Saved." });
} else if (existing_full?.id) {
window.location.reload();
} else {
window.location.pathname = `/admin/settings/users/${res.singleRes.id}`;
}
}
@@ -1,6 +1,6 @@
import AddModalShell from "@/src/layouts/bots/(partials)/add-modal-shell";
import type { BUN_SQLITE_COBBLE_USERS_JOIN } from "@/src/types/sql-joins";
import UserForm from "@/src/pages/admin/people/create/(partials)/user-form";
import UserForm from "./user-form";
type Props = {
open: boolean;
@@ -0,0 +1,77 @@
import Form from "@moduletrace/twui/form/Form";
import Row from "@moduletrace/twui/layout/Row";
import useFormInit from "@/src/hooks/use-form-init";
import { UneditableUserTypes } from "@/src/dict/user-types-dict";
import type { UserFormObject } from "@/src/types";
import type { BUN_SQLITE_COBBLE_USERS_JOIN } from "@/src/types/sql-joins";
import parseUserTypes from "@/src/utils/parse-user-types";
import submitUserForm from "@/src/pages/admin/settings/users/(functions)/submit-user-form";
import UserFormName from "./user-form-name";
import UserFormAction from "./user-form-action";
import UserFormEmail from "./user-form-email";
import UserFormUsername from "./user-form-username";
import UserFormUserTypes from "./user-form-user-types";
import UserFormImage from "./user-form-image";
import UserFormPassword from "./user-form-password";
type Props = {
/**
* Edits this user; adds one when not given.
*/
person?: BUN_SQLITE_COBBLE_USERS_JOIN;
/**
* Called with the saved user instead of reloading the page.
*/
onSaved?: (params: { person: BUN_SQLITE_COBBLE_USERS_JOIN; msg: string }) => void;
onCancel?: () => void;
/**
* On a settings page: no Cancel, and the save updates in place.
*/
embedded?: boolean;
};
/**
* A user's photo, name, email, username, user types and password, saved
* in one go through `users/save-user`. What they reach is set on their
* settings page.
*/
export default function UserForm({
person,
onSaved,
onCancel,
embedded,
}: Props) {
const init = useFormInit<UserFormObject>({
existing: person
? {
first_name: person.first_name,
last_name: person.last_name,
email: person.email,
username: person.username,
user_types: parseUserTypes({ user_types: person.user_types }).filter(
(type) => !UneditableUserTypes.includes(type),
),
}
: undefined,
existing_full: person ? { id: person.id } : undefined,
default: { user_types: ["member"] },
});
return (
<Form className="w-full gap-4! items-stretch pt-4" onSubmit={() => submitUserForm({ ...init, onSaved })}>
<UserFormImage {...init} person={person} />
<UserFormName {...init} />
<Row className="w-full grid sm:grid-cols-2 gap-4!">
<UserFormEmail {...init} />
<UserFormUsername {...init} />
</Row>
<UserFormUserTypes {...init} person={person} />
<UserFormPassword {...init} />
<UserFormAction
{...init}
onCancel={onCancel}
hide_cancel={embedded}
/>
</Form>
);
}
@@ -0,0 +1,32 @@
import useFormInit from "@/src/hooks/use-form-init";
import AddFormActions from "@/src/layouts/bots/(partials)/add-form-actions";
import type { UserFormObject } from "@/src/types";
type Props = ReturnType<typeof useFormInit<UserFormObject>> & {
onCancel?: () => void;
/**
* On a settings page, where there is nothing to cancel into.
*/
hide_cancel?: boolean;
};
export default function UserFormAction({
existing_full,
loading,
status,
onCancel,
hide_cancel,
}: Props) {
return (
<AddFormActions
submit_label={existing_full?.id ? "Save" : "Add user"}
loading={loading}
error={status?.error ? status.msg : undefined}
onCancel={
hide_cancel
? undefined
: onCancel || (() => window.history.back())
}
/>
);
}
@@ -0,0 +1,28 @@
import type { BUN_SQLITE_COBBLE_USERS } from "@/db/types/db";
import Input from "@moduletrace/twui/form/Input";
import useFormInit from "@/src/hooks/use-form-init";
export default function UserFormEmail({
setForm,
form,
}: ReturnType<typeof useFormInit<BUN_SQLITE_COBBLE_USERS>>) {
return (
<Input
id="user-form-email"
title="Email"
label="Email"
type="email"
placeholder="[email protected]"
autoComplete="off"
onChange={(e) => {
setForm((prev) => ({
...prev,
email: e.target.value,
}));
}}
defaultValue={form.email}
showLabel
required
/>
);
}
@@ -0,0 +1,27 @@
import ImageUpload from "@moduletrace/twui/form/ImageUpload";
import grabMediaURL from "@/src/functions/backend/media/grab-media-url";
import useFormInit from "@/src/hooks/use-form-init";
import type { UserFormObject } from "@/src/types";
import type { BUN_SQLITE_COBBLE_USERS_JOIN } from "@/src/types/sql-joins";
type Props = ReturnType<typeof useFormInit<UserFormObject>> & {
person?: BUN_SQLITE_COBBLE_USERS_JOIN;
};
/**
* Their profile photo, saved with the rest of the form.
*/
export default function UserFormImage({ person, setImage }: Props) {
const user_image_url = person?.profile_media_id
? grabMediaURL({ media: { id: person.profile_media_id } }).media_url
: undefined;
return (
<ImageUpload
label="Photo"
existingImageUrl={user_image_url}
onChangeHandler={(imgData) => setImage(imgData)}
restoreImageFn={() => setImage(undefined)}
/>
);
}
@@ -0,0 +1,46 @@
import type { BUN_SQLITE_COBBLE_USERS } from "@/db/types/db";
import Input from "@moduletrace/twui/form/Input";
import Row from "@moduletrace/twui/layout/Row";
import useFormInit from "@/src/hooks/use-form-init";
export default function UserFormName({
setForm,
form,
}: ReturnType<typeof useFormInit<BUN_SQLITE_COBBLE_USERS>>) {
return (
<Row className="w-full grid sm:grid-cols-2 gap-4!">
<Input
id="user-form-first-name"
title="First name"
label="First name"
placeholder="Ada"
onChange={(e) => {
setForm((prev) => ({
...prev,
first_name: e.target.value,
}));
}}
defaultValue={form.first_name}
autoComplete="off"
showLabel
required
/>
<Input
id="user-form-last-name"
title="Last name"
label="Last name"
placeholder="Lovelace"
onChange={(e) => {
setForm((prev) => ({
...prev,
last_name: e.target.value,
}));
}}
defaultValue={form.last_name}
autoComplete="off"
showLabel
/>
</Row>
);
}
@@ -0,0 +1,45 @@
import Input from "@moduletrace/twui/form/Input";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import { AppData } from "@/src/data/app-data";
import useFormInit from "@/src/hooks/use-form-init";
import type { UserFormObject } from "@/src/types";
/**
* The password, then the same again to confirm it. Editing someone, both
* may stay empty to keep theirs.
*/
export default function UserFormPassword({ setForm, existing_full }: ReturnType<typeof useFormInit<UserFormObject>>) {
const optional = Boolean(existing_full?.id);
return (
<Stack className="w-full gap-3! items-stretch pt-2 border-0 border-t border-solid border-hairline">
<Stack className="gap-0.5!">
<Span className="text-sm font-bold leading-5 text-ink">{optional ? "New password" : "Password"}</Span>
<Span className="text-[13px] leading-[18px] text-ink-secondary">
{`At least ${AppData.PasswordMinLength} characters.${optional ? " Leave both empty to keep theirs." : " They sign in with it."}`}
</Span>
</Stack>
<Input
id="user-form-password"
title="Password"
label="Password"
showLabel
type="password"
placeholder={optional ? "Leave empty to keep it" : "A strong password"}
autoComplete="new-password"
onChange={(e) => setForm((prev) => ({ ...prev, password: e.target.value }))}
/>
<Input
id="user-form-confirm-password"
title="Confirm password"
label="Confirm password"
showLabel
type="password"
placeholder="The same password again"
autoComplete="new-password"
onChange={(e) => setForm((prev) => ({ ...prev, confirm_password: e.target.value }))}
/>
</Stack>
);
}
@@ -0,0 +1,53 @@
import SearchSelect from "@moduletrace/twui/form/SearchSelect";
import { UneditableUserTypes, UserTypes } from "@/src/dict/user-types-dict";
import useFormInit from "@/src/hooks/use-form-init";
import type { UserFormObject, UserType } from "@/src/types";
import type { BUN_SQLITE_COBBLE_USERS_JOIN } from "@/src/types/sql-joins";
import parseUserTypes from "@/src/utils/parse-user-types";
import filterAssignableUserTypes from "@/src/utils/rbac/filter-assignable-user-types";
type Props = ReturnType<typeof useFormInit<UserFormObject>> & {
person?: BUN_SQLITE_COBBLE_USERS_JOIN;
};
/**
* Picks a user's types, offering only those the session user may grant
* (never `super_admin`). The super admin's types aren't shown: they never
* change here.
*/
export default function UserFormUserTypes({
setForm,
form,
person,
app_context,
}: Props) {
if (parseUserTypes({ user_types: person?.user_types }).includes("super_admin")) {
return null;
}
const assignable = filterAssignableUserTypes({
actor_user_types: app_context.pageProps?.user_types,
user_types: UserTypes.map((type) => type.value).filter((type) => !UneditableUserTypes.includes(type)),
});
return (
<SearchSelect
id="user-form-user-types"
title="User types"
label="User types"
showLabel
multiple
placeholder="Pick their user types"
options={UserTypes.filter((type) => assignable.includes(type.value)).map((type) => ({
value: type.value,
title: type.title,
}))}
defaultValues={form.user_types || []}
info="At least one, or they can't sign in."
changeHandler={(value) => {
const user_types = (Array.isArray(value) ? value : value ? [value] : []) as UserType[];
setForm((prev) => ({ ...prev, user_types }));
}}
/>
);
}
@@ -0,0 +1,28 @@
import type { BUN_SQLITE_COBBLE_USERS } from "@/db/types/db";
import Input from "@moduletrace/twui/form/Input";
import useFormInit from "@/src/hooks/use-form-init";
export default function UserFormUsername({
setForm,
form,
}: ReturnType<typeof useFormInit<BUN_SQLITE_COBBLE_USERS>>) {
return (
<Input
id="user-form-username"
title="Username"
label="Username"
placeholder="ada"
autoComplete="off"
info="Lowercase letters, numbers, dots, dashes or underscores."
onChange={(e) => {
setForm((prev) => ({
...prev,
username: e.target.value,
}));
}}
defaultValue={form.username}
showLabel
required
/>
);
}
@@ -1,6 +1,6 @@
import { useState } from "react";
import type { BUN_SQLITE_COBBLE_USERS_JOIN } from "@/src/types/sql-joins";
import UserForm from "@/src/pages/admin/people/create/(partials)/user-form";
import UserForm from "../../(partials)/user-form";
import SettingsGroup from "../../../(partials)/settings-group";
import SettingsStatus from "../../../(partials)/settings-status";