Query/filtering with list users endpoint (#314)

This commit is contained in:
Zai Shi 2024-10-30 02:16:39 +01:00 committed by GitHub
parent ef7707ad1f
commit fd8d166e04
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 446 additions and 156 deletions

View File

@ -0,0 +1,11 @@
-- CreateIndex
CREATE INDEX "ProjectUser_displayName_asc" ON "ProjectUser"("projectId", "displayName" ASC);
-- CreateIndex
CREATE INDEX "ProjectUser_displayName_desc" ON "ProjectUser"("projectId", "displayName" DESC);
-- CreateIndex
CREATE INDEX "ProjectUser_createdAt_asc" ON "ProjectUser"("projectId", "createdAt" ASC);
-- CreateIndex
CREATE INDEX "ProjectUser_createdAt_desc" ON "ProjectUser"("projectId", "createdAt" DESC);

View File

@ -251,6 +251,12 @@ model ProjectUser {
oauthAuthMethod OAuthAuthMethod[]
@@id([projectId, projectUserId])
// indices for sorting and filtering
@@index([projectId, displayName(sort: Asc)], name: "ProjectUser_displayName_asc")
@@index([projectId, displayName(sort: Desc)], name: "ProjectUser_displayName_desc")
@@index([projectId, createdAt(sort: Asc)], name: "ProjectUser_createdAt_asc")
@@index([projectId, createdAt(sort: Desc)], name: "ProjectUser_createdAt_desc")
}
// This should be renamed to "OAuthAccount" as it is not always bound to a user

View File

@ -4,8 +4,10 @@ import { KnownErrors } from "@stackframe/stack-shared";
import { adaptSchema, clientOrHigherAuthTypeSchema } from "@stackframe/stack-shared/dist/schema-fields";
import { resetPasswordVerificationCodeHandler } from "../reset/verification-code-handler";
import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors";
import { usersCrudHandlers } from "../../../users/crud";
import { userPrismaToCrud, usersCrudHandlers } from "../../../users/crud";
import { wait } from "@stackframe/stack-shared/dist/utils/promises";
import { prismaClient } from "@/prisma-client";
import { getAuthContactChannel } from "@/lib/contact-channel";
export const POST = createSmartRouteHandler({
metadata: {
@ -36,14 +38,16 @@ export const POST = createSmartRouteHandler({
}
// TODO filter in the query
const allUsers = await usersCrudHandlers.adminList({
project,
});
const users = allUsers.items.filter((user) => user.primary_email === email && user.auth_with_email);
if (users.length > 1) {
throw new StackAssertionError("Multiple users found with the same email and email auth enabled; this should never happen", { users });
}
if (users.length === 0) {
const contactChannel = await getAuthContactChannel(
prismaClient,
{
projectId: project.id,
type: "EMAIL",
value: email,
},
);
if (!contactChannel) {
await wait(2000 + Math.random() * 1000);
return {
statusCode: 200,
@ -53,8 +57,10 @@ export const POST = createSmartRouteHandler({
},
};
}
const user = users[0];
const user = await usersCrudHandlers.adminRead({
project,
user_id: contactChannel.projectUserId,
});
await resetPasswordVerificationCodeHandler.sendCode({
project,
callbackUrl,

View File

@ -7,13 +7,14 @@ import { BooleanTrue, Prisma } from "@prisma/client";
import { KnownErrors } from "@stackframe/stack-shared";
import { currentUserCrud } from "@stackframe/stack-shared/dist/interface/crud/current-user";
import { UsersCrud, usersCrud } from "@stackframe/stack-shared/dist/interface/crud/users";
import { userIdOrMeSchema, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { userIdOrMeSchema, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { validateBase64Image } from "@stackframe/stack-shared/dist/utils/base64";
import { decodeBase64 } from "@stackframe/stack-shared/dist/utils/bytes";
import { StackAssertionError, StatusError, throwErr } from "@stackframe/stack-shared/dist/utils/errors";
import { allProviders } from "@stackframe/stack-shared/dist/utils/oauth";
import { hashPassword } from "@stackframe/stack-shared/dist/utils/password";
import { createLazyProxy } from "@stackframe/stack-shared/dist/utils/proxies";
import { typedToLowercase } from "@stackframe/stack-shared/dist/utils/strings";
import { typedToLowercase, typedToUppercase } from "@stackframe/stack-shared/dist/utils/strings";
import { waitUntil } from '@vercel/functions';
import { teamPrismaToCrud, teamsCrudHandlers } from "../teams/crud";
@ -244,12 +245,17 @@ export async function getUser(options: { projectId: string, userId: string }) {
}
export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersCrud, {
querySchema: yupObject({
team_id: yupString().uuid().optional().meta({ openapiField: { onlyShowInOperations: [ 'List' ] }})
}),
paramsSchema: yupObject({
user_id: userIdOrMeSchema.required(),
}),
querySchema: yupObject({
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).max(200).optional().meta({ openapiField: { onlyShowInOperations: [ 'List' ], description: "The maximum number of items to return. Defaults to 100, max is 200" }}),
cursor: yupString().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" }}),
desc: yupBoolean().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 display name and primary email." }}),
}),
onRead: async ({ auth, params }) => {
const user = await getUser({ projectId: auth.project.id, userId: params.user_id });
if (!user) {
@ -258,25 +264,67 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC
return user;
},
onList: async ({ auth, query }) => {
const db = await prismaClient.projectUser.findMany({
where: {
projectId: auth.project.id,
...query.team_id ? {
teamMembers: {
some: {
teamId: query.team_id,
const where = {
projectId: auth.project.id,
...query.team_id ? {
teamMembers: {
some: {
teamId: query.team_id,
},
},
} : {},
...query.query ? {
OR: [
{
displayName: {
contains: query.query,
mode: 'insensitive',
},
},
} : {},
},
{
contactChannels: {
some: {
value: {
contains: query.query,
mode: 'insensitive',
},
},
},
},
] as any,
} : {},
};
const limit = query.limit ?? 100;
const db = await prismaClient.projectUser.findMany({
where,
include: userFullInclude,
orderBy: {
[({
signed_up_at: 'createdAt',
} as const)[query.order_by ?? 'signed_up_at']]: query.desc ? 'desc' : 'asc',
},
// +1 because we need to know if there is a next page
take: limit + 1,
...query.cursor ? {
cursor: {
projectId_projectUserId: {
projectId: auth.project.id,
projectUserId: query.cursor,
},
},
} : {},
});
const lastActiveAtMillis = await getUsersLastActiveAtMillis(db.map(user => user.projectUserId), db.map(user => user.createdAt));
return {
items: db.map((user, index) => userPrismaToCrud(user, lastActiveAtMillis[index])),
is_paginated: false,
// remove the last item because it's the next cursor
items: db.map((user, index) => userPrismaToCrud(user, lastActiveAtMillis[index])).slice(0, limit),
is_paginated: true,
pagination: {
// if result is not full length, there is no next cursor
next_cursor: db.length >= limit + 1 ? db[db.length - 1].projectUserId : null,
},
};
},
onCreate: async ({ auth, data }) => {
@ -840,7 +888,7 @@ export const currentUserCrudHandlers = createLazyProxy(() => createCrudHandlers(
project: auth.project,
user_id: auth.user?.id ?? throwErr(new KnownErrors.CannotGetOwnUserWithoutUser()),
data,
allowedErrorTypes: [StatusError]
allowedErrorTypes: [StatusError],
});
},
async onDelete({ auth }) {

View File

@ -165,7 +165,7 @@ function parseRouteHandler(options: {
return result;
}
function getFieldSchema(field: yup.SchemaFieldDescription, crudOperation?: Capitalize<CrudlOperation>): { type: string, items?: any, properties?: any, required?: any } | undefined {
function getFieldSchema(field: yup.SchemaFieldDescription, crudOperation?: Capitalize<CrudlOperation>): { type: string, items?: any, properties?: any, required?: any, default?: any } | undefined {
const meta = "meta" in field ? field.meta : {};
if (meta?.openapiField?.hidden) {
return undefined;
@ -178,6 +178,7 @@ function getFieldSchema(field: yup.SchemaFieldDescription, crudOperation?: Capit
const openapiFieldExtra = {
example: meta?.openapiField?.exampleValue,
description: meta?.openapiField?.description,
default: (field as any).default,
};
switch (field.type) {

View File

@ -132,7 +132,14 @@ export function createCrudHandlers<
crudOperation === "List"
? yupObject({
items: yupArray(read).required(),
is_paginated: yupBoolean().oneOf([false]).required().meta({ openapiField: { hidden: true } }),
is_paginated: yupBoolean().required().meta({ openapiField: { hidden: true } }),
pagination: yupObject({
next_cursor: yupString().nullable().defined(),
}).when('is_paginated', {
is: true,
then: (schema) => schema.required(),
otherwise: (schema) => schema.optional(),
}),
}).required()
: crudOperation === "Delete"
? yupMixed<any>().oneOf([undefined])

View File

@ -4,14 +4,13 @@ import { UserTable } from "@/components/data-table/user-table";
import { FormDialog } from "@/components/form-dialog";
import { InputField, SwitchField } from "@/components/form-fields";
import { StyledLink } from "@/components/link";
import { Alert, Button, Label, Switch } from "@stackframe/stack-ui";
import { Alert, Button } from "@stackframe/stack-ui";
import * as yup from "yup";
import { PageLayout } from "../page-layout";
import { useAdminApp } from "../use-admin-app";
import { useState } from "react";
import { useEffect } from "react";
function CreateDialog(props: {
existingEmails: string[],
open?: boolean,
onOpenChange?: (open: boolean) => void,
trigger?: React.ReactNode,
@ -20,7 +19,7 @@ function CreateDialog(props: {
const project = adminApp.useProject();
const formSchema = yup.object({
displayName: yup.string().optional(),
primaryEmail: yup.string().email().notOneOf(props.existingEmails, "Email already exists").required(),
primaryEmail: yup.string().email().required(),
primaryEmailVerified: yup.boolean().optional().test({
name: 'otp-verified',
message: 'Primary email must be verified if OTP/magic link sign-in is enabled',
@ -64,22 +63,21 @@ function CreateDialog(props: {
export default function PageClient() {
const stackAdminApp = useAdminApp();
const allUsers = stackAdminApp.useUsers();
const users = stackAdminApp.useUsers();
return (
<PageLayout
title="Users"
actions={<CreateDialog
trigger={<Button>Create User</Button>}
existingEmails={allUsers.map(u => u.primaryEmail).filter(e => e !== null) as string[]}
/>}
>
{allUsers.length > 0 ? null : (
{users.length > 0 ? null : (
<Alert variant='success'>
Congratulations on starting your project! Check the <StyledLink href="https://docs.stack-auth.com">documentation</StyledLink> to add your first users.
</Alert>
)}
<UserTable users={allUsers} />
<UserTable />
</PageLayout>
);
}

View File

@ -4,9 +4,9 @@ import { ServerUser } from '@stackframe/stack';
import { jsonStringOrEmptySchema } from "@stackframe/stack-shared/dist/schema-fields";
import { allProviders } from '@stackframe/stack-shared/dist/utils/oauth';
import { deindent } from '@stackframe/stack-shared/dist/utils/strings';
import { ActionCell, ActionDialog, AvatarCell, BadgeCell, CopyField, DataTable, DataTableColumnHeader, DataTableFacetedFilter, DateCell, SearchToolbarItem, SimpleTooltip, TextCell, Typography, arrayFilterFn, standardFilterFn } from "@stackframe/stack-ui";
import { ColumnDef, Row, Table } from "@tanstack/react-table";
import { useMemo, useState } from "react";
import { ActionCell, ActionDialog, AvatarCell, BadgeCell, CopyField, DataTableColumnHeader, DataTableFacetedFilter, DataTableManual, DateCell, SearchToolbarItem, SimpleTooltip, TextCell, Typography } from "@stackframe/stack-ui";
import { ColumnDef, ColumnFiltersState, Row, SortingState, Table } from "@tanstack/react-table";
import React, { useEffect, useState } from "react";
import * as yup from "yup";
import { FormDialog } from "../form-dialog";
import { DateField, InputField, SwitchField, TextAreaField } from "../form-fields";
@ -20,10 +20,10 @@ function userToolbarRender<TData>(table: Table<TData>) {
return (
<>
<SearchToolbarItem table={table} placeholder="Search table" />
<DataTableFacetedFilter
{/* <DataTableFacetedFilter
column={table.getColumn("authTypes")}
title="Auth Method"
options={['email', 'password', ...allProviders].map((provider) => ({
options={['otp', 'password', ...allProviders].map((provider) => ({
value: provider,
label: provider,
}))}
@ -35,7 +35,7 @@ function userToolbarRender<TData>(table: Table<TData>) {
{ value: "verified", label: "verified" },
{ value: "unverified", label: "unverified" },
]}
/>
/> */}
</>
);
}
@ -198,13 +198,13 @@ export const getCommonUserColumns = <T extends ExtendedServerUser>() => [
accessorKey: "id",
header: ({ column }) => <DataTableColumnHeader column={column} columnTitle="ID" />,
cell: ({ row }) => <TextCell size={60}>{row.original.id}</TextCell>,
enableGlobalFilter: true,
enableSorting: false,
},
{
accessorKey: "displayName",
header: ({ column }) => <DataTableColumnHeader column={column} columnTitle="Display Name" />,
cell: ({ row }) => <TextCell size={120}><span className={row.original.displayName === null ? 'text-slate-400' : ''}>{row.original.displayName ?? ''}</span></TextCell>,
enableGlobalFilter: true,
enableSorting: false,
},
{
accessorKey: "primaryEmail",
@ -214,19 +214,19 @@ export const getCommonUserColumns = <T extends ExtendedServerUser>() => [
icon={row.original.emailVerified === "unverified" && <SimpleTooltip tooltip='Email not verified' type='warning'/>}>
{row.original.primaryEmail}
</TextCell>,
enableGlobalFilter: true,
enableSorting: false,
},
{
accessorKey: "lastActiveAt",
header: ({ column }) => <DataTableColumnHeader column={column} columnTitle="Last Active" />,
cell: ({ row }) => <DateCell date={row.original.lastActiveAt} />,
enableSorting: false,
},
{
accessorKey: "emailVerified",
header: ({ column }) => <DataTableColumnHeader column={column} columnTitle="Email Verified" />,
cell: ({ row }) => <TextCell>{row.original.emailVerified === 'verified' ? '✓' : '✗'}</TextCell>,
filterFn: standardFilterFn,
enableGlobalFilter: false,
enableSorting: false,
},
] satisfies ColumnDef<T>[];
@ -236,7 +236,7 @@ const columns: ColumnDef<ExtendedServerUser>[] = [
accessorKey: "authTypes",
header: ({ column }) => <DataTableColumnHeader column={column} columnTitle="Auth Method" />,
cell: ({ row }) => <BadgeCell badges={row.original.authTypes} />,
filterFn: arrayFilterFn,
enableSorting: false,
},
{
accessorKey: "signedUpAt",
@ -253,7 +253,7 @@ export function extendUsers(users: ServerUser[]): ExtendedServerUser[] {
return users.map((user) => ({
...user,
authTypes: [
...user.emailAuthEnabled ? ["email"] : [],
...user.otpAuthEnabled ? ["otp"] : [],
...user.hasPassword ? ["password"] : [],
...user.oauthProviders.map(p => p.id),
],
@ -261,12 +261,66 @@ export function extendUsers(users: ServerUser[]): ExtendedServerUser[] {
} satisfies ExtendedServerUser)).sort((a, b) => a.signedUpAt > b.signedUpAt ? -1 : 1);
}
export function UserTable(props: { users: ServerUser[] }) {
const extendedUsers: ExtendedServerUser[] = useMemo(() => extendUsers(props.users), [props.users]);
return <DataTable
data={extendedUsers}
export function UserTable() {
const stackAdminApp = useAdminApp();
const [users, setUsers] = useState<ExtendedServerUser[]>([]);
const [sorting, setSorting] = React.useState<SortingState>([]);
const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 10 });
const [cursors, setCursors] = useState<Record<number, string>>({});
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [globalFilter, setGlobalFilter] = useState<any>();
const [refreshCounter, setRefreshCounter] = useState(0);
useEffect(() => {
let filters: any = {};
const orderMap = {
signedUpAt: "signedUpAt",
} as const;
if (sorting.length > 0 && sorting[0].id in orderMap) {
filters.orderBy = orderMap[sorting[0].id as keyof typeof orderMap];
filters.desc = sorting[0].desc;
}
stackAdminApp.listUsers({
cursor: cursors[pagination.pageIndex],
limit: pagination.pageSize,
query: globalFilter,
...filters,
}).then((users) => {
setUsers(extendUsers(users));
setCursors(c => users.nextCursor ? { ...c, [pagination.pageIndex + 1]: users.nextCursor } : c);
}).catch(console.error);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pagination, stackAdminApp, sorting, columnFilters, refreshCounter]);
// Reset to first page when filters change
useEffect(() => {
setPagination(pagination => ({ ...pagination, pageIndex: 0 }));
setCursors({});
}, [columnFilters, sorting, pagination.pageSize]);
// Refresh the users when the global filter changes. Delay to prevent unnecessary re-renders.
useEffect(() => {
const timer = setTimeout(() => {
setRefreshCounter(x => x + 1);
}, 500);
return () => clearTimeout(timer);
}, [globalFilter]);
return <DataTableManual
columns={columns}
data={users}
toolbarRender={userToolbarRender}
sorting={sorting}
setSorting={setSorting}
pagination={pagination}
setPagination={setPagination}
columnFilters={columnFilters}
setColumnFilters={setColumnFilters}
defaultVisibility={{ emailVerified: false }}
rowCount={pagination.pageSize * Object.keys(cursors).length + (cursors[pagination.pageIndex + 1] ? 1 : 0)}
globalFilter={globalFilter}
setGlobalFilter={setGlobalFilter}
/>;
}

View File

@ -65,7 +65,7 @@ it("creates a team and manage users on the server", async ({ expect }) => {
NiceResponse {
"status": 200,
"body": {
"is_paginated": false,
"is_paginated": true,
"items": [
{
"auth_with_email": true,
@ -110,6 +110,7 @@ it("creates a team and manage users on the server", async ({ expect }) => {
"signed_up_at_millis": <stripped field 'signed_up_at_millis'>,
},
],
"pagination": { "next_cursor": null },
},
"headers": Headers { <some fields may have been hidden> },
}
@ -137,7 +138,7 @@ it("creates a team and manage users on the server", async ({ expect }) => {
NiceResponse {
"status": 200,
"body": {
"is_paginated": false,
"is_paginated": true,
"items": [
{
"auth_with_email": true,
@ -161,6 +162,7 @@ it("creates a team and manage users on the server", async ({ expect }) => {
"signed_up_at_millis": <stripped field 'signed_up_at_millis'>,
},
],
"pagination": { "next_cursor": null },
},
"headers": Headers { <some fields may have been hidden> },
}

View File

@ -1,6 +1,6 @@
import { generateSecureRandomString } from "@stackframe/stack-shared/dist/utils/crypto";
import { describe } from "vitest";
import { it } from "../../../../helpers";
import { createMailbox, it } from "../../../../helpers";
import { Auth, InternalProjectKeys, Project, backendContext, niceBackendFetch } from "../../../backend-helpers";
describe("without project access", () => {
@ -715,7 +715,7 @@ describe("with server access", () => {
NiceResponse {
"status": 200,
"body": {
"is_paginated": false,
"is_paginated": true,
"items": [
{
"auth_with_email": true,
@ -739,12 +739,40 @@ describe("with server access", () => {
"signed_up_at_millis": <stripped field 'signed_up_at_millis'>,
},
],
"pagination": { "next_cursor": null },
},
"headers": Headers { <some fields may have been hidden> },
}
`);
});
it("list next cursor", async ({ expect }) => {
await Project.createAndSwitch({ config: { magic_link_enabled: true } });
for (let i = 0; i < 5; i++) {
backendContext.set({ mailbox: createMailbox() });
await Auth.Otp.signIn();
}
const allResponse = await niceBackendFetch("/api/v1/users", {
accessType: "server",
});
const response1 = await niceBackendFetch("/api/v1/users?limit=2", {
accessType: "server",
});
expect(response1.body.pagination.next_cursor).toBeDefined();
const response2 = await niceBackendFetch(`/api/v1/users?limit=3&cursor=${response1.body.pagination.next_cursor}`, {
accessType: "server",
});
expect(response2.body.pagination.next_cursor).toBeDefined();
// check if response 1 + response 2 = allResponse
expect(response1.body.items.length + response2.body.items.length).toEqual(allResponse.body.items.length);
const allUserIds = new Set(allResponse.body.items.map((user: any) => user.id));
const concatenatedUserIds = new Set([...response1.body.items.map((user: any) => user.id), ...response2.body.items.map((user: any) => user.id)]);
expect(concatenatedUserIds).toEqual(allUserIds);
});
it("should be able to read a user", async ({ expect }) => {
await Auth.Otp.signIn();
const signedInResponse = (await niceBackendFetch("/api/v1/users/me", {

View File

@ -545,19 +545,54 @@ console.log(user); // null if not found
### `listUsers()`
List all users.
Lists users.
If `limit` is not provided, it will return all users by making multiple requests to the server (this might be slow for a large number of users, so it is recommended to always use pagination).
**Parameters:**
<div className="indented">
<ParamField path="options" type="object">
<div className="indented">
<ParamField path="cursor" type="string">
The cursor to start the result set from.
</ParamField>
<ParamField path="limit" type="number">
The maximum number of items to return. Max is 200. If not provided, it will return all users.
</ParamField>
<ParamField path="orderBy" type="'signedUpAt'">
The field to sort the results by. Currently only `signedUpAt` is supported.
</ParamField>
<ParamField path="desc" type="boolean" default="false">
Whether to sort the results in descending order.
</ParamField>
<ParamField path="query" type="string">
A query to filter the results by. This is a free-text search on the user's display name and emails.
</ParamField>
</div>
</ParamField>
</div>
**Returns:**
<div className="indented">
`Promise<ServerUser[]>`: The list of users.
`Promise<ServerUser[] & { nextCursor: string | null }>`: The list of users. If `nextCursor` is not null, there is a next page.
</div>
**Example:**
```typescript
const users = await stackServerApp.listUsers();
const users = await stackServerApp.listUsers({ limit: 20 });
console.log(users);
if (users.nextCursor) {
const nextPageUsers = await stackServerApp.listUsers({ cursor: users.nextCursor, limit: 20 });
console.log(nextPageUsers);
}
```

View File

@ -107,6 +107,9 @@ type InnerCrudTypeOf<S extends InnerCrudSchema> =
& (S['readSchema'] extends {} ? { List: {
items: yup.InferType<S['readSchema']>[],
is_paginated: boolean,
pagination?: {
next_cursor: string | null,
},
}, } : {});
export type CrudTypeOf<S extends CrudSchema> = {

View File

@ -169,10 +169,28 @@ export class StackServerInterface extends StackClientInterface {
return result.items;
}
async listServerUsers(): Promise<UsersCrud['Server']['Read'][]> {
const response = await this.sendServerRequest("/users", {}, null);
const result = await response.json() as UsersCrud['Server']['List'];
return result.items;
async listServerUsers(options: {
cursor?: string,
limit?: number,
orderBy?: 'signedUpAt',
desc?: boolean,
query?: string,
}): Promise<UsersCrud['Server']['List']> {
const searchParams = new URLSearchParams(filterUndefined({
cursor: options.cursor,
limit: options.limit?.toString(),
desc: options.desc?.toString(),
...options.orderBy ? {
order_by: {
signedUpAt: "signed_up_at",
}[options.orderBy],
} : {},
...options.query ? {
query: options.query,
} : {},
}));
const response = await this.sendServerRequest("/users?" + searchParams.toString(), {}, null);
return await response.json();
}
async listServerTeams(options?: {

View File

@ -12,6 +12,8 @@ import {
ColumnDef,
ColumnFiltersState,
GlobalFiltering,
OnChangeFn,
PaginationState,
SortingState,
Table as TableType,
VisibilityState,
@ -27,57 +29,19 @@ import {
import React from "react";
import { DataTablePagination } from "./pagination";
import { DataTableToolbar } from "./toolbar";
import { ColumnFilter } from "@tanstack/react-table";
interface DataTableProps<TData, TValue> {
export function TableView<TData, TValue>(props: {
table: TableType<TData>,
columns: ColumnDef<TData, TValue>[],
data: TData[],
toolbarRender?: (table: TableType<TData>) => React.ReactNode,
defaultVisibility?: VisibilityState,
}
export function DataTable<TData, TValue>({
columns,
data,
toolbarRender,
defaultVisibility,
}: DataTableProps<TData, TValue>) {
const [rowSelection, setRowSelection] = React.useState({});
const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>(defaultVisibility || {});
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([]);
const [sorting, setSorting] = React.useState<SortingState>([]);
const table: TableType<TData> = useReactTable({
data,
columns,
state: {
sorting,
columnVisibility,
rowSelection,
columnFilters,
},
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
onColumnVisibilityChange: setColumnVisibility,
getColumnCanGlobalFilter: (c) => c.columnDef.enableGlobalFilter ?? GlobalFiltering.getDefaultOptions!(table).getColumnCanGlobalFilter!(c),
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFacetedRowModel: getFacetedRowModel(),
getFacetedUniqueValues: getFacetedUniqueValues(),
autoResetAll: false,
});
}) {
return (
<div className="space-y-4">
<DataTableToolbar table={table} toolbarRender={toolbarRender} />
<DataTableToolbar table={props.table} toolbarRender={props.toolbarRender} />
<div className="rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
{props.table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
@ -95,8 +59,8 @@ export function DataTable<TData, TValue>({
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row) => (
{props.table.getRowModel().rows.length ? (
props.table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
@ -114,7 +78,7 @@ export function DataTable<TData, TValue>({
) : (
<TableRow>
<TableCell
colSpan={columns.length}
colSpan={props.columns.length}
className="h-24 text-center"
>
No results.
@ -124,7 +88,114 @@ export function DataTable<TData, TValue>({
</TableBody>
</Table>
</div>
<DataTablePagination table={table} />
<DataTablePagination table={props.table} />
</div>
);
}
type DataTableProps<TData, TValue> = {
columns: ColumnDef<TData, TValue>[],
data: TData[],
toolbarRender?: (table: TableType<TData>) => React.ReactNode,
defaultVisibility?: VisibilityState,
}
export function DataTable<TData, TValue>({
columns,
data,
toolbarRender,
defaultVisibility,
}: DataTableProps<TData, TValue>) {
const [sorting, setSorting] = React.useState<SortingState>([]);
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([]);
const [pagination, setPagination] = React.useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
});
const [globalFilter, setGlobalFilter] = React.useState<any>();
return <DataTableManual
columns={columns}
data={data}
toolbarRender={toolbarRender}
defaultVisibility={defaultVisibility}
sorting={sorting}
setSorting={setSorting}
columnFilters={columnFilters}
setColumnFilters={setColumnFilters}
manualPagination={false}
manualFiltering={false}
pagination={pagination}
setPagination={setPagination}
globalFilter={globalFilter}
setGlobalFilter={setGlobalFilter}
/>;
}
type DataTableServerProps<TData, TValue> = DataTableProps<TData, TValue> & {
sorting?: SortingState,
setSorting?: OnChangeFn<SortingState>,
pagination?: PaginationState,
setPagination?: OnChangeFn<PaginationState>,
rowCount?: number,
columnFilters?: ColumnFiltersState,
setColumnFilters?: OnChangeFn<ColumnFiltersState>,
manualPagination?: boolean,
manualFiltering?: boolean,
globalFilter?: any,
setGlobalFilter?: OnChangeFn<any>,
}
export function DataTableManual<TData, TValue>({
columns,
data,
toolbarRender,
defaultVisibility,
sorting,
setSorting,
pagination,
setPagination,
rowCount,
columnFilters,
setColumnFilters,
globalFilter,
setGlobalFilter,
manualPagination = true,
manualFiltering = true,
}: DataTableServerProps<TData, TValue>) {
const [rowSelection, setRowSelection] = React.useState({});
const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>(defaultVisibility || {});
const table: TableType<TData> = useReactTable({
data,
columns,
state: {
sorting,
columnVisibility,
rowSelection,
columnFilters,
pagination,
globalFilter: globalFilter,
},
enableRowSelection: true,
onGlobalFilterChange: setGlobalFilter,
onRowSelectionChange: setRowSelection,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
onColumnVisibilityChange: setColumnVisibility,
onPaginationChange: setPagination,
getColumnCanGlobalFilter: (c) => c.columnDef.enableGlobalFilter ?? GlobalFiltering.getDefaultOptions!(table).getColumnCanGlobalFilter!(c),
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFacetedRowModel: getFacetedRowModel(),
getFacetedUniqueValues: getFacetedUniqueValues(),
autoResetAll: false,
manualPagination,
manualFiltering,
rowCount,
});
return <TableView table={table} columns={columns} toolbarRender={toolbarRender} />;
}

View File

@ -111,11 +111,6 @@ export function DataTableFacetedFilter<TData, TValue>({
<option.icon className="mr-2 h-4 w-4 text-muted-foreground" />
)}
<span>{option.label}</span>
{facets?.get(option.value) && (
<span className="ml-auto flex h-4 w-4 items-center justify-center font-mono text-xs">
{facets.get(option.value)}
</span>
)}
</CommandItem>
);
})}

View File

@ -1,9 +1,4 @@
import {
ChevronLeftIcon,
ChevronRightIcon,
DoubleArrowLeftIcon,
DoubleArrowRightIcon,
} from "@radix-ui/react-icons";
import { ChevronLeftIcon, ChevronRightIcon } from "@radix-ui/react-icons";
import {
Button, Select,
SelectContent,
@ -23,10 +18,9 @@ export function DataTablePagination<TData>({
return (
<div className="flex items-center justify-between px-2 flex-col sm:flex-row gap-y-4 sm:gap-y-0">
<div className="flex-1 text-sm text-muted-foreground">
{table.getFilteredSelectedRowModel().rows.length === 0 ?
`${table.getFilteredRowModel().rows.length} row(s) found` :
`${table.getFilteredSelectedRowModel().rows.length} of ${table.getFilteredRowModel().rows.length} row(s) selected`
}
{table.getFilteredSelectedRowModel().rows.length !== 0 ?
`${table.getFilteredSelectedRowModel().rows.length} of ${table.getFilteredRowModel().rows.length} row(s) selected` :
undefined}
</div>
<div className="flex items-center gap-x-6 lg:gap-x-8 flex-col sm:flex-row gap-y-4 sm:gap-y-0">
<div className="flex items-center space-x-2">
@ -51,19 +45,10 @@ export function DataTablePagination<TData>({
</div>
<div className="flex items-center gap-4">
<div className="flex items-center justify-center text-sm font-medium">
Page {table.getState().pagination.pageIndex + 1} of{" "}
{table.getPageCount()}
{table.getState().pagination.pageIndex + 1}
{/* {" / "}{table.getPageCount()} */}
</div>
<div className="flex items-center space-x-2">
<Button
variant="outline"
className="hidden h-8 w-8 p-0 lg:flex"
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">Go to first page</span>
<DoubleArrowLeftIcon className="h-4 w-4" />
</Button>
<Button
variant="outline"
className="h-8 w-8 p-0"
@ -82,15 +67,6 @@ export function DataTablePagination<TData>({
<span className="sr-only">Go to next page</span>
<ChevronRightIcon className="h-4 w-4" />
</Button>
<Button
variant="outline"
className="hidden h-8 w-8 p-0 lg:flex"
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">Go to last page</span>
<DoubleArrowRightIcon className="h-4 w-4" />
</Button>
</div>
</div>
</div>

View File

@ -1584,8 +1584,14 @@ class _StackServerAppImpl<HasTokenStore extends boolean, ProjectId extends strin
private readonly _currentServerUserCache = createCacheBySession(async (session) => {
return await this._interface.getServerUserByToken(session);
});
private readonly _serverUsersCache = createCache(async () => {
return await this._interface.listServerUsers();
private readonly _serverUsersCache = createCache<[
cursor?: string,
limit?: number,
orderBy?: 'signedUpAt',
desc?: boolean,
query?: string,
], UsersCrud['Server']['List']>(async ([cursor, limit, orderBy, desc, query]) => {
return await this._interface.listServerUsers({ cursor, limit, orderBy, desc, query });
});
private readonly _serverUserCache = createCache<string[], UsersCrud['Server']['Read'] | null>(async ([userId]) => {
const user = await this._interface.getServerUserById(userId);
@ -2030,15 +2036,30 @@ class _StackServerAppImpl<HasTokenStore extends boolean, ProjectId extends strin
}, [crud]);
}
async listUsers(): Promise<ServerUser[]> {
const crud = await this._serverUsersCache.getOrWait([], "write-only");
return crud.map((j) => this._serverUserFromCrud(j));
async listUsers(options?: ServerListUsersOptions): Promise<ServerUser[] & { nextCursor: string | null }> {
if (!options?.limit) {
const result: ServerUser[] = [];
let nextCursor: string | null = options?.cursor ?? null;
while (true) {
const crud = await this._serverUsersCache.getOrWait([nextCursor ?? undefined, 200, options?.orderBy, options?.desc, options?.query], "write-only");
result.push(...crud.items.map((j) => this._serverUserFromCrud(j)));
nextCursor = crud.pagination?.next_cursor ?? null;
if (nextCursor === null) break;
}
(result as any).nextCursor = null;
return result as any;
} else {
const crud = await this._serverUsersCache.getOrWait([options.cursor, options.limit, options.orderBy, options.desc, options.query], "write-only");
const result: any = crud.items.map((j) => this._serverUserFromCrud(j));
result.nextCursor = crud.pagination?.next_cursor ?? null;
return result as any;
}
}
useUsers(): ServerUser[] {
const crud = useAsyncCache(this._serverUsersCache, [], "useServerUsers()");
return useMemo(() => {
return crud.map((j) => this._serverUserFromCrud(j));
return crud.items.map((j) => this._serverUserFromCrud(j));
}, [crud]);
}
@ -3061,6 +3082,14 @@ export type ServerTeam = {
removeUser(userId: string): Promise<void>,
} & Team;
export type ServerListUsersOptions = {
cursor?: string,
limit?: number,
orderBy?: 'signedUpAt',
desc?: boolean,
query?: string,
};
export type ServerTeamCreateOptions = TeamCreateOptions;
function serverTeamCreateOptionsToCrud(options: ServerTeamCreateOptions): TeamsCrud["Server"]["Create"] {
return teamCreateOptionsToCrud(options);
@ -3210,9 +3239,11 @@ export type StackServerApp<HasTokenStore extends boolean = boolean, ProjectId ex
getUser(options: GetUserOptions<HasTokenStore> & { or: 'redirect' }): Promise<ProjectCurrentServerUser<ProjectId>>,
getUser(options: GetUserOptions<HasTokenStore> & { or: 'throw' }): Promise<ProjectCurrentServerUser<ProjectId>>,
getUser(options?: GetUserOptions<HasTokenStore>): Promise<ProjectCurrentServerUser<ProjectId> | null>,
listUsers(options?: ServerListUsersOptions): Promise<ServerUser[] & { nextCursor: string | null }>,
}
& AsyncStoreProperty<"user", [id: string], ServerUser | null, false>
& AsyncStoreProperty<"users", [], ServerUser[], true>
& Omit<AsyncStoreProperty<"users", [], ServerUser[], true>, "listUsers">
& AsyncStoreProperty<"team", [id: string], ServerTeam | null, false>
& AsyncStoreProperty<"teams", [], ServerTeam[], true>
& StackClientApp<HasTokenStore, ProjectId>