Skip to content

Commit 2adafbc

Browse files
committed
refactor(dashboard): data-grid overhaul + session-replays / team-payments surfaces
- Rewrite data-grid with URL-synced state, new sizing logic, tests - Move analytics/replays → session-replays; add per-user session replays card - Add team-analytics and team-payments to team detail page - Add project_user.last_active_at index + permission-definitions pagination - Various editable-input / inline-save-discard / settings polish
1 parent 227dac6 commit 2adafbc

91 files changed

Lines changed: 5644 additions & 1858 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

AGENTS.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,8 @@ To see all development ports, refer to the index.html of `apps/dev-launchpad/pub
9595
- When building internal tools for Stack Auth developers (eg. internal interfaces like the WAL info log etc.): Make the interfaces look very concise, assume the user is a pro-user. This only applies to internal tools that are used primarily by Stack Auth developers.
9696
- The dev server already builds the packages in the background whenever you update a file. If you run into issues with typechecking or linting in a dependency after updating something in a package, just wait a few seconds, and then try again, and they will likely be resolved.
9797
- When asked to review PR comments, you can use `gh pr status` to get the current pull request you're working on.
98-
- NEVER EVER AUTOMATICALLY COMMIT OR STAGE ANY CHANGES — DON'T MODIFY GIT WITHOUT USER CONSENT!
98+
- NEVER EVER AUTOMATICALLY COMMIT OR STAGE ANY CHANGES — DON'T MODIFY GIT WITHOUT USER CONSENT! if its already staged and you didnt do it then dont unstage it.
99+
- NEVER run destructive or working-tree-mutating git operations without EXPLICIT user permission in the current turn. This includes (non-exhaustive): `git stash` / `stash pop` / `stash drop` / `stash clear`, `git reset` (any flag), `git checkout -- <path>` / `git restore`, `git clean`, `git rebase`, `git revert`, `git commit --amend`, `git push --force` / `--force-with-lease`, `git branch -D`, `git tag -d`, `git filter-branch`, `git update-ref`. Read-only inspection (`git status`, `git diff`, `git log`, `git blame`, `git show`, `git show HEAD:path`) is fine. If you think a destructive op is the right move, describe it and wait for a yes — do NOT run it to "verify" or "clean up".
99100
- When building frontend or React code for the dashboard, refer to DESIGN-GUIDE.md.
100101
- NEVER implement a hacky solution without EXPLICIT approval from the user. Always go the extra mile to make sure the solution is clean, maintainable, and robust.
101102
- Fail early, fail loud. Fail fast with an error instead of silently continuing.

apps/backend/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
"with-env:prod": "dotenv -c production --",
1313
"with-env:test": "dotenv -c test --",
1414
"dev": "BACKEND_PORT=${STACK_DEV_FALLBACK_BACKEND:+${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}10} && BACKEND_PORT=${BACKEND_PORT:-${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}02} && concurrently -n \"dev,codegen,prisma-studio,email-queue,cron-jobs,bulldozer-studio\" -k \"STACK_DISABLE_REACT_ASYNC_DEBUG_INFO=${STACK_DISABLE_REACT_ASYNC_DEBUG_INFO:-true} next dev --port $BACKEND_PORT ${STACK_BACKEND_DEV_EXTRA_ARGS:-}\" \"pnpm run codegen:watch\" \"pnpm run prisma-studio\" \"pnpm run run-email-queue\" \"pnpm run run-cron-jobs\" \"pnpm run run-bulldozer-studio\"",
15+
"dev:tui": "pnpm run dev",
1516
"dev:inspect": "STACK_BACKEND_DEV_EXTRA_ARGS=\"--inspect\" pnpm run dev",
1617
"dev:profile": "STACK_BACKEND_DEV_EXTRA_ARGS=\"--experimental-cpu-prof\" pnpm run dev",
1718
"build": "pnpm run codegen && next build",
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
-- SPLIT_STATEMENT_SENTINEL
2+
-- SINGLE_STATEMENT_SENTINEL
3+
-- RUN_OUTSIDE_TRANSACTION_SENTINEL
4+
CREATE INDEX CONCURRENTLY IF NOT EXISTS "ProjectUser_lastActiveAt_asc"
5+
ON "ProjectUser"("tenancyId", "isAnonymous", "lastActiveAt" ASC);
6+
7+
-- SPLIT_STATEMENT_SENTINEL
8+
-- SINGLE_STATEMENT_SENTINEL
9+
-- RUN_OUTSIDE_TRANSACTION_SENTINEL
10+
CREATE INDEX CONCURRENTLY IF NOT EXISTS "ProjectUser_lastActiveAt_desc"
11+
ON "ProjectUser"("tenancyId", "isAnonymous", "lastActiveAt" DESC);

apps/backend/prisma/schema.prisma

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,8 @@ 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+
@@index([tenancyId, isAnonymous, lastActiveAt(sort: Asc)], name: "ProjectUser_lastActiveAt_asc")
337+
@@index([tenancyId, isAnonymous, lastActiveAt(sort: Desc)], name: "ProjectUser_lastActiveAt_desc")
336338
@@index([tenancyId, isAnonymous, signUpIp, signedUpAt], name: "ProjectUser_signUpIp_recent_idx")
337339
@@index([tenancyId, isAnonymous, signUpEmailNormalized, signedUpAt], name: "ProjectUser_signUpEmailNormalized_recent_idx")
338340
@@index([tenancyId, isAnonymous, signUpEmailBase, signedUpAt], name: "ProjectUser_signUpEmailBase_recent_idx")

apps/backend/src/app/api/latest/internal/session-replays/route.tsx

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,8 @@ export const GET = createSmartRouteHandler({
100100
last_event_at_from_millis: yupString().optional(),
101101
last_event_at_to_millis: yupString().optional(),
102102
click_count_min: yupString().optional(),
103+
sort_direction: yupString().oneOf(["asc", "desc"]).optional(),
104+
q: yupString().optional(),
103105
}).optional(),
104106
}),
105107
response: yupObject({
@@ -138,6 +140,8 @@ export const GET = createSmartRouteHandler({
138140
const clickCountMin = parseNonNegativeInt("click_count_min", query.click_count_min);
139141
const lastEventAtFrom = parseMillis("last_event_at_from_millis", query.last_event_at_from_millis);
140142
const lastEventAtTo = parseMillis("last_event_at_to_millis", query.last_event_at_to_millis);
143+
const sortDirection: "asc" | "desc" = query.sort_direction === "asc" ? "asc" : "desc";
144+
const searchQuery = query.q?.trim() || null;
141145

142146
if (durationMsMin !== null && durationMsMax !== null && durationMsMin > durationMsMax) {
143147
throw new StatusError(StatusError.BadRequest, "duration_ms_min must be less than or equal to duration_ms_max");
@@ -176,6 +180,24 @@ export const GET = createSmartRouteHandler({
176180
}
177181
}
178182

183+
const cursorComparator = sortDirection === "asc"
184+
? cursorPivot
185+
? Prisma.sql`AND (
186+
sr."lastEventAt" > ${cursorPivot.lastEventAt}
187+
OR (sr."lastEventAt" = ${cursorPivot.lastEventAt} AND sr."id" > ${cursorId})
188+
)`
189+
: Prisma.empty
190+
: cursorPivot
191+
? Prisma.sql`AND (
192+
sr."lastEventAt" < ${cursorPivot.lastEventAt}
193+
OR (sr."lastEventAt" = ${cursorPivot.lastEventAt} AND sr."id" < ${cursorId})
194+
)`
195+
: Prisma.empty;
196+
197+
const orderBySql = sortDirection === "asc"
198+
? Prisma.sql`ORDER BY sr."lastEventAt" ASC, sr."id" ASC`
199+
: Prisma.sql`ORDER BY sr."lastEventAt" DESC, sr."id" DESC`;
200+
179201
const suffixSql = Prisma.sql`
180202
${userIdsFilter.length > 0 ? Prisma.sql`AND sr."projectUserId" IN (${Prisma.join(userIdsFilter)})` : Prisma.empty}
181203
${lastEventAtFrom ? Prisma.sql`AND sr."lastEventAt" >= ${lastEventAtFrom}` : Prisma.empty}
@@ -189,11 +211,12 @@ export const GET = createSmartRouteHandler({
189211
${clickQualifiedIds ? Prisma.sql`AND sr."id" IN (${Prisma.join(clickQualifiedIds)})` : Prisma.empty}
190212
${durationMsMin !== null ? Prisma.sql`AND EXTRACT(EPOCH FROM (sr."lastEventAt" - sr."startedAt")) * 1000 >= ${durationMsMin}` : Prisma.empty}
191213
${durationMsMax !== null ? Prisma.sql`AND EXTRACT(EPOCH FROM (sr."lastEventAt" - sr."startedAt")) * 1000 <= ${durationMsMax}` : Prisma.empty}
192-
${cursorPivot ? Prisma.sql`AND (
193-
sr."lastEventAt" < ${cursorPivot.lastEventAt}
194-
OR (sr."lastEventAt" = ${cursorPivot.lastEventAt} AND sr."id" < ${cursorId})
214+
${searchQuery ? Prisma.sql`AND (
215+
sr."id"::text ILIKE ${`%${searchQuery}%`}
216+
OR pu."displayName" ILIKE ${`%${searchQuery}%`}
195217
)` : Prisma.empty}
196-
ORDER BY sr."lastEventAt" DESC, sr."id" DESC
218+
${cursorComparator}
219+
${orderBySql}
197220
LIMIT ${limit + 1}
198221
`;
199222

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
type PermissionDefinition = {
2+
id: string,
3+
description?: string,
4+
contained_permission_ids: string[],
5+
};
6+
7+
type ListQuery = {
8+
limit?: number,
9+
cursor?: string,
10+
query?: string,
11+
};
12+
13+
/**
14+
* Permission definitions live in tenancy config rather than a DB table, so
15+
* paginating them means filtering and slicing the in-memory list returned by
16+
* `listPermissionDefinitions`. The list is already sorted by id, which makes
17+
* the id a stable cursor.
18+
*/
19+
export function paginatePermissionDefinitions(items: PermissionDefinition[], query: ListQuery) {
20+
const search = query.query?.trim().toLowerCase();
21+
const filtered = search
22+
? items.filter((p) =>
23+
p.id.toLowerCase().includes(search)
24+
|| (p.description?.toLowerCase().includes(search) ?? false))
25+
: items;
26+
27+
if (query.limit === undefined) {
28+
return { items: filtered, is_paginated: false as const };
29+
}
30+
31+
const startIdx = query.cursor
32+
? filtered.findIndex((p) => p.id === query.cursor) + 1 || filtered.length
33+
: 0;
34+
const slice = filtered.slice(startIdx, startIdx + query.limit);
35+
const hasMore = startIdx + query.limit < filtered.length;
36+
37+
return {
38+
items: slice,
39+
is_paginated: true as const,
40+
pagination: {
41+
next_cursor: hasMore && slice.length > 0 ? slice[slice.length - 1].id : null,
42+
},
43+
};
44+
}

apps/backend/src/app/api/latest/project-permission-definitions/crud.tsx

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,20 @@ import { createPermissionDefinition, deletePermissionDefinition, listPermissionD
22
import { getPrismaClientForTenancy, globalPrismaClient } from "@/prisma-client";
33
import { createCrudHandlers } from "@/route-handlers/crud-handler";
44
import { projectPermissionDefinitionsCrud } from '@stackframe/stack-shared/dist/interface/crud/project-permissions';
5-
import { permissionDefinitionIdSchema, yupObject } from "@stackframe/stack-shared/dist/schema-fields";
5+
import { permissionDefinitionIdSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
66
import { createLazyProxy } from "@stackframe/stack-shared/dist/utils/proxies";
7+
import { paginatePermissionDefinitions } from "../permission-definitions-pagination";
78

89

910
export const projectPermissionDefinitionsCrudHandlers = createLazyProxy(() => createCrudHandlers(projectPermissionDefinitionsCrud, {
1011
paramsSchema: yupObject({
1112
permission_id: permissionDefinitionIdSchema.defined(),
1213
}),
14+
querySchema: yupObject({
15+
limit: yupNumber().integer().min(1).optional().meta({ openapiField: { onlyShowInOperations: ['List'], description: "Maximum number of items to return. When set, the response is paginated via cursor." } }),
16+
cursor: yupString().optional().meta({ openapiField: { onlyShowInOperations: ['List'], description: "Cursor (permission id) to start the next page from." } }),
17+
query: yupString().optional().meta({ openapiField: { onlyShowInOperations: ['List'], description: "Free-text filter applied to permission id and description (case-insensitive)." } }),
18+
}),
1319
async onCreate({ auth, data }) {
1420
return await createPermissionDefinition(
1521
globalPrismaClient,
@@ -45,13 +51,11 @@ export const projectPermissionDefinitionsCrudHandlers = createLazyProxy(() => cr
4551
}
4652
);
4753
},
48-
async onList({ auth }) {
49-
return {
50-
items: await listPermissionDefinitions({
51-
scope: "project",
52-
tenancy: auth.tenancy,
53-
}),
54-
is_paginated: false,
55-
};
54+
async onList({ auth, query }) {
55+
const all = await listPermissionDefinitions({
56+
scope: "project",
57+
tenancy: auth.tenancy,
58+
});
59+
return paginatePermissionDefinitions(all, query);
5660
},
5761
}));

apps/backend/src/app/api/latest/team-permission-definitions/crud.tsx

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,19 @@ import { createPermissionDefinition, deletePermissionDefinition, listPermissionD
22
import { getPrismaClientForTenancy, globalPrismaClient } from "@/prisma-client";
33
import { createCrudHandlers } from "@/route-handlers/crud-handler";
44
import { teamPermissionDefinitionsCrud } from '@stackframe/stack-shared/dist/interface/crud/team-permissions';
5-
import { permissionDefinitionIdSchema, yupObject } from "@stackframe/stack-shared/dist/schema-fields";
5+
import { permissionDefinitionIdSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
66
import { createLazyProxy } from "@stackframe/stack-shared/dist/utils/proxies";
7+
import { paginatePermissionDefinitions } from "../permission-definitions-pagination";
78

89
export const teamPermissionDefinitionsCrudHandlers = createLazyProxy(() => createCrudHandlers(teamPermissionDefinitionsCrud, {
910
paramsSchema: yupObject({
1011
permission_id: permissionDefinitionIdSchema.defined(),
1112
}),
13+
querySchema: yupObject({
14+
limit: yupNumber().integer().min(1).optional().meta({ openapiField: { onlyShowInOperations: ['List'], description: "Maximum number of items to return. When set, the response is paginated via cursor." } }),
15+
cursor: yupString().optional().meta({ openapiField: { onlyShowInOperations: ['List'], description: "Cursor (permission id) to start the next page from." } }),
16+
query: yupString().optional().meta({ openapiField: { onlyShowInOperations: ['List'], description: "Free-text filter applied to permission id and description (case-insensitive)." } }),
17+
}),
1218
async onCreate({ auth, data }) {
1319
return await createPermissionDefinition(
1420
globalPrismaClient,
@@ -48,13 +54,11 @@ export const teamPermissionDefinitionsCrudHandlers = createLazyProxy(() => creat
4854
}
4955
);
5056
},
51-
async onList({ auth }) {
52-
return {
53-
items: await listPermissionDefinitions({
54-
scope: "team",
55-
tenancy: auth.tenancy,
56-
}),
57-
is_paginated: false,
58-
};
57+
async onList({ auth, query }) {
58+
const all = await listPermissionDefinitions({
59+
scope: "team",
60+
tenancy: auth.tenancy,
61+
});
62+
return paginatePermissionDefinitions(all, query);
5963
},
6064
}));

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

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,12 @@ import { runAsynchronouslyAndWaitUntil } from "@/utils/background-tasks";
1010
import { Prisma, PurchaseCreationSource } from "@/generated/prisma/client";
1111
import { KnownErrors } from "@stackframe/stack-shared";
1212
import { teamsCrud } from "@stackframe/stack-shared/dist/interface/crud/teams";
13-
import { userIdOrMeSchema, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
13+
import { userIdOrMeSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
1414
import { validateBase64Image } from "@stackframe/stack-shared/dist/utils/base64";
1515
import { StatusError, throwErr } from "@stackframe/stack-shared/dist/utils/errors";
1616
import { getOrUndefined } from "@stackframe/stack-shared/dist/utils/objects";
1717
import { createLazyProxy } from "@stackframe/stack-shared/dist/utils/proxies";
18+
import { isUuid } from "@stackframe/stack-shared/dist/utils/uuids";
1819
import { addUserToTeam } from "../team-memberships/crud";
1920

2021

@@ -35,6 +36,11 @@ export const teamsCrudHandlers = createLazyProxy(() => createCrudHandlers(teamsC
3536
user_id: userIdOrMeSchema.optional().meta({ openapiField: { onlyShowInOperations: ['List'], description: 'Filter for the teams that the user is a member of. Can be either `me` or an ID. Must be `me` in the client API', exampleValue: 'me' } }),
3637
/** @deprecated use creator_user_id in the body instead */
3738
add_current_user: yupString().oneOf(["true", "false"]).optional().meta({ openapiField: { onlyShowInOperations: ['Create'], hidden: true } }),
39+
order_by: yupString().oneOf(["createdAt"]).optional().meta({ openapiField: { onlyShowInOperations: ['List'], description: 'Field to order results by. Currently only `createdAt` is supported.', exampleValue: 'createdAt' } }),
40+
desc: yupString().oneOf(["true", "false"]).optional().meta({ openapiField: { onlyShowInOperations: ['List'], description: 'Whether to order results in descending order. Defaults to false (ascending).', exampleValue: 'false' } }),
41+
limit: yupNumber().integer().min(1).optional().meta({ openapiField: { onlyShowInOperations: ['List'], description: 'The maximum number of items to return.' } }),
42+
cursor: yupString().uuid().optional().meta({ openapiField: { onlyShowInOperations: ['List'], description: 'The cursor to start the result set from.' } }),
43+
query: yupString().optional().meta({ openapiField: { onlyShowInOperations: ['List'], description: "A search query to filter the results by. Free-text search applied to the team's id (exact-match) and display name." } }),
3844
}),
3945
paramsSchema: yupObject({
4046
team_id: yupString().uuid().defined(),
@@ -274,6 +280,8 @@ export const teamsCrudHandlers = createLazyProxy(() => createCrudHandlers(teamsC
274280
}
275281

276282
const prisma = await getPrismaClientForTenancy(auth.tenancy);
283+
const queryWithoutSpecialChars = query.query?.replace(/[^a-zA-Z0-9\-_.]/g, '');
284+
const sortDirection = query.desc === 'true' ? 'desc' : 'asc';
277285
const db = await prisma.team.findMany({
278286
where: {
279287
tenancyId: auth.tenancy.id,
@@ -284,12 +292,48 @@ export const teamsCrudHandlers = createLazyProxy(() => createCrudHandlers(teamsC
284292
},
285293
},
286294
} : {},
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+
} : {},
287310
},
288-
orderBy: {
289-
createdAt: 'asc',
290-
},
311+
orderBy: [
312+
{ createdAt: sortDirection },
313+
{ teamId: sortDirection },
314+
],
315+
take: query.limit ? query.limit + 1 : undefined,
316+
...query.cursor ? {
317+
cursor: {
318+
tenancyId_teamId: {
319+
tenancyId: auth.tenancy.id,
320+
teamId: query.cursor,
321+
},
322+
},
323+
} : {},
291324
});
292325

326+
if (query.limit) {
327+
const items = db.slice(0, query.limit).map(teamPrismaToCrud);
328+
return {
329+
items,
330+
is_paginated: true,
331+
pagination: {
332+
next_cursor: db.length >= query.limit + 1 ? db[db.length - 1].teamId : null,
333+
},
334+
};
335+
}
336+
293337
return {
294338
items: db.map(teamPrismaToCrud),
295339
is_paginated: false,

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -522,7 +522,7 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC
522522
team_id: yupString().uuid().optional().meta({ openapiField: { onlyShowInOperations: [ 'List' ], description: "Only return users who are members of the given team" } }),
523523
limit: yupNumber().integer().min(1).optional().meta({ openapiField: { onlyShowInOperations: [ 'List' ], description: "The maximum number of items to return" } }),
524524
cursor: yupString().uuid().optional().meta({ openapiField: { onlyShowInOperations: [ 'List' ], description: "The cursor to start the result set from." } }),
525-
order_by: yupString().oneOf(['signed_up_at']).optional().meta({ openapiField: { onlyShowInOperations: [ 'List' ], description: "The field to sort the results by. Defaults to signed_up_at" } }),
525+
order_by: yupString().oneOf(['signed_up_at', 'last_active_at']).optional().meta({ openapiField: { onlyShowInOperations: [ 'List' ], description: "The field to sort the results by. Defaults to signed_up_at" } }),
526526
desc: yupString().oneOf(["true", "false"]).optional().meta({ openapiField: { onlyShowInOperations: [ 'List' ], description: "Whether to sort the results in descending order. Defaults to false" } }),
527527
query: yupString().optional().meta({ openapiField: { onlyShowInOperations: [ 'List' ], description: "A search query to filter the results by. This is a free-text search that is applied to the user's id (exact-match only), display name and primary email." } }),
528528
include_anonymous: yupString().oneOf(["true", "false"]).optional().meta({ openapiField: { onlyShowInOperations: [ 'List' ], description: "Whether to include anonymous users in the results. When true, also includes restricted users. Defaults to false" } }),
@@ -624,6 +624,7 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC
624624
{
625625
[({
626626
signed_up_at: 'signedUpAt',
627+
last_active_at: 'lastActiveAt',
627628
} as const)[query.order_by ?? 'signed_up_at']]: sortDirection,
628629
},
629630
{ projectUserId: sortDirection },

0 commit comments

Comments
 (0)