Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions ui/apps/console/e2e/members.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
10 changes: 5 additions & 5 deletions ui/apps/console/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
Expand Down Expand Up @@ -165,7 +165,7 @@ export default function App() {
path="/admin/license"
element={
isCloud() ? (
<Navigate to="/admin/dashboard" replace />
<Navigate to="/admin/instance" replace />
) : (
<AdminLicense />
)
Expand All @@ -174,11 +174,11 @@ export default function App() {
<Route element={<LicenseGuard />}>
<Route
path="/admin"
element={<Navigate to="/admin/dashboard" replace />}
element={<Navigate to="/admin/instance" replace />}
/>
<Route
path="/admin/dashboard"
element={<AdminDashboard />}
path="/admin/instance"
element={<AdminInstance />}
/>
<Route path="/admin/users" element={<AdminUsers />} />
<Route
Expand Down
41 changes: 5 additions & 36 deletions ui/apps/console/src/components/common/DeviceLimitBanner.tsx
Original file line number Diff line number Diff line change
@@ -1,48 +1,17 @@
import NoticeBanner from "@/components/common/NoticeBanner";
import { useAdminLicense } from "@/hooks/useAdminLicense";
import { useAdminStats } from "@/hooks/useAdminStats";
import { useDeviceCapacity } from "@/hooks/useDeviceCapacity";

/**
* Warns when the namespace is at or near its licensed device limit, before an enrolment starts
* failing rather than after.
*/
export default function DeviceLimitBanner() {
const {
data: license,
isLoading: licenseLoading,
isError: licenseError,
} = useAdminLicense();
const {
stats,
isLoading: statsLoading,
isError: statsError,
} = useAdminStats();
const { capacity, isLoading, isError } = useDeviceCapacity();

const cap = license?.features.devices;
const registered = stats?.registered_devices;
const over = capacity?.state === "over";
const approaching = capacity?.state === "approaching";

const over =
typeof cap === "number" &&
cap >= 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";

Expand Down
81 changes: 81 additions & 0 deletions ui/apps/console/src/components/common/FilterTabs.tsx
Original file line number Diff line number Diff line change
@@ -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<T extends string> {
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<T extends string>({
tabs,
value,
onChange,
label,
}: {
tabs: FilterTab<T>[];
value: T;
onChange: (value: T) => void;
label: string;
}) {
const selected = Math.max(
0,
tabs.findIndex((tab) => tab.value === value),
);

const moveTo = (e: KeyboardEvent<HTMLDivElement>) => {
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<HTMLElement>('[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
<div
className="flex items-center h-8 bg-card border border-border rounded-md p-0.5"
role="tablist"
aria-label={label}
onKeyDown={moveTo}
>
{tabs.map((tab, index) => (
<button
type="button"
key={tab.value}
role="tab"
aria-selected={value === tab.value}
tabIndex={index === selected ? 0 : -1}
onClick={() => onChange(tab.value)}
className={cn(
"h-full px-3.5 text-xs font-medium rounded transition-all duration-150",
value === tab.value
? "bg-primary/15 text-primary border border-primary/25"
: "text-text-muted hover:text-text-secondary border border-transparent",
)}
>
{tab.label}
</button>
))}
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@ beforeEach(() => {

function renderAdminRoute() {
return render(
<MemoryRouter initialEntries={["/admin/dashboard"]}>
<MemoryRouter initialEntries={["/admin/instance"]}>
<Routes>
<Route element={<AdminRoute />}>
<Route path="/admin/dashboard" element={<div>admin content</div>} />
<Route path="/admin/instance" element={<div>admin content</div>} />
</Route>
<Route
path="/admin/unauthorized"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,11 @@ function makeLicense(

function renderGuard(Wrapper?: ComponentType<{ children: ReactNode }>) {
const ui = (
<MemoryRouter initialEntries={["/admin/dashboard"]}>
<MemoryRouter initialEntries={["/admin/instance"]}>
<Routes>
<Route element={<LicenseGuard />}>
<Route
path="/admin/dashboard"
path="/admin/instance"
element={<div>protected content</div>}
/>
</Route>
Expand Down
2 changes: 1 addition & 1 deletion ui/apps/console/src/components/layout/AdminSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export default function AdminSidebar({ expanded }: { expanded: boolean }) {
expanded={expanded}
covered
onNavigate={minimizeAll}
logoHref="/admin/dashboard"
logoHref="/admin/instance"
/>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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" }),
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
3 changes: 2 additions & 1 deletion ui/apps/console/src/components/layout/adminNav.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
HomeIcon,
UsersIcon,
ServerStackIcon,
ServerIcon,
KeyIcon,
LockClosedIcon,
DocumentCheckIcon,
Expand Down Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
37 changes: 36 additions & 1 deletion ui/apps/console/src/hooks/useAdminNamespaces.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useQuery } from "@tanstack/react-query";
import { useQueries, useQuery } from "@tanstack/react-query";
import {
getNamespacesAdmin as getNamespacesAdminSdk,
getNamespacesAdminQueryKey,
Expand Down Expand Up @@ -56,6 +56,7 @@ export function useAdminNamespaces({
totalCount: result.data?.totalCount ?? 0,
isLoading: result.isLoading,
error: result.error,
isError: result.isError,
refetch: result.refetch,
};
}
Expand All @@ -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.
*/
Comment thread
gustavosbarreto marked this conversation as resolved.
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<string, string[]>();
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;
},
});
}
16 changes: 12 additions & 4 deletions ui/apps/console/src/hooks/useAdminUserMutations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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",
);
Comment thread
gustavosbarreto marked this conversation as resolved.
return useMutation({
...adminDeleteUserMutation(),
onSuccess: invalidate,
Expand Down
Loading
Loading