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:
@@ -35,5 +35,5 @@ export default function useSettingsList<T extends { id?: number | string | "" }>
|
||||
setStatus({ success: true, msg: res.msg || "Deleted." });
|
||||
};
|
||||
|
||||
return { items, setItems, busy, status, setStatus, remove };
|
||||
return { items, setItems, busy, setBusy, status, setStatus, remove };
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ export default function PasswordForm({ has_password: initial }: Props) {
|
||||
className="w-full gap-0! items-stretch"
|
||||
onSubmit={() => {
|
||||
if (!dirty || loading) return;
|
||||
if (!window.confirm(has_password ? `Change your password?` : `Set your password?`)) return;
|
||||
submitPasswordForm({ ...init, has_password, onDone });
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -42,6 +42,7 @@ export default function EnvForm({ scope, env, known, onSaved, onCancel }: Props)
|
||||
|
||||
const submit = async () => {
|
||||
if (loading) return;
|
||||
if (!window.confirm(env?.id ? `Save ${env.key}?` : `Add ${key || "this env"}?`)) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(undefined);
|
||||
|
||||
@@ -56,6 +56,7 @@ export default function HarnessSettingsForm({
|
||||
|
||||
const submit = async () => {
|
||||
if (!dirty || loading) return;
|
||||
if (!window.confirm(`Save these model settings?`)) return;
|
||||
|
||||
setLoading(true);
|
||||
setStatus({});
|
||||
|
||||
@@ -44,6 +44,7 @@ export default function ProviderForm({ provider, onSaved, onCancel }: Props) {
|
||||
|
||||
const submit = async () => {
|
||||
if (loading) return;
|
||||
if (!window.confirm(provider?.id ? `Save ${provider.name || "this provider"}?` : `Add ${name || "this provider"}?`)) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(undefined);
|
||||
|
||||
@@ -62,6 +62,7 @@ export default function ProfileForm({ person }: Props) {
|
||||
className="w-full gap-0! items-stretch"
|
||||
onSubmit={() => {
|
||||
if (!dirty || loading) return;
|
||||
if (!window.confirm(`Save your profile?`)) return;
|
||||
submitProfileForm({
|
||||
...init,
|
||||
onSaved: ({ saved, photo_src, msg }) => {
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
|
||||
import type { BUN_SQLITE_COBBLE_USERS_JOIN } from "@/src/types/sql-joins";
|
||||
import parseUserTypes from "@/src/utils/parse-user-types";
|
||||
|
||||
type Params = {
|
||||
person: BUN_SQLITE_COBBLE_USERS_JOIN;
|
||||
};
|
||||
|
||||
/**
|
||||
* Adds `revoked` to a user's types through `users/save-user`, as picking
|
||||
* Revoked in their edit form would. Everything else stays as it is.
|
||||
*/
|
||||
export default async function revokeUser({ person }: Params) {
|
||||
return await postAdminApi<BUN_SQLITE_COBBLE_USERS_JOIN>({
|
||||
path: "users/save-user",
|
||||
body: {
|
||||
user_id: person.id,
|
||||
first_name: person.first_name,
|
||||
last_name: person.last_name || "",
|
||||
email: person.email,
|
||||
username: person.username,
|
||||
user_types: [...parseUserTypes({ user_types: person.user_types }), "revoked"],
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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[])
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -10,21 +10,49 @@ import SettingsList from "../../(partials)/settings-list";
|
||||
import SettingsStatus from "../../(partials)/settings-status";
|
||||
import UserEditModal from "../(partials)/user-edit-modal";
|
||||
import UserSettingsRow from "../(partials)/user-settings-row";
|
||||
import revokeUser from "../(functions)/revoke-user";
|
||||
|
||||
/**
|
||||
* Everyone who can sign in (for super admins and admins): add a user
|
||||
* with a password, edit one (name, email, username, a new password, user
|
||||
* types), or delete one with everything that's theirs. What each person
|
||||
* with a password, edit one (photo, name, email, username, a new
|
||||
* password, user types), revoke one, or delete one with everything
|
||||
* that's theirs. What each person
|
||||
* may do here is the RBAC helpers' call.
|
||||
*/
|
||||
export default function UsersSettingsSection() {
|
||||
const { pageProps } = useContext(AppContext);
|
||||
const { items, setItems, busy, status, setStatus, remove } = useSettingsList({ items: pageProps?.persons || [] });
|
||||
const { items, setItems, busy, setBusy, status, setStatus, remove } = useSettingsList({
|
||||
items: pageProps?.persons || [],
|
||||
});
|
||||
/**
|
||||
* The user being edited, or `true` while adding one.
|
||||
*/
|
||||
const [editing, setEditing] = useState<BUN_SQLITE_COBBLE_USERS_JOIN | true>();
|
||||
|
||||
const savePerson = (person: BUN_SQLITE_COBBLE_USERS_JOIN) =>
|
||||
setItems((prev) =>
|
||||
prev.some((p) => p.id == person.id) ? prev.map((p) => (p.id == person.id ? person : p)) : [...prev, person],
|
||||
);
|
||||
|
||||
const revoke = async (person: BUN_SQLITE_COBBLE_USERS_JOIN) => {
|
||||
if (busy || !window.confirm(`Revoke ${person.first_name || "this user"}? They can't sign in until Revoked is removed from their user types. Their crew and files are kept.`)) return;
|
||||
|
||||
setBusy(true);
|
||||
setStatus(undefined);
|
||||
|
||||
const res = await revokeUser({ person });
|
||||
|
||||
setBusy(false);
|
||||
|
||||
if (!res.success || !res.singleRes?.id) {
|
||||
setStatus({ error: true, msg: res.msg || "Couldn't revoke them." });
|
||||
return;
|
||||
}
|
||||
|
||||
savePerson(res.singleRes);
|
||||
setStatus({ success: true, msg: `${person.first_name || "They"} can't sign in now.` });
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsGroup
|
||||
title="People"
|
||||
@@ -39,6 +67,7 @@ export default function UsersSettingsSection() {
|
||||
actor_user_types={pageProps?.user_types}
|
||||
busy={busy}
|
||||
onEdit={() => setEditing(person)}
|
||||
onRevoke={() => revoke(person)}
|
||||
onDelete={() =>
|
||||
remove({
|
||||
id: person.id,
|
||||
@@ -70,11 +99,7 @@ export default function UsersSettingsSection() {
|
||||
open={Boolean(editing)}
|
||||
onClose={() => setEditing(undefined)}
|
||||
onSaved={({ person, msg }) => {
|
||||
setItems((prev) =>
|
||||
prev.some((p) => p.id == person.id)
|
||||
? prev.map((p) => (p.id == person.id ? person : p))
|
||||
: [...prev, person],
|
||||
);
|
||||
savePerson(person);
|
||||
setEditing(undefined);
|
||||
setStatus({ success: true, msg });
|
||||
}}
|
||||
|
||||
Reference in New Issue
Block a user