mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-03-13 11:42:40 +00:00
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:
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
|
||||
21
web/lib/opal/src/icons/filter-plus.tsx
Normal file
21
web/lib/opal/src/icons/filter-plus.tsx
Normal 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;
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>> = {
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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
|
||||
|
||||
186
web/src/refresh-pages/admin/UsersPage/GroupsCell.tsx
Normal file
186
web/src/refresh-pages/admin/UsersPage/GroupsCell.tsx
Normal 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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user