Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
2adafbc
refactor(dashboard): data-grid overhaul + session-replays / team-paym…
mantrakp04 May 8, 2026
cabcf19
feat(dashboard): implement paginated teams listing and enhance permis…
mantrakp04 May 8, 2026
c8ce7f4
update docs
mantrakp04 May 8, 2026
0ef67be
Merge branch 'dev' into refactor/data-grid-and-dashboard-surfaces
mantrakp04 May 8, 2026
8ea4ccc
refactor(dashboard): enhance permission handling and pagination
mantrakp04 May 8, 2026
de9b714
fix(dashboard): improve session replay query handling and data grid s…
mantrakp04 May 9, 2026
c43647d
Merge branch 'dev' into refactor/data-grid-and-dashboard-surfaces
mantrakp04 May 9, 2026
b0d290e
chore: remove unused 'dev:tui' script from multiple package.json files
mantrakp04 May 9, 2026
9a55c2a
refactor(api): streamline user and team data retrieval with pagination
mantrakp04 May 9, 2026
99f94e3
fix(api): improve pagination error handling and sorting logic
mantrakp04 May 11, 2026
bc9b1e5
fix(api): enhance pagination validation and error handling
mantrakp04 May 11, 2026
37ead67
fix(dashboard): enhance user permissions handling and loading states
mantrakp04 May 11, 2026
8b7e60a
fix(dashboard): enhance data grid state management and pagination
mantrakp04 May 11, 2026
bb11dd8
Merge branch 'dev' into refactor/data-grid-and-dashboard-surfaces
mantrakp04 May 11, 2026
3d5594d
refactor(api): streamline session replay and user management APIs
mantrakp04 May 12, 2026
f0f48be
Merge branch 'dev' into refactor/data-grid-and-dashboard-surfaces
mantrakp04 May 12, 2026
468f309
refactor(api): update user query parameters for clarity
mantrakp04 May 12, 2026
9e09c82
fix(docs): add missing newline at end of JSON files
mantrakp04 May 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,8 @@ To see all development ports, refer to the index.html of `apps/dev-launchpad/pub
- 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.
- 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.
- When asked to review PR comments, you can use `gh pr status` to get the current pull request you're working on.
- NEVER EVER AUTOMATICALLY COMMIT OR STAGE ANY CHANGES — DON'T MODIFY GIT WITHOUT USER CONSENT!
- 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.
- 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".
- When building frontend or React code for the dashboard, refer to DESIGN-GUIDE.md.
- 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.
- Fail early, fail loud. Fail fast with an error instead of silently continuing.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
-- SPLIT_STATEMENT_SENTINEL
-- SINGLE_STATEMENT_SENTINEL
-- RUN_OUTSIDE_TRANSACTION_SENTINEL
CREATE INDEX CONCURRENTLY IF NOT EXISTS "ProjectUser_lastActiveAt"
ON "ProjectUser"("tenancyId", "isAnonymous", "lastActiveAt");
1 change: 1 addition & 0 deletions apps/backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,7 @@ model ProjectUser {
@@index([tenancyId, createdAt(sort: Asc)], name: "ProjectUser_createdAt_asc")
@@index([tenancyId, createdAt(sort: Desc)], name: "ProjectUser_createdAt_desc")
@@index([tenancyId, isAnonymous, signedUpAt(sort: Asc)], name: "ProjectUser_signedUpAt_asc")
@@index([tenancyId, isAnonymous, lastActiveAt], name: "ProjectUser_lastActiveAt")
@@index([tenancyId, isAnonymous, signUpIp, signedUpAt], name: "ProjectUser_signUpIp_recent_idx")
@@index([tenancyId, isAnonymous, signUpEmailNormalized, signedUpAt], name: "ProjectUser_signUpEmailNormalized_recent_idx")
@@index([tenancyId, isAnonymous, signUpEmailBase, signedUpAt], name: "ProjectUser_signUpEmailBase_recent_idx")
Expand Down
45 changes: 37 additions & 8 deletions apps/backend/src/app/api/latest/internal/session-replays/route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -163,17 +163,46 @@ export const GET = createSmartRouteHandler({
};
}

// Handle cursor-based pagination
// Handle cursor-based pagination — validate the cursor row still matches
// the current filter set so that swapping filters between requests doesn't
// anchor pagination on a row that no longer qualifies.
const cursorId = query.cursor;
let cursorPivot: { id: string, lastEventAt: Date } | null = null;
if (cursorId) {
cursorPivot = await prisma.sessionReplay.findUnique({
where: { tenancyId_id: { tenancyId: auth.tenancy.id, id: cursorId } },
select: { id: true, lastEventAt: true },
if (clickQualifiedIds && !clickQualifiedIds.includes(cursorId)) {
throw new KnownErrors.ItemNotFound(cursorId);
}
const row = await prisma.sessionReplay.findFirst({
where: {
tenancyId: auth.tenancy.id,
id: cursorId,
...userIdsFilter.length > 0 ? { projectUserId: { in: userIdsFilter } } : {},
...lastEventAtFrom ? { lastEventAt: { gte: lastEventAtFrom } } : {},
...lastEventAtTo ? { lastEventAt: { lte: lastEventAtTo } } : {},
...teamIdsFilter.length > 0 ? {
projectUser: {
teamMembers: {
some: {
tenancyId: auth.tenancy.id,
teamId: { in: teamIdsFilter },
},
},
},
} : {},
},
select: { id: true, lastEventAt: true, startedAt: true },
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if (!cursorPivot) {
if (!row) {
throw new KnownErrors.ItemNotFound(cursorId);
}
const durationMs = row.lastEventAt.getTime() - row.startedAt.getTime();
if (durationMsMin !== null && durationMs < durationMsMin) {
throw new KnownErrors.ItemNotFound(cursorId);
}
if (durationMsMax !== null && durationMs > durationMsMax) {
throw new KnownErrors.ItemNotFound(cursorId);
}
cursorPivot = { id: row.id, lastEventAt: row.lastEventAt };
}

const suffixSql = Prisma.sql`
Expand All @@ -190,9 +219,9 @@ export const GET = createSmartRouteHandler({
${durationMsMin !== null ? Prisma.sql`AND EXTRACT(EPOCH FROM (sr."lastEventAt" - sr."startedAt")) * 1000 >= ${durationMsMin}` : Prisma.empty}
${durationMsMax !== null ? Prisma.sql`AND EXTRACT(EPOCH FROM (sr."lastEventAt" - sr."startedAt")) * 1000 <= ${durationMsMax}` : Prisma.empty}
${cursorPivot ? Prisma.sql`AND (
Comment thread
mantrakp04 marked this conversation as resolved.
sr."lastEventAt" < ${cursorPivot.lastEventAt}
OR (sr."lastEventAt" = ${cursorPivot.lastEventAt} AND sr."id" < ${cursorId})
)` : Prisma.empty}
sr."lastEventAt" < ${cursorPivot.lastEventAt}
OR (sr."lastEventAt" = ${cursorPivot.lastEventAt} AND sr."id" < ${cursorId})
)` : Prisma.empty}
ORDER BY sr."lastEventAt" DESC, sr."id" DESC
LIMIT ${limit + 1}
`;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { StatusError } from "@stackframe/stack-shared/dist/utils/errors";
import { stringCompare } from "@stackframe/stack-shared/dist/utils/strings";

// Binary search: index of the first item whose id > cursor, in an
// array already sorted by `stringCompare(a.id, b.id)`.
function firstIndexAfter<T extends { id: string }>(sorted: T[], cursor: string): number {
let lo = 0;
let hi = sorted.length;
while (lo < hi) {
const mid = (lo + hi) >>> 1;
if (stringCompare(sorted[mid].id, cursor) <= 0) lo = mid + 1;
else hi = mid;
}
return lo;
}

type PermissionDefinition = {
id: string,
description?: string,
contained_permission_ids: string[],
};

type ListQuery = {
limit?: number,
cursor?: string,
query?: string,
};

export const permissionDefinitionsListQuerySchema = yupObject({
limit: yupNumber().integer().min(1).max(200).optional().meta({ openapiField: { onlyShowInOperations: ['List'], description: "Maximum number of items to return (capped at 200). When set, the response is paginated via cursor." } }),
cursor: yupString().optional().meta({ openapiField: { onlyShowInOperations: ['List'], description: "Cursor (permission id) to start the next page from. Requires `limit` to also be set." } }),
query: yupString().optional().meta({ openapiField: { onlyShowInOperations: ['List'], description: "Free-text filter applied to permission id and description (case-insensitive)." } }),
});

export function paginatePermissionDefinitions(items: PermissionDefinition[], query: ListQuery) {
if (query.cursor != null && query.limit === undefined) {
throw new StatusError(StatusError.BadRequest, "`cursor` requires `limit` to also be set.");
}

const search = query.query?.trim().toLowerCase();
const filtered = (search
? items.filter((p) =>
p.id.toLowerCase().includes(search)
|| (p.description?.toLowerCase().includes(search) ?? false))
: items.slice()
).sort((a, b) => stringCompare(a.id, b.id));

if (query.limit === undefined) {
return { items: filtered, is_paginated: false as const };
}

let startIdx = 0;
if (query.cursor != null) {
const cursorIdx = filtered.findIndex((p) => p.id === query.cursor);
// If the cursor row was deleted (or filtered out) between page
// requests, fall back to "first id strictly greater than the cursor"
// rather than 400'ing the client mid-scroll. Worst case the user
// sees a one-row gap; the alternative is a hard error on infinite
// scroll for any concurrent edit.
startIdx = cursorIdx === -1
? firstIndexAfter(filtered, query.cursor)
: cursorIdx + 1;
}
const slice = filtered.slice(startIdx, startIdx + query.limit);
const hasMore = startIdx + query.limit < filtered.length;

return {
items: slice,
is_paginated: true as const,
pagination: {
next_cursor: hasMore && slice.length > 0 ? slice[slice.length - 1].id : null,
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@ import { createCrudHandlers } from "@/route-handlers/crud-handler";
import { teamPermissionDefinitionsCrud } from '@stackframe/stack-shared/dist/interface/crud/team-permissions';
import { permissionDefinitionIdSchema, yupObject } from "@stackframe/stack-shared/dist/schema-fields";
import { createLazyProxy } from "@stackframe/stack-shared/dist/utils/proxies";
import { paginatePermissionDefinitions, permissionDefinitionsListQuerySchema } from "../permission-definitions-pagination";

export const teamPermissionDefinitionsCrudHandlers = createLazyProxy(() => createCrudHandlers(teamPermissionDefinitionsCrud, {
paramsSchema: yupObject({
permission_id: permissionDefinitionIdSchema.defined(),
}),
querySchema: permissionDefinitionsListQuerySchema,
async onCreate({ auth, data }) {
return await createPermissionDefinition(
globalPrismaClient,
Expand Down Expand Up @@ -48,13 +50,11 @@ export const teamPermissionDefinitionsCrudHandlers = createLazyProxy(() => creat
}
);
},
async onList({ auth }) {
return {
items: await listPermissionDefinitions({
scope: "team",
tenancy: auth.tenancy,
}),
is_paginated: false,
};
async onList({ auth, query }) {
const all = await listPermissionDefinitions({
scope: "team",
tenancy: auth.tenancy,
});
return paginatePermissionDefinitions(all, query);
},
}));
59 changes: 55 additions & 4 deletions apps/backend/src/app/api/latest/teams/crud.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,12 @@ import { runAsynchronouslyAndWaitUntil } from "@/utils/background-tasks";
import { Prisma, PurchaseCreationSource } from "@/generated/prisma/client";
import { KnownErrors } from "@stackframe/stack-shared";
import { teamsCrud } from "@stackframe/stack-shared/dist/interface/crud/teams";
import { userIdOrMeSchema, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { userIdOrMeSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { validateBase64Image } from "@stackframe/stack-shared/dist/utils/base64";
import { StatusError, throwErr } from "@stackframe/stack-shared/dist/utils/errors";
import { getOrUndefined } from "@stackframe/stack-shared/dist/utils/objects";
import { createLazyProxy } from "@stackframe/stack-shared/dist/utils/proxies";
import { isUuid } from "@stackframe/stack-shared/dist/utils/uuids";
import { addUserToTeam } from "../team-memberships/crud";


Expand All @@ -35,6 +36,11 @@ export const teamsCrudHandlers = createLazyProxy(() => createCrudHandlers(teamsC
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' } }),
/** @deprecated use creator_user_id in the body instead */
add_current_user: yupString().oneOf(["true", "false"]).optional().meta({ openapiField: { onlyShowInOperations: ['Create'], hidden: true } }),
order_by: yupString().oneOf(["created_at"]).optional().meta({ openapiField: { onlyShowInOperations: ['List'], description: 'Field to order results by. Currently only `created_at` is supported.', exampleValue: 'created_at' } }),
desc: yupString().oneOf(["true", "false"]).optional().meta({ openapiField: { onlyShowInOperations: ['List'], description: 'Whether to order results in descending order. Defaults to false (ascending).', exampleValue: 'false' } }),
limit: yupNumber().integer().min(1).max(200).optional().meta({ openapiField: { onlyShowInOperations: ['List'], description: 'The maximum number of items to return (capped at 200).' } }),
cursor: yupString().uuid().optional().meta({ openapiField: { onlyShowInOperations: ['List'], description: 'The cursor to start the result set from. Requires `limit` to also be set.' } }),
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." } }),
}),
paramsSchema: yupObject({
team_id: yupString().uuid().defined(),
Expand Down Expand Up @@ -273,7 +279,28 @@ export const teamsCrudHandlers = createLazyProxy(() => createCrudHandlers(teamsC
}
}

if (query.cursor && !query.limit) {
throw new StatusError(StatusError.BadRequest, "`cursor` requires `limit` to also be set.");
}

const prisma = await getPrismaClientForTenancy(auth.tenancy);
const sortDirection = query.desc === 'true' ? 'desc' : 'asc';

let queryFilter: Prisma.TeamWhereInput | undefined;
if (query.query) {
queryFilter = {
OR: [
...isUuid(query.query) ? [{ teamId: { equals: query.query } }] : [],
Comment thread
mantrakp04 marked this conversation as resolved.
{
displayName: {
contains: query.query,
mode: 'insensitive' as const,
},
},
],
};
}

const db = await prisma.team.findMany({
where: {
tenancyId: auth.tenancy.id,
Expand All @@ -284,12 +311,36 @@ export const teamsCrudHandlers = createLazyProxy(() => createCrudHandlers(teamsC
},
},
} : {},
...queryFilter ?? {},
},
orderBy: {
createdAt: 'asc',
},
orderBy: [
{ createdAt: sortDirection },
{ teamId: sortDirection },
],
take: query.limit ? query.limit + 1 : undefined,
...query.cursor ? {
Comment thread
mantrakp04 marked this conversation as resolved.
skip: 1,
cursor: {
tenancyId_teamId: {
tenancyId: auth.tenancy.id,
teamId: query.cursor,
},
},
} : {},
});

if (query.limit) {
const items = db.slice(0, query.limit).map(teamPrismaToCrud);
const hasMore = db.length > query.limit;
return {
items,
is_paginated: true,
pagination: {
next_cursor: hasMore && items.length > 0 ? items[items.length - 1].id : null,
},
};
}

return {
items: db.map(teamPrismaToCrud),
is_paginated: false,
Expand Down
17 changes: 11 additions & 6 deletions apps/backend/src/app/api/latest/users/crud.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -522,7 +522,7 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC
team_id: yupString().uuid().optional().meta({ openapiField: { onlyShowInOperations: [ 'List' ], description: "Only return users who are members of the given team" } }),
limit: yupNumber().integer().min(1).optional().meta({ openapiField: { onlyShowInOperations: [ 'List' ], description: "The maximum number of items to return" } }),
cursor: yupString().uuid().optional().meta({ openapiField: { onlyShowInOperations: [ 'List' ], description: "The cursor to start the result set from." } }),
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" } }),
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" } }),
desc: yupString().oneOf(["true", "false"]).optional().meta({ openapiField: { onlyShowInOperations: [ 'List' ], description: "Whether to sort the results in descending order. Defaults to false" } }),
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." } }),
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" } }),
Expand Down Expand Up @@ -624,13 +624,18 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC
{
[({
signed_up_at: 'signedUpAt',
last_active_at: 'lastActiveAt',
} as const)[query.order_by ?? 'signed_up_at']]: sortDirection,
},
{ projectUserId: sortDirection },
],
// +1 because we need to know if there is a next page
// +1 to detect whether a next page exists without a separate count.
take: query.limit ? query.limit + 1 : undefined,
// Cursor convention (matches teams/crud.tsx): the client sends the
// id of the LAST row of the previous page; Prisma starts AT that id,
// and `skip: 1` drops it so we don't re-emit it.
...query.cursor ? {
skip: 1,
cursor: {
tenancyId_projectUserId: {
tenancyId: auth.tenancy.id,
Expand All @@ -640,13 +645,13 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC
} : {},
});

const items = db.slice(0, query.limit).map((user) => userPrismaToCrud(user, auth.tenancy.config));
const hasMore = query.limit != null && db.length > query.limit;
return {
// remove the last item because it's the next cursor
items: db.map((user) => userPrismaToCrud(user, auth.tenancy.config)).slice(0, query.limit),
items,
is_paginated: true,
pagination: {
// if result is not full length, there is no next cursor
next_cursor: query.limit && db.length >= query.limit + 1 ? db[db.length - 1].projectUserId : null,
next_cursor: hasMore && items.length > 0 ? items[items.length - 1].id : null,
},
};
},
Expand Down
15 changes: 13 additions & 2 deletions apps/backend/src/lib/openapi.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -200,8 +200,19 @@ function getFieldSchema(field: yup.SchemaFieldDescription, crudOperation?: Capit
};

switch (field.type) {
case 'string':
case 'number':
case 'string': {
const oneOf = (field as any).oneOf as unknown[] | undefined;
return {
type: 'string',
...oneOf && oneOf.length > 0 ? { enum: oneOf } : {},
...openapiFieldExtra,
};
}
case 'number': {
const tests = (field as any).tests as Array<{ name?: string }> | undefined;
const isInteger = tests?.some(t => t.name === 'integer') ?? false;
return { type: isInteger ? 'integer' : 'number', ...openapiFieldExtra };
}
case 'boolean': {
return { type: field.type, ...openapiFieldExtra };
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
'use client';

import { CodeBlock } from '@/components/code-block';
import { DesignButton } from "@/components/design-components";
import { APIEnvKeys, NextJsEnvKeys, ViteEnvKeys } from '@/components/env-keys';
import { InlineCode } from '@/components/inline-code';
import { StyledLink } from '@/components/link';
import { CopyPromptButton, Tabs, TabsContent, TabsList, TabsTrigger, Typography, cn } from "@/components/ui";
import { DesignButton } from "@/components/design-components";
import { useThemeWatcher } from '@/lib/theme';
import { BookIcon, SparkleIcon, XIcon } from "@phosphor-icons/react";
import { use } from "@stackframe/stack-shared/dist/utils/react";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ export default function PageClient() {
onSave={handleSave}
onDiscard={handleDiscard}
externalModifiedKeys={modifiedKeys}
className="gap-y-3"
/>
</DesignCard>
</PageLayout>
Expand Down
Loading
Loading