mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-04 21:04:37 +08:00
Add server-side flags for anonymous users
This commit is contained in:
parent
ae3ad74e66
commit
d3ea2b9001
@ -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
|
||||
|
||||
@ -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(),
|
||||
});
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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++) {
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
Loading…
Reference in New Issue
Block a user