diff --git a/web/src/refresh-pages/admin/ServiceAccountsPage/EditServiceAccountModal.tsx b/web/src/refresh-pages/admin/ServiceAccountsPage/EditServiceAccountModal.tsx new file mode 100644 index 0000000000..9ec764ea7b --- /dev/null +++ b/web/src/refresh-pages/admin/ServiceAccountsPage/EditServiceAccountModal.tsx @@ -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, b: Set): 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( + 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 | 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(null); + const contentRef = useCallback((node: HTMLDivElement | null) => { + setContentEl(node); + }, []); + + return ( + !isOpen && !isSubmitting && onClose()} + > + + + +
+
+ + + {/* asChild merges trigger props onto this div instead of rendering a
+ + + + setSelectedRole(v as UserRole)} + > + + + {SERVICE_ACCOUNT_ROLE_OPTIONS.map((opt) => ( + + {USER_ROLE_LABELS[opt.role]} + + ))} + + + } + /> +
+
+ + + + + +
+
+ ); +} diff --git a/web/src/refresh-pages/admin/ServiceAccountsPage/index.tsx b/web/src/refresh-pages/admin/ServiceAccountsPage/index.tsx index 67ccb6fe40..2c363e13a5 100644 --- a/web/src/refresh-pages/admin/ServiceAccountsPage/index.tsx +++ b/web/src/refresh-pages/admin/ServiceAccountsPage/index.tsx @@ -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(null); const [deleteTarget, setDeleteTarget] = useState(null); + const [groupsRolesTarget, setGroupsRolesTarget] = useState( + null + ); const visibleApiKeys = (apiKeys ?? []).filter( (key) => key.api_key_name !== DISCORD_SERVICE_API_KEY_NAME @@ -174,27 +179,16 @@ export default function ServiceAccountsPage() { > - - {USER_ROLE_LABELS[UserRole.ADMIN]} - - - {USER_ROLE_LABELS[UserRole.BASIC]} - - - {USER_ROLE_LABELS[UserRole.LIMITED]} - + {SERVICE_ACCOUNT_ROLE_OPTIONS.map((opt) => ( + + {USER_ROLE_LABELS[opt.role]} + + ))} ), @@ -218,6 +212,12 @@ export default function ServiceAccountsPage() { + setGroupsRolesTarget(row)} + > + Groups & Roles + { @@ -390,6 +390,14 @@ export default function ServiceAccountsPage() { /> )} + {groupsRolesTarget && ( + setGroupsRolesTarget(null)} + onMutate={() => mutate(API_KEY_SWR_KEY)} + /> + )} + {regenerateTarget && (