Skip to content

Commit cabcf19

Browse files
committed
feat(dashboard): implement paginated teams listing and enhance permission handling
- Introduced pagination for teams in the dashboard, allowing for efficient data retrieval and display. - Updated permission definitions to include cursor-based pagination, improving user experience when navigating through permissions. - Refactored various components to utilize the new paginated API, ensuring consistency across the application. - Added error handling for pagination and improved loading states in user session replays and team member tables. - Enhanced the data grid to support URL-synced state for column widths and visibility, improving user customization options. This update significantly enhances the dashboard's performance and usability, particularly for users managing large teams and permissions.
1 parent 2adafbc commit cabcf19

18 files changed

Lines changed: 340 additions & 70 deletions

File tree

apps/backend/prisma/schema.prisma

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,9 @@ model ProjectUser {
333333
@@index([tenancyId, createdAt(sort: Asc)], name: "ProjectUser_createdAt_asc")
334334
@@index([tenancyId, createdAt(sort: Desc)], name: "ProjectUser_createdAt_desc")
335335
@@index([tenancyId, isAnonymous, signedUpAt(sort: Asc)], name: "ProjectUser_signedUpAt_asc")
336+
// ASC + DESC pair for lastActiveAt to match the createdAt/displayName
337+
// convention above. signedUpAt remains ASC-only (legacy); revisit if its
338+
// descending queries ever show up in slow-query logs.
336339
@@index([tenancyId, isAnonymous, lastActiveAt(sort: Asc)], name: "ProjectUser_lastActiveAt_asc")
337340
@@index([tenancyId, isAnonymous, lastActiveAt(sort: Desc)], name: "ProjectUser_lastActiveAt_desc")
338341
@@index([tenancyId, isAnonymous, signUpIp, signedUpAt], name: "ProjectUser_signUpIp_recent_idx")

apps/backend/src/app/api/latest/permission-definitions-pagination.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { KnownErrors } from "@stackframe/stack-shared";
2+
13
type PermissionDefinition = {
24
id: string,
35
description?: string,
@@ -15,6 +17,12 @@ type ListQuery = {
1517
* paginating them means filtering and slicing the in-memory list returned by
1618
* `listPermissionDefinitions`. The list is already sorted by id, which makes
1719
* the id a stable cursor.
20+
*
21+
* Cursor convention: `cursor` is the id of the last item returned on the
22+
* previous page; the next page starts immediately after it. If the cursor
23+
* isn't present in the filtered list (e.g. the caller's `query` filter
24+
* changed across pages) we throw rather than silently returning an empty
25+
* page — that way the caller learns to reset their pagination state.
1826
*/
1927
export function paginatePermissionDefinitions(items: PermissionDefinition[], query: ListQuery) {
2028
const search = query.query?.trim().toLowerCase();
@@ -28,9 +36,14 @@ export function paginatePermissionDefinitions(items: PermissionDefinition[], que
2836
return { items: filtered, is_paginated: false as const };
2937
}
3038

31-
const startIdx = query.cursor
32-
? filtered.findIndex((p) => p.id === query.cursor) + 1 || filtered.length
33-
: 0;
39+
let startIdx = 0;
40+
if (query.cursor) {
41+
const cursorIdx = filtered.findIndex((p) => p.id === query.cursor);
42+
if (cursorIdx === -1) {
43+
throw new KnownErrors.ItemNotFound(query.cursor);
44+
}
45+
startIdx = cursorIdx + 1;
46+
}
3447
const slice = filtered.slice(startIdx, startIdx + query.limit);
3548
const hasMore = startIdx + query.limit < filtered.length;
3649

apps/backend/src/app/api/latest/teams/crud.tsx

Lines changed: 28 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -280,8 +280,28 @@ export const teamsCrudHandlers = createLazyProxy(() => createCrudHandlers(teamsC
280280
}
281281

282282
const prisma = await getPrismaClientForTenancy(auth.tenancy);
283-
const queryWithoutSpecialChars = query.query?.replace(/[^a-zA-Z0-9\-_.]/g, '');
284283
const sortDirection = query.desc === 'true' ? 'desc' : 'asc';
284+
285+
let queryFilter: Prisma.TeamWhereInput | undefined;
286+
if (query.query) {
287+
const sanitized = query.query.replace(/[^a-zA-Z0-9\-_.]/g, '');
288+
queryFilter = {
289+
OR: [
290+
...isUuid(sanitized) ? [{
291+
teamId: {
292+
equals: sanitized,
293+
},
294+
}] : [],
295+
{
296+
displayName: {
297+
contains: query.query,
298+
mode: 'insensitive' as const,
299+
},
300+
},
301+
],
302+
};
303+
}
304+
285305
const db = await prisma.team.findMany({
286306
where: {
287307
tenancyId: auth.tenancy.id,
@@ -292,28 +312,18 @@ export const teamsCrudHandlers = createLazyProxy(() => createCrudHandlers(teamsC
292312
},
293313
},
294314
} : {},
295-
...query.query ? {
296-
OR: [
297-
...isUuid(queryWithoutSpecialChars!) ? [{
298-
teamId: {
299-
equals: queryWithoutSpecialChars,
300-
},
301-
}] : [],
302-
{
303-
displayName: {
304-
contains: query.query,
305-
mode: 'insensitive' as const,
306-
},
307-
},
308-
],
309-
} : {},
315+
...queryFilter ?? {},
310316
},
311317
orderBy: [
312318
{ createdAt: sortDirection },
313319
{ teamId: sortDirection },
314320
],
315321
take: query.limit ? query.limit + 1 : undefined,
322+
// Cursor convention: `cursor` is the id of the last item returned to
323+
// the caller on the previous page. Prisma's cursor is inclusive, so we
324+
// must `skip: 1` to exclude that row from the new page.
316325
...query.cursor ? {
326+
skip: 1,
317327
cursor: {
318328
tenancyId_teamId: {
319329
tenancyId: auth.tenancy.id,
@@ -325,11 +335,12 @@ export const teamsCrudHandlers = createLazyProxy(() => createCrudHandlers(teamsC
325335

326336
if (query.limit) {
327337
const items = db.slice(0, query.limit).map(teamPrismaToCrud);
338+
const hasMore = db.length > query.limit;
328339
return {
329340
items,
330341
is_paginated: true,
331342
pagination: {
332-
next_cursor: db.length >= query.limit + 1 ? db[db.length - 1].teamId : null,
343+
next_cursor: hasMore && items.length > 0 ? items[items.length - 1].id : null,
333344
},
334345
};
335346
}

apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/teams/page-client.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ type CreateDialogProps = {
1616

1717
export default function PageClient() {
1818
const stackAdminApp = useAdminApp();
19-
const teams = stackAdminApp.useTeams({ limit: 1 });
19+
const teams = stackAdminApp.useTeamsPaginated({ limit: 1 });
2020
const project = stackAdminApp.useProject();
2121

2222
const [createTeamsOpen, setCreateTeamsOpen] = React.useState(false);

apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/[userId]/page-client.tsx

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -202,9 +202,9 @@ function RestrictionDialog({
202202
open: boolean,
203203
onOpenChange: (open: boolean) => void,
204204
}) {
205-
const restrictedByAdmin = (user as any).restrictedByAdmin ?? false;
206-
const restrictedByAdminReason = (user as any).restrictedByAdminReason ?? null;
207-
const restrictedByAdminPrivateDetails = (user as any).restrictedByAdminPrivateDetails ?? null;
205+
const restrictedByAdmin = user.restrictedByAdmin;
206+
const restrictedByAdminReason = user.restrictedByAdminReason;
207+
const restrictedByAdminPrivateDetails = user.restrictedByAdminPrivateDetails;
208208

209209
const [publicReason, setPublicReason] = useState(restrictedByAdminReason ?? '');
210210
const [privateDetails, setPrivateDetails] = useState(restrictedByAdminPrivateDetails ?? '');
@@ -227,7 +227,7 @@ function RestrictionDialog({
227227

228228
setIsSaving(true);
229229
try {
230-
await user.update({ restrictedByAdmin: true, restrictedByAdminReason: publicReason.trim() || null, restrictedByAdminPrivateDetails: privateDetails.trim() || null } as any);
230+
await user.update({ restrictedByAdmin: true, restrictedByAdminReason: publicReason.trim() || null, restrictedByAdminPrivateDetails: privateDetails.trim() || null });
231231
onOpenChange(false);
232232
} catch (error) {
233233
captureError(`user-restriction-save-and-restrict-error`, new StackAssertionError(`Failed to save and restrict user ${user.id}`, { cause: error }));
@@ -243,7 +243,7 @@ function RestrictionDialog({
243243
restrictedByAdmin: false,
244244
restrictedByAdminReason: null,
245245
restrictedByAdminPrivateDetails: null,
246-
} as any);
246+
});
247247
onOpenChange(false);
248248
} finally {
249249
setIsSaving(false);
@@ -316,9 +316,9 @@ function RestrictionDialog({
316316
function RestrictionBanner({ user }: { user: ServerUser }) {
317317
if (!user.isRestricted) return null;
318318

319-
const restrictedByAdmin = (user as any).restrictedByAdmin ?? false;
320-
const restrictedByAdminReason = (user as any).restrictedByAdminReason ?? null;
321-
const restrictedByAdminPrivateDetails = (user as any).restrictedByAdminPrivateDetails ?? null;
319+
const restrictedByAdmin = user.restrictedByAdmin;
320+
const restrictedByAdminReason = user.restrictedByAdminReason;
321+
const restrictedByAdminPrivateDetails = user.restrictedByAdminPrivateDetails;
322322
const reasonText = getRestrictionReasonText(user);
323323

324324
return (

apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/[userId]/user-page-table-section.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ type UserPageTableSectionProps<TRow> = {
1616
onLoadMore?: () => void,
1717
onSortChange?: (model: DataGridSortModel) => void,
1818
paginated?: boolean,
19+
/** True until the first request settles. When true and rows is empty, show a loading state instead of "empty". */
20+
isInitialLoading?: boolean,
21+
/** Non-null when the latest fetch failed. Rendered in place of empty/loading state. */
22+
error?: ReactNode | null,
1923
};
2024

2125
export function UserPageTableSection<TRow,>({
@@ -31,6 +35,8 @@ export function UserPageTableSection<TRow,>({
3135
onLoadMore,
3236
onSortChange,
3337
paginated,
38+
isInitialLoading,
39+
error,
3440
}: UserPageTableSectionProps<TRow>) {
3541
const [gridState, setGridState] = useState(() => createDefaultDataGridState(columns));
3642
const gridData = useDataSource({
@@ -81,7 +87,11 @@ export function UserPageTableSection<TRow,>({
8187
))}
8288
</div>
8389
<div className="flex min-h-16 items-center justify-center py-4 text-sm font-medium text-muted-foreground">
84-
{emptyLabel}
90+
{error
91+
? error
92+
: isInitialLoading
93+
? "Loading…"
94+
: emptyLabel}
8595
</div>
8696
</div>
8797
) : (

apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/[userId]/user-session-replays.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ export function UserSessionReplaysSection({ user }: { user: ServerUser }) {
4242
const [nextCursor, setNextCursor] = useState<string | null>(null);
4343
const [hasMore, setHasMore] = useState(false);
4444
const [isLoadingMore, setIsLoadingMore] = useState(false);
45+
const [isInitialLoading, setIsInitialLoading] = useState(true);
46+
const [error, setError] = useState<string | null>(null);
4547
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("desc");
4648
const [searchInput, setSearchInput] = useState("");
4749
const [searchQuery, setSearchQuery] = useState("");
@@ -78,7 +80,14 @@ export function UserSessionReplaysSection({ user }: { user: ServerUser }) {
7880
setRows((prev) => (cursor ? [...prev, ...res.items] : res.items));
7981
setNextCursor(res.nextCursor);
8082
setHasMore(res.nextCursor !== null);
83+
setError(null);
84+
} catch (e) {
85+
if (reqId !== requestIdRef.current) return;
86+
setError(e instanceof Error ? e.message : "Failed to load session replays");
8187
} finally {
88+
if (reqId === requestIdRef.current && cursor === null) {
89+
setIsInitialLoading(false);
90+
}
8291
if (cursor !== null) {
8392
loadingMoreRef.current = false;
8493
setIsLoadingMore(false);
@@ -91,6 +100,8 @@ export function UserSessionReplaysSection({ user }: { user: ServerUser }) {
91100
setRows([]);
92101
setNextCursor(null);
93102
setHasMore(false);
103+
setIsInitialLoading(true);
104+
setError(null);
94105
runAsynchronously(() => fetchPage(null), { noErrorLogging: true });
95106
}, [fetchPage]);
96107

@@ -160,6 +171,8 @@ export function UserSessionReplaysSection({ user }: { user: ServerUser }) {
160171
rows={rows}
161172
getRowId={(replay) => replay.id}
162173
emptyLabel="No session replays for this user"
174+
isInitialLoading={isInitialLoading}
175+
error={error}
163176
onRowClick={(row) => navigateToReplay(row.id)}
164177
onSortChange={onSortChange}
165178
hasMore={hasMore}

apps/dashboard/src/components/data-table/team-member-table.tsx

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import {
2424
type DataGridState,
2525
} from "@stackframe/dashboard-ui-components";
2626
import { CheckCircleIcon, CopyIcon, XCircleIcon } from "@phosphor-icons/react";
27-
import { useCallback, useEffect, useMemo, useState } from "react";
27+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2828
import { useDebounce } from "use-debounce";
2929
import * as yup from "yup";
3030
import { Link } from "../link";
@@ -212,7 +212,10 @@ function EditPermissionDialog(props: {
212212
return await props.user.revokePermission(props.team, p.id);
213213
}
214214
});
215-
await Promise.allSettled(promises);
215+
// Use Promise.all so a single failed grant/revoke aborts and the dialog
216+
// stays open — otherwise users see "saved" while only some permissions
217+
// were applied.
218+
await Promise.all(promises);
216219
props.onSubmit();
217220
}}
218221
cancelButton
@@ -261,10 +264,32 @@ function Actions(props: {
261264
);
262265
}
263266

267+
const PERMISSION_FETCH_TIMEOUT_MS = 10_000;
268+
269+
function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
270+
return new Promise<T>((resolve, reject) => {
271+
const timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
272+
promise.then(
273+
(v) => {
274+
clearTimeout(timer);
275+
resolve(v);
276+
},
277+
(e) => {
278+
clearTimeout(timer);
279+
reject(e);
280+
},
281+
);
282+
});
283+
}
284+
264285
export function TeamMemberTable(props: { team: ServerTeam }) {
265286
const stackAdminApp = useAdminApp();
266287
const [updateCounter, setUpdateCounter] = useState(0);
267288
const [permissions, setPermissions] = useState<Map<string, string[]>>(new Map());
289+
// Bumped each time we kick off a new generator request; setPermissions
290+
// calls from older requests no-op so that a slow earlier fetch can't
291+
// clobber the state of a fresh one.
292+
const permissionRequestIdRef = useRef(0);
268293

269294
const teamMemberColumns = useMemo<DataGridColumnDef<ExtendedServerUserForTeam>[]>(() => [
270295
{
@@ -366,6 +391,7 @@ export function TeamMemberTable(props: { team: ServerTeam }) {
366391

367392
const dataSource = useMemo<DataGridDataSource<ExtendedServerUserForTeam>>(
368393
() => async function* (params) {
394+
const reqId = ++permissionRequestIdRef.current;
369395
const activeSort = params.sorting.find((s) => s.columnId === "lastActiveAt");
370396
const sortDesc = activeSort?.direction !== "asc";
371397
const cursor = typeof params.cursor === "string" ? params.cursor : undefined;
@@ -383,12 +409,20 @@ export function TeamMemberTable(props: { team: ServerTeam }) {
383409
includeRestricted: true,
384410
});
385411
const extended = extendUsers(result);
412+
// Bound each per-user permission fetch so a single slow user can't
413+
// hang the whole grid page indefinitely.
386414
const permissionResults = await Promise.all(
387415
extended.map(async (user) => {
388-
const perms = await user.listPermissions(props.team, { recursive: false });
416+
const perms = await withTimeout(
417+
user.listPermissions(props.team, { recursive: false }),
418+
PERMISSION_FETCH_TIMEOUT_MS,
419+
`listPermissions(${user.id})`,
420+
);
389421
return [user.id, perms.map(p => p.id)] as const;
390422
})
391423
);
424+
// Drop the result if a newer request started while we were fetching.
425+
if (reqId !== permissionRequestIdRef.current) return;
392426
setPermissions((prev) => {
393427
const next = new Map(prev);
394428
for (const [id, perms] of permissionResults) next.set(id, perms);

apps/dashboard/src/components/data-table/team-table.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,7 @@ export function TeamTable() {
169169
const search = typeof params.quickSearch === "string" && params.quickSearch.trim().length > 0
170170
? params.quickSearch.trim()
171171
: undefined;
172-
const result = await stackAdminApp.listTeams({
172+
const result = await stackAdminApp.listTeamsPaginated({
173173
limit: PAGE_SIZE,
174174
orderBy: "createdAt",
175175
desc: sortDesc,

apps/e2e/tests/backend/endpoints/api/v1/teams.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,45 @@ it("lists all the teams the current user has on the server", async ({ expect })
8383
`);
8484
});
8585

86+
it("paginates teams across two pages without duplicates or skips", async ({ expect }) => {
87+
await Project.createAndSwitch();
88+
89+
// Create 5 teams. Use a stable, ordered display name so we can assert the
90+
// sequence after pagination.
91+
const teamCount = 5;
92+
for (let i = 0; i < teamCount; i++) {
93+
const createResponse = await niceBackendFetch("/api/v1/teams", {
94+
accessType: "server",
95+
method: "POST",
96+
body: { display_name: `Pagination team ${i.toString().padStart(2, "0")}` },
97+
});
98+
expect(createResponse.status).toBe(201);
99+
}
100+
101+
const limit = 3;
102+
const page1 = await niceBackendFetch(`/api/v1/teams?limit=${limit}`, { accessType: "server" });
103+
expect(page1.status).toBe(200);
104+
expect(page1.body.is_paginated).toBe(true);
105+
expect(page1.body.items).toHaveLength(limit);
106+
const cursor = page1.body.pagination?.next_cursor;
107+
expect(cursor).toEqual(expect.any(String));
108+
109+
// Cursor should be the id of the last item we received, not a peek-ahead.
110+
expect(cursor).toBe(page1.body.items[limit - 1].id);
111+
112+
const page2 = await niceBackendFetch(`/api/v1/teams?limit=${limit}&cursor=${encodeURIComponent(cursor)}`, { accessType: "server" });
113+
expect(page2.status).toBe(200);
114+
expect(page2.body.items.length).toBe(teamCount - limit);
115+
116+
const page1Ids = new Set(page1.body.items.map((t: any) => t.id));
117+
const page2Ids = page2.body.items.map((t: any) => t.id);
118+
for (const id of page2Ids) {
119+
expect(page1Ids.has(id)).toBe(false); // no duplicates across pages
120+
}
121+
expect(page1.body.items.length + page2.body.items.length).toBe(teamCount); // no skips
122+
expect(page2.body.pagination?.next_cursor ?? null).toBeNull();
123+
});
124+
86125
it("creates a team on the client", async ({ expect }) => {
87126
await Auth.fastSignUp();
88127
await Team.createWithCurrentAsCreator();

0 commit comments

Comments
 (0)