mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-04 21:04:37 +08:00
Query/filtering with list users endpoint (#314)
This commit is contained in:
parent
ef7707ad1f
commit
fd8d166e04
@ -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);
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 }) {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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])
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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}
|
||||
/>;
|
||||
}
|
||||
|
||||
@ -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> },
|
||||
}
|
||||
|
||||
@ -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", {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
|
||||
@ -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> = {
|
||||
|
||||
@ -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?: {
|
||||
|
||||
@ -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} />;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user