mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-10-23 03:42:59 -05:00
feat(service-accounts): add Groups & Roles modal (#12174)
This commit is contained in:
1 parent
8db2dce9b8
commit
de656baeda
3 files changed
+411
-25
No files matched your search
@@ -0,0 +1,351 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo, useCallback, useEffect } from "react";
|
||||
import { Button, Divider } from "@opal/components";
|
||||
import { SvgUsers, SvgLogOut, SvgCheck } from "@opal/icons";
|
||||
import { ContentAction } from "@opal/layouts";
|
||||
import Modal from "@/refresh-components/Modal";
|
||||
import { InputTypeIn } from "@opal/components";
|
||||
import InputSelect from "@/refresh-components/inputs/InputSelect";
|
||||
import { Popover } from "@opal/components";
|
||||
import LineItem from "@/refresh-components/buttons/LineItem";
|
||||
import { ShadowDiv } from "@opal/components";
|
||||
import { Tooltip } from "@opal/components";
|
||||
import { Section } from "@/layouts/general-layouts";
|
||||
import { toast } from "@/hooks/useToast";
|
||||
import { UserRole, USER_ROLE_LABELS } from "@/lib/types";
|
||||
import useGroups from "@/hooks/useGroups";
|
||||
import {
|
||||
addUserToGroup,
|
||||
removeUserFromGroup,
|
||||
} from "@/refresh-pages/admin/UsersPage/svc";
|
||||
import { updateApiKey } from "@/refresh-pages/admin/ServiceAccountsPage/svc";
|
||||
import { SERVICE_ACCOUNT_ROLE_OPTIONS } from "@/refresh-pages/admin/ServiceAccountsPage/interfaces";
|
||||
import type { APIKey } from "@/refresh-pages/admin/ServiceAccountsPage/interfaces";
|
||||
import { cn } from "@opal/utils";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const setsAreEqual = (a: Set<number>, b: Set<number>): boolean =>
|
||||
a.size === b.size && Array.from(a).every((value) => b.has(value));
|
||||
|
||||
interface EditServiceAccountModalProps {
|
||||
apiKey: APIKey;
|
||||
onClose: () => void;
|
||||
onMutate: () => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function EditServiceAccountModal({
|
||||
apiKey,
|
||||
onClose,
|
||||
onMutate,
|
||||
}: EditServiceAccountModalProps) {
|
||||
const {
|
||||
data: allGroups,
|
||||
isLoading: groupsLoading,
|
||||
refreshGroups,
|
||||
} = useGroups();
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [popoverOpen, setPopoverOpen] = useState(false);
|
||||
const [selectedRole, setSelectedRole] = useState<UserRole>(
|
||||
apiKey.api_key_role
|
||||
);
|
||||
|
||||
// The API-key's synthetic user is a real group member, so derive current
|
||||
// membership from the groups list (defaults are excluded server-side).
|
||||
const initialMemberGroupIds = useMemo(
|
||||
() =>
|
||||
new Set(
|
||||
(allGroups ?? [])
|
||||
.filter((g) => g.users.some((u) => u.id === apiKey.user_id))
|
||||
.map((g) => g.id)
|
||||
),
|
||||
[allGroups, apiKey.user_id]
|
||||
);
|
||||
|
||||
// null until the admin edits — lets the derived set populate after groups
|
||||
// load without an effect, while a non-null value holds explicit edits.
|
||||
const [editedGroupIds, setEditedGroupIds] = useState<Set<number> | null>(
|
||||
null
|
||||
);
|
||||
// Tracks the live membership until the admin starts editing, then freezes.
|
||||
// Save diffs against this baseline so it applies only this admin's deltas —
|
||||
// a concurrent edit by another admin to the same account isn't clobbered.
|
||||
const [baselineGroupIds, setBaselineGroupIds] = useState(
|
||||
initialMemberGroupIds
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (editedGroupIds === null) {
|
||||
setBaselineGroupIds((prev) =>
|
||||
setsAreEqual(prev, initialMemberGroupIds) ? prev : initialMemberGroupIds
|
||||
);
|
||||
}
|
||||
}, [initialMemberGroupIds, editedGroupIds]);
|
||||
|
||||
const memberGroupIds = editedGroupIds ?? initialMemberGroupIds;
|
||||
|
||||
const dropdownGroups = useMemo(() => {
|
||||
if (!allGroups) return [];
|
||||
if (searchTerm.length === 0) return allGroups;
|
||||
const lower = searchTerm.toLowerCase();
|
||||
return allGroups.filter((g) => g.name.toLowerCase().includes(lower));
|
||||
}, [allGroups, searchTerm]);
|
||||
|
||||
const joinedGroups = useMemo(() => {
|
||||
if (!allGroups) return [];
|
||||
return allGroups.filter((g) => memberGroupIds.has(g.id));
|
||||
}, [allGroups, memberGroupIds]);
|
||||
|
||||
const hasGroupChanges = useMemo(() => {
|
||||
if (memberGroupIds.size !== baselineGroupIds.size) return true;
|
||||
return Array.from(memberGroupIds).some((id) => !baselineGroupIds.has(id));
|
||||
}, [memberGroupIds, baselineGroupIds]);
|
||||
|
||||
const hasRoleChange = selectedRole !== apiKey.api_key_role;
|
||||
const hasChanges = hasGroupChanges || hasRoleChange;
|
||||
|
||||
const toggleGroup = (groupId: number) => {
|
||||
setEditedGroupIds((prev) => {
|
||||
const next = new Set(prev ?? baselineGroupIds);
|
||||
if (next.has(groupId)) {
|
||||
next.delete(groupId);
|
||||
} else {
|
||||
next.add(groupId);
|
||||
}
|
||||
return setsAreEqual(next, baselineGroupIds) ? null : next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const toAdd = Array.from(memberGroupIds).filter(
|
||||
(id) => !baselineGroupIds.has(id)
|
||||
);
|
||||
const toRemove = Array.from(baselineGroupIds).filter(
|
||||
(id) => !memberGroupIds.has(id)
|
||||
);
|
||||
|
||||
for (const groupId of toAdd) {
|
||||
await addUserToGroup(groupId, apiKey.user_id);
|
||||
}
|
||||
// Removal replaces the group's member list with the cached snapshot
|
||||
// minus this account; concurrent edits to the same group lose.
|
||||
for (const groupId of toRemove) {
|
||||
const group = allGroups?.find((g) => g.id === groupId);
|
||||
if (group) {
|
||||
const currentUserIds = group.users.map((u) => u.id);
|
||||
const ccPairIds = group.cc_pairs.map((cc) => cc.id);
|
||||
await removeUserFromGroup(
|
||||
groupId,
|
||||
currentUserIds,
|
||||
apiKey.user_id,
|
||||
ccPairIds
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedRole !== apiKey.api_key_role) {
|
||||
const res = await updateApiKey(apiKey.api_key_id, {
|
||||
name: apiKey.api_key_name ?? undefined,
|
||||
role: selectedRole,
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error((await res.text()) || "Failed to update role");
|
||||
}
|
||||
}
|
||||
|
||||
onMutate();
|
||||
refreshGroups();
|
||||
toast.success("Service account updated");
|
||||
onClose();
|
||||
} catch (err) {
|
||||
// Partial writes may have landed — refresh both caches.
|
||||
onMutate();
|
||||
refreshGroups();
|
||||
toast.error(err instanceof Error ? err.message : "An error occurred");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const displayName = apiKey.api_key_name || "Unnamed";
|
||||
const [contentEl, setContentEl] = useState<HTMLDivElement | null>(null);
|
||||
const contentRef = useCallback((node: HTMLDivElement | null) => {
|
||||
setContentEl(node);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
onOpenChange={(isOpen) => !isOpen && !isSubmitting && onClose()}
|
||||
>
|
||||
<Modal.Content width="sm" ref={contentRef}>
|
||||
<Modal.Header
|
||||
icon={SvgUsers}
|
||||
title={`Edit ${displayName}'s Groups & Roles`}
|
||||
description={apiKey.api_key_display}
|
||||
onClose={isSubmitting ? undefined : onClose}
|
||||
/>
|
||||
<Modal.Body twoTone>
|
||||
<Section padding={0} height="auto" alignItems="stretch">
|
||||
<Section
|
||||
gap={0.5}
|
||||
padding={0.25}
|
||||
height={joinedGroups.length === 0 && !popoverOpen ? "auto" : 14.5}
|
||||
alignItems="stretch"
|
||||
justifyContent="start"
|
||||
className="bg-background-tint-02 rounded-08"
|
||||
>
|
||||
<Popover open={popoverOpen} onOpenChange={setPopoverOpen}>
|
||||
<Popover.Trigger asChild>
|
||||
{/* asChild merges trigger props onto this div instead of rendering a <button>.
|
||||
Without it, the trigger <button> would nest around InputTypeIn's
|
||||
internal IconButton <button>, causing a hydration error. */}
|
||||
<div>
|
||||
<InputTypeIn
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
placeholder="Search groups to join..."
|
||||
searchIcon
|
||||
/>
|
||||
</div>
|
||||
</Popover.Trigger>
|
||||
<Popover.Content
|
||||
width="trigger"
|
||||
align="start"
|
||||
container={contentEl}
|
||||
>
|
||||
{groupsLoading ? (
|
||||
<LineItem skeleton description="Loading groups...">
|
||||
Loading...
|
||||
</LineItem>
|
||||
) : dropdownGroups.length === 0 ? (
|
||||
<LineItem
|
||||
skeleton
|
||||
description="Try a different search term."
|
||||
>
|
||||
No groups found
|
||||
</LineItem>
|
||||
) : (
|
||||
<ShadowDiv
|
||||
shadowHeight="0.75rem"
|
||||
className={cn("flex flex-col gap-1 max-h-60 rounded-08")}
|
||||
>
|
||||
{dropdownGroups.map((group) => {
|
||||
const isMember = memberGroupIds.has(group.id);
|
||||
return (
|
||||
<LineItem
|
||||
key={group.id}
|
||||
icon={isMember ? SvgCheck : SvgUsers}
|
||||
description={`${group.users.length} ${
|
||||
group.users.length === 1 ? "user" : "users"
|
||||
}`}
|
||||
selected={isMember}
|
||||
emphasized={isMember}
|
||||
onClick={() => toggleGroup(group.id)}
|
||||
>
|
||||
{group.name}
|
||||
</LineItem>
|
||||
);
|
||||
})}
|
||||
</ShadowDiv>
|
||||
)}
|
||||
</Popover.Content>
|
||||
</Popover>
|
||||
|
||||
<ShadowDiv
|
||||
className={cn(" max-h-44 flex flex-col gap-1 rounded-08")}
|
||||
shadowHeight="0.75rem"
|
||||
>
|
||||
{joinedGroups.length === 0 ? (
|
||||
<LineItem
|
||||
icon={SvgUsers}
|
||||
skeleton
|
||||
interactive={false}
|
||||
description={`${displayName} is not in any groups.`}
|
||||
>
|
||||
No groups found
|
||||
</LineItem>
|
||||
) : (
|
||||
joinedGroups.map((group) => (
|
||||
<div
|
||||
key={group.id}
|
||||
className="bg-background-tint-01 rounded-08"
|
||||
>
|
||||
<LineItem
|
||||
key={group.id}
|
||||
icon={SvgUsers}
|
||||
description={`${group.users.length} ${
|
||||
group.users.length === 1 ? "user" : "users"
|
||||
}`}
|
||||
rightChildren={
|
||||
<Tooltip tooltip="Remove from group" side="left">
|
||||
<SvgLogOut height={16} width={16} />
|
||||
</Tooltip>
|
||||
}
|
||||
onClick={() => toggleGroup(group.id)}
|
||||
>
|
||||
{group.name}
|
||||
</LineItem>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</ShadowDiv>
|
||||
</Section>
|
||||
|
||||
<Divider paddingParallel="fit" paddingPerpendicular="fit" />
|
||||
|
||||
<ContentAction
|
||||
title="Account Role"
|
||||
description="This controls the service account's general permissions."
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
padding="fit"
|
||||
rightChildren={
|
||||
<InputSelect
|
||||
value={selectedRole}
|
||||
onValueChange={(v) => setSelectedRole(v as UserRole)}
|
||||
>
|
||||
<InputSelect.Trigger />
|
||||
<InputSelect.Content>
|
||||
{SERVICE_ACCOUNT_ROLE_OPTIONS.map((opt) => (
|
||||
<InputSelect.Item
|
||||
key={opt.role}
|
||||
value={opt.role.toString()}
|
||||
icon={opt.icon}
|
||||
description={opt.description}
|
||||
>
|
||||
{USER_ROLE_LABELS[opt.role]}
|
||||
</InputSelect.Item>
|
||||
))}
|
||||
</InputSelect.Content>
|
||||
</InputSelect>
|
||||
}
|
||||
/>
|
||||
</Section>
|
||||
</Modal.Body>
|
||||
|
||||
<Modal.Footer>
|
||||
<Button
|
||||
prominence="secondary"
|
||||
onClick={isSubmitting ? undefined : onClose}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button disabled={isSubmitting || !hasChanges} onClick={handleSave}>
|
||||
Save Changes
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal.Content>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -12,14 +12,12 @@ import SvgNoResult from "@opal/illustrations/no-result";
|
||||
import {
|
||||
SvgDownload,
|
||||
SvgKey,
|
||||
SvgLock,
|
||||
SvgMoreHorizontal,
|
||||
SvgRefreshCw,
|
||||
SvgTrash,
|
||||
SvgUser,
|
||||
SvgUserEdit,
|
||||
SvgUserKey,
|
||||
SvgUserManage,
|
||||
SvgUsers,
|
||||
SvgSimpleLoader,
|
||||
} from "@opal/icons";
|
||||
import { USER_ROLE_LABELS, UserRole } from "@/lib/types";
|
||||
@@ -41,8 +39,12 @@ import {
|
||||
updateApiKey,
|
||||
} from "@/refresh-pages/admin/ServiceAccountsPage/svc";
|
||||
import type { APIKey } from "@/refresh-pages/admin/ServiceAccountsPage/interfaces";
|
||||
import { DISCORD_SERVICE_API_KEY_NAME } from "@/refresh-pages/admin/ServiceAccountsPage/interfaces";
|
||||
import {
|
||||
DISCORD_SERVICE_API_KEY_NAME,
|
||||
SERVICE_ACCOUNT_ROLE_OPTIONS,
|
||||
} from "@/refresh-pages/admin/ServiceAccountsPage/interfaces";
|
||||
import ApiKeyFormModal from "@/refresh-pages/admin/ServiceAccountsPage/ApiKeyFormModal";
|
||||
import EditServiceAccountModal from "@/refresh-pages/admin/ServiceAccountsPage/EditServiceAccountModal";
|
||||
import { Table } from "@opal/components";
|
||||
import { createTableColumns } from "@opal/components/table/columns";
|
||||
import { Section } from "@/layouts/general-layouts";
|
||||
@@ -75,6 +77,9 @@ export default function ServiceAccountsPage() {
|
||||
const [search, setSearch] = useState("");
|
||||
const [regenerateTarget, setRegenerateTarget] = useState<APIKey | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<APIKey | null>(null);
|
||||
const [groupsRolesTarget, setGroupsRolesTarget] = useState<APIKey | null>(
|
||||
null
|
||||
);
|
||||
|
||||
const visibleApiKeys = (apiKeys ?? []).filter(
|
||||
(key) => key.api_key_name !== DISCORD_SERVICE_API_KEY_NAME
|
||||
@@ -174,27 +179,16 @@ export default function ServiceAccountsPage() {
|
||||
>
|
||||
<InputSelect.Trigger />
|
||||
<InputSelect.Content>
|
||||
<InputSelect.Item
|
||||
value={UserRole.ADMIN.toString()}
|
||||
icon={SvgUserManage}
|
||||
description="Unrestricted admin access to all endpoints."
|
||||
>
|
||||
{USER_ROLE_LABELS[UserRole.ADMIN]}
|
||||
</InputSelect.Item>
|
||||
<InputSelect.Item
|
||||
value={UserRole.BASIC.toString()}
|
||||
icon={SvgUser}
|
||||
description="Standard user-level access to non-admin endpoints."
|
||||
>
|
||||
{USER_ROLE_LABELS[UserRole.BASIC]}
|
||||
</InputSelect.Item>
|
||||
<InputSelect.Item
|
||||
value={UserRole.LIMITED.toString()}
|
||||
icon={SvgLock}
|
||||
description="For agents: chat posting and read-only access to other endpoints."
|
||||
>
|
||||
{USER_ROLE_LABELS[UserRole.LIMITED]}
|
||||
</InputSelect.Item>
|
||||
{SERVICE_ACCOUNT_ROLE_OPTIONS.map((opt) => (
|
||||
<InputSelect.Item
|
||||
key={opt.role}
|
||||
value={opt.role.toString()}
|
||||
icon={opt.icon}
|
||||
description={opt.description}
|
||||
>
|
||||
{USER_ROLE_LABELS[opt.role]}
|
||||
</InputSelect.Item>
|
||||
))}
|
||||
</InputSelect.Content>
|
||||
</InputSelect>
|
||||
),
|
||||
@@ -218,6 +212,12 @@ export default function ServiceAccountsPage() {
|
||||
</Popover.Trigger>
|
||||
<Popover.Content side="bottom" align="end" width="md">
|
||||
<PopoverMenu>
|
||||
<LineItem
|
||||
icon={SvgUsers}
|
||||
onClick={() => setGroupsRolesTarget(row)}
|
||||
>
|
||||
Groups & Roles
|
||||
</LineItem>
|
||||
<LineItem
|
||||
icon={SvgUserEdit}
|
||||
onClick={() => {
|
||||
@@ -390,6 +390,14 @@ export default function ServiceAccountsPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{groupsRolesTarget && (
|
||||
<EditServiceAccountModal
|
||||
apiKey={groupsRolesTarget}
|
||||
onClose={() => setGroupsRolesTarget(null)}
|
||||
onMutate={() => mutate(API_KEY_SWR_KEY)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{regenerateTarget && (
|
||||
<ConfirmationModalLayout
|
||||
icon={SvgRefreshCw}
|
||||
|
||||
@@ -1,7 +1,34 @@
|
||||
import { UserRole } from "@/lib/types";
|
||||
import { SvgUserManage, SvgUser, SvgLock } from "@opal/icons";
|
||||
import type { IconFunctionComponent } from "@opal/types";
|
||||
|
||||
export const DISCORD_SERVICE_API_KEY_NAME = "discord-bot-service";
|
||||
|
||||
// Roles assignable to a service account, shared by the Account Type column and
|
||||
// the Groups & Roles modal so the option list lives in one place.
|
||||
export const SERVICE_ACCOUNT_ROLE_OPTIONS: {
|
||||
role: UserRole;
|
||||
icon: IconFunctionComponent;
|
||||
description: string;
|
||||
}[] = [
|
||||
{
|
||||
role: UserRole.ADMIN,
|
||||
icon: SvgUserManage,
|
||||
description: "Unrestricted admin access to all endpoints.",
|
||||
},
|
||||
{
|
||||
role: UserRole.BASIC,
|
||||
icon: SvgUser,
|
||||
description: "Standard user-level access to non-admin endpoints.",
|
||||
},
|
||||
{
|
||||
role: UserRole.LIMITED,
|
||||
icon: SvgLock,
|
||||
description:
|
||||
"For agents: chat posting and read-only access to other endpoints.",
|
||||
},
|
||||
];
|
||||
|
||||
export interface APIKey {
|
||||
api_key_id: number;
|
||||
api_key_display: string;
|
||||
|
||||
Reference in new issue
Block a user