diff --git a/apps/backend/src/app/api/latest/users/crud.tsx b/apps/backend/src/app/api/latest/users/crud.tsx index 6d1670891..c118f09b1 100644 --- a/apps/backend/src/app/api/latest/users/crud.tsx +++ b/apps/backend/src/app/api/latest/users/crud.tsx @@ -498,6 +498,7 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC 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" } }), + only_anonymous: yupString().oneOf(["true", "false"]).optional().meta({ openapiField: { onlyShowInOperations: [ 'List' ], description: "Whether to return only anonymous users. When true, implies include_anonymous=true. Defaults to false" } }), include_restricted: yupString().oneOf(["true", "false"]).optional().meta({ openapiField: { onlyShowInOperations: [ 'List' ], description: "Whether to include restricted users in the results. Defaults to false" } }), }), onRead: async ({ auth, params, query }) => { @@ -515,7 +516,12 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC // - No flags: only Normal users (not anonymous, not restricted) // - include_restricted=true: Restricted + Normal users (not anonymous) // - include_anonymous=true: Anonymous + Restricted + Normal users (everything) + // - only_anonymous=true with include_anonymous=true: only Anonymous users + const onlyAnonymous = query.only_anonymous === "true"; const includeAnonymous = query.include_anonymous === "true"; + if (onlyAnonymous && !includeAnonymous) { + throw new StatusError(StatusError.BadRequest, "only_anonymous=true requires include_anonymous=true"); + } const includeRestricted = query.include_restricted === "true" || includeAnonymous; // include_anonymous also includes restricted // TODO: Instead of hardcoding this, we should use computeRestrictedStatus @@ -531,10 +537,16 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC }, }, } : {}, - ...includeAnonymous ? {} : { - // Don't return anonymous users unless explicitly requested - isAnonymous: false, - }, + ...onlyAnonymous + ? { + isAnonymous: true, + } + : !includeAnonymous + ? { + // Don't return anonymous users unless explicitly requested + isAnonymous: false, + } + : {}, // Filter out restricted users if needed (restricted = signed up but email not verified) ...shouldFilterRestrictedByEmail ? { // User must have a verified primary email to not be restricted diff --git a/apps/dashboard/src/components/data-table/user-table.tsx b/apps/dashboard/src/components/data-table/user-table.tsx index 207d3b45c..0cd022f45 100644 --- a/apps/dashboard/src/components/data-table/user-table.tsx +++ b/apps/dashboard/src/components/data-table/user-table.tsx @@ -351,15 +351,20 @@ function UserTableBody(props: { const { resetCache } = props.cursorPaginationCache; const baseOptions = useMemo( - () => ({ - limit: query.pageSize, - orderBy: "signedUpAt" as const, - desc: query.signedUpOrder === "desc", - query: query.search, - includeRestricted: query.includeRestricted, - includeAnonymous: query.includeAnonymous, - }), - [query.pageSize, query.search, query.includeRestricted, query.includeAnonymous, query.signedUpOrder], + (): NonNullable[0]> => { + const common = { + limit: query.pageSize, + orderBy: "signedUpAt" as const, + desc: query.signedUpOrder === "desc", + query: query.search, + includeRestricted: query.includeRestricted, + }; + if (query.onlyAnonymous) { + return { ...common, includeAnonymous: true, onlyAnonymous: true }; + } + return { ...common, includeAnonymous: query.includeAnonymous }; + }, + [query.pageSize, query.search, query.includeRestricted, query.includeAnonymous, query.onlyAnonymous, query.signedUpOrder, stackAdminApp], ); const rawUsers = stackAdminApp.useUsers({ @@ -390,13 +395,9 @@ function UserTableBody(props: { () => createUserColumns(setQuery, query.signedUpOrder === "desc"), [setQuery, query.signedUpOrder], ); - const displayedUsers = useMemo( - () => (query.onlyAnonymous ? users.filter((user) => user.isAnonymous) : users), - [users, query.onlyAnonymous], - ); const table = useReactTable({ - data: displayedUsers, + data: users, columns, getCoreRowModel: getCoreRowModel(), }); diff --git a/apps/dashboard/src/components/export-users-dialog.tsx b/apps/dashboard/src/components/export-users-dialog.tsx index 6d9bc1838..0ab0f1180 100644 --- a/apps/dashboard/src/components/export-users-dialog.tsx +++ b/apps/dashboard/src/components/export-users-dialog.tsx @@ -270,20 +270,24 @@ async function fetchAllUsers( const limit = 100; // Fetch in batches of 100 do { - const batch = await stackAdminApp.listUsers({ + const listUsersOptions: Parameters[0] = { limit, cursor, query: options?.search, includeAnonymous: options?.onlyAnonymous ? true : (options?.includeAnonymous ?? true), orderBy: "signedUpAt", desc: true, - }); + }; + if (options?.onlyAnonymous) { + Object.assign(listUsersOptions, { onlyAnonymous: true }); + } + const batch = await stackAdminApp.listUsers(listUsersOptions); allUsers.push(...batch); cursor = batch.nextCursor ?? undefined; } while (cursor); - return options?.onlyAnonymous ? allUsers.filter((user) => user.isAnonymous) : allUsers; + return allUsers; } function transformUserData( diff --git a/apps/e2e/tests/backend/endpoints/api/v1/users.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/users.test.ts index 7399b4f38..a8bdb919d 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/users.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/users.test.ts @@ -992,6 +992,18 @@ describe("with server access", () => { `); }); + it("should require include_anonymous=true when only_anonymous=true", async ({ expect }) => { + await Project.createAndSwitch(); + await Auth.fastSignUp(); + + const response = await niceBackendFetch("/api/v1/users?only_anonymous=true", { + accessType: "server", + }); + + expect(response.status).toBe(400); + expect(response.body).toBe("only_anonymous=true requires include_anonymous=true"); + }); + it("lists users with pagination", async ({ expect }) => { await Project.createAndSwitch(); for (let i = 0; i < 5; i++) { diff --git a/apps/e2e/tests/js/list-users.test.ts b/apps/e2e/tests/js/list-users.test.ts index b1f7b959e..1fd930c68 100644 --- a/apps/e2e/tests/js/list-users.test.ts +++ b/apps/e2e/tests/js/list-users.test.ts @@ -50,3 +50,25 @@ it("should default to excluding anonymous users when includeAnonymous is not spe // Verify anonymous user is NOT included by default expect(users.map(u => u.id)).not.toContain(anonymousUser.id); }); + +it("should list only anonymous users when onlyAnonymous is true", async ({ expect }) => { + const { serverApp, clientApp } = await createApp(); + + const regularUser = await serverApp.createUser({ + primaryEmail: "regular3@test.com", + password: "password", + primaryEmailAuthEnabled: true, + primaryEmailVerified: true, + }); + + const anonymousUser1 = await clientApp.getUser({ or: "anonymous", tokenStore: { headers: new Headers() } }); + await anonymousUser1.signOut(); + const anonymousUser2 = await clientApp.getUser({ or: "anonymous", tokenStore: { headers: new Headers() } }); + + const anonymousOnlyUsers = await serverApp.listUsers({ onlyAnonymous: true, includeAnonymous: true, orderBy: "signedUpAt" }); + const anonymousOnlyUserIds = anonymousOnlyUsers.map((u) => u.id); + + expect(anonymousOnlyUserIds).toContain(anonymousUser1.id); + expect(anonymousOnlyUserIds).toContain(anonymousUser2.id); + expect(anonymousOnlyUserIds).not.toContain(regularUser.id); +}); diff --git a/packages/stack-shared/src/interface/server-interface.ts b/packages/stack-shared/src/interface/server-interface.ts index 68c4b9059..7c8dd6baf 100644 --- a/packages/stack-shared/src/interface/server-interface.ts +++ b/packages/stack-shared/src/interface/server-interface.ts @@ -296,15 +296,26 @@ export class StackServerInterface extends StackClientInterface { return result.items; } - async listServerUsers(options: { - cursor?: string, - limit?: number, - orderBy?: 'signedUpAt', - desc?: boolean, - query?: string, - includeRestricted?: boolean, - includeAnonymous?: boolean, - }): Promise { + async listServerUsers(options: ( + & { + cursor?: string, + limit?: number, + orderBy?: 'signedUpAt', + desc?: boolean, + query?: string, + includeRestricted?: boolean, + } + & ( + { + includeAnonymous?: boolean, + onlyAnonymous?: false, + } + | { + includeAnonymous: true, + onlyAnonymous: true, + } + ) + )): Promise { const searchParams = new URLSearchParams(filterUndefined({ cursor: options.cursor, limit: options.limit?.toString(), @@ -323,6 +334,9 @@ export class StackServerInterface extends StackClientInterface { ...options.includeAnonymous ? { include_anonymous: 'true', } : {}, + ...options.onlyAnonymous ? { + only_anonymous: 'true', + } : {}, })); const response = await this.sendServerRequest("/users?" + searchParams.toString(), {}, null); return await response.json(); diff --git a/packages/template/src/lib/stack-app/apps/implementations/server-app-impl.ts b/packages/template/src/lib/stack-app/apps/implementations/server-app-impl.ts index 4f1f7f9ce..b2ebc3555 100644 --- a/packages/template/src/lib/stack-app/apps/implementations/server-app-impl.ts +++ b/packages/template/src/lib/stack-app/apps/implementations/server-app-impl.ts @@ -58,7 +58,14 @@ export class _StackServerAppImplIncomplete(async ([cursor, limit, orderBy, desc, query, includeRestricted, includeAnonymous]) => { + onlyAnonymous?: boolean, + ], UsersCrud['Server']['List']>(async ([cursor, limit, orderBy, desc, query, includeRestricted, includeAnonymous, onlyAnonymous]) => { + if (onlyAnonymous && !includeAnonymous) { + throw new StackAssertionError("onlyAnonymous=true requires includeAnonymous=true"); + } + if (onlyAnonymous) { + return await this._interface.listServerUsers({ cursor, limit, orderBy, desc, query, includeRestricted, includeAnonymous: true, onlyAnonymous: true }); + } return await this._interface.listServerUsers({ cursor, limit, orderBy, desc, query, includeRestricted, includeAnonymous }); }); private readonly _serverUserCache = createCache(async ([userId]) => { @@ -1342,7 +1349,7 @@ export class _StackServerAppImplIncomplete { - const crud = Result.orThrow(await this._serverUsersCache.getOrWait([options?.cursor, options?.limit, options?.orderBy, options?.desc, options?.query, options?.includeRestricted, options?.includeAnonymous], "write-only")); + const crud = Result.orThrow(await this._serverUsersCache.getOrWait([options?.cursor, options?.limit, options?.orderBy, options?.desc, options?.query, options?.includeRestricted, options?.includeAnonymous, options?.onlyAnonymous], "write-only")); const result: any = crud.items.map((j) => this._serverUserFromCrud(j)); result.nextCursor = crud.pagination?.next_cursor ?? null; return result as any; @@ -1350,7 +1357,7 @@ export class _StackServerAppImplIncomplete this._serverUserFromCrud(j)); result.nextCursor = crud.pagination?.next_cursor ?? null; return result as any; diff --git a/packages/template/src/lib/stack-app/teams/index.ts b/packages/template/src/lib/stack-app/teams/index.ts index 11ba2a4fd..2b56ecb0c 100644 --- a/packages/template/src/lib/stack-app/teams/index.ts +++ b/packages/template/src/lib/stack-app/teams/index.ts @@ -126,7 +126,7 @@ export type ServerTeam = { removeUser(userId: string): Promise, } & Team; -export type ServerListUsersOptions = { +type ServerListUsersOptionsBase = { cursor?: string, limit?: number, orderBy?: 'signedUpAt', @@ -144,6 +144,20 @@ export type ServerListUsersOptions = { includeAnonymous?: boolean, }; +export type ServerListUsersOptions = ServerListUsersOptionsBase & ( + { + onlyAnonymous?: false, + } | { + /** + * Whether to return only anonymous users. + * Requires includeAnonymous=true. + * Defaults to false. + */ + onlyAnonymous: true, + includeAnonymous: true, + } +); + export type ServerTeamCreateOptions = TeamCreateOptions & { creatorUserId?: string, };