Share one user form, add user photos and Revoke, confirm every form

- Settings > Users uses the people UserForm (useFormInit) instead of its
  own copy; it saves in one request through users/save-user, which now
  takes an optional profile photo (setUserProfileImage)
- Revoke action on user rows adds the revoked type, as the edit form would
- Every admin form asks for confirmation before it saves

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-19 11:03:57 +01:00
co-authored by Claude Opus 5.5
parent c44048dbab
commit 4dd34ed251
35 changed files with 352 additions and 535 deletions
@@ -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 "./user-form";
import UserForm from "@/src/pages/admin/people/create/(partials)/user-form";
type Props = {
/**
@@ -1,141 +0,0 @@
import Form from "@moduletrace/twui/form/Form";
import Input from "@moduletrace/twui/form/Input";
import Row from "@moduletrace/twui/layout/Row";
import { useContext, useState } from "react";
import { UneditableUserTypes } from "@/src/dict/user-types-dict";
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
import AddFormActions from "@/src/layouts/bots/(partials)/add-form-actions";
import type { BUN_SQLITE_COBBLE_USERS_JOIN } from "@/src/types/sql-joins";
import { AppContext } from "@/src/pages/__root";
import type { UserType } from "@/src/types";
import parseUserTypes from "@/src/utils/parse-user-types";
import UserPasswordFields from "./user-password-fields";
import UserTypesSelect from "./user-types-select";
type Props = {
/**
* Edits this user; adds one when not given.
*/
person?: BUN_SQLITE_COBBLE_USERS_JOIN;
onCancel: () => void;
onSaved: (params: { person: BUN_SQLITE_COBBLE_USERS_JOIN; msg: string }) => void;
};
/**
* A user's name, email, username, password (twice, to confirm) and user
* types. Uneditable types (`super_admin`) aren't offered, and the super
* admin's types aren't shown at all: they never change here.
*/
export default function UserForm({ person, onCancel, onSaved }: Props) {
const { pageProps } = useContext(AppContext);
const current_types = parseUserTypes({ user_types: person?.user_types });
const is_super_admin = current_types.includes("super_admin");
const [first_name, setFirstName] = useState(person?.first_name || "");
const [last_name, setLastName] = useState(person?.last_name || "");
const [email, setEmail] = useState(person?.email || "");
const [username, setUsername] = useState(person?.username || "");
const [password, setPassword] = useState("");
const [confirm_password, setConfirmPassword] = useState("");
const [user_types, setUserTypes] = useState<UserType[]>(
person ? current_types.filter((type) => !UneditableUserTypes.includes(type)) : ["member"],
);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string>();
const submit = async () => {
if (loading) return;
if (password != confirm_password) {
setError("The passwords don't match.");
return;
}
setLoading(true);
setError(undefined);
const res = await postAdminApi<BUN_SQLITE_COBBLE_USERS_JOIN>({
path: "users/save-user",
body: {
user_id: person?.id || null,
first_name,
last_name,
email,
username,
password,
confirm_password,
user_types: is_super_admin ? [] : user_types,
},
});
setLoading(false);
if (!res.success || !res.singleRes?.id) {
setError(res.msg || "Couldn't save the user.");
return;
}
onSaved({ person: res.singleRes, msg: res.msg || "Saved." });
};
return (
<Form className="w-full gap-4! items-stretch pt-4" onSubmit={submit}>
<Row className="w-full gap-4! flex-nowrap items-start">
<Input
id="user-form-first-name"
title="First name"
label="First name"
showLabel
placeholder="Ada"
autoComplete="off"
defaultValue={first_name}
wrapperWrapperProps={{ className: "grow min-w-0" }}
onChange={(e) => setFirstName(e.target.value)}
/>
<Input
id="user-form-last-name"
title="Last name"
label="Last name"
showLabel
placeholder="Lovelace"
autoComplete="off"
defaultValue={last_name}
wrapperWrapperProps={{ className: "grow min-w-0" }}
onChange={(e) => setLastName(e.target.value)}
/>
</Row>
<Input
id="user-form-email"
title="Email"
label="Email"
showLabel
type="email"
placeholder="[email protected]"
autoComplete="off"
defaultValue={email}
onChange={(e) => setEmail(e.target.value)}
/>
<Input
id="user-form-username"
title="Username"
label="Username"
showLabel
placeholder="ada"
autoComplete="off"
defaultValue={username}
info="Lowercase letters, numbers, dots, dashes or underscores."
onChange={(e) => setUsername(e.target.value)}
/>
{is_super_admin ? null : (
<UserTypesSelect actor_user_types={pageProps?.user_types} value={user_types} onChange={setUserTypes} />
)}
<UserPasswordFields optional={Boolean(person)} onPassword={setPassword} onConfirm={setConfirmPassword} />
<AddFormActions
submit_label={person ? "Save" : "Add user"}
loading={loading}
error={error}
onCancel={onCancel}
/>
</Form>
);
}
@@ -1,50 +0,0 @@
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";
type Props = {
/**
* Editing someone: both may stay empty to keep their password.
*/
optional?: boolean;
onPassword: (password: string) => void;
onConfirm: (password: string) => void;
};
/**
* The password section of the user form: the password, then the same
* again to confirm it.
*/
export default function UserPasswordFields({ optional, onPassword, onConfirm }: Props) {
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) => onPassword(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) => onConfirm(e.target.value)}
/>
</Stack>
);
}
@@ -14,13 +14,14 @@ type Props = {
actor_user_types?: BUN_SQLITE_COBBLE_USER_TYPES[] | null;
busy?: boolean;
onEdit: () => void;
onRevoke: () => void;
onDelete: () => void;
};
/**
* One user: their photo, name, user types and email, then Edit and
* Delete where the actor may (`canUpdateUser`, `canRevokeUser`; never
* deleting yourself).
* One user: their photo, name, user types and email, then Edit, Revoke
* (not once revoked) and Delete where the actor may (`canUpdateUser`,
* `canRevokeUser`; never revoking or deleting yourself).
*/
export default function UserSettingsRow({
person,
@@ -28,6 +29,7 @@ export default function UserSettingsRow({
actor_user_types,
busy,
onEdit,
onRevoke,
onDelete,
}: Props) {
const name =
@@ -42,6 +44,7 @@ export default function UserSettingsRow({
const target_user_types = user_types.map((user_type) => ({ user_type }));
const can_edit = canUpdateUser({ actor_user_types, actor_user_id, target_user_id: person.id, target_user_types });
const can_delete = !is_self && canRevokeUser({ user_types: actor_user_types, target_user_types });
const can_revoke = can_delete && !user_types.includes("revoked");
return (
<SettingsListRow
@@ -58,6 +61,14 @@ export default function UserSettingsRow({
onClick={onEdit}
/>
) : null}
{can_revoke ? (
<SettingsRowAction
title={`Revoke ${name}`}
label="Revoke"
disabled={busy}
onClick={onRevoke}
/>
) : null}
{can_delete ? (
<SettingsRowAction
title={`Delete ${name}`}
@@ -1,45 +0,0 @@
import SearchSelect from "@moduletrace/twui/form/SearchSelect";
import type { BUN_SQLITE_COBBLE_USER_TYPES } from "@/db/types/db";
import { UneditableUserTypes, UserTypes } from "@/src/dict/user-types-dict";
import type { UserType } from "@/src/types";
import filterAssignableUserTypes from "@/src/utils/rbac/filter-assignable-user-types";
type Props = {
/**
* The session user's types: only what they may grant is offered.
*/
actor_user_types?: BUN_SQLITE_COBBLE_USER_TYPES[] | null;
value: UserType[];
onChange: (user_types: UserType[]) => void;
};
/**
* Picks a user's types from `UserTypes`, leaving out the uneditable ones
* (`super_admin`) and any the assignment matrix keeps from the actor.
*/
export default function UserTypesSelect({ actor_user_types, value, onChange }: Props) {
const assignable = filterAssignableUserTypes({
actor_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={value}
info="At least one, or they can't sign in."
changeHandler={(next) =>
onChange((Array.isArray(next) ? next : next ? [next] : []) as UserType[])
}
/>
);
}