mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-03-09 01:32:40 +00:00
Compare commits
9 Commits
nikg/admin
...
nikg/admin
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c0774169fa | ||
|
|
584d818555 | ||
|
|
491b8e9cda | ||
|
|
13f4633752 | ||
|
|
24289c86ba | ||
|
|
d1873054b0 | ||
|
|
649edcb244 | ||
|
|
4e479896cb | ||
|
|
faaf7dc08e |
@@ -0,0 +1,43 @@
|
||||
"""add timestamps to user table
|
||||
|
||||
Revision ID: 27fb147a843f
|
||||
Revises: a3b8d9e2f1c4
|
||||
Create Date: 2026-03-08 17:18:40.828644
|
||||
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "27fb147a843f"
|
||||
down_revision = "a3b8d9e2f1c4"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"user",
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"user",
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("user", "updated_at")
|
||||
op.drop_column("user", "created_at")
|
||||
@@ -280,6 +280,16 @@ class User(SQLAlchemyBaseUserTableUUID, Base):
|
||||
TIMESTAMPAware(timezone=True), nullable=True
|
||||
)
|
||||
|
||||
created_at: Mapped[datetime.datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
updated_at: Mapped[datetime.datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
default_model: Mapped[str] = mapped_column(Text, nullable=True)
|
||||
# organized in typical structured fashion
|
||||
# formatted as `displayName__provider__modelName`
|
||||
|
||||
@@ -24,6 +24,7 @@ from onyx.db.models import Persona__User
|
||||
from onyx.db.models import SamlAccount
|
||||
from onyx.db.models import User
|
||||
from onyx.db.models import User__UserGroup
|
||||
from onyx.db.models import UserGroup
|
||||
from onyx.utils.variable_functionality import fetch_ee_implementation_or_noop
|
||||
|
||||
|
||||
@@ -358,3 +359,28 @@ def delete_user_from_db(
|
||||
# NOTE: edge case may exist with race conditions
|
||||
# with this `invited user` scheme generally.
|
||||
remove_user_from_invited_users(user_to_delete.email)
|
||||
|
||||
|
||||
def batch_get_user_groups(
|
||||
db_session: Session,
|
||||
user_ids: list[UUID],
|
||||
) -> dict[UUID, list[tuple[int, str]]]:
|
||||
"""Fetch group memberships for a batch of users in a single query.
|
||||
Returns a mapping of user_id -> list of (group_id, group_name) tuples."""
|
||||
if not user_ids:
|
||||
return {}
|
||||
|
||||
rows = db_session.execute(
|
||||
select(
|
||||
User__UserGroup.user_id,
|
||||
UserGroup.id,
|
||||
UserGroup.name,
|
||||
)
|
||||
.join(UserGroup, UserGroup.id == User__UserGroup.user_group_id)
|
||||
.where(User__UserGroup.user_id.in_(user_ids))
|
||||
).all()
|
||||
|
||||
result: dict[UUID, list[tuple[int, str]]] = {uid: [] for uid in user_ids}
|
||||
for user_id, group_id, group_name in rows:
|
||||
result[user_id].append((group_id, group_name))
|
||||
return result
|
||||
|
||||
@@ -67,6 +67,7 @@ from onyx.db.user_preferences import update_user_role
|
||||
from onyx.db.user_preferences import update_user_shortcut_enabled
|
||||
from onyx.db.user_preferences import update_user_temperature_override_enabled
|
||||
from onyx.db.user_preferences import update_user_theme_preference
|
||||
from onyx.db.users import batch_get_user_groups
|
||||
from onyx.db.users import delete_user_from_db
|
||||
from onyx.db.users import get_all_users
|
||||
from onyx.db.users import get_page_of_filtered_users
|
||||
@@ -98,6 +99,7 @@ from onyx.server.manage.models import UserSpecificAssistantPreferences
|
||||
from onyx.server.models import FullUserSnapshot
|
||||
from onyx.server.models import InvitedUserSnapshot
|
||||
from onyx.server.models import MinimalUserSnapshot
|
||||
from onyx.server.models import UserGroupInfo
|
||||
from onyx.server.usage_limits import is_tenant_on_trial_fn
|
||||
from onyx.server.utils import BasicAuthenticationError
|
||||
from onyx.utils.logger import setup_logger
|
||||
@@ -203,9 +205,19 @@ def list_accepted_users(
|
||||
total_items=0,
|
||||
)
|
||||
|
||||
user_ids = [user.id for user in filtered_accepted_users]
|
||||
groups_by_user = batch_get_user_groups(db_session, user_ids)
|
||||
|
||||
return PaginatedReturn(
|
||||
items=[
|
||||
FullUserSnapshot.from_user_model(user) for user in filtered_accepted_users
|
||||
FullUserSnapshot.from_user_model(
|
||||
user,
|
||||
groups=[
|
||||
UserGroupInfo(id=gid, name=gname)
|
||||
for gid, gname in groups_by_user.get(user.id, [])
|
||||
],
|
||||
)
|
||||
for user in filtered_accepted_users
|
||||
],
|
||||
total_items=total_accepted_users_count,
|
||||
)
|
||||
@@ -269,24 +281,10 @@ def list_all_users(
|
||||
if accepted_page is None or invited_page is None or slack_users_page is None:
|
||||
return AllUsersResponse(
|
||||
accepted=[
|
||||
FullUserSnapshot(
|
||||
id=user.id,
|
||||
email=user.email,
|
||||
role=user.role,
|
||||
is_active=user.is_active,
|
||||
password_configured=user.password_configured,
|
||||
)
|
||||
for user in accepted_users
|
||||
FullUserSnapshot.from_user_model(user) for user in accepted_users
|
||||
],
|
||||
slack_users=[
|
||||
FullUserSnapshot(
|
||||
id=user.id,
|
||||
email=user.email,
|
||||
role=user.role,
|
||||
is_active=user.is_active,
|
||||
password_configured=user.password_configured,
|
||||
)
|
||||
for user in slack_users
|
||||
FullUserSnapshot.from_user_model(user) for user in slack_users
|
||||
],
|
||||
invited=[InvitedUserSnapshot(email=email) for email in invited_emails],
|
||||
accepted_pages=1,
|
||||
@@ -296,26 +294,10 @@ def list_all_users(
|
||||
|
||||
# Otherwise, return paginated results
|
||||
return AllUsersResponse(
|
||||
accepted=[
|
||||
FullUserSnapshot(
|
||||
id=user.id,
|
||||
email=user.email,
|
||||
role=user.role,
|
||||
is_active=user.is_active,
|
||||
password_configured=user.password_configured,
|
||||
)
|
||||
for user in accepted_users
|
||||
][accepted_page * USERS_PAGE_SIZE : (accepted_page + 1) * USERS_PAGE_SIZE],
|
||||
slack_users=[
|
||||
FullUserSnapshot(
|
||||
id=user.id,
|
||||
email=user.email,
|
||||
role=user.role,
|
||||
is_active=user.is_active,
|
||||
password_configured=user.password_configured,
|
||||
)
|
||||
for user in slack_users
|
||||
][
|
||||
accepted=[FullUserSnapshot.from_user_model(user) for user in accepted_users][
|
||||
accepted_page * USERS_PAGE_SIZE : (accepted_page + 1) * USERS_PAGE_SIZE
|
||||
],
|
||||
slack_users=[FullUserSnapshot.from_user_model(user) for user in slack_users][
|
||||
slack_users_page
|
||||
* USERS_PAGE_SIZE : (slack_users_page + 1)
|
||||
* USERS_PAGE_SIZE
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import datetime
|
||||
from typing import Generic
|
||||
from typing import Optional
|
||||
from typing import TypeVar
|
||||
@@ -31,21 +32,38 @@ class MinimalUserSnapshot(BaseModel):
|
||||
email: str
|
||||
|
||||
|
||||
class UserGroupInfo(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
|
||||
|
||||
class FullUserSnapshot(BaseModel):
|
||||
id: UUID
|
||||
email: str
|
||||
role: UserRole
|
||||
is_active: bool
|
||||
password_configured: bool
|
||||
personal_name: str | None
|
||||
created_at: datetime.datetime
|
||||
updated_at: datetime.datetime
|
||||
groups: list[UserGroupInfo]
|
||||
|
||||
@classmethod
|
||||
def from_user_model(cls, user: User) -> "FullUserSnapshot":
|
||||
def from_user_model(
|
||||
cls,
|
||||
user: User,
|
||||
groups: list[UserGroupInfo] | None = None,
|
||||
) -> "FullUserSnapshot":
|
||||
return cls(
|
||||
id=user.id,
|
||||
email=user.email,
|
||||
role=user.role,
|
||||
is_active=user.is_active,
|
||||
password_configured=user.password_configured,
|
||||
personal_name=user.personal_name,
|
||||
created_at=user.created_at,
|
||||
updated_at=user.updated_at,
|
||||
groups=groups or [],
|
||||
)
|
||||
|
||||
|
||||
|
||||
46
web/src/hooks/useAdminUsers.ts
Normal file
46
web/src/hooks/useAdminUsers.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import useSWR from "swr";
|
||||
import { errorHandlingFetcher } from "@/lib/fetcher";
|
||||
import type { UserRole } from "@/lib/types";
|
||||
import type { PaginatedUsersResponse } from "@/refresh-pages/admin/UsersPage/interfaces";
|
||||
|
||||
interface UseAdminUsersParams {
|
||||
pageIndex: number;
|
||||
pageSize: number;
|
||||
searchTerm?: string;
|
||||
roles?: UserRole[];
|
||||
isActive?: boolean | undefined;
|
||||
}
|
||||
|
||||
export default function useAdminUsers({
|
||||
pageIndex,
|
||||
pageSize,
|
||||
searchTerm,
|
||||
roles,
|
||||
isActive,
|
||||
}: UseAdminUsersParams) {
|
||||
const queryParams = new URLSearchParams({
|
||||
page_num: String(pageIndex),
|
||||
page_size: String(pageSize),
|
||||
...(searchTerm && { q: searchTerm }),
|
||||
...(isActive === true && { is_active: "true" }),
|
||||
...(isActive === false && { is_active: "false" }),
|
||||
});
|
||||
for (const role of roles ?? []) {
|
||||
queryParams.append("roles", role);
|
||||
}
|
||||
|
||||
const { data, isLoading, error, mutate } = useSWR<PaginatedUsersResponse>(
|
||||
`/api/manage/users/accepted?${queryParams.toString()}`,
|
||||
errorHandlingFetcher
|
||||
);
|
||||
|
||||
return {
|
||||
users: data?.items ?? [],
|
||||
totalItems: data?.total_items ?? 0,
|
||||
isLoading,
|
||||
error,
|
||||
refresh: mutate,
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { SvgUser, SvgUserPlus } from "@opal/icons";
|
||||
import { Button } from "@opal/components";
|
||||
import * as SettingsLayouts from "@/layouts/settings-layouts";
|
||||
@@ -8,6 +9,8 @@ import { usePaidEnterpriseFeaturesEnabled } from "@/components/settings/usePaidE
|
||||
import useUserCounts from "@/hooks/useUserCounts";
|
||||
|
||||
import UsersSummary from "./UsersPage/UsersSummary";
|
||||
import UsersTable from "./UsersPage/UsersTable";
|
||||
import InviteUsersModal from "./UsersPage/InviteUsersModal";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Users page content
|
||||
@@ -30,7 +33,7 @@ function UsersContent() {
|
||||
showScim={showScim}
|
||||
/>
|
||||
|
||||
{/* Table and filters will be added in subsequent PRs */}
|
||||
<UsersTable />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -40,19 +43,24 @@ function UsersContent() {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function UsersPage() {
|
||||
const [inviteOpen, setInviteOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<SettingsLayouts.Root width="lg">
|
||||
<SettingsLayouts.Header
|
||||
title="Users & Requests"
|
||||
icon={SvgUser}
|
||||
rightChildren={
|
||||
// TODO (ENG-3806): Wire up invite modal
|
||||
<Button icon={SvgUserPlus}>Invite Users</Button>
|
||||
<Button icon={SvgUserPlus} onClick={() => setInviteOpen(true)}>
|
||||
Invite Users
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<SettingsLayouts.Body>
|
||||
<UsersContent />
|
||||
</SettingsLayouts.Body>
|
||||
|
||||
<InviteUsersModal open={inviteOpen} onOpenChange={setInviteOpen} />
|
||||
</SettingsLayouts.Root>
|
||||
);
|
||||
}
|
||||
|
||||
163
web/src/refresh-pages/admin/UsersPage/EditGroupsModal.tsx
Normal file
163
web/src/refresh-pages/admin/UsersPage/EditGroupsModal.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { Button, Tag } from "@opal/components";
|
||||
import { SvgUsers } 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 { toast } from "@/hooks/useToast";
|
||||
import useGroups from "@/hooks/useGroups";
|
||||
import { addUserToGroup, removeUserFromGroup } from "./svc";
|
||||
import type { UserRow } from "./interfaces";
|
||||
|
||||
interface EditGroupsModalProps {
|
||||
user: UserRow;
|
||||
onClose: () => void;
|
||||
onMutate: () => void;
|
||||
}
|
||||
|
||||
export default function EditGroupsModal({
|
||||
user,
|
||||
onClose,
|
||||
onMutate,
|
||||
}: EditGroupsModalProps) {
|
||||
const { data: allGroups, isLoading: groupsLoading } = useGroups();
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// 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]
|
||||
);
|
||||
const [memberGroupIds, setMemberGroupIds] = useState<Set<number>>(
|
||||
() => new Set(initialMemberGroupIds)
|
||||
);
|
||||
|
||||
const filteredGroups = useMemo(() => {
|
||||
if (!allGroups) return [];
|
||||
if (!searchTerm) return allGroups;
|
||||
const lower = searchTerm.toLowerCase();
|
||||
return allGroups.filter((g) => g.name.toLowerCase().includes(lower));
|
||||
}, [allGroups, searchTerm]);
|
||||
|
||||
const hasChanges = useMemo(() => {
|
||||
if (memberGroupIds.size !== initialMemberGroupIds.size) return true;
|
||||
return Array.from(memberGroupIds).some(
|
||||
(id) => !initialMemberGroupIds.has(id)
|
||||
);
|
||||
}, [memberGroupIds, initialMemberGroupIds]);
|
||||
|
||||
const toggleGroup = (groupId: number) => {
|
||||
setMemberGroupIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(groupId)) {
|
||||
next.delete(groupId);
|
||||
} else {
|
||||
next.add(groupId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const toAdd = Array.from(memberGroupIds).filter(
|
||||
(id) => !initialMemberGroupIds.has(id)
|
||||
);
|
||||
const toRemove = Array.from(initialMemberGroupIds).filter(
|
||||
(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));
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
onMutate();
|
||||
toast.success("Group memberships updated");
|
||||
onClose();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "An error occurred");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
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})`}
|
||||
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
|
||||
/>
|
||||
|
||||
{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>
|
||||
) : (
|
||||
<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 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"}
|
||||
</Text>
|
||||
</div>
|
||||
{isMember && <Tag title="Joined" color="green" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button prominence="secondary" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Disabled disabled={isSubmitting || !hasChanges}>
|
||||
<Button onClick={handleSave}>Save Changes</Button>
|
||||
</Disabled>
|
||||
</Modal.Footer>
|
||||
</Modal.Content>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
188
web/src/refresh-pages/admin/UsersPage/InviteUsersModal.tsx
Normal file
188
web/src/refresh-pages/admin/UsersPage/InviteUsersModal.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@opal/components";
|
||||
import { SvgUsers, SvgUser } from "@opal/icons";
|
||||
import { Disabled } from "@opal/core";
|
||||
import Modal, { BasicModalFooter } from "@/refresh-components/Modal";
|
||||
import InputChipField from "@/refresh-components/inputs/InputChipField";
|
||||
import type { ChipItem } from "@/refresh-components/inputs/InputChipField";
|
||||
import InputSelect from "@/refresh-components/inputs/InputSelect";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import { toast } from "@/hooks/useToast";
|
||||
import { UserRole, USER_ROLE_LABELS } from "@/lib/types";
|
||||
import { inviteUsers } from "./svc";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
/** Roles available for invite — excludes curator-specific and system roles */
|
||||
const INVITE_ROLES = [
|
||||
UserRole.BASIC,
|
||||
UserRole.ADMIN,
|
||||
UserRole.GLOBAL_CURATOR,
|
||||
] as const;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface InviteUsersModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function InviteUsersModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: InviteUsersModalProps) {
|
||||
const [chips, setChips] = useState<ChipItem[]>([]);
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const [role, setRole] = useState<string>(UserRole.BASIC);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
function addEmail(value: string) {
|
||||
// Support comma-separated input
|
||||
const entries = value
|
||||
.split(",")
|
||||
.map((e) => e.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
|
||||
const newChips: ChipItem[] = [];
|
||||
for (const email of entries) {
|
||||
const alreadyAdded = chips.some((c) => c.label === email);
|
||||
if (!alreadyAdded) {
|
||||
newChips.push({ id: email, label: email });
|
||||
}
|
||||
}
|
||||
|
||||
if (newChips.length > 0) {
|
||||
setChips((prev) => [...prev, ...newChips]);
|
||||
}
|
||||
setInputValue("");
|
||||
}
|
||||
|
||||
function removeChip(id: string) {
|
||||
setChips((prev) => prev.filter((c) => c.id !== id));
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
onOpenChange(false);
|
||||
// Reset state after close animation
|
||||
setTimeout(() => {
|
||||
setChips([]);
|
||||
setInputValue("");
|
||||
setRole(UserRole.BASIC);
|
||||
}, 200);
|
||||
}
|
||||
|
||||
async function handleInvite() {
|
||||
const validEmails = chips
|
||||
.map((c) => c.label)
|
||||
.filter((e) => EMAIL_REGEX.test(e));
|
||||
|
||||
if (validEmails.length === 0) {
|
||||
toast.error("Please add at least one valid email address");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await inviteUsers(validEmails);
|
||||
toast.success(
|
||||
`Invited ${validEmails.length} user${validEmails.length > 1 ? "s" : ""}`
|
||||
);
|
||||
handleClose();
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : "Failed to invite users"
|
||||
);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const hasInvalidEmails = chips.some((c) => !EMAIL_REGEX.test(c.label));
|
||||
|
||||
return (
|
||||
<Modal open={open} onOpenChange={onOpenChange}>
|
||||
<Modal.Content width="sm" height="fit">
|
||||
<Modal.Header
|
||||
icon={SvgUsers}
|
||||
title="Invite Users"
|
||||
onClose={handleClose}
|
||||
/>
|
||||
|
||||
<Modal.Body>
|
||||
<InputChipField
|
||||
chips={chips}
|
||||
onRemoveChip={removeChip}
|
||||
onAdd={addEmail}
|
||||
value={inputValue}
|
||||
onChange={(val) => {
|
||||
// Auto-add on comma
|
||||
if (val.includes(",")) {
|
||||
addEmail(val);
|
||||
} else {
|
||||
setInputValue(val);
|
||||
}
|
||||
}}
|
||||
placeholder="Add emails to invite, comma separated"
|
||||
/>
|
||||
|
||||
{hasInvalidEmails && (
|
||||
<Text as="p" secondaryBody className="text-status-error-text">
|
||||
Some entries are not valid email addresses and will be skipped.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<div className="flex items-start justify-between w-full gap-4">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<Text as="p" mainUiAction text04>
|
||||
User Role
|
||||
</Text>
|
||||
<Text as="p" secondaryBody text03>
|
||||
Invite new users as
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div className="w-[200px]">
|
||||
<InputSelect value={role} onValueChange={setRole}>
|
||||
<InputSelect.Trigger />
|
||||
<InputSelect.Content>
|
||||
{INVITE_ROLES.map((r) => (
|
||||
<InputSelect.Item key={r} value={r} icon={SvgUser}>
|
||||
{USER_ROLE_LABELS[r]}
|
||||
</InputSelect.Item>
|
||||
))}
|
||||
</InputSelect.Content>
|
||||
</InputSelect>
|
||||
</div>
|
||||
</div>
|
||||
</Modal.Body>
|
||||
|
||||
<Modal.Footer>
|
||||
<BasicModalFooter
|
||||
cancel={
|
||||
<Button prominence="tertiary" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
}
|
||||
submit={
|
||||
<Disabled disabled={isSubmitting || chips.length === 0}>
|
||||
<Button onClick={handleInvite}>Invite</Button>
|
||||
</Disabled>
|
||||
}
|
||||
/>
|
||||
</Modal.Footer>
|
||||
</Modal.Content>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
131
web/src/refresh-pages/admin/UsersPage/UserFilters.tsx
Normal file
131
web/src/refresh-pages/admin/UsersPage/UserFilters.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
"use client";
|
||||
|
||||
import { SvgFilter, SvgUsers } from "@opal/icons";
|
||||
import FilterButton from "@/refresh-components/buttons/FilterButton";
|
||||
import Popover from "@/refresh-components/Popover";
|
||||
import Checkbox from "@/refresh-components/inputs/Checkbox";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import { UserRole, USER_ROLE_LABELS } from "@/lib/types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface UserFiltersProps {
|
||||
selectedRoles: UserRole[];
|
||||
onRolesChange: (roles: UserRole[]) => void;
|
||||
selectedStatus: "all" | "active" | "inactive";
|
||||
onStatusChange: (status: "all" | "active" | "inactive") => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const FILTERABLE_ROLES = Object.entries(USER_ROLE_LABELS).filter(
|
||||
([role]) => role !== UserRole.EXT_PERM_USER
|
||||
) as [UserRole, string][];
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: "all" as const, label: "All Status" },
|
||||
{ value: "active" as const, label: "Active" },
|
||||
{ value: "inactive" as const, label: "Inactive" },
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function UserFilters({
|
||||
selectedRoles,
|
||||
onRolesChange,
|
||||
selectedStatus,
|
||||
onStatusChange,
|
||||
}: UserFiltersProps) {
|
||||
const hasRoleFilter = selectedRoles.length > 0;
|
||||
const hasStatusFilter = selectedStatus !== "all";
|
||||
|
||||
const toggleRole = (role: UserRole) => {
|
||||
if (selectedRoles.includes(role)) {
|
||||
onRolesChange(selectedRoles.filter((r) => r !== role));
|
||||
} else {
|
||||
onRolesChange([...selectedRoles, role]);
|
||||
}
|
||||
};
|
||||
|
||||
const roleLabel = hasRoleFilter
|
||||
? `${selectedRoles.length} role${
|
||||
selectedRoles.length > 1 ? "s" : ""
|
||||
} selected`
|
||||
: "All Roles";
|
||||
|
||||
const statusLabel = hasStatusFilter
|
||||
? STATUS_OPTIONS.find((o) => o.value === selectedStatus)?.label ?? "Status"
|
||||
: "All Status";
|
||||
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
{/* Role filter */}
|
||||
<Popover>
|
||||
<Popover.Trigger asChild>
|
||||
<FilterButton
|
||||
leftIcon={SvgUsers}
|
||||
active={hasRoleFilter}
|
||||
onClear={() => onRolesChange([])}
|
||||
>
|
||||
{roleLabel}
|
||||
</FilterButton>
|
||||
</Popover.Trigger>
|
||||
<Popover.Content align="start">
|
||||
<div className="flex flex-col gap-1 p-2 min-w-[200px]">
|
||||
{FILTERABLE_ROLES.map(([role, label]) => (
|
||||
<label
|
||||
key={role}
|
||||
className="flex items-center gap-2 px-2 py-1.5 rounded-8 cursor-pointer hover:bg-background-tint-01"
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedRoles.includes(role)}
|
||||
onCheckedChange={() => toggleRole(role)}
|
||||
/>
|
||||
<Text as="span" mainUiAction>
|
||||
{label}
|
||||
</Text>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover>
|
||||
|
||||
{/* Status filter */}
|
||||
<Popover>
|
||||
<Popover.Trigger asChild>
|
||||
<FilterButton
|
||||
leftIcon={SvgFilter}
|
||||
active={hasStatusFilter}
|
||||
onClear={() => onStatusChange("all")}
|
||||
>
|
||||
{statusLabel}
|
||||
</FilterButton>
|
||||
</Popover.Trigger>
|
||||
<Popover.Content align="start">
|
||||
<div className="flex flex-col gap-1 p-2 min-w-[160px]">
|
||||
{STATUS_OPTIONS.map((option) => (
|
||||
<label
|
||||
key={option.value}
|
||||
className="flex items-center gap-2 px-2 py-1.5 rounded-8 cursor-pointer hover:bg-background-tint-01"
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedStatus === option.value}
|
||||
onCheckedChange={() => onStatusChange(option.value)}
|
||||
/>
|
||||
<Text as="span" mainUiAction>
|
||||
{option.label}
|
||||
</Text>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
103
web/src/refresh-pages/admin/UsersPage/UserRoleCell.tsx
Normal file
103
web/src/refresh-pages/admin/UsersPage/UserRoleCell.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { UserRole, USER_ROLE_LABELS } from "@/lib/types";
|
||||
import { usePaidEnterpriseFeaturesEnabled } from "@/components/settings/usePaidEnterpriseFeaturesEnabled";
|
||||
import InputSelect from "@/refresh-components/inputs/InputSelect";
|
||||
import GenericConfirmModal from "@/components/modals/GenericConfirmModal";
|
||||
import { setUserRole } from "./svc";
|
||||
import type { UserRow } from "./interfaces";
|
||||
|
||||
interface UserRoleCellProps {
|
||||
user: UserRow;
|
||||
onMutate: () => void;
|
||||
}
|
||||
|
||||
export default function UserRoleCell({ user, onMutate }: UserRoleCellProps) {
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const [showConfirmModal, setShowConfirmModal] = useState(false);
|
||||
const [pendingRole, setPendingRole] = useState<string | null>(null);
|
||||
const isPaidEnterpriseFeaturesEnabled = usePaidEnterpriseFeaturesEnabled();
|
||||
|
||||
const applyRole = async (newRole: string) => {
|
||||
setIsUpdating(true);
|
||||
try {
|
||||
await setUserRole(user.email, newRole);
|
||||
onMutate();
|
||||
} catch {
|
||||
// error is surfaced by svc layer; refresh to show current state
|
||||
onMutate();
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (value: string) => {
|
||||
if (value === user.role) return;
|
||||
if (user.role === UserRole.CURATOR) {
|
||||
setPendingRole(value);
|
||||
setShowConfirmModal(true);
|
||||
} else {
|
||||
applyRole(value);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (pendingRole) {
|
||||
applyRole(pendingRole);
|
||||
}
|
||||
setShowConfirmModal(false);
|
||||
setPendingRole(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{showConfirmModal && (
|
||||
<GenericConfirmModal
|
||||
title="Change Curator Role"
|
||||
message={`Warning: Switching roles from Curator to ${
|
||||
USER_ROLE_LABELS[pendingRole as UserRole] ??
|
||||
USER_ROLE_LABELS[user.role]
|
||||
} will remove their status as individual curators from all groups.`}
|
||||
confirmText={`Switch Role to ${
|
||||
USER_ROLE_LABELS[pendingRole as UserRole] ??
|
||||
USER_ROLE_LABELS[user.role]
|
||||
}`}
|
||||
onClose={() => setShowConfirmModal(false)}
|
||||
onConfirm={handleConfirm}
|
||||
/>
|
||||
)}
|
||||
|
||||
<InputSelect
|
||||
value={user.role}
|
||||
onValueChange={handleChange}
|
||||
disabled={isUpdating}
|
||||
>
|
||||
<InputSelect.Trigger />
|
||||
|
||||
<InputSelect.Content>
|
||||
{(Object.entries(USER_ROLE_LABELS) as [UserRole, string][]).map(
|
||||
([role, label]) => {
|
||||
if (role === UserRole.EXT_PERM_USER) return null;
|
||||
|
||||
const isNotVisibleRole =
|
||||
(!isPaidEnterpriseFeaturesEnabled &&
|
||||
role === UserRole.GLOBAL_CURATOR) ||
|
||||
role === UserRole.CURATOR ||
|
||||
role === UserRole.LIMITED ||
|
||||
role === UserRole.SLACK_USER;
|
||||
|
||||
const isCurrentRole = user.role === role;
|
||||
|
||||
return isNotVisibleRole && !isCurrentRole ? null : (
|
||||
<InputSelect.Item key={role} value={role}>
|
||||
{label}
|
||||
</InputSelect.Item>
|
||||
);
|
||||
}
|
||||
)}
|
||||
</InputSelect.Content>
|
||||
</InputSelect>
|
||||
</>
|
||||
);
|
||||
}
|
||||
216
web/src/refresh-pages/admin/UsersPage/UserRowActions.tsx
Normal file
216
web/src/refresh-pages/admin/UsersPage/UserRowActions.tsx
Normal file
@@ -0,0 +1,216 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@opal/components";
|
||||
import {
|
||||
SvgMoreHorizontal,
|
||||
SvgUsers,
|
||||
SvgXCircle,
|
||||
SvgTrash,
|
||||
SvgCheck,
|
||||
} from "@opal/icons";
|
||||
import { Disabled } from "@opal/core";
|
||||
import Popover from "@/refresh-components/Popover";
|
||||
import ConfirmationModalLayout from "@/refresh-components/layouts/ConfirmationModalLayout";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import { toast } from "@/hooks/useToast";
|
||||
import { deactivateUser, activateUser, deleteUser } from "./svc";
|
||||
import EditGroupsModal from "./EditGroupsModal";
|
||||
import type { UserRow } from "./interfaces";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type ModalType = "deactivate" | "activate" | "delete" | "editGroups" | null;
|
||||
|
||||
interface UserRowActionsProps {
|
||||
user: UserRow;
|
||||
onMutate: () => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function UserRowActions({
|
||||
user,
|
||||
onMutate,
|
||||
}: UserRowActionsProps) {
|
||||
const [modal, setModal] = useState<ModalType>(null);
|
||||
const [popoverOpen, setPopoverOpen] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
async function handleAction(
|
||||
action: () => Promise<void>,
|
||||
successMessage: string
|
||||
) {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await action();
|
||||
onMutate();
|
||||
toast.success(successMessage);
|
||||
setModal(null);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "An error occurred");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popover open={popoverOpen} onOpenChange={setPopoverOpen}>
|
||||
<Popover.Trigger asChild>
|
||||
<Button prominence="tertiary" icon={SvgMoreHorizontal} />
|
||||
</Popover.Trigger>
|
||||
<Popover.Content align="end">
|
||||
<div className="flex flex-col gap-0.5 p-1 min-w-[180px]">
|
||||
<Button
|
||||
prominence="tertiary"
|
||||
icon={SvgUsers}
|
||||
onClick={() => {
|
||||
setPopoverOpen(false);
|
||||
setModal("editGroups");
|
||||
}}
|
||||
>
|
||||
Groups
|
||||
</Button>
|
||||
{user.is_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>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover>
|
||||
|
||||
{modal === "editGroups" && (
|
||||
<EditGroupsModal
|
||||
user={user}
|
||||
onClose={() => setModal(null)}
|
||||
onMutate={onMutate}
|
||||
/>
|
||||
)}
|
||||
|
||||
{modal === "deactivate" && (
|
||||
<ConfirmationModalLayout
|
||||
icon={SvgXCircle}
|
||||
title="Deactivate User"
|
||||
onClose={() => setModal(null)}
|
||||
submit={
|
||||
<Disabled disabled={isSubmitting}>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={() => {
|
||||
handleAction(
|
||||
() => deactivateUser(user.email),
|
||||
"User deactivated"
|
||||
);
|
||||
}}
|
||||
>
|
||||
Deactivate
|
||||
</Button>
|
||||
</Disabled>
|
||||
}
|
||||
>
|
||||
<Text as="p" text03>
|
||||
<Text as="span" text05>
|
||||
{user.email}
|
||||
</Text>{" "}
|
||||
will immediately lose access to Onyx. Their sessions and agents will
|
||||
be preserved. You can reactivate this account later.
|
||||
</Text>
|
||||
</ConfirmationModalLayout>
|
||||
)}
|
||||
|
||||
{modal === "activate" && (
|
||||
<ConfirmationModalLayout
|
||||
icon={SvgCheck}
|
||||
title="Activate User"
|
||||
onClose={() => setModal(null)}
|
||||
submit={
|
||||
<Disabled disabled={isSubmitting}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
handleAction(
|
||||
() => activateUser(user.email),
|
||||
"User activated"
|
||||
);
|
||||
}}
|
||||
>
|
||||
Activate
|
||||
</Button>
|
||||
</Disabled>
|
||||
}
|
||||
>
|
||||
<Text as="p" text03>
|
||||
<Text as="span" text05>
|
||||
{user.email}
|
||||
</Text>{" "}
|
||||
will regain access to Onyx.
|
||||
</Text>
|
||||
</ConfirmationModalLayout>
|
||||
)}
|
||||
|
||||
{modal === "delete" && (
|
||||
<ConfirmationModalLayout
|
||||
icon={SvgTrash}
|
||||
title="Delete User"
|
||||
onClose={() => setModal(null)}
|
||||
submit={
|
||||
<Disabled disabled={isSubmitting}>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={() => {
|
||||
handleAction(() => deleteUser(user.email), "User deleted");
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</Disabled>
|
||||
}
|
||||
>
|
||||
<Text as="p" text03>
|
||||
<Text as="span" text05>
|
||||
{user.email}
|
||||
</Text>{" "}
|
||||
will be permanently removed from Onyx. All of their session history
|
||||
will be deleted. This cannot be undone.
|
||||
</Text>
|
||||
</ConfirmationModalLayout>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
206
web/src/refresh-pages/admin/UsersPage/UsersTable.tsx
Normal file
206
web/src/refresh-pages/admin/UsersPage/UsersTable.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import type { SortingState } from "@tanstack/react-table";
|
||||
import DataTable from "@/refresh-components/table/DataTable";
|
||||
import { createTableColumns } from "@/refresh-components/table/columns";
|
||||
import { Content } from "@opal/layouts";
|
||||
import { Tag } from "@opal/components";
|
||||
import { UserRole } from "@/lib/types";
|
||||
import { timeAgo } from "@/lib/time";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import InputTypeIn from "@/refresh-components/inputs/InputTypeIn";
|
||||
import useAdminUsers from "@/hooks/useAdminUsers";
|
||||
import UserFilters from "./UserFilters";
|
||||
import UserRowActions from "./UserRowActions";
|
||||
import UserRoleCell from "./UserRoleCell";
|
||||
import type { UserRow, StatusFilter } from "./interfaces";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function getInitials(name: string | null, email: string): string {
|
||||
if (name) {
|
||||
const parts = name.trim().split(/\s+/);
|
||||
if (parts.length >= 2) {
|
||||
return ((parts[0]?.[0] ?? "") + (parts[1]?.[0] ?? "")).toUpperCase();
|
||||
}
|
||||
return name.slice(0, 2).toUpperCase();
|
||||
}
|
||||
const local = email.split("@")[0];
|
||||
if (!local) return "?";
|
||||
const parts = local.split(/[._-]/);
|
||||
if (parts.length >= 2) {
|
||||
return ((parts[0]?.[0] ?? "") + (parts[1]?.[0] ?? "")).toUpperCase();
|
||||
}
|
||||
return local.slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
function statusLabel(isActive: boolean): string {
|
||||
return isActive ? "Active" : "Inactive";
|
||||
}
|
||||
|
||||
function statusColor(isActive: boolean): "green" | "gray" {
|
||||
return isActive ? "green" : "gray";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Columns
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const tc = createTableColumns<UserRow>();
|
||||
|
||||
function buildColumns(onMutate: () => void) {
|
||||
return [
|
||||
tc.qualifier({
|
||||
content: "avatar-user",
|
||||
getInitials: (row) => getInitials(row.personal_name, row.email),
|
||||
selectable: false,
|
||||
}),
|
||||
tc.column("email", {
|
||||
header: "User",
|
||||
weight: 25,
|
||||
minWidth: 180,
|
||||
cell: (value, row) => (
|
||||
<Content
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
title={row.personal_name ?? value}
|
||||
description={row.personal_name ? value : undefined}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
tc.column("groups", {
|
||||
header: "Groups",
|
||||
weight: 20,
|
||||
minWidth: 120,
|
||||
cell: (value) => {
|
||||
if (!value.length) {
|
||||
return (
|
||||
<Text as="span" secondaryBody text03>
|
||||
—
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
const visible = value.slice(0, 2);
|
||||
const overflow = value.length - visible.length;
|
||||
return (
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
{visible.map((g) => (
|
||||
<Tag key={g.id} title={g.name} />
|
||||
))}
|
||||
{overflow > 0 && (
|
||||
<Text as="span" secondaryBody text03>
|
||||
+{overflow}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}),
|
||||
tc.column("role", {
|
||||
header: "Account Type",
|
||||
weight: 18,
|
||||
minWidth: 120,
|
||||
cell: (_value, row) => <UserRoleCell user={row} onMutate={onMutate} />,
|
||||
}),
|
||||
tc.column("is_active", {
|
||||
header: "Status",
|
||||
weight: 15,
|
||||
minWidth: 100,
|
||||
cell: (value) => (
|
||||
<Tag title={statusLabel(value)} color={statusColor(value)} />
|
||||
),
|
||||
}),
|
||||
tc.column("updated_at", {
|
||||
header: "Last Updated",
|
||||
weight: 14,
|
||||
minWidth: 100,
|
||||
cell: (value) => (
|
||||
<Text as="span" secondaryBody text03>
|
||||
{timeAgo(value) ?? "—"}
|
||||
</Text>
|
||||
),
|
||||
}),
|
||||
tc.actions({
|
||||
cell: (row) => <UserRowActions user={row} onMutate={onMutate} />,
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
export default function UsersTable() {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [pageIndex, setPageIndex] = useState(0);
|
||||
const [selectedRoles, setSelectedRoles] = useState<UserRole[]>([]);
|
||||
const [selectedStatus, setSelectedStatus] = useState<StatusFilter>("all");
|
||||
|
||||
const isActive =
|
||||
selectedStatus === "active"
|
||||
? true
|
||||
: selectedStatus === "inactive"
|
||||
? false
|
||||
: undefined;
|
||||
|
||||
const { users, totalItems, isLoading, refresh } = useAdminUsers({
|
||||
pageIndex,
|
||||
pageSize: PAGE_SIZE,
|
||||
searchTerm: searchTerm || undefined,
|
||||
roles: selectedRoles.length > 0 ? selectedRoles : undefined,
|
||||
isActive,
|
||||
});
|
||||
|
||||
const columns = useMemo(() => buildColumns(() => refresh()), [refresh]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<InputTypeIn
|
||||
value={searchTerm}
|
||||
onChange={(e) => {
|
||||
setSearchTerm(e.target.value);
|
||||
setPageIndex(0);
|
||||
}}
|
||||
placeholder="Search users by email..."
|
||||
leftSearchIcon
|
||||
/>
|
||||
<UserFilters
|
||||
selectedRoles={selectedRoles}
|
||||
onRolesChange={(roles) => {
|
||||
setSelectedRoles(roles);
|
||||
setPageIndex(0);
|
||||
}}
|
||||
selectedStatus={selectedStatus}
|
||||
onStatusChange={(status) => {
|
||||
setSelectedStatus(status);
|
||||
setPageIndex(0);
|
||||
}}
|
||||
/>
|
||||
<DataTable
|
||||
data={users}
|
||||
columns={columns}
|
||||
getRowId={(row) => row.id}
|
||||
pageSize={PAGE_SIZE}
|
||||
searchTerm={searchTerm}
|
||||
footer={{ mode: "summary" }}
|
||||
serverSide={{
|
||||
totalItems,
|
||||
isLoading,
|
||||
onSortingChange: setSorting,
|
||||
onPaginationChange: (idx) => {
|
||||
setPageIndex(idx);
|
||||
},
|
||||
onSearchTermChange: () => {
|
||||
// search state managed via searchTerm prop above
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
24
web/src/refresh-pages/admin/UsersPage/interfaces.ts
Normal file
24
web/src/refresh-pages/admin/UsersPage/interfaces.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { UserRole } from "@/lib/types";
|
||||
|
||||
export interface UserGroupInfo {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface UserRow {
|
||||
id: string;
|
||||
email: string;
|
||||
role: UserRole;
|
||||
is_active: boolean;
|
||||
personal_name: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
groups: UserGroupInfo[];
|
||||
}
|
||||
|
||||
export interface PaginatedUsersResponse {
|
||||
items: UserRow[];
|
||||
total_items: number;
|
||||
}
|
||||
|
||||
export type StatusFilter = "all" | "active" | "inactive";
|
||||
96
web/src/refresh-pages/admin/UsersPage/svc.ts
Normal file
96
web/src/refresh-pages/admin/UsersPage/svc.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
export async function deactivateUser(email: string): Promise<void> {
|
||||
const res = await fetch("/api/manage/admin/deactivate-user", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ user_email: email }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const detail = (await res.json()).detail;
|
||||
throw new Error(detail ?? "Failed to deactivate user");
|
||||
}
|
||||
}
|
||||
|
||||
export async function activateUser(email: string): Promise<void> {
|
||||
const res = await fetch("/api/manage/admin/activate-user", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ user_email: email }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const detail = (await res.json()).detail;
|
||||
throw new Error(detail ?? "Failed to activate user");
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteUser(email: string): Promise<void> {
|
||||
const res = await fetch("/api/manage/admin/delete-user", {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ user_email: email }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const detail = (await res.json()).detail;
|
||||
throw new Error(detail ?? "Failed to delete user");
|
||||
}
|
||||
}
|
||||
|
||||
export async function setUserRole(
|
||||
email: string,
|
||||
newRole: string
|
||||
): Promise<void> {
|
||||
const res = await fetch("/api/manage/set-user-role", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ user_email: email, new_role: newRole }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const detail = (await res.json()).detail;
|
||||
throw new Error(detail ?? "Failed to update user role");
|
||||
}
|
||||
}
|
||||
|
||||
export async function addUserToGroup(
|
||||
groupId: number,
|
||||
userId: string
|
||||
): Promise<void> {
|
||||
const res = await fetch(`/api/manage/admin/user-group/${groupId}/add-users`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ user_ids: [userId] }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const detail = (await res.json()).detail;
|
||||
throw new Error(detail ?? "Failed to add user to group");
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeUserFromGroup(
|
||||
groupId: number,
|
||||
currentUserIds: string[],
|
||||
userIdToRemove: string
|
||||
): Promise<void> {
|
||||
const res = await fetch(`/api/manage/admin/user-group/${groupId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
user_ids: currentUserIds.filter((id) => id !== userIdToRemove),
|
||||
cc_pair_ids: [],
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const detail = (await res.json()).detail;
|
||||
throw new Error(detail ?? "Failed to remove user from group");
|
||||
}
|
||||
}
|
||||
|
||||
export async function inviteUsers(emails: string[]): Promise<void> {
|
||||
const res = await fetch("/api/manage/admin/users", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ emails }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const detail = (await res.json()).detail;
|
||||
throw new Error(detail ?? "Failed to invite users");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user