diff --git a/apps/backend/src/app/api/v1/contact-channels/[user_id]/[contact_channel_id]/send-verification-code/route.tsx b/apps/backend/src/app/api/v1/contact-channels/[user_id]/[contact_channel_id]/send-verification-code/route.tsx index 67c3f1181..5312a821f 100644 --- a/apps/backend/src/app/api/v1/contact-channels/[user_id]/[contact_channel_id]/send-verification-code/route.tsx +++ b/apps/backend/src/app/api/v1/contact-channels/[user_id]/[contact_channel_id]/send-verification-code/route.tsx @@ -3,20 +3,20 @@ import { prismaClient } from "@/prisma-client"; import { CrudHandlerInvocationError } from "@/route-handlers/crud-handler"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; import { KnownErrors } from "@stackframe/stack-shared"; -import { adaptSchema, clientOrHigherAuthTypeSchema, emailVerificationCallbackUrlSchema, userIdOrMeSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; +import { adaptSchema, clientOrHigherAuthTypeSchema, contactChannelIdSchema, emailVerificationCallbackUrlSchema, userIdOrMeSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; import { StatusError, throwErr } from "@stackframe/stack-shared/dist/utils/errors"; import { contactChannelVerificationCodeHandler } from "../../../verify/verification-code-handler"; export const POST = createSmartRouteHandler({ metadata: { - summary: "Send email verification code", - description: "Send a code to the user's email address for verifying the email.", - tags: ["Emails"], + summary: "Send contact channel verification code", + description: "Send a code to the user's contact channel for verifying the contact channel.", + tags: ["Contact Channels"], }, request: yupObject({ params: yupObject({ - user_id: userIdOrMeSchema.required(), - contact_channel_id: yupString().uuid().required(), + user_id: userIdOrMeSchema.required().meta({ openapiField: { description: "The user to send the verification code to.", exampleValue: 'me' } }), + contact_channel_id: contactChannelIdSchema.required().meta({ openapiField: { description: "The contact channel to send the verification code to.", exampleValue: 'b3d396b8-c574-4c80-97b3-50031675ceb2' } }), }).required(), auth: yupObject({ type: clientOrHigherAuthTypeSchema, diff --git a/apps/backend/src/app/api/v1/contact-channels/crud.tsx b/apps/backend/src/app/api/v1/contact-channels/crud.tsx index 89da7b6e8..ea219e855 100644 --- a/apps/backend/src/app/api/v1/contact-channels/crud.tsx +++ b/apps/backend/src/app/api/v1/contact-channels/crud.tsx @@ -27,8 +27,8 @@ export const contactChannelsCrudHandlers = createLazyProxy(() => createCrudHandl contact_channel_id: yupString().uuid().optional(), }), paramsSchema: yupObject({ - user_id: userIdOrMeSchema.required(), - contact_channel_id: yupString().uuid().required(), + user_id: userIdOrMeSchema.required().meta({ openapiField: { description: "the user that the contact channel belongs to", exampleValue: 'me', onlyShowInOperations: ["Read", "Update", "Delete"] } }), + contact_channel_id: yupString().uuid().required().meta({ openapiField: { description: "the target contact channel", exampleValue: 'b3d396b8-c574-4c80-97b3-50031675ceb2', onlyShowInOperations: ["Read", "Update", "Delete"] } }), }), onRead: async ({ params, auth }) => { if (auth.type === 'client') { diff --git a/apps/backend/src/app/api/v1/contact-channels/verify/verification-code-handler.tsx b/apps/backend/src/app/api/v1/contact-channels/verify/verification-code-handler.tsx index fef367e95..8c04e735e 100644 --- a/apps/backend/src/app/api/v1/contact-channels/verify/verification-code-handler.tsx +++ b/apps/backend/src/app/api/v1/contact-channels/verify/verification-code-handler.tsx @@ -10,12 +10,12 @@ export const contactChannelVerificationCodeHandler = createVerificationCodeHandl post: { summary: "Verify an email", description: "Verify an email address of a user", - tags: ["Emails"], + tags: ["Contact Channels"], }, check: { summary: "Check email verification code", description: "Check if an email verification code is valid without using it", - tags: ["Emails"], + tags: ["Contact Channels"], }, }, type: VerificationCodeType.CONTACT_CHANNEL_VERIFICATION, diff --git a/apps/backend/src/app/api/v1/users/crud.tsx b/apps/backend/src/app/api/v1/users/crud.tsx index 3faf7feb7..af8138a78 100644 --- a/apps/backend/src/app/api/v1/users/crud.tsx +++ b/apps/backend/src/app/api/v1/users/crud.tsx @@ -249,7 +249,7 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC }), querySchema: yupObject({ team_id: yupString().uuid().optional().meta({ openapiField: { onlyShowInOperations: [ 'List' ], description: "Only return users who are members of the given team" }}), - limit: yupNumber().integer().min(1).optional().meta({ openapiField: { onlyShowInOperations: [ 'List' ], description: "The maximum number of items to return. Defaults to 100" }}), + limit: yupNumber().integer().min(1).optional().meta({ openapiField: { onlyShowInOperations: [ 'List' ], description: "The maximum number of items to return" }}), cursor: yupString().optional().meta({ openapiField: { onlyShowInOperations: [ 'List' ], description: "The cursor to start the result set from." }}), order_by: yupString().oneOf(['signed_up_at']).optional().meta({ openapiField: { onlyShowInOperations: [ 'List' ], description: "The field to sort the results by. Defaults to signed_up_at" }}), desc: yupBoolean().optional().meta({ openapiField: { onlyShowInOperations: [ 'List' ], description: "Whether to sort the results in descending order. Defaults to false" }}), diff --git a/apps/backend/src/route-handlers/crud-handler.tsx b/apps/backend/src/route-handlers/crud-handler.tsx index ca83c7223..5ad5c47c3 100644 --- a/apps/backend/src/route-handlers/crud-handler.tsx +++ b/apps/backend/src/route-handlers/crud-handler.tsx @@ -134,7 +134,7 @@ export function createCrudHandlers< items: yupArray(read).required(), is_paginated: yupBoolean().required().meta({ openapiField: { hidden: true } }), pagination: yupObject({ - next_cursor: yupString().nullable().defined(), + next_cursor: yupString().nullable().defined().meta({ openapiField: { description: "The cursor to fetch the next page of results. null if there is no next page.", exampleValue: 'b3d396b8-c574-4c80-97b3-50031675ceb2' } }), }).when('is_paginated', { is: true, then: (schema) => schema.required(), diff --git a/docs/fern/docs.yml b/docs/fern/docs.yml index 79fa6beef..505e3a88f 100644 --- a/docs/fern/docs.yml +++ b/docs/fern/docs.yml @@ -158,6 +158,8 @@ navigation: path: ./docs/pages/sdk/team-permission.mdx - page: Project path: ./docs/pages/sdk/project.mdx + - page: ContactChannel + path: ./docs/pages/sdk/contact-channel.mdx # - page: ConnectedAccount # path: ./docs/pages/sdk/connected-account.mdx - section: Hooks diff --git a/docs/fern/docs/pages/sdk/contact-channel.mdx b/docs/fern/docs/pages/sdk/contact-channel.mdx index e69de29bb..b31742b41 100644 --- a/docs/fern/docs/pages/sdk/contact-channel.mdx +++ b/docs/fern/docs/pages/sdk/contact-channel.mdx @@ -0,0 +1,170 @@ +--- +slug: sdk/contact-channel +--- + +`ContactChannel` represents a user's contact information, such as an email address. In the future, it will support additional contact types like phone numbers. Contact channels can optionally be used for authentication. + +Stack provides two types of contact channels: + +1. `ContactChannel` + - Used on the client side + - Contains basic information like the contact value (e.g. email address) and type + - You can obtain it by calling `user.listContactChannels()` or `user.useContactChannels()` +2. `ServerContactChannel` + - Used on the server side + - Extends `ContactChannel` with additional properties: + - Verification status + - Whether it's the user's primary contact channel + - You can obtain it by calling `serverUser.listContactChannels()` or `serverUser.useContactChannels()` + +## `ContactChannel` + +### Properties + +
+ + The id of the contact channel. + + + The value of the contact channel. If type is `"email"`, this is an email address. + + + The type of the contact channel. Currently always `"email"`. + + + Whether the contact channel is the user's primary contact channel. If an email is set to primary, it will be the value on the `user.primaryEmail` field. + + + Whether the contact channel is verified. + + + Whether the contact channel is used for authentication. If set to `true`, the user can use this contact channel together with OTP or password to sign in. + +
+ +### `sendVerificationEmail()` + +Sends a verification email to this contact channel. Once the user clicks the verification link in the email, the contact channel will be marked as verified. + +This method will throw an error if the contact channel has already been verified. + +**Parameters:** + +
+ None +
+ +**Returns:** + +
+ `Promise` +
+ +**Example:** + +```typescript +await contactChannel.sendVerificationEmail(); +``` + +### `update()` + +Update the contact channel. + +After updating the value, the contact channel will be marked as unverified. + +**Parameters:** + +
+ +
+ + The new value of the contact channel. + + + The new type of the contact channel. Currently always `"email"`. + + + Whether the contact channel is used for authentication. + +
+
+
+ +**Returns:** + +
+ `Promise` +
+ +**Example:** + +```typescript +await contactChannel.update({ value: "new-email@example.com", usedForAuth: true }); +``` + +### `delete()` + +Delete the contact channel. + +**Parameters:** + +
+ No parameters +
+ +**Returns:** + +
+ `Promise` +
+ +**Example:** + +```typescript +await contactChannel.delete(); +``` + + +## `ServerContactChannel` + +It extends `ContactChannel` with additional methods listed below. + +### `update()` + +Update the contact channel. + +This is similar to `ContactChannel.update()`, but it also allows you to set the `isVerified` property. + +**Parameters:** + +
+ +
+ + The new value of the contact channel. + + + The new type of the contact channel. Currently always `"email"`. + + + Whether the contact channel is used for authentication. + + + Whether the contact channel is verified. + +
+
+
+ +**Returns:** + +
+ `Promise` +
+ + +**Example:** + +```typescript +await serverContactChannel.update({ value: "new-email@example.com", usedForAuth: true, isVerified: true }); +``` diff --git a/docs/fern/docs/pages/sdk/user.mdx b/docs/fern/docs/pages/sdk/user.mdx index ae4b7a48c..af5ea482b 100644 --- a/docs/fern/docs/pages/sdk/user.mdx +++ b/docs/fern/docs/pages/sdk/user.mdx @@ -505,6 +505,50 @@ This is the same as `listPermissions` but it is a React hook. const permissions = user.usePermissions(team); ``` +### `listContactChannels()` + +List all the contact channels of the user. + +**Parameters:** + +
+ No parameters. +
+ +**Returns:** + +
+ `Promise`: The list of contact channels. +
+ +**Example:** +```typescript +const contactChannels = await user.listContactChannels(); +``` + +### `useContactChannels()` + +List all the contact channels of the user. + +This is the same as `listContactChannels` but it is a React hook. + +**Parameters:** + +
+ No parameters. +
+ +**Returns:** + +
+ `ContactChannel[]`: The list of contact channels. +
+ +**Example:** +```typescript +const contactChannels = user.useContactChannels(); +``` + ### `updatePassword()` This will update the user's password. It will return an error object (not throw an error) if the passwords mismatch or if the new password does not meet the requirements. If successful, it will return undefined. @@ -686,6 +730,52 @@ await serverUser.update({ }); ``` +### `listContactChannels()` + +List all the contact channels of the user. + +This is similar to `CurrentUser.listContactChannels()` but it returns a list of `ServerContactChannel` objects instead of `ContactChannel` objects. + +**Parameters:** + +
+ No parameters. +
+ +**Returns:** + +
+ `Promise`: The list of contact channels. +
+ +**Example:** +```typescript +const contactChannels = await serverUser.listContactChannels(); +``` + +### `useContactChannels()` + +List all the contact channels of the user. + +This is the same as `listContactChannels` but it is a React hook. This is also similar to `CurrentUser.useContactChannels()` but it returns a list of `ServerContactChannel` objects instead of `ContactChannel` objects. + +**Parameters:** + +
+ No parameters. +
+ +**Returns:** + +
+ `ServerContactChannel[]`: The list of contact channels. +
+ +**Example:** +```typescript +const contactChannels = serverUser.useContactChannels(); +``` + ### `grantPermission()` Grant a permission to a user for a team. diff --git a/packages/stack-shared/src/interface/crud/contact-channels.ts b/packages/stack-shared/src/interface/crud/contact-channels.ts index f04e4d28b..065c3cfd1 100644 --- a/packages/stack-shared/src/interface/crud/contact-channels.ts +++ b/packages/stack-shared/src/interface/crud/contact-channels.ts @@ -1,44 +1,37 @@ import { CrudTypeOf, createCrud } from "../../crud"; -import { userIdOrMeSchema, userIdSchema, yupBoolean, yupMixed, yupObject, yupString } from "../../schema-fields"; - -const contactChannelsTypes = ['email'] as const; -const type = yupString().oneOf(contactChannelsTypes); -const value = yupString().when('type', { - is: 'email', - then: (schema) => schema.email(), -}); +import { contactChannelIdSchema, contactChannelIsPrimarySchema, contactChannelIsVerifiedSchema, contactChannelTypeSchema, contactChannelUsedForAuthSchema, contactChannelValueSchema, userIdOrMeSchema, userIdSchema, yupMixed, yupObject } from "../../schema-fields"; export const contactChannelsClientReadSchema = yupObject({ user_id: userIdSchema.required(), - id: yupString().required(), - value: value.required(), - type: type.required(), - used_for_auth: yupBoolean().required(), - is_verified: yupBoolean().required(), - is_primary: yupBoolean().required(), + id: contactChannelIdSchema.required(), + value: contactChannelValueSchema.required(), + type: contactChannelTypeSchema.required(), + used_for_auth: contactChannelUsedForAuthSchema.required(), + is_verified: contactChannelIsVerifiedSchema.required(), + is_primary: contactChannelIsPrimarySchema.required(), }).required(); export const contactChannelsCrudClientUpdateSchema = yupObject({ - value: value.optional(), - type: type.optional(), - used_for_auth: yupBoolean().optional(), - is_primary: yupBoolean().optional(), + value: contactChannelValueSchema.optional(), + type: contactChannelTypeSchema.optional(), + used_for_auth: contactChannelUsedForAuthSchema.optional(), + is_primary: contactChannelIsPrimarySchema.optional(), }).required(); export const contactChannelsCrudServerUpdateSchema = contactChannelsCrudClientUpdateSchema.concat(yupObject({ - is_verified: yupBoolean().optional(), + is_verified: contactChannelIsVerifiedSchema.optional(), })); export const contactChannelsCrudClientCreateSchema = yupObject({ user_id: userIdOrMeSchema.required(), - value: value.required(), - type: type.required(), - used_for_auth: yupBoolean().required(), - is_primary: yupBoolean().optional(), + value: contactChannelValueSchema.required(), + type: contactChannelTypeSchema.required(), + used_for_auth: contactChannelUsedForAuthSchema.required(), + is_primary: contactChannelIsPrimarySchema.optional(), }).required(); export const contactChannelsCrudServerCreateSchema = contactChannelsCrudClientCreateSchema.concat(yupObject({ - is_verified: yupBoolean().optional(), + is_verified: contactChannelIsVerifiedSchema.optional(), })); export const contactChannelsCrudClientDeleteSchema = yupMixed(); @@ -52,19 +45,29 @@ export const contactChannelsCrud = createCrud({ serverCreateSchema: contactChannelsCrudServerCreateSchema, docs: { clientRead: { - hidden: true, + summary: "Get a contact channel", + description: "", + tags: ["Contact Channels"], }, clientCreate: { - hidden: true, + summary: "Create a contact channel", + description: "", + tags: ["Contact Channels"], }, clientUpdate: { - hidden: true, + summary: "Update a contact channel", + description: "", + tags: ["Contact Channels"], }, clientDelete: { - hidden: true, + summary: "Delete a contact channel", + description: "", + tags: ["Contact Channels"], }, clientList: { - hidden: true, + summary: "List contact channels", + description: "", + tags: ["Contact Channels"], } } }); diff --git a/packages/stack-shared/src/interface/serverInterface.ts b/packages/stack-shared/src/interface/serverInterface.ts index c10e26eb9..f40c5d47f 100644 --- a/packages/stack-shared/src/interface/serverInterface.ts +++ b/packages/stack-shared/src/interface/serverInterface.ts @@ -471,8 +471,8 @@ export class StackServerInterface extends StackClientInterface { userId: string, contactChannelId: string, callbackUrl: string, - ): Promise> { - const responseOrError = await this.sendServerRequestAndCatchKnownError( + ): Promise { + await this.sendServerRequest( `/contact-channels/${userId}/${contactChannelId}/send-verification-code`, { method: "POST", @@ -482,12 +482,6 @@ export class StackServerInterface extends StackClientInterface { body: JSON.stringify({ callback_url: callbackUrl }), }, null, - [KnownErrors.EmailAlreadyVerified], ); - - if (responseOrError.status === "error") { - return Result.error(responseOrError.error); - } - return Result.ok(undefined); } } diff --git a/packages/stack-shared/src/schema-fields.ts b/packages/stack-shared/src/schema-fields.ts index 74d53b715..7720d9d7a 100644 --- a/packages/stack-shared/src/schema-fields.ts +++ b/packages/stack-shared/src/schema-fields.ts @@ -57,7 +57,7 @@ export async function yupValidate>( } } -const _idDescription = (identify: string) => `The unique identifier of this ${identify}`; +const _idDescription = (identify: string) => `The unique identifier of the ${identify}`; const _displayNameDescription = (identify: string) => `Human-readable ${identify} display name. This is not a unique identifier.`; const _clientMetaDataDescription = (identify: string) => `Client metadata. Used as a data store, accessible from the client side. Do not store information that should not be exposed to the client.`; const _clientReadOnlyMetaDataDescription = (identify: string) => `Client read-only, server-writable metadata. Used as a data store, accessible from the client side. Do not store information that should not be exposed to the client. The client can read this data, but cannot modify it. This is useful for things like subscription status.`; @@ -329,6 +329,17 @@ export const teamCreatorUserIdSchema = userIdOrMeSchema.meta({ openapiField: { d export const teamMemberDisplayNameSchema = yupString().meta({ openapiField: { description: _displayNameDescription('team member') + ' Note that this is separate from the display_name of the user.', exampleValue: 'John Doe' } }); export const teamMemberProfileImageUrlSchema = urlSchema.max(1000000).meta({ openapiField: { description: _profileImageUrlDescription('team member'), exampleValue: 'https://example.com/image.jpg' } }); +// Contact channels +export const contactChannelIdSchema = yupString().uuid().meta({ openapiField: { description: _idDescription('contact channel'), exampleValue: 'b3d396b8-c574-4c80-97b3-50031675ceb2' } }); +export const contactChannelTypeSchema = yupString().oneOf(['email']).meta({ openapiField: { description: `The type of the contact channel. Currently only "email" is supported.`, exampleValue: 'email' } }); +export const contactChannelValueSchema = yupString().when('type', { + is: 'email', + then: (schema) => schema.email(), +}).meta({ openapiField: { description: 'The value of the contact channel. For email, this should be a valid email address.', exampleValue: 'johndoe@example.com' } }); +export const contactChannelUsedForAuthSchema = yupBoolean().meta({ openapiField: { description: 'Whether the contact channel is used for authentication. If this is set to `true`, the user will be able to sign in with the contact channel with password or OTP.', exampleValue: true } }); +export const contactChannelIsVerifiedSchema = yupBoolean().meta({ openapiField: { description: 'Whether the contact channel has been verified. If this is set to `true`, the contact channel has been verified to belong to the user.', exampleValue: true } }); +export const contactChannelIsPrimarySchema = yupBoolean().meta({ openapiField: { description: 'Whether the contact channel is the primary contact channel. If this is set to `true`, it will be used for authentication and notifications by default.', exampleValue: true } }); + // Utils export function yupRequiredWhen( schema: S, diff --git a/packages/stack/src/lib/stack-app.ts b/packages/stack/src/lib/stack-app.ts index d336a8079..f5be73e9c 100644 --- a/packages/stack/src/lib/stack-app.ts +++ b/packages/stack/src/lib/stack-app.ts @@ -769,7 +769,7 @@ class _StackClientAppImpl>, + sendVerificationEmail(): Promise, update(data: ContactChannelUpdateOptions): Promise, delete(): Promise, } @@ -2426,7 +2426,9 @@ function contactChannelUpdateOptionsToCrud(options: ContactChannelUpdateOptions) }; } -type ServerContactChannel = ContactChannel; +type ServerContactChannel = ContactChannel & { + update(data: ServerContactChannelUpdateOptions): Promise, +} type ServerContactChannelUpdateOptions = ContactChannelUpdateOptions & { isVerified?: boolean, }