diff --git a/apps/backend/package.json b/apps/backend/package.json index c57a66dc09..838fa0d9a9 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -7,7 +7,7 @@ "typecheck": "tsc --noEmit", "with-env": "dotenv -c development --", "with-env:prod": "dotenv -c --", - "dev": "concurrently \"next dev --port 8102\" \"pnpm run watch-docs\" \"pnpm run prisma-studio\"", + "dev": "concurrently -k \"next dev --port 8102\" \"pnpm run watch-docs\" \"pnpm run prisma-studio\"", "build": "pnpm run codegen && next build", "analyze-bundle": "ANALYZE_BUNDLE=1 pnpm run build", "start": "next start --port 8102", diff --git a/apps/backend/prisma/migrations/20240811194548_client_team_creation/migration.sql b/apps/backend/prisma/migrations/20240811194548_client_team_creation/migration.sql new file mode 100644 index 0000000000..ae17b11e43 --- /dev/null +++ b/apps/backend/prisma/migrations/20240811194548_client_team_creation/migration.sql @@ -0,0 +1,8 @@ +-- AlterTable +ALTER TABLE "ProjectConfig" ADD COLUMN "clientTeamCreationEnabled" BOOLEAN NOT NULL DEFAULT false; + +-- Update existing rows +UPDATE "ProjectConfig" SET "clientTeamCreationEnabled" = false; + +-- Remove the default constraint +ALTER TABLE "ProjectConfig" ALTER COLUMN "clientTeamCreationEnabled" DROP DEFAULT; \ No newline at end of file diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index e64c6a4d7e..ef63bcf8de 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -39,12 +39,12 @@ model ProjectConfig { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - allowLocalhost Boolean - signUpEnabled Boolean @default(true) - credentialEnabled Boolean - magicLinkEnabled Boolean - - createTeamOnSignUp Boolean + allowLocalhost Boolean + signUpEnabled Boolean @default(true) + credentialEnabled Boolean + magicLinkEnabled Boolean + createTeamOnSignUp Boolean + clientTeamCreationEnabled Boolean projects Project[] oauthProviderConfigs OAuthProviderConfig[] @@ -219,11 +219,11 @@ model ProjectUser { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - project Project @relation("ProjectUsers", fields: [projectId], references: [id], onDelete: Cascade) - projectUserRefreshTokens ProjectUserRefreshToken[] - projectUserAuthorizationCodes ProjectUserAuthorizationCode[] - projectUserOAuthAccounts ProjectUserOAuthAccount[] - teamMembers TeamMember[] + project Project @relation("ProjectUsers", fields: [projectId], references: [id], onDelete: Cascade) + projectUserRefreshTokens ProjectUserRefreshToken[] + projectUserAuthorizationCodes ProjectUserAuthorizationCode[] + projectUserOAuthAccounts ProjectUserOAuthAccount[] + teamMembers TeamMember[] // @deprecated projectUserEmailVerificationCode ProjectUserEmailVerificationCode[] @@ -239,8 +239,8 @@ model ProjectUser { passwordHash String? authWithEmail Boolean - requiresTotpMfa Boolean @default(false) - totpSecret Bytes? + requiresTotpMfa Boolean @default(false) + totpSecret Bytes? serverMetadata Json? clientMetadata Json? @@ -362,7 +362,7 @@ model VerificationCode { usedAt DateTime? redirectUrl String? - method Json @default("null") + method Json @default("null") data Json diff --git a/apps/backend/prisma/seed.ts b/apps/backend/prisma/seed.ts index 06f9813742..78e70a806a 100644 --- a/apps/backend/prisma/seed.ts +++ b/apps/backend/prisma/seed.ts @@ -60,6 +60,7 @@ async function seed() { credentialEnabled: true, magicLinkEnabled: true, createTeamOnSignUp: false, + clientTeamCreationEnabled: true, }, }, }, diff --git a/apps/backend/src/app/api/v1/auth/otp/sign-in/verification-code-handler.tsx b/apps/backend/src/app/api/v1/auth/otp/sign-in/verification-code-handler.tsx index 0eca0f06ca..8faa6a1878 100644 --- a/apps/backend/src/app/api/v1/auth/otp/sign-in/verification-code-handler.tsx +++ b/apps/backend/src/app/api/v1/auth/otp/sign-in/verification-code-handler.tsx @@ -1,13 +1,10 @@ -import * as yup from "yup"; -import { yupObject, yupString, yupNumber, yupBoolean, yupArray, yupMixed } from "@stackframe/stack-shared/dist/schema-fields"; -import { prismaClient } from "@/prisma-client"; +import { sendEmailFromTemplate } from "@/lib/emails"; import { createAuthTokens } from "@/lib/tokens"; +import { prismaClient } from "@/prisma-client"; import { createVerificationCodeHandler } from "@/route-handlers/verification-code-handler"; -import { signInResponseSchema } from "@stackframe/stack-shared/dist/schema-fields"; import { VerificationCodeType } from "@prisma/client"; import { UsersCrud } from "@stackframe/stack-shared/dist/interface/crud/users"; -import { sendEmailFromTemplate } from "@/lib/emails"; -import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; +import { signInResponseSchema, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; import { createMfaRequiredError } from "../../mfa/sign-in/verification-code-handler"; export const signInVerificationCodeHandler = createVerificationCodeHandler({ diff --git a/apps/backend/src/app/api/v1/internal/projects/crud.tsx b/apps/backend/src/app/api/v1/internal/projects/crud.tsx index c925c06740..7389fee9bb 100644 --- a/apps/backend/src/app/api/v1/internal/projects/crud.tsx +++ b/apps/backend/src/app/api/v1/internal/projects/crud.tsx @@ -39,6 +39,7 @@ export const internalProjectsCrudHandlers = createLazyProxy(() => createCrudHand magicLinkEnabled: data.config?.magic_link_enabled ?? false, allowLocalhost: data.config?.allow_localhost ?? true, createTeamOnSignUp: data.config?.create_team_on_sign_up ?? false, + clientTeamCreationEnabled: data.config?.client_team_creation_enabled ?? false, domains: data.config?.domains ? { create: data.config.domains.map(item => ({ domain: item.domain, diff --git a/apps/backend/src/app/api/v1/projects/current/crud.tsx b/apps/backend/src/app/api/v1/projects/current/crud.tsx index 1f7aba68db..640ab6d387 100644 --- a/apps/backend/src/app/api/v1/projects/current/crud.tsx +++ b/apps/backend/src/app/api/v1/projects/current/crud.tsx @@ -256,6 +256,7 @@ export const projectsCrudHandlers = createLazyProxy(() => createCrudHandlers(pro signUpEnabled: data.config?.sign_up_enabled, credentialEnabled: data.config?.credential_enabled, magicLinkEnabled: data.config?.magic_link_enabled, + clientTeamCreationEnabled: data.config?.client_team_creation_enabled, allowLocalhost: data.config?.allow_localhost, createTeamOnSignUp: data.config?.create_team_on_sign_up, domains: data.config?.domains ? { diff --git a/apps/backend/src/app/api/v1/team-invitations/accept/verification-code-handler.tsx b/apps/backend/src/app/api/v1/team-invitations/accept/verification-code-handler.tsx index 335eaf476b..c82f2c9d38 100644 --- a/apps/backend/src/app/api/v1/team-invitations/accept/verification-code-handler.tsx +++ b/apps/backend/src/app/api/v1/team-invitations/accept/verification-code-handler.tsx @@ -21,7 +21,6 @@ export const teamInvitationCodeHandler = createVerificationCodeHandler({ tags: ["Teams"], }, }, - userRequired: true, type: VerificationCodeType.TEAM_INVITATION, data: yupObject({ team_id: yupString().required(), @@ -60,6 +59,8 @@ export const teamInvitationCodeHandler = createVerificationCodeHandler({ }); }, async handler(project, {}, data, body, user) { + if (!user) throw new KnownErrors.UserAuthenticationRequired(); + const oldMembership = await prismaClient.teamMember.findUnique({ where: { projectId_projectUserId_teamId: { @@ -86,6 +87,8 @@ export const teamInvitationCodeHandler = createVerificationCodeHandler({ }; }, async details(project, {}, data, body, user) { + if (!user) throw new KnownErrors.UserAuthenticationRequired(); + const team = await teamsCrudHandlers.adminRead({ project, team_id: data.team_id, diff --git a/apps/backend/src/app/api/v1/team-member-profiles/crud.tsx b/apps/backend/src/app/api/v1/team-member-profiles/crud.tsx index 959a5d3da6..4ff2f0fabb 100644 --- a/apps/backend/src/app/api/v1/team-member-profiles/crud.tsx +++ b/apps/backend/src/app/api/v1/team-member-profiles/crud.tsx @@ -8,8 +8,9 @@ import { teamMemberProfilesCrud } from "@stackframe/stack-shared/dist/interface/ import { userIdOrMeSchema, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; import { StatusError, throwErr } from "@stackframe/stack-shared/dist/utils/errors"; import { createLazyProxy } from "@stackframe/stack-shared/dist/utils/proxies"; +import { userFullInclude, userPrismaToCrud } from "../users/crud"; -const fullInclude = { projectUser: true }; +const fullInclude = { projectUser: { include: userFullInclude } }; function prismaToCrud(prisma: Prisma.TeamMemberGetPayload<{ include: typeof fullInclude }>) { return { @@ -17,6 +18,7 @@ function prismaToCrud(prisma: Prisma.TeamMemberGetPayload<{ include: typeof full user_id: prisma.projectUserId, display_name: prisma.displayName ?? prisma.projectUser.displayName, profile_image_url: prisma.profileImageUrl ?? prisma.projectUser.profileImageUrl, + user: userPrismaToCrud(prisma.projectUser), }; } @@ -37,7 +39,7 @@ export const teamMemberProfilesCrudHandlers = createLazyProxy(() => createCrudHa // - list users in their own team if they have the $read_members permission // - list their own profile - const currentUserId = auth.user?.id ?? throwErr("Client must be authenticated"); + const currentUserId = auth.user?.id ?? throwErr(new KnownErrors.CannotGetOwnUserWithoutUser()); if (!query.team_id) { throw new StatusError(StatusError.BadRequest, 'team_id is required for access type client'); @@ -85,14 +87,17 @@ export const teamMemberProfilesCrudHandlers = createLazyProxy(() => createCrudHa return await prismaClient.$transaction(async (tx) => { const userId = getIdFromUserIdOrMe(params.user_id, auth.user); - if (auth.type === 'client' && userId !== auth.user?.id) { - await ensureUserTeamPermissionExists(tx, { - project: auth.project, - teamId: params.team_id, - userId: auth.user?.id ?? throwErr("Client must be authenticated"), - permissionId: '$read_members', - errorType: 'required', - }); + if (auth.type === 'client') { + const currentUserId = auth.user?.id ?? throwErr(new KnownErrors.CannotGetOwnUserWithoutUser()); + if (userId !== currentUserId) { + await ensureUserTeamPermissionExists(tx, { + project: auth.project, + teamId: params.team_id, + userId: currentUserId, + permissionId: '$read_members', + errorType: 'required', + }); + } } await ensureTeamMembershipExists(tx, { projectId: auth.project.id, teamId: params.team_id, userId: userId }); @@ -120,14 +125,17 @@ export const teamMemberProfilesCrudHandlers = createLazyProxy(() => createCrudHa return await prismaClient.$transaction(async (tx) => { const userId = getIdFromUserIdOrMe(params.user_id, auth.user); - if (auth.type === 'client' && userId !== auth.user?.id) { - throw new StatusError(StatusError.Forbidden, 'Cannot update another user\'s profile'); + if (auth.type === 'client') { + const currentUserId = auth.user?.id ?? throwErr(new KnownErrors.CannotGetOwnUserWithoutUser()); + if (userId !== currentUserId) { + throw new StatusError(StatusError.Forbidden, 'Cannot update another user\'s profile'); + } } await ensureTeamMembershipExists(tx, { projectId: auth.project.id, teamId: params.team_id, - userId: auth.user?.id ?? throwErr("Client must be authenticated"), + userId, }); const db = await tx.teamMember.update({ diff --git a/apps/backend/src/app/api/v1/team-memberships/crud.tsx b/apps/backend/src/app/api/v1/team-memberships/crud.tsx index 4ee218e218..f63efb1cc7 100644 --- a/apps/backend/src/app/api/v1/team-memberships/crud.tsx +++ b/apps/backend/src/app/api/v1/team-memberships/crud.tsx @@ -99,14 +99,18 @@ export const teamMembershipsCrudHandlers = createLazyProxy(() => createCrudHandl // Users are always allowed to remove themselves from a team // Only users with the $remove_members permission can remove other users - if (auth.type === 'client' && userId !== auth.user?.id) { - await ensureUserTeamPermissionExists(tx, { - project: auth.project, - teamId: params.team_id, - userId: auth.user?.id ?? throwErr('auth.user is null'), - permissionId: "$remove_members", - errorType: 'required', - }); + if (auth.type === 'client') { + const currentUserId = auth.user?.id ?? throwErr(new KnownErrors.CannotGetOwnUserWithoutUser()); + + if (userId !== currentUserId) { + await ensureUserTeamPermissionExists(tx, { + project: auth.project, + teamId: params.team_id, + userId: auth.user?.id ?? throwErr('auth.user is null'), + permissionId: "$remove_members", + errorType: 'required', + }); + } } await ensureTeamMembershipExists(tx, { diff --git a/apps/backend/src/app/api/v1/team-permissions/crud.tsx b/apps/backend/src/app/api/v1/team-permissions/crud.tsx index 6cf314e2fa..e79575ae7d 100644 --- a/apps/backend/src/app/api/v1/team-permissions/crud.tsx +++ b/apps/backend/src/app/api/v1/team-permissions/crud.tsx @@ -3,9 +3,10 @@ import { ensureTeamMembershipExists, ensureUserTeamPermissionExists } from "@/li import { prismaClient } from "@/prisma-client"; import { createCrudHandlers } from "@/route-handlers/crud-handler"; import { getIdFromUserIdOrMe } from "@/route-handlers/utils"; +import { KnownErrors } from "@stackframe/stack-shared"; import { teamPermissionsCrud } from '@stackframe/stack-shared/dist/interface/crud/team-permissions'; import { teamPermissionDefinitionIdSchema, userIdOrMeSchema, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; -import { StatusError } from "@stackframe/stack-shared/dist/utils/errors"; +import { StatusError, throwErr } from "@stackframe/stack-shared/dist/utils/errors"; import { createLazyProxy } from "@stackframe/stack-shared/dist/utils/proxies"; export const teamPermissionsCrudHandlers = createLazyProxy(() => createCrudHandlers(teamPermissionsCrud, { @@ -52,8 +53,12 @@ export const teamPermissionsCrudHandlers = createLazyProxy(() => createCrudHandl }, async onList({ auth, query }) { const userId = getIdFromUserIdOrMe(query.user_id, auth.user); - if (auth.type === 'client' && userId !== auth.user?.id) { - throw new StatusError(StatusError.Forbidden, 'Client can only list permissions for their own user. user_id must be either "me" or the ID of the current user'); + if (auth.type === 'client') { + const currentUserId = auth.user?.id || throwErr(new KnownErrors.CannotGetOwnUserWithoutUser()); + + if (userId !== currentUserId) { + throw new StatusError(StatusError.Forbidden, 'Client can only list permissions for their own user. user_id must be either "me" or the ID of the current user'); + } } return await prismaClient.$transaction(async (tx) => { diff --git a/apps/backend/src/app/api/v1/teams/crud.tsx b/apps/backend/src/app/api/v1/teams/crud.tsx index 76099c7584..f73925c8f4 100644 --- a/apps/backend/src/app/api/v1/teams/crud.tsx +++ b/apps/backend/src/app/api/v1/teams/crud.tsx @@ -30,6 +30,14 @@ export const teamsCrudHandlers = createLazyProxy(() => createCrudHandlers(teamsC team_id: yupString().uuid().required(), }), onCreate: async ({ query, auth, data }) => { + if (auth.type === 'client' && !auth.user) { + throw new KnownErrors.UserAuthenticationRequired(); + } + + if (auth.type === 'client' && !auth.project.config.client_team_creation_enabled) { + throw new StatusError(StatusError.Forbidden, 'Client team creation is disabled for this project'); + } + const db = await prismaClient.$transaction(async (tx) => { const db = await tx.team.create({ data: { @@ -69,7 +77,7 @@ export const teamsCrudHandlers = createLazyProxy(() => createCrudHandlers(teamsC await ensureTeamMembershipExists(tx, { projectId: auth.project.id, teamId: params.team_id, - userId: auth.user?.id ?? throwErr('auth.user is null'), + userId: auth.user?.id ?? throwErr(new KnownErrors.UserAuthenticationRequired()), }); } @@ -97,7 +105,7 @@ export const teamsCrudHandlers = createLazyProxy(() => createCrudHandlers(teamsC await ensureUserTeamPermissionExists(tx, { project: auth.project, teamId: params.team_id, - userId: auth.user?.id ?? throwErr('auth.user is null'), + userId: auth.user?.id ?? throwErr(new KnownErrors.UserAuthenticationRequired()), permissionId: "$update_team", errorType: 'required', }); @@ -134,7 +142,7 @@ export const teamsCrudHandlers = createLazyProxy(() => createCrudHandlers(teamsC await ensureUserTeamPermissionExists(tx, { project: auth.project, teamId: params.team_id, - userId: auth.user?.id ?? throwErr('auth.user is null'), + userId: auth.user?.id ?? throwErr(new KnownErrors.UserAuthenticationRequired()), permissionId: "$delete_team", errorType: 'required', }); @@ -160,8 +168,12 @@ export const teamsCrudHandlers = createLazyProxy(() => createCrudHandlers(teamsC }, onList: async ({ query, auth }) => { const userId = getIdFromUserIdOrMe(query.user_id, auth.user); - if (auth.type === 'client' && userId !== auth.user?.id) { - throw new StatusError(StatusError.Forbidden, 'Client can only list teams for their own user. user_id must be either "me" or the ID of the current user'); + if (auth.type === 'client') { + const currentUserId = auth.user?.id || throwErr(new KnownErrors.CannotGetOwnUserWithoutUser()); + + if (userId !== currentUserId) { + throw new StatusError(StatusError.Forbidden, 'Client can only list teams for their own user. user_id must be either "me" or the ID of the current user'); + } } const db = await prismaClient.team.findMany({ diff --git a/apps/backend/src/app/api/v1/users/crud.tsx b/apps/backend/src/app/api/v1/users/crud.tsx index b29030d24e..3db7e02227 100644 --- a/apps/backend/src/app/api/v1/users/crud.tsx +++ b/apps/backend/src/app/api/v1/users/crud.tsx @@ -1,4 +1,5 @@ import { ensureTeamMembershipExists, ensureUserExist } from "@/lib/request-checks"; +import { sendUserCreatedWebhook, sendUserDeletedWebhook, sendUserUpdatedWebhook } from "@/lib/webhooks"; import { prismaClient } from "@/prisma-client"; import { createCrudHandlers } from "@/route-handlers/crud-handler"; import { BooleanTrue, Prisma } from "@prisma/client"; @@ -6,15 +7,13 @@ 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 { decodeBase64 } from "@stackframe/stack-shared/dist/utils/bytes"; import { StackAssertionError, StatusError, captureError, throwErr } from "@stackframe/stack-shared/dist/utils/errors"; import { hashPassword } from "@stackframe/stack-shared/dist/utils/password"; import { createLazyProxy } from "@stackframe/stack-shared/dist/utils/proxies"; import { teamPrismaToCrud } from "../teams/crud"; -import { sendUserCreatedWebhook, sendUserDeletedWebhook, sendUserUpdatedWebhook } from "@/lib/webhooks"; -import { getPasswordError } from "@stackframe/stack-shared/dist/helpers/password"; -import { decodeBase64, encodeBase64 } from "@stackframe/stack-shared/dist/utils/bytes"; -const fullInclude = { +export const userFullInclude = { projectUserOAuthAccounts: { include: { providerConfig: true, @@ -30,7 +29,7 @@ const fullInclude = { }, } satisfies Prisma.ProjectUserInclude; -const prismaToCrud = (prisma: Prisma.ProjectUserGetPayload<{ include: typeof fullInclude}>): UsersCrud["Admin"]["Read"] => { +export const userPrismaToCrud = (prisma: Prisma.ProjectUserGetPayload<{ include: typeof userFullInclude}>): UsersCrud["Admin"]["Read"] => { const selectedTeamMembers = prisma.teamMembers; if (selectedTeamMembers.length > 1) { throw new StackAssertionError("User cannot have more than one selected team; this should never happen"); @@ -112,14 +111,14 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC projectUserId: params.user_id, }, }, - include: fullInclude, + include: userFullInclude, }); if (!db) { throw new KnownErrors.UserNotFound(); } - return prismaToCrud(db); + return userPrismaToCrud(db); }, onList: async ({ auth, query }) => { const db = await prismaClient.projectUser.findMany({ @@ -133,11 +132,11 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC }, } : {}, }, - include: fullInclude, + include: userFullInclude, }); return { - items: db.map(prismaToCrud), + items: db.map(userPrismaToCrud), is_paginated: false, }; }, @@ -187,10 +186,10 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC } : undefined, totpSecret: data.totp_secret_base64 == null ? data.totp_secret_base64 : Buffer.from(decodeBase64(data.totp_secret_base64)), }, - include: fullInclude, + include: userFullInclude, }); - const result = prismaToCrud(db); + const result = userPrismaToCrud(db); await sendUserCreatedWebhook({ projectId: auth.project.id, @@ -257,13 +256,13 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC requiresTotpMfa: data.totp_secret_base64 === undefined ? undefined : (data.totp_secret_base64 !== null), totpSecret: data.totp_secret_base64 == null ? data.totp_secret_base64 : Buffer.from(decodeBase64(data.totp_secret_base64)), }, - include: fullInclude, + include: userFullInclude, }); return db; }); - const result = prismaToCrud(db); + const result = userPrismaToCrud(db); await sendUserUpdatedWebhook({ projectId: auth.project.id, @@ -283,7 +282,7 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC projectUserId: params.user_id, }, }, - include: fullInclude, + include: userFullInclude, }); }); diff --git a/apps/backend/src/lib/projects.tsx b/apps/backend/src/lib/projects.tsx index 0677899947..ffa32b1123 100644 --- a/apps/backend/src/lib/projects.tsx +++ b/apps/backend/src/lib/projects.tsx @@ -103,6 +103,7 @@ export function projectPrismaToCrud( credential_enabled: prisma.config.credentialEnabled, magic_link_enabled: prisma.config.magicLinkEnabled, create_team_on_sign_up: prisma.config.createTeamOnSignUp, + client_team_creation_enabled: prisma.config.clientTeamCreationEnabled, domains: prisma.config.domains .map((domain) => ({ domain: domain.domain, diff --git a/apps/backend/src/lib/request-checks.tsx b/apps/backend/src/lib/request-checks.tsx index e0b7fda929..2d6b6ee12c 100644 --- a/apps/backend/src/lib/request-checks.tsx +++ b/apps/backend/src/lib/request-checks.tsx @@ -4,6 +4,7 @@ import { ProjectsCrud } from "@stackframe/stack-shared/dist/interface/crud/proje import { ProviderType, sharedProviders, standardProviders } from "@stackframe/stack-shared/dist/utils/oauth"; import { listUserTeamPermissions } from "./permissions"; import { PrismaTransaction } from "./types"; +import { UsersCrud } from "@stackframe/stack-shared/dist/interface/crud/users"; async function _getTeamMembership( diff --git a/apps/backend/src/route-handlers/verification-code-handler.tsx b/apps/backend/src/route-handlers/verification-code-handler.tsx index 083d15eeaf..6c04c9a5bb 100644 --- a/apps/backend/src/route-handlers/verification-code-handler.tsx +++ b/apps/backend/src/route-handlers/verification-code-handler.tsx @@ -43,7 +43,6 @@ export function createVerificationCodeHandler< RequestBody extends {} & DeepPartial, Response extends SmartResponse, DetailsResponse extends SmartResponse | undefined, - UserRequired extends boolean, SendCodeExtraOptions extends {}, Method extends {}, >(options: { @@ -56,7 +55,6 @@ export function createVerificationCodeHandler< data: yup.Schema, method: yup.Schema, requestBody?: yup.ObjectSchema, - userRequired?: UserRequired, detailsResponse?: yup.Schema, response: yup.Schema, send?( @@ -69,21 +67,21 @@ export function createVerificationCodeHandler< method: Method, data: Data, body: RequestBody, - user: UserRequired extends true ? UsersCrud["Admin"]["Read"] : undefined + user:UsersCrud["Admin"]["Read"] | undefined ): Promise, handler( project: ProjectsCrud["Admin"]["Read"], method: Method, data: Data, body: RequestBody, - user: UserRequired extends true ? UsersCrud["Admin"]["Read"] : undefined + user: UsersCrud["Admin"]["Read"] | undefined, ): Promise, details?: DetailsResponse extends SmartResponse ? (( project: ProjectsCrud["Admin"]["Read"], method: Method, data: Data, body: RequestBody, - user: UserRequired extends true ? UsersCrud["Admin"]["Read"] : undefined + user: UsersCrud["Admin"]["Read"] | undefined ) => Promise) : undefined, }): VerificationCodeHandler { const createHandler = (type: 'post' | 'check' | 'details') => createSmartRouteHandler({ @@ -91,7 +89,7 @@ export function createVerificationCodeHandler< request: yupObject({ auth: yupObject({ project: adaptSchema.required(), - user: options.userRequired ? adaptSchema.required() : adaptSchema, + user: adaptSchema, }).required(), body: yupObject({ code: yupString().required(), @@ -149,7 +147,7 @@ export function createVerificationCodeHandler< }, }); - return await options.handler(auth.project, validatedMethod, validatedData, requestBody as any, auth.user as any); + return await options.handler(auth.project, validatedMethod, validatedData, requestBody as any, auth.user); } case 'check': { return { @@ -215,4 +213,4 @@ export function createVerificationCodeHandler< checkHandler: createHandler('check'), detailsHandler: (options.detailsResponse ? createHandler('details') : undefined) as any, }; -} +} \ No newline at end of file diff --git a/apps/dashboard/prisma/schema.prisma b/apps/dashboard/prisma/schema.prisma index df1b841f0b..ef63bcf8de 100644 --- a/apps/dashboard/prisma/schema.prisma +++ b/apps/dashboard/prisma/schema.prisma @@ -39,12 +39,12 @@ model ProjectConfig { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - allowLocalhost Boolean - signUpEnabled Boolean @default(true) - credentialEnabled Boolean - magicLinkEnabled Boolean - - createTeamOnSignUp Boolean + allowLocalhost Boolean + signUpEnabled Boolean @default(true) + credentialEnabled Boolean + magicLinkEnabled Boolean + createTeamOnSignUp Boolean + clientTeamCreationEnabled Boolean projects Project[] oauthProviderConfigs OAuthProviderConfig[] @@ -219,11 +219,11 @@ model ProjectUser { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - project Project @relation("ProjectUsers", fields: [projectId], references: [id], onDelete: Cascade) - projectUserRefreshTokens ProjectUserRefreshToken[] - projectUserAuthorizationCodes ProjectUserAuthorizationCode[] - projectUserOAuthAccounts ProjectUserOAuthAccount[] - teamMembers TeamMember[] + project Project @relation("ProjectUsers", fields: [projectId], references: [id], onDelete: Cascade) + projectUserRefreshTokens ProjectUserRefreshToken[] + projectUserAuthorizationCodes ProjectUserAuthorizationCode[] + projectUserOAuthAccounts ProjectUserOAuthAccount[] + teamMembers TeamMember[] // @deprecated projectUserEmailVerificationCode ProjectUserEmailVerificationCode[] @@ -239,8 +239,8 @@ model ProjectUser { passwordHash String? authWithEmail Boolean - requiresTotpMfa Boolean @default(false) - totpSecret Bytes? + requiresTotpMfa Boolean @default(false) + totpSecret Bytes? serverMetadata Json? clientMetadata Json? @@ -362,9 +362,7 @@ model VerificationCode { usedAt DateTime? redirectUrl String? - // @deprecated in favor of method (TODO next-release; this is no longer used) - email String @default("") - method Json + method Json @default("null") data Json diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/domains/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/domains/page-client.tsx index c23152b3fc..d2bcb2b5e5 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/domains/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/domains/page-client.tsx @@ -1,11 +1,10 @@ "use client"; -import { ActionCell } from "@/components/data-table/elements/cells"; import { SmartFormDialog } from "@/components/form-dialog"; import { SettingCard, SettingSwitch } from "@/components/settings"; import { DomainConfigJson } from "@/temporary-types"; import { AdminProject } from "@stackframe/stack"; import { urlSchema } from "@stackframe/stack-shared/dist/schema-fields"; -import { ActionDialog, Alert, Button, Table, TableBody, TableCell, TableHead, TableHeader, TableRow, Typography } from "@stackframe/stack-ui"; +import { ActionCell, ActionDialog, Alert, Button, Table, TableBody, TableCell, TableHead, TableHeader, TableRow, Typography } from "@stackframe/stack-ui"; import React from "react"; import * as yup from "yup"; import { PageLayout } from "../page-layout"; diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/emails/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/emails/page-client.tsx index 93fc9af5b1..78d17fbc23 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/emails/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/emails/page-client.tsx @@ -1,6 +1,5 @@ "use client"; -import { ActionCell } from "@/components/data-table/elements/cells"; import { FormDialog } from "@/components/form-dialog"; import { InputField, SelectField } from "@/components/form-fields"; import { useRouter } from "@/components/router"; @@ -10,7 +9,7 @@ import { AdminProject } from "@stackframe/stack"; import { Reader } from "@stackframe/stack-emails/dist/editor/email-builder/index"; import { EMAIL_TEMPLATES_METADATA, convertEmailSubjectVariables, convertEmailTemplateMetadataExampleValues, convertEmailTemplateVariables, validateEmailTemplateContent } from "@stackframe/stack-emails/dist/utils"; import { EmailTemplateType } from "@stackframe/stack-shared/dist/interface/crud/email-templates"; -import { ActionDialog, Button, Card, SimpleTooltip, Typography } from "@stackframe/stack-ui"; +import { ActionCell, ActionDialog, Button, Card, SimpleTooltip, Typography } from "@stackframe/stack-ui"; import { useMemo, useState } from "react"; import * as yup from "yup"; import { PageLayout } from "../page-layout"; diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/team-settings/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/team-settings/page-client.tsx index 2fab6f9618..ed795c1dd1 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/team-settings/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/team-settings/page-client.tsx @@ -62,6 +62,23 @@ export default function PageClient() { return ( + + { + await project.update({ + config: { + clientTeamCreationEnabled: checked, + }, + }); + }} + /> + + When enabled, users are allowed to create teams from the client-side. If disabled, teams can only be created on the dashboard/server. + + + (table: Table) { return ( diff --git a/apps/dashboard/src/components/data-table/team-table.tsx b/apps/dashboard/src/components/data-table/team-table.tsx index 85b419c33a..e39ef52bb2 100644 --- a/apps/dashboard/src/components/data-table/team-table.tsx +++ b/apps/dashboard/src/components/data-table/team-table.tsx @@ -2,16 +2,12 @@ import { useAdminApp } from "@/app/(main)/(protected)/projects/[projectId]/use-admin-app"; import { useRouter } from "@/components/router"; import { ServerTeam } from '@stackframe/stack'; -import { ActionDialog, Typography } from "@stackframe/stack-ui"; +import { ActionCell, ActionDialog, DataTable, DataTableColumnHeader, DateCell, SearchToolbarItem, TextCell, Typography } from "@stackframe/stack-ui"; import { ColumnDef, Row, Table } from "@tanstack/react-table"; import { useState } from "react"; import * as yup from "yup"; import { FormDialog } from "../form-dialog"; import { InputField } from "../form-fields"; -import { ActionCell, DateCell, TextCell } from "./elements/cells"; -import { DataTableColumnHeader } from "./elements/column-header"; -import { DataTable } from "./elements/data-table"; -import { SearchToolbarItem } from "./elements/toolbar-items"; function toolbarRender(table: Table) { return ( diff --git a/apps/dashboard/src/components/data-table/user-table.tsx b/apps/dashboard/src/components/data-table/user-table.tsx index 0c2e9b3cdc..99fde822b2 100644 --- a/apps/dashboard/src/components/data-table/user-table.tsx +++ b/apps/dashboard/src/components/data-table/user-table.tsx @@ -4,18 +4,12 @@ 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 { ActionDialog, CopyField, SimpleTooltip, Typography } from "@stackframe/stack-ui"; +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 * as yup from "yup"; import { FormDialog } from "../form-dialog"; import { DateField, InputField, SwitchField, TextAreaField } from "../form-fields"; -import { ActionCell, AvatarCell, BadgeCell, DateCell, TextCell } from "./elements/cells"; -import { DataTableColumnHeader } from "./elements/column-header"; -import { DataTable } from "./elements/data-table"; -import { DataTableFacetedFilter } from "./elements/faceted-filter"; -import { SearchToolbarItem } from "./elements/toolbar-items"; -import { arrayFilterFn, standardFilterFn } from "./elements/utils"; export type ExtendedServerUser = ServerUser & { authTypes: string[], diff --git a/apps/dashboard/src/lib/projects.tsx b/apps/dashboard/src/lib/projects.tsx index a6d79e9362..7c99c925bb 100644 --- a/apps/dashboard/src/lib/projects.tsx +++ b/apps/dashboard/src/lib/projects.tsx @@ -182,6 +182,7 @@ export async function createProject( credentialEnabled: !!projectOptions.config?.credentialEnabled, magicLinkEnabled: !!projectOptions.config?.magicLinkEnabled, createTeamOnSignUp: !!projectOptions.config?.createTeamOnSignUp, + clientTeamCreationEnabled: false, emailServiceConfig: { create: { proxiedEmailServiceConfig: { diff --git a/apps/e2e/tests/backend/backend-helpers.ts b/apps/e2e/tests/backend/backend-helpers.ts index d6a751c6b1..8362789c71 100644 --- a/apps/e2e/tests/backend/backend-helpers.ts +++ b/apps/e2e/tests/backend/backend-helpers.ts @@ -691,7 +691,7 @@ export namespace Project { export namespace Team { export async function create(options: { accessType?: "client" | "server" } = {}, body?: any) { const response = await niceBackendFetch("/api/v1/teams?add_current_user=true", { - accessType: options.accessType ?? "client", + accessType: options.accessType ?? "server", method: "POST", body: { display_name: body?.display_name || 'New Team', diff --git a/apps/e2e/tests/backend/endpoints/api/v1/internal/projects.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/internal/projects.test.ts index 54f08a78ab..6045201fb5 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/internal/projects.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/internal/projects.test.ts @@ -66,6 +66,7 @@ it("creates a new project", async ({ expect }) => { "body": { "config": { "allow_localhost": true, + "client_team_creation_enabled": false, "create_team_on_sign_up": false, "credential_enabled": true, "domains": [], @@ -110,6 +111,7 @@ it("creates a new project with different configurations", async ({ expect }) => "body": { "config": { "allow_localhost": false, + "client_team_creation_enabled": false, "create_team_on_sign_up": false, "credential_enabled": false, "domains": [], @@ -159,6 +161,7 @@ it("creates a new project with different configurations", async ({ expect }) => "body": { "config": { "allow_localhost": true, + "client_team_creation_enabled": false, "create_team_on_sign_up": false, "credential_enabled": true, "domains": [], @@ -210,6 +213,7 @@ it("creates a new project with different configurations", async ({ expect }) => "body": { "config": { "allow_localhost": true, + "client_team_creation_enabled": false, "create_team_on_sign_up": false, "credential_enabled": true, "domains": [], @@ -254,6 +258,7 @@ it("creates a new project with different configurations", async ({ expect }) => "body": { "config": { "allow_localhost": true, + "client_team_creation_enabled": false, "create_team_on_sign_up": false, "credential_enabled": true, "domains": [], @@ -307,6 +312,7 @@ it("creates a new project with different configurations", async ({ expect }) => "body": { "config": { "allow_localhost": true, + "client_team_creation_enabled": false, "create_team_on_sign_up": false, "credential_enabled": true, "domains": [ @@ -353,6 +359,7 @@ it("lists the current projects after creating a new project", async ({ expect }) { "config": { "allow_localhost": true, + "client_team_creation_enabled": false, "create_team_on_sign_up": false, "credential_enabled": true, "domains": [], diff --git a/apps/e2e/tests/backend/endpoints/api/v1/projects.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/projects.test.ts index ca880d2780..44f3182d27 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/projects.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/projects.test.ts @@ -33,6 +33,7 @@ it("gets current project (internal)", async ({ expect }) => { "status": 200, "body": { "config": { + "client_team_creation_enabled": true, "credential_enabled": true, "enabled_oauth_providers": [ { "id": "facebook" }, @@ -65,6 +66,7 @@ it("creates and updates the basic project information of a project", async ({ ex "body": { "config": { "allow_localhost": true, + "client_team_creation_enabled": false, "create_team_on_sign_up": false, "credential_enabled": true, "domains": [], @@ -106,6 +108,7 @@ it("updates the basic project configuration", async ({ expect }) => { "body": { "config": { "allow_localhost": false, + "client_team_creation_enabled": false, "create_team_on_sign_up": false, "credential_enabled": false, "domains": [], @@ -147,6 +150,7 @@ it("updates the project domains configuration", async ({ expect }) => { "body": { "config": { "allow_localhost": true, + "client_team_creation_enabled": false, "create_team_on_sign_up": false, "credential_enabled": true, "domains": [ @@ -195,6 +199,7 @@ it("updates the project domains configuration", async ({ expect }) => { "body": { "config": { "allow_localhost": true, + "client_team_creation_enabled": false, "create_team_on_sign_up": false, "credential_enabled": true, "domains": [ @@ -250,6 +255,7 @@ it("updates the project email configuration", async ({ expect }) => { "body": { "config": { "allow_localhost": true, + "client_team_creation_enabled": false, "create_team_on_sign_up": false, "credential_enabled": true, "domains": [], @@ -301,6 +307,7 @@ it("updates the project email configuration", async ({ expect }) => { "body": { "config": { "allow_localhost": true, + "client_team_creation_enabled": false, "create_team_on_sign_up": false, "credential_enabled": true, "domains": [], @@ -346,6 +353,7 @@ it("updates the project email configuration", async ({ expect }) => { "body": { "config": { "allow_localhost": true, + "client_team_creation_enabled": false, "create_team_on_sign_up": false, "credential_enabled": true, "domains": [], @@ -383,6 +391,7 @@ it("updates the project email configuration", async ({ expect }) => { "body": { "config": { "allow_localhost": true, + "client_team_creation_enabled": false, "create_team_on_sign_up": false, "credential_enabled": true, "domains": [], @@ -426,6 +435,7 @@ it("updates the project email configuration", async ({ expect }) => { "body": { "config": { "allow_localhost": true, + "client_team_creation_enabled": false, "create_team_on_sign_up": false, "credential_enabled": true, "domains": [], @@ -528,6 +538,7 @@ it("updates the project oauth configuration", async ({ expect }) => { "body": { "config": { "allow_localhost": true, + "client_team_creation_enabled": false, "create_team_on_sign_up": false, "credential_enabled": true, "domains": [], @@ -573,6 +584,7 @@ it("updates the project oauth configuration", async ({ expect }) => { "body": { "config": { "allow_localhost": true, + "client_team_creation_enabled": false, "create_team_on_sign_up": false, "credential_enabled": true, "domains": [], @@ -620,6 +632,7 @@ it("updates the project oauth configuration", async ({ expect }) => { "body": { "config": { "allow_localhost": true, + "client_team_creation_enabled": false, "create_team_on_sign_up": false, "credential_enabled": true, "domains": [], @@ -692,6 +705,7 @@ it("updates the project oauth configuration", async ({ expect }) => { "body": { "config": { "allow_localhost": true, + "client_team_creation_enabled": false, "create_team_on_sign_up": false, "credential_enabled": true, "domains": [], @@ -752,6 +766,7 @@ it("updates the project oauth configuration", async ({ expect }) => { "body": { "config": { "allow_localhost": true, + "client_team_creation_enabled": false, "create_team_on_sign_up": false, "credential_enabled": true, "domains": [], diff --git a/apps/e2e/tests/backend/endpoints/api/v1/team-permissions.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/team-permissions.test.ts index 801dcef7af..87318e1484 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/team-permissions.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/team-permissions.test.ts @@ -186,6 +186,7 @@ it("can customize default team permissions", async ({ expect }) => { "body": { "config": { "allow_localhost": true, + "client_team_creation_enabled": false, "create_team_on_sign_up": false, "credential_enabled": true, "domains": [], diff --git a/apps/e2e/tests/backend/endpoints/api/v1/teams.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/teams.test.ts index 451d66552b..fa1ee86a53 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/teams.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/teams.test.ts @@ -89,6 +89,7 @@ it("creates a team on the client", async ({ expect }) => { NiceResponse { "status": 201, "body": { + "created_at_millis": , "display_name": "New Team", "id": "", "profile_image_url": null, @@ -122,6 +123,7 @@ it("gets a specific team", async ({ expect }) => { NiceResponse { "status": 201, "body": { + "created_at_millis": , "display_name": "New Team", "id": "", "profile_image_url": null, @@ -158,6 +160,7 @@ it("gets a team that the user is not part of on the server", async ({ expect }) NiceResponse { "status": 201, "body": { + "created_at_millis": , "display_name": "New Team", "id": "", "profile_image_url": null, @@ -195,6 +198,7 @@ it("should not be allowed to get a team that the user is not part of on the clie NiceResponse { "status": 201, "body": { + "created_at_millis": , "display_name": "New Team", "id": "", "profile_image_url": null, diff --git a/examples/demo/src/app/teams/create-team.tsx b/examples/demo/src/app/teams/create-team.tsx deleted file mode 100644 index 8df0abd4a5..0000000000 --- a/examples/demo/src/app/teams/create-team.tsx +++ /dev/null @@ -1,25 +0,0 @@ -"use client"; - -import { useUser } from "@stackframe/stack"; -import { Button, Input } from "@stackframe/stack-ui"; -import React from "react"; - -export function CreateTeam() { - const [displayName, setDisplayName] = React.useState(''); - const user = useUser({ or: 'redirect' }); - - return ( -
- setDisplayName(e.target.value)} placeholder='Team Name' /> - -
- ); -} diff --git a/examples/demo/src/app/teams/my-teams.tsx b/examples/demo/src/app/teams/my-teams.tsx deleted file mode 100644 index 4abf4839c4..0000000000 --- a/examples/demo/src/app/teams/my-teams.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import React from 'react'; -import { SelectedTeamSwitcher } from "@stackframe/stack"; -import { stackServerApp } from 'src/stack'; -import { CreateTeam } from './create-team'; - -export default async function MyTeams() { - const user = await stackServerApp.getUser({ or: 'redirect' }); - const teams = await user.listTeams(); - - - return ( -
- - - - {teams.length > 0 && teams.map((team) => ( -
-

{team.displayName}, Open

-
- ))} -
- ); -} diff --git a/examples/demo/src/app/teams/page.tsx b/examples/demo/src/app/teams/page.tsx index 3e5850609a..89369dc9e6 100644 --- a/examples/demo/src/app/teams/page.tsx +++ b/examples/demo/src/app/teams/page.tsx @@ -1,27 +1,13 @@ +'use client'; -import { stackServerApp } from "src/stack"; -import MyTeams from "./my-teams"; +import { SelectedTeamSwitcher, useUser } from "@stackframe/stack"; -export default async function Page() { - const teams = await stackServerApp.listTeams(); - const user = await stackServerApp.getUser({ or: 'redirect' }); - const userTeams = await user.listTeams(); +export default function Page() { + const user = useUser({ or: 'redirect' }); return ( -
-

My Teams

- - - -

All Teams

- - {teams.map((team) => ( -
-

{team.displayName}

- Open -

{userTeams.some(t => t.id === team.id) ? '(You are a member)' : ''}

-
- ))} +
+
); } diff --git a/packages/stack-shared/src/interface/clientInterface.ts b/packages/stack-shared/src/interface/clientInterface.ts index 040629cc3e..87e3e0600e 100644 --- a/packages/stack-shared/src/interface/clientInterface.ts +++ b/packages/stack-shared/src/interface/clientInterface.ts @@ -7,11 +7,13 @@ import { generateSecureRandomString } from '../utils/crypto'; import { StackAssertionError, throwErr } from '../utils/errors'; import { globalVar } from '../utils/globals'; import { ReadonlyJson } from '../utils/json'; +import { filterUndefined } from '../utils/objects'; import { Result } from "../utils/results"; import { deindent } from '../utils/strings'; import { CurrentUserCrud } from './crud/current-user'; import { ConnectedAccountAccessTokenCrud } from './crud/oauth'; import { InternalProjectsCrud, ProjectsCrud } from './crud/projects'; +import { TeamMemberProfilesCrud } from './crud/team-member-profiles'; import { TeamPermissionsCrud } from './crud/team-permissions'; import { TeamsCrud } from './crud/teams'; @@ -866,6 +868,78 @@ export class StackClientInterface { return user; } + async listTeamMemberProfiles( + options: { + teamId?: string, + userId?: string, + }, + session: InternalSession, + ): Promise { + const response = await this.sendClientRequest( + "/team-member-profiles?" + new URLSearchParams(filterUndefined({ + team_id: options.teamId, + user_id: options.userId, + })), + {}, + session, + ); + const result = await response.json() as TeamMemberProfilesCrud['Client']['List']; + return result.items; + } + + async getTeamMemberProfile( + options: { + teamId: string, + userId: string, + }, + session: InternalSession, + ): Promise { + const response = await this.sendClientRequest( + `/team-member-profiles/${options.teamId}/${options.userId}`, + {}, + session, + ); + return await response.json(); + } + + async leaveTeam( + teamId: string, + session: InternalSession, + ) { + await this.sendClientRequest( + `/team-memberships/${teamId}/me`, + { + method: "DELETE", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({}), + }, + session, + ); + } + + async updateTeamMemberProfile( + options: { + teamId: string, + userId: string, + profile: TeamMemberProfilesCrud['Client']['Update'], + }, + session: InternalSession, + ) { + await this.sendClientRequest( + `/team-member-profiles/${options.teamId}/${options.userId}`, + { + method: "PATCH", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify(options.profile), + }, + session, + ); + } + async listCurrentUserTeamPermissions( options: { teamId: string, diff --git a/packages/stack-shared/src/interface/crud/projects.ts b/packages/stack-shared/src/interface/crud/projects.ts index 24605b6fde..d91318970d 100644 --- a/packages/stack-shared/src/interface/crud/projects.ts +++ b/packages/stack-shared/src/interface/crud/projects.ts @@ -49,6 +49,7 @@ export const projectsCrudServerReadSchema = yupObject({ sign_up_enabled: schemaFields.projectSignUpEnabledSchema.required(), credential_enabled: schemaFields.projectCredentialEnabledSchema.required(), magic_link_enabled: schemaFields.projectMagicLinkEnabledSchema.required(), + client_team_creation_enabled: schemaFields.projectClientTeamCreationEnabledSchema.required(), oauth_providers: yupArray(oauthProviderSchema.required()).required(), enabled_oauth_providers: yupArray(enabledOAuthProviderSchema.required()).required(), domains: yupArray(domainSchema.required()).required(), @@ -66,6 +67,7 @@ export const projectsCrudClientReadSchema = yupObject({ sign_up_enabled: schemaFields.projectSignUpEnabledSchema.required(), credential_enabled: schemaFields.projectCredentialEnabledSchema.required(), magic_link_enabled: schemaFields.projectMagicLinkEnabledSchema.required(), + client_team_creation_enabled: schemaFields.projectClientTeamCreationEnabledSchema.required(), enabled_oauth_providers: yupArray(enabledOAuthProviderSchema.required()).required(), }).required(), }).required(); @@ -79,6 +81,7 @@ export const projectsCrudServerUpdateSchema = yupObject({ sign_up_enabled: schemaFields.projectSignUpEnabledSchema.optional(), credential_enabled: schemaFields.projectCredentialEnabledSchema.optional(), magic_link_enabled: schemaFields.projectMagicLinkEnabledSchema.optional(), + client_team_creation_enabled: schemaFields.projectClientTeamCreationEnabledSchema.optional(), allow_localhost: schemaFields.projectAllowLocalhostSchema.optional(), email_config: emailConfigSchema.optional().default(undefined), domains: yupArray(domainSchema.required()).optional().default(undefined), diff --git a/packages/stack-shared/src/interface/crud/team-member-profiles.ts b/packages/stack-shared/src/interface/crud/team-member-profiles.ts index e732502a57..b5cf0b6ad7 100644 --- a/packages/stack-shared/src/interface/crud/team-member-profiles.ts +++ b/packages/stack-shared/src/interface/crud/team-member-profiles.ts @@ -1,6 +1,7 @@ import { CrudTypeOf, createCrud } from "../../crud"; import * as schemaFields from "../../schema-fields"; import { yupObject } from "../../schema-fields"; +import { usersCrudServerReadSchema } from "./users"; export const teamMemberProfilesCrudClientReadSchema = yupObject({ @@ -10,6 +11,10 @@ export const teamMemberProfilesCrudClientReadSchema = yupObject({ profile_image_url: schemaFields.teamMemberProfileImageUrlSchema.nullable().defined(), }).required(); +export const teamMemberProfilesCrudServerReadSchema = teamMemberProfilesCrudClientReadSchema.concat(yupObject({ + user: usersCrudServerReadSchema.required(), +})).required(); + export const teamMemberProfilesCrudClientUpdateSchema = yupObject({ display_name: schemaFields.teamMemberDisplayNameSchema.optional(), profile_image_url: schemaFields.teamMemberProfileImageUrlSchema.nullable().optional(), @@ -17,6 +22,7 @@ export const teamMemberProfilesCrudClientUpdateSchema = yupObject({ export const teamMemberProfilesCrud = createCrud({ clientReadSchema: teamMemberProfilesCrudClientReadSchema, + serverReadSchema: teamMemberProfilesCrudServerReadSchema, clientUpdateSchema: teamMemberProfilesCrudClientUpdateSchema, docs: { clientList: { diff --git a/packages/stack-shared/src/interface/serverInterface.ts b/packages/stack-shared/src/interface/serverInterface.ts index f20379d659..bad0ab6fe8 100644 --- a/packages/stack-shared/src/interface/serverInterface.ts +++ b/packages/stack-shared/src/interface/serverInterface.ts @@ -9,6 +9,7 @@ import { } from "./clientInterface"; import { CurrentUserCrud } from "./crud/current-user"; import { ConnectedAccountAccessTokenCrud } from "./crud/oauth"; +import { TeamMemberProfilesCrud } from "./crud/team-member-profiles"; import { TeamMembershipsCrud } from "./crud/team-memberships"; import { TeamPermissionsCrud } from "./crud/team-permissions"; import { TeamsCrud } from "./crud/teams"; @@ -118,6 +119,34 @@ export class StackServerInterface extends StackClientInterface { return Result.ok(user); } + async listServerTeamMemberProfiles( + options: { + teamId: string, + }, + ): Promise { + const response = await this.sendServerRequest( + "/team-member-profiles?team_id=" + options.teamId, + {}, + null, + ); + const result = await response.json() as TeamMemberProfilesCrud['Server']['List']; + return result.items; + } + + async getServerTeamMemberProfile( + options: { + teamId: string, + userId: string, + }, + ): Promise { + const response = await this.sendServerRequest( + `/team-member-profiles/${options.teamId}/${options.userId}`, + {}, + null, + ); + return await response.json(); + } + async listServerTeamPermissions( options: { userId?: string, @@ -295,6 +324,25 @@ export class StackServerInterface extends StackClientInterface { }; } + async leaveServerTeam( + options: { + teamId: string, + userId: string, + }, + ) { + await this.sendClientRequest( + `/team-memberships/${options.teamId}/${options.userId}`, + { + method: "DELETE", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({}), + }, + null, + ); + } + async grantServerTeamUserPermission(teamId: string, userId: string, permissionId: string) { await this.sendServerRequest( `/team-permissions/${teamId}/${userId}/${permissionId}`, diff --git a/packages/stack-shared/src/schema-fields.ts b/packages/stack-shared/src/schema-fields.ts index d3aaf34c58..1c450b7a24 100644 --- a/packages/stack-shared/src/schema-fields.ts +++ b/packages/stack-shared/src/schema-fields.ts @@ -155,6 +155,7 @@ export const projectConfigIdSchema = yupString().meta({ openapiField: { descript export const projectAllowLocalhostSchema = yupBoolean().meta({ openapiField: { description: 'Whether localhost is allowed as a domain for this project. Should only be allowed in development mode', exampleValue: true } }); export const projectCreateTeamOnSignUpSchema = yupBoolean().meta({ openapiField: { description: 'Whether a team should be created for each user that signs up', exampleValue: true } }); export const projectMagicLinkEnabledSchema = yupBoolean().meta({ openapiField: { description: 'Whether magic link authentication is enabled for this project', exampleValue: true } }); +export const projectClientTeamCreationEnabledSchema = yupBoolean().meta({ openapiField: { description: 'Whether client users can create teams', exampleValue: true } }); export const projectSignUpEnabledSchema = yupBoolean().meta({ openapiField: { description: 'Whether users can sign up new accounts, or whether they are only allowed to sign in to existing accounts. Regardless of this option, the server API can always create new users with the `POST /users` endpoint.', exampleValue: true } }); export const projectCredentialEnabledSchema = yupBoolean().meta({ openapiField: { description: 'Whether email password authentication is enabled for this project', exampleValue: true } }); // Project OAuth config diff --git a/packages/stack-ui/package.json b/packages/stack-ui/package.json index ab66792186..5e254f6f40 100644 --- a/packages/stack-ui/package.json +++ b/packages/stack-ui/package.json @@ -65,6 +65,8 @@ "@radix-ui/react-toggle-group": "^1.0.4", "@radix-ui/react-tooltip": "^1.0.7", "class-variance-authority": "^0.7.0", + "@tanstack/react-table": "^8.17.0", + "export-to-csv": "^1.3.0", "clsx": "^2.0.0", "cmdk": "^1.0.0", "input-otp": "^1.2.4", diff --git a/apps/dashboard/src/components/data-table/elements/cells.tsx b/packages/stack-ui/src/components/data-table/cells.tsx similarity index 92% rename from apps/dashboard/src/components/data-table/elements/cells.tsx rename to packages/stack-ui/src/components/data-table/cells.tsx index 72c49c06f2..e5fb9b9e18 100644 --- a/apps/dashboard/src/components/data-table/elements/cells.tsx +++ b/packages/stack-ui/src/components/data-table/cells.tsx @@ -1,10 +1,8 @@ 'use client'; import { DotsHorizontalIcon } from "@radix-ui/react-icons"; -import { Avatar, AvatarImage, Badge, Button, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@stackframe/stack-ui"; import React, { useEffect, useRef, useState } from "react"; -import { cn } from "@/lib/utils"; -import { SimpleTooltip } from "@stackframe/stack-ui"; +import { SimpleTooltip, cn, Avatar, AvatarImage, Badge, Button, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "../.."; export function TextCell(props: { children: React.ReactNode, size?: number, icon?: React.ReactNode }) { const textRef = useRef(null); diff --git a/apps/dashboard/src/components/data-table/elements/column-header.tsx b/packages/stack-ui/src/components/data-table/column-header.tsx similarity index 56% rename from apps/dashboard/src/components/data-table/elements/column-header.tsx rename to packages/stack-ui/src/components/data-table/column-header.tsx index 50f37c69f6..30c2a8fb95 100644 --- a/apps/dashboard/src/components/data-table/elements/column-header.tsx +++ b/packages/stack-ui/src/components/data-table/column-header.tsx @@ -1,13 +1,24 @@ -import { cn } from "@/lib/utils"; -import { ArrowDownIcon, ArrowUpIcon, EyeNoneIcon } from "@radix-ui/react-icons"; +import { cn } from "../.."; import { Button, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@stackframe/stack-ui"; import { Column } from "@tanstack/react-table"; +import { LucideIcon, EyeOff, ArrowUp, ArrowDown } from "lucide-react"; interface DataTableColumnHeaderProps extends React.HTMLAttributes { column: Column, columnTitle: React.ReactNode, } +function Item(props: { icon: LucideIcon, onClick: () => void, children: React.ReactNode }) { + return ( + +
+ + {props.children} +
+
+ ); +} + export function DataTableColumnHeader({ column, columnTitle, @@ -24,30 +35,20 @@ export function DataTableColumnHeader({ > {columnTitle} {column.getIsSorted() === "desc" ? ( - + ) : column.getIsSorted() === "asc" ? ( - + ) : null} {column.getCanSort() && ( <> - column.toggleSorting(false)}> - - Asc - - column.toggleSorting(true)}> - - Desc - - + column.toggleSorting(false)}>Asc + column.toggleSorting(true)}>Desc )} - column.toggleVisibility(false)}> - - Hide - + column.toggleVisibility(false)}>Hide
diff --git a/apps/dashboard/src/components/data-table/elements/data-table.tsx b/packages/stack-ui/src/components/data-table/data-table.tsx similarity index 100% rename from apps/dashboard/src/components/data-table/elements/data-table.tsx rename to packages/stack-ui/src/components/data-table/data-table.tsx diff --git a/apps/dashboard/src/components/data-table/elements/faceted-filter.tsx b/packages/stack-ui/src/components/data-table/faceted-filter.tsx similarity index 99% rename from apps/dashboard/src/components/data-table/elements/faceted-filter.tsx rename to packages/stack-ui/src/components/data-table/faceted-filter.tsx index dd10232189..07ad576e3e 100644 --- a/apps/dashboard/src/components/data-table/elements/faceted-filter.tsx +++ b/packages/stack-ui/src/components/data-table/faceted-filter.tsx @@ -1,4 +1,4 @@ -import { cn } from "@/lib/utils"; +import { cn } from "../.."; import { CheckIcon } from "@radix-ui/react-icons"; import { Badge, Button, Command, diff --git a/packages/stack-ui/src/components/data-table/index.tsx b/packages/stack-ui/src/components/data-table/index.tsx new file mode 100644 index 0000000000..01c202ed10 --- /dev/null +++ b/packages/stack-ui/src/components/data-table/index.tsx @@ -0,0 +1,9 @@ +export * from "./cells"; +export * from "./column-header"; +export * from "./data-table"; +export * from "./faceted-filter"; +export * from "./pagination"; +export * from "./toolbar-items"; +export * from "./toolbar"; +export * from "./utils"; +export * from "./view-options"; \ No newline at end of file diff --git a/apps/dashboard/src/components/data-table/elements/pagination.tsx b/packages/stack-ui/src/components/data-table/pagination.tsx similarity index 100% rename from apps/dashboard/src/components/data-table/elements/pagination.tsx rename to packages/stack-ui/src/components/data-table/pagination.tsx diff --git a/apps/dashboard/src/components/data-table/elements/toolbar-items.tsx b/packages/stack-ui/src/components/data-table/toolbar-items.tsx similarity index 90% rename from apps/dashboard/src/components/data-table/elements/toolbar-items.tsx rename to packages/stack-ui/src/components/data-table/toolbar-items.tsx index fa880789cf..ecb80c5680 100644 --- a/apps/dashboard/src/components/data-table/elements/toolbar-items.tsx +++ b/packages/stack-ui/src/components/data-table/toolbar-items.tsx @@ -1,4 +1,4 @@ -import { Input } from "@stackframe/stack-ui"; +import { Input } from "../.."; import { Table } from "@tanstack/react-table"; export function SearchToolbarItem(props: { table: Table, keyName: string, placeholder: string }) { diff --git a/apps/dashboard/src/components/data-table/elements/toolbar.tsx b/packages/stack-ui/src/components/data-table/toolbar.tsx similarity index 100% rename from apps/dashboard/src/components/data-table/elements/toolbar.tsx rename to packages/stack-ui/src/components/data-table/toolbar.tsx diff --git a/apps/dashboard/src/components/data-table/elements/utils.tsx b/packages/stack-ui/src/components/data-table/utils.tsx similarity index 100% rename from apps/dashboard/src/components/data-table/elements/utils.tsx rename to packages/stack-ui/src/components/data-table/utils.tsx diff --git a/apps/dashboard/src/components/data-table/elements/view-options.tsx b/packages/stack-ui/src/components/data-table/view-options.tsx similarity index 100% rename from apps/dashboard/src/components/data-table/elements/view-options.tsx rename to packages/stack-ui/src/components/data-table/view-options.tsx diff --git a/packages/stack-ui/src/components/editable-text.tsx b/packages/stack-ui/src/components/editable-text.tsx new file mode 100644 index 0000000000..897159468f --- /dev/null +++ b/packages/stack-ui/src/components/editable-text.tsx @@ -0,0 +1,44 @@ +'use client'; + +import { Edit } from "lucide-react"; +import { useState } from "react"; +import { Button, Input, Typography } from ".."; + +export function EditableText(props: { value: string, onSave?: (value: string) => void | Promise }) { + const [editing, setEditing] = useState(false); + const [editingValue, setEditingValue] = useState(props.value); + + return ( +
+ {editing ? ( + <> + setEditingValue(e.target.value)} + /> + + + + ) : ( + <> + {props.value} + + + )} +
+ ); +} \ No newline at end of file diff --git a/packages/stack-ui/src/components/ui/container.tsx b/packages/stack-ui/src/components/ui/container.tsx index ae4220de37..1c1a6d0b3f 100644 --- a/packages/stack-ui/src/components/ui/container.tsx +++ b/packages/stack-ui/src/components/ui/container.tsx @@ -1,7 +1,7 @@ 'use client'; -import React, { useId } from 'react'; -import { cn } from '../..'; +import { filterUndefined } from '@stackframe/stack-shared/dist/utils/objects'; +import React from 'react'; type ContainerProps = { size: number, @@ -11,23 +11,14 @@ const Container = React.forwardRef(({ size, ...props }, ref) => { - const styleId = useId().replaceAll(':', '-'); - const styleSheet = ` - .stack-inner-container-${styleId} { - max-width: 100%; - } - @media (min-width: ${size}px) { - .stack-inner-container-${styleId} { - width: ${size}px; - } - } - `; - return ( <> -
-
+
{props.children}
diff --git a/packages/stack-ui/src/index.ts b/packages/stack-ui/src/index.ts index a20842a5b6..2c009701b8 100644 --- a/packages/stack-ui/src/index.ts +++ b/packages/stack-ui/src/index.ts @@ -3,6 +3,8 @@ export * from "./components/action-dialog"; export * from "./components/browser-frame"; export * from "./components/copy-button"; export * from "./components/copy-field"; +export * from "./components/data-table"; +export * from "./components/editable-text"; export * from "./components/simple-tooltip"; export * from "./components/ui/accordion"; export * from "./components/ui/alert"; @@ -51,3 +53,4 @@ export * from "./components/ui/tooltip"; export * from "./components/ui/typography"; export * from "./components/ui/use-toast"; export { cn } from "./lib/utils"; + diff --git a/packages/stack/package.json b/packages/stack/package.json index b27020a3fb..6b5232601d 100644 --- a/packages/stack/package.json +++ b/packages/stack/package.json @@ -21,7 +21,7 @@ "build": "rimraf dist && npm run codegen && tsup-node", "codegen": "npm run css", "clean": "rimraf dist && rimraf node_modules", - "dev": "rimraf dist && concurrently \"tsup-node --watch\" \"npm run css:tw:watch\" \"npm run css:sc:watch\"", + "dev": "rimraf dist && concurrently -k \"tsup-node --watch\" \"npm run css:tw:watch\" \"npm run css:sc:watch\"", "lint": "eslint --ext .tsx,.ts .", "css": "npm run css:tw && npm run css:sc", "css:tw:watch": "tailwindcss -i ./src/global.css -o ./src/generated/tailwind.css --watch", @@ -74,6 +74,7 @@ "postcss-nested": "^6.0.1", "react": "^18.2.0", "tailwindcss": "^3.4.4", - "tsup": "^8.0.2" + "tsup": "^8.0.2", + "chokidar-cli": "^3.0.0" } } diff --git a/packages/stack/src/components-page/account-settings.tsx b/packages/stack/src/components-page/account-settings.tsx index 99f8f630e1..f6e1977907 100644 --- a/packages/stack/src/components-page/account-settings.tsx +++ b/packages/stack/src/components-page/account-settings.tsx @@ -1,20 +1,47 @@ 'use client'; -import React, { useEffect } from 'react'; -import { CurrentUser, Project, useStackApp, useUser } from '..'; -import { PredefinedMessageCard } from '../components/message-cards/predefined-message-card'; -import { UserAvatar } from '../components/elements/user-avatar'; -import { useState } from 'react'; -import { FormWarningText } from '../components/elements/form-warning'; import { getPasswordError } from '@stackframe/stack-shared/dist/helpers/password'; -import { Button, Card, CardContent, CardFooter, CardHeader, Container, Input, Label, PasswordInput, Typography, cn } from '@stackframe/stack-ui'; +import { useAsyncCallback } from '@stackframe/stack-shared/dist/hooks/use-async-callback'; import { generateRandomValues } from '@stackframe/stack-shared/dist/utils/crypto'; +import { throwErr } from '@stackframe/stack-shared/dist/utils/errors'; +import { runAsynchronouslyWithAlert } from '@stackframe/stack-shared/dist/utils/promises'; +import { Button, Card, CardContent, CardFooter, CardHeader, Container, Input, Label, PasswordInput, Typography } from '@stackframe/stack-ui'; +import { Contact, Settings, Shield, ShieldCheck } from 'lucide-react'; import { TOTPController, createTOTPKeyURI } from "oslo/otp"; import * as QRCode from 'qrcode'; -import { throwErr } from '@stackframe/stack-shared/dist/utils/errors'; -import { useAsyncCallback } from '@stackframe/stack-shared/dist/hooks/use-async-callback'; -import { runAsynchronously, runAsynchronouslyWithAlert } from '@stackframe/stack-shared/dist/utils/promises'; -import { set } from 'react-hook-form'; +import React, { useEffect, useState } from 'react'; +import { CurrentUser, Project, useStackApp, useUser } from '..'; +import { FormWarningText } from '../components/elements/form-warning'; +import { SidebarLayout } from '../components/elements/sidebar-layout'; +import { UserAvatar } from '../components/elements/user-avatar'; + +export function AccountSettings({ fullPage=false }: { fullPage?: boolean }) { + const user = useUser({ or: 'redirect' }); + + const inner = , icon: Contact }, + { title: 'Security', content:
+ + + +
, icon: ShieldCheck }, + { title: 'Settings', content: , icon: Settings }, + ].filter(({ content }) => content as any)} + title='Team Settings' + />; + + if (fullPage) { + return ( + + {inner} + + ); + } else { + return inner; + } +} + function SettingSection(props: { title: string, @@ -315,36 +342,4 @@ function SignOutSection() { > ); -} - -export function AccountSettings({ fullPage=false }: { fullPage?: boolean }) { - const user = useUser(); - if (!user) { - return ; - } - - const inner = ( -
-
- Account Settings - Manage your account -
- - - - - - -
- ); - - if (fullPage) { - return ( - - {inner} - - ); - } else { - return inner; - } -} +} \ No newline at end of file diff --git a/packages/stack/src/components-page/stack-handler.tsx b/packages/stack/src/components-page/stack-handler.tsx index f9788895c3..4d19463ccd 100644 --- a/packages/stack/src/components-page/stack-handler.tsx +++ b/packages/stack/src/components-page/stack-handler.tsx @@ -12,6 +12,8 @@ import { OAuthCallback } from "./oauth-callback"; import { PasswordReset } from "./password-reset"; import { SignOut } from "./sign-out"; import { TeamInvitation } from "./team-invitation"; +import { TeamSettings } from "./team-settings"; +import { TeamCreation } from "./team-creation"; export default async function StackHandler({ app, @@ -60,10 +62,25 @@ export default async function StackHandler({ accountSettings: 'account-settings', magicLinkCallback: 'magic-link-callback', teamInvitation: 'team-invitation', + teamCreation: 'team-creation', error: 'error', }; const path = stack.join('/'); + + if (/team-settings\/[a-zA-Z0-9-]+/.test(path)) { + const teamId = path.split('/')[1]; + const user = await app.getUser(); + const team = await user?.getTeam(teamId); + + if (!team) { + return notFound(); + } + + return ; + } + + switch (path) { case availablePaths.signIn: { redirectIfNotHandler('signIn'); @@ -105,6 +122,15 @@ export default async function StackHandler({ redirectIfNotHandler('teamInvitation'); return ; } + case availablePaths.teamCreation: { + const project = await app.getProject(); + if (!project.config.clientTeamCreationEnabled) { + return notFound(); + } + + redirectIfNotHandler('teamCreation'); + return ; + } case availablePaths.error: { return ; } diff --git a/packages/stack/src/components-page/team-creation.tsx b/packages/stack/src/components-page/team-creation.tsx new file mode 100644 index 0000000000..5818578923 --- /dev/null +++ b/packages/stack/src/components-page/team-creation.tsx @@ -0,0 +1,72 @@ +'use client'; + +import { yupResolver } from "@hookform/resolvers/yup"; +import { yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; +import { runAsynchronously } from "@stackframe/stack-shared/dist/utils/promises"; +import { Button, Input, Label, Typography } from "@stackframe/stack-ui"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import * as yup from "yup"; +import { MessageCard, useStackApp, useUser } from ".."; +import { FormWarningText } from "../components/elements/form-warning"; +import { MaybeFullPage } from "../components/elements/maybe-full-page"; +import { useRouter } from "next/navigation"; + +const schema = yupObject({ + displayName: yupString().required('Please enter a team name'), +}); + +export function TeamCreation(props: { fullPage?: boolean }) { + const { register, handleSubmit, formState: { errors } } = useForm({ + resolver: yupResolver(schema) + }); + const app = useStackApp(); + const project = app.useProject(); + const user = useUser({ or: 'redirect' }); + const [loading, setLoading] = useState(false); + const router = useRouter(); + + if (!project.config.clientTeamCreationEnabled) { + return ; + } + + const onSubmit = async (data: yup.InferType) => { + setLoading(true); + + try { + const team = await user.createTeam({ displayName: data.displayName }); + router.push(`${app.urls.handler}/team-settings/${team.id}`); + } finally { + setLoading(false); + } + }; + + return ( + +
+
+ + Create a Team + +
+
runAsynchronously(handleSubmit(onSubmit)(e))} + noValidate + > + + + + + + +
+
+ ); +} \ No newline at end of file diff --git a/packages/stack/src/components-page/team-settings.tsx b/packages/stack/src/components-page/team-settings.tsx new file mode 100644 index 0000000000..936fd2d3d3 --- /dev/null +++ b/packages/stack/src/components-page/team-settings.tsx @@ -0,0 +1,192 @@ +'use client'; + +import { ActionCell, ActionDialog, Button, Container, EditableText, Input, Label, SimpleTooltip, Table, TableBody, TableCell, TableHead, TableHeader, TableRow, Typography } from "@stackframe/stack-ui"; +import { Contact, Info, Settings, Users } from "lucide-react"; +import { useEffect, useState } from "react"; +import { MessageCard, Team, useStackApp, useUser } from ".."; +import { SidebarLayout } from "../components/elements/sidebar-layout"; +import { UserAvatar } from "../components/elements/user-avatar"; + +export function TeamSettings(props: { fullPage?: boolean, teamId: string }) { + const user = useUser({ or: 'redirect' }); + const team = user.useTeam(props.teamId); + + if (!team) { + return ; + } + + + const inner = content as any)} + title='Team Settings' + />; + + if (props.fullPage) { + return ( + + {inner} + + ); + } else { + return inner; + } +} + +function managementSettings(props: { team: Team }) { + const user = useUser({ or: 'redirect' }); + const updateTeamPermission = user.usePermission(props.team, '$update_team'); + + if (!updateTeamPermission) { + return null; + } + + return ( + <> +
+ + {}}/> +
+ + ); +} + +function profileSettings(props: { team: Team }) { + const user = useUser({ or: 'redirect' }); + const profile = user.useTeamProfile(props.team); + + return ( +
+
+ + { + await profile.update({ displayName: newDisplayName }); + }}/> +
+
+ ); +} + +function userSettings(props: { team: Team }) { + const app = useStackApp(); + const user = useUser({ or: 'redirect' }); + + return ( +
+
+ +
+
+ ); +} + +function RemoveMemberDialog(props: { + open?: boolean, + onOpenChange?: (open: boolean) => void, +}) { + return ( + { + } + }} + cancelButton + > + + Do you really want to remove from the team? + + + ); +} + +function membersSettings(props: { team: Team }) { + const [removeModalOpen, setRemoveModalOpen] = useState(false); + const user = useUser({ or: 'redirect' }); + const removeMemberPermission = user.usePermission(props.team, '$remove_members'); + const readMemberPermission = user.usePermission(props.team, '$read_members'); + const inviteMemberPermission = user.usePermission(props.team, '$invite_members'); + const [email, setEmail] = useState(''); + const [invited, setInvited] = useState(false); + + if (!readMemberPermission && !inviteMemberPermission) { + return null; + } + + const users = props.team.useUsers(); + + useEffect(() => { + if (invited && email) { + setInvited(false); + } + }, [email]); + + return ( + <> +
+ {inviteMemberPermission && +
+ +
+ setEmail(e.target.value)}/> + +
+ {invited && User invited.} +
} + {readMemberPermission && +
+ + + + + User + Name + {/* {removeMemberPermission && Actions} */} + + + + {users.map(({ id, teamProfile }, i) => ( + + + + + + {teamProfile.displayName} + + {/* {removeMemberPermission && + setRemoveModalOpen(true), danger: true }, + ]}/> + + } */} + + ))} + +
+
} +
+ + ); +} \ No newline at end of file diff --git a/packages/stack/src/components/elements/sidebar-layout.tsx b/packages/stack/src/components/elements/sidebar-layout.tsx new file mode 100644 index 0000000000..5fb422212d --- /dev/null +++ b/packages/stack/src/components/elements/sidebar-layout.tsx @@ -0,0 +1,115 @@ +'use client'; + +import { Button, Typography, cn } from '@stackframe/stack-ui'; +import { ArrowLeft, LucideIcon, XIcon } from 'lucide-react'; +import React, { ReactNode } from 'react'; + +type Item = { + title: React.ReactNode, + description?: React.ReactNode, + icon?: LucideIcon, + content: React.ReactNode, +} + +export function SidebarNavItem(props: { item: Item, selected: boolean, onClick: () => void }) { + return ( + + ); +} + +export function SidebarLayout(props: { items: Item[], title?: ReactNode }) { + return ( + <> +
+ +
+
+ +
+ + ); +} + +function DesktopLayout(props: { items: Item[], title?: ReactNode }) { + const [selectedIndex, setSelectedIndex] = React.useState(0); + const currentItem = props.items[selectedIndex]; + + return ( +
+
+ {props.title &&
+ {props.title} +
} + + {props.items.map((item, index) => ( + setSelectedIndex(index)} + selected={index === selectedIndex} + /> + ))} +
+
+
+ {currentItem.title} + {currentItem.description && {currentItem.description}} +
+
+ {currentItem.content} +
+
+
+ ); +} + +function MobileLayout(props: { items: Item[], title?: ReactNode }) { + const [selectedIndex, setSelectedIndex] = React.useState(null); + + if (selectedIndex === null) { + return ( +
+ {props.title &&
+ {props.title} +
} + + {props.items.map((item, index) => ( + setSelectedIndex(index)} + selected={false} + /> + ))} +
+ ); + } else { + return ( +
+
+
+ {props.items[selectedIndex].title} + +
+ {props.items[selectedIndex].description && {props.items[selectedIndex].description}} +
+
+ {props.items[selectedIndex].content} +
+
+ ); + } +} \ No newline at end of file diff --git a/packages/stack/src/components/elements/user-avatar.tsx b/packages/stack/src/components/elements/user-avatar.tsx index cb493003f0..5b70f5381e 100644 --- a/packages/stack/src/components/elements/user-avatar.tsx +++ b/packages/stack/src/components/elements/user-avatar.tsx @@ -2,7 +2,14 @@ import { UserRound } from "lucide-react"; import { User } from "../../lib/stack-app"; import { Avatar, AvatarFallback, AvatarImage } from "@stackframe/stack-ui"; -export function UserAvatar(props: { size?: number, user: User | null }) { +export function UserAvatar(props: { + size?: number, + user?: { + profileImageUrl?: string | null, + displayName?: string | null, + primaryEmail?: string | null, + } | null, +}) { const user = props.user; return ( diff --git a/packages/stack/src/components/selected-team-switcher.tsx b/packages/stack/src/components/selected-team-switcher.tsx index d4c835384a..65d6ccc002 100644 --- a/packages/stack/src/components/selected-team-switcher.tsx +++ b/packages/stack/src/components/selected-team-switcher.tsx @@ -1,19 +1,22 @@ 'use client'; import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises"; import { + Button, Select, SelectContent, SelectGroup, SelectItem, SelectLabel, + SelectSeparator, SelectTrigger, SelectValue, Typography } from "@stackframe/stack-ui"; import { useRouter } from "next/navigation"; import { useEffect, useMemo } from "react"; -import { Team, useUser } from ".."; +import { Team, useStackApp, useUser } from ".."; import Image from "next/image"; +import { PlusCircle, Settings } from "lucide-react"; type SelectedTeamSwitcherProps = { urlMap?: (team: Team) => string, @@ -38,7 +41,9 @@ function TeamIcon(props: { team: Team }) { } export function SelectedTeamSwitcher(props: SelectedTeamSwitcherProps) { + const app = useStackApp(); const user = useUser(); + const project = app.useProject(); const router = useRouter(); const selectedTeam = user?.selectedTeam || props.selectedTeam; const rawTeams = user?.useTeams(); @@ -73,20 +78,52 @@ export function SelectedTeamSwitcher(props: SelectedTeamSwitcherProps) { - {teams && teams.map(team => ( - + {user?.selectedTeam ? + +
+ Current team + +
+
+
- - {team.displayName} + + {user.selectedTeam.displayName}
- ))} +
: undefined} - {teams?.length === 0 && ( + {teams?.length ? + + Other teams + {teams.filter(team => team.id !== user?.selectedTeam?.id) + .map(team => ( + +
+ + {team.displayName} +
+
+ ))} +
: - No teams - - )} + No teams yet + } + + {project.config.clientTeamCreationEnabled && <> + +
+ +
+ }
); diff --git a/packages/stack/src/lib/stack-app.ts b/packages/stack/src/lib/stack-app.ts index 947f4d777c..df53423a96 100644 --- a/packages/stack/src/lib/stack-app.ts +++ b/packages/stack/src/lib/stack-app.ts @@ -6,6 +6,7 @@ import { ApiKeysCrud } from "@stackframe/stack-shared/dist/interface/crud/api-ke import { CurrentUserCrud } from "@stackframe/stack-shared/dist/interface/crud/current-user"; import { EmailTemplateCrud, EmailTemplateType } from "@stackframe/stack-shared/dist/interface/crud/email-templates"; import { InternalProjectsCrud, ProjectsCrud } from "@stackframe/stack-shared/dist/interface/crud/projects"; +import { TeamMemberProfilesCrud } from "@stackframe/stack-shared/dist/interface/crud/team-member-profiles"; import { TeamPermissionDefinitionsCrud, TeamPermissionsCrud } from "@stackframe/stack-shared/dist/interface/crud/team-permissions"; import { TeamsCrud } from "@stackframe/stack-shared/dist/interface/crud/teams"; import { UsersCrud } from "@stackframe/stack-shared/dist/interface/crud/users"; @@ -71,6 +72,7 @@ export type HandlerUrls = { magicLinkCallback: string, accountSettings: string, teamInvitation: string, + teamCreation: string, error: string, } @@ -103,6 +105,7 @@ function getUrls(partial: Partial): HandlerUrls { accountSettings: `${handler}/account-settings`, error: `${handler}/error`, teamInvitation: `${handler}/team-invitation`, + teamCreation: `${handler}/team-creation`, ...filterUndefined(partial), }; } @@ -320,6 +323,16 @@ class _StackClientAppImpl( + async (session, [teamId]) => { + return await this._interface.listTeamMemberProfiles({ teamId }, session); + } + ); + private readonly _currentUserTeamProfileCache = createCacheBySession<[string], TeamMemberProfilesCrud['Client']['Read']>( + async (session, [teamId]) => { + return await this._interface.getTeamMemberProfile({ teamId, userId: 'me' }, session); + } + ); protected async _getUserOAuthConnectionCacheFn(options: { getUser: () => Promise, @@ -683,6 +696,7 @@ class _StackClientAppImpl ({ id: p.id, })), @@ -696,13 +710,22 @@ class _StackClientAppImpl app._clientTeamUserFromCrud(crud)); + }, + useUsers() { + const result = useAsyncCache(app._teamMemberProfilesCache, [app._getSession(), crud.id], "team.useUsers()"); + return result.map((crud) => app._clientTeamUserFromCrud(crud)); + }, }; } @@ -765,6 +796,25 @@ class _StackClientAppImpl; @@ -816,6 +866,10 @@ class _StackClientAppImpl { const recursive = options?.recursive ?? true; const permissions = await app._currentUserPermissionsCache.getOrWait([session, scope.id, recursive], "write-only"); @@ -849,6 +903,14 @@ class _StackClientAppImpl { const redirectUrl = constructRedirectUrl(this.urls.passwordReset); @@ -1390,12 +1453,6 @@ class _StackServerAppImpl(async ([userId]) => { return await this._interface.listServerTeams({ userId }); }); - private readonly _serverTeamUsersCache = createCache< - string[], - UsersCrud['Server']['Read'][] - >(async ([teamId]) => { - return await this._interface.listServerTeamUsers(teamId); - }); private readonly _serverTeamUserPermissionsCache = createCache< [string, string, boolean], TeamPermissionsCrud['Server']['Read'][] @@ -1428,6 +1485,16 @@ class _StackServerAppImpl( + async ([teamId]) => { + return await this._interface.listServerTeamMemberProfiles({ teamId }); + } + ); + private readonly _serverUserTeamProfileCache = createCache<[string, string], TeamMemberProfilesCrud['Client']['Read']>( + async ([teamId, userId]) => { + return await this._interface.getServerTeamMemberProfile({ teamId, userId }); + } + ); private async _updateServerUser(userId: string, update: ServerUserUpdateOptions): Promise { const result = await this._interface.updateServerUser(userId, serverUserUpdateOptionsToCrud(update)); @@ -1435,6 +1502,18 @@ class _StackServerAppImpl | { @@ -1548,6 +1627,10 @@ class _StackServerAppImpl { + await app._interface.leaveServerTeam({ teamId: team.id, userId: crud.id }); + // TODO: refresh cache + }, async listPermissions(scope: Team, options?: { recursive?: boolean }): Promise { const recursive = options?.recursive ?? true; const permissions = await app._serverTeamUserPermissionsCache.getOrWait([scope.id, crud.id, recursive], "write-only"); @@ -1578,6 +1661,24 @@ class _StackServerAppImpl app._serverEditableTeamProfileFromCrud(result), [result]); + }, + }; + } + + protected _serverTeamUserFromCrud(crud: TeamMemberProfilesCrud["Server"]["Read"]): ServerTeamUser { + return { + ...this._serverUserFromCrud(crud.user), + teamProfile: { + displayName: crud.display_name, + profileImageUrl: crud.profile_image_url, + }, }; } @@ -1600,9 +1701,6 @@ class _StackServerAppImpl app._serverUserFromCrud(u)); - }, async update(update: Partial) { await app._interface.updateServerTeam(crud.id, serverTeamUpdateOptionsToCrud(update)); await app._serverTeamsCache.refresh([undefined]); @@ -1611,23 +1709,27 @@ class _StackServerAppImpl app._serverTeamUserFromCrud(u)); + }, useUsers() { - const result = useAsyncCache(app._serverTeamUsersCache, [crud.id], "team.useUsers()"); - return useMemo(() => result.map((u) => app._serverUserFromCrud(u)), [result]); + const result = useAsyncCache(app._serverTeamMemberProfilesCache, [crud.id], "team.useUsers()"); + return useMemo(() => result.map(u => app._serverTeamUserFromCrud(u)), [result]); }, async addUser(userId) { await app._interface.addServerUserToTeam({ teamId: crud.id, userId, }); - await app._serverTeamUsersCache.refresh([crud.id]); + await app._serverTeamMemberProfilesCache.refresh([crud.id]); }, async removeUser(userId) { await app._interface.removeServerUserFromTeam({ teamId: crud.id, userId, }); - await app._serverTeamUsersCache.refresh([crud.id]); + await app._serverTeamMemberProfilesCache.refresh([crud.id]); }, async inviteUser(options: { email: string }) { return await app._interface.sendTeamInvitation({ @@ -1863,6 +1965,7 @@ class _StackAdminAppImpl ((p.type === 'shared' ? { id: p.id, @@ -2228,6 +2331,10 @@ type UserExtra = { hasPermission(scope: Team, permissionId: string): Promise, setSelectedTeam(team: Team | null): Promise, createTeam(data: TeamCreateOptions): Promise, + leaveTeam(team: Team): Promise, + + getTeamProfile(team: Team): Promise, + useTeamProfile(team: Team): EditableTeamMemberProfile, } & AsyncStoreProperty<"team", [id: string], Team | null, false> & AsyncStoreProperty<"teams", [], Team[], true> @@ -2410,6 +2517,7 @@ function adminProjectUpdateOptionsToCrud(options: AdminProjectUpdateOptions): Pr magic_link_enabled: options.config?.magicLinkEnabled, allow_localhost: options.config?.allowLocalhost, create_team_on_sign_up: options.config?.createTeamOnSignUp, + client_team_creation_enabled: options.config?.clientTeamCreationEnabled, team_creator_default_permissions: options.config?.teamCreatorDefaultPermissions, team_member_default_permissions: options.config?.teamMemberDefaultPermissions, }, @@ -2432,6 +2540,7 @@ export type ProjectConfig = { readonly signUpEnabled: boolean, readonly credentialEnabled: boolean, readonly magicLinkEnabled: boolean, + readonly clientTeamCreationEnabled: boolean, readonly oauthProviders: OAuthProviderConfig[], }; @@ -2440,9 +2549,11 @@ export type OAuthProviderConfig = { }; export type AdminProjectConfig = { + readonly id: string, readonly signUpEnabled: boolean, readonly credentialEnabled: boolean, readonly magicLinkEnabled: boolean, + readonly clientTeamCreationEnabled: boolean, readonly allowLocalhost: boolean, readonly oauthProviders: AdminOAuthProviderConfig[], readonly emailConfig?: AdminEmailConfig, @@ -2450,7 +2561,7 @@ export type AdminProjectConfig = { readonly createTeamOnSignUp: boolean, readonly teamCreatorDefaultPermissions: AdminTeamPermission[], readonly teamMemberDefaultPermissions: AdminTeamPermission[], -} & OAuthProviderConfig; +}; export type AdminEmailConfig = ( { @@ -2494,6 +2605,7 @@ export type AdminProjectConfigUpdateOptions = { signUpEnabled?: boolean, credentialEnabled?: boolean, magicLinkEnabled?: boolean, + clientTeamCreationEnabled?: boolean, allowLocalhost?: boolean, createTeamOnSignUp?: boolean, emailConfig?: AdminEmailConfig, @@ -2554,12 +2666,33 @@ function apiKeyCreateOptionsToCrud(options: ApiKeyCreateOptions): ApiKeyCreateCr type _______________TEAM_______________ = never; // this is a marker for VSCode's outline view type ___________client_team = never; // this is a marker for VSCode's outline view +export type TeamMemberProfile = { + displayName: string | null, + profileImageUrl: string | null, +} + +type TeamMemberProfileUpdateOptions = { + displayName?: string, + profileImageUrl?: string | null, +}; + +export type EditableTeamMemberProfile = TeamMemberProfile & { + update(update: TeamMemberProfileUpdateOptions): Promise, +} + +export type TeamUser = { + id: string, + teamProfile: TeamMemberProfile, +} + export type Team = { id: string, displayName: string, profileImageUrl: string | null, inviteUser(options: { email: string }): Promise>, + listUsers(): Promise, + useUsers(): TeamUser[], }; export type TeamCreateOptions = { @@ -2575,10 +2708,15 @@ function teamCreateOptionsToCrud(options: TeamCreateOptions): TeamsCrud["Client" type ___________server_team = never; // this is a marker for VSCode's outline view +export type ServerTeamMemberProfile = TeamMemberProfile; + +export type ServerTeamUser = ServerUser & { + teamProfile: ServerTeamMemberProfile, +} export type ServerTeam = { createdAt: Date, - listUsers(): Promise, + listUsers(): Promise, useUsers(): ServerUser[], update(update: ServerTeamUpdateOptions): Promise, delete(): Promise, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 09598a709e..31c0f9a5e6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -712,6 +712,9 @@ importers: autoprefixer: specifier: ^10.4.17 version: 10.4.19(postcss@8.4.38) + chokidar-cli: + specifier: ^3.0.0 + version: 3.0.0 esbuild: specifier: ^0.20.2 version: 0.20.2 @@ -939,6 +942,9 @@ importers: '@stackframe/stack-shared': specifier: workspace:* version: link:../stack-shared + '@tanstack/react-table': + specifier: ^8.17.0 + version: 8.17.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) class-variance-authority: specifier: ^0.7.0 version: 0.7.0 @@ -951,6 +957,9 @@ importers: date-fns: specifier: ^3.6.0 version: 3.6.0 + export-to-csv: + specifier: ^1.3.0 + version: 1.3.0 input-otp: specifier: ^1.2.4 version: 1.2.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -3955,6 +3964,10 @@ packages: resolution: {integrity: sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==} engines: {node: '>=0.10.0'} + ansi-regex@4.1.1: + resolution: {integrity: sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==} + engines: {node: '>=6'} + ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -4291,6 +4304,11 @@ packages: check-error@1.0.3: resolution: {integrity: sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==} + chokidar-cli@3.0.0: + resolution: {integrity: sha512-xVW+Qeh7z15uZRxHOkP93Ux8A0xbPzwK4GaqD8dQOYc34TlkqUhVSS59fK36DOp5WdJlrRzlYSy02Ht99FjZqQ==} + engines: {node: '>= 8.10.0'} + hasBin: true + chokidar@3.5.3: resolution: {integrity: sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==} engines: {node: '>= 8.10.0'} @@ -4336,6 +4354,9 @@ packages: client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + cliui@5.0.0: + resolution: {integrity: sha512-PYeGSEmmHM6zvoef2w8TPzlrnNpXIjTipYK780YswmIP9vjxmd6Y2a3CB2Ks6/AU8NHjZugXvo8w3oWM2qnwXA==} + cliui@6.0.0: resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} @@ -4749,6 +4770,9 @@ packages: electron-to-chromium@1.4.803: resolution: {integrity: sha512-61H9mLzGOCLLVsnLiRzCbc63uldP0AniRYPV3hbGVtONA1pI7qSGILdbofR7A8TMbOypDocEAjH/e+9k1QIe3g==} + emoji-regex@7.0.3: + resolution: {integrity: sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==} + emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -5170,6 +5194,10 @@ packages: find-root@1.1.0: resolution: {integrity: sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==} + find-up@3.0.0: + resolution: {integrity: sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==} + engines: {node: '>=6'} + find-up@4.1.0: resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} engines: {node: '>=8'} @@ -5667,6 +5695,10 @@ packages: is-finalizationregistry@1.0.2: resolution: {integrity: sha512-0by5vtUJs8iFQb5TYUHHPudOR+qXYIMKtiUzvLIZITZUjknFmziyBJuLhVRc+Ds0dREFlskDNJKYIdIzu/9pfw==} + is-fullwidth-code-point@2.0.0: + resolution: {integrity: sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w==} + engines: {node: '>=4'} + is-fullwidth-code-point@3.0.0: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} @@ -5979,6 +6011,10 @@ packages: localforage@1.10.0: resolution: {integrity: sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg==} + locate-path@3.0.0: + resolution: {integrity: sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==} + engines: {node: '>=6'} + locate-path@5.0.0: resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} engines: {node: '>=8'} @@ -5987,6 +6023,9 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + lodash.debounce@4.0.8: + resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} + lodash.includes@4.3.0: resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} @@ -5999,6 +6038,9 @@ packages: lodash.startcase@4.4.0: resolution: {integrity: sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==} + lodash.throttle@4.1.1: + resolution: {integrity: sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==} + lodash@4.17.21: resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} @@ -6673,6 +6715,10 @@ packages: resolution: {integrity: sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==} engines: {node: '>=18'} + p-locate@3.0.0: + resolution: {integrity: sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==} + engines: {node: '>=6'} + p-locate@4.1.0: resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} engines: {node: '>=8'} @@ -6710,6 +6756,10 @@ packages: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} + path-exists@3.0.0: + resolution: {integrity: sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==} + engines: {node: '>=4'} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -7490,6 +7540,10 @@ packages: streamx@2.18.0: resolution: {integrity: sha512-LLUC1TWdjVdn1weXGcSxyTR3T4+acB6tVGXT95y0nGbca4t4o/ng1wKAGTljm9VicuCVLvRlqFYXYy5GwgM7sQ==} + string-width@3.1.0: + resolution: {integrity: sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==} + engines: {node: '>=6'} + string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -7523,6 +7577,10 @@ packages: resolution: {integrity: sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg==} engines: {node: '>=0.10.0'} + strip-ansi@5.2.0: + resolution: {integrity: sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==} + engines: {node: '>=6'} + strip-ansi@6.0.1: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} @@ -8236,6 +8294,10 @@ packages: wordwrap@1.0.0: resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} + wrap-ansi@5.1.0: + resolution: {integrity: sha512-QC1/iN/2/RPVJ5jYK8BGttj5z83LmSKmvbvrXPNCLZSEb32KKVDJDl/MOt2N01qU2H/FkzEa9PKto1BqDjtd7Q==} + engines: {node: '>=6'} + wrap-ansi@6.2.0: resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} engines: {node: '>=8'} @@ -8327,6 +8389,9 @@ packages: engines: {node: '>= 14'} hasBin: true + yargs-parser@13.1.2: + resolution: {integrity: sha512-3lbsNRf/j+A4QuSZfDRA7HRSfWrzO0YjqTJd5kjAq37Zep1CEgaYmrH9Q3GwPiB9cHyd1Y1UwggGhJGoxipbzg==} + yargs-parser@18.1.3: resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} engines: {node: '>=6'} @@ -8335,6 +8400,9 @@ packages: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} + yargs@13.3.2: + resolution: {integrity: sha512-AX3Zw5iPruN5ie6xGRIDgqkT+ZhnRlZMLMHAs8tg7nRruy2Nb+i5o9bwghAogtM08q1dpr2LVoS8KSTMYpWXUw==} + yargs@15.4.1: resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==} engines: {node: '>=8'} @@ -11468,6 +11536,8 @@ snapshots: ansi-regex@2.1.1: {} + ansi-regex@4.1.1: {} + ansi-regex@5.0.1: {} ansi-regex@6.0.1: {} @@ -11844,6 +11914,13 @@ snapshots: dependencies: get-func-name: 2.0.2 + chokidar-cli@3.0.0: + dependencies: + chokidar: 3.6.0 + lodash.debounce: 4.0.8 + lodash.throttle: 4.1.1 + yargs: 13.3.2 + chokidar@3.5.3: dependencies: anymatch: 3.1.3 @@ -11892,6 +11969,12 @@ snapshots: client-only@0.0.1: {} + cliui@5.0.0: + dependencies: + string-width: 3.1.0 + strip-ansi: 5.2.0 + wrap-ansi: 5.1.0 + cliui@6.0.0: dependencies: string-width: 4.2.3 @@ -12258,6 +12341,8 @@ snapshots: electron-to-chromium@1.4.803: {} + emoji-regex@7.0.3: {} + emoji-regex@8.0.0: {} emoji-regex@9.2.2: {} @@ -12926,6 +13011,10 @@ snapshots: find-root@1.1.0: {} + find-up@3.0.0: + dependencies: + locate-path: 3.0.0 + find-up@4.1.0: dependencies: locate-path: 5.0.0 @@ -13539,6 +13628,8 @@ snapshots: dependencies: call-bind: 1.0.7 + is-fullwidth-code-point@2.0.0: {} + is-fullwidth-code-point@3.0.0: {} is-generator-function@1.0.10: @@ -13851,6 +13942,11 @@ snapshots: dependencies: lie: 3.1.1 + locate-path@3.0.0: + dependencies: + p-locate: 3.0.0 + path-exists: 3.0.0 + locate-path@5.0.0: dependencies: p-locate: 4.1.0 @@ -13859,6 +13955,8 @@ snapshots: dependencies: p-locate: 5.0.0 + lodash.debounce@4.0.8: {} + lodash.includes@4.3.0: {} lodash.merge@4.6.2: {} @@ -13867,6 +13965,8 @@ snapshots: lodash.startcase@4.4.0: {} + lodash.throttle@4.1.1: {} + lodash@4.17.21: {} log-symbols@4.1.0: @@ -14854,6 +14954,10 @@ snapshots: dependencies: yocto-queue: 1.0.0 + p-locate@3.0.0: + dependencies: + p-limit: 2.3.0 + p-locate@4.1.0: dependencies: p-limit: 2.3.0 @@ -14899,6 +15003,8 @@ snapshots: parseurl@1.3.3: {} + path-exists@3.0.0: {} + path-exists@4.0.0: {} path-is-absolute@1.0.1: {} @@ -15813,6 +15919,12 @@ snapshots: optionalDependencies: bare-events: 2.4.2 + string-width@3.1.0: + dependencies: + emoji-regex: 7.0.3 + is-fullwidth-code-point: 2.0.0 + strip-ansi: 5.2.0 + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -15872,6 +15984,10 @@ snapshots: dependencies: ansi-regex: 2.1.1 + strip-ansi@5.2.0: + dependencies: + ansi-regex: 4.1.1 + strip-ansi@6.0.1: dependencies: ansi-regex: 5.0.1 @@ -16762,6 +16878,12 @@ snapshots: wordwrap@1.0.0: {} + wrap-ansi@5.1.0: + dependencies: + ansi-styles: 3.2.1 + string-width: 3.1.0 + strip-ansi: 5.2.0 + wrap-ansi@6.2.0: dependencies: ansi-styles: 4.3.0 @@ -16810,6 +16932,11 @@ snapshots: yaml@2.4.5: {} + yargs-parser@13.1.2: + dependencies: + camelcase: 5.3.1 + decamelize: 1.2.0 + yargs-parser@18.1.3: dependencies: camelcase: 5.3.1 @@ -16817,6 +16944,19 @@ snapshots: yargs-parser@21.1.1: {} + yargs@13.3.2: + dependencies: + cliui: 5.0.0 + find-up: 3.0.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + require-main-filename: 2.0.0 + set-blocking: 2.0.0 + string-width: 3.1.0 + which-module: 2.0.1 + y18n: 4.0.3 + yargs-parser: 13.1.2 + yargs@15.4.1: dependencies: cliui: 6.0.0