Add dedicated bot, workspace and user settings pages
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
import { useState } from "react";
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import type { BUN_SQLITE_COBBLE_USER_TYPES } from "@/db/types/db";
|
||||
import type { BUN_SQLITE_COBBLE_USERS_JOIN } from "@/src/types/sql-joins";
|
||||
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
|
||||
import parseUserTypes from "@/src/utils/parse-user-types";
|
||||
import canRevokeUser from "@/src/utils/rbac/can-revoke-user";
|
||||
import revokeUser from "../../(functions)/revoke-user";
|
||||
import SettingsGroup from "../../../(partials)/settings-group";
|
||||
import SettingsStatus from "../../../(partials)/settings-status";
|
||||
|
||||
type Props = {
|
||||
person: BUN_SQLITE_COBBLE_USERS_JOIN;
|
||||
actor_user_id?: number | string;
|
||||
actor_user_types?: BUN_SQLITE_COBBLE_USER_TYPES[] | null;
|
||||
onUpdated: (person: BUN_SQLITE_COBBLE_USERS_JOIN) => void;
|
||||
};
|
||||
|
||||
type Status = { error?: boolean; success?: boolean; msg?: string };
|
||||
|
||||
/**
|
||||
* Revoking or deleting a user, where the actor may. Never yourself, and
|
||||
* never the super admin.
|
||||
*/
|
||||
export default function UserDangerGroup({
|
||||
person,
|
||||
actor_user_id,
|
||||
actor_user_types,
|
||||
onUpdated,
|
||||
}: Props) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [status, setStatus] = useState<Status>();
|
||||
const name = person.first_name || "this user";
|
||||
const user_types = parseUserTypes({ user_types: person.user_types });
|
||||
const is_self = person.id == actor_user_id;
|
||||
const target_user_types = user_types.map((user_type) => ({ user_type }));
|
||||
const can_delete =
|
||||
!is_self &&
|
||||
canRevokeUser({ user_types: actor_user_types, target_user_types });
|
||||
const can_revoke = can_delete && !user_types.includes("revoked");
|
||||
|
||||
const revoke = async () => {
|
||||
if (
|
||||
busy ||
|
||||
!window.confirm(
|
||||
`Revoke ${name}? 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;
|
||||
}
|
||||
|
||||
onUpdated(res.singleRes);
|
||||
setStatus({ success: true, msg: `${name} can't sign in now.` });
|
||||
};
|
||||
|
||||
const remove = async () => {
|
||||
if (
|
||||
busy ||
|
||||
!window.confirm(
|
||||
`Delete ${name} with their bots, workspaces and computers? Their computers' files stay on disk.`,
|
||||
)
|
||||
)
|
||||
return;
|
||||
|
||||
setBusy(true);
|
||||
setStatus(undefined);
|
||||
|
||||
const res = await postAdminApi({
|
||||
path: "users/delete-user",
|
||||
body: { user_id: person.id },
|
||||
});
|
||||
|
||||
if (res.success) {
|
||||
window.location.href = "/admin/settings/users";
|
||||
return;
|
||||
}
|
||||
|
||||
setBusy(false);
|
||||
setStatus({ error: true, msg: res.msg || "Couldn't delete them." });
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsGroup
|
||||
id="danger"
|
||||
title="Danger zone"
|
||||
description="Revoking keeps everything they had; deleting takes it with them."
|
||||
>
|
||||
{is_self ? (
|
||||
<Span className="text-[13px] leading-[18px] text-ink-secondary">
|
||||
This is you. You can't revoke or delete yourself here.
|
||||
</Span>
|
||||
) : !can_delete ? (
|
||||
<Span className="text-[13px] leading-[18px] text-ink-secondary">
|
||||
Nothing can be done here.
|
||||
</Span>
|
||||
) : (
|
||||
<Row className="w-full gap-2! flex-wrap items-center">
|
||||
{can_revoke ? (
|
||||
<Button
|
||||
title={`Revoke ${name}`}
|
||||
type="button"
|
||||
variant="outlined"
|
||||
color="gray"
|
||||
size="small"
|
||||
loading={busy}
|
||||
disabled={busy}
|
||||
onClick={revoke}
|
||||
>
|
||||
Revoke
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
title={`Delete ${name}`}
|
||||
type="button"
|
||||
variant="outlined"
|
||||
color="gray"
|
||||
size="small"
|
||||
loading={busy}
|
||||
disabled={busy}
|
||||
onClick={remove}
|
||||
>
|
||||
Delete user
|
||||
</Button>
|
||||
</Row>
|
||||
)}
|
||||
<SettingsStatus status={status} />
|
||||
</SettingsGroup>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useState } from "react";
|
||||
import type { BUN_SQLITE_COBBLE_USERS_JOIN } from "@/src/types/sql-joins";
|
||||
import UserForm from "@/src/pages/admin/people/create/(partials)/user-form";
|
||||
import SettingsGroup from "../../../(partials)/settings-group";
|
||||
import SettingsStatus from "../../../(partials)/settings-status";
|
||||
|
||||
type Props = {
|
||||
person: BUN_SQLITE_COBBLE_USERS_JOIN;
|
||||
onSaved: (person: BUN_SQLITE_COBBLE_USERS_JOIN) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* The user's profile, photo, user types, access and password, saved
|
||||
* together. The same form as adding one, embedded on the page.
|
||||
*/
|
||||
export default function UserEditGroup({ person, onSaved }: Props) {
|
||||
const [status, setStatus] = useState<{ success?: boolean; msg?: string }>();
|
||||
|
||||
return (
|
||||
<SettingsGroup
|
||||
id="edit"
|
||||
title="Edit"
|
||||
description="Their photo, name, contact details, user types, what they reach and a new password."
|
||||
>
|
||||
<UserForm
|
||||
key={`${person.id}-${person.updated_at || ""}`}
|
||||
person={person}
|
||||
embedded
|
||||
onSaved={({ person: saved, msg }) => {
|
||||
onSaved(saved);
|
||||
setStatus({ success: true, msg });
|
||||
}}
|
||||
/>
|
||||
<SettingsStatus status={status} />
|
||||
</SettingsGroup>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import UserAvatar from "@/src/components/general/user-avatar";
|
||||
import { UserTypes } from "@/src/dict/user-types-dict";
|
||||
import type { BUN_SQLITE_COBBLE_BOTS, BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
|
||||
import type { BUN_SQLITE_COBBLE_USERS_JOIN } from "@/src/types/sql-joins";
|
||||
import parseIdList from "@/src/utils/parse-id-list";
|
||||
import parseUserTypes from "@/src/utils/parse-user-types";
|
||||
import SettingsGroup from "../../../(partials)/settings-group";
|
||||
|
||||
type Props = {
|
||||
person: BUN_SQLITE_COBBLE_USERS_JOIN;
|
||||
/**
|
||||
* The session user, to say "You".
|
||||
*/
|
||||
actor_user_id?: number | string;
|
||||
/**
|
||||
* Workers and workspaces a member reaches.
|
||||
*/
|
||||
crew_bots: BUN_SQLITE_COBBLE_BOTS[];
|
||||
crew_workspaces: BUN_SQLITE_COBBLE_WORKSPACES[];
|
||||
};
|
||||
|
||||
/**
|
||||
* A user at a glance: their photo, name, contact details, user types and
|
||||
* what they reach.
|
||||
*/
|
||||
export default function UserOverviewGroup({
|
||||
person,
|
||||
actor_user_id,
|
||||
crew_bots,
|
||||
crew_workspaces,
|
||||
}: Props) {
|
||||
const name =
|
||||
[person.first_name, person.last_name].filter(Boolean).join(" ") ||
|
||||
person.email ||
|
||||
`User ${person.id}`;
|
||||
const user_types = parseUserTypes({ user_types: person.user_types });
|
||||
const is_self = person.id == actor_user_id;
|
||||
const reaches_everything =
|
||||
user_types.includes("super_admin") || user_types.includes("admin");
|
||||
const bot_ids = parseIdList({ ids: person.bot_ids });
|
||||
const workspace_ids = parseIdList({ ids: person.workspace_ids });
|
||||
const bot_names = crew_bots
|
||||
.filter((bot) => bot_ids.includes(Number(bot.id)))
|
||||
.map((bot) => bot.name || `Bot ${bot.id}`);
|
||||
const workspace_names = crew_workspaces
|
||||
.filter((workspace) => workspace_ids.includes(Number(workspace.id)))
|
||||
.map((workspace) => workspace.name || `Workspace ${workspace.id}`);
|
||||
const access = reaches_everything
|
||||
? "Every worker and workspace."
|
||||
: [
|
||||
bot_names.length
|
||||
? `Bots: ${bot_names.join(", ")}`
|
||||
: "No bots.",
|
||||
workspace_names.length
|
||||
? `Workspaces: ${workspace_names.join(", ")}`
|
||||
: "No workspaces.",
|
||||
].join(" ");
|
||||
|
||||
return (
|
||||
<SettingsGroup
|
||||
id="overview"
|
||||
title="Overview"
|
||||
description="Who this is, and what they can reach."
|
||||
>
|
||||
<Row className="w-full gap-4! flex-nowrap items-start">
|
||||
<UserAvatar person={person} size="lg" />
|
||||
<Stack className="grow min-w-0 gap-1!">
|
||||
<Row className="gap-2! flex-nowrap items-center">
|
||||
<Span className="text-sm font-bold leading-5 text-ink truncate">
|
||||
{name}
|
||||
</Span>
|
||||
{is_self ? (
|
||||
<Span className="shrink-0 text-[11px] font-bold leading-4 text-ink-secondary">
|
||||
You
|
||||
</Span>
|
||||
) : null}
|
||||
</Row>
|
||||
<Span className="text-[13px] leading-[18px] text-ink-secondary truncate w-full">
|
||||
{[person.email, person.username].filter(Boolean).join(" · ")}
|
||||
</Span>
|
||||
<Span className="text-[13px] leading-[18px] text-ink-secondary">
|
||||
{UserTypes.filter((type) =>
|
||||
user_types.includes(type.value),
|
||||
)
|
||||
.map((type) => type.title)
|
||||
.join(", ") || "No user types"}
|
||||
</Span>
|
||||
<Span className="text-[13px] leading-[18px] text-ink-secondary">
|
||||
{access}
|
||||
</Span>
|
||||
</Stack>
|
||||
</Row>
|
||||
</SettingsGroup>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useContext, useState } from "react";
|
||||
import { AppContext } from "@/src/pages/__root";
|
||||
import { UserTypes } from "@/src/dict/user-types-dict";
|
||||
import type { BUN_SQLITE_COBBLE_USERS_JOIN } from "@/src/types/sql-joins";
|
||||
import parseUserTypes from "@/src/utils/parse-user-types";
|
||||
import SettingsShell from "../../../(partials)/settings-shell";
|
||||
import UserDangerGroup from "../(partials)/user-danger-group";
|
||||
import UserEditGroup from "../(partials)/user-edit-group";
|
||||
import UserOverviewGroup from "../(partials)/user-overview-group";
|
||||
|
||||
/**
|
||||
* One user's settings: overview, the form that edits them, and revoking
|
||||
* or deleting them where the actor may.
|
||||
*/
|
||||
export default function UserSettingsSection() {
|
||||
const { pageProps } = useContext(AppContext);
|
||||
const [person, setPerson] = useState<
|
||||
BUN_SQLITE_COBBLE_USERS_JOIN | undefined
|
||||
>(pageProps?.target_person || undefined);
|
||||
|
||||
if (!person?.id) return null;
|
||||
|
||||
const name =
|
||||
[person.first_name, person.last_name].filter(Boolean).join(" ") ||
|
||||
person.email ||
|
||||
`User ${person.id}`;
|
||||
const user_types = parseUserTypes({ user_types: person.user_types });
|
||||
const types_title = UserTypes.filter((type) =>
|
||||
user_types.includes(type.value),
|
||||
)
|
||||
.map((type) => type.title)
|
||||
.join(", ");
|
||||
|
||||
return (
|
||||
<SettingsShell
|
||||
title={name}
|
||||
description={
|
||||
types_title || person.email || "A user on this install."
|
||||
}
|
||||
breadcrumb_title={name}
|
||||
>
|
||||
<UserOverviewGroup
|
||||
person={person}
|
||||
actor_user_id={pageProps?.user?.id}
|
||||
crew_bots={pageProps?.crew_bots || []}
|
||||
crew_workspaces={pageProps?.crew_workspaces || []}
|
||||
/>
|
||||
<UserEditGroup person={person} onSaved={setPerson} />
|
||||
<UserDangerGroup
|
||||
person={person}
|
||||
actor_user_id={pageProps?.user?.id}
|
||||
actor_user_types={pageProps?.user_types}
|
||||
onUpdated={setPerson}
|
||||
/>
|
||||
</SettingsShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { PagePropsType, PageQueryObject } from "@/src/types";
|
||||
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
|
||||
import { SettingsDefaultPath } from "@/src/dict/settings-pages-dict";
|
||||
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||
import grabCrewOptions from "@/src/functions/backend/db/access/grab-crew-options";
|
||||
import grabUsers from "@/src/functions/backend/db/users/grab-users";
|
||||
import sanitizeUserBio from "@/src/functions/backend/db/users/sanitize-user-bio";
|
||||
import canListUsers from "@/src/utils/rbac/can-list-users";
|
||||
import grabSettingsServerProps from "../../(functions)/grab-settings-server-props";
|
||||
|
||||
/**
|
||||
* One user's settings, for those who may list users (`canListUsers`):
|
||||
* their profile, user types, access and danger zone.
|
||||
*/
|
||||
export const server: BunextPageServerFn<PagePropsType> = async (params) => {
|
||||
const settings = await grabSettingsServerProps(params);
|
||||
|
||||
if (!settings.props) return settings;
|
||||
|
||||
const { user_types } = await userAuth({ req: params.req });
|
||||
|
||||
if (!canListUsers({ user_types })) {
|
||||
return { redirect: { destination: SettingsDefaultPath, permanent: false } };
|
||||
}
|
||||
|
||||
const query: PageQueryObject = params.query;
|
||||
const target_person = (
|
||||
await grabUsers({ user_id: String(query.user_id) })
|
||||
).singleRes;
|
||||
|
||||
if (!target_person?.id) {
|
||||
return { redirect: { destination: "/admin/settings/users", permanent: false } };
|
||||
}
|
||||
|
||||
return {
|
||||
props: {
|
||||
...settings.props,
|
||||
target_person: sanitizeUserBio({ user: target_person }),
|
||||
...(await grabCrewOptions()),
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
import UserSettingsSection from "./(sections)/user-settings-section";
|
||||
import settingsRecordPageMeta from "../../(functions)/settings-record-page-meta";
|
||||
|
||||
export default function AdminSettingsUserPage() {
|
||||
return <UserSettingsSection />;
|
||||
}
|
||||
|
||||
export const meta = settingsRecordPageMeta({ kind: "users" });
|
||||
Reference in New Issue
Block a user