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:
@@ -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)
|
- `/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
|
- 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 <file>` since `restore` is interactive) via `src/functions/backend/backups/`; API routes in `api/admin/backups/`
|
- `/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 <file>` 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`).
|
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`).
|
||||||
|
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,6 +48,7 @@ export default function AddComputerForm({ computer, onCancel, onCreated, onUpdat
|
|||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
if (loading) return;
|
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);
|
setLoading(true);
|
||||||
setError(undefined);
|
setError(undefined);
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export default function AddWorkspaceForm({ onCancel }: Props) {
|
|||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
if (loading) return;
|
if (loading) return;
|
||||||
|
if (!window.confirm(`Create ${name || "this workspace"}?`)) return;
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(undefined);
|
setError(undefined);
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ export default function BotForm({ bot, onCancel }: Props) {
|
|||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
if (loading) return;
|
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);
|
setLoading(true);
|
||||||
setError(undefined);
|
setError(undefined);
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
import AdminHero from "@/src/components/general/admin-hero";
|
|
||||||
import Section from "@moduletrace/twui/layout/Section";
|
import Section from "@moduletrace/twui/layout/Section";
|
||||||
import { AppContext } from "@/src/pages/__root";
|
import { AppContext } from "@/src/pages/__root";
|
||||||
import { useContext } from "react";
|
import { useContext } from "react";
|
||||||
import UserForm from "../../../create/(partials)/user-form";
|
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() {
|
export default function EditPersonFormSection() {
|
||||||
const { pageProps } = useContext(AppContext);
|
const { pageProps } = useContext(AppContext);
|
||||||
@@ -14,28 +10,9 @@ export default function EditPersonFormSection() {
|
|||||||
|
|
||||||
if (!person) return null;
|
if (!person) return null;
|
||||||
|
|
||||||
const person_name = `${person.first_name + (person.last_name ? ` ${person.last_name}` : "")}`;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section>
|
<Section>
|
||||||
<UserForm
|
<UserForm person={person} />
|
||||||
existing_user={{
|
|
||||||
...(_.pick<
|
|
||||||
BUN_SQLITE_COBBLE_USERS_JOIN,
|
|
||||||
keyof BUN_SQLITE_COBBLE_USERS_JOIN
|
|
||||||
>(person, [
|
|
||||||
"first_name",
|
|
||||||
"last_name",
|
|
||||||
"email",
|
|
||||||
"username",
|
|
||||||
"bio",
|
|
||||||
]) as UserFormObject),
|
|
||||||
user_types: person.user_types?.split(",") as
|
|
||||||
| UserType[]
|
|
||||||
| undefined,
|
|
||||||
}}
|
|
||||||
existing_user_full={person}
|
|
||||||
/>
|
|
||||||
</Section>
|
</Section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,133 +1,64 @@
|
|||||||
import type {
|
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
|
||||||
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 type useFormInit from "@/src/hooks/use-form-init";
|
import type useFormInit from "@/src/hooks/use-form-init";
|
||||||
import type { UserFormObject } from "@/src/types";
|
import type { UserFormObject } from "@/src/types";
|
||||||
import _ from "lodash";
|
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({
|
export default async function submitUserForm({
|
||||||
|
form,
|
||||||
|
image,
|
||||||
|
existing_full,
|
||||||
|
loading,
|
||||||
setLoading,
|
setLoading,
|
||||||
setStatus,
|
setStatus,
|
||||||
form,
|
onSaved,
|
||||||
existing_full,
|
}: Params) {
|
||||||
existing,
|
if (loading) return;
|
||||||
image,
|
|
||||||
}: ReturnType<typeof useFormInit<UserFormObject>>) {
|
|
||||||
try {
|
|
||||||
const confirm_msg = existing_full?.id
|
|
||||||
? `Update this user?`
|
|
||||||
: `Create new user?`;
|
|
||||||
|
|
||||||
if (!window.confirm(confirm_msg)) {
|
if ((form.password || "") != (form.confirm_password || "")) {
|
||||||
return;
|
setStatus({ error: true, msg: "The passwords don't match." });
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const new_user_data: BUN_SQLITE_COBBLE_USERS = _.pick<
|
if (!window.confirm(existing_full?.id ? `Save changes to ${form.first_name || "this user"}?` : `Add ${form.first_name || "this user"}?`)) return;
|
||||||
UserFormObject,
|
|
||||||
keyof UserFormObject
|
|
||||||
>(form, ["first_name", "last_name", "email", "username", "bio"]);
|
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
const create_or_update_user_res = await adminCrudHandler({
|
const res = await postAdminApi<BUN_SQLITE_COBBLE_USERS_JOIN>({
|
||||||
action: existing_full?.id ? "update" : "insert",
|
path: "users/save-user",
|
||||||
table: "users",
|
body: {
|
||||||
insert_data: [new_user_data],
|
user_id: existing_full?.id || null,
|
||||||
update_data: new_user_data,
|
first_name: form.first_name,
|
||||||
id: existing_full?.id,
|
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 =
|
setLoading(false);
|
||||||
existing_full?.id || create_or_update_user_res.singleRes?.id;
|
|
||||||
|
|
||||||
if (image?.imageBase64Full) {
|
if (!res.success || !res.singleRes?.id) {
|
||||||
const is_user_settings =
|
setStatus({ error: true, msg: res.msg || "Couldn't save the user." });
|
||||||
window.location.pathname.includes("admin/settings");
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const delete_media_res = await clientDeleteMediaHandler({
|
if (onSaved) {
|
||||||
user_id: target_user_id,
|
onSaved({ person: res.singleRes, msg: res.msg || "Saved." });
|
||||||
media_paradigm: "user-profile-image",
|
} else if (existing_full?.id) {
|
||||||
});
|
window.location.reload();
|
||||||
|
} else {
|
||||||
if (!delete_media_res.success) {
|
window.location.pathname = `/admin/people/${res.singleRes.id}`;
|
||||||
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<BUN_SQLITE_COBBLE_USER_TYPES>({
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,55 +1,63 @@
|
|||||||
import Form from "@moduletrace/twui/form/Form";
|
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 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 submitUserForm from "../../(functions)/submit-user-form";
|
||||||
import UserFormName from "./user-form-name";
|
import UserFormName from "./user-form-name";
|
||||||
import Tag from "@moduletrace/twui/elements/Tag";
|
|
||||||
import UserFormAction from "./user-form-action";
|
import UserFormAction from "./user-form-action";
|
||||||
import UserFormEmail from "./user-form-email";
|
import UserFormEmail from "./user-form-email";
|
||||||
import UserFormUsername from "./user-form-username";
|
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 UserFormUserTypes from "./user-form-user-types";
|
||||||
import UserFormBio from "./user-form-bio";
|
|
||||||
import UserFormImage from "./user-form-image";
|
import UserFormImage from "./user-form-image";
|
||||||
|
import UserFormPassword from "./user-form-password";
|
||||||
|
|
||||||
type Props = {
|
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<UserFormObject>({
|
const init = useFormInit<UserFormObject>({
|
||||||
existing: existing_user,
|
existing: person
|
||||||
existing_full: existing_user_full,
|
? {
|
||||||
|
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 (
|
return (
|
||||||
<>
|
<Form className="w-full gap-4! items-stretch pt-4" onSubmit={() => submitUserForm({ ...init, onSaved })}>
|
||||||
<Form
|
<UserFormImage {...init} person={person} />
|
||||||
className="w-full gap-6"
|
<UserFormName {...init} />
|
||||||
onSubmit={() => {
|
<Row className="w-full grid sm:grid-cols-2 gap-4!">
|
||||||
submitUserForm(init);
|
<UserFormEmail {...init} />
|
||||||
}}
|
<UserFormUsername {...init} />
|
||||||
>
|
</Row>
|
||||||
{status?.error && <Tag color="error">{status.msg}</Tag>}
|
<UserFormUserTypes {...init} person={person} />
|
||||||
{loading && <LoadingOverlay />}
|
<UserFormPassword {...init} />
|
||||||
|
<UserFormAction {...init} onCancel={onCancel} />
|
||||||
<UserFormName {...init} />
|
</Form>
|
||||||
<Row className="w-full grid xl:grid-cols-2">
|
|
||||||
<UserFormEmail {...init} />
|
|
||||||
<UserFormUsername {...init} />
|
|
||||||
</Row>
|
|
||||||
<UserFormUserTypes {...init} />
|
|
||||||
<Row className="w-full grid xl:grid-cols-2 items-stretch">
|
|
||||||
<UserFormBio {...init} />
|
|
||||||
<UserFormImage {...init} />
|
|
||||||
</Row>
|
|
||||||
<UserFormAction {...init} />
|
|
||||||
</Form>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 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({
|
type Props = ReturnType<typeof useFormInit<UserFormObject>> & {
|
||||||
setForm,
|
onCancel?: () => void;
|
||||||
existing,
|
};
|
||||||
existing_full,
|
|
||||||
}: ReturnType<typeof useFormInit<BUN_SQLITE_COBBLE_USERS>>) {
|
|
||||||
const title = existing_full?.id ? "Update User" : "Create New User";
|
|
||||||
|
|
||||||
|
export default function UserFormAction({ existing_full, loading, status, onCancel }: Props) {
|
||||||
return (
|
return (
|
||||||
<>
|
<AddFormActions
|
||||||
<Button title={title}>{title}</Button>
|
submit_label={existing_full?.id ? "Save" : "Add user"}
|
||||||
</>
|
loading={loading}
|
||||||
|
error={status?.error ? status.msg : undefined}
|
||||||
|
onCancel={onCancel || (() => window.history.back())}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<typeof useFormInit<BUN_SQLITE_COBBLE_USERS>>) {
|
|
||||||
const title = existing_full?.id ? "Update User" : "Create New User";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<TinyMCEEditor
|
|
||||||
defaultValue={form.bio}
|
|
||||||
changeHandler={(content) => {
|
|
||||||
setForm((prev) => ({
|
|
||||||
...prev,
|
|
||||||
bio: content,
|
|
||||||
}));
|
|
||||||
}}
|
|
||||||
placeholder="Write Something about yourself"
|
|
||||||
options={{ height: 300 }}
|
|
||||||
name="Bio"
|
|
||||||
showLabel
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -7,20 +7,22 @@ export default function UserFormEmail({
|
|||||||
form,
|
form,
|
||||||
}: ReturnType<typeof useFormInit<BUN_SQLITE_COBBLE_USERS>>) {
|
}: ReturnType<typeof useFormInit<BUN_SQLITE_COBBLE_USERS>>) {
|
||||||
return (
|
return (
|
||||||
<>
|
<Input
|
||||||
<Input
|
id="user-form-email"
|
||||||
type="email"
|
title="Email"
|
||||||
placeholder="Email Addres"
|
label="Email"
|
||||||
onChange={(e) => {
|
type="email"
|
||||||
setForm((prev) => ({
|
placeholder="[email protected]"
|
||||||
...prev,
|
autoComplete="off"
|
||||||
email: e.target.value,
|
onChange={(e) => {
|
||||||
}));
|
setForm((prev) => ({
|
||||||
}}
|
...prev,
|
||||||
defaultValue={form.email}
|
email: e.target.value,
|
||||||
showLabel
|
}));
|
||||||
required
|
}}
|
||||||
/>
|
defaultValue={form.email}
|
||||||
</>
|
showLabel
|
||||||
|
required
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,51 +4,24 @@ import useFormInit from "@/src/hooks/use-form-init";
|
|||||||
import type { UserFormObject } from "@/src/types";
|
import type { UserFormObject } from "@/src/types";
|
||||||
import type { BUN_SQLITE_COBBLE_USERS_JOIN } from "@/src/types/sql-joins";
|
import type { BUN_SQLITE_COBBLE_USERS_JOIN } from "@/src/types/sql-joins";
|
||||||
|
|
||||||
export default function UserFormImage({
|
type Props = ReturnType<typeof useFormInit<UserFormObject>> & {
|
||||||
setForm,
|
person?: BUN_SQLITE_COBBLE_USERS_JOIN;
|
||||||
existing_full,
|
};
|
||||||
setImage,
|
|
||||||
}: ReturnType<typeof useFormInit<UserFormObject>>) {
|
|
||||||
const title = existing_full?.id ? "Update User" : "Create New User";
|
|
||||||
|
|
||||||
const person = existing_full as BUN_SQLITE_COBBLE_USERS_JOIN | undefined;
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
const user_image_url = person?.profile_media_id
|
||||||
? grabMediaURL({
|
? grabMediaURL({ media: { id: person.profile_media_id } }).media_url
|
||||||
media: { id: person?.profile_media_id },
|
|
||||||
})
|
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<ImageUpload
|
||||||
<ImageUpload
|
label="Photo"
|
||||||
label="User Image"
|
existingImageUrl={user_image_url}
|
||||||
existingImageUrl={user_image_url?.media_url}
|
onChangeHandler={(imgData) => setImage(imgData)}
|
||||||
onChangeHandler={(imgData) => {
|
restoreImageFn={() => setImage(undefined)}
|
||||||
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 || "",
|
|
||||||
}));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,10 +8,12 @@ export default function UserFormName({
|
|||||||
form,
|
form,
|
||||||
}: ReturnType<typeof useFormInit<BUN_SQLITE_COBBLE_USERS>>) {
|
}: ReturnType<typeof useFormInit<BUN_SQLITE_COBBLE_USERS>>) {
|
||||||
return (
|
return (
|
||||||
<Row className="w-full grid xl:grid-cols-2">
|
<Row className="w-full grid sm:grid-cols-2 gap-4!">
|
||||||
<Input
|
<Input
|
||||||
title="First Name"
|
id="user-form-first-name"
|
||||||
placeholder="Eg. John"
|
title="First name"
|
||||||
|
label="First name"
|
||||||
|
placeholder="Ada"
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setForm((prev) => ({
|
setForm((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
@@ -19,14 +21,16 @@ export default function UserFormName({
|
|||||||
}));
|
}));
|
||||||
}}
|
}}
|
||||||
defaultValue={form.first_name}
|
defaultValue={form.first_name}
|
||||||
autoComplete="given-name"
|
autoComplete="off"
|
||||||
showLabel
|
showLabel
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
title="Last Name"
|
id="user-form-last-name"
|
||||||
placeholder="Eg. Doe"
|
title="Last name"
|
||||||
|
label="Last name"
|
||||||
|
placeholder="Lovelace"
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setForm((prev) => ({
|
setForm((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
@@ -34,7 +38,7 @@ export default function UserFormName({
|
|||||||
}));
|
}));
|
||||||
}}
|
}}
|
||||||
defaultValue={form.last_name}
|
defaultValue={form.last_name}
|
||||||
autoComplete="family-name"
|
autoComplete="off"
|
||||||
showLabel
|
showLabel
|
||||||
/>
|
/>
|
||||||
</Row>
|
</Row>
|
||||||
|
|||||||
+9
-14
@@ -2,21 +2,16 @@ import Input from "@moduletrace/twui/form/Input";
|
|||||||
import Span from "@moduletrace/twui/layout/Span";
|
import Span from "@moduletrace/twui/layout/Span";
|
||||||
import Stack from "@moduletrace/twui/layout/Stack";
|
import Stack from "@moduletrace/twui/layout/Stack";
|
||||||
import { AppData } from "@/src/data/app-data";
|
import { AppData } from "@/src/data/app-data";
|
||||||
|
import useFormInit from "@/src/hooks/use-form-init";
|
||||||
type Props = {
|
import type { UserFormObject } from "@/src/types";
|
||||||
/**
|
|
||||||
* 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
|
* The password, then the same again to confirm it. Editing someone, both
|
||||||
* again to confirm it.
|
* may stay empty to keep theirs.
|
||||||
*/
|
*/
|
||||||
export default function UserPasswordFields({ optional, onPassword, onConfirm }: Props) {
|
export default function UserFormPassword({ setForm, existing_full }: ReturnType<typeof useFormInit<UserFormObject>>) {
|
||||||
|
const optional = Boolean(existing_full?.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack className="w-full gap-3! items-stretch pt-2 border-0 border-t border-solid border-hairline">
|
<Stack className="w-full gap-3! items-stretch pt-2 border-0 border-t border-solid border-hairline">
|
||||||
<Stack className="gap-0.5!">
|
<Stack className="gap-0.5!">
|
||||||
@@ -33,7 +28,7 @@ export default function UserPasswordFields({ optional, onPassword, onConfirm }:
|
|||||||
type="password"
|
type="password"
|
||||||
placeholder={optional ? "Leave empty to keep it" : "A strong password"}
|
placeholder={optional ? "Leave empty to keep it" : "A strong password"}
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
onChange={(e) => onPassword(e.target.value)}
|
onChange={(e) => setForm((prev) => ({ ...prev, password: e.target.value }))}
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
id="user-form-confirm-password"
|
id="user-form-confirm-password"
|
||||||
@@ -43,7 +38,7 @@ export default function UserPasswordFields({ optional, onPassword, onConfirm }:
|
|||||||
type="password"
|
type="password"
|
||||||
placeholder="The same password again"
|
placeholder="The same password again"
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
onChange={(e) => onConfirm(e.target.value)}
|
onChange={(e) => setForm((prev) => ({ ...prev, confirm_password: e.target.value }))}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
@@ -2,32 +2,52 @@ import SearchSelect from "@moduletrace/twui/form/SearchSelect";
|
|||||||
import { UneditableUserTypes, UserTypes } from "@/src/dict/user-types-dict";
|
import { UneditableUserTypes, UserTypes } from "@/src/dict/user-types-dict";
|
||||||
import useFormInit from "@/src/hooks/use-form-init";
|
import useFormInit from "@/src/hooks/use-form-init";
|
||||||
import type { UserFormObject, UserType } from "@/src/types";
|
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({
|
export default function UserFormUserTypes({
|
||||||
setForm,
|
setForm,
|
||||||
form,
|
form,
|
||||||
}: ReturnType<typeof useFormInit<UserFormObject>>) {
|
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 (
|
return (
|
||||||
<>
|
<SearchSelect
|
||||||
<SearchSelect
|
id="user-form-user-types"
|
||||||
multiple
|
title="User types"
|
||||||
options={UserTypes.filter(
|
label="User types"
|
||||||
(ut) => !UneditableUserTypes.includes(ut.value),
|
showLabel
|
||||||
).map((ut) => ({
|
multiple
|
||||||
value: ut.value,
|
placeholder="Pick their user types"
|
||||||
}))}
|
options={UserTypes.filter((type) => assignable.includes(type.value)).map((type) => ({
|
||||||
defaultValues={form.user_types || []}
|
value: type.value,
|
||||||
changeHandler={(value) => {
|
title: type.title,
|
||||||
const user_types = Array.isArray(value)
|
}))}
|
||||||
? (value as UserType[])
|
defaultValues={form.user_types || []}
|
||||||
: value
|
info="At least one, or they can't sign in."
|
||||||
? ([value] as UserType[])
|
changeHandler={(value) => {
|
||||||
: [];
|
const user_types = (Array.isArray(value) ? value : value ? [value] : []) as UserType[];
|
||||||
setForm({ ...form, user_types });
|
setForm((prev) => ({ ...prev, user_types }));
|
||||||
}}
|
}}
|
||||||
title="User Types"
|
/>
|
||||||
showLabel
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,18 +7,22 @@ export default function UserFormUsername({
|
|||||||
form,
|
form,
|
||||||
}: ReturnType<typeof useFormInit<BUN_SQLITE_COBBLE_USERS>>) {
|
}: ReturnType<typeof useFormInit<BUN_SQLITE_COBBLE_USERS>>) {
|
||||||
return (
|
return (
|
||||||
<>
|
<Input
|
||||||
<Input
|
id="user-form-username"
|
||||||
placeholder="Username"
|
title="Username"
|
||||||
onChange={(e) => {
|
label="Username"
|
||||||
setForm((prev) => ({
|
placeholder="ada"
|
||||||
...prev,
|
autoComplete="off"
|
||||||
username: e.target.value,
|
info="Lowercase letters, numbers, dots, dashes or underscores."
|
||||||
}));
|
onChange={(e) => {
|
||||||
}}
|
setForm((prev) => ({
|
||||||
defaultValue={form.username}
|
...prev,
|
||||||
showLabel
|
username: e.target.value,
|
||||||
/>
|
}));
|
||||||
</>
|
}}
|
||||||
|
defaultValue={form.username}
|
||||||
|
showLabel
|
||||||
|
required
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export default function RoutineForm({ cron_job, onCancel, onSaved }: Props) {
|
|||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
if (loading) return;
|
if (loading) return;
|
||||||
|
if (!window.confirm(`Save ${title || "this routine"}?`)) return;
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(undefined);
|
setError(undefined);
|
||||||
|
|||||||
@@ -35,5 +35,5 @@ export default function useSettingsList<T extends { id?: number | string | "" }>
|
|||||||
setStatus({ success: true, msg: res.msg || "Deleted." });
|
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"
|
className="w-full gap-0! items-stretch"
|
||||||
onSubmit={() => {
|
onSubmit={() => {
|
||||||
if (!dirty || loading) return;
|
if (!dirty || loading) return;
|
||||||
|
if (!window.confirm(has_password ? `Change your password?` : `Set your password?`)) return;
|
||||||
submitPasswordForm({ ...init, has_password, onDone });
|
submitPasswordForm({ ...init, has_password, onDone });
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export default function EnvForm({ scope, env, known, onSaved, onCancel }: Props)
|
|||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
if (loading) return;
|
if (loading) return;
|
||||||
|
if (!window.confirm(env?.id ? `Save ${env.key}?` : `Add ${key || "this env"}?`)) return;
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(undefined);
|
setError(undefined);
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ export default function HarnessSettingsForm({
|
|||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
if (!dirty || loading) return;
|
if (!dirty || loading) return;
|
||||||
|
if (!window.confirm(`Save these model settings?`)) return;
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setStatus({});
|
setStatus({});
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export default function ProviderForm({ provider, onSaved, onCancel }: Props) {
|
|||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
if (loading) return;
|
if (loading) return;
|
||||||
|
if (!window.confirm(provider?.id ? `Save ${provider.name || "this provider"}?` : `Add ${name || "this provider"}?`)) return;
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(undefined);
|
setError(undefined);
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ export default function ProfileForm({ person }: Props) {
|
|||||||
className="w-full gap-0! items-stretch"
|
className="w-full gap-0! items-stretch"
|
||||||
onSubmit={() => {
|
onSubmit={() => {
|
||||||
if (!dirty || loading) return;
|
if (!dirty || loading) return;
|
||||||
|
if (!window.confirm(`Save your profile?`)) return;
|
||||||
submitProfileForm({
|
submitProfileForm({
|
||||||
...init,
|
...init,
|
||||||
onSaved: ({ saved, photo_src, msg }) => {
|
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 AddModalShell from "@/src/layouts/bots/(partials)/add-modal-shell";
|
||||||
import type { BUN_SQLITE_COBBLE_USERS_JOIN } from "@/src/types/sql-joins";
|
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 = {
|
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -14,13 +14,14 @@ type Props = {
|
|||||||
actor_user_types?: BUN_SQLITE_COBBLE_USER_TYPES[] | null;
|
actor_user_types?: BUN_SQLITE_COBBLE_USER_TYPES[] | null;
|
||||||
busy?: boolean;
|
busy?: boolean;
|
||||||
onEdit: () => void;
|
onEdit: () => void;
|
||||||
|
onRevoke: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One user: their photo, name, user types and email, then Edit and
|
* One user: their photo, name, user types and email, then Edit, Revoke
|
||||||
* Delete where the actor may (`canUpdateUser`, `canRevokeUser`; never
|
* (not once revoked) and Delete where the actor may (`canUpdateUser`,
|
||||||
* deleting yourself).
|
* `canRevokeUser`; never revoking or deleting yourself).
|
||||||
*/
|
*/
|
||||||
export default function UserSettingsRow({
|
export default function UserSettingsRow({
|
||||||
person,
|
person,
|
||||||
@@ -28,6 +29,7 @@ export default function UserSettingsRow({
|
|||||||
actor_user_types,
|
actor_user_types,
|
||||||
busy,
|
busy,
|
||||||
onEdit,
|
onEdit,
|
||||||
|
onRevoke,
|
||||||
onDelete,
|
onDelete,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const name =
|
const name =
|
||||||
@@ -42,6 +44,7 @@ export default function UserSettingsRow({
|
|||||||
const target_user_types = user_types.map((user_type) => ({ user_type }));
|
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_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_delete = !is_self && canRevokeUser({ user_types: actor_user_types, target_user_types });
|
||||||
|
const can_revoke = can_delete && !user_types.includes("revoked");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsListRow
|
<SettingsListRow
|
||||||
@@ -58,6 +61,14 @@ export default function UserSettingsRow({
|
|||||||
onClick={onEdit}
|
onClick={onEdit}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
{can_revoke ? (
|
||||||
|
<SettingsRowAction
|
||||||
|
title={`Revoke ${name}`}
|
||||||
|
label="Revoke"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={onRevoke}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
{can_delete ? (
|
{can_delete ? (
|
||||||
<SettingsRowAction
|
<SettingsRowAction
|
||||||
title={`Delete ${name}`}
|
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 SettingsStatus from "../../(partials)/settings-status";
|
||||||
import UserEditModal from "../(partials)/user-edit-modal";
|
import UserEditModal from "../(partials)/user-edit-modal";
|
||||||
import UserSettingsRow from "../(partials)/user-settings-row";
|
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
|
* 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
|
* with a password, edit one (photo, name, email, username, a new
|
||||||
* types), or delete one with everything that's theirs. What each person
|
* password, user types), revoke one, or delete one with everything
|
||||||
|
* that's theirs. What each person
|
||||||
* may do here is the RBAC helpers' call.
|
* may do here is the RBAC helpers' call.
|
||||||
*/
|
*/
|
||||||
export default function UsersSettingsSection() {
|
export default function UsersSettingsSection() {
|
||||||
const { pageProps } = useContext(AppContext);
|
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.
|
* The user being edited, or `true` while adding one.
|
||||||
*/
|
*/
|
||||||
const [editing, setEditing] = useState<BUN_SQLITE_COBBLE_USERS_JOIN | true>();
|
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 (
|
return (
|
||||||
<SettingsGroup
|
<SettingsGroup
|
||||||
title="People"
|
title="People"
|
||||||
@@ -39,6 +67,7 @@ export default function UsersSettingsSection() {
|
|||||||
actor_user_types={pageProps?.user_types}
|
actor_user_types={pageProps?.user_types}
|
||||||
busy={busy}
|
busy={busy}
|
||||||
onEdit={() => setEditing(person)}
|
onEdit={() => setEditing(person)}
|
||||||
|
onRevoke={() => revoke(person)}
|
||||||
onDelete={() =>
|
onDelete={() =>
|
||||||
remove({
|
remove({
|
||||||
id: person.id,
|
id: person.id,
|
||||||
@@ -70,11 +99,7 @@ export default function UsersSettingsSection() {
|
|||||||
open={Boolean(editing)}
|
open={Boolean(editing)}
|
||||||
onClose={() => setEditing(undefined)}
|
onClose={() => setEditing(undefined)}
|
||||||
onSaved={({ person, msg }) => {
|
onSaved={({ person, msg }) => {
|
||||||
setItems((prev) =>
|
savePerson(person);
|
||||||
prev.some((p) => p.id == person.id)
|
|
||||||
? prev.map((p) => (p.id == person.id ? person : p))
|
|
||||||
: [...prev, person],
|
|
||||||
);
|
|
||||||
setEditing(undefined);
|
setEditing(undefined);
|
||||||
setStatus({ success: true, msg });
|
setStatus({ success: true, msg });
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export default function WorkspaceBotModal({ workspace_id, workspace_name, bot, o
|
|||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
if (loading) return;
|
if (loading) return;
|
||||||
|
if (!window.confirm(`Save ${bot.name || "this bot"}'s instructions and memory here?`)) return;
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(undefined);
|
setError(undefined);
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export default function WorkspaceDetailsGroup({ workspace, onSaved }: Props) {
|
|||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
if (!dirty || loading) return;
|
if (!dirty || loading) return;
|
||||||
|
if (!window.confirm(`Save ${name || "this workspace"}?`)) return;
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setStatus(undefined);
|
setStatus(undefined);
|
||||||
|
|||||||
@@ -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 ensureMainComputer from "@/src/functions/backend/db/computers/ensure-main-computer";
|
||||||
import grabUsers from "@/src/functions/backend/db/users/grab-users";
|
import grabUsers from "@/src/functions/backend/db/users/grab-users";
|
||||||
import saveUser from "@/src/functions/backend/db/users/save-user";
|
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 logEvent, { grabRequestIP, logError } from "@/src/functions/backend/logger";
|
||||||
import { SaveUserRequestSchema } from "@/src/schemas/user-schema";
|
import { SaveUserRequestSchema } from "@/src/schemas/user-schema";
|
||||||
import type { BUN_SQLITE_COBBLE_USERS_JOIN } from "@/src/types/sql-joins";
|
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`):
|
* Creates a user (`canCreateUser`) or updates one (`canUpdateUser`):
|
||||||
* name, email, username, password and user types, each type checked
|
* name, email, username, password, user types (each checked against the
|
||||||
* against the assignment matrix. A new user gets their coordinator and main computer
|
* 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.
|
* straight away, as at signup. Returns the user.
|
||||||
*/
|
*/
|
||||||
export const handler: BunextAPIRouteHandler<APIResponseObject<BUN_SQLITE_COBBLE_USERS_JOIN>> = async ({ req, body }) => {
|
export const handler: BunextAPIRouteHandler<APIResponseObject<BUN_SQLITE_COBBLE_USERS_JOIN>> = async ({ req, body }) => {
|
||||||
@@ -59,6 +60,10 @@ export const handler: BunextAPIRouteHandler<APIResponseObject<BUN_SQLITE_COBBLE_
|
|||||||
}
|
}
|
||||||
const { user_id, created } = await saveUser({ ...data, actor_user_types: user_types || [] });
|
const { user_id, created } = await saveUser({ ...data, actor_user_types: user_types || [] });
|
||||||
|
|
||||||
|
if (data.image) {
|
||||||
|
await setUserProfileImage({ user_id, data_url: data.image });
|
||||||
|
}
|
||||||
|
|
||||||
if (created) {
|
if (created) {
|
||||||
/**
|
/**
|
||||||
* The account exists now; `/admin/bots` creates both on their
|
* The account exists now; `/admin/bots` creates both on their
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ export const SaveUserRequestSchema = z
|
|||||||
* as they are.
|
* as they are.
|
||||||
*/
|
*/
|
||||||
user_types: z.array(z.enum(UserTypes.map((type) => type.value))).default([]),
|
user_types: z.array(z.enum(UserTypes.map((type) => 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, {
|
.refine((data) => data.password == data.confirm_password, {
|
||||||
message: "The passwords don't match",
|
message: "The passwords don't match",
|
||||||
|
|||||||
+1
-1
@@ -152,7 +152,7 @@
|
|||||||
--radius-default-lg: 10px;
|
--radius-default-lg: 10px;
|
||||||
--radius-default-xl: 14px;
|
--radius-default-xl: 14px;
|
||||||
|
|
||||||
--container-container: 960px;
|
--container-container: 870px;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
|
|||||||
@@ -755,6 +755,8 @@ export type AdminFetchParams<T extends {} = BUN_SQLITE_COBBLE_ALL_TYPEDEFS> = {
|
|||||||
|
|
||||||
export type UserFormObject = BUN_SQLITE_COBBLE_USERS & {
|
export type UserFormObject = BUN_SQLITE_COBBLE_USERS & {
|
||||||
user_types?: UserType[];
|
user_types?: UserType[];
|
||||||
|
password?: string;
|
||||||
|
confirm_password?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PageQueryObject = {
|
export type PageQueryObject = {
|
||||||
|
|||||||
Reference in New Issue
Block a user