Add server-side flags for anonymous users

This commit is contained in:
Konstantin Wohlwend 2026-04-03 10:43:31 -07:00
parent ae3ad74e66
commit d3ea2b9001
8 changed files with 120 additions and 34 deletions

View File

@ -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

View File

@ -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<Parameters<typeof stackAdminApp.listUsers>[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(),
});

View File

@ -270,20 +270,24 @@ async function fetchAllUsers(
const limit = 100; // Fetch in batches of 100
do {
const batch = await stackAdminApp.listUsers({
const listUsersOptions: Parameters<typeof stackAdminApp.listUsers>[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(

View File

@ -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++) {

View File

@ -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);
});

View File

@ -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<UsersCrud['Server']['List']> {
async listServerUsers(options: (
& {
cursor?: string,
limit?: number,
orderBy?: 'signedUpAt',
desc?: boolean,
query?: string,
includeRestricted?: boolean,
}
& (
{
includeAnonymous?: boolean,
onlyAnonymous?: false,
}
| {
includeAnonymous: true,
onlyAnonymous: true,
}
)
)): Promise<UsersCrud['Server']['List']> {
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();

View File

@ -58,7 +58,14 @@ export class _StackServerAppImplIncomplete<HasTokenStore extends boolean, Projec
query?: string,
includeRestricted?: boolean,
includeAnonymous?: boolean,
], UsersCrud['Server']['List']>(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<string[], UsersCrud['Server']['Read'] | null>(async ([userId]) => {
@ -1342,7 +1349,7 @@ export class _StackServerAppImplIncomplete<HasTokenStore extends boolean, Projec
// END_PLATFORM
async listUsers(options?: ServerListUsersOptions): Promise<ServerUser[] & { nextCursor: string | null }> {
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<HasTokenStore extends boolean, Projec
// IF_PLATFORM react-like
useUsers(options?: ServerListUsersOptions): ServerUser[] & { nextCursor: string | null } {
const crud = useAsyncCache(this._serverUsersCache, [options?.cursor, options?.limit, options?.orderBy, options?.desc, options?.query, options?.includeRestricted, options?.includeAnonymous] as const, "serverApp.useUsers()");
const crud = useAsyncCache(this._serverUsersCache, [options?.cursor, options?.limit, options?.orderBy, options?.desc, options?.query, options?.includeRestricted, options?.includeAnonymous, options?.onlyAnonymous] as const, "serverApp.useUsers()");
const result: any = crud.items.map((j) => this._serverUserFromCrud(j));
result.nextCursor = crud.pagination?.next_cursor ?? null;
return result as any;

View File

@ -126,7 +126,7 @@ export type ServerTeam = {
removeUser(userId: string): Promise<void>,
} & 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,
};