diff --git a/apps/backend/prisma/migrations/20241026024655_user_sorting_indices/migration.sql b/apps/backend/prisma/migrations/20241026024655_user_sorting_indices/migration.sql new file mode 100644 index 000000000..59fe3d4fa --- /dev/null +++ b/apps/backend/prisma/migrations/20241026024655_user_sorting_indices/migration.sql @@ -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); diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 4a9f83f63..a72c9cda1 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -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 diff --git a/apps/backend/src/app/api/v1/auth/password/send-reset-code/route.tsx b/apps/backend/src/app/api/v1/auth/password/send-reset-code/route.tsx index 21188c47b..776219108 100644 --- a/apps/backend/src/app/api/v1/auth/password/send-reset-code/route.tsx +++ b/apps/backend/src/app/api/v1/auth/password/send-reset-code/route.tsx @@ -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, diff --git a/apps/backend/src/app/api/v1/users/crud.tsx b/apps/backend/src/app/api/v1/users/crud.tsx index c9733220e..1dd88cc90 100644 --- a/apps/backend/src/app/api/v1/users/crud.tsx +++ b/apps/backend/src/app/api/v1/users/crud.tsx @@ -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 }) { diff --git a/apps/backend/src/lib/openapi.tsx b/apps/backend/src/lib/openapi.tsx index 45337da17..f19bd991f 100644 --- a/apps/backend/src/lib/openapi.tsx +++ b/apps/backend/src/lib/openapi.tsx @@ -165,7 +165,7 @@ function parseRouteHandler(options: { return result; } -function getFieldSchema(field: yup.SchemaFieldDescription, crudOperation?: Capitalize): { type: string, items?: any, properties?: any, required?: any } | undefined { +function getFieldSchema(field: yup.SchemaFieldDescription, crudOperation?: Capitalize): { 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) { diff --git a/apps/backend/src/route-handlers/crud-handler.tsx b/apps/backend/src/route-handlers/crud-handler.tsx index f2569a7b9..ca83c7223 100644 --- a/apps/backend/src/route-handlers/crud-handler.tsx +++ b/apps/backend/src/route-handlers/crud-handler.tsx @@ -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().oneOf([undefined]) diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/page-client.tsx index cad6a3a65..206f4dfd5 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/page-client.tsx @@ -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 ( Create User} - existingEmails={allUsers.map(u => u.primaryEmail).filter(e => e !== null) as string[]} />} > - {allUsers.length > 0 ? null : ( + {users.length > 0 ? null : ( Congratulations on starting your project! Check the documentation to add your first users. )} - + ); } diff --git a/apps/dashboard/src/components/data-table/user-table.tsx b/apps/dashboard/src/components/data-table/user-table.tsx index 1166292cb..3188d4b82 100644 --- a/apps/dashboard/src/components/data-table/user-table.tsx +++ b/apps/dashboard/src/components/data-table/user-table.tsx @@ -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(table: Table) { return ( <> - ({ + options={['otp', 'password', ...allProviders].map((provider) => ({ value: provider, label: provider, }))} @@ -35,7 +35,7 @@ function userToolbarRender(table: Table) { { value: "verified", label: "verified" }, { value: "unverified", label: "unverified" }, ]} - /> + /> */} ); } @@ -198,13 +198,13 @@ export const getCommonUserColumns = () => [ accessorKey: "id", header: ({ column }) => , cell: ({ row }) => {row.original.id}, - enableGlobalFilter: true, + enableSorting: false, }, { accessorKey: "displayName", header: ({ column }) => , cell: ({ row }) => {row.original.displayName ?? '–'}, - enableGlobalFilter: true, + enableSorting: false, }, { accessorKey: "primaryEmail", @@ -214,19 +214,19 @@ export const getCommonUserColumns = () => [ icon={row.original.emailVerified === "unverified" && }> {row.original.primaryEmail} , - enableGlobalFilter: true, + enableSorting: false, }, { accessorKey: "lastActiveAt", header: ({ column }) => , cell: ({ row }) => , + enableSorting: false, }, { accessorKey: "emailVerified", header: ({ column }) => , cell: ({ row }) => {row.original.emailVerified === 'verified' ? '✓' : '✗'}, - filterFn: standardFilterFn, - enableGlobalFilter: false, + enableSorting: false, }, ] satisfies ColumnDef[]; @@ -236,7 +236,7 @@ const columns: ColumnDef[] = [ accessorKey: "authTypes", header: ({ column }) => , cell: ({ row }) => , - 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 ([]); + const [sorting, setSorting] = React.useState([]); + const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 10 }); + const [cursors, setCursors] = useState>({}); + const [columnFilters, setColumnFilters] = useState([]); + const [globalFilter, setGlobalFilter] = useState(); + 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 ; } diff --git a/apps/e2e/tests/backend/endpoints/api/v1/team-memberships.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/team-memberships.test.ts index 214876e07..6c1d481b3 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/team-memberships.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/team-memberships.test.ts @@ -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": , }, ], + "pagination": { "next_cursor": null }, }, "headers": Headers {