feat(editor): move the admin directory onto TanStack Query (#7726)

# Description of Changes

Step 5 of the TanStack Query rollout, covering the admin People, Teams
and Team details screens. Follows #7264, #7283, #7285.

## The problem

Two separate ones, in the same three files.

**Reads.** Each section fetched and held its own copy of the same
resources: People read the roster and the team list, Teams read the team
list plus the roster again when its add-member modal opened, Team
details read all three. Cost scaled with how many screens you visited
rather than with how much data exists.

**Writes.** Thirteen handlers each did the same five things by hand: set
a processing flag, call the service, toast the outcome, dig a message
out of an axios error, and reload their own slice. Refreshing was a
convention, not a mechanism, and one handler had already forgotten it.

## The fix

Three shared query keys (`adminUsers`, `teams`, `teamDetails`), and one
`useAdminMutation` helper that every write is declared against:

```ts
const createTeam = useAdminMutation({
  write: (name: string) => teamService.createTeam(name),
  invalidates: ["teams"],
  success: t("workspace.teams.createTeam.success"),
  errorFallback: t("workspace.teams.createTeam.error"),
  onDone: () => { setNewTeamName(""); setCreateModalOpened(false); },
});
```

Each write names the slices it disturbs, which is the part that only
works when reads and writes are designed together: `createTeam`
invalidates the team list, while a membership move invalidates the list,
both teams' detail rows and the roster, because it genuinely changes all
three. Invalidation refetches only mounted queries, so this costs
nothing extra.

The blanket "invalidate everything" helper survives in exactly one role:
child components (invite, password change, seat update) that write
through their own services, where the affected scopes are not visible
from the call site.

## Why it is better, measured

Request counts come from one harness driving `teams -> team details ->
back -> people`, run against the branch point and against this branch.
The assertion is committed, so it cannot silently regress.

| | Before | After |
|---|---|---|
| Requests | 7 | **3** |
| `getTeams` | 4 | **1** |
| `getUsers` | 2 | **1** |
| `getTeamDetails` | 1 | 1 |
| Committed renders | 17 | **15** |

Three is one per distinct resource, the floor for that sequence. The
four `getTeams` were the Teams table, Team details fetching the same
list for its "move to team" dropdown, the explicit refresh on the back
button, and People.

Renders barely move, which is expected: this changes where data lives,
not how often React draws. It is reported because a caching change can
quietly cost renders, and this one does not.

On the code itself, across the three sections:

| | |
|---|---|
| Net lines | **-216** |
| `useState`/`useEffect` removed | **11**, none added |
| Duplicated `isAxiosError` blocks | 13 to **1** |
| `setProcessing` calls | 19 to **0** |

`isAxiosError` is no longer imported by any of the three files.

## Bug fixed

`disableMfaByAdmin` showed a success toast and never refreshed. The menu
item renders only when `user.mfaEnabled` is true, so an admin disabled
MFA, was told it worked, and watched the option stay on screen until a
manual reload. It is covered by a test that fails if the invalidation is
removed.

## Behaviour worth checking in review

- A write no longer blocks its handler before closing the modal. The
dialog closes when the write succeeds and the table updates when the
refetch lands, rather than the button spinning through both.
- Modal submit buttons now track their own mutation rather than one
shared flag. Team details still derives a single busy flag, now from its
five mutations rather than a `useState`, so its row actions disable
together as before.
- The per-handler `console.error` is kept, once, in the shared error
path.

## Testing

Five tests, each verified by breaking the implementation and confirming
that one test, and only that one, fails:

| Mutation | Caught by |
|---|---|
| Drop the shared stale window (`staleTime: 0`) | request-count test |
| Make invalidation a no-op | write-visibility test |
| Ignore the login-enabled gate | login-disabled test |
| Stop invalidating after the MFA write | MFA-refresh test |
| Fall back to the generic error message | server-message test |

The write tests drive the real flows through their modals and menus
rather than calling hooks directly.

`task frontend:check` passes typecheck, lint and oxfmt, and 2383 of 2385
editor tests. The two failures, `workbenchSession.test.ts` and
`notificationActions.test.tsx`, are untouched here and fail identically
with this branch's changes reverted.

## Scope

The three services keep their current shape; nothing outside these three
sections and the new hook module changes. Child modals that write
through their own services still refresh via the blanket helper, and
converting those is separate work.
This commit is contained in:
ConnorYoh
2026-08-29 00:22:05 +00:00
committed by GitHub
parent d3708c1e63
commit c22d9ecf58
6 changed files with 885 additions and 651 deletions
+4
View File
@@ -1,5 +1,7 @@
/** Editor query keys: ["editor", <resource>, ...params]. */
export const qk = {
/** The admin directory payload: a different endpoint and shape to qk.users(). */
adminUsers: () => ["editor", "adminUsers"] as const,
appConfig: () => ["editor", "appConfig"] as const,
endpointsAvailability: () => ["editor", "endpointsAvailability"] as const,
endpointEnabled: (endpoint: string) =>
@@ -9,5 +11,7 @@ export const qk = {
/** Keyed on the asking identity: two users must never share one answer. */
portalAccess: (userId: string | null) =>
["editor", "portalAccess", userId] as const,
teamDetails: (teamId: number) => ["editor", "teamDetails", teamId] as const,
teams: () => ["editor", "teams"] as const,
users: () => ["editor", "users"] as const,
} as const;
@@ -1,5 +1,4 @@
import { useState, useEffect } from "react";
import { isAxiosError } from "axios";
import { useMemo, useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import {
Stack,
@@ -20,14 +19,12 @@ import {
import { Button } from "@app/ui/Button";
import { ActionIcon } from "@app/ui/ActionIcon";
import LocalIcon from "@app/components/shared/LocalIcon";
import { alert } from "@app/components/toast";
import {
userManagementService,
User,
} from "@app/services/userManagementService";
import { teamService, Team } from "@app/services/teamService";
import { type Team } from "@app/services/teamService";
import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import InviteMembersModal from "@app/components/shared/InviteMembersModal";
import { useLoginRequired } from "@app/hooks/useLoginRequired";
import LoginRequiredBanner from "@app/components/shared/config/LoginRequiredBanner";
@@ -36,17 +33,109 @@ import UpdateSeatsButton from "@app/components/shared/UpdateSeatsButton";
import { useLicense } from "@app/contexts/LicenseContext";
import ChangeUserPasswordModal from "@app/components/shared/ChangeUserPasswordModal";
import { useAuth } from "@app/auth/UseSession";
import {
useAdminUsers,
useTeams,
useAdminMutation,
useInvalidateAdminDirectory,
} from "@app/hooks/useAdminDirectory";
const EXAMPLE_USERS: User[] = [
{
id: 1,
username: "admin",
email: "admin@example.com",
enabled: true,
roleName: "ROLE_ADMIN",
rolesAsString: "ROLE_ADMIN",
authenticationType: "password",
isActive: true,
lastRequest: Date.now(),
team: { id: 1, name: "Engineering" },
},
{
id: 2,
username: "john.doe",
email: "john.doe@example.com",
enabled: true,
roleName: "ROLE_USER",
rolesAsString: "ROLE_USER",
authenticationType: "password",
isActive: false,
lastRequest: Date.now() - 86400000,
team: { id: 1, name: "Engineering" },
},
{
id: 3,
username: "jane.smith",
email: "jane.smith@example.com",
enabled: true,
roleName: "ROLE_USER",
rolesAsString: "ROLE_USER",
authenticationType: "oauth",
isActive: true,
lastRequest: Date.now(),
team: { id: 2, name: "Marketing" },
},
{
id: 4,
username: "bob.wilson",
email: "bob.wilson@example.com",
enabled: false,
roleName: "ROLE_USER",
rolesAsString: "ROLE_USER",
authenticationType: "password",
isActive: false,
lastRequest: Date.now() - 604800000,
team: undefined,
},
];
const EXAMPLE_TEAMS: Team[] = [
{ id: 1, name: "Engineering", userCount: 3 },
{ id: 2, name: "Marketing", userCount: 2 },
];
const EXAMPLE_LICENSE = {
maxAllowedUsers: 10,
availableSlots: 6,
grandfatheredUserCount: 0,
licenseMaxUsers: 5,
premiumEnabled: true,
totalUsers: 4,
};
export default function PeopleSection() {
const { t } = useTranslation();
const { config } = useAppConfig();
const { loginEnabled } = useLoginRequired();
const { user: currentUser } = useAuth();
const navigate = useNavigate();
const { licenseInfo: globalLicenseInfo } = useLicense();
const [users, setUsers] = useState<User[]>([]);
const [teams, setTeams] = useState<Team[]>([]);
const [loading, setLoading] = useState(true);
const admin = useAdminUsers(loginEnabled);
const { data: fetchedTeams } = useTeams(loginEnabled);
const refreshDirectory = useInvalidateAdminDirectory();
// Session and MFA state arrive alongside the roster, keyed by username.
const fetchedUsers = useMemo<User[]>(() => {
if (!admin.data) return [];
return admin.data.users.map((user) => ({
...user,
isActive: admin.data.userSessions[user.username] || false,
lastRequest: admin.data.userLastRequest[user.username] || undefined,
mfaEnabled:
(
admin.data.userSettings?.[user.username] as
| Record<string, unknown>
| undefined
)?.mfaEnabled === "true",
}));
}, [admin.data]);
// Login off means the endpoints are not callable, so the table shows a
// worked example instead of an empty state.
const users = loginEnabled ? fetchedUsers : EXAMPLE_USERS;
const teams = loginEnabled ? (fetchedTeams ?? []) : EXAMPLE_TEAMS;
const loading = loginEnabled && admin.isPending;
const [searchQuery, setSearchQuery] = useState("");
const [inviteModalOpened, setInviteModalOpened] = useState(false);
const [editUserModalOpened, setEditUserModalOpened] = useState(false);
@@ -54,19 +143,20 @@ export default function PeopleSection() {
useState(false);
const [passwordUser, setPasswordUser] = useState<User | null>(null);
const [selectedUser, setSelectedUser] = useState<User | null>(null);
const [processing, setProcessing] = useState(false);
const [mailEnabled, setMailEnabled] = useState(false);
const [lockedUsers, setLockedUsers] = useState<string[]>([]);
// License information
const [licenseInfo, setLicenseInfo] = useState<{
maxAllowedUsers: number;
availableSlots: number;
grandfatheredUserCount: number;
licenseMaxUsers: number;
premiumEnabled: boolean;
totalUsers: number;
} | null>(null);
const mailEnabled = loginEnabled ? (admin.data?.mailEnabled ?? false) : false;
const lockedUsers = loginEnabled ? (admin.data?.lockedUsers ?? []) : [];
const licenseInfo = loginEnabled
? admin.data
? {
maxAllowedUsers: admin.data.maxAllowedUsers,
availableSlots: admin.data.availableSlots,
grandfatheredUserCount: admin.data.grandfatheredUserCount,
licenseMaxUsers: admin.data.licenseMaxUsers,
premiumEnabled: admin.data.premiumEnabled,
totalUsers: admin.data.totalUsers,
}
: null
: EXAMPLE_LICENSE;
const hasNoSlots = licenseInfo ? licenseInfo.availableSlots === 0 : false;
const handleAddMembersClick = () => {
if (!loginEnabled) {
@@ -115,253 +205,103 @@ export default function PeopleSection() {
teamId: undefined as number | undefined,
});
useEffect(() => {
fetchData();
}, []);
const updateUserRole = useAdminMutation({
write: (payload: { username: string; role: string; teamId?: number }) =>
userManagementService.updateUserRole(payload),
// A role edit can also move the user, which changes both teams' counts.
invalidates: ["users", "teams"],
success: t("workspace.people.editMember.success"),
errorFallback: t("workspace.people.editMember.error"),
onDone: () => closeEditModal(),
});
useEffect(() => {
if (config) {
console.log(
"[PeopleSection] Email invites enabled:",
config.enableEmailInvites,
);
}
}, [config]);
const toggleEnabled = useAdminMutation({
write: (user: User) =>
userManagementService.toggleUserEnabled(user.username, !user.enabled),
invalidates: ["users"],
success: t("workspace.people.toggleEnabled.success"),
errorFallback: t("workspace.people.toggleEnabled.error"),
});
const fetchData = async () => {
try {
setLoading(true);
const deleteUser = useAdminMutation({
write: (username: string) => userManagementService.deleteUser(username),
invalidates: ["users", "teams"],
success: t(
"workspace.people.deleteUserSuccess",
"User deleted successfully",
),
errorFallback: t(
"workspace.people.deleteUserError",
"Failed to delete user",
),
});
if (loginEnabled) {
const [adminData, teamsData] = await Promise.all([
userManagementService.getUsers(),
teamService.getTeams(),
]);
const unlockUser = useAdminMutation({
write: (username: string) => userManagementService.unlockUser(username),
invalidates: ["users"],
success: t(
"workspace.people.unlockUserSuccess",
"User account unlocked successfully",
),
errorFallback: t(
"workspace.people.unlockUserError",
"Failed to unlock user account",
),
});
// Enrich users with session data
const enrichedUsers = adminData.users.map((user) => ({
...user,
isActive: adminData.userSessions[user.username] || false,
lastRequest: adminData.userLastRequest[user.username] || undefined,
mfaEnabled:
(
adminData.userSettings?.[user.username] as
| Record<string, unknown>
| undefined
)?.mfaEnabled === "true",
}));
const disableMfa = useAdminMutation({
write: (username: string) =>
userManagementService.disableMfaByAdmin(username),
invalidates: ["users"],
success: t(
"workspace.people.mfa.adminDisableSuccess",
"MFA disabled successfully for user",
),
errorFallback: t(
"workspace.people.mfa.adminDisableError",
"Failed to disable MFA for user",
),
});
setUsers(enrichedUsers);
setTeams(teamsData);
// Store license information
setLicenseInfo({
maxAllowedUsers: adminData.maxAllowedUsers,
availableSlots: adminData.availableSlots,
grandfatheredUserCount: adminData.grandfatheredUserCount,
licenseMaxUsers: adminData.licenseMaxUsers,
premiumEnabled: adminData.premiumEnabled,
totalUsers: adminData.totalUsers,
});
setMailEnabled(adminData.mailEnabled);
setLockedUsers(adminData.lockedUsers || []);
} else {
// Provide example data when login is disabled
const exampleUsers: User[] = [
{
id: 1,
username: "admin",
email: "admin@example.com",
enabled: true,
roleName: "ROLE_ADMIN",
rolesAsString: "ROLE_ADMIN",
authenticationType: "password",
isActive: true,
lastRequest: Date.now(),
team: { id: 1, name: "Engineering" },
},
{
id: 2,
username: "john.doe",
email: "john.doe@example.com",
enabled: true,
roleName: "ROLE_USER",
rolesAsString: "ROLE_USER",
authenticationType: "password",
isActive: false,
lastRequest: Date.now() - 86400000,
team: { id: 1, name: "Engineering" },
},
{
id: 3,
username: "jane.smith",
email: "jane.smith@example.com",
enabled: true,
roleName: "ROLE_USER",
rolesAsString: "ROLE_USER",
authenticationType: "oauth",
isActive: true,
lastRequest: Date.now(),
team: { id: 2, name: "Marketing" },
},
{
id: 4,
username: "bob.wilson",
email: "bob.wilson@example.com",
enabled: false,
roleName: "ROLE_USER",
rolesAsString: "ROLE_USER",
authenticationType: "password",
isActive: false,
lastRequest: Date.now() - 604800000,
team: undefined,
},
];
const exampleTeams: Team[] = [
{ id: 1, name: "Engineering", userCount: 3 },
{ id: 2, name: "Marketing", userCount: 2 },
];
setUsers(exampleUsers);
setTeams(exampleTeams);
setMailEnabled(false);
setLockedUsers([]);
// Example license information
setLicenseInfo({
maxAllowedUsers: 10,
availableSlots: 6,
grandfatheredUserCount: 0,
licenseMaxUsers: 5,
premiumEnabled: true,
totalUsers: 4,
});
}
} catch (error) {
console.error("[PeopleSection] Failed to fetch people data:", error);
alert({ alertType: "error", title: "Failed to load people data" });
} finally {
setLoading(false);
}
};
const handleUpdateUserRole = async () => {
const handleUpdateUserRole = () => {
if (!selectedUser) return;
try {
setProcessing(true);
await userManagementService.updateUserRole({
username: selectedUser.username,
role: editForm.role,
teamId: editForm.teamId,
});
alert({
alertType: "success",
title: t("workspace.people.editMember.success"),
});
closeEditModal();
fetchData();
} catch (error: unknown) {
console.error("[PeopleSection] Failed to update user:", error);
const errorMessage = isAxiosError(error)
? error.response?.data?.message ||
error.response?.data?.error ||
error.message
: (error instanceof Error ? error.message : undefined) ||
t("workspace.people.editMember.error");
alert({ alertType: "error", title: errorMessage });
} finally {
setProcessing(false);
}
updateUserRole.mutate({
username: selectedUser.username,
role: editForm.role,
teamId: editForm.teamId,
});
};
const handleToggleEnabled = async (user: User) => {
try {
await userManagementService.toggleUserEnabled(
user.username,
!user.enabled,
);
alert({
alertType: "success",
title: t("workspace.people.toggleEnabled.success"),
});
fetchData();
} catch (error: unknown) {
console.error("[PeopleSection] Failed to toggle user status:", error);
const errorMessage = isAxiosError(error)
? error.response?.data?.message ||
error.response?.data?.error ||
error.message
: (error instanceof Error ? error.message : undefined) ||
t("workspace.people.toggleEnabled.error");
alert({ alertType: "error", title: errorMessage });
}
const handleToggleEnabled = (user: User) => {
toggleEnabled.mutate(user);
};
const handleDeleteUser = async (user: User) => {
const handleDeleteUser = (user: User) => {
const confirmMessage = t(
"workspace.people.confirmDelete",
"Are you sure you want to delete this user? This action cannot be undone.",
);
if (!window.confirm(`${confirmMessage}\n\nUser: ${user.username}`)) {
return;
}
if (
!window.confirm(`${confirmMessage}
try {
await userManagementService.deleteUser(user.username);
alert({
alertType: "success",
title: t(
"workspace.people.deleteUserSuccess",
"User deleted successfully",
),
});
fetchData();
} catch (error: unknown) {
console.error("[PeopleSection] Failed to delete user:", error);
const errorMessage = isAxiosError(error)
? error.response?.data?.message ||
error.response?.data?.error ||
error.message
: (error instanceof Error ? error.message : undefined) ||
t("workspace.people.deleteUserError", "Failed to delete user");
alert({ alertType: "error", title: errorMessage });
}
User: ${user.username}`)
)
return;
deleteUser.mutate(user.username);
};
const handleUnlockUser = async (user: User) => {
const handleUnlockUser = (user: User) => {
const confirmMessage = t(
"workspace.people.confirmUnlock",
"Are you sure you want to unlock this user account?",
);
if (!window.confirm(`${confirmMessage}\n\nUser: ${user.username}`)) {
return;
}
if (
!window.confirm(`${confirmMessage}
try {
await userManagementService.unlockUser(user.username);
alert({
alertType: "success",
title: t(
"workspace.people.unlockUserSuccess",
"User account unlocked successfully",
),
});
fetchData();
} catch (error: unknown) {
console.error("[PeopleSection] Failed to unlock user:", error);
const errorMessage = isAxiosError(error)
? error.response?.data?.message ||
error.response?.data?.error ||
error.message
: (error instanceof Error ? error.message : undefined) ||
t(
"workspace.people.unlockUserError",
"Failed to unlock user account",
);
alert({ alertType: "error", title: errorMessage });
}
User: ${user.username}`)
)
return;
unlockUser.mutate(user.username);
};
const openEditModal = (user: User) => {
@@ -549,7 +489,7 @@ export default function PeopleSection() {
<Text size="sm" c="dimmed" span>
</Text>
<UpdateSeatsButton size="sm" onSuccess={fetchData} />
<UpdateSeatsButton size="sm" onSuccess={refreshDirectory} />
</>
)}
</Group>
@@ -891,40 +831,7 @@ export default function PeopleSection() {
height="1rem"
/>
}
onClick={async () => {
try {
await userManagementService.disableMfaByAdmin(
user.username,
);
alert({
alertType: "success",
title: t(
"workspace.people.mfa.adminDisableSuccess",
"MFA disabled successfully for user",
),
});
} catch (error: unknown) {
console.error(
"[PeopleSection] Failed to disable MFA for user:",
error,
);
const errorMessage = isAxiosError(error)
? error.response?.data?.message ||
error.response?.data?.error ||
error.message
: (error instanceof Error
? error.message
: undefined) ||
t(
"workspace.people.mfa.adminDisableError",
"Failed to disable MFA for user",
);
alert({
alertType: "error",
title: errorMessage,
});
}
}}
onClick={() => disableMfa.mutate(user.username)}
disabled={!loginEnabled}
>
{t(
@@ -968,14 +875,14 @@ export default function PeopleSection() {
<InviteMembersModal
opened={inviteModalOpened}
onClose={() => setInviteModalOpened(false)}
onSuccess={fetchData}
onSuccess={refreshDirectory}
/>
<ChangeUserPasswordModal
opened={changePasswordModalOpened}
onClose={closeChangePasswordModal}
user={passwordUser}
onSuccess={fetchData}
onSuccess={refreshDirectory}
mailEnabled={mailEnabled}
/>
@@ -1075,7 +982,7 @@ export default function PeopleSection() {
/>
<Button
onClick={handleUpdateUserRole}
loading={processing}
loading={updateUserRole.isPending}
fullWidth
size="md"
style={{ marginTop: "var(--mantine-spacing-md)" }}
@@ -1,5 +1,4 @@
import { useState, useEffect } from "react";
import { isAxiosError } from "axios";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Stack,
@@ -19,13 +18,20 @@ import { Button } from "@app/ui/Button";
import { ActionIcon } from "@app/ui/ActionIcon";
import LocalIcon from "@app/components/shared/LocalIcon";
import { alert } from "@app/components/toast";
import { teamService, Team } from "@app/services/teamService";
import { teamService } from "@app/services/teamService";
import {
User,
userManagementService,
} from "@app/services/userManagementService";
import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
import ChangeUserPasswordModal from "@app/components/shared/ChangeUserPasswordModal";
import {
useAdminUsers,
useTeamDetails,
useTeams,
useAdminMutation,
useInvalidateAdminDirectory,
} from "@app/hooks/useAdminDirectory";
interface TeamDetailsSectionProps {
teamId: number;
@@ -37,14 +43,27 @@ export default function TeamDetailsSection({
onBack,
}: TeamDetailsSectionProps) {
const { t } = useTranslation();
const [loading, setLoading] = useState(true);
const [team, setTeam] = useState<Team | null>(null);
const [teamUsers, setTeamUsers] = useState<User[]>([]);
const [availableUsers, setAvailableUsers] = useState<User[]>([]);
const [allTeams, setAllTeams] = useState<Team[]>([]);
const [userLastRequest, setUserLastRequest] = useState<
Record<string, number>
>({});
const details = useTeamDetails(teamId, true);
const admin = useAdminUsers(true);
// The same list TeamsSection is showing behind this view.
const { data: allTeams = [] } = useTeams(true);
const refreshDirectory = useInvalidateAdminDirectory();
const loading = details.isPending || admin.isPending;
const team = details.data?.team ?? null;
const teamUsers = Array.isArray(details.data?.teamUsers)
? details.data.teamUsers
: [];
const availableUsers = Array.isArray(details.data?.availableUsers)
? details.data.availableUsers
: [];
const userLastRequest = details.data?.userLastRequest ?? {};
const licenseInfo = admin.data
? { availableSlots: admin.data.availableSlots }
: null;
const mailEnabled = admin.data?.mailEnabled ?? false;
const lockedUsers = admin.data?.lockedUsers ?? [];
const [addMemberModalOpened, setAddMemberModalOpened] = useState(false);
const [changeTeamModalOpened, setChangeTeamModalOpened] = useState(false);
const [changePasswordModalOpened, setChangePasswordModalOpened] =
@@ -53,68 +72,122 @@ export default function TeamDetailsSection({
const [selectedUser, setSelectedUser] = useState<User | null>(null);
const [selectedUserId, setSelectedUserId] = useState<string>("");
const [selectedTeamId, setSelectedTeamId] = useState<string>("");
const [processing, setProcessing] = useState(false);
const availableUsersForTeam = team
? availableUsers.filter((user) => user.team?.id !== team.id)
: [];
// License information
const [licenseInfo, setLicenseInfo] = useState<{
availableSlots: number;
} | null>(null);
const [mailEnabled, setMailEnabled] = useState(false);
const [lockedUsers, setLockedUsers] = useState<string[]>([]);
const isLockedUser = (user: User) => lockedUsers.includes(user.username);
// A failed load leaves nothing to show, so the view hands back to the list.
const loadFailed = details.isLoadingError || admin.isLoadingError;
const reportedRef = useRef(false);
useEffect(() => {
fetchTeamDetails();
fetchAllTeams();
}, [teamId]);
if (!loadFailed || reportedRef.current) return;
reportedRef.current = true;
alert({
alertType: "error",
title: t("workspace.teams.loadError", "Failed to load team details"),
});
onBack();
}, [loadFailed, onBack, t]);
const fetchTeamDetails = async () => {
try {
setLoading(true);
const [data, adminData] = await Promise.all([
teamService.getTeamDetails(teamId),
userManagementService.getUsers(),
]);
console.log("[TeamDetailsSection] Raw data:", data);
setTeam(data.team);
setTeamUsers(Array.isArray(data.teamUsers) ? data.teamUsers : []);
setAvailableUsers(
Array.isArray(data.availableUsers) ? data.availableUsers : [],
// A membership move changes the team's count, the member's own team and
// both teams' detail rows.
const MEMBERSHIP = ["teams", "teamDetails", "users"] as const;
const addMember = useAdminMutation({
write: (userId: number) => teamService.addUserToTeam(teamId, userId),
invalidates: MEMBERSHIP,
success: t(
"workspace.teams.addMemberToTeam.success",
"User added to team successfully",
),
errorFallback: t(
"workspace.teams.addMemberToTeam.error",
"Failed to add user to team",
),
onDone: () => {
setAddMemberModalOpened(false);
setSelectedUserId("");
},
});
const removeMember = useAdminMutation({
write: (user: User) => {
const defaultTeam = allTeams.find((team) => team.name === "Default");
if (!defaultTeam) throw new Error("Default team not found");
return teamService.moveUserToTeam(
user.username,
user.rolesAsString || "ROLE_USER",
defaultTeam.id,
);
setUserLastRequest(data.userLastRequest || {});
},
invalidates: MEMBERSHIP,
success: t("workspace.teams.removeMemberSuccess", "User removed from team"),
errorFallback: t(
"workspace.teams.removeMemberError",
"Failed to remove user from team",
),
});
// Store license information
setLicenseInfo({
availableSlots: adminData.availableSlots,
});
setMailEnabled(adminData.mailEnabled);
setLockedUsers(adminData.lockedUsers || []);
} catch (error) {
console.error("Failed to fetch team details:", error);
alert({
alertType: "error",
title: t("workspace.teams.loadError", "Failed to load team details"),
});
onBack();
} finally {
setLoading(false);
}
};
const changeTeam = useAdminMutation({
write: ({ user, teamId: target }: { user: User; teamId: number }) =>
teamService.moveUserToTeam(
user.username,
user.rolesAsString || "ROLE_USER",
target,
),
invalidates: MEMBERSHIP,
success: t(
"workspace.teams.changeTeam.success",
"Team changed successfully",
),
errorFallback: t(
"workspace.teams.changeTeam.error",
"Failed to change team",
),
onDone: () => {
setChangeTeamModalOpened(false);
setSelectedUser(null);
setSelectedTeamId("");
},
});
const fetchAllTeams = async () => {
try {
const teams = await teamService.getTeams();
setAllTeams(teams);
} catch (error) {
console.error("Failed to fetch teams:", error);
}
};
const deleteUser = useAdminMutation({
write: (username: string) => userManagementService.deleteUser(username),
invalidates: ["users", "teams"],
success: t(
"workspace.people.deleteUserSuccess",
"User deleted successfully",
),
errorFallback: t(
"workspace.people.deleteUserError",
"Failed to delete user",
),
});
const handleAddMember = async () => {
const unlockUser = useAdminMutation({
write: (username: string) => userManagementService.unlockUser(username),
invalidates: ["users"],
success: t(
"workspace.people.unlockUserSuccess",
"User account unlocked successfully",
),
errorFallback: t(
"workspace.people.unlockUserError",
"Failed to unlock user account",
),
});
// Row actions are blocked while any write is in flight, as before.
const processing =
addMember.isPending ||
removeMember.isPending ||
changeTeam.isPending ||
deleteUser.isPending ||
unlockUser.isPending;
const handleAddMember = () => {
if (!selectedUserId) {
alert({
alertType: "error",
@@ -125,155 +198,44 @@ export default function TeamDetailsSection({
});
return;
}
try {
setProcessing(true);
await teamService.addUserToTeam(teamId, parseInt(selectedUserId));
alert({
alertType: "success",
title: t(
"workspace.teams.addMemberToTeam.success",
"User added to team successfully",
),
});
setAddMemberModalOpened(false);
setSelectedUserId("");
fetchTeamDetails();
} catch (error: unknown) {
console.error("Failed to add member:", error);
const errorMessage = isAxiosError(error)
? error.response?.data?.message ||
error.response?.data?.error ||
error.message
: (error instanceof Error ? error.message : undefined) ||
t(
"workspace.teams.addMemberToTeam.error",
"Failed to add user to team",
);
alert({ alertType: "error", title: errorMessage });
} finally {
setProcessing(false);
}
addMember.mutate(parseInt(selectedUserId));
};
const handleRemoveMember = async (user: User) => {
if (
!window.confirm(
t(
"workspace.teams.confirmRemove",
`Remove ${user.username} from this team?`,
),
)
) {
return;
}
try {
setProcessing(true);
// Find the Default team ID
const defaultTeam = allTeams.find((t) => t.name === "Default");
if (!defaultTeam) {
throw new Error("Default team not found");
}
// Move user to Default team by updating their role with the Default team ID
await teamService.moveUserToTeam(
user.username,
user.rolesAsString || "ROLE_USER",
defaultTeam.id,
);
alert({
alertType: "success",
title: t(
"workspace.teams.removeMemberSuccess",
"User removed from team",
),
});
fetchTeamDetails();
} catch (error: unknown) {
console.error("Failed to remove member:", error);
const errorMessage = isAxiosError(error)
? error.response?.data?.message ||
error.response?.data?.error ||
error.message
: (error instanceof Error ? error.message : undefined) ||
t(
"workspace.teams.removeMemberError",
"Failed to remove user from team",
);
alert({ alertType: "error", title: errorMessage });
} finally {
setProcessing(false);
}
const handleRemoveMember = (user: User) => {
const confirmMessage = t(
"workspace.teams.confirmRemove",
`Remove ${user.username} from this team?`,
);
if (!window.confirm(confirmMessage)) return;
removeMember.mutate(user);
};
const handleDeleteUser = async (user: User) => {
const handleDeleteUser = (user: User) => {
const confirmMessage = t(
"workspace.people.confirmDelete",
"Are you sure you want to delete this user? This action cannot be undone.",
);
if (!window.confirm(`${confirmMessage}\n\nUser: ${user.username}`)) {
return;
}
if (
!window.confirm(`${confirmMessage}
try {
setProcessing(true);
await userManagementService.deleteUser(user.username);
alert({
alertType: "success",
title: t(
"workspace.people.deleteUserSuccess",
"User deleted successfully",
),
});
fetchTeamDetails();
} catch (error: unknown) {
console.error("Failed to delete user:", error);
const errorMessage = isAxiosError(error)
? error.response?.data?.message ||
error.response?.data?.error ||
error.message
: (error instanceof Error ? error.message : undefined) ||
t("workspace.people.deleteUserError", "Failed to delete user");
alert({ alertType: "error", title: errorMessage });
} finally {
setProcessing(false);
}
User: ${user.username}`)
)
return;
deleteUser.mutate(user.username);
};
const handleUnlockUser = async (user: User) => {
const handleUnlockUser = (user: User) => {
const confirmMessage = t(
"workspace.people.confirmUnlock",
"Are you sure you want to unlock this user account?",
);
if (!window.confirm(`${confirmMessage}\n\nUser: ${user.username}`)) {
return;
}
if (
!window.confirm(`${confirmMessage}
try {
await userManagementService.unlockUser(user.username);
alert({
alertType: "success",
title: t(
"workspace.people.unlockUserSuccess",
"User account unlocked successfully",
),
});
fetchTeamDetails();
} catch (error: unknown) {
console.error("[TeamDetailsSection] Failed to unlock user:", error);
const errorMessage = isAxiosError(error)
? error.response?.data?.message ||
error.response?.data?.error ||
error.message
: (error instanceof Error ? error.message : undefined) ||
t(
"workspace.people.unlockUserError",
"Failed to unlock user account",
);
alert({ alertType: "error", title: errorMessage });
}
User: ${user.username}`)
)
return;
unlockUser.mutate(user.username);
};
const openChangeTeamModal = (user: User) => {
@@ -292,7 +254,7 @@ export default function TeamDetailsSection({
setPasswordUser(null);
};
const handleChangeTeam = async () => {
const handleChangeTeam = () => {
if (!selectedUser || !selectedTeamId) {
alert({
alertType: "error",
@@ -303,37 +265,10 @@ export default function TeamDetailsSection({
});
return;
}
try {
setProcessing(true);
await teamService.moveUserToTeam(
selectedUser.username,
selectedUser.rolesAsString || "ROLE_USER",
parseInt(selectedTeamId),
);
alert({
alertType: "success",
title: t(
"workspace.teams.changeTeam.success",
"Team changed successfully",
),
});
setChangeTeamModalOpened(false);
setSelectedUser(null);
setSelectedTeamId("");
fetchTeamDetails();
} catch (error: unknown) {
console.error("Failed to change team:", error);
const errorMessage = isAxiosError(error)
? error.response?.data?.message ||
error.response?.data?.error ||
error.message
: (error instanceof Error ? error.message : undefined) ||
t("workspace.teams.changeTeam.error", "Failed to change team");
alert({ alertType: "error", title: errorMessage });
} finally {
setProcessing(false);
}
changeTeam.mutate({
user: selectedUser,
teamId: parseInt(selectedTeamId),
});
};
if (loading) {
@@ -686,7 +621,7 @@ export default function TeamDetailsSection({
opened={changePasswordModalOpened}
onClose={closeChangePasswordModal}
user={passwordUser}
onSuccess={fetchTeamDetails}
onSuccess={refreshDirectory}
mailEnabled={mailEnabled}
/>
@@ -762,7 +697,7 @@ export default function TeamDetailsSection({
<Button
onClick={handleAddMember}
loading={processing}
loading={addMember.isPending}
fullWidth
size="md"
style={{ marginTop: "var(--mantine-spacing-md)" }}
@@ -839,7 +774,7 @@ export default function TeamDetailsSection({
<Button
onClick={handleChangeTeam}
loading={processing}
loading={changeTeam.isPending}
fullWidth
size="md"
style={{ marginTop: "var(--mantine-spacing-md)" }}
@@ -1,5 +1,4 @@
import { useState, useEffect } from "react";
import { isAxiosError } from "axios";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import {
Stack,
@@ -19,26 +18,37 @@ import { ActionIcon } from "@app/ui/ActionIcon";
import LocalIcon from "@app/components/shared/LocalIcon";
import { alert } from "@app/components/toast";
import { teamService, Team } from "@app/services/teamService";
import {
userManagementService,
User,
} from "@app/services/userManagementService";
import { type User } from "@app/services/userManagementService";
import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
import TeamDetailsSection from "@app/components/shared/config/configSections/TeamDetailsSection";
import { useLoginRequired } from "@app/hooks/useLoginRequired";
import {
useTeams,
useFetchAdminUsers,
useAdminMutation,
} from "@app/hooks/useAdminDirectory";
import LoginRequiredBanner from "@app/components/shared/config/LoginRequiredBanner";
const EXAMPLE_TEAMS: Team[] = [
{ id: 1, name: "Engineering", userCount: 3 },
{ id: 2, name: "Marketing", userCount: 2 },
{ id: 3, name: "Internal", userCount: 1 },
];
export default function TeamsSection() {
const { t } = useTranslation();
const { loginEnabled } = useLoginRequired();
const [teams, setTeams] = useState<Team[]>([]);
const [loading, setLoading] = useState(true);
const { data: fetchedTeams, isPending } = useTeams(loginEnabled);
const fetchAdminUsers = useFetchAdminUsers();
// Login off means the endpoints are not callable, so the table shows a
// worked example instead of an empty state.
const teams = loginEnabled ? (fetchedTeams ?? []) : EXAMPLE_TEAMS;
const loading = loginEnabled && isPending;
const [createModalOpened, setCreateModalOpened] = useState(false);
const [renameModalOpened, setRenameModalOpened] = useState(false);
const [addMemberModalOpened, setAddMemberModalOpened] = useState(false);
const [selectedTeam, setSelectedTeam] = useState<Team | null>(null);
const [availableUsers, setAvailableUsers] = useState<User[]>([]);
const [processing, setProcessing] = useState(false);
const [viewingTeamId, setViewingTeamId] = useState<number | null>(null);
// Form states
@@ -49,34 +59,53 @@ export default function TeamsSection() {
? availableUsers.filter((user) => user.team?.id !== selectedTeam.id)
: [];
useEffect(() => {
fetchTeams();
}, []);
const createTeam = useAdminMutation({
write: (name: string) => teamService.createTeam(name),
invalidates: ["teams"],
success: t("workspace.teams.createTeam.success"),
errorFallback: t("workspace.teams.createTeam.error"),
onDone: () => {
setNewTeamName("");
setCreateModalOpened(false);
},
});
const fetchTeams = async () => {
try {
setLoading(true);
if (loginEnabled) {
const teamsData = await teamService.getTeams();
setTeams(teamsData);
} else {
// Provide example data when login is disabled
const exampleTeams: Team[] = [
{ id: 1, name: "Engineering", userCount: 3 },
{ id: 2, name: "Marketing", userCount: 2 },
{ id: 3, name: "Internal", userCount: 1 },
];
setTeams(exampleTeams);
}
} catch (error) {
console.error("Failed to fetch teams:", error);
alert({ alertType: "error", title: "Failed to load teams" });
} finally {
setLoading(false);
}
};
const renameTeam = useAdminMutation({
write: ({ id, name }: { id: number; name: string }) =>
teamService.renameTeam(id, name),
invalidates: ["teams"],
success: t("workspace.teams.renameTeam.success"),
errorFallback: t("workspace.teams.renameTeam.error"),
onDone: () => {
setRenameTeamName("");
setSelectedTeam(null);
setRenameModalOpened(false);
},
});
const handleCreateTeam = async () => {
const deleteTeam = useAdminMutation({
write: (id: number) => teamService.deleteTeam(id),
invalidates: ["teams"],
success: t("workspace.teams.deleteTeam.success"),
errorFallback: t("workspace.teams.deleteTeam.error"),
});
// Membership changes a team's count, the member's own team, and both
// teams' detail rows.
const addMember = useAdminMutation({
write: ({ teamId, userId }: { teamId: number; userId: number }) =>
teamService.addUserToTeam(teamId, userId),
invalidates: ["teams", "teamDetails", "users"],
success: t("workspace.teams.addMemberToTeam.success"),
errorFallback: t("workspace.teams.addMemberToTeam.error"),
onDone: () => {
setSelectedUserId("");
setSelectedTeam(null);
setAddMemberModalOpened(false);
},
});
const handleCreateTeam = () => {
if (!newTeamName.trim()) {
alert({
alertType: "error",
@@ -84,32 +113,10 @@ export default function TeamsSection() {
});
return;
}
try {
setProcessing(true);
await teamService.createTeam(newTeamName);
alert({
alertType: "success",
title: t("workspace.teams.createTeam.success"),
});
setNewTeamName("");
setCreateModalOpened(false);
await fetchTeams();
} catch (error: unknown) {
console.error("Failed to create team:", error);
const errorMessage = isAxiosError(error)
? error.response?.data?.message ||
error.response?.data?.error ||
error.message
: (error instanceof Error ? error.message : undefined) ||
t("workspace.teams.createTeam.error");
alert({ alertType: "error", title: errorMessage });
} finally {
setProcessing(false);
}
createTeam.mutate(newTeamName);
};
const handleRenameTeam = async () => {
const handleRenameTeam = () => {
if (!selectedTeam || !renameTeamName.trim()) {
alert({
alertType: "error",
@@ -117,33 +124,10 @@ export default function TeamsSection() {
});
return;
}
try {
setProcessing(true);
await teamService.renameTeam(selectedTeam.id, renameTeamName);
alert({
alertType: "success",
title: t("workspace.teams.renameTeam.success"),
});
setRenameTeamName("");
setSelectedTeam(null);
setRenameModalOpened(false);
await fetchTeams();
} catch (error: unknown) {
console.error("Failed to rename team:", error);
const errorMessage = isAxiosError(error)
? error.response?.data?.message ||
error.response?.data?.error ||
error.message
: (error instanceof Error ? error.message : undefined) ||
t("workspace.teams.renameTeam.error");
alert({ alertType: "error", title: errorMessage });
} finally {
setProcessing(false);
}
renameTeam.mutate({ id: selectedTeam.id, name: renameTeamName });
};
const handleDeleteTeam = async (team: Team) => {
const handleDeleteTeam = (team: Team) => {
if (team.name === "Internal") {
alert({
alertType: "error",
@@ -151,28 +135,8 @@ export default function TeamsSection() {
});
return;
}
if (!confirm(t("workspace.teams.confirmDelete"))) {
return;
}
try {
await teamService.deleteTeam(team.id);
alert({
alertType: "success",
title: t("workspace.teams.deleteTeam.success"),
});
await fetchTeams();
} catch (error: unknown) {
console.error("Failed to delete team:", error);
const errorMessage = isAxiosError(error)
? error.response?.data?.message ||
error.response?.data?.error ||
error.message
: (error instanceof Error ? error.message : undefined) ||
t("workspace.teams.deleteTeam.error");
alert({ alertType: "error", title: errorMessage });
}
if (!confirm(t("workspace.teams.confirmDelete"))) return;
deleteTeam.mutate(team.id);
};
const openRenameModal = (team: Team) => {
@@ -198,8 +162,7 @@ export default function TeamsSection() {
}
setSelectedTeam(team);
try {
// Fetch all users to show in dropdown
const adminData = await userManagementService.getUsers();
const adminData = await fetchAdminUsers();
setAvailableUsers(adminData.users);
setAddMemberModalOpened(true);
} catch (error) {
@@ -211,7 +174,7 @@ export default function TeamsSection() {
}
};
const handleAddMember = async () => {
const handleAddMember = () => {
if (!selectedTeam || !selectedUserId) {
alert({
alertType: "error",
@@ -219,30 +182,10 @@ export default function TeamsSection() {
});
return;
}
try {
setProcessing(true);
await teamService.addUserToTeam(
selectedTeam.id,
parseInt(selectedUserId),
);
alert({
alertType: "success",
title: t("workspace.teams.addMemberToTeam.success"),
});
setSelectedUserId("");
setSelectedTeam(null);
setAddMemberModalOpened(false);
await fetchTeams();
} catch (error) {
console.error("Failed to add member to team:", error);
alert({
alertType: "error",
title: t("workspace.teams.addMemberToTeam.error"),
});
} finally {
setProcessing(false);
}
addMember.mutate({
teamId: selectedTeam.id,
userId: parseInt(selectedUserId),
});
};
// If viewing team details, render TeamDetailsSection
@@ -252,7 +195,6 @@ export default function TeamsSection() {
teamId={viewingTeamId}
onBack={() => {
setViewingTeamId(null);
fetchTeams(); // Refresh teams list
}}
/>
);
@@ -493,7 +435,7 @@ export default function TeamsSection() {
<Button
onClick={handleCreateTeam}
loading={processing}
loading={createTeam.isPending}
fullWidth
size="md"
style={{ marginTop: "var(--mantine-spacing-md)" }}
@@ -559,7 +501,7 @@ export default function TeamsSection() {
<Button
onClick={handleRenameTeam}
loading={processing}
loading={renameTeam.isPending}
fullWidth
size="md"
style={{ marginTop: "var(--mantine-spacing-md)" }}
@@ -642,7 +584,7 @@ export default function TeamsSection() {
<Button
onClick={handleAddMember}
loading={processing}
loading={addMember.isPending}
fullWidth
size="md"
style={{ marginTop: "var(--mantine-spacing-md)" }}
@@ -0,0 +1,295 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { type ReactNode } from "react";
import { render, screen, waitFor, act } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MantineProvider } from "@mantine/core";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { baseQueryOptions } from "@app/query/queryClient";
import { AppConfigProvider } from "@app/contexts/AppConfigContext";
import TeamsSection from "@app/components/shared/config/configSections/TeamsSection";
import PeopleSection from "@app/components/shared/config/configSections/PeopleSection";
import {
teamService,
type Team,
type TeamDetailsUIResponse,
} from "@app/services/teamService";
import {
userManagementService,
type AdminSettingsData,
} from "@app/services/userManagementService";
vi.mock("@app/components/toast", () => ({ alert: vi.fn() }));
import { alert } from "@app/components/toast";
import { allowConsole } from "@app/tests/failOnConsole";
vi.mock("react-router-dom", () => ({ useNavigate: () => vi.fn() }));
vi.mock("@app/auth/UseSession", () => ({
useAuth: () => ({ user: { username: "admin" } }),
}));
vi.mock("@app/contexts/LicenseContext", () => ({
useLicense: () => ({ licenseInfo: null }),
}));
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (k: string, f?: unknown) => (typeof f === "string" ? f : k),
}),
Trans: ({ children }: { children?: ReactNode }) => children ?? null,
}));
const calls = { getTeams: 0, getUsers: 0, getTeamDetails: 0 };
let client: QueryClient;
const TEAMS: Team[] = [
{ id: 1, name: "Engineering", userCount: 8 },
{ id: 2, name: "Marketing", userCount: 3 },
];
const ADMIN_DATA: AdminSettingsData = {
users: [
{
id: 1,
username: "alice",
email: "alice@example.com",
enabled: true,
roleName: "ROLE_ADMIN",
rolesAsString: "ROLE_ADMIN",
authenticationType: "password",
},
],
userSessions: {},
userLastRequest: {},
totalUsers: 1,
activeUsers: 1,
disabledUsers: 0,
maxAllowedUsers: 10,
availableSlots: 9,
grandfatheredUserCount: 0,
licenseMaxUsers: 10,
premiumEnabled: true,
mailEnabled: false,
userSettings: {},
lockedUsers: [],
};
const TEAM_DETAILS: TeamDetailsUIResponse = {
team: { id: 1, name: "Engineering" },
teamUsers: [
{
id: 1,
username: "alice",
enabled: true,
roleName: "ROLE_ADMIN",
rolesAsString: "ROLE_ADMIN",
authenticationType: "password",
},
],
availableUsers: [],
userLastRequest: {},
};
let teamsPayload: Team[] = TEAMS;
function stubServices() {
teamService.getTeams = async () => {
calls.getTeams++;
return teamsPayload;
};
teamService.getTeamDetails = async () => {
calls.getTeamDetails++;
return TEAM_DETAILS;
};
userManagementService.getUsers = async () => {
calls.getUsers++;
return ADMIN_DATA;
};
}
function Harness({ children }: { children: ReactNode }) {
return (
<MantineProvider>
<QueryClientProvider client={client}>
<AppConfigProvider
autoFetch={false}
bootstrapMode="non-blocking"
initialConfig={{ enableLogin: true }}
>
{children}
</AppConfigProvider>
</QueryClientProvider>
</MantineProvider>
);
}
function totalRequests() {
return calls.getTeams + calls.getUsers + calls.getTeamDetails;
}
describe("admin directory reads", () => {
beforeEach(() => {
calls.getTeams = 0;
calls.getUsers = 0;
calls.getTeamDetails = 0;
teamsPayload = TEAMS;
// Mirrors the app client, so the stale window under test is the real one.
client = new QueryClient({
defaultOptions: { queries: { ...baseQueryOptions, retry: false } },
});
stubServices();
});
it("costs three requests for teams -> details -> back -> people", async () => {
const user = userEvent.setup();
const teamsView = render(
<Harness>
<TeamsSection />
</Harness>,
);
await screen.findByText("Engineering");
await user.click(screen.getByText("Engineering"));
await waitFor(() => expect(calls.getTeamDetails).toBe(1));
await act(async () => {});
await user.click(screen.getByRole("button", { name: /back/i }));
await screen.findByText("Marketing");
// Config sections are swapped, not stacked: changing tab unmounts one.
teamsView.unmount();
render(
<Harness>
<PeopleSection />
</Harness>,
);
await waitFor(() => expect(calls.getUsers).toBe(1));
await act(async () => {});
// One fetch per distinct resource. The team list is read by all three
// views and the roster by two, so both were previously fetched per view.
expect(calls.getTeams).toBe(1);
expect(calls.getUsers).toBe(1);
expect(calls.getTeamDetails).toBe(1);
expect(totalRequests()).toBe(3);
});
it("shows the team list a write produced, without a manual refresh call", async () => {
const user = userEvent.setup();
render(
<Harness>
<TeamsSection />
</Harness>,
);
await screen.findByText("Engineering");
// The write lands server-side; only an invalidation brings it back.
teamService.createTeam = async () => {
teamsPayload = [...TEAMS, { id: 3, name: "Platform", userCount: 0 }];
};
await user.click(
screen.getByRole("button", { name: "workspace.teams.createNewTeam" }),
);
await user.type(
await screen.findByPlaceholderText(
"workspace.teams.createTeam.teamNamePlaceholder",
),
"Platform",
);
await user.click(
screen.getByRole("button", {
name: "workspace.teams.createTeam.submit",
}),
);
await screen.findByText("Platform");
});
it("makes no request and shows example data when login is disabled", async () => {
render(
<MantineProvider>
<QueryClientProvider client={client}>
<AppConfigProvider
autoFetch={false}
bootstrapMode="non-blocking"
initialConfig={{ enableLogin: false }}
>
<TeamsSection />
</AppConfigProvider>
</QueryClientProvider>
</MantineProvider>,
);
// Example rows, not a spinner: the endpoints are not callable.
await screen.findByText("Internal");
expect(calls.getTeams).toBe(0);
});
it("refreshes the roster after disabling a user's MFA", async () => {
const user = userEvent.setup();
let mfa = "true";
userManagementService.getUsers = async () => {
calls.getUsers++;
return {
...ADMIN_DATA,
userSettings: { alice: { mfaEnabled: mfa } },
};
};
userManagementService.disableMfaByAdmin = async () => {
mfa = "false";
};
render(
<Harness>
<PeopleSection />
</Harness>,
);
await screen.findByText("alice");
await user.click(screen.getByLabelText("Member actions"));
await user.click(await screen.findByText("Disable MFA"));
// The row drove the menu item off itself: it must reflect the new state
// without a reload.
await waitFor(() => expect(calls.getUsers).toBe(2));
});
it("reports the server's refusal message, not a generic one", async () => {
const user = userEvent.setup();
allowConsole.error(/Admin directory write failed/);
teamService.createTeam = async () => {
throw Object.assign(new Error("Request failed"), {
isAxiosError: true,
response: { data: { message: "A team with that name exists" } },
});
};
render(
<Harness>
<TeamsSection />
</Harness>,
);
await screen.findByText("Engineering");
await user.click(
screen.getByRole("button", { name: "workspace.teams.createNewTeam" }),
);
await user.type(
await screen.findByPlaceholderText(
"workspace.teams.createTeam.teamNamePlaceholder",
),
"Engineering",
);
await user.click(
screen.getByRole("button", {
name: "workspace.teams.createTeam.submit",
}),
);
await waitFor(() =>
expect(alert).toHaveBeenCalledWith(
expect.objectContaining({
alertType: "error",
title: "A team with that name exists",
}),
),
);
});
});
@@ -0,0 +1,151 @@
import { useCallback } from "react";
import { isAxiosError } from "axios";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { alert } from "@app/components/toast";
import { qk } from "@app/query/keys";
import {
userManagementService,
type AdminSettingsData,
} from "@app/services/userManagementService";
import {
teamService,
type Team,
type TeamDetailsUIResponse,
} from "@app/services/teamService";
/**
* The people and teams an admin screen reads and writes. Three sections read
* overlapping slices of it, so they share these keys rather than each holding
* a copy, and each write says which slices it invalidates.
*
* `enabled` is the login-enabled gate: with login off the endpoints are not
* callable and the sections render example data instead.
*/
export function useAdminUsers(enabled: boolean) {
return useQuery<AdminSettingsData>({
queryKey: qk.adminUsers(),
queryFn: () => userManagementService.getUsers(),
enabled,
});
}
export function useTeams(enabled: boolean) {
return useQuery<Team[]>({
queryKey: qk.teams(),
queryFn: () => teamService.getTeams(),
enabled,
});
}
export function useTeamDetails(teamId: number, enabled: boolean) {
return useQuery<TeamDetailsUIResponse>({
queryKey: qk.teamDetails(teamId),
queryFn: () => teamService.getTeamDetails(teamId),
enabled,
});
}
/**
* Imperative read, for flows that fetch before opening a modal. Serves the
* same cache entry the sections render from, so an already-loaded directory
* costs nothing.
*/
export function useFetchAdminUsers() {
const queryClient = useQueryClient();
return useCallback(
() =>
queryClient.fetchQuery<AdminSettingsData>({
queryKey: qk.adminUsers(),
queryFn: () => userManagementService.getUsers(),
}),
[queryClient],
);
}
/** Which slices of the directory a write disturbs. */
export type DirectoryScope = "users" | "teams" | "teamDetails";
const SCOPE_KEYS: Record<DirectoryScope, readonly unknown[]> = {
users: qk.adminUsers(),
teams: qk.teams(),
// Prefix, not one id: a membership move changes two teams' detail rows.
teamDetails: ["editor", "teamDetails"],
};
/**
* Blanket invalidation, for child components that write through their own
* services (invites, password changes, seat updates). The scopes those touch
* are not visible from here, so they refresh everything.
*/
export function useInvalidateAdminDirectory() {
const invalidate = useInvalidateScopes();
return useCallback(
() => invalidate(["users", "teams", "teamDetails"]),
[invalidate],
);
}
function useInvalidateScopes() {
const queryClient = useQueryClient();
return useCallback(
(scopes: readonly DirectoryScope[]) => {
for (const scope of scopes) {
queryClient.invalidateQueries({ queryKey: SCOPE_KEYS[scope] });
}
},
[queryClient],
);
}
/** The server's message if it sent one, since it explains the refusal. */
export function adminErrorMessage(error: unknown, fallback: string): string {
if (isAxiosError(error)) {
return (
error.response?.data?.message ||
error.response?.data?.error ||
error.message ||
fallback
);
}
return (error instanceof Error ? error.message : undefined) || fallback;
}
interface AdminMutationOptions<TArgs> {
write: (args: TArgs) => Promise<unknown>;
invalidates: readonly DirectoryScope[];
success: string;
errorFallback: string;
/** Local state to clear once the write lands, such as closing its modal. */
onDone?: () => void;
}
/**
* One directory write: toasts the outcome, refreshes the slices it changed,
* and exposes `isPending` for the button that triggered it. All thirteen
* call sites did this by hand, and one of them forgot the refresh.
*/
export function useAdminMutation<TArgs = void>({
write,
invalidates,
success,
errorFallback,
onDone,
}: AdminMutationOptions<TArgs>) {
const invalidate = useInvalidateScopes();
return useMutation({
mutationFn: write,
onSuccess: () => {
alert({ alertType: "success", title: success });
invalidate(invalidates);
onDone?.();
},
onError: (error) => {
// The toast carries the server's wording; the console keeps the cause.
console.error("Admin directory write failed:", error);
alert({
alertType: "error",
title: adminErrorMessage(error, errorFallback),
});
},
});
}