From 4dd34ed2510e2c9461ec6b1d5ec6e2910696a20d Mon Sep 17 00:00:00 2001 From: Benjamin Toby Date: Mon, 19 Oct 2026 11:03:57 +0100 Subject: [PATCH] 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 --- AGENTS.md | 2 +- .../db/users/set-user-profile-image.ts | 36 ++++ .../bots/(partials)/add-computer-form.tsx | 1 + .../bots/(partials)/add-workspace-form.tsx | 1 + src/layouts/bots/(partials)/bot-form.tsx | 1 + .../(sections)/edit-person-form-section.tsx | 25 +-- .../create/(functions)/submit-user-form.ts | 167 +++++------------- .../create/(partials)/user-form/index.tsx | 80 +++++---- .../(partials)/user-form/user-form-action.tsx | 23 +-- .../(partials)/user-form/user-form-bio.tsx | 31 ---- .../(partials)/user-form/user-form-email.tsx | 32 ++-- .../(partials)/user-form/user-form-image.tsx | 55 ++---- .../(partials)/user-form/user-form-name.tsx | 18 +- .../user-form/user-form-password.tsx} | 23 +-- .../user-form/user-form-user-types.tsx | 64 ++++--- .../user-form/user-form-username.tsx | 30 ++-- .../routines/(partials)/routine-form.tsx | 1 + .../settings/(hooks)/use-settings-list.ts | 2 +- .../(partials)/password-form/index.tsx | 1 + .../environment/(partials)/env-form.tsx | 1 + .../(partials)/harness-settings-form.tsx | 1 + .../models/(partials)/provider-form.tsx | 1 + .../profile/(partials)/profile-form.tsx | 1 + .../settings/users/(functions)/revoke-user.ts | 25 +++ .../users/(partials)/user-edit-modal.tsx | 2 +- .../settings/users/(partials)/user-form.tsx | 141 --------------- .../users/(partials)/user-settings-row.tsx | 17 +- .../users/(partials)/user-types-select.tsx | 45 ----- .../(sections)/users-settings-section.tsx | 41 ++++- .../(partials)/workspace-bot-modal.tsx | 1 + .../(partials)/workspace-details-group.tsx | 1 + src/pages/api/admin/users/save-user.ts | 9 +- src/schemas/user-schema.ts | 4 + src/styles/main.css | 2 +- src/types/index.ts | 2 + 35 files changed, 352 insertions(+), 535 deletions(-) create mode 100644 src/functions/backend/db/users/set-user-profile-image.ts delete mode 100644 src/pages/admin/people/create/(partials)/user-form/user-form-bio.tsx rename src/pages/admin/{settings/users/(partials)/user-password-fields.tsx => people/create/(partials)/user-form/user-form-password.tsx} (71%) create mode 100644 src/pages/admin/settings/users/(functions)/revoke-user.ts delete mode 100644 src/pages/admin/settings/users/(partials)/user-form.tsx delete mode 100644 src/pages/admin/settings/users/(partials)/user-types-select.tsx diff --git a/AGENTS.md b/AGENTS.md index bcb0c6e..47cdb52 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -145,7 +145,7 @@ Live routes under `/admin` (see `AdminSections`): `/admin/bots`, `/admin/workspa - `/admin/help` (live) - familiar problems and their fixes, one `SettingsGroup` per topic (`(data)/help-topics.ts`, markdown rendered with the chat's `MessageMarkdownComponents`, the topic id as the group's anchor). First topic: signing in to Google and other strict sites with Cookie-Editor over the Desktop view (`HelpSignInTopicId` in `src/dict/help-dict.ts`; `ask_user_to_take_over` points bots to it) - Routines, Activity and Help are the `RailPages` (`src/dict/rail-pages-dict.ts`), opened from the rail's user popover (which lists them, then Settings and Sign out). On them the rail shows its own aside (`(partials)/rail-pages-list.tsx`): a back link to the bots, then those pages, like settings - `/admin/backups` - super admin only: create/list/download database snapshots in `db/.backups`, plus `restore/` (restore a listed backup, or upload a SQLite database file which is saved to `.backups` then restored). Shells out to the `bun-sqlite` CLI (`backup`, and `import ` since `restore` is interactive) via `src/functions/backend/backups/`; API routes in `api/admin/backups/` -- `/admin/settings` (live) - redirects to `SettingsDefaultPath` (`/admin/settings/general`). Sub pages: `general/` (theme: System/Light/Dark per device via `useThemePreference`, stored in `localStorage[AppData.ThemeStorageKey]`, the same key `ThemeInitScript` and twui's `ColorSchemeSelector` read; applies instantly, no save bar), `profile/` (current user profile), `change-password/` (set/change own password) `models/` (super admin only, `super_admin_only` in the dict hides it from the rail: model providers and which model each harness uses) and `environment/` (super admin only: app settings and the envs bots and computers use, per scope), each wrapped in `SettingsShell` (`(partials)/settings-shell.tsx`: 64px pane header like the conversation header, then one centered column). Content is built from `SettingsGroup` blocks (label + description left, controls right, hairline-separated, no cards) and ends with the sticky `SettingsSaveBar` (Save enabled only when dirty, Discard, one-line status). Saves don't reload the page. Keep save notices in component state: `useFormInit` clears `status` whenever the form changes. `bots/` (the crew: chat, edit with `BotForm`, models, delete a worker via `api/admin/bots/delete-bot`), `workspaces/` (open, create through the Add flow, delete), `computers/` (state and resources, live; super admins create, edit with `AddComputerForm`, retry, delete) and `users/` (for `canListUsers`: super admins and admins; the dict's `can_view` gates it in the rail; add with a password (`canCreateUser`), edit (`canUpdateUser`), delete (`canRevokeUser`'s rules, never yourself), pick their user types from `UserTypes` (`user-types-select.tsx`: only the ones `filterAssignableUserTypes` allows, never `UneditableUserTypes`; at least one, since login needs a type; `saveUser` checks each grant and removal with `userTypeAssignment`; the super admin's types are never shown or changed there, and they can't be revoked; the password is entered twice, `SaveUserRequestSchema` checks they match), delete with everything theirs; `api/admin/users/save-user`, `delete-user`). The lists share `SettingsList`, `SettingsListRow`, `SettingsRowAction` and `use-settings-list`. Sub pages are declared once in `src/dict/settings-pages-dict.ts`; on every `/admin/settings/*` page the rail swaps the crew for a back link plus that list. Add a sub page by adding its folder and a dict entry +- `/admin/settings` (live) - redirects to `SettingsDefaultPath` (`/admin/settings/general`). Sub pages: `general/` (theme: System/Light/Dark per device via `useThemePreference`, stored in `localStorage[AppData.ThemeStorageKey]`, the same key `ThemeInitScript` and twui's `ColorSchemeSelector` read; applies instantly, no save bar), `profile/` (current user profile), `change-password/` (set/change own password) `models/` (super admin only, `super_admin_only` in the dict hides it from the rail: model providers and which model each harness uses) and `environment/` (super admin only: app settings and the envs bots and computers use, per scope), each wrapped in `SettingsShell` (`(partials)/settings-shell.tsx`: 64px pane header like the conversation header, then one centered column). Content is built from `SettingsGroup` blocks (label + description left, controls right, hairline-separated, no cards) and ends with the sticky `SettingsSaveBar` (Save enabled only when dirty, Discard, one-line status). Saves don't reload the page. Keep save notices in component state: `useFormInit` clears `status` whenever the form changes. `bots/` (the crew: chat, edit with `BotForm`, models, delete a worker via `api/admin/bots/delete-bot`), `workspaces/` (open, create through the Add flow, delete), `computers/` (state and resources, live; super admins create, edit with `AddComputerForm`, retry, delete) and `users/` (for `canListUsers`: super admins and admins; the dict's `can_view` gates it in the rail; add with a password (`canCreateUser`), edit (`canUpdateUser`), both with the one `UserForm` (`admin/people/create/(partials)/user-form/`, `useFormInit`, photo included, saved in one request), revoke (adds `revoked`, `canRevokeUser`), delete (`canRevokeUser`'s rules, never yourself), pick their user types from `UserTypes` (`user-types-select.tsx`: only the ones `filterAssignableUserTypes` allows, never `UneditableUserTypes`; at least one, since login needs a type; `saveUser` checks each grant and removal with `userTypeAssignment`; the super admin's types are never shown or changed there, and they can't be revoked; the password is entered twice, `SaveUserRequestSchema` checks they match), delete with everything theirs; `api/admin/users/save-user`, `delete-user`). The lists share `SettingsList`, `SettingsListRow`, `SettingsRowAction` and `use-settings-list`. Sub pages are declared once in `src/dict/settings-pages-dict.ts`; on every `/admin/settings/*` page the rail swaps the crew for a back link plus that list. Add a sub page by adding its folder and a dict entry Every `/admin/*` page renders in the bots layout (`src/layouts/bots/`); there is no separate admin layout or aside. The rail header's Add icon opens the Add flow (`(partials)/add-flow.tsx`, `AddFlowContext`): a bot, a workspace or (super admins) a computer, each form in a modal stacked over the choice, so closing it goes back there (the Bots and Workspaces labels have a "+" that opens the form directly); on a workspace's pages new bots and computers join it. The rail shows the crew (`(partials)/rail-crew-list.tsx`, needs `bots` and `workspaces` in page props): the coordinator, the latest `AppData.RailRecentBots` workers (plus any open, unread or needing you, `pick-rail-bots`), "See all bots" with a few stones opening every bot in a modal, then Workspaces with a "+". On `/admin/workspaces/*` it shows the workspace's crew instead (`(partials)/rail-workspace-list.tsx`: back link, the workspace, its coordinator, "Workspace bots"), except on `/admin/settings/*` where it shows "Back to bots" plus the settings sub pages (`(partials)/rail-settings-list.tsx`). Page heroes use the shared `AdminHero` component (`src/components/general/admin-hero.tsx`). diff --git a/src/functions/backend/db/users/set-user-profile-image.ts b/src/functions/backend/db/users/set-user-profile-image.ts new file mode 100644 index 0000000..755d505 --- /dev/null +++ b/src/functions/backend/db/users/set-user-profile-image.ts @@ -0,0 +1,36 @@ +import bufferFromBase64 from "@/src/utils/buffer-from-base-64"; +import deleteMedia from "../media/delete-media"; +import uploadAndRecordMedia from "./media/upload-and-record-media"; + +type Params = { + user_id: number; + /** + * The new photo as a base64 data URL. + */ + data_url: string; +}; + +/** + * Replaces a user's profile photo: their old one is deleted, then this one + * stored as their primary `user-profile-image`. The caller checks the actor + * may update this user. + */ +export default async function setUserProfileImage({ user_id, data_url }: Params) { + const { buffer, ext } = await bufferFromBase64(data_url); + + await deleteMedia({ media_paradigm: "user-profile-image", user_id, can_delete_all_media: true }); + + const res = await uploadAndRecordMedia({ + data: buffer, + media_paradigms: ["user-profile-image"], + media_type: "image", + is_primary: true, + update_on_duplicate: true, + media_mime_type: ext, + user: { id: user_id }, + }); + + if (!res.success) { + throw new Error(`Couldn't save the photo: ${res.msg}`); + } +} diff --git a/src/layouts/bots/(partials)/add-computer-form.tsx b/src/layouts/bots/(partials)/add-computer-form.tsx index acc9ca4..eda61ef 100644 --- a/src/layouts/bots/(partials)/add-computer-form.tsx +++ b/src/layouts/bots/(partials)/add-computer-form.tsx @@ -48,6 +48,7 @@ export default function AddComputerForm({ computer, onCancel, onCreated, onUpdat const submit = async () => { if (loading) return; + if (!window.confirm(computer?.id ? `Save ${computer.name || "this computer"}? New resources restart it; its files stay.` : `Create ${name || "this computer"}?`)) return; setLoading(true); setError(undefined); diff --git a/src/layouts/bots/(partials)/add-workspace-form.tsx b/src/layouts/bots/(partials)/add-workspace-form.tsx index c0a17b0..8bd3ea2 100644 --- a/src/layouts/bots/(partials)/add-workspace-form.tsx +++ b/src/layouts/bots/(partials)/add-workspace-form.tsx @@ -27,6 +27,7 @@ export default function AddWorkspaceForm({ onCancel }: Props) { const submit = async () => { if (loading) return; + if (!window.confirm(`Create ${name || "this workspace"}?`)) return; setLoading(true); setError(undefined); diff --git a/src/layouts/bots/(partials)/bot-form.tsx b/src/layouts/bots/(partials)/bot-form.tsx index 9106257..303af6d 100644 --- a/src/layouts/bots/(partials)/bot-form.tsx +++ b/src/layouts/bots/(partials)/bot-form.tsx @@ -49,6 +49,7 @@ export default function BotForm({ bot, onCancel }: Props) { const submit = async () => { if (loading) return; + if (!window.confirm(bot?.id ? `Save changes to ${bot.name || "this bot"}?` : `Add ${name || "this bot"} to your crew?`)) return; setLoading(true); setError(undefined); diff --git a/src/pages/admin/people/[user_id]/edit/(sections)/edit-person-form-section.tsx b/src/pages/admin/people/[user_id]/edit/(sections)/edit-person-form-section.tsx index 6a3f2f9..8e8555d 100644 --- a/src/pages/admin/people/[user_id]/edit/(sections)/edit-person-form-section.tsx +++ b/src/pages/admin/people/[user_id]/edit/(sections)/edit-person-form-section.tsx @@ -1,11 +1,7 @@ -import AdminHero from "@/src/components/general/admin-hero"; import Section from "@moduletrace/twui/layout/Section"; import { AppContext } from "@/src/pages/__root"; import { useContext } from "react"; import UserForm from "../../../create/(partials)/user-form"; -import _ from "lodash"; -import type { UserFormObject, UserType } from "@/src/types"; -import type { BUN_SQLITE_COBBLE_USERS_JOIN } from "@/src/types/sql-joins"; export default function EditPersonFormSection() { const { pageProps } = useContext(AppContext); @@ -14,28 +10,9 @@ export default function EditPersonFormSection() { if (!person) return null; - const person_name = `${person.first_name + (person.last_name ? ` ${person.last_name}` : "")}`; - return (
- (person, [ - "first_name", - "last_name", - "email", - "username", - "bio", - ]) as UserFormObject), - user_types: person.user_types?.split(",") as - | UserType[] - | undefined, - }} - existing_user_full={person} - /> +
); } diff --git a/src/pages/admin/people/create/(functions)/submit-user-form.ts b/src/pages/admin/people/create/(functions)/submit-user-form.ts index d1601de..73c6f15 100644 --- a/src/pages/admin/people/create/(functions)/submit-user-form.ts +++ b/src/pages/admin/people/create/(functions)/submit-user-form.ts @@ -1,133 +1,64 @@ -import type { - BUN_SQLITE_COBBLE_USER_TYPES, - BUN_SQLITE_COBBLE_USERS, -} from "@/db/types/db"; -import adminCrudHandler from "@/src/functions/frontend/admin-crud-handler"; -import clientDeleteMediaHandler from "@/src/functions/frontend/client-delete-media-handler"; -import clientUploadMediaHandler from "@/src/functions/frontend/client-upload-media-handler"; +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 _ from "lodash"; +import type { BUN_SQLITE_COBBLE_USERS_JOIN } from "@/src/types/sql-joins"; +type Params = ReturnType> & { + 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, - form, - existing_full, - existing, - image, -}: ReturnType>) { - try { - const confirm_msg = existing_full?.id - ? `Update this user?` - : `Create new user?`; + onSaved, +}: Params) { + if (loading) return; - if (!window.confirm(confirm_msg)) { - return; - } + if ((form.password || "") != (form.confirm_password || "")) { + setStatus({ error: true, msg: "The passwords don't match." }); + return; + } - const new_user_data: BUN_SQLITE_COBBLE_USERS = _.pick< - UserFormObject, - keyof UserFormObject - >(form, ["first_name", "last_name", "email", "username", "bio"]); + if (!window.confirm(existing_full?.id ? `Save changes to ${form.first_name || "this user"}?` : `Add ${form.first_name || "this user"}?`)) return; - setLoading(true); + setLoading(true); - const create_or_update_user_res = await adminCrudHandler({ - action: existing_full?.id ? "update" : "insert", - table: "users", - insert_data: [new_user_data], - update_data: new_user_data, - id: existing_full?.id, - }); + const res = await postAdminApi({ + 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, + }, + }); - const target_user_id = - existing_full?.id || create_or_update_user_res.singleRes?.id; + setLoading(false); - if (image?.imageBase64Full) { - const is_user_settings = - window.location.pathname.includes("admin/settings"); + if (!res.success || !res.singleRes?.id) { + setStatus({ error: true, msg: res.msg || "Couldn't save the user." }); + return; + } - const delete_media_res = await clientDeleteMediaHandler({ - user_id: target_user_id, - media_paradigm: "user-profile-image", - }); - - if (!delete_media_res.success) { - console.log("delete_media_res", delete_media_res); - - throw new Error( - delete_media_res.msg || - delete_media_res.error || - `Couldn't delete old media`, - ); - } - - const upload_image_res = await clientUploadMediaHandler({ - user_id: target_user_id, - media_base_64_data_url: image.imageBase64Full, - media_paradigms: ["user-profile-image"], - media_type: "image", - is_media_primary: true, - update_on_duplicate: true, - reauth: is_user_settings, - }); - - console.log("upload_image_res", upload_image_res); - - if (!upload_image_res.success) { - throw new Error( - upload_image_res.msg || "Couldn't upload Image", - ); - } - } - - if (create_or_update_user_res.success && target_user_id) { - const delete_user_types = await adminCrudHandler({ - action: "delete", - table: "user_types", - sql_query: { - query: { - user_id: { - value: target_user_id, - }, - }, - }, - }); - - const user_types = form.user_types; - - if (user_types?.[0]) { - const add_user_types = - await adminCrudHandler({ - action: "insert", - table: "user_types", - insert_data: user_types.map((ut) => ({ - user_id: target_user_id, - user_type: ut, - })), - }); - - if (existing_full?.id) { - window.location.reload(); - } else { - window.location.pathname = `/admin/people/${target_user_id}`; - } - } - } else { - console.log("res", create_or_update_user_res); - console.log("form", form); - - throw new Error( - create_or_update_user_res.msg || "Person Creation Failed", - ); - } - } catch (error: any) { - setStatus({ - error: true, - msg: error.message || "User Form Error", - }); - - setLoading(false); + if (onSaved) { + onSaved({ person: res.singleRes, msg: res.msg || "Saved." }); + } else if (existing_full?.id) { + window.location.reload(); + } else { + window.location.pathname = `/admin/people/${res.singleRes.id}`; } } diff --git a/src/pages/admin/people/create/(partials)/user-form/index.tsx b/src/pages/admin/people/create/(partials)/user-form/index.tsx index 715d25e..62720ec 100644 --- a/src/pages/admin/people/create/(partials)/user-form/index.tsx +++ b/src/pages/admin/people/create/(partials)/user-form/index.tsx @@ -1,55 +1,63 @@ import Form from "@moduletrace/twui/form/Form"; -import LoadingOverlay from "@moduletrace/twui/elements/LoadingOverlay"; +import Row from "@moduletrace/twui/layout/Row"; import useFormInit from "@/src/hooks/use-form-init"; -import type { BUN_SQLITE_COBBLE_USERS } from "@/db/types/db"; +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 "../../(functions)/submit-user-form"; import UserFormName from "./user-form-name"; -import Tag from "@moduletrace/twui/elements/Tag"; import UserFormAction from "./user-form-action"; import UserFormEmail from "./user-form-email"; import UserFormUsername from "./user-form-username"; -import Row from "@moduletrace/twui/layout/Row"; -import type { UserFormObject } from "@/src/types"; import UserFormUserTypes from "./user-form-user-types"; -import UserFormBio from "./user-form-bio"; import UserFormImage from "./user-form-image"; +import UserFormPassword from "./user-form-password"; type Props = { - existing_user?: UserFormObject; - existing_user_full?: BUN_SQLITE_COBBLE_USERS; + /** + * 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; }; -export default function UserForm({ existing_user, existing_user_full }: Props) { +/** + * A user's photo, name, email, username, user types and password, saved + * in one go through `users/save-user`. + */ +export default function UserForm({ person, onSaved, onCancel }: Props) { const init = useFormInit({ - existing: existing_user, - existing_full: existing_user_full, + 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"] }, }); - const { loading, status } = init; - return ( - <> -
{ - submitUserForm(init); - }} - > - {status?.error && {status.msg}} - {loading && } - - - - - - - - - - - - - - +
submitUserForm({ ...init, onSaved })}> + + + + + + + + + + ); } diff --git a/src/pages/admin/people/create/(partials)/user-form/user-form-action.tsx b/src/pages/admin/people/create/(partials)/user-form/user-form-action.tsx index d514add..397f8f6 100644 --- a/src/pages/admin/people/create/(partials)/user-form/user-form-action.tsx +++ b/src/pages/admin/people/create/(partials)/user-form/user-form-action.tsx @@ -1,17 +1,18 @@ -import type { BUN_SQLITE_COBBLE_USERS } from "@/db/types/db"; -import Button from "@moduletrace/twui/layout/Button"; import useFormInit from "@/src/hooks/use-form-init"; +import AddFormActions from "@/src/layouts/bots/(partials)/add-form-actions"; +import type { UserFormObject } from "@/src/types"; -export default function UserFormAction({ - setForm, - existing, - existing_full, -}: ReturnType>) { - const title = existing_full?.id ? "Update User" : "Create New User"; +type Props = ReturnType> & { + onCancel?: () => void; +}; +export default function UserFormAction({ existing_full, loading, status, onCancel }: Props) { return ( - <> - - + window.history.back())} + /> ); } diff --git a/src/pages/admin/people/create/(partials)/user-form/user-form-bio.tsx b/src/pages/admin/people/create/(partials)/user-form/user-form-bio.tsx deleted file mode 100644 index f292736..0000000 --- a/src/pages/admin/people/create/(partials)/user-form/user-form-bio.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import type { BUN_SQLITE_COBBLE_USERS } from "@/db/types/db"; -import TinyMCEEditor from "@moduletrace/twui/editors/TinyMCE"; -import Button from "@moduletrace/twui/layout/Button"; -import useFormInit from "@/src/hooks/use-form-init"; - -export default function UserFormBio({ - setForm, - existing, - existing_full, - form, -}: ReturnType>) { - const title = existing_full?.id ? "Update User" : "Create New User"; - - return ( - <> - { - setForm((prev) => ({ - ...prev, - bio: content, - })); - }} - placeholder="Write Something about yourself" - options={{ height: 300 }} - name="Bio" - showLabel - /> - - ); -} diff --git a/src/pages/admin/people/create/(partials)/user-form/user-form-email.tsx b/src/pages/admin/people/create/(partials)/user-form/user-form-email.tsx index 1a1f92a..e99f182 100644 --- a/src/pages/admin/people/create/(partials)/user-form/user-form-email.tsx +++ b/src/pages/admin/people/create/(partials)/user-form/user-form-email.tsx @@ -7,20 +7,22 @@ export default function UserFormEmail({ form, }: ReturnType>) { return ( - <> - { - setForm((prev) => ({ - ...prev, - email: e.target.value, - })); - }} - defaultValue={form.email} - showLabel - required - /> - + { + setForm((prev) => ({ + ...prev, + email: e.target.value, + })); + }} + defaultValue={form.email} + showLabel + required + /> ); } diff --git a/src/pages/admin/people/create/(partials)/user-form/user-form-image.tsx b/src/pages/admin/people/create/(partials)/user-form/user-form-image.tsx index 0d8d428..1db04df 100644 --- a/src/pages/admin/people/create/(partials)/user-form/user-form-image.tsx +++ b/src/pages/admin/people/create/(partials)/user-form/user-form-image.tsx @@ -4,51 +4,24 @@ 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"; -export default function UserFormImage({ - setForm, - existing_full, - setImage, -}: ReturnType>) { - const title = existing_full?.id ? "Update User" : "Create New User"; - - const person = existing_full as BUN_SQLITE_COBBLE_USERS_JOIN | undefined; +type Props = ReturnType> & { + 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 }, - }) + ? grabMediaURL({ media: { id: person.profile_media_id } }).media_url : undefined; return ( - <> - { - setImage(imgData); - }} - setImgURL={(imgURL) => { - if ( - typeof imgURL !== "string" && - typeof imgURL !== "undefined" - ) { - return; - } - - setForm((prev) => ({ - ...prev, - image: imgURL || "", - image_thumbnail: imgURL || "", - })); - }} - restoreImageFn={() => { - setForm((prev) => ({ - ...prev, - image: user_image_url || "", - image_thumbnail: user_image_url || "", - })); - }} - /> - + setImage(imgData)} + restoreImageFn={() => setImage(undefined)} + /> ); } diff --git a/src/pages/admin/people/create/(partials)/user-form/user-form-name.tsx b/src/pages/admin/people/create/(partials)/user-form/user-form-name.tsx index a6cc6fa..8fa28be 100644 --- a/src/pages/admin/people/create/(partials)/user-form/user-form-name.tsx +++ b/src/pages/admin/people/create/(partials)/user-form/user-form-name.tsx @@ -8,10 +8,12 @@ export default function UserFormName({ form, }: ReturnType>) { return ( - + { setForm((prev) => ({ ...prev, @@ -19,14 +21,16 @@ export default function UserFormName({ })); }} defaultValue={form.first_name} - autoComplete="given-name" + autoComplete="off" showLabel required /> { setForm((prev) => ({ ...prev, @@ -34,7 +38,7 @@ export default function UserFormName({ })); }} defaultValue={form.last_name} - autoComplete="family-name" + autoComplete="off" showLabel /> diff --git a/src/pages/admin/settings/users/(partials)/user-password-fields.tsx b/src/pages/admin/people/create/(partials)/user-form/user-form-password.tsx similarity index 71% rename from src/pages/admin/settings/users/(partials)/user-password-fields.tsx rename to src/pages/admin/people/create/(partials)/user-form/user-form-password.tsx index 51e29dc..a7c5590 100644 --- a/src/pages/admin/settings/users/(partials)/user-password-fields.tsx +++ b/src/pages/admin/people/create/(partials)/user-form/user-form-password.tsx @@ -2,21 +2,16 @@ 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; -}; +import useFormInit from "@/src/hooks/use-form-init"; +import type { UserFormObject } from "@/src/types"; /** - * The password section of the user form: the password, then the same - * again to confirm it. + * The password, then the same again to confirm it. Editing someone, both + * may stay empty to keep theirs. */ -export default function UserPasswordFields({ optional, onPassword, onConfirm }: Props) { +export default function UserFormPassword({ setForm, existing_full }: ReturnType>) { + const optional = Boolean(existing_full?.id); + return ( @@ -33,7 +28,7 @@ export default function UserPasswordFields({ optional, onPassword, onConfirm }: type="password" placeholder={optional ? "Leave empty to keep it" : "A strong password"} autoComplete="new-password" - onChange={(e) => onPassword(e.target.value)} + onChange={(e) => setForm((prev) => ({ ...prev, password: e.target.value }))} /> onConfirm(e.target.value)} + onChange={(e) => setForm((prev) => ({ ...prev, confirm_password: e.target.value }))} /> ); diff --git a/src/pages/admin/people/create/(partials)/user-form/user-form-user-types.tsx b/src/pages/admin/people/create/(partials)/user-form/user-form-user-types.tsx index 6df7127..675a146 100644 --- a/src/pages/admin/people/create/(partials)/user-form/user-form-user-types.tsx +++ b/src/pages/admin/people/create/(partials)/user-form/user-form-user-types.tsx @@ -2,32 +2,52 @@ 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> & { + 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, -}: ReturnType>) { + 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 ( - <> - !UneditableUserTypes.includes(ut.value), - ).map((ut) => ({ - value: ut.value, - }))} - defaultValues={form.user_types || []} - changeHandler={(value) => { - const user_types = Array.isArray(value) - ? (value as UserType[]) - : value - ? ([value] as UserType[]) - : []; - setForm({ ...form, user_types }); - }} - title="User Types" - showLabel - /> - + 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 })); + }} + /> ); } diff --git a/src/pages/admin/people/create/(partials)/user-form/user-form-username.tsx b/src/pages/admin/people/create/(partials)/user-form/user-form-username.tsx index 5e0c541..0396a93 100644 --- a/src/pages/admin/people/create/(partials)/user-form/user-form-username.tsx +++ b/src/pages/admin/people/create/(partials)/user-form/user-form-username.tsx @@ -7,18 +7,22 @@ export default function UserFormUsername({ form, }: ReturnType>) { return ( - <> - { - setForm((prev) => ({ - ...prev, - username: e.target.value, - })); - }} - defaultValue={form.username} - showLabel - /> - + { + setForm((prev) => ({ + ...prev, + username: e.target.value, + })); + }} + defaultValue={form.username} + showLabel + required + /> ); } diff --git a/src/pages/admin/routines/(partials)/routine-form.tsx b/src/pages/admin/routines/(partials)/routine-form.tsx index e1476c0..64df924 100644 --- a/src/pages/admin/routines/(partials)/routine-form.tsx +++ b/src/pages/admin/routines/(partials)/routine-form.tsx @@ -24,6 +24,7 @@ export default function RoutineForm({ cron_job, onCancel, onSaved }: Props) { const submit = async () => { if (loading) return; + if (!window.confirm(`Save ${title || "this routine"}?`)) return; setLoading(true); setError(undefined); diff --git a/src/pages/admin/settings/(hooks)/use-settings-list.ts b/src/pages/admin/settings/(hooks)/use-settings-list.ts index 1f28dab..8b0eeca 100644 --- a/src/pages/admin/settings/(hooks)/use-settings-list.ts +++ b/src/pages/admin/settings/(hooks)/use-settings-list.ts @@ -35,5 +35,5 @@ export default function useSettingsList setStatus({ success: true, msg: res.msg || "Deleted." }); }; - return { items, setItems, busy, status, setStatus, remove }; + return { items, setItems, busy, setBusy, status, setStatus, remove }; } diff --git a/src/pages/admin/settings/change-password/(partials)/password-form/index.tsx b/src/pages/admin/settings/change-password/(partials)/password-form/index.tsx index 92e9c2f..1f573fe 100644 --- a/src/pages/admin/settings/change-password/(partials)/password-form/index.tsx +++ b/src/pages/admin/settings/change-password/(partials)/password-form/index.tsx @@ -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 }); }} > diff --git a/src/pages/admin/settings/environment/(partials)/env-form.tsx b/src/pages/admin/settings/environment/(partials)/env-form.tsx index e5c63ef..4b2e290 100644 --- a/src/pages/admin/settings/environment/(partials)/env-form.tsx +++ b/src/pages/admin/settings/environment/(partials)/env-form.tsx @@ -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); diff --git a/src/pages/admin/settings/models/(partials)/harness-settings-form.tsx b/src/pages/admin/settings/models/(partials)/harness-settings-form.tsx index aa9fbd3..541fbcb 100644 --- a/src/pages/admin/settings/models/(partials)/harness-settings-form.tsx +++ b/src/pages/admin/settings/models/(partials)/harness-settings-form.tsx @@ -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({}); diff --git a/src/pages/admin/settings/models/(partials)/provider-form.tsx b/src/pages/admin/settings/models/(partials)/provider-form.tsx index dab623b..7893f08 100644 --- a/src/pages/admin/settings/models/(partials)/provider-form.tsx +++ b/src/pages/admin/settings/models/(partials)/provider-form.tsx @@ -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); diff --git a/src/pages/admin/settings/profile/(partials)/profile-form.tsx b/src/pages/admin/settings/profile/(partials)/profile-form.tsx index 59fce84..6e2128c 100644 --- a/src/pages/admin/settings/profile/(partials)/profile-form.tsx +++ b/src/pages/admin/settings/profile/(partials)/profile-form.tsx @@ -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 }) => { diff --git a/src/pages/admin/settings/users/(functions)/revoke-user.ts b/src/pages/admin/settings/users/(functions)/revoke-user.ts new file mode 100644 index 0000000..c6c36cc --- /dev/null +++ b/src/pages/admin/settings/users/(functions)/revoke-user.ts @@ -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({ + 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"], + }, + }); +} diff --git a/src/pages/admin/settings/users/(partials)/user-edit-modal.tsx b/src/pages/admin/settings/users/(partials)/user-edit-modal.tsx index d617b98..251c311 100644 --- a/src/pages/admin/settings/users/(partials)/user-edit-modal.tsx +++ b/src/pages/admin/settings/users/(partials)/user-edit-modal.tsx @@ -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 = { /** diff --git a/src/pages/admin/settings/users/(partials)/user-form.tsx b/src/pages/admin/settings/users/(partials)/user-form.tsx deleted file mode 100644 index 824464f..0000000 --- a/src/pages/admin/settings/users/(partials)/user-form.tsx +++ /dev/null @@ -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( - person ? current_types.filter((type) => !UneditableUserTypes.includes(type)) : ["member"], - ); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(); - - 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({ - 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 ( -
- - setFirstName(e.target.value)} - /> - setLastName(e.target.value)} - /> - - setEmail(e.target.value)} - /> - setUsername(e.target.value)} - /> - {is_super_admin ? null : ( - - )} - - - - ); -} diff --git a/src/pages/admin/settings/users/(partials)/user-settings-row.tsx b/src/pages/admin/settings/users/(partials)/user-settings-row.tsx index e36514a..17c9295 100644 --- a/src/pages/admin/settings/users/(partials)/user-settings-row.tsx +++ b/src/pages/admin/settings/users/(partials)/user-settings-row.tsx @@ -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 ( ) : null} + {can_revoke ? ( + + ) : null} {can_delete ? ( 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 ( - 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[]) - } - /> - ); -} diff --git a/src/pages/admin/settings/users/(sections)/users-settings-section.tsx b/src/pages/admin/settings/users/(sections)/users-settings-section.tsx index 56b4250..3a6886f 100644 --- a/src/pages/admin/settings/users/(sections)/users-settings-section.tsx +++ b/src/pages/admin/settings/users/(sections)/users-settings-section.tsx @@ -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(); + 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 ( 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 }); }} diff --git a/src/pages/admin/workspaces/[workspace_id]/(partials)/workspace-bot-modal.tsx b/src/pages/admin/workspaces/[workspace_id]/(partials)/workspace-bot-modal.tsx index 2d937ef..1bedb5b 100644 --- a/src/pages/admin/workspaces/[workspace_id]/(partials)/workspace-bot-modal.tsx +++ b/src/pages/admin/workspaces/[workspace_id]/(partials)/workspace-bot-modal.tsx @@ -28,6 +28,7 @@ export default function WorkspaceBotModal({ workspace_id, workspace_name, bot, o const submit = async () => { if (loading) return; + if (!window.confirm(`Save ${bot.name || "this bot"}'s instructions and memory here?`)) return; setLoading(true); setError(undefined); diff --git a/src/pages/admin/workspaces/[workspace_id]/(partials)/workspace-details-group.tsx b/src/pages/admin/workspaces/[workspace_id]/(partials)/workspace-details-group.tsx index b8d1d95..268fbf7 100644 --- a/src/pages/admin/workspaces/[workspace_id]/(partials)/workspace-details-group.tsx +++ b/src/pages/admin/workspaces/[workspace_id]/(partials)/workspace-details-group.tsx @@ -34,6 +34,7 @@ export default function WorkspaceDetailsGroup({ workspace, onSaved }: Props) { const submit = async () => { if (!dirty || loading) return; + if (!window.confirm(`Save ${name || "this workspace"}?`)) return; setLoading(true); setStatus(undefined); diff --git a/src/pages/api/admin/users/save-user.ts b/src/pages/api/admin/users/save-user.ts index 0a921dd..16759e7 100644 --- a/src/pages/api/admin/users/save-user.ts +++ b/src/pages/api/admin/users/save-user.ts @@ -3,6 +3,7 @@ import ensureCoordinatorBot from "@/src/functions/backend/db/bots/ensure-coordin import ensureMainComputer from "@/src/functions/backend/db/computers/ensure-main-computer"; import grabUsers from "@/src/functions/backend/db/users/grab-users"; import saveUser from "@/src/functions/backend/db/users/save-user"; +import setUserProfileImage from "@/src/functions/backend/db/users/set-user-profile-image"; import logEvent, { grabRequestIP, logError } from "@/src/functions/backend/logger"; import { SaveUserRequestSchema } from "@/src/schemas/user-schema"; import type { BUN_SQLITE_COBBLE_USERS_JOIN } from "@/src/types/sql-joins"; @@ -20,8 +21,8 @@ import type { /** * Creates a user (`canCreateUser`) or updates one (`canUpdateUser`): - * name, email, username, password and user types, each type checked - * against the assignment matrix. A new user gets their coordinator and main computer + * name, email, username, password, user types (each checked against the + * assignment matrix) and, when one is sent, their profile photo. A new user gets their coordinator and main computer * straight away, as at signup. Returns the user. */ export const handler: BunextAPIRouteHandler> = async ({ req, body }) => { @@ -59,6 +60,10 @@ export const handler: BunextAPIRouteHandler type.value))).default([]), + /** + * A new profile photo as a base64 data URL; left out keeps theirs. + */ + image: z.string().startsWith("data:image/", "Pick an image").nullish(), }) .refine((data) => data.password == data.confirm_password, { message: "The passwords don't match", diff --git a/src/styles/main.css b/src/styles/main.css index c2a6d9d..8d9a777 100755 --- a/src/styles/main.css +++ b/src/styles/main.css @@ -152,7 +152,7 @@ --radius-default-lg: 10px; --radius-default-xl: 14px; - --container-container: 960px; + --container-container: 870px; } body { diff --git a/src/types/index.ts b/src/types/index.ts index 2cca969..1b262a1 100755 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -755,6 +755,8 @@ export type AdminFetchParams = { export type UserFormObject = BUN_SQLITE_COBBLE_USERS & { user_types?: UserType[]; + password?: string; + confirm_password?: string; }; export type PageQueryObject = {