From 6972d26aaef63a738314c4b35ebac349609801bd Mon Sep 17 00:00:00 2001 From: "Luis Gustavo S. Barreto" Date: Fri, 2 Oct 2026 11:56:42 -0300 Subject: [PATCH] feat(ui): rebuild the admin dashboard around the instance The six counters and the recent sessions repeated what a namespace already shows. The page becomes Instance and differs by edition. Enterprise leads with what waits on the instance admin: members a non-admin namespace owner added, who stay awaiting approval until an instance admin approves them, and a license that is expiring or out of devices. Cloud has open signup and no approval step, so it shows the instance's figures and no list of people. The admin namespace list returns members empty, so finding the namespace a pending member is joining takes one detail request per namespace. The scan stops at 10 namespaces; a member of a namespace past that is listed without "joining". The user filter grammar holds a single operator for all conditions, so it cannot express (name OR username OR email) AND subset. Search on the All tab matches the three fields; under a tab it falls back to username. expires_at <= 0 is a license that never expires on the server, shown as Never rather than the date or "Now". The Cloud device and session figures link nowhere: the admin pages for them were removed. LicenseGuard already sends an expired license, grace period included, and a failed license fetch to the license page. The grace-period notice and the failed-license line on Instance only show when a background refetch turns stale data into that state while the page is open. A figure checks failure before loading because a failed list request still reports a zero total, which would otherwise read as the answer. FilterTabs selects a tab as soon as the arrow keys reach it (automatic activation): a filter has nothing to preview before it applies. Past the device limit the cloud firewall refuses every connection, not just new accepts, so that case gets its own notice. A license whose starts_at is in the future refuses every device accept until then, so it gets a notice of its own. --- ui/apps/console/e2e/members.spec.ts | 6 +- ui/apps/console/src/App.tsx | 10 +- .../components/common/DeviceLimitBanner.tsx | 41 +- .../src/components/common/FilterTabs.tsx | 81 +++ .../common/__tests__/AdminRoute.test.tsx | 4 +- .../common/__tests__/LicenseGuard.test.tsx | 4 +- .../src/components/layout/AdminSidebar.tsx | 2 +- .../layout/__tests__/AdminNavBar.test.tsx | 2 +- .../layout/__tests__/AppLayout.test.tsx | 8 +- .../layout/__tests__/adminNav.test.ts | 2 +- .../console/src/components/layout/adminNav.ts | 3 +- .../__tests__/useSyncWorkspaceTab.test.tsx | 2 +- .../hooks/useAdminAccountRequestMutations.ts | 6 +- .../console/src/hooks/useAdminNamespaces.ts | 37 +- .../src/hooks/useAdminUserMutations.ts | 16 +- ui/apps/console/src/hooks/useAdminUsers.ts | 78 ++- .../console/src/hooks/useDeviceCapacity.ts | 38 ++ ui/apps/console/src/hooks/useServerInfo.ts | 25 + ui/apps/console/src/hooks/useSshEndpoint.ts | 6 +- .../__tests__/AdminLicenseRoute.test.tsx | 8 +- ui/apps/console/src/pages/admin/Dashboard.tsx | 112 ---- ui/apps/console/src/pages/admin/Instance.tsx | 100 ++++ .../pages/admin/__tests__/Dashboard.test.tsx | 105 ---- .../pages/admin/__tests__/Instance.test.tsx | 530 ++++++++++++++++++ .../src/pages/admin/instance/AccessPanel.tsx | 36 ++ .../pages/admin/instance/InstanceNumbers.tsx | 137 +++++ .../src/pages/admin/instance/LicensePanel.tsx | 107 ++++ .../pages/admin/instance/NeedsAttention.tsx | 292 ++++++++++ .../src/pages/admin/instance/Panel.tsx | 125 +++++ .../src/pages/admin/instance/ServerPanel.tsx | 47 ++ .../admin/users/ApproveAccountDialog.tsx | 55 ++ .../pages/admin/users/RejectAccountDialog.tsx | 53 ++ .../admin/users/__tests__/AdminUsers.test.tsx | 214 ++++++- .../console/src/pages/admin/users/index.tsx | 115 ++-- .../console/src/stores/workspaceTabsStore.ts | 2 +- ui/apps/console/src/tests/edition.ts | 10 + ui/apps/console/src/tests/factories.ts | 21 + .../src/utils/__tests__/license.test.ts | 21 + ui/apps/console/src/utils/count.ts | 8 + ui/apps/console/src/utils/license.ts | 20 + 40 files changed, 2131 insertions(+), 358 deletions(-) create mode 100644 ui/apps/console/src/components/common/FilterTabs.tsx create mode 100644 ui/apps/console/src/hooks/useDeviceCapacity.ts create mode 100644 ui/apps/console/src/hooks/useServerInfo.ts delete mode 100644 ui/apps/console/src/pages/admin/Dashboard.tsx create mode 100644 ui/apps/console/src/pages/admin/Instance.tsx delete mode 100644 ui/apps/console/src/pages/admin/__tests__/Dashboard.test.tsx create mode 100644 ui/apps/console/src/pages/admin/__tests__/Instance.test.tsx create mode 100644 ui/apps/console/src/pages/admin/instance/AccessPanel.tsx create mode 100644 ui/apps/console/src/pages/admin/instance/InstanceNumbers.tsx create mode 100644 ui/apps/console/src/pages/admin/instance/LicensePanel.tsx create mode 100644 ui/apps/console/src/pages/admin/instance/NeedsAttention.tsx create mode 100644 ui/apps/console/src/pages/admin/instance/Panel.tsx create mode 100644 ui/apps/console/src/pages/admin/instance/ServerPanel.tsx create mode 100644 ui/apps/console/src/pages/admin/users/ApproveAccountDialog.tsx create mode 100644 ui/apps/console/src/pages/admin/users/RejectAccountDialog.tsx create mode 100644 ui/apps/console/src/tests/edition.ts create mode 100644 ui/apps/console/src/utils/count.ts diff --git a/ui/apps/console/e2e/members.spec.ts b/ui/apps/console/e2e/members.spec.ts index 42bbef6ee14..e05cef9ed7a 100644 --- a/ui/apps/console/e2e/members.spec.ts +++ b/ui/apps/console/e2e/members.spec.ts @@ -150,13 +150,15 @@ test.describe("invitations", () => { const { owner: admin } = await createTeam({ admin: true }); await signInAndOpen(page, admin.username, "/admin/users"); - await page.getByLabel("Search users by username").fill(username); + await page + .getByLabel("Search users by name, username or email") + .fill(username); const row = findRow(page, email); await expect(row).toContainText("Awaiting Approval"); await row .getByRole("button", { name: `Approve account for ${email}` }) .click(); - await confirmDialog(page, "Approve account", "Approve account"); + await confirmDialog(page, "Approve user", "Approve"); await expect(row).toContainText("Confirmed"); const { tenant: joined } = await loginAs(username, password); diff --git a/ui/apps/console/src/App.tsx b/ui/apps/console/src/App.tsx index ddc4d2f4797..9895e20fc98 100644 --- a/ui/apps/console/src/App.tsx +++ b/ui/apps/console/src/App.tsx @@ -86,7 +86,7 @@ const UpdatePassword = lazy(() => import("./pages/UpdatePassword")); const AcceptInvite = lazy(() => import("./pages/AcceptInvite")); const AcceptDevice = lazy(() => import("./pages/AcceptDevice")); const SecureVault = lazy(() => import("./pages/secure-vault")); -const AdminDashboard = lazy(() => import("./pages/admin/Dashboard")); +const AdminInstance = lazy(() => import("./pages/admin/Instance")); const AdminLicense = lazy(() => import("./pages/admin/License")); const AdminUnauthorized = lazy(() => import("./pages/admin/Unauthorized")); const AdminUsers = lazy(() => import("./pages/admin/users")); @@ -165,7 +165,7 @@ export default function App() { path="/admin/license" element={ isCloud() ? ( - + ) : ( ) @@ -174,11 +174,11 @@ export default function App() { }> } + element={} /> } + path="/admin/instance" + element={} /> } /> = 0 && - typeof registered === "number" && - Number.isFinite(registered) && - registered >= cap; - - const approaching = - typeof cap === "number" && - cap > 0 && - !over && - typeof registered === "number" && - Number.isFinite(registered) && - registered / cap >= 0.9; - - const visible = - !licenseLoading && - !licenseError && - !statsLoading && - !statsError && - license != null && - (over || approaching); + const visible = !isLoading && !isError && (over || approaching); const severity = over ? "error" : "warning"; diff --git a/ui/apps/console/src/components/common/FilterTabs.tsx b/ui/apps/console/src/components/common/FilterTabs.tsx new file mode 100644 index 00000000000..4ce009e1d7d --- /dev/null +++ b/ui/apps/console/src/components/common/FilterTabs.tsx @@ -0,0 +1,81 @@ +import type { KeyboardEvent } from "react"; +import { cn } from "@shellhub/design-system/cn"; + +/** + * One choice in a FilterTabs row. value must be unique within the row: it is both the React key + * and how the selected tab is recognised. + */ +export interface FilterTab { + label: string; + value: T; +} + +/** + * A segmented row of mutually exclusive filters over a list, announced as a tablist. Only the + * selected tab is in the tab order; the arrow keys, Home and End move to another tab and select + * it at once. A value matching no tab leaves the first one focusable. + */ +export default function FilterTabs({ + tabs, + value, + onChange, + label, +}: { + tabs: FilterTab[]; + value: T; + onChange: (value: T) => void; + label: string; +}) { + const selected = Math.max( + 0, + tabs.findIndex((tab) => tab.value === value), + ); + + const moveTo = (e: KeyboardEvent) => { + const target = + e.key === "ArrowRight" + ? (selected + 1) % tabs.length + : e.key === "ArrowLeft" + ? (selected - 1 + tabs.length) % tabs.length + : e.key === "Home" + ? 0 + : e.key === "End" + ? tabs.length - 1 + : null; + if (target === null) return; + e.preventDefault(); + onChange(tabs[target].value); + const buttons = + e.currentTarget.querySelectorAll('[role="tab"]'); + buttons[target]?.focus(); + }; + + return ( + // eslint-disable-next-line jsx-a11y/interactive-supports-focus -- focus sits on the tabs; the list only relays the arrow keys between them +
+ {tabs.map((tab, index) => ( + + ))} +
+ ); +} diff --git a/ui/apps/console/src/components/common/__tests__/AdminRoute.test.tsx b/ui/apps/console/src/components/common/__tests__/AdminRoute.test.tsx index c3248b0df0f..219268f4912 100644 --- a/ui/apps/console/src/components/common/__tests__/AdminRoute.test.tsx +++ b/ui/apps/console/src/components/common/__tests__/AdminRoute.test.tsx @@ -10,10 +10,10 @@ beforeEach(() => { function renderAdminRoute() { return render( - + }> - admin content} /> + admin content} /> ) { const ui = ( - + }> protected content} /> diff --git a/ui/apps/console/src/components/layout/AdminSidebar.tsx b/ui/apps/console/src/components/layout/AdminSidebar.tsx index eaf3b907f65..68155fdbd0b 100644 --- a/ui/apps/console/src/components/layout/AdminSidebar.tsx +++ b/ui/apps/console/src/components/layout/AdminSidebar.tsx @@ -14,7 +14,7 @@ export default function AdminSidebar({ expanded }: { expanded: boolean }) { expanded={expanded} covered onNavigate={minimizeAll} - logoHref="/admin/dashboard" + logoHref="/admin/instance" /> ); } diff --git a/ui/apps/console/src/components/layout/__tests__/AdminNavBar.test.tsx b/ui/apps/console/src/components/layout/__tests__/AdminNavBar.test.tsx index 3e097660258..e8030e5f725 100644 --- a/ui/apps/console/src/components/layout/__tests__/AdminNavBar.test.tsx +++ b/ui/apps/console/src/components/layout/__tests__/AdminNavBar.test.tsx @@ -86,7 +86,7 @@ describe("AdminNavBar", () => { await screen.findByRole("link", { name: "License" }), ).toBeInTheDocument(); expect( - screen.queryByRole("link", { name: "Dashboard" }), + screen.queryByRole("link", { name: "Instance" }), ).not.toBeInTheDocument(); }); diff --git a/ui/apps/console/src/components/layout/__tests__/AppLayout.test.tsx b/ui/apps/console/src/components/layout/__tests__/AppLayout.test.tsx index 737079ae8ed..dd9491df34b 100644 --- a/ui/apps/console/src/components/layout/__tests__/AppLayout.test.tsx +++ b/ui/apps/console/src/components/layout/__tests__/AppLayout.test.tsx @@ -106,7 +106,7 @@ describe("AppLayout", () => { viewport.desktop = desktop; seedAuthStore({ isAdmin: true }); mockGetConfig.mockReturnValue({ ...defaultConfig, edition: "cloud" }); - renderLayout("/admin/dashboard"); + renderLayout("/admin/instance"); const menus = await screen.findAllByRole("button", { name: /account menu for/i, @@ -121,9 +121,9 @@ describe("AppLayout", () => { it("gives way to the admin bar across the top on admin routes", async () => { seedAuthStore({ isAdmin: true }); mockGetConfig.mockReturnValue({ ...defaultConfig, edition: "cloud" }); - renderLayout("/admin/dashboard"); + renderLayout("/admin/instance"); expect( - await screen.findByRole("link", { name: "Dashboard" }), + await screen.findByRole("link", { name: "Instance" }), ).toBeInTheDocument(); expect( screen.queryByRole("navigation", { name: "Main navigation" }), @@ -258,7 +258,7 @@ describe("AppLayout", () => { const user = userEvent.setup(); seedAuthStore({ isAdmin: true }); mockGetConfig.mockReturnValue({ ...defaultConfig, edition: "cloud" }); - renderLayout("/admin/dashboard"); + renderLayout("/admin/instance"); const adminTab = await screen.findByRole("tab", { name: /admin console/i, diff --git a/ui/apps/console/src/components/layout/__tests__/adminNav.test.ts b/ui/apps/console/src/components/layout/__tests__/adminNav.test.ts index 140558a2af8..2dc4ddd6751 100644 --- a/ui/apps/console/src/components/layout/__tests__/adminNav.test.ts +++ b/ui/apps/console/src/components/layout/__tests__/adminNav.test.ts @@ -3,7 +3,7 @@ import { adminNavSectionTitle } from "../adminNav"; describe("adminNavSectionTitle", () => { it.each([ - ["/admin/dashboard", undefined], + ["/admin/instance", undefined], ["/admin/users", "Accounts"], ["/admin/namespaces", "Accounts"], ["/admin/settings/authentication", "Instance"], diff --git a/ui/apps/console/src/components/layout/adminNav.ts b/ui/apps/console/src/components/layout/adminNav.ts index 9bacbbcc98d..431dbb230a0 100644 --- a/ui/apps/console/src/components/layout/adminNav.ts +++ b/ui/apps/console/src/components/layout/adminNav.ts @@ -2,6 +2,7 @@ import { HomeIcon, UsersIcon, ServerStackIcon, + ServerIcon, KeyIcon, LockClosedIcon, DocumentCheckIcon, @@ -53,7 +54,7 @@ function buildFullSections(): AdminNavSection[] { return [ { - items: [{ to: "/admin/dashboard", label: "Dashboard", icon: HomeIcon }], + items: [{ to: "/admin/instance", label: "Instance", icon: ServerIcon }], }, { title: "Accounts", diff --git a/ui/apps/console/src/hooks/__tests__/useSyncWorkspaceTab.test.tsx b/ui/apps/console/src/hooks/__tests__/useSyncWorkspaceTab.test.tsx index 1a94b31480a..8c508af6740 100644 --- a/ui/apps/console/src/hooks/__tests__/useSyncWorkspaceTab.test.tsx +++ b/ui/apps/console/src/hooks/__tests__/useSyncWorkspaceTab.test.tsx @@ -29,7 +29,7 @@ beforeEach(() => { describe("useSyncWorkspaceTab", () => { it("opens the admin console's tab on its pages", () => { - syncAt("/admin/dashboard"); + syncAt("/admin/instance"); expect(useWorkspaceTabsStore.getState().tabs.map((t) => t.id)).toEqual([ "admin", diff --git a/ui/apps/console/src/hooks/useAdminAccountRequestMutations.ts b/ui/apps/console/src/hooks/useAdminAccountRequestMutations.ts index 4cffaf84b29..1570b44fb3e 100644 --- a/ui/apps/console/src/hooks/useAdminAccountRequestMutations.ts +++ b/ui/apps/console/src/hooks/useAdminAccountRequestMutations.ts @@ -8,7 +8,11 @@ import { useInvalidateByIds } from "./useInvalidateQueries"; * for them afterwards from the members list. */ export function useApproveAccountRequest() { - const invalidate = useInvalidateByIds("getUsers", "getUser"); + const invalidate = useInvalidateByIds( + "getUsers", + "getUser", + "getNamespaceAdmin", + ); return useMutation({ ...approveUserMutation(), onSuccess: invalidate, diff --git a/ui/apps/console/src/hooks/useAdminNamespaces.ts b/ui/apps/console/src/hooks/useAdminNamespaces.ts index 247d69cbb1f..26307a96115 100644 --- a/ui/apps/console/src/hooks/useAdminNamespaces.ts +++ b/ui/apps/console/src/hooks/useAdminNamespaces.ts @@ -1,4 +1,4 @@ -import { useQuery } from "@tanstack/react-query"; +import { useQueries, useQuery } from "@tanstack/react-query"; import { getNamespacesAdmin as getNamespacesAdminSdk, getNamespacesAdminQueryKey, @@ -56,6 +56,7 @@ export function useAdminNamespaces({ totalCount: result.data?.totalCount ?? 0, isLoading: result.isLoading, error: result.error, + isError: result.isError, refetch: result.refetch, }; } @@ -75,3 +76,37 @@ export function useAdminNamespace(tenantId: string) { refetchOnWindowFocus: false, }); } + +/** + * The namespaces each user awaiting approval was added to, by user id, read from the details of + * up to scanLimit namespaces. A user added to a namespace beyond that is missing from the map. + */ +export function useAwaitingMemberNamespaces(scanLimit: number) { + const isAdmin = useAuthStore((s) => s.isAdmin); + const { namespaces } = useAdminNamespaces({ perPage: scanLimit }); + + return useQueries({ + queries: namespaces.map((namespace) => ({ + ...getNamespaceAdminOptions({ path: { tenant: namespace.tenant_id } }), + enabled: isAdmin, + staleTime: 5 * 60 * 1000, + retry: (count: number, err: unknown) => + isSdkError(err) && err.status === 401 ? false : count < 1, + refetchOnWindowFocus: false, + })), + combine: (results) => { + const joiningByUser = new Map(); + for (const { data } of results) { + if (!data) continue; + for (const member of data.members) { + if (!member.id || !member.awaiting_approval) continue; + joiningByUser.set(member.id, [ + ...(joiningByUser.get(member.id) ?? []), + data.name, + ]); + } + } + return joiningByUser; + }, + }); +} diff --git a/ui/apps/console/src/hooks/useAdminUserMutations.ts b/ui/apps/console/src/hooks/useAdminUserMutations.ts index 8f644d4a943..5a0ff05030c 100644 --- a/ui/apps/console/src/hooks/useAdminUserMutations.ts +++ b/ui/apps/console/src/hooks/useAdminUserMutations.ts @@ -8,10 +8,10 @@ import { import { useInvalidateByIds } from "./useInvalidateQueries"; /** - * Creates a user as an admin, refreshing the user list. + * Creates a user as an admin, refreshing the user list and the instance counts. */ export function useCreateUser() { - const invalidate = useInvalidateByIds("getUsers"); + const invalidate = useInvalidateByIds("getUsers", "getStats"); return useMutation({ ...createUserAdminMutation(), onSuccess: invalidate, @@ -30,10 +30,18 @@ export function useUpdateUser() { } /** - * Deletes a user as an admin, refreshing the list and the user's own query. + * Deletes a user as an admin, along with the namespaces they own, refreshing the user and + * namespace lists, the user's own query, the instance counts and + * the namespace details that listed them as a member. */ export function useDeleteUser() { - const invalidate = useInvalidateByIds("getUsers", "getUser"); + const invalidate = useInvalidateByIds( + "getUsers", + "getUser", + "getStats", + "getNamespaceAdmin", + "getNamespacesAdmin", + ); return useMutation({ ...adminDeleteUserMutation(), onSuccess: invalidate, diff --git a/ui/apps/console/src/hooks/useAdminUsers.ts b/ui/apps/console/src/hooks/useAdminUsers.ts index ed5a760f74f..a6df8cc0dab 100644 --- a/ui/apps/console/src/hooks/useAdminUsers.ts +++ b/ui/apps/console/src/hooks/useAdminUsers.ts @@ -11,20 +11,77 @@ import { useAuthStore } from "../stores/authStore"; import { isSdkError } from "../api/errors"; import { toBase64Json } from "@/utils/encoding"; -function buildUsernameFilter(search: string): string { - const filter = [ - { - type: "property", - params: { name: "username", operator: "contains", value: search }, - }, - ]; - return toBase64Json(filter); +/** + * A subset of the user list the admin can narrow to: accounts waiting for an admin's approval, the + * instance admins themselves, or sign-ups that never confirmed their email. The empty string is + * every user. + */ +export type AdminUserSubset = + "" | "awaiting_approval" | "admin" | "not_confirmed"; + +type FilterProperty = { + type: "property"; + params: { name: string; operator: string; value: unknown }; +}; + +const SUBSET_FILTERS: Record, FilterProperty> = { + awaiting_approval: { + type: "property", + params: { name: "awaiting_approval", operator: "bool", value: true }, + }, + admin: { + type: "property", + params: { name: "admin", operator: "bool", value: true }, + }, + not_confirmed: { + type: "property", + params: { name: "status", operator: "eq", value: "not-confirmed" }, + }, +}; + +function contains(name: string, value: string): FilterProperty { + return { type: "property", params: { name, operator: "contains", value } }; +} + +function usersSearchFields( + subset: AdminUserSubset, +): ("name" | "username" | "email")[] { + return subset ? ["username"] : ["name", "username", "email"]; +} + +/** + * The fields a search over this subset matches, as a phrase for the search box ("name, username + * or email"). Every user is searched by all three; a subset narrows the search to username. The + * phrase and the filter come from the same list, so they cannot disagree. + */ +export function usersSearchScope(subset: AdminUserSubset): string { + const fields = usersSearchFields(subset); + return fields.length > 1 + ? `${fields.slice(0, -1).join(", ")} or ${fields[fields.length - 1]}` + : fields[0]; +} + +function buildUsersFilter( + search: string, + subset: AdminUserSubset, +): string | undefined { + const matches = search + ? usersSearchFields(subset).map((field) => contains(field, search)) + : []; + if (!subset) return matches.length > 0 ? toBase64Json(matches) : undefined; + if (matches.length === 0) return toBase64Json([SUBSET_FILTERS[subset]]); + return toBase64Json([ + { type: "operator", params: { name: "and" } }, + ...matches, + SUBSET_FILTERS[subset], + ]); } interface UseAdminUsersParams { page?: number; perPage?: number; search?: string; + subset?: AdminUserSubset; } /** @@ -34,11 +91,13 @@ export function useAdminUsers({ page = 1, perPage = 10, search = "", + subset = "", }: UseAdminUsersParams = {}) { const isAdmin = useAuthStore((s) => s.isAdmin); const query: GetUsersData["query"] = { page, per_page: perPage }; - if (search) query.filter = buildUsernameFilter(search); + const filter = buildUsersFilter(search, subset); + if (filter) query.filter = filter; const options = { query }; const result = useQuery>({ @@ -56,6 +115,7 @@ export function useAdminUsers({ totalCount: result.data?.totalCount ?? 0, isLoading: result.isLoading, error: result.error, + isError: result.isError, refetch: result.refetch, }; } diff --git a/ui/apps/console/src/hooks/useDeviceCapacity.ts b/ui/apps/console/src/hooks/useDeviceCapacity.ts new file mode 100644 index 00000000000..5480fb13c91 --- /dev/null +++ b/ui/apps/console/src/hooks/useDeviceCapacity.ts @@ -0,0 +1,38 @@ +import { useAdminLicense } from "@/hooks/useAdminLicense"; +import { useAdminStats } from "@/hooks/useAdminStats"; +import { deviceCapacity, type DeviceCapacity } from "@/utils/license"; + +/** + * The installed licence and its accepted devices against its limit. capacity is null until both + * the licence and the stats have answered, and stays null when either failed or no licence is + * installed, so a caller checks isLoading and failed before reading a null as nothing to report. + * failed names the request that failed, the licence's first when both did. + */ +export function useDeviceCapacity() { + const license = useAdminLicense(); + const { + stats, + isLoading: statsLoading, + isError: statsError, + } = useAdminStats(); + + const limit = license.installedLicense?.features.devices; + const used = stats?.registered_devices; + const failed: "license" | "stats" | null = license.isError + ? "license" + : statsError + ? "stats" + : null; + const capacity: DeviceCapacity | null = + typeof limit === "number" && typeof used === "number" + ? deviceCapacity(limit, used) + : null; + + return { + license: license.installedLicense, + capacity, + isLoading: license.isLoading || statsLoading, + isError: failed !== null, + failed, + }; +} diff --git a/ui/apps/console/src/hooks/useServerInfo.ts b/ui/apps/console/src/hooks/useServerInfo.ts new file mode 100644 index 00000000000..04ec2f932f8 --- /dev/null +++ b/ui/apps/console/src/hooks/useServerInfo.ts @@ -0,0 +1,25 @@ +import { useQuery } from "@tanstack/react-query"; +import { getInfoOptions } from "@/client"; + +/** + * What the server reports about itself: its version, the endpoints users reach it on, and which + * sign-in methods are enabled. The methods are an empty list until the server answers, so a + * caller checks isLoading and isError before reading an empty list as none enabled. + */ +export function useServerInfo() { + const { data, isLoading, isError } = useQuery(getInfoOptions()); + + const signInMethods = [ + data?.authentication?.local && "Local", + data?.authentication?.saml && "SAML", + ].filter((method): method is string => !!method); + + return { + version: data?.version, + sshEndpoint: data?.endpoints?.ssh, + apiEndpoint: data?.endpoints?.api, + signInMethods, + isLoading, + isError, + }; +} diff --git a/ui/apps/console/src/hooks/useSshEndpoint.ts b/ui/apps/console/src/hooks/useSshEndpoint.ts index 3595d8de933..814803cddb8 100644 --- a/ui/apps/console/src/hooks/useSshEndpoint.ts +++ b/ui/apps/console/src/hooks/useSshEndpoint.ts @@ -1,5 +1,4 @@ -import { useQuery } from "@tanstack/react-query"; -import { getInfoOptions } from "@/client"; +import { useServerInfo } from "@/hooks/useServerInfo"; import { DEFAULT_SSH_PORT } from "@/utils/sshid"; @@ -9,8 +8,7 @@ import { DEFAULT_SSH_PORT } from "@/utils/sshid"; * endpoint. */ export function useSshEndpoint(): { host: string; port: number } { - const { data } = useQuery(getInfoOptions()); - const endpoint = data?.endpoints?.ssh ?? ""; + const endpoint = useServerInfo().sshEndpoint ?? ""; const colon = endpoint.lastIndexOf(":"); const host = (colon < 0 ? endpoint : endpoint.slice(0, colon)) || diff --git a/ui/apps/console/src/pages/__tests__/AdminLicenseRoute.test.tsx b/ui/apps/console/src/pages/__tests__/AdminLicenseRoute.test.tsx index fd2b6ab6748..3aff50622c7 100644 --- a/ui/apps/console/src/pages/__tests__/AdminLicenseRoute.test.tsx +++ b/ui/apps/console/src/pages/__tests__/AdminLicenseRoute.test.tsx @@ -30,13 +30,13 @@ function renderRoute() { path="/admin/license" element={ isCloud() ? ( - + ) : ( ) } /> - } /> + } /> , ); @@ -52,11 +52,11 @@ describe("AdminLicenseRoute — /admin/license element", () => { mockedGetConfig.mockReturnValue({ ...defaultConfig, edition: "cloud" }); }); - it("redirects to /admin/dashboard in cloud mode", () => { + it("redirects to /admin/instance in cloud mode", () => { renderRoute(); expect(screen.getByTestId("pathname")).toHaveTextContent( - "/admin/dashboard", + "/admin/instance", ); }); }); diff --git a/ui/apps/console/src/pages/admin/Dashboard.tsx b/ui/apps/console/src/pages/admin/Dashboard.tsx deleted file mode 100644 index 6c99aba5af9..00000000000 --- a/ui/apps/console/src/pages/admin/Dashboard.tsx +++ /dev/null @@ -1,112 +0,0 @@ -import { - UsersIcon, - CpuChipIcon, - SignalIcon, - ClockIcon, - XCircleIcon, - CommandLineIcon, - ChartBarIcon, - ExclamationCircleIcon, -} from "@heroicons/react/24/outline"; -import PageHeader from "@/components/common/PageHeader"; -import StatCard, { type StatCardProps } from "@/components/common/StatCard"; -import { useAdminStats } from "@/hooks/useAdminStats"; -import PageLoader from "@/components/common/PageLoader"; - -/** - * The admin dashboard: instance-wide counts of users, devices and sessions. - */ -export default function AdminDashboard() { - const { - stats: statsData, - isLoading: statsLoading, - isError: statsError, - } = useAdminStats(); - - if (statsLoading) { - return ; - } - - if (statsError) { - return ( -
-
- -

- Failed to load dashboard statistics -

-

- Please try again later. -

-
-
- ); - } - - const stats = statsData ?? {}; - - const statCards: StatCardProps[] = [ - { - value: stats.registered_users ?? 0, - icon: , - title: "Registered Users", - action: { label: "View all Users", to: "/admin/users" }, - }, - { - value: stats.registered_devices ?? 0, - icon: , - title: "Registered Devices", - }, - { - value: stats.online_devices ?? 0, - icon: , - title: "Online Devices", - accent: "text-accent-green", - }, - { - value: stats.pending_devices ?? 0, - icon: , - title: "Pending Devices", - accent: "text-accent-yellow", - }, - { - value: stats.rejected_devices ?? 0, - icon: , - title: "Rejected Devices", - accent: "text-accent-red", - }, - { - value: stats.active_sessions ?? 0, - icon: , - title: "Active Sessions", - }, - ]; - - return ( -
- } - title="System Overview" - description="Monitor key metrics about users, devices, and sessions across the instance." - /> - -
-

- Stats -

-
- -
- {statCards.map((card, i) => ( -
- -
- ))} -
-
- ); -} diff --git a/ui/apps/console/src/pages/admin/Instance.tsx b/ui/apps/console/src/pages/admin/Instance.tsx new file mode 100644 index 00000000000..638ff8816b7 --- /dev/null +++ b/ui/apps/console/src/pages/admin/Instance.tsx @@ -0,0 +1,100 @@ +import { useState } from "react"; +import { ServerIcon, UserPlusIcon } from "@heroicons/react/24/outline"; +import { Button } from "@shellhub/design-system/primitives"; +import { isEnterprise } from "@/env"; +import { useAdminStats } from "@/hooks/useAdminStats"; +import { useAdminNamespaces } from "@/hooks/useAdminNamespaces"; +import PageHeader from "@/components/common/PageHeader"; +import CreateUserModal from "./users/CreateUserModal"; +import NeedsAttention from "./instance/NeedsAttention"; +import LicensePanel from "./instance/LicensePanel"; +import AccessPanel from "./instance/AccessPanel"; +import ServerPanel from "./instance/ServerPanel"; +import InstanceNumbers from "./instance/InstanceNumbers"; +import { Count, Row } from "./instance/Panel"; + +function EnterpriseInstance() { + const { + stats, + isLoading: statsLoading, + isError: statsError, + } = useAdminStats(); + const namespaces = useAdminNamespaces({ perPage: 1 }); + + return ( +
+
+ + +
+
+ + + + + + + + + +
+
+ ); +} + +function CloudInstance() { + return ( +
+ +
+ + +
+
+ ); +} + +/** + * The instance admin's starting point. Enterprise leads with what waits on the admin and the + * licence; Cloud, where people sign themselves up, leads with the instance's figures. + */ +export default function AdminInstance() { + const [createUserOpen, setCreateUserOpen] = useState(false); + const enterprise = isEnterprise(); + + return ( +
+ } + title="Instance" + description={ + enterprise + ? "What's waiting on you, and what this instance runs." + : "How much this instance carries, and what it runs." + } + > + + + + {enterprise ? : } + + setCreateUserOpen(false)} + /> +
+ ); +} diff --git a/ui/apps/console/src/pages/admin/__tests__/Dashboard.test.tsx b/ui/apps/console/src/pages/admin/__tests__/Dashboard.test.tsx deleted file mode 100644 index 70b887f793c..00000000000 --- a/ui/apps/console/src/pages/admin/__tests__/Dashboard.test.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import { describe, it, expect, beforeEach } from "vitest"; -import { render, screen, waitFor } from "@testing-library/react"; -import { MemoryRouter } from "react-router-dom"; -import { http, HttpResponse } from "msw"; -import { server } from "@/tests/msw"; -import { createTestWrapper } from "@/tests/wrapper"; -import { useAuthStore } from "@/stores/authStore"; -import AdminDashboard from "../Dashboard"; - -const fullStats = { - registered_users: 42, - registered_devices: 150, - online_devices: 75, - active_sessions: 12, - pending_devices: 5, - rejected_devices: 3, -}; - -function renderPage() { - return render( - - - , - { wrapper: createTestWrapper() }, - ); -} - -beforeEach(() => { - useAuthStore.setState({ isAdmin: true }); - server.use(http.get("*/admin/api/stats", () => HttpResponse.json(fullStats))); -}); - -describe("AdminDashboard", () => { - describe("loading state", () => { - it("renders spinner with role='status'", () => { - server.use(http.get("*/admin/api/stats", () => new Promise(() => {}))); - renderPage(); - expect(screen.getByRole("status")).toBeInTheDocument(); - }); - }); - - describe("error state", () => { - it("renders error message with role='alert'", async () => { - server.use( - http.get("*/admin/api/stats", () => - HttpResponse.json({}, { status: 500 }), - ), - ); - renderPage(); - await waitFor(() => { - expect(screen.getByRole("alert")).toHaveTextContent( - "Failed to load dashboard statistics", - ); - }); - expect(screen.queryByText("Registered Users")).not.toBeInTheDocument(); - }); - }); - - describe("success state, all fields present", () => { - it("renders correct numeric values for each stat", async () => { - renderPage(); - await waitFor(() => { - expect(screen.getByText("42")).toBeInTheDocument(); - }); - expect(screen.getByText("150")).toBeInTheDocument(); - expect(screen.getByText("75")).toBeInTheDocument(); - expect(screen.getByText("12")).toBeInTheDocument(); - expect(screen.getByText("5")).toBeInTheDocument(); - expect(screen.getByText("3")).toBeInTheDocument(); - }); - - it("links the users count to the users page", async () => { - renderPage(); - await waitFor(() => { - expect( - screen.getByRole("link", { name: /view all users/i }), - ).toHaveAttribute("href", "/admin/users"); - }); - }); - - it("links neither the device nor the session counts", async () => { - renderPage(); - await screen.findByText("Registered Devices"); - expect( - screen.queryByRole("link", { name: /devices|sessions/i }), - ).not.toBeInTheDocument(); - }); - }); - - describe("success state, partial stats response", () => { - it("renders 0 for each missing stat field", async () => { - server.use( - http.get("*/admin/api/stats", () => - HttpResponse.json({ registered_users: 10 }), - ), - ); - renderPage(); - await waitFor(() => { - expect(screen.getByText("10")).toBeInTheDocument(); - }); - const zeros = screen.getAllByText("0"); - expect(zeros.length).toBeGreaterThanOrEqual(5); - }); - }); -}); diff --git a/ui/apps/console/src/pages/admin/__tests__/Instance.test.tsx b/ui/apps/console/src/pages/admin/__tests__/Instance.test.tsx new file mode 100644 index 00000000000..1a84fa415f0 --- /dev/null +++ b/ui/apps/console/src/pages/admin/__tests__/Instance.test.tsx @@ -0,0 +1,530 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { MemoryRouter } from "react-router-dom"; +import { http, HttpResponse } from "msw"; +import { server, jsonWithTotal } from "@/tests/msw"; +import { createTestWrapper } from "@/tests/wrapper"; +import { decodeB64url } from "@/tests/decodeB64url"; +import { setEdition } from "@/tests/edition"; +import { mockAdminUser, mockLicense, mockNamespace } from "@/tests/factories"; +import { useAuthStore } from "@/stores/authStore"; +import type { GetLicenseResponse, UserAdminResponse } from "@/client"; +import AdminInstance from "../Instance"; + +vi.mock("@/components/common/CopyButton", async () => ({ + default: (await import("@/tests/mocks")).MockCopyButton, +})); + +const maria = mockAdminUser({ + id: "user-maria", + name: "Maria", + email: "maria@acme.io", + username: "maria", +}); + +const stats = { + registered_users: 42, + registered_devices: 9, + online_devices: 3, + pending_devices: 2, + rejected_devices: 1, + active_sessions: 4, +}; + +type FilterProperty = { + type: "property"; + params: { name: string; operator: string; value: unknown }; +}; + +const SUBSET_PROPERTIES: Record = { + awaiting_approval: { + name: "awaiting_approval", + operator: "bool", + value: true, + }, + admin: { name: "admin", operator: "bool", value: true }, + not_confirmed: { name: "status", operator: "eq", value: "not-confirmed" }, +}; + +function requestedSubset(request: Request): string { + const raw = new URL(request.url).searchParams.get("filter"); + if (!raw) return ""; + const nodes = decodeB64url(raw) as { type: string; params: unknown }[]; + const found = Object.entries(SUBSET_PROPERTIES).find(([, params]) => + nodes.some( + (node) => + node.type === "property" && + JSON.stringify(node.params) === JSON.stringify(params), + ), + ); + return found?.[0] ?? ""; +} + +function serveUsers({ + pending = [] as UserAdminResponse[], + admins = 1, + unconfirmed = 0, +} = {}) { + const state = { pending }; + server.use( + http.get("*/admin/api/users", ({ request }) => { + const subset = requestedSubset(request); + if (subset === "awaiting_approval") return jsonWithTotal(state.pending); + if (subset === "admin") return jsonWithTotal([], admins); + if (subset === "not_confirmed") return jsonWithTotal([], unconfirmed); + return jsonWithTotal([]); + }), + ); + return state; +} + +function serveLicense(overrides: Partial = {}) { + server.use( + http.get("*/admin/api/license", () => + HttpResponse.json(mockLicense(overrides)), + ), + ); +} + +function withDeviceLimit(devices: number): Partial { + return { features: { ...mockLicense().features, devices } }; +} + +function serveStats(registeredDevices: number) { + server.use( + http.get("*/admin/api/stats", () => + HttpResponse.json({ ...stats, registered_devices: registeredDevices }), + ), + ); +} + +function failStats() { + server.use( + http.get("*/admin/api/stats", () => HttpResponse.json({}, { status: 500 })), + ); +} + +function renderPage() { + return render( + + + , + { wrapper: createTestWrapper() }, + ); +} + +beforeEach(() => { + vi.clearAllMocks(); + useAuthStore.setState({ isAdmin: true }); + setEdition("enterprise"); + serveUsers(); + serveLicense(); + server.use( + http.get("*/admin/api/stats", () => HttpResponse.json(stats)), + http.get("*/info", () => + HttpResponse.json({ + version: "v0.27.0", + endpoints: { ssh: "shellhub.example:22", api: "shellhub.example:443" }, + setup: true, + authentication: { local: true, saml: true }, + }), + ), + http.get("*/admin/api/namespaces", () => + jsonWithTotal([mockNamespace({ name: "acme", tenant_id: "t-acme" })], 7), + ), + http.get("*/admin/api/namespaces/:tenant", () => + HttpResponse.json( + mockNamespace({ + name: "acme", + tenant_id: "t-acme", + members: [ + { + id: "user-maria", + email: "maria@acme.io", + role: "operator", + awaiting_approval: true, + }, + ], + }), + ), + ), + ); +}); + +describe("AdminInstance", () => { + describe("enterprise", () => { + describe("member requests", () => { + it("lists each member request with the namespace they are joining", async () => { + serveUsers({ pending: [maria] }); + renderPage(); + + const requests = await screen.findByRole("list", { + name: "New members awaiting approval", + }); + expect(within(requests).getByText("Maria")).toBeInTheDocument(); + expect( + await within(requests).findByText(/joining acme/), + ).toBeInTheDocument(); + expect( + screen.getByRole("link", { name: /open in the users list/i }), + ).toHaveAttribute("href", "/admin/users?subset=awaiting_approval"); + }); + + it("approves a member request and drops it from the list", async () => { + const users = serveUsers({ pending: [maria] }); + const approved = vi.fn(); + server.use( + http.post("*/admin/api/users/:id/approve", ({ params }) => { + approved(params.id); + users.pending = []; + return new HttpResponse(null, { status: 200 }); + }), + ); + renderPage(); + + await userEvent.click( + await screen.findByRole("button", { + name: "Approve account for maria@acme.io", + }), + ); + const dialog = await screen.findByRole("dialog", { + name: "Approve user", + }); + await userEvent.click( + within(dialog).getByRole("button", { name: "Approve" }), + ); + + await waitFor(() => + expect(approved).toHaveBeenCalledWith("user-maria"), + ); + expect( + await screen.findByText("Nothing needs your attention."), + ).toBeInTheDocument(); + expect(screen.queryByText("Maria")).not.toBeInTheDocument(); + }); + + it("rejects a member request by deleting the pending user and drops it from the list", async () => { + const users = serveUsers({ pending: [maria] }); + const deleted = vi.fn(); + server.use( + http.delete("*/admin/api/users/:id", ({ params }) => { + deleted(params.id); + users.pending = []; + return new HttpResponse(null, { status: 200 }); + }), + ); + renderPage(); + + await userEvent.click( + await screen.findByRole("button", { + name: "Reject account for maria@acme.io", + }), + ); + const dialog = await screen.findByRole("dialog", { + name: "Reject user", + }); + await userEvent.click( + within(dialog).getByRole("button", { name: "Reject" }), + ); + + await waitFor(() => expect(deleted).toHaveBeenCalledWith("user-maria")); + await waitFor(() => + expect(screen.queryByText("Maria")).not.toBeInTheDocument(), + ); + }); + + it("keeps the rest of the page when member requests fail to load", async () => { + server.use( + http.get("*/admin/api/users", ({ request }) => + requestedSubset(request) === "awaiting_approval" + ? HttpResponse.json({}, { status: 500 }) + : jsonWithTotal([], 1), + ), + ); + renderPage(); + + expect( + await screen.findByText("Couldn't load member requests."), + ).toBeInTheDocument(); + expect( + screen.queryByText("Nothing needs your attention."), + ).not.toBeInTheDocument(); + expect( + screen.getByRole("region", { name: "License" }), + ).toBeInTheDocument(); + }); + }); + + describe("needs attention", () => { + it("says nothing needs attention once requests and the license have answered", async () => { + renderPage(); + + expect( + await screen.findByText("Nothing needs your attention."), + ).toBeInTheDocument(); + }); + + it("reports a failed license check instead of saying nothing needs attention", async () => { + server.use( + http.get("*/admin/api/license", () => + HttpResponse.json({}, { status: 500 }), + ), + ); + renderPage(); + + const panel = screen.getByRole("region", { + name: "Needs your attention", + }); + expect( + await within(panel).findByText( + "Couldn't check the license and its device limit.", + ), + ).toBeInTheDocument(); + expect( + within(panel).queryByText("Nothing needs your attention."), + ).not.toBeInTheDocument(); + }); + + it("reports a failed device count in both panels and dashes the users count", async () => { + failStats(); + renderPage(); + + expect( + await screen.findByText("Couldn't load the accepted device count."), + ).toBeInTheDocument(); + expect( + screen.getByText("Couldn't check the license and its device limit."), + ).toBeInTheDocument(); + expect( + screen.queryByText("Nothing needs your attention."), + ).not.toBeInTheDocument(); + const access = screen.getByRole("region", { name: "Access" }); + expect( + within(access).getByRole("link", { name: /^users\s*—/i }), + ).toBeInTheDocument(); + }); + + it.each([ + { + case: "a license about to expire", + license: { about_to_expire: true }, + used: 1, + notice: "Your license is about to expire!", + }, + { + case: "a license in its grace period", + license: { expired: true, grace_period: true }, + used: 1, + notice: + "Your license has expired, but you are still within the grace period.", + }, + { + case: "a license that has not started", + license: { starts_at: Date.UTC(2100, 0, 15, 12) / 1000 }, + used: 1, + notice: + "This license starts on Jan 15, 2100. Devices can't be accepted until then.", + }, + { + case: "accepted devices at 90% of the limit", + license: withDeviceLimit(10), + used: 9, + notice: "9 of 10 licensed devices are in use.", + }, + { + case: "accepted devices at the limit", + license: withDeviceLimit(9), + used: 9, + notice: + "All 9 licensed devices are in use. New devices can't be accepted.", + }, + { + case: "accepted devices past the limit", + license: withDeviceLimit(6), + used: 9, + notice: + "9 devices are accepted against a limit of 6. Connections to devices are refused until the count is back within the license.", + }, + ])("warns about $case", async ({ license, used, notice }) => { + serveLicense(license); + serveStats(used); + renderPage(); + + expect(await screen.findByText(notice)).toBeInTheDocument(); + expect( + screen.queryByText("Nothing needs your attention."), + ).not.toBeInTheDocument(); + }); + }); + + describe("license", () => { + it("shows accepted devices against the licensed limit", async () => { + serveLicense(withDeviceLimit(9)); + serveStats(9); + renderPage(); + + const bar = await screen.findByRole("progressbar", { + name: "Licensed devices in use", + }); + expect(bar).toHaveAttribute("aria-valuenow", "9"); + expect(bar).toHaveAttribute("aria-valuemax", "9"); + }); + + it("keeps the bar's value in range when accepted devices exceed the limit", async () => { + serveLicense(withDeviceLimit(6)); + serveStats(9); + renderPage(); + + const bar = await screen.findByRole("progressbar", { + name: "Licensed devices in use", + }); + expect(bar).toHaveAttribute("aria-valuenow", "6"); + expect(bar).toHaveAttribute("aria-valuemax", "6"); + expect(bar).toHaveAttribute("aria-valuetext", "9 of 6 devices"); + }); + + it("reads an unlimited, non-expiring license as such", async () => { + serveLicense({ expires_at: 0 }); + renderPage(); + + expect( + await screen.findByText("accepted, unlimited"), + ).toBeInTheDocument(); + const license = screen.getByRole("region", { name: "License" }); + expect(within(license).getByText("Never")).toBeInTheDocument(); + expect(screen.queryByRole("progressbar")).not.toBeInTheDocument(); + }); + + it("names who the license was issued to", async () => { + serveLicense({ + customer: { + id: "cus-1", + name: "Ana", + email: "a@b.c", + company: "Acme", + }, + }); + renderPage(); + + const license = screen.getByRole("region", { name: "License" }); + expect(await within(license).findByText("Acme")).toBeInTheDocument(); + }); + + it("says the license is loading until it answers", () => { + server.use( + http.get("*/admin/api/license", () => new Promise(() => {})), + ); + renderPage(); + + const license = screen.getByRole("region", { name: "License" }); + expect(within(license).getByRole("status")).toHaveTextContent( + "Loading the license…", + ); + }); + + it("says in the License panel when the license failed to load", async () => { + server.use( + http.get("*/admin/api/license", () => + HttpResponse.json({}, { status: 500 }), + ), + ); + renderPage(); + + const license = screen.getByRole("region", { name: "License" }); + expect( + await within(license).findByText("Couldn't load the license."), + ).toBeInTheDocument(); + }); + }); + + describe("access", () => { + it("counts users, namespaces and instance admins, linking each to its list", async () => { + serveUsers({ admins: 2 }); + renderPage(); + + const access = screen.getByRole("region", { name: "Access" }); + await waitFor(() => + expect( + within(access).getByRole("link", { name: /^users\s*42/i }), + ).toHaveAttribute("href", "/admin/users"), + ); + expect( + within(access).getByRole("link", { name: /^namespaces\s*7/i }), + ).toHaveAttribute("href", "/admin/namespaces"); + expect( + within(access).getByRole("link", { name: /^instance admins\s*2/i }), + ).toHaveAttribute("href", "/admin/users?subset=admin"); + }); + + it("lists the enabled sign-in methods", async () => { + renderPage(); + + const access = screen.getByRole("region", { name: "Access" }); + expect( + await within(access).findByText("Local · SAML"), + ).toBeInTheDocument(); + }); + }); + + describe("server", () => { + it("shows the server version and endpoints", async () => { + renderPage(); + + const serverPanel = screen.getByRole("region", { name: "Server" }); + expect( + await within(serverPanel).findByText("v0.27.0"), + ).toBeInTheDocument(); + expect( + within(serverPanel).getByText("shellhub.example:22"), + ).toBeInTheDocument(); + }); + + it("shows server information as loading, not failed, until it answers", () => { + server.use(http.get("*/info", () => new Promise(() => {}))); + renderPage(); + + const serverPanel = screen.getByRole("region", { name: "Server" }); + expect(within(serverPanel).getAllByText("…")).toHaveLength(3); + expect(within(serverPanel).queryByText("—")).not.toBeInTheDocument(); + }); + }); + }); + + describe("cloud", () => { + beforeEach(() => { + setEdition("cloud"); + }); + + it("leads with the instance figures and shows no people or license", async () => { + serveUsers({ unconfirmed: 5 }); + renderPage(); + + const numbers = screen.getByRole("region", { name: "Instance numbers" }); + expect(await within(numbers).findByText("42")).toBeInTheDocument(); + expect(within(numbers).getByText("4")).toBeInTheDocument(); + expect( + await within(numbers).findByText("5 haven't confirmed their email"), + ).toBeInTheDocument(); + expect( + screen.queryByRole("region", { name: "Needs your attention" }), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole("region", { name: "License" }), + ).not.toBeInTheDocument(); + }); + + it("dashes the figures it could not load", async () => { + failStats(); + renderPage(); + + const numbers = screen.getByRole("region", { name: "Instance numbers" }); + expect( + await within(numbers).findByText( + "Couldn't load some of these numbers.", + ), + ).toBeInTheDocument(); + expect(within(numbers).getAllByText("—")).toHaveLength(4); + }); + }); +}); diff --git a/ui/apps/console/src/pages/admin/instance/AccessPanel.tsx b/ui/apps/console/src/pages/admin/instance/AccessPanel.tsx new file mode 100644 index 00000000000..36dc139b16a --- /dev/null +++ b/ui/apps/console/src/pages/admin/instance/AccessPanel.tsx @@ -0,0 +1,36 @@ +import type { ReactNode } from "react"; +import { useAdminUsers } from "@/hooks/useAdminUsers"; +import { useServerInfo } from "@/hooks/useServerInfo"; +import { Count, Loaded, Panel, PanelError, Row } from "./Panel"; + +/** + * Who holds instance admin rights and how people sign in. children are extra rows placed above + * those two, for an edition that shows more here. + */ +export default function AccessPanel({ children }: { children?: ReactNode }) { + const admins = useAdminUsers({ perPage: 1, subset: "admin" }); + const info = useServerInfo(); + + return ( + + {admins.isError && ( + Couldn't load the instance admins. + )} + {children} + + + + + + {info.signInMethods.length > 0 + ? info.signInMethods.join(" · ") + : "None"} + + + + ); +} diff --git a/ui/apps/console/src/pages/admin/instance/InstanceNumbers.tsx b/ui/apps/console/src/pages/admin/instance/InstanceNumbers.tsx new file mode 100644 index 00000000000..24e3cdf64bb --- /dev/null +++ b/ui/apps/console/src/pages/admin/instance/InstanceNumbers.tsx @@ -0,0 +1,137 @@ +import type { ReactNode } from "react"; +import { Link } from "react-router-dom"; +import { Card } from "@shellhub/design-system/primitives"; +import { useAdminStats } from "@/hooks/useAdminStats"; +import { useAdminNamespaces } from "@/hooks/useAdminNamespaces"; +import { useAdminUsers } from "@/hooks/useAdminUsers"; +import { LABEL_BASE } from "@/utils/styles"; +import { formatCount } from "@/utils/count"; +import { Count } from "./Panel"; + +function Metric({ + label, + value, + isLoading, + isError, + detail, + to, +}: { + label: string; + value?: number; + isLoading: boolean; + isError: boolean; + detail?: ReactNode; + to?: string; +}) { + const body = ( + <> +

{label}

+

+ +

+

{detail}

+ + ); + + return to ? ( + + {body} + + ) : ( + {body} + ); +} + +function percent(part?: number, whole?: number) { + if (part === undefined || !whole) return undefined; + return Math.round((part / whole) * 100); +} + +/** + * The instance's size and live load in figures, for an instance too large to read person by + * person. A figure that could not be loaded shows a dash and the strip says so. + */ +export default function InstanceNumbers() { + const { + stats, + isLoading: statsLoading, + isError: statsError, + } = useAdminStats(); + const namespaces = useAdminNamespaces({ perPage: 1 }); + const unconfirmed = useAdminUsers({ perPage: 1, subset: "not_confirmed" }); + + const namespacesKnown = !namespaces.isLoading && !namespaces.isError; + const onlineShare = percent(stats?.online_devices, stats?.registered_devices); + const devicesPerNamespace = + stats?.registered_devices !== undefined && + namespacesKnown && + namespaces.totalCount > 0 + ? (stats.registered_devices / namespaces.totalCount).toFixed(1) + : undefined; + const unconfirmedKnown = !unconfirmed.isLoading && !unconfirmed.isError; + + return ( +
+ {(statsError || namespaces.isError || unconfirmed.isError) && ( +

+ Couldn't load some of these numbers. +

+ )} +
+ + + + + +
+
+ ); +} diff --git a/ui/apps/console/src/pages/admin/instance/LicensePanel.tsx b/ui/apps/console/src/pages/admin/instance/LicensePanel.tsx new file mode 100644 index 00000000000..a9885842f42 --- /dev/null +++ b/ui/apps/console/src/pages/admin/instance/LicensePanel.tsx @@ -0,0 +1,107 @@ +import { Link } from "react-router-dom"; +import { ArrowRightIcon } from "@heroicons/react/24/outline"; +import { cn } from "@shellhub/design-system/cn"; +import { useDeviceCapacity } from "@/hooks/useDeviceCapacity"; +import { formatCount } from "@/utils/count"; +import { formatLicenseTimestamp, type DeviceCapacity } from "@/utils/license"; +import { LABEL_BASE } from "@/utils/styles"; +import { Panel, PanelError, Row } from "./Panel"; + +const BAR_COLOR: Record = { + unlimited: "bg-primary", + ok: "bg-primary", + approaching: "bg-accent-yellow", + over: "bg-accent-red", +}; + +function DeviceUsage({ capacity }: { capacity: DeviceCapacity }) { + return ( +
+

Devices

+

+ + {formatCount(capacity.used)} + + + {capacity.state === "unlimited" + ? "accepted, unlimited" + : `of ${formatCount(capacity.limit)} accepted`} + +

+ {capacity.state !== "unlimited" && capacity.limit > 0 && ( +
+
+
+ )} +
+ ); +} + +/** + * The installed licence: accepted devices against its limit, when it expires and who it was + * issued to. A licence with no expiry reads as Never. + */ +export default function LicensePanel() { + const { + license: installedLicense, + capacity, + isLoading, + failed, + } = useDeviceCapacity(); + const customer = installedLicense?.customer; + const issuedTo = customer?.company || customer?.name; + + return ( + + Details + + + } + > + {failed === "license" && ( + Couldn't load the license. + )} + {failed === "stats" && ( + Couldn't load the accepted device count. + )} + {isLoading && !installedLicense && ( +

+ Loading the license… +

+ )} + {capacity && } + {installedLicense && ( + <> + + {installedLicense.expires_at > 0 + ? formatLicenseTimestamp(installedLicense.expires_at) + : "Never"} + + {issuedTo && {issuedTo}} + + )} +
+ ); +} diff --git a/ui/apps/console/src/pages/admin/instance/NeedsAttention.tsx b/ui/apps/console/src/pages/admin/instance/NeedsAttention.tsx new file mode 100644 index 00000000000..27b8def704d --- /dev/null +++ b/ui/apps/console/src/pages/admin/instance/NeedsAttention.tsx @@ -0,0 +1,292 @@ +import { useId, useState, type ReactNode } from "react"; +import { Link } from "react-router-dom"; +import { + ArrowRightIcon, + CheckCircleIcon, + ExclamationTriangleIcon, + UserPlusIcon, +} from "@heroicons/react/24/outline"; +import { Badge, Button } from "@shellhub/design-system/primitives"; +import { cn } from "@shellhub/design-system/cn"; +import type { UserAdminResponse } from "@/client"; +import { useAdminUsers } from "@/hooks/useAdminUsers"; +import { useAwaitingMemberNamespaces } from "@/hooks/useAdminNamespaces"; +import { useDeviceCapacity } from "@/hooks/useDeviceCapacity"; +import { formatCount } from "@/utils/count"; +import { formatRelative } from "@/utils/date"; +import { + formatLicenseTimestamp, + getLicenseAlertConfig, + type DeviceCapacity, +} from "@/utils/license"; +import ApproveAccountDialog from "../users/ApproveAccountDialog"; +import RejectAccountDialog from "../users/RejectAccountDialog"; +import { Panel, PanelError } from "./Panel"; + +const PENDING_PREVIEW = 5; +const NAMESPACE_SCAN = 10; + +const NOTICE_COLOR = { + warning: "text-accent-yellow", + error: "text-accent-red", + info: "text-accent-blue", +}; + +function Notice({ + tone, + children, +}: { + tone: keyof typeof NOTICE_COLOR; + children: ReactNode; +}) { + return ( +
  • + +

    {children}

    + +
  • + ); +} + +function startNotice(license: { starts_at: number }) { + if (license.starts_at * 1000 <= Date.now()) return null; + return { + tone: "warning" as const, + message: `This license starts on ${formatLicenseTimestamp(license.starts_at)}. Devices can't be accepted until then.`, + }; +} + +function capacityNotice(capacity: DeviceCapacity | null) { + if (capacity?.state === "over" && capacity.used > capacity.limit) { + return { + tone: "error" as const, + message: `${formatCount(capacity.used)} devices are accepted against a limit of ${formatCount(capacity.limit)}. Connections to devices are refused until the count is back within the license.`, + }; + } + if (capacity?.state === "over") { + return { + tone: "error" as const, + message: `All ${formatCount(capacity.limit)} licensed devices are in use. New devices can't be accepted.`, + }; + } + if (capacity?.state === "approaching") { + return { + tone: "warning" as const, + message: `${formatCount(capacity.used)} of ${formatCount(capacity.limit)} licensed devices are in use.`, + }; + } + return null; +} + +function MemberRequest({ + user, + namespaces, + onApprove, + onReject, +}: { + user: UserAdminResponse; + namespaces: string[]; + onApprove: () => void; + onReject: () => void; +}) { + const details = [ + user.email, + namespaces.length > 0 && `joining ${namespaces.join(", ")}`, + formatRelative(user.created_at), + ].filter(Boolean); + + return ( +
  • + +
    + + {user.name || user.username} + +

    + {details.join(" · ")} +

    +
    +
    + + +
    +
  • + ); +} + +function MemberRequests({ + users, + total, + onApprove, + onReject, +}: { + users: UserAdminResponse[]; + total: number; + onApprove: (user: UserAdminResponse) => void; + onReject: (user: UserAdminResponse) => void; +}) { + const joiningByUser = useAwaitingMemberNamespaces(NAMESPACE_SCAN); + const headingId = useId(); + + return ( +
    +
    + +

    + New members awaiting approval +

    + + {formatCount(total)} + +
    +
      + {users.map((user) => ( + onApprove(user)} + onReject={() => onReject(user)} + /> + ))} +
    +
    + ); +} + +/** + * What waits on an Enterprise instance admin: members a namespace owner added who need approval, + * and a licence that is expiring, not started yet or out of devices. It says nothing needs + * attention only once every source has answered, and says which source failed otherwise. + */ +export default function NeedsAttention() { + const pending = useAdminUsers({ + perPage: PENDING_PREVIEW, + subset: "awaiting_approval", + }); + const devices = useDeviceCapacity(); + const [approveTarget, setApproveTarget] = useState( + null, + ); + const [rejectTarget, setRejectTarget] = useState( + null, + ); + + const licenseAlert = devices.license + ? getLicenseAlertConfig(devices.license) + : null; + const notices = [ + licenseAlert && { + tone: licenseAlert.variant, + message: licenseAlert.message, + }, + devices.license && startNotice(devices.license), + capacityNotice(devices.capacity), + ].filter((notice) => !!notice); + const count = pending.totalCount + notices.length; + const settled = + !pending.isLoading && + !pending.isError && + !devices.isLoading && + !devices.isError; + + return ( + 0 && ( + + {formatCount(count)} + + ) + } + > + {pending.isError && ( + Couldn't load member requests. + )} + {devices.isError && ( + + Couldn't check the license and its device limit. + + )} + + {settled && count === 0 && ( +

    + + Nothing needs your attention. +

    + )} + + {notices.length > 0 && ( +
      + {notices.map((notice) => ( + + {notice.message} + + ))} +
    + )} + + {pending.users.length > 0 && ( + + )} + + {pending.totalCount > 0 && ( + + {pending.totalCount > pending.users.length + ? `View all ${formatCount(pending.totalCount)} member requests` + : "Open in the users list"} + + + )} + + setApproveTarget(null)} + user={approveTarget} + /> + setRejectTarget(null)} + user={rejectTarget} + /> +
    + ); +} diff --git a/ui/apps/console/src/pages/admin/instance/Panel.tsx b/ui/apps/console/src/pages/admin/instance/Panel.tsx new file mode 100644 index 00000000000..7ba2e842211 --- /dev/null +++ b/ui/apps/console/src/pages/admin/instance/Panel.tsx @@ -0,0 +1,125 @@ +import { useId, type ReactNode } from "react"; +import { Link } from "react-router-dom"; +import { ArrowRightIcon } from "@heroicons/react/24/outline"; +import { Card } from "@shellhub/design-system/primitives"; +import { cn } from "@shellhub/design-system/cn"; +import { LABEL_BASE } from "@/utils/styles"; +import { formatCount } from "@/utils/count"; + +/** + * A titled dashboard card. The header has a fixed height so titles line up across columns whether + * or not an action sits beside them. + */ +export function Panel({ + title, + action, + children, +}: { + title: string; + action?: ReactNode; + children: ReactNode; +}) { + const titleId = useId(); + + return ( + +
    +

    + {title} +

    + {action} +
    + {children} +
    + ); +} + +/** + * A panel's own failure, announced as an alert, so one failing request leaves the rest of the + * page usable. + */ +export function PanelError({ children }: { children: ReactNode }) { + return ( +

    + {children} +

    + ); +} + +/** + * A labelled value inside a panel. With to, the whole row is a link to the page behind the value. + */ +export function Row({ + label, + to, + children, +}: { + label: string; + to?: string; + children: ReactNode; +}) { + const body = ( + <> + {label} + + {children} + {to && ( + + )} + + + ); + const className = + "flex items-center justify-between gap-4 px-5 py-3 border-t border-border"; + + return to ? ( + + {body} + + ) : ( +
    {body}
    + ); +} + +/** + * What a panel shows for a value that comes from a request: a dash once the request failed, even + * if it still carries a value, an ellipsis until it answers, then children. + */ +export function Loaded({ + isLoading, + isError, + children, +}: { + isLoading: boolean; + isError: boolean; + children: ReactNode; +}) { + if (isError) return <>—; + if (isLoading) return <>…; + return <>{children}; +} + +/** + * A figure from a request, grouped for the reader's locale, under the same placeholders as Loaded. + * A value still undefined after the request answered reads as loading. + */ +export function Count({ + value, + isLoading, + isError, +}: { + value?: number; + isLoading: boolean; + isError: boolean; +}) { + return ( + + + {value !== undefined && formatCount(value)} + + + ); +} diff --git a/ui/apps/console/src/pages/admin/instance/ServerPanel.tsx b/ui/apps/console/src/pages/admin/instance/ServerPanel.tsx new file mode 100644 index 00000000000..ae3086ca3e7 --- /dev/null +++ b/ui/apps/console/src/pages/admin/instance/ServerPanel.tsx @@ -0,0 +1,47 @@ +import CopyButton from "@/components/common/CopyButton"; +import { useServerInfo } from "@/hooks/useServerInfo"; +import { Loaded, Panel, PanelError, Row } from "./Panel"; + +function Reported({ + label, + value, + isLoading, + isError, +}: { + label: string; + value?: string; + isLoading: boolean; + isError: boolean; +}) { + return ( + + + + {value || "Not reported"} + + + {value && } + + ); +} + +/** + * The version the server runs and the endpoints it reports for SSH and the API. A value the + * server does not report reads as Not reported, never as a blank or a placeholder. + */ +export default function ServerPanel() { + const { version, sshEndpoint, apiEndpoint, isLoading, isError } = + useServerInfo(); + const status = { isLoading, isError }; + + return ( + + {isError && ( + Couldn't load server information. + )} + + + + + ); +} diff --git a/ui/apps/console/src/pages/admin/users/ApproveAccountDialog.tsx b/ui/apps/console/src/pages/admin/users/ApproveAccountDialog.tsx new file mode 100644 index 00000000000..8b5203bf169 --- /dev/null +++ b/ui/apps/console/src/pages/admin/users/ApproveAccountDialog.tsx @@ -0,0 +1,55 @@ +import { useState } from "react"; +import { CheckBadgeIcon } from "@heroicons/react/24/outline"; +import { useApproveAccountRequest } from "@/hooks/useAdminAccountRequestMutations"; +import ConfirmDialog from "@/components/common/ConfirmDialog"; +import ObjectName from "@/components/common/ObjectName"; + +interface ApproveAccountDialogProps { + open: boolean; + onClose: () => void; + user: { id: string; email: string } | null; +} + +/** + * Confirms approving an account request. The account is already confirmed when it awaits + * approval, so approving it is all that stands between the person and signing in. + */ +export default function ApproveAccountDialog({ + open, + onClose, + user, +}: ApproveAccountDialogProps) { + const approve = useApproveAccountRequest(); + const [error, setError] = useState(""); + + return ( + { + setError(""); + onClose(); + }} + onConfirm={async () => { + if (!user) return; + setError(""); + try { + await approve.mutateAsync({ path: { id: user.id } }); + onClose(); + } catch { + setError("Failed to approve the account. Please try again."); + } + }} + icon={} + title="Approve user" + description={ + <> + {user?.email} can sign in as soon as you + approve. + + } + errorMessage={error || null} + confirmLabel="Approve" + variant="primary" + /> + ); +} diff --git a/ui/apps/console/src/pages/admin/users/RejectAccountDialog.tsx b/ui/apps/console/src/pages/admin/users/RejectAccountDialog.tsx new file mode 100644 index 00000000000..e035605011c --- /dev/null +++ b/ui/apps/console/src/pages/admin/users/RejectAccountDialog.tsx @@ -0,0 +1,53 @@ +import { useState } from "react"; +import { NoSymbolIcon } from "@heroicons/react/24/outline"; +import { useDeleteUser } from "@/hooks/useAdminUserMutations"; +import ConfirmDialog from "@/components/common/ConfirmDialog"; +import ObjectName from "@/components/common/ObjectName"; + +interface RejectAccountDialogProps { + open: boolean; + onClose: () => void; + user: { id: string; email: string } | null; +} + +/** + * Confirms rejecting an account request, which deletes the pending account. + */ +export default function RejectAccountDialog({ + open, + onClose, + user, +}: RejectAccountDialogProps) { + const deleteUser = useDeleteUser(); + const [error, setError] = useState(""); + + return ( + { + setError(""); + onClose(); + }} + onConfirm={async () => { + if (!user) return; + setError(""); + try { + await deleteUser.mutateAsync({ path: { id: user.id } }); + onClose(); + } catch { + setError("Failed to reject the account. Please try again."); + } + }} + icon={} + title="Reject user" + description={ + <> + {user?.email} is removed and doesn't + join the namespace that added them. + + } + errorMessage={error || null} + confirmLabel="Reject" + /> + ); +} diff --git a/ui/apps/console/src/pages/admin/users/__tests__/AdminUsers.test.tsx b/ui/apps/console/src/pages/admin/users/__tests__/AdminUsers.test.tsx index df14e26e23c..1e5b2b81be8 100644 --- a/ui/apps/console/src/pages/admin/users/__tests__/AdminUsers.test.tsx +++ b/ui/apps/console/src/pages/admin/users/__tests__/AdminUsers.test.tsx @@ -14,6 +14,9 @@ import AdminUsers from "../index"; import type { UserAdminResponse } from "@/client"; import { createTestWrapper } from "@/tests/wrapper"; import { useAuthStore } from "@/stores/authStore"; +import { mockAdminUser } from "@/tests/factories"; +import { decodeB64url } from "@/tests/decodeB64url"; +import { setEdition } from "@/tests/edition"; vi.mock("../CreateUserModal", () => ({ default: ({ open }: { open: boolean }) => @@ -37,22 +40,6 @@ vi.mock("react-router-dom", async (importOriginal) => { return { ...actual, useNavigate: () => mockNavigate }; }); -function mockAdminUser( - overrides: Partial = {}, -): UserAdminResponse { - return { - id: "user-id-1", - name: "Alice Smith", - email: "alice@example.com", - username: "alice", - status: "confirmed", - admin: false, - created_at: "2024-01-01T00:00:00Z", - last_login: "2024-06-01T00:00:00Z", - ...overrides, - }; -} - let lastRequestUrl: URL | null; function setUsers(users: UserAdminResponse[], total?: number) { @@ -76,6 +63,7 @@ function renderPage(initialEntries: string[] = ["/"]) { describe("AdminUsers", () => { beforeEach(() => { vi.clearAllMocks(); + setEdition("community"); lastRequestUrl = null; useAuthStore.setState({ isAdmin: true }); setUsers([]); @@ -119,6 +107,25 @@ describe("AdminUsers", () => { await user.click(await screen.findByText("Clickable User")); expect(mockNavigate).toHaveBeenCalledWith("/admin/users/uid-abc"); }); + + it("opens the reject dialog, not the delete one, for an account awaiting approval", async () => { + setEdition("enterprise"); + setUsers([ + mockAdminUser({ email: "maria@acme.io", awaiting_approval: true }), + ]); + renderPage(["/?subset=awaiting_approval"]); + + await userEvent.click( + await screen.findByRole("button", { + name: "Reject account for maria@acme.io", + }), + ); + + expect( + await screen.findByRole("dialog", { name: "Reject user" }), + ).toBeInTheDocument(); + expect(screen.queryByTestId("delete-dialog")).not.toBeInTheDocument(); + }); }); describe("error state", () => { @@ -136,6 +143,179 @@ describe("AdminUsers", () => { }); }); + describe("subset tabs", () => { + function sentFilter() { + const raw = lastRequestUrl?.searchParams.get("filter"); + return raw ? decodeB64url(raw) : null; + } + + it.each([ + [ + "admin", + [ + { + type: "property", + params: { name: "admin", operator: "bool", value: true }, + }, + ], + ], + [ + "not_confirmed", + [ + { + type: "property", + params: { name: "status", operator: "eq", value: "not-confirmed" }, + }, + ], + ], + ])("sends the %s filter from the URL", async (subset, expected) => { + renderPage([`/?subset=${subset}`]); + await waitFor(() => expect(sentFilter()).toEqual(expected)); + }); + + it("searches name, username and email when no subset is picked", async () => { + renderPage(["/?search=al"]); + await waitFor(() => + expect(sentFilter()).toEqual( + ["name", "username", "email"].map((name) => ({ + type: "property", + params: { name, operator: "contains", value: "al" }, + })), + ), + ); + expect( + screen.getByRole("searchbox", { + name: "Search users by name, username or email", + }), + ).toBeInTheDocument(); + }); + + it("combines a subset with the username search under an and operator", async () => { + renderPage(["/?subset=admin&search=al"]); + await waitFor(() => + expect(sentFilter()).toEqual([ + { type: "operator", params: { name: "and" } }, + { + type: "property", + params: { name: "username", operator: "contains", value: "al" }, + }, + { + type: "property", + params: { name: "admin", operator: "bool", value: true }, + }, + ]), + ); + }); + + it("sends the awaiting approval filter in enterprise", async () => { + setEdition("enterprise"); + renderPage(["/?subset=awaiting_approval"]); + await waitFor(() => + expect(sentFilter()).toEqual([ + { + type: "property", + params: { + name: "awaiting_approval", + operator: "bool", + value: true, + }, + }, + ]), + ); + }); + + it("narrows the search box to username under a tab", async () => { + renderPage(["/?subset=admin"]); + expect( + await screen.findByRole("searchbox", { + name: "Search users by username", + }), + ).toBeInTheDocument(); + }); + + it("switches the subset when a tab is clicked", async () => { + const user = userEvent.setup(); + renderPage(); + + await user.click(await screen.findByRole("tab", { name: "Admins" })); + await waitFor(() => + expect(sentFilter()).toEqual([ + { + type: "property", + params: { name: "admin", operator: "bool", value: true }, + }, + ]), + ); + expect(screen.getByRole("tab", { name: "Admins" })).toHaveAttribute( + "aria-selected", + "true", + ); + }); + + it("keeps only the selected tab in the tab order", async () => { + renderPage(["/?subset=admin"]); + + expect( + await screen.findByRole("tab", { name: "Admins" }), + ).toHaveAttribute("tabindex", "0"); + expect(screen.getByRole("tab", { name: "All" })).toHaveAttribute( + "tabindex", + "-1", + ); + }); + + it.each([ + { + case: "ArrowRight moves to the next tab", + from: "/?subset=admin", + start: "Admins", + key: "{ArrowRight}", + expected: "Unconfirmed", + }, + { + case: "ArrowLeft from the first tab wraps to the last", + from: "/", + start: "All", + key: "{ArrowLeft}", + expected: "Unconfirmed", + }, + { + case: "End selects the last tab", + from: "/", + start: "All", + key: "{End}", + expected: "Unconfirmed", + }, + { + case: "Home selects the first tab", + from: "/?subset=admin", + start: "Admins", + key: "{Home}", + expected: "All", + }, + ])("$case", async ({ from, start, key, expected }) => { + const user = userEvent.setup(); + renderPage([from]); + + (await screen.findByRole("tab", { name: start })).focus(); + await user.keyboard(key); + + const target = screen.getByRole("tab", { name: expected }); + await waitFor(() => expect(target).toHaveFocus()); + expect(target).toHaveAttribute("aria-selected", "true"); + }); + + it("ignores awaiting approval outside enterprise", async () => { + setEdition("cloud"); + renderPage(["/?subset=awaiting_approval"]); + await screen.findByText("No users found"); + expect(sentFilter()).toBeNull(); + expect( + screen.queryByRole("tab", { name: "Awaiting approval" }), + ).not.toBeInTheDocument(); + }); + }); + describe("debounce — search is debounced before reaching the query hook", () => { beforeEach(() => { vi.useFakeTimers({ shouldAdvanceTime: true }); @@ -149,7 +329,7 @@ describe("AdminUsers", () => { renderPage(["/"]); const searchbox = screen.getByRole("searchbox", { - name: "Search users by username", + name: "Search users by name, username or email", }); act(() => { diff --git a/ui/apps/console/src/pages/admin/users/index.tsx b/ui/apps/console/src/pages/admin/users/index.tsx index 919908383e4..2fb191c348a 100644 --- a/ui/apps/console/src/pages/admin/users/index.tsx +++ b/ui/apps/console/src/pages/admin/users/index.tsx @@ -7,23 +7,29 @@ import { TrashIcon, CheckIcon, ArrowRightStartOnRectangleIcon, - CheckBadgeIcon, } from "@heroicons/react/24/outline"; -import { useAdminUsers } from "@/hooks/useAdminUsers"; -import { useApproveAccountRequest } from "@/hooks/useAdminAccountRequestMutations"; +import FilterTabs, { type FilterTab } from "@/components/common/FilterTabs"; +import { isEnterprise } from "@/env"; +import { + useAdminUsers, + usersSearchScope, + type AdminUserSubset, +} from "@/hooks/useAdminUsers"; import { useLoginAsUser } from "@/hooks/useLoginAsUser"; import { useDebouncedValue } from "@/hooks/useDebouncedValue"; import { usePaginatedListState } from "@/hooks/usePaginatedListState"; +import type { ListParamConstraints } from "@/hooks/paginatedListParams"; import type { UserAdminResponse } from "@/client"; import PageHeader from "@/components/common/PageHeader"; import { adminNavSectionTitle } from "@/components/layout/adminNav"; import DataTable, { type Column } from "@/components/common/DataTable"; -import ConfirmDialog from "@/components/common/ConfirmDialog"; import SearchField from "@/components/common/fields/SearchField"; import UserStatusChip from "./UserStatusChip"; import CreateUserModal from "./CreateUserModal"; import EditUserModal from "./EditUserModal"; import DeleteUserDialog from "./DeleteUserDialog"; +import ApproveAccountDialog from "./ApproveAccountDialog"; +import RejectAccountDialog from "./RejectAccountDialog"; import { Badge, Button, @@ -32,18 +38,30 @@ import { } from "@shellhub/design-system/primitives"; import { apiErrorMessage } from "@/api/errors"; import { PER_PAGE, pageCount } from "@/utils/pagination"; -import ObjectName from "@/components/common/ObjectName"; const SEARCH_DEBOUNCE_MS = 300; +const subsetTabs: FilterTab[] = [ + { label: "All", value: "" }, + { label: "Awaiting approval", value: "awaiting_approval" }, + { label: "Admins", value: "admin" }, + { label: "Unconfirmed", value: "not_confirmed" }, +]; + +const CONSTRAINTS: ListParamConstraints = { + subset: subsetTabs.map((tab) => tab.value), +}; + type AdminUsersParams = { page: number; search: string; + subset: AdminUserSubset; }; const DEFAULTS: AdminUsersParams = { page: 1, search: "", + subset: "", }; /** @@ -51,8 +69,11 @@ const DEFAULTS: AdminUsersParams = { */ export default function AdminUsers() { const navigate = useNavigate(); - const { params, setPage, setSearch } = - usePaginatedListState({ defaults: DEFAULTS }); + const { params, setPage, setSearch, setFilter } = + usePaginatedListState({ + defaults: DEFAULTS, + constraints: CONSTRAINTS, + }); const debouncedSearch = useDebouncedValue(params.search, SEARCH_DEBOUNCE_MS); const [createOpen, setCreateOpen] = useState(false); const [editTarget, setEditTarget] = useState(null); @@ -62,18 +83,28 @@ export default function AdminUsers() { const [approveTarget, setApproveTarget] = useState( null, ); - const [approveError, setApproveError] = useState(""); + const [rejectTarget, setRejectTarget] = useState( + null, + ); const { loginAs, loadingId: loginAsId, errorId: loginAsError, } = useLoginAsUser(); - const approve = useApproveAccountRequest(); + + const tabs = subsetTabs.filter( + (tab) => tab.value !== "awaiting_approval" || isEnterprise(), + ); + const subset = tabs.some((tab) => tab.value === params.subset) + ? params.subset + : ""; + const searchScope = usersSearchScope(subset); const { users, totalCount, isLoading, error } = useAdminUsers({ page: params.page, perPage: PER_PAGE, search: debouncedSearch, + subset, }); const totalPages = pageCount(totalCount); @@ -174,10 +205,15 @@ export default function AdminUsers() { { e.stopPropagation(); - setDeleteTarget(user); + if (user.awaiting_approval) setRejectTarget(user); + else setDeleteTarget(user); }} > @@ -203,13 +239,21 @@ export default function AdminUsers() { - +
    + setFilter("subset", next)} + label="Filter users" + /> + + +
    {error && ( @@ -261,33 +305,16 @@ export default function AdminUsers() { user={deleteTarget} /> - { - setApproveError(""); - setApproveTarget(null); - }} - onConfirm={async () => { - if (!approveTarget) return; - setApproveError(""); - try { - await approve.mutateAsync({ path: { id: approveTarget.id } }); - setApproveTarget(null); - } catch { - setApproveError("Failed to approve the account. Please try again."); - } - }} - icon={} - title="Approve account" - description={ - <> - {approveTarget?.email} can sign in once they - finish activating the account. - - } - errorMessage={approveError || null} - confirmLabel="Approve account" - variant="primary" + onClose={() => setApproveTarget(null)} + user={approveTarget} + /> + + setRejectTarget(null)} + user={rejectTarget} />
    ); diff --git a/ui/apps/console/src/stores/workspaceTabsStore.ts b/ui/apps/console/src/stores/workspaceTabsStore.ts index c3e3a988692..3c3d8bc03e2 100644 --- a/ui/apps/console/src/stores/workspaceTabsStore.ts +++ b/ui/apps/console/src/stores/workspaceTabsStore.ts @@ -59,7 +59,7 @@ export function preferencesTab(path = PREFERENCES_PATH): WorkspaceTab { * The admin console's tab, landing on path when activated. ensure keeps an open tab's own path, * so path only decides where a tab not yet open lands. */ -export function adminTab(path = "/admin/dashboard"): WorkspaceTab { +export function adminTab(path = "/admin/instance"): WorkspaceTab { return { id: ADMIN_TAB_ID, kind: "admin", name: "Admin Console", path }; } diff --git a/ui/apps/console/src/tests/edition.ts b/ui/apps/console/src/tests/edition.ts new file mode 100644 index 00000000000..0e353d2cbeb --- /dev/null +++ b/ui/apps/console/src/tests/edition.ts @@ -0,0 +1,10 @@ +import { vi } from "vitest"; +import { getConfig, defaultConfig, type Edition } from "@/env"; + +/** + * Runs the code under test as the given edition, every other setting at its test default. The + * edition sticks until the next call, so a file that switches it resets it in beforeEach. + */ +export function setEdition(edition: Edition) { + vi.mocked(getConfig).mockReturnValue({ ...defaultConfig, edition }); +} diff --git a/ui/apps/console/src/tests/factories.ts b/ui/apps/console/src/tests/factories.ts index 05f8f769b4c..6d89f8ffe45 100644 --- a/ui/apps/console/src/tests/factories.ts +++ b/ui/apps/console/src/tests/factories.ts @@ -10,11 +10,32 @@ import type { PublicKeyResponse, Session, Tag, + UserAdminResponse, UserAuth, Webendpoint, } from "@/client"; import type { RecordingMeta } from "@/utils/recordings"; +/** + * Builds a user as the admin API returns it for a test: confirmed, not an admin and not awaiting + * approval, so a case names only what it is about. + */ +export function mockAdminUser( + overrides: Partial = {}, +): UserAdminResponse { + return { + id: "user-id-1", + name: "Alice Smith", + email: "alice@example.com", + username: "alice", + status: "confirmed", + admin: false, + created_at: "2024-01-01T00:00:00Z", + last_login: "2024-06-01T00:00:00Z", + ...overrides, + }; +} + /** * Builds a signed-in user for a test. Every field has a value, so a case names only what it is about * and the rest stays out of the way. diff --git a/ui/apps/console/src/utils/__tests__/license.test.ts b/ui/apps/console/src/utils/__tests__/license.test.ts index 8b10f1fab68..e6e24e7af9d 100644 --- a/ui/apps/console/src/utils/__tests__/license.test.ts +++ b/ui/apps/console/src/utils/__tests__/license.test.ts @@ -6,8 +6,29 @@ import { getDisplayFeatures, validateLicenseFile, getLicenseAlertConfig, + deviceCapacity, } from "../license"; +describe("deviceCapacity", () => { + it.each([ + { limit: -1, used: 50, expected: { state: "unlimited", used: 50 } }, + { limit: 10, used: 8, expected: { state: "ok", used: 8, limit: 10 } }, + { + limit: 10, + used: 9, + expected: { state: "approaching", used: 9, limit: 10 }, + }, + { limit: 10, used: 10, expected: { state: "over", used: 10, limit: 10 } }, + { limit: 10, used: 12, expected: { state: "over", used: 12, limit: 10 } }, + { limit: 0, used: 0, expected: { state: "over", used: 0, limit: 0 } }, + ])( + "classifies $used of $limit as $expected.state", + ({ limit, used, expected }) => { + expect(deviceCapacity(limit, used)).toEqual(expected); + }, + ); +}); + describe("formatLicenseTimestamp", () => { it('returns "Now" for -1', () => { expect(formatLicenseTimestamp(-1)).toBe("Now"); diff --git a/ui/apps/console/src/utils/count.ts b/ui/apps/console/src/utils/count.ts new file mode 100644 index 00000000000..5b7b2a86934 --- /dev/null +++ b/ui/apps/console/src/utils/count.ts @@ -0,0 +1,8 @@ +const numberFormat = new Intl.NumberFormat(); + +/** + * A count as a person reads it, grouped for their locale (12,345 or 12.345). + */ +export function formatCount(value: number): string { + return numberFormat.format(value); +} diff --git a/ui/apps/console/src/utils/license.ts b/ui/apps/console/src/utils/license.ts index c15adda9c77..9772a9e5ad2 100644 --- a/ui/apps/console/src/utils/license.ts +++ b/ui/apps/console/src/utils/license.ts @@ -86,3 +86,23 @@ export function getLicenseAlertConfig(license: LicenseFlags | null): AlertConfig } return null; } + +/** + * How many devices are accepted against the licensed limit. A negative limit is the licence's + * sentinel for no limit. Approaching starts at 90% of the limit, and over includes reaching it + * exactly, because the next accept is the one refused. + */ +export type DeviceCapacity = + | { state: "unlimited"; used: number } + | { state: "ok" | "approaching" | "over"; used: number; limit: number }; + +/** + * Classifies accepted devices against the licensed limit. A limit of zero is over at once, since + * no device can be accepted under it. + */ +export function deviceCapacity(limit: number, used: number): DeviceCapacity { + if (limit < 0) return { state: "unlimited", used }; + if (used >= limit) return { state: "over", used, limit }; + if (used / limit >= 0.9) return { state: "approaching", used, limit }; + return { state: "ok", used, limit }; +}