fix(admin): fix null handling, status-aware actions, data loading, and UI polish

- Add null guards for invited/pending users (id: null, role: null) across
  EditGroupsModal, GroupsCell, UserRowActions
- Add status-aware action menus per user status (Active, Inactive, Invited, Requested)
- Add cancelInvite and approveRequest service functions
- Switch useAdminUsers to /api/manage/users/accepted with toUserRow() mapper
- Add UserStatus enum, USER_STATUS_LABELS, and StatusFilter type
- Fix dark mode avatars with bg-background-neutral-inverted-00 + Text inverted
- Switch page layout to width="full" to prevent table overflow
- Reduce column weights for better space distribution
- Add GroupsCell component and filter-plus icon
This commit is contained in:
Nik
2026-03-09 12:20:32 -07:00
parent 18f7613c2e
commit f494089b10
16 changed files with 735 additions and 172 deletions

View File

@@ -9,6 +9,8 @@ import { cn } from "@opal/utils";
type TagColor = "green" | "purple" | "blue" | "gray" | "amber";
type TagSize = "sm" | "md";
interface TagProps {
/** Optional icon component. */
icon?: IconFunctionComponent;
@@ -18,6 +20,9 @@ interface TagProps {
/** Color variant. Default: `"gray"`. */
color?: TagColor;
/** Size variant. Default: `"sm"`. */
size?: TagSize;
}
// ---------------------------------------------------------------------------
@@ -36,11 +41,11 @@ const COLOR_CONFIG: Record<TagColor, { bg: string; text: string }> = {
// Tag
// ---------------------------------------------------------------------------
function Tag({ icon: Icon, title, color = "gray" }: TagProps) {
function Tag({ icon: Icon, title, color = "gray", size = "sm" }: TagProps) {
const config = COLOR_CONFIG[color];
return (
<div className={cn("opal-auxiliary-tag", config.bg)}>
<div className={cn("opal-auxiliary-tag", config.bg)} data-size={size}>
{Icon && (
<div className="opal-auxiliary-tag-icon-container">
<Icon className={cn("opal-auxiliary-tag-icon", config.text)} />
@@ -48,7 +53,8 @@ function Tag({ icon: Icon, title, color = "gray" }: TagProps) {
)}
<span
className={cn(
"opal-auxiliary-tag-title px-[2px] font-figure-small-value",
"opal-auxiliary-tag-title px-[2px]",
size === "md" ? "font-secondary-body" : "font-figure-small-value",
config.text
)}
>
@@ -58,4 +64,4 @@ function Tag({ icon: Icon, title, color = "gray" }: TagProps) {
);
}
export { Tag, type TagProps, type TagColor };
export { Tag, type TagProps, type TagColor, type TagSize };

View File

@@ -13,6 +13,12 @@
gap: 0;
}
.opal-auxiliary-tag[data-size="md"] {
height: 1.375rem;
padding: 0 0.375rem;
border-radius: 0.375rem;
}
.opal-auxiliary-tag-icon-container {
display: flex;
align-items: center;

View File

@@ -0,0 +1,21 @@
import type { IconProps } from "@opal/types";
const SvgFilterPlus = ({ size, ...props }: IconProps) => (
<svg
width={size}
height={size}
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
stroke="currentColor"
{...props}
>
<path
d="M9.5 12.5L6.83334 11.1667V7.80667L1.5 1.5H14.8333L12.1667 4.65333M12.1667 7V9.5M12.1667 9.5V12M12.1667 9.5H9.66667M12.1667 9.5H14.6667"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
export default SvgFilterPlus;

View File

@@ -72,6 +72,7 @@ export { default as SvgFileChartPie } from "@opal/icons/file-chart-pie";
export { default as SvgFileSmall } from "@opal/icons/file-small";
export { default as SvgFileText } from "@opal/icons/file-text";
export { default as SvgFilter } from "@opal/icons/filter";
export { default as SvgFilterPlus } from "@opal/icons/filter-plus";
export { default as SvgFold } from "@opal/icons/fold";
export { default as SvgFolder } from "@opal/icons/folder";
export { default as SvgFolderIn } from "@opal/icons/folder-in";

View File

@@ -8,8 +8,65 @@ import SvgAlertTriangle from "@opal/icons/alert-triangle";
import SvgEdit from "@opal/icons/edit";
import SvgXOctagon from "@opal/icons/x-octagon";
import type { IconFunctionComponent } from "@opal/types";
import "@opal/components/tooltip.css";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { cn } from "@opal/utils";
import { useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
// ---------------------------------------------------------------------------
// Overflow tooltip helper
// ---------------------------------------------------------------------------
/** Returns a ref + boolean indicating whether the element's text is clipped. */
function useIsOverflowing<T extends HTMLElement>() {
const ref = useRef<T>(null);
const [overflowing, setOverflowing] = useState(false);
const check = useCallback(() => {
const el = ref.current;
if (!el) return;
setOverflowing(el.scrollWidth > el.clientWidth);
}, []);
useEffect(() => {
check();
window.addEventListener("resize", check);
return () => window.removeEventListener("resize", check);
}, [check]);
return { ref, overflowing, check };
}
/**
* Wraps children in a Radix tooltip that only appears when the element is
* overflowing (text truncated). Uses the same opal-tooltip styling as Button.
*/
function OverflowTooltip({
text,
overflowing,
children,
}: {
text: string;
overflowing: boolean;
children: React.ReactNode;
}) {
if (!overflowing) return children;
return (
<TooltipPrimitive.Root>
<TooltipPrimitive.Trigger asChild>{children}</TooltipPrimitive.Trigger>
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
className="opal-tooltip"
side="top"
sideOffset={4}
>
{text}
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
</TooltipPrimitive.Root>
);
}
// ---------------------------------------------------------------------------
// Types
@@ -142,6 +199,8 @@ function ContentMd({
const [editing, setEditing] = useState(false);
const [editValue, setEditValue] = useState(title);
const inputRef = useRef<HTMLInputElement>(null);
const titleOverflow = useIsOverflowing<HTMLSpanElement>();
const descOverflow = useIsOverflowing<HTMLDivElement>();
const config = CONTENT_MD_PRESETS[sizePreset];
@@ -211,18 +270,24 @@ function ContentMd({
/>
</div>
) : (
<span
className={cn(
"opal-content-md-title",
config.titleFont,
"text-text-04",
editable && "cursor-pointer"
)}
onClick={editable ? startEditing : undefined}
style={{ height: config.lineHeight }}
<OverflowTooltip
text={title}
overflowing={titleOverflow.overflowing}
>
{title}
</span>
<span
ref={titleOverflow.ref}
className={cn(
"opal-content-md-title",
config.titleFont,
"text-text-04",
editable && "cursor-pointer"
)}
onClick={editable ? startEditing : undefined}
style={{ height: config.lineHeight }}
>
{title}
</span>
</OverflowTooltip>
)}
{optional && (
@@ -275,9 +340,17 @@ function ContentMd({
</div>
{description && (
<div className="opal-content-md-description font-secondary-body text-text-03">
{description}
</div>
<OverflowTooltip
text={description}
overflowing={descOverflow.overflowing}
>
<div
ref={descOverflow.ref}
className="opal-content-md-description font-secondary-body text-text-03"
>
{description}
</div>
</OverflowTooltip>
)}
</div>
</div>

View File

@@ -224,7 +224,7 @@
--------------------------------------------------------------------------- */
.opal-content-md {
@apply flex flex-row items-start;
@apply flex flex-row items-start min-w-0;
}
/* ---------------------------------------------------------------------------
@@ -311,7 +311,7 @@
--------------------------------------------------------------------------- */
.opal-content-md-description {
@apply text-left w-full;
@apply text-left w-full truncate;
padding: 0 0.125rem;
}

View File

@@ -79,7 +79,7 @@ export const USER_STATUS_LABELS: Record<UserStatus, string> = {
[UserStatus.ACTIVE]: "Active",
[UserStatus.INACTIVE]: "Inactive",
[UserStatus.INVITED]: "Invite Pending",
[UserStatus.REQUESTED]: "Requested",
[UserStatus.REQUESTED]: "Request to Join",
};
export const INVALID_ROLE_HOVER_TEXT: Partial<Record<UserRole, string>> = {

View File

@@ -273,6 +273,7 @@ export default function DataTable<TData>(props: DataTableProps<TData>) {
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setPage}
leftExtra={footerConfig.leftExtra}
/>
);
}
@@ -301,7 +302,25 @@ export default function DataTable<TData>(props: DataTableProps<TData>) {
: undefined),
}}
>
<Table>
<Table
width={
Object.keys(columnWidths).length > 0
? Object.values(columnWidths).reduce((sum, w) => sum + w, 0)
: undefined
}
>
<colgroup>
{table.getAllLeafColumns().map((col) => (
<col
key={col.id}
style={
columnWidths[col.id] != null
? { width: columnWidths[col.id] }
: undefined
}
/>
))}
</colgroup>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>

View File

@@ -61,6 +61,8 @@ interface FooterSummaryModeProps {
totalPages: number;
/** Called when the user navigates to a different page. */
onPageChange: (page: number) => void;
/** Optional extra element rendered after the summary text (e.g. a download icon). */
leftExtra?: React.ReactNode;
/** Controls overall footer sizing. `"regular"` (default) or `"small"`. */
size?: TableSize;
className?: string;
@@ -115,12 +117,15 @@ export default function Footer(props: FooterProps) {
isSmall={isSmall}
/>
) : (
<SummaryLeft
rangeStart={props.rangeStart}
rangeEnd={props.rangeEnd}
totalItems={props.totalItems}
isSmall={isSmall}
/>
<>
<SummaryLeft
rangeStart={props.rangeStart}
rangeEnd={props.rangeEnd}
totalItems={props.totalItems}
isSmall={isSmall}
/>
{props.leftExtra}
</>
)}
</div>

View File

@@ -21,13 +21,13 @@ export default function TableCell({
const resolvedSize = size ?? contextSize;
return (
<td
className="tbl-cell"
className="tbl-cell overflow-hidden"
data-size={resolvedSize}
style={width != null ? { width } : undefined}
{...props}
>
<div
className={cn("tbl-cell-inner", "flex items-center")}
className={cn("tbl-cell-inner", "flex items-center overflow-hidden")}
data-size={resolvedSize}
>
{children}

View File

@@ -141,6 +141,8 @@ export interface DataTableFooterSelection {
export interface DataTableFooterSummary {
mode: "summary";
/** Optional extra element rendered after the summary text (e.g. a download icon). */
leftExtra?: ReactNode;
}
export type DataTableFooterConfig =

View File

@@ -1,23 +1,43 @@
"use client";
import { useState, useMemo } from "react";
import { Button, Tag } from "@opal/components";
import { SvgUsers } from "@opal/icons";
import { useState, useMemo, useRef, useCallback } from "react";
import { Button } from "@opal/components";
import { SvgUsers, SvgUser, SvgLogOut } from "@opal/icons";
import { Disabled } from "@opal/core";
import Modal from "@/refresh-components/Modal";
import Text from "@/refresh-components/texts/Text";
import InputTypeIn from "@/refresh-components/inputs/InputTypeIn";
import InputSelect from "@/refresh-components/inputs/InputSelect";
import Separator from "@/refresh-components/Separator";
import { toast } from "@/hooks/useToast";
import { UserRole } from "@/lib/types";
import useGroups from "@/hooks/useGroups";
import { addUserToGroup, removeUserFromGroup } from "./svc";
import { addUserToGroup, removeUserFromGroup, setUserRole } from "./svc";
import type { UserRow } from "./interfaces";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const ASSIGNABLE_ROLES: { value: UserRole; label: string }[] = [
{ value: UserRole.ADMIN, label: "Admin" },
{ value: UserRole.BASIC, label: "Basic" },
];
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface EditGroupsModalProps {
user: UserRow & { id: string };
onClose: () => void;
onMutate: () => void;
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export default function EditGroupsModal({
user,
onClose,
@@ -25,9 +45,20 @@ export default function EditGroupsModal({
}: EditGroupsModalProps) {
const { data: allGroups, isLoading: groupsLoading } = useGroups();
const [searchTerm, setSearchTerm] = useState("");
const [dropdownOpen, setDropdownOpen] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const closeDropdown = useCallback(() => {
// Delay to allow click events on dropdown items to fire before closing
setTimeout(() => {
if (!containerRef.current?.contains(document.activeElement)) {
setDropdownOpen(false);
}
}, 0);
}, []);
const [selectedRole, setSelectedRole] = useState<string>(user.role ?? "");
// Track which groups the user is currently a member of (local state for optimistic toggling)
const initialMemberGroupIds = useMemo(
() => new Set(user.groups.map((g) => g.id)),
[user.groups]
@@ -36,20 +67,30 @@ export default function EditGroupsModal({
() => new Set(initialMemberGroupIds)
);
const filteredGroups = useMemo(() => {
// Dropdown shows all groups filtered by search term
const dropdownGroups = useMemo(() => {
if (!allGroups) return [];
if (!searchTerm) return allGroups;
if (searchTerm.length === 0) return allGroups;
const lower = searchTerm.toLowerCase();
return allGroups.filter((g) => g.name.toLowerCase().includes(lower));
}, [allGroups, searchTerm]);
const hasChanges = useMemo(() => {
// Joined groups shown in the modal body
const joinedGroups = useMemo(() => {
if (!allGroups) return [];
return allGroups.filter((g) => memberGroupIds.has(g.id));
}, [allGroups, memberGroupIds]);
const hasGroupChanges = useMemo(() => {
if (memberGroupIds.size !== initialMemberGroupIds.size) return true;
return Array.from(memberGroupIds).some(
(id) => !initialMemberGroupIds.has(id)
);
}, [memberGroupIds, initialMemberGroupIds]);
const hasRoleChange = user.role !== null && selectedRole !== user.role;
const hasChanges = hasGroupChanges || hasRoleChange;
const toggleGroup = (groupId: number) => {
setMemberGroupIds((prev) => {
const next = new Set(prev);
@@ -65,6 +106,8 @@ export default function EditGroupsModal({
const handleSave = async () => {
setIsSubmitting(true);
try {
const promises: Promise<void>[] = [];
const toAdd = Array.from(memberGroupIds).filter(
(id) => !initialMemberGroupIds.has(id)
);
@@ -72,21 +115,28 @@ export default function EditGroupsModal({
(id) => !memberGroupIds.has(id)
);
const promises: Promise<void>[] = [];
for (const groupId of toAdd) {
promises.push(addUserToGroup(groupId, user.id));
}
for (const groupId of toRemove) {
const group = allGroups?.find((g) => g.id === groupId);
if (group) {
const currentUserIds = group.users.map((u) => u.id);
promises.push(removeUserFromGroup(groupId, currentUserIds, user.id));
if (user.id) {
for (const groupId of toAdd) {
promises.push(addUserToGroup(groupId, user.id));
}
for (const groupId of toRemove) {
const group = allGroups?.find((g) => g.id === groupId);
if (group) {
const currentUserIds = group.users.map((u) => u.id);
promises.push(
removeUserFromGroup(groupId, currentUserIds, user.id)
);
}
}
}
if (hasRoleChange) {
promises.push(setUserRole(user.email, selectedRole));
}
await Promise.all(promises);
onMutate();
toast.success("Group memberships updated");
toast.success("User updated");
onClose();
} catch (err) {
toast.error(err instanceof Error ? err.message : "An error occurred");
@@ -95,60 +145,160 @@ export default function EditGroupsModal({
}
};
const displayName = user.personal_name ?? user.email;
return (
<Modal open onOpenChange={(isOpen) => !isOpen && onClose()}>
<Modal.Content width="sm">
<Modal.Header
icon={SvgUsers}
title="Edit User's Groups"
description={`${user.personal_name ?? user.email} (${user.email})`}
title="Edit User's Groups & Roles"
description={`${displayName} (${user.email})`}
onClose={onClose}
/>
<Modal.Body twoTone>
<div className="flex flex-col gap-3">
<InputTypeIn
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Search groups..."
leftSearchIcon
/>
<div className="flex flex-col gap-3 w-full min-h-[240px]">
<div ref={containerRef} className="relative w-full">
<InputTypeIn
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value);
if (!dropdownOpen) setDropdownOpen(true);
}}
onFocus={() => setDropdownOpen(true)}
onBlur={closeDropdown}
placeholder="Search groups to join..."
leftSearchIcon
/>
{dropdownOpen && (
<div className="absolute top-full left-0 right-0 z-50 mt-1 bg-background-neutral-00 border border-border-02 rounded-12 shadow-md max-h-[200px] overflow-y-auto p-1">
{groupsLoading ? (
<div className="px-3 py-2">
<Text as="p" text03 secondaryBody>
Loading groups...
</Text>
</div>
) : dropdownGroups.length === 0 ? (
<div className="px-3 py-2">
<Text as="p" text03 secondaryBody>
No groups found
</Text>
</div>
) : (
dropdownGroups.map((group, idx) => {
const isMember = memberGroupIds.has(group.id);
return (
<button
key={group.id}
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => toggleGroup(group.id)}
className={`flex items-center justify-between gap-2 px-3 py-2.5 w-full hover:bg-background-neutral-02 transition-colors text-left rounded-lg ${
idx > 0 ? "border-t border-border-01" : ""
}`}
>
<div className="flex flex-col gap-0.5">
<Text as="span" mainUiAction text05>
{group.name}
</Text>
<Text as="span" secondaryBody text03>
{group.users.length}{" "}
{group.users.length === 1 ? "user" : "users"}
</Text>
</div>
{isMember && (
<Text as="span" secondaryBody text03>
Joined
</Text>
)}
</button>
);
})
)}
</div>
)}
</div>
{groupsLoading ? (
<Text as="p" text03 secondaryBody>
Loading groups...
</Text>
) : filteredGroups.length === 0 ? (
<Text as="p" text03 secondaryBody>
{searchTerm ? "No groups match your search" : "No groups found"}
</Text>
{joinedGroups.length === 0 ? (
<div className="flex items-start gap-3 px-3 py-2">
<SvgUsers className="w-4 h-4 text-text-03 flex-shrink-0 mt-0.5" />
<div className="flex flex-col gap-0.5">
<Text as="span" mainUiAction text05>
No groups found
</Text>
<Text as="span" secondaryBody text03>
{displayName} is not in any groups.
</Text>
</div>
</div>
) : (
<div className="flex flex-col gap-1 max-h-[320px] overflow-y-auto">
{filteredGroups.map((group) => {
const isMember = memberGroupIds.has(group.id);
return (
<button
key={group.id}
type="button"
onClick={() => toggleGroup(group.id)}
className="flex items-center justify-between gap-2 rounded-md px-3 py-2 hover:bg-background-neutral-02 transition-colors text-left"
>
<div className="flex flex-col overflow-y-auto max-h-[200px]">
{joinedGroups.map((group, idx) => (
<button
key={group.id}
type="button"
onClick={() => toggleGroup(group.id)}
className={`flex items-center justify-between gap-2 px-3 py-2.5 hover:bg-background-neutral-02 transition-colors text-left ${
idx > 0 ? "border-t border-border-01" : ""
}`}
>
<div className="flex items-center gap-3">
<SvgUsers className="w-4 h-4 text-text-03 flex-shrink-0" />
<div className="flex flex-col gap-0.5">
<Text as="span" mainUiAction text05>
{group.name}
</Text>
<Text as="span" secondaryBody text03>
{group.users.length}{" "}
{group.users.length === 1 ? "member" : "members"}
{group.users.length === 1 ? "user" : "users"}
</Text>
</div>
{isMember && <Tag title="Joined" color="green" />}
</button>
);
})}
</div>
<SvgLogOut className="w-4 h-4 text-text-03 flex-shrink-0" />
</button>
))}
</div>
)}
</div>
</Modal.Body>
{user.role && (
<>
<Separator noPadding />
<div className="flex items-center justify-between w-full gap-4 px-4 py-3">
<div className="flex flex-col gap-0.5">
<Text as="p" mainUiAction text04>
User Role
</Text>
<Text as="p" secondaryBody text03>
This controls their general permissions.
</Text>
</div>
<div className="w-[200px] flex-shrink-0">
<InputSelect
value={selectedRole}
onValueChange={setSelectedRole}
>
<InputSelect.Trigger />
<InputSelect.Content>
{ASSIGNABLE_ROLES.map(({ value, label }) => (
<InputSelect.Item
key={value}
value={value}
icon={SvgUser}
>
{label}
</InputSelect.Item>
))}
</InputSelect.Content>
</InputSelect>
</div>
</div>
</>
)}
<Modal.Footer>
<Button prominence="secondary" onClick={onClose}>
Cancel

View File

@@ -0,0 +1,186 @@
"use client";
import { useState, useRef, useLayoutEffect, useCallback } from "react";
import { SvgEdit } from "@opal/icons";
import { Tag } from "@opal/components";
import IconButton from "@/refresh-components/buttons/IconButton";
import Text from "@/refresh-components/texts/Text";
import SimpleTooltip from "@/refresh-components/SimpleTooltip";
import EditGroupsModal from "./EditGroupsModal";
import type { UserRow, UserGroupInfo } from "./interfaces";
interface GroupsCellProps {
groups: UserGroupInfo[];
user: UserRow;
onMutate: () => void;
}
/**
* Measures how many Tag pills fit in the container, accounting for a "+N"
* overflow counter when not all tags are visible. Uses a two-phase render:
* first renders all tags (clipped by overflow:hidden) for measurement, then
* re-renders with only the visible subset + "+N".
*
* Hovering the cell shows a tooltip with ALL groups. Clicking opens the
* edit groups modal.
*/
export default function GroupsCell({
groups,
user,
onMutate,
}: GroupsCellProps) {
const [showModal, setShowModal] = useState(false);
const [visibleCount, setVisibleCount] = useState<number | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const computeVisibleCount = useCallback(() => {
const container = containerRef.current;
if (!container || groups.length <= 1) {
setVisibleCount(groups.length);
return;
}
const tags = container.querySelectorAll<HTMLElement>("[data-group-tag]");
if (tags.length === 0) return;
const containerWidth = container.clientWidth;
const gap = 4; // gap-1
const counterWidth = 32; // "+N" Tag approximate width
let used = 0;
let count = 0;
for (let i = 0; i < tags.length; i++) {
const tagWidth = tags[i]!.offsetWidth;
const gapBefore = count > 0 ? gap : 0;
const hasMore = i < tags.length - 1;
const reserve = hasMore ? gap + counterWidth : 0;
if (used + gapBefore + tagWidth + reserve <= containerWidth) {
used += gapBefore + tagWidth;
count++;
} else {
break;
}
}
setVisibleCount(Math.max(1, count));
}, [groups]);
// Reset to measurement phase when groups change
useLayoutEffect(() => {
setVisibleCount(null);
}, [groups]);
// Measure after the "show all" render
useLayoutEffect(() => {
if (visibleCount !== null) return;
computeVisibleCount();
}, [visibleCount, computeVisibleCount]);
// Re-measure on container resize
useLayoutEffect(() => {
const container = containerRef.current;
if (!container) return;
const observer = new ResizeObserver(() => {
setVisibleCount(null);
});
observer.observe(container);
return () => observer.disconnect();
}, []);
const isMeasuring = visibleCount === null;
const effectiveVisible = visibleCount ?? groups.length;
const overflowCount = groups.length - effectiveVisible;
const hasOverflow = !isMeasuring && overflowCount > 0;
const allGroupsTooltip = (
<div className="flex flex-wrap gap-1 max-w-[14rem]">
{groups.map((g) => (
<div key={g.id} className="max-w-[10rem]">
<Tag title={g.name} size="md" />
</div>
))}
</div>
);
const tagsContent = (
<>
{(isMeasuring ? groups : groups.slice(0, effectiveVisible)).map((g) => (
<div key={g.id} data-group-tag className="flex-shrink-0">
<Tag title={g.name} size="md" />
</div>
))}
{hasOverflow && (
<div className="flex-shrink-0">
<Tag title={`+${overflowCount}`} size="md" />
</div>
)}
</>
);
return (
<>
<div
className={`group/groups relative flex items-center w-full min-w-0 ${
user.id ? "cursor-pointer" : ""
}`}
onClick={user.id ? () => setShowModal(true) : undefined}
>
{groups.length === 0 ? (
<div
ref={containerRef}
className="flex items-center gap-1 overflow-hidden flex-nowrap min-w-0 pr-7"
>
<Text as="span" secondaryBody text03>
</Text>
</div>
) : hasOverflow ? (
<SimpleTooltip
side="bottom"
tooltip={allGroupsTooltip}
className="bg-background-neutral-01 border border-border-01 shadow-sm"
delayDuration={200}
>
<div
ref={containerRef}
className="flex items-center gap-1 overflow-hidden flex-nowrap min-w-0 pr-7"
>
{tagsContent}
</div>
</SimpleTooltip>
) : (
<div
ref={containerRef}
className="flex items-center gap-1 overflow-hidden flex-nowrap min-w-0 pr-7"
>
{tagsContent}
</div>
)}
{user.id && (
<IconButton
tertiary
icon={SvgEdit}
tooltip="Edit"
toolTipPosition="left"
tooltipSize="sm"
className="absolute right-0 opacity-0 group-hover/groups:opacity-100 transition-opacity"
onClick={(e) => {
e.stopPropagation();
setShowModal(true);
}}
/>
)}
</div>
{showModal && user.id && (
<EditGroupsModal
user={user}
onClose={() => setShowModal(false)}
onMutate={onMutate}
/>
)}
</>
);
}

View File

@@ -18,22 +18,6 @@ import {
import { NEXT_PUBLIC_CLOUD_ENABLED } from "@/lib/constants";
import type { GroupOption, StatusFilter, StatusCountMap } from "./interfaces";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface UserFiltersProps {
selectedRoles: UserRole[];
onRolesChange: (roles: UserRole[]) => void;
selectedGroups: number[];
onGroupsChange: (groupIds: number[]) => void;
groups: GroupOption[];
selectedStatuses: StatusFilter;
onStatusesChange: (statuses: StatusFilter) => void;
roleCounts: Record<string, number>;
statusCounts: StatusCountMap;
}
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
@@ -76,6 +60,18 @@ function CountBadge({ count }: { count: number | undefined }) {
// Component
// ---------------------------------------------------------------------------
interface UserFiltersProps {
selectedRoles: UserRole[];
onRolesChange: (roles: UserRole[]) => void;
selectedGroups: number[];
onGroupsChange: (groupIds: number[]) => void;
groups: GroupOption[];
selectedStatuses: StatusFilter;
onStatusesChange: (statuses: StatusFilter) => void;
roleCounts: Record<string, number>;
statusCounts: StatusCountMap;
}
export default function UserFilters({
selectedRoles,
onRolesChange,
@@ -101,14 +97,6 @@ export default function UserFilters({
}
};
const roleLabel = hasRoleFilter
? FILTERABLE_ROLES.filter(([role]) => selectedRoles.includes(role))
.map(([, label]) => label)
.slice(0, 2)
.join(", ") +
(selectedRoles.length > 2 ? `, +${selectedRoles.length - 2}` : "")
: "All Account Types";
const toggleGroup = (groupId: number) => {
if (selectedGroups.includes(groupId)) {
onGroupsChange(selectedGroups.filter((id) => id !== groupId));
@@ -117,6 +105,22 @@ export default function UserFilters({
}
};
const toggleStatus = (status: UserStatus) => {
if (selectedStatuses.includes(status)) {
onStatusesChange(selectedStatuses.filter((s) => s !== status));
} else {
onStatusesChange([...selectedStatuses, status]);
}
};
const roleLabel = hasRoleFilter
? FILTERABLE_ROLES.filter(([role]) => selectedRoles.includes(role))
.map(([, label]) => label)
.slice(0, 2)
.join(", ") +
(selectedRoles.length > 2 ? `, +${selectedRoles.length - 2}` : "")
: "All Account Types";
const groupLabel = hasGroupFilter
? groups
.filter((g) => selectedGroups.includes(g.id))
@@ -126,14 +130,6 @@ export default function UserFilters({
(selectedGroups.length > 2 ? `, +${selectedGroups.length - 2}` : "")
: "All Groups";
const toggleStatus = (status: UserStatus) => {
if (selectedStatuses.includes(status)) {
onStatusesChange(selectedStatuses.filter((s) => s !== status));
} else {
onStatusesChange([...selectedStatuses, status]);
}
};
const statusLabel = hasStatusFilter
? FILTERABLE_STATUSES.filter(([status]) =>
selectedStatuses.includes(status)

View File

@@ -15,7 +15,13 @@ import ConfirmationModalLayout from "@/refresh-components/layouts/ConfirmationMo
import Text from "@/refresh-components/texts/Text";
import { UserStatus } from "@/lib/types";
import { toast } from "@/hooks/useToast";
import { deactivateUser, activateUser, deleteUser } from "./svc";
import {
deactivateUser,
activateUser,
deleteUser,
cancelInvite,
approveRequest,
} from "./svc";
import EditGroupsModal from "./EditGroupsModal";
import type { UserRow } from "./interfaces";
@@ -23,7 +29,13 @@ import type { UserRow } from "./interfaces";
// Types
// ---------------------------------------------------------------------------
type ModalType = "deactivate" | "activate" | "delete" | "editGroups" | null;
type ModalType =
| "deactivate"
| "activate"
| "delete"
| "cancelInvite"
| "editGroups"
| null;
interface UserRowActionsProps {
user: UserRow;
@@ -59,14 +71,106 @@ export default function UserRowActions({
}
}
// Only show actions for accepted users (active or inactive).
// Invited/requested users have no row actions in this PR.
if (
user.status !== UserStatus.ACTIVE &&
user.status !== UserStatus.INACTIVE
) {
return null;
}
const openModal = (type: ModalType) => {
setPopoverOpen(false);
setModal(type);
};
// Status-aware action menus
const actionButtons = (() => {
switch (user.status) {
case UserStatus.INVITED:
return (
<Button
prominence="tertiary"
variant="danger"
icon={SvgXCircle}
onClick={() => openModal("cancelInvite")}
>
Cancel Invite
</Button>
);
case UserStatus.REQUESTED:
return (
<>
<Button
prominence="tertiary"
icon={SvgCheck}
onClick={() => {
setPopoverOpen(false);
handleAction(
() => approveRequest(user.email),
"Request approved"
);
}}
>
Approve
</Button>
<Button
prominence="tertiary"
variant="danger"
icon={SvgXCircle}
onClick={() => openModal("cancelInvite")}
>
Reject
</Button>
</>
);
case UserStatus.ACTIVE:
return (
<>
{user.id && (
<Button
prominence="tertiary"
icon={SvgUsers}
onClick={() => openModal("editGroups")}
>
Groups
</Button>
)}
<Button
prominence="tertiary"
icon={SvgXCircle}
onClick={() => openModal("deactivate")}
>
Deactivate User
</Button>
</>
);
case UserStatus.INACTIVE:
return (
<>
{user.id && (
<Button
prominence="tertiary"
icon={SvgUsers}
onClick={() => openModal("editGroups")}
>
Groups
</Button>
)}
<Button
prominence="tertiary"
icon={SvgCheck}
onClick={() => openModal("activate")}
>
Activate User
</Button>
<Button
prominence="tertiary"
variant="danger"
icon={SvgTrash}
onClick={() => openModal("delete")}
>
Delete User
</Button>
</>
);
}
})();
// SCIM-managed users cannot be modified from the UI — changes would be
// overwritten on the next IdP sync.
@@ -82,52 +186,7 @@ export default function UserRowActions({
</Popover.Trigger>
<Popover.Content align="end">
<div className="flex flex-col gap-0.5 p-1">
<Button
prominence="tertiary"
icon={SvgUsers}
onClick={() => {
setPopoverOpen(false);
setModal("editGroups");
}}
>
Groups
</Button>
{user.status === UserStatus.ACTIVE ? (
<Button
prominence="tertiary"
icon={SvgXCircle}
onClick={() => {
setPopoverOpen(false);
setModal("deactivate");
}}
>
Deactivate User
</Button>
) : (
<>
<Button
prominence="tertiary"
icon={SvgCheck}
onClick={() => {
setPopoverOpen(false);
setModal("activate");
}}
>
Activate User
</Button>
<Button
prominence="tertiary"
variant="danger"
icon={SvgTrash}
onClick={() => {
setPopoverOpen(false);
setModal("delete");
}}
>
Delete User
</Button>
</>
)}
{actionButtons}
</div>
</Popover.Content>
</Popover>
@@ -140,6 +199,44 @@ export default function UserRowActions({
/>
)}
{modal === "cancelInvite" && (
<ConfirmationModalLayout
icon={SvgXCircle}
title={
user.status === UserStatus.REQUESTED
? "Reject Request"
: "Cancel Invite"
}
onClose={() => setModal(null)}
submit={
<Disabled disabled={isSubmitting}>
<Button
variant="danger"
onClick={() => {
handleAction(
() => cancelInvite(user.email),
user.status === UserStatus.REQUESTED
? "Request rejected"
: "Invite cancelled"
);
}}
>
{user.status === UserStatus.REQUESTED ? "Reject" : "Cancel"}
</Button>
</Disabled>
}
>
<Text as="p" text03>
<Text as="span" text05>
{user.email}
</Text>{" "}
{user.status === UserStatus.REQUESTED
? "will be removed from the pending requests list."
: "will no longer be able to join Onyx with this invite."}
</Text>
</ConfirmationModalLayout>
)}
{modal === "deactivate" && (
<ConfirmationModalLayout
icon={SvgXCircle}
@@ -166,7 +263,8 @@ export default function UserRowActions({
{user.email}
</Text>{" "}
will immediately lose access to Onyx. Their sessions and agents will
be preserved. You can reactivate this account later.
be preserved. Their license seat will be freed. You can reactivate
this account later.
</Text>
</ConfirmationModalLayout>
)}
@@ -226,7 +324,7 @@ export default function UserRowActions({
{user.email}
</Text>{" "}
will be permanently removed from Onyx. All of their session history
will be deleted. This cannot be undone.
will be deleted. Deletion cannot be undone.
</Text>
</ConfirmationModalLayout>
)}

View File

@@ -8,7 +8,7 @@ import Link from "next/link";
import { ADMIN_PATHS } from "@/lib/admin-routes";
// ---------------------------------------------------------------------------
// Stats cell — number + label
// Stats cell — number + label + hover filter icon
// ---------------------------------------------------------------------------
type StatCellProps = {