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 }; +}