Contact channel docs (#327)

* added docs

* added contact channel sdk docs
This commit is contained in:
Zai Shi 2024-11-01 01:32:20 +01:00 committed by GitHub
parent f6ffd50f3b
commit 35afb5785c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 326 additions and 54 deletions

View File

@ -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,

View File

@ -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') {

View File

@ -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,

View File

@ -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" }}),

View File

@ -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(),

View File

@ -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

View File

@ -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
<div className="indented">
<ParamField path="id" type="string">
The id of the contact channel.
</ParamField>
<ParamField path="value" type="string">
The value of the contact channel. If type is `"email"`, this is an email address.
</ParamField>
<ParamField path="type" type="'email'">
The type of the contact channel. Currently always `"email"`.
</ParamField>
<ParamField path="isPrimary" type="boolean">
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.
</ParamField>
<ParamField path="isVerified" type="boolean">
Whether the contact channel is verified.
</ParamField>
<ParamField path="usedForAuth" type="boolean">
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.
</ParamField>
</div>
### `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:**
<div className="indented">
None
</div>
**Returns:**
<div className="indented">
`Promise<void>`
</div>
**Example:**
```typescript
await contactChannel.sendVerificationEmail();
```
### `update()`
Update the contact channel.
After updating the value, the contact channel will be marked as unverified.
**Parameters:**
<div className="indented">
<ParamField path="options" type="object">
<div className="indented">
<ParamField path="value" type="string">
The new value of the contact channel.
</ParamField>
<ParamField path="type" type="'email'">
The new type of the contact channel. Currently always `"email"`.
</ParamField>
<ParamField path="usedForAuth" type="boolean">
Whether the contact channel is used for authentication.
</ParamField>
</div>
</ParamField>
</div>
**Returns:**
<div className="indented">
`Promise<void>`
</div>
**Example:**
```typescript
await contactChannel.update({ value: "new-email@example.com", usedForAuth: true });
```
### `delete()`
Delete the contact channel.
**Parameters:**
<div className="indented">
No parameters
</div>
**Returns:**
<div className="indented">
`Promise<void>`
</div>
**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:**
<div className="indented">
<ParamField path="options" type="object">
<div className="indented">
<ParamField path="value" type="string">
The new value of the contact channel.
</ParamField>
<ParamField path="type" type="'email'">
The new type of the contact channel. Currently always `"email"`.
</ParamField>
<ParamField path="usedForAuth" type="boolean">
Whether the contact channel is used for authentication.
</ParamField>
<ParamField path="isVerified" type="boolean">
Whether the contact channel is verified.
</ParamField>
</div>
</ParamField>
</div>
**Returns:**
<div className="indented">
`Promise<void>`
</div>
**Example:**
```typescript
await serverContactChannel.update({ value: "new-email@example.com", usedForAuth: true, isVerified: true });
```

View File

@ -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:**
<div className="indented">
No parameters.
</div>
**Returns:**
<div className="indented">
`Promise<ContactChannel[]>`: The list of contact channels.
</div>
**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:**
<div className="indented">
No parameters.
</div>
**Returns:**
<div className="indented">
`ContactChannel[]`: The list of contact channels.
</div>
**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:**
<div className="indented">
No parameters.
</div>
**Returns:**
<div className="indented">
`Promise<ServerContactChannel[]>`: The list of contact channels.
</div>
**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:**
<div className="indented">
No parameters.
</div>
**Returns:**
<div className="indented">
`ServerContactChannel[]`: The list of contact channels.
</div>
**Example:**
```typescript
const contactChannels = serverUser.useContactChannels();
```
### `grantPermission()`
Grant a permission to a user for a team.

View File

@ -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"],
}
}
});

View File

@ -471,8 +471,8 @@ export class StackServerInterface extends StackClientInterface {
userId: string,
contactChannelId: string,
callbackUrl: string,
): Promise<Result<undefined, KnownErrors["EmailAlreadyVerified"]>> {
const responseOrError = await this.sendServerRequestAndCatchKnownError(
): Promise<void> {
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);
}
}

View File

@ -57,7 +57,7 @@ export async function yupValidate<S extends yup.ISchema<any>>(
}
}
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<S extends yup.AnyObject>(
schema: S,

View File

@ -769,7 +769,7 @@ class _StackClientAppImpl<HasTokenStore extends boolean, ProjectId extends strin
usedForAuth: crud.used_for_auth,
async sendVerificationEmail() {
return await app._interface.sendCurrentUserContactChannelVerificationEmail(crud.id, constructRedirectUrl(app.urls.emailVerification), app._getSession());
await app._interface.sendCurrentUserContactChannelVerificationEmail(crud.id, constructRedirectUrl(app.urls.emailVerification), app._getSession());
},
async update(data: ContactChannelUpdateOptions) {
await app._interface.updateClientContactChannel(crud.id, contactChannelUpdateOptionsToCrud(data), app._getSession());
@ -1671,7 +1671,7 @@ class _StackServerAppImpl<HasTokenStore extends boolean, ProjectId extends strin
return {
...this._clientContactChannelFromCrud(crud),
async sendVerificationEmail() {
return await app._interface.sendServerContactChannelVerificationEmail(userId, crud.id, constructRedirectUrl(app.urls.emailVerification));
await app._interface.sendServerContactChannelVerificationEmail(userId, crud.id, constructRedirectUrl(app.urls.emailVerification));
},
async update(data: ServerContactChannelUpdateOptions) {
await app._interface.updateServerContactChannel(userId, crud.id, serverContactChannelUpdateOptionsToCrud(data));
@ -2392,7 +2392,7 @@ type ContactChannel = {
isVerified: boolean,
usedForAuth: boolean,
sendVerificationEmail(): Promise<Result<undefined, KnownErrors["EmailAlreadyVerified"]>>,
sendVerificationEmail(): Promise<void>,
update(data: ContactChannelUpdateOptions): Promise<void>,
delete(): Promise<void>,
}
@ -2426,7 +2426,9 @@ function contactChannelUpdateOptionsToCrud(options: ContactChannelUpdateOptions)
};
}
type ServerContactChannel = ContactChannel;
type ServerContactChannel = ContactChannel & {
update(data: ServerContactChannelUpdateOptions): Promise<void>,
}
type ServerContactChannelUpdateOptions = ContactChannelUpdateOptions & {
isVerified?: boolean,
}