mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-04 21:04:37 +08:00
Team metadata & client read only metadata (#196)
* added team metadata * added client readonly metadata * updated tests * added team client meta data tests * added user metadata tests * added client read only metadata to stack-app * added client read only metadata
This commit is contained in:
parent
926fd84983
commit
7b5d0ed793
@ -0,0 +1,7 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "ProjectUser" ADD COLUMN "clientReadOnlyMetadata" JSONB;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Team" ADD COLUMN "clientMetadata" JSONB,
|
||||
ADD COLUMN "clientReadOnlyMetadata" JSONB,
|
||||
ADD COLUMN "serverMetadata" JSONB;
|
||||
@ -93,8 +93,11 @@ model Team {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
displayName String
|
||||
profileImageUrl String?
|
||||
displayName String
|
||||
profileImageUrl String?
|
||||
clientMetadata Json?
|
||||
clientReadOnlyMetadata Json?
|
||||
serverMetadata Json?
|
||||
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
permissions Permission[]
|
||||
@ -242,8 +245,9 @@ model ProjectUser {
|
||||
requiresTotpMfa Boolean @default(false)
|
||||
totpSecret Bytes?
|
||||
|
||||
serverMetadata Json?
|
||||
clientMetadata Json?
|
||||
clientMetadata Json?
|
||||
clientReadOnlyMetadata Json?
|
||||
serverMetadata Json?
|
||||
|
||||
@@id([projectId, projectUserId])
|
||||
}
|
||||
|
||||
@ -18,6 +18,9 @@ export function teamPrismaToCrud(prisma: Prisma.TeamGetPayload<{}>) {
|
||||
display_name: prisma.displayName,
|
||||
profile_image_url: prisma.profileImageUrl,
|
||||
created_at_millis: prisma.createdAt.getTime(),
|
||||
client_metadata: prisma.clientMetadata,
|
||||
client_read_only_metadata: prisma.clientReadOnlyMetadata,
|
||||
server_metadata: prisma.serverMetadata,
|
||||
};
|
||||
}
|
||||
|
||||
@ -43,6 +46,10 @@ export const teamsCrudHandlers = createLazyProxy(() => createCrudHandlers(teamsC
|
||||
data: {
|
||||
displayName: data.display_name,
|
||||
projectId: auth.project.id,
|
||||
profileImageUrl: data.profile_image_url,
|
||||
clientMetadata: data.client_metadata === null ? Prisma.JsonNull : data.client_metadata,
|
||||
clientReadOnlyMetadata: data.client_read_only_metadata === null ? Prisma.JsonNull : data.client_read_only_metadata,
|
||||
serverMetadata: data.server_metadata === null ? Prisma.JsonNull : data.server_metadata,
|
||||
},
|
||||
});
|
||||
|
||||
@ -123,6 +130,9 @@ export const teamsCrudHandlers = createLazyProxy(() => createCrudHandlers(teamsC
|
||||
data: {
|
||||
displayName: data.display_name,
|
||||
profileImageUrl: data.profile_image_url,
|
||||
clientMetadata: data.client_metadata === null ? Prisma.JsonNull : data.client_metadata,
|
||||
clientReadOnlyMetadata: data.client_read_only_metadata === null ? Prisma.JsonNull : data.client_read_only_metadata,
|
||||
serverMetadata: data.server_metadata === null ? Prisma.JsonNull : data.server_metadata,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@ -12,7 +12,6 @@ import { StackAssertionError, StatusError, captureError, throwErr } from "@stack
|
||||
import { hashPassword } from "@stackframe/stack-shared/dist/utils/password";
|
||||
import { createLazyProxy } from "@stackframe/stack-shared/dist/utils/proxies";
|
||||
import { teamPrismaToCrud, teamsCrudHandlers } from "../teams/crud";
|
||||
import { teamsCrud } from "@stackframe/stack-shared/dist/interface/crud/teams";
|
||||
|
||||
export const userFullInclude = {
|
||||
projectUserOAuthAccounts: {
|
||||
@ -81,6 +80,7 @@ export const userPrismaToCrud = (prisma: Prisma.ProjectUserGetPayload<{ include:
|
||||
profile_image_url: prisma.profileImageUrl,
|
||||
signed_up_at_millis: prisma.createdAt.getTime(),
|
||||
client_metadata: prisma.clientMetadata,
|
||||
client_read_only_metadata: prisma.clientReadOnlyMetadata,
|
||||
server_metadata: prisma.serverMetadata,
|
||||
has_password: !!prisma.passwordHash,
|
||||
auth_with_email: prisma.authWithEmail,
|
||||
@ -169,6 +169,7 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC
|
||||
projectId: auth.project.id,
|
||||
displayName: data.display_name === undefined ? undefined : (data.display_name || null),
|
||||
clientMetadata: data.client_metadata === null ? Prisma.JsonNull : data.client_metadata,
|
||||
clientReadOnlyMetadata: data.client_read_only_metadata === null ? Prisma.JsonNull : data.client_read_only_metadata,
|
||||
serverMetadata: data.server_metadata === null ? Prisma.JsonNull : data.server_metadata,
|
||||
primaryEmail: data.primary_email,
|
||||
primaryEmailVerified: data.primary_email_verified ?? false,
|
||||
@ -266,6 +267,7 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC
|
||||
data: {
|
||||
displayName: data.display_name === undefined ? undefined : (data.display_name || null),
|
||||
clientMetadata: data.client_metadata === null ? Prisma.JsonNull : data.client_metadata,
|
||||
clientReadOnlyMetadata: data.client_read_only_metadata === null ? Prisma.JsonNull : data.client_read_only_metadata,
|
||||
serverMetadata: data.server_metadata === null ? Prisma.JsonNull : data.server_metadata,
|
||||
primaryEmail: data.primary_email,
|
||||
primaryEmailVerified: data.primary_email_verified ?? (data.primary_email !== undefined ? false : undefined),
|
||||
|
||||
@ -93,8 +93,11 @@ model Team {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
displayName String
|
||||
profileImageUrl String?
|
||||
displayName String
|
||||
profileImageUrl String?
|
||||
clientMetadata Json?
|
||||
clientReadOnlyMetadata Json?
|
||||
serverMetadata Json?
|
||||
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
permissions Permission[]
|
||||
@ -242,8 +245,9 @@ model ProjectUser {
|
||||
requiresTotpMfa Boolean @default(false)
|
||||
totpSecret Bytes?
|
||||
|
||||
serverMetadata Json?
|
||||
clientMetadata Json?
|
||||
clientMetadata Json?
|
||||
clientReadOnlyMetadata Json?
|
||||
serverMetadata Json?
|
||||
|
||||
@@id([projectId, projectUserId])
|
||||
}
|
||||
|
||||
@ -45,6 +45,7 @@ describe("with grant_type === 'authorization_code'", async () => {
|
||||
],
|
||||
"auth_with_email": false,
|
||||
"client_metadata": null,
|
||||
"client_read_only_metadata": null,
|
||||
"connected_accounts": [
|
||||
{
|
||||
"provider": {
|
||||
|
||||
@ -67,10 +67,13 @@ it("invites a user to a team", async ({ expect }) => {
|
||||
"is_paginated": false,
|
||||
"items": [
|
||||
{
|
||||
"client_metadata": null,
|
||||
"client_read_only_metadata": null,
|
||||
"created_at_millis": <stripped field 'created_at_millis'>,
|
||||
"display_name": "New Team",
|
||||
"id": "<stripped UUID>",
|
||||
"profile_image_url": null,
|
||||
"server_metadata": null,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@ -76,6 +76,7 @@ it("creates a team and manage users on the server", async ({ expect }) => {
|
||||
],
|
||||
"auth_with_email": true,
|
||||
"client_metadata": null,
|
||||
"client_read_only_metadata": null,
|
||||
"connected_accounts": [],
|
||||
"display_name": null,
|
||||
"has_password": false,
|
||||
@ -102,6 +103,7 @@ it("creates a team and manage users on the server", async ({ expect }) => {
|
||||
],
|
||||
"auth_with_email": true,
|
||||
"client_metadata": null,
|
||||
"client_read_only_metadata": null,
|
||||
"connected_accounts": [],
|
||||
"display_name": null,
|
||||
"has_password": false,
|
||||
@ -158,6 +160,7 @@ it("creates a team and manage users on the server", async ({ expect }) => {
|
||||
],
|
||||
"auth_with_email": true,
|
||||
"client_metadata": null,
|
||||
"client_read_only_metadata": null,
|
||||
"connected_accounts": [],
|
||||
"display_name": null,
|
||||
"has_password": false,
|
||||
|
||||
@ -88,10 +88,13 @@ it("creates a team on the client", async ({ expect }) => {
|
||||
NiceResponse {
|
||||
"status": 201,
|
||||
"body": {
|
||||
"client_metadata": null,
|
||||
"client_read_only_metadata": null,
|
||||
"created_at_millis": <stripped field 'created_at_millis'>,
|
||||
"display_name": "New Team",
|
||||
"id": "<stripped UUID>",
|
||||
"profile_image_url": null,
|
||||
"server_metadata": null,
|
||||
},
|
||||
"headers": Headers { <some fields may have been hidden> },
|
||||
}
|
||||
@ -105,10 +108,13 @@ it("creates a team on the server", async ({ expect }) => {
|
||||
NiceResponse {
|
||||
"status": 201,
|
||||
"body": {
|
||||
"client_metadata": null,
|
||||
"client_read_only_metadata": null,
|
||||
"created_at_millis": <stripped field 'created_at_millis'>,
|
||||
"display_name": "New Team",
|
||||
"id": "<stripped UUID>",
|
||||
"profile_image_url": null,
|
||||
"server_metadata": null,
|
||||
},
|
||||
"headers": Headers { <some fields may have been hidden> },
|
||||
}
|
||||
@ -122,10 +128,13 @@ it("gets a specific team on the client", async ({ expect }) => {
|
||||
NiceResponse {
|
||||
"status": 201,
|
||||
"body": {
|
||||
"client_metadata": null,
|
||||
"client_read_only_metadata": null,
|
||||
"created_at_millis": <stripped field 'created_at_millis'>,
|
||||
"display_name": "New Team",
|
||||
"id": "<stripped UUID>",
|
||||
"profile_image_url": null,
|
||||
"server_metadata": null,
|
||||
},
|
||||
"headers": Headers { <some fields may have been hidden> },
|
||||
}
|
||||
@ -136,6 +145,8 @@ it("gets a specific team on the client", async ({ expect }) => {
|
||||
NiceResponse {
|
||||
"status": 200,
|
||||
"body": {
|
||||
"client_metadata": null,
|
||||
"client_read_only_metadata": null,
|
||||
"display_name": "New Team",
|
||||
"id": "<stripped UUID>",
|
||||
"profile_image_url": null,
|
||||
@ -188,10 +199,13 @@ it("gets a team that the user is not part of on the server", async ({ expect })
|
||||
NiceResponse {
|
||||
"status": 201,
|
||||
"body": {
|
||||
"client_metadata": null,
|
||||
"client_read_only_metadata": null,
|
||||
"created_at_millis": <stripped field 'created_at_millis'>,
|
||||
"display_name": "New Team",
|
||||
"id": "<stripped UUID>",
|
||||
"profile_image_url": null,
|
||||
"server_metadata": null,
|
||||
},
|
||||
"headers": Headers { <some fields may have been hidden> },
|
||||
}
|
||||
@ -202,10 +216,13 @@ it("gets a team that the user is not part of on the server", async ({ expect })
|
||||
NiceResponse {
|
||||
"status": 200,
|
||||
"body": {
|
||||
"client_metadata": null,
|
||||
"client_read_only_metadata": null,
|
||||
"created_at_millis": <stripped field 'created_at_millis'>,
|
||||
"display_name": "New Team",
|
||||
"id": "<stripped UUID>",
|
||||
"profile_image_url": null,
|
||||
"server_metadata": null,
|
||||
},
|
||||
"headers": Headers { <some fields may have been hidden> },
|
||||
}
|
||||
@ -226,10 +243,13 @@ it("should not be allowed to get a team that the user is not part of on the clie
|
||||
NiceResponse {
|
||||
"status": 201,
|
||||
"body": {
|
||||
"client_metadata": null,
|
||||
"client_read_only_metadata": null,
|
||||
"created_at_millis": <stripped field 'created_at_millis'>,
|
||||
"display_name": "New Team",
|
||||
"id": "<stripped UUID>",
|
||||
"profile_image_url": null,
|
||||
"server_metadata": null,
|
||||
},
|
||||
"headers": Headers { <some fields may have been hidden> },
|
||||
}
|
||||
@ -278,6 +298,8 @@ it("updates a team on the client", async ({ expect }) => {
|
||||
NiceResponse {
|
||||
"status": 200,
|
||||
"body": {
|
||||
"client_metadata": null,
|
||||
"client_read_only_metadata": null,
|
||||
"display_name": "My Updated Team",
|
||||
"id": "<stripped UUID>",
|
||||
"profile_image_url": null,
|
||||
@ -287,6 +309,79 @@ it("updates a team on the client", async ({ expect }) => {
|
||||
`);
|
||||
});
|
||||
|
||||
it("updates team client metadata on the client", async ({ expect }) => {
|
||||
const { userId } = await Auth.Otp.signIn();
|
||||
const { teamId } = await Team.create();
|
||||
|
||||
// grant permission to update a team
|
||||
await niceBackendFetch(`/api/v1/team-permissions/${teamId}/${userId}/$update_team`, {
|
||||
accessType: "server",
|
||||
method: "POST",
|
||||
body: {},
|
||||
});
|
||||
|
||||
// Has permission to update a team
|
||||
const response2 = await niceBackendFetch(`/api/v1/teams/${teamId}`, {
|
||||
accessType: "client",
|
||||
method: "PATCH",
|
||||
body: {
|
||||
client_metadata: {
|
||||
test: "test-value"
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(response2).toMatchInlineSnapshot(`
|
||||
NiceResponse {
|
||||
"status": 200,
|
||||
"body": {
|
||||
"client_metadata": { "test": "test-value" },
|
||||
"client_read_only_metadata": null,
|
||||
"display_name": "New Team",
|
||||
"id": "<stripped UUID>",
|
||||
"profile_image_url": null,
|
||||
},
|
||||
"headers": Headers { <some fields may have been hidden> },
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it("should not be able to update team client read only metadata on the client", async ({ expect }) => {
|
||||
const { userId } = await Auth.Otp.signIn();
|
||||
const { teamId } = await Team.create();
|
||||
|
||||
// grant permission to update a team
|
||||
await niceBackendFetch(`/api/v1/team-permissions/${teamId}/${userId}/$update_team`, {
|
||||
accessType: "server",
|
||||
method: "POST",
|
||||
body: {},
|
||||
});
|
||||
|
||||
// Has permission to update a team
|
||||
const response2 = await niceBackendFetch(`/api/v1/teams/${teamId}`, {
|
||||
accessType: "client",
|
||||
method: "PATCH",
|
||||
body: {
|
||||
client_read_only_metadata: {
|
||||
test: "test-value"
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(response2).toMatchInlineSnapshot(`
|
||||
NiceResponse {
|
||||
"status": 400,
|
||||
"body": {
|
||||
"code": "SCHEMA_ERROR",
|
||||
"details": { "message": "Request validation failed on PATCH /api/v1/teams/<stripped UUID>:\\n - body contains unknown properties: client_read_only_metadata" },
|
||||
"error": "Request validation failed on PATCH /api/v1/teams/<stripped UUID>:\\n - body contains unknown properties: client_read_only_metadata",
|
||||
},
|
||||
"headers": Headers {
|
||||
"x-stack-known-error": "SCHEMA_ERROR",
|
||||
<some fields may have been hidden>,
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it("should not update a team without permission on the client", async ({ expect }) => {
|
||||
await Auth.Otp.signIn();
|
||||
const { teamId } = await Team.create();
|
||||
@ -328,16 +423,23 @@ it("updates a team on the server", async ({ expect }) => {
|
||||
method: "PATCH",
|
||||
body: {
|
||||
display_name: "My Updated Team",
|
||||
profile_image_url: "https://example.com/image.jpg",
|
||||
server_metadata: {
|
||||
"test": "test-value"
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(response1).toMatchInlineSnapshot(`
|
||||
NiceResponse {
|
||||
"status": 200,
|
||||
"body": {
|
||||
"client_metadata": null,
|
||||
"client_read_only_metadata": null,
|
||||
"created_at_millis": <stripped field 'created_at_millis'>,
|
||||
"display_name": "My Updated Team",
|
||||
"id": "<stripped UUID>",
|
||||
"profile_image_url": null,
|
||||
"profile_image_url": "https://example.com/image.jpg",
|
||||
"server_metadata": { "test": "test-value" },
|
||||
},
|
||||
"headers": Headers { <some fields may have been hidden> },
|
||||
}
|
||||
@ -351,10 +453,13 @@ it("updates a team on the server", async ({ expect }) => {
|
||||
"is_paginated": false,
|
||||
"items": [
|
||||
{
|
||||
"client_metadata": null,
|
||||
"client_read_only_metadata": null,
|
||||
"created_at_millis": <stripped field 'created_at_millis'>,
|
||||
"display_name": "My Updated Team",
|
||||
"id": "<stripped UUID>",
|
||||
"profile_image_url": null,
|
||||
"profile_image_url": "https://example.com/image.jpg",
|
||||
"server_metadata": { "test": "test-value" },
|
||||
},
|
||||
],
|
||||
},
|
||||
@ -363,6 +468,52 @@ it("updates a team on the server", async ({ expect }) => {
|
||||
`);
|
||||
});
|
||||
|
||||
it("updates team client read only metadata on the server", async ({ expect }) => {
|
||||
await Auth.Otp.signIn();
|
||||
const { teamId } = await Team.create({ accessType: "server" });
|
||||
|
||||
const response1 = await niceBackendFetch(`/api/v1/teams/${teamId}`, {
|
||||
accessType: "server",
|
||||
method: "PATCH",
|
||||
body: {
|
||||
client_read_only_metadata: {
|
||||
test: "test-value"
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(response1).toMatchInlineSnapshot(`
|
||||
NiceResponse {
|
||||
"status": 200,
|
||||
"body": {
|
||||
"client_metadata": null,
|
||||
"client_read_only_metadata": { "test": "test-value" },
|
||||
"created_at_millis": <stripped field 'created_at_millis'>,
|
||||
"display_name": "New Team",
|
||||
"id": "<stripped UUID>",
|
||||
"profile_image_url": null,
|
||||
"server_metadata": null,
|
||||
},
|
||||
"headers": Headers { <some fields may have been hidden> },
|
||||
}
|
||||
`);
|
||||
|
||||
// check on the client
|
||||
const response2 = await niceBackendFetch(`/api/v1/teams/${teamId}`, { accessType: "client" });
|
||||
expect(response2).toMatchInlineSnapshot(`
|
||||
NiceResponse {
|
||||
"status": 200,
|
||||
"body": {
|
||||
"client_metadata": null,
|
||||
"client_read_only_metadata": { "test": "test-value" },
|
||||
"display_name": "New Team",
|
||||
"id": "<stripped UUID>",
|
||||
"profile_image_url": null,
|
||||
},
|
||||
"headers": Headers { <some fields may have been hidden> },
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it("deletes a team on the client", async ({ expect }) => {
|
||||
const { userId } = await Auth.Otp.signIn();
|
||||
const { teamId } = await Team.create();
|
||||
@ -486,10 +637,13 @@ it("enables create team on sign up", async ({ expect }) => {
|
||||
"is_paginated": false,
|
||||
"items": [
|
||||
{
|
||||
"client_metadata": null,
|
||||
"client_read_only_metadata": null,
|
||||
"created_at_millis": <stripped field 'created_at_millis'>,
|
||||
"display_name": "<stripped UUID>@stack-generated.example.com's Team",
|
||||
"id": "<stripped UUID>",
|
||||
"profile_image_url": null,
|
||||
"server_metadata": null,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@ -88,6 +88,7 @@ describe("with client access", () => {
|
||||
],
|
||||
"auth_with_email": true,
|
||||
"client_metadata": null,
|
||||
"client_read_only_metadata": null,
|
||||
"connected_accounts": [],
|
||||
"display_name": null,
|
||||
"has_password": false,
|
||||
@ -127,6 +128,7 @@ describe("with client access", () => {
|
||||
],
|
||||
"auth_with_email": true,
|
||||
"client_metadata": null,
|
||||
"client_read_only_metadata": null,
|
||||
"connected_accounts": [],
|
||||
"display_name": null,
|
||||
"has_password": false,
|
||||
@ -211,6 +213,7 @@ describe("with client access", () => {
|
||||
],
|
||||
"auth_with_email": true,
|
||||
"client_metadata": null,
|
||||
"client_read_only_metadata": null,
|
||||
"connected_accounts": [],
|
||||
"display_name": "John Doe",
|
||||
"has_password": false,
|
||||
@ -249,6 +252,7 @@ describe("with client access", () => {
|
||||
],
|
||||
"auth_with_email": true,
|
||||
"client_metadata": { "key": "value" },
|
||||
"client_read_only_metadata": null,
|
||||
"connected_accounts": [],
|
||||
"display_name": "John Doe",
|
||||
"has_password": false,
|
||||
@ -381,6 +385,7 @@ describe("with client access", () => {
|
||||
],
|
||||
"auth_with_email": true,
|
||||
"client_metadata": null,
|
||||
"client_read_only_metadata": null,
|
||||
"connected_accounts": [],
|
||||
"display_name": "John Doe",
|
||||
"has_password": false,
|
||||
@ -419,6 +424,7 @@ describe("with client access", () => {
|
||||
],
|
||||
"auth_with_email": true,
|
||||
"client_metadata": null,
|
||||
"client_read_only_metadata": null,
|
||||
"connected_accounts": [],
|
||||
"display_name": null,
|
||||
"has_password": false,
|
||||
@ -475,7 +481,6 @@ describe("with client access", () => {
|
||||
`);
|
||||
});
|
||||
|
||||
|
||||
it("should not be able to list users", async ({ expect }) => {
|
||||
const response = await niceBackendFetch("/api/v1/users", {
|
||||
accessType: "client",
|
||||
@ -532,6 +537,74 @@ describe("with client access", () => {
|
||||
`);
|
||||
});
|
||||
|
||||
it("should be able to update own client metadata", async ({ expect }) => {
|
||||
await Auth.Otp.signIn();
|
||||
const response = await niceBackendFetch("/api/v1/users/me", {
|
||||
accessType: "client",
|
||||
method: "PATCH",
|
||||
body: {
|
||||
client_metadata: { key: "value" },
|
||||
},
|
||||
});
|
||||
expect(response).toMatchInlineSnapshot(`
|
||||
NiceResponse {
|
||||
"status": 200,
|
||||
"body": {
|
||||
"auth_methods": [
|
||||
{
|
||||
"contact_channel": {
|
||||
"email": "<stripped UUID>@stack-generated.example.com",
|
||||
"type": "email",
|
||||
},
|
||||
"type": "otp",
|
||||
},
|
||||
],
|
||||
"auth_with_email": true,
|
||||
"client_metadata": { "key": "value" },
|
||||
"client_read_only_metadata": null,
|
||||
"connected_accounts": [],
|
||||
"display_name": null,
|
||||
"has_password": false,
|
||||
"id": "<stripped UUID>",
|
||||
"oauth_providers": [],
|
||||
"primary_email": "<stripped UUID>@stack-generated.example.com",
|
||||
"primary_email_verified": true,
|
||||
"profile_image_url": null,
|
||||
"requires_totp_mfa": false,
|
||||
"selected_team": null,
|
||||
"selected_team_id": null,
|
||||
"signed_up_at_millis": <stripped field 'signed_up_at_millis'>,
|
||||
},
|
||||
"headers": Headers { <some fields may have been hidden> },
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it("should not be able to update own client read-only metadata", async ({ expect }) => {
|
||||
await Auth.Otp.signIn();
|
||||
const response = await niceBackendFetch("/api/v1/users/me", {
|
||||
accessType: "client",
|
||||
method: "PATCH",
|
||||
body: {
|
||||
client_read_only_metadata: { key: "value" },
|
||||
},
|
||||
});
|
||||
expect(response).toMatchInlineSnapshot(`
|
||||
NiceResponse {
|
||||
"status": 400,
|
||||
"body": {
|
||||
"code": "SCHEMA_ERROR",
|
||||
"details": { "message": "Request validation failed on PATCH /api/v1/users/me:\\n - body contains unknown properties: client_read_only_metadata" },
|
||||
"error": "Request validation failed on PATCH /api/v1/users/me:\\n - body contains unknown properties: client_read_only_metadata",
|
||||
},
|
||||
"headers": Headers {
|
||||
"x-stack-known-error": "SCHEMA_ERROR",
|
||||
<some fields may have been hidden>,
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it.todo("should be able to set selected team id, updating the selected team object");
|
||||
});
|
||||
|
||||
@ -556,6 +629,7 @@ describe("with server access", () => {
|
||||
],
|
||||
"auth_with_email": true,
|
||||
"client_metadata": null,
|
||||
"client_read_only_metadata": null,
|
||||
"connected_accounts": [],
|
||||
"display_name": null,
|
||||
"has_password": false,
|
||||
@ -599,6 +673,7 @@ describe("with server access", () => {
|
||||
],
|
||||
"auth_with_email": true,
|
||||
"client_metadata": null,
|
||||
"client_read_only_metadata": null,
|
||||
"connected_accounts": [],
|
||||
"display_name": "John Doe",
|
||||
"has_password": false,
|
||||
@ -662,6 +737,7 @@ describe("with server access", () => {
|
||||
],
|
||||
"auth_with_email": true,
|
||||
"client_metadata": null,
|
||||
"client_read_only_metadata": null,
|
||||
"connected_accounts": [],
|
||||
"display_name": null,
|
||||
"has_password": false,
|
||||
@ -695,6 +771,7 @@ describe("with server access", () => {
|
||||
"auth_methods": [],
|
||||
"auth_with_email": false,
|
||||
"client_metadata": null,
|
||||
"client_read_only_metadata": null,
|
||||
"connected_accounts": [],
|
||||
"display_name": null,
|
||||
"has_password": false,
|
||||
@ -740,6 +817,7 @@ describe("with server access", () => {
|
||||
],
|
||||
"auth_with_email": true,
|
||||
"client_metadata": null,
|
||||
"client_read_only_metadata": null,
|
||||
"connected_accounts": [],
|
||||
"display_name": "John Dough",
|
||||
"has_password": false,
|
||||
@ -789,6 +867,7 @@ describe("with server access", () => {
|
||||
],
|
||||
"auth_with_email": true,
|
||||
"client_metadata": null,
|
||||
"client_read_only_metadata": null,
|
||||
"connected_accounts": [],
|
||||
"display_name": null,
|
||||
"has_password": true,
|
||||
@ -879,6 +958,7 @@ describe("with server access", () => {
|
||||
],
|
||||
"auth_with_email": true,
|
||||
"client_metadata": null,
|
||||
"client_read_only_metadata": null,
|
||||
"connected_accounts": [],
|
||||
"display_name": null,
|
||||
"has_password": false,
|
||||
@ -934,6 +1014,7 @@ describe("with server access", () => {
|
||||
"auth_methods": [],
|
||||
"auth_with_email": false,
|
||||
"client_metadata": null,
|
||||
"client_read_only_metadata": null,
|
||||
"connected_accounts": [],
|
||||
"display_name": null,
|
||||
"has_password": false,
|
||||
@ -980,6 +1061,7 @@ describe("with server access", () => {
|
||||
],
|
||||
"auth_with_email": true,
|
||||
"client_metadata": null,
|
||||
"client_read_only_metadata": null,
|
||||
"connected_accounts": [],
|
||||
"display_name": null,
|
||||
"has_password": true,
|
||||
@ -1041,6 +1123,7 @@ describe("with server access", () => {
|
||||
],
|
||||
"auth_with_email": true,
|
||||
"client_metadata": null,
|
||||
"client_read_only_metadata": null,
|
||||
"connected_accounts": [],
|
||||
"display_name": null,
|
||||
"has_password": true,
|
||||
@ -1072,6 +1155,7 @@ describe("with server access", () => {
|
||||
"auth_methods": [],
|
||||
"auth_with_email": false,
|
||||
"client_metadata": null,
|
||||
"client_read_only_metadata": null,
|
||||
"connected_accounts": [],
|
||||
"display_name": null,
|
||||
"has_password": false,
|
||||
@ -1135,6 +1219,7 @@ describe("with server access", () => {
|
||||
],
|
||||
"auth_with_email": true,
|
||||
"client_metadata": null,
|
||||
"client_read_only_metadata": null,
|
||||
"connected_accounts": [],
|
||||
"display_name": "John Doe",
|
||||
"has_password": false,
|
||||
@ -1170,6 +1255,7 @@ describe("with server access", () => {
|
||||
],
|
||||
"auth_with_email": true,
|
||||
"client_metadata": null,
|
||||
"client_read_only_metadata": null,
|
||||
"connected_accounts": [],
|
||||
"display_name": "John Doe",
|
||||
"has_password": false,
|
||||
@ -1227,4 +1313,50 @@ describe("with server access", () => {
|
||||
`);
|
||||
});
|
||||
|
||||
it("should be able to update all metadata fields", async ({ expect }) => {
|
||||
await Auth.Otp.signIn();
|
||||
const response = await niceBackendFetch("/api/v1/users/me", {
|
||||
accessType: "server",
|
||||
method: "PATCH",
|
||||
body: {
|
||||
client_metadata: { key: "client value" },
|
||||
client_read_only_metadata: { key: "client read only value" },
|
||||
server_metadata: { key: "server value" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(response).toMatchInlineSnapshot(`
|
||||
NiceResponse {
|
||||
"status": 200,
|
||||
"body": {
|
||||
"auth_methods": [
|
||||
{
|
||||
"contact_channel": {
|
||||
"email": "<stripped UUID>@stack-generated.example.com",
|
||||
"type": "email",
|
||||
},
|
||||
"type": "otp",
|
||||
},
|
||||
],
|
||||
"auth_with_email": true,
|
||||
"client_metadata": { "key": "client value" },
|
||||
"client_read_only_metadata": { "key": "client read only value" },
|
||||
"connected_accounts": [],
|
||||
"display_name": null,
|
||||
"has_password": false,
|
||||
"id": "<stripped UUID>",
|
||||
"oauth_providers": [],
|
||||
"primary_email": "<stripped UUID>@stack-generated.example.com",
|
||||
"primary_email_verified": true,
|
||||
"profile_image_url": null,
|
||||
"requires_totp_mfa": false,
|
||||
"selected_team": null,
|
||||
"selected_team_id": null,
|
||||
"server_metadata": { "key": "server value" },
|
||||
"signed_up_at_millis": <stripped field 'signed_up_at_millis'>,
|
||||
},
|
||||
"headers": Headers { <some fields may have been hidden> },
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
@ -151,7 +151,7 @@ console.log(user.clientMetadata);
|
||||
|
||||
If you want to store sensitive information, you can use the `serverMetadata` field. This data is only readable & writable from the server.
|
||||
|
||||
```tsx title="my-server-component.tsx"
|
||||
```tsx
|
||||
const user = await stackServerApp.getUser();
|
||||
await user.update({
|
||||
serverMetadata: {
|
||||
@ -164,6 +164,22 @@ const user = await stackServerApp.getUser();
|
||||
console.log(user.serverMetadata);
|
||||
```
|
||||
|
||||
If you want to store some information that is writable by the server but only readable by the client, you can use the `clientReadOnlyMetadata` field. This is useful for things like subscription status, where the client needs to know the status but shouldn't be able to change it.
|
||||
|
||||
```tsx
|
||||
// On the server:
|
||||
const user = await stackServerApp.getUser();
|
||||
await user.update({
|
||||
clientReadOnlyMetadata: {
|
||||
subscriptionPlan: "premium",
|
||||
},
|
||||
});
|
||||
|
||||
// On the client:
|
||||
const user = useUser();
|
||||
console.log(user.clientReadOnlyMetadata);
|
||||
```
|
||||
|
||||
## Signing out
|
||||
|
||||
You can sign out the user by redirecting them to `/handler/sign-out` or simply by calling `user.signOut()`. They will be redirected to the URL [configured as `afterSignOut` in the `StackServerApp`](/sdk/stack-app).
|
||||
|
||||
@ -18,6 +18,7 @@ const clientReadSchema = usersCrudServerReadSchema.pick([
|
||||
"primary_email_verified",
|
||||
"display_name",
|
||||
"client_metadata",
|
||||
"client_read_only_metadata",
|
||||
"profile_image_url",
|
||||
"signed_up_at_millis",
|
||||
"has_password",
|
||||
|
||||
@ -8,17 +8,23 @@ export const teamsCrudClientReadSchema = yupObject({
|
||||
id: fieldSchema.teamIdSchema.required(),
|
||||
display_name: fieldSchema.teamDisplayNameSchema.required(),
|
||||
profile_image_url: fieldSchema.teamProfileImageUrlSchema.nullable().defined(),
|
||||
client_metadata: fieldSchema.teamClientMetadataSchema.optional(),
|
||||
client_read_only_metadata: fieldSchema.teamClientReadOnlyMetadataSchema.optional(),
|
||||
}).required();
|
||||
export const teamsCrudServerReadSchema = teamsCrudClientReadSchema.concat(yupObject({
|
||||
created_at_millis: fieldSchema.teamCreatedAtMillisSchema.required(),
|
||||
server_metadata: fieldSchema.teamServerMetadataSchema.optional(),
|
||||
}).required());
|
||||
|
||||
// Update
|
||||
export const teamsCrudClientUpdateSchema = yupObject({
|
||||
display_name: fieldSchema.teamDisplayNameSchema.optional(),
|
||||
profile_image_url: fieldSchema.teamProfileImageUrlSchema.nullable().optional(),
|
||||
client_metadata: fieldSchema.teamClientMetadataSchema.optional(),
|
||||
}).required();
|
||||
export const teamsCrudServerUpdateSchema = teamsCrudClientUpdateSchema.concat(yupObject({
|
||||
client_read_only_metadata: fieldSchema.teamClientReadOnlyMetadataSchema.optional(),
|
||||
server_metadata: fieldSchema.teamServerMetadataSchema.optional(),
|
||||
}).required());
|
||||
|
||||
// Create
|
||||
|
||||
@ -7,6 +7,7 @@ export const usersCrudServerUpdateSchema = fieldSchema.yupObject({
|
||||
display_name: fieldSchema.userDisplayNameSchema.optional(),
|
||||
profile_image_url: fieldSchema.profileImageUrlSchema.optional(),
|
||||
client_metadata: fieldSchema.userClientMetadataSchema.optional(),
|
||||
client_read_only_metadata: fieldSchema.userClientReadOnlyMetadataSchema.optional(),
|
||||
server_metadata: fieldSchema.userServerMetadataSchema.optional(),
|
||||
primary_email: fieldSchema.primaryEmailSchema.nullable().optional(),
|
||||
primary_email_verified: fieldSchema.primaryEmailVerifiedSchema.optional(),
|
||||
@ -66,6 +67,7 @@ export const usersCrudServerReadSchema = fieldSchema.yupObject({
|
||||
}).required(),
|
||||
)).required().meta({ openapiField: { hidden: true, description: 'A list of connected accounts to this user', exampleValue: [ { "provider": { "provider_user_id": "12345", "type": "google", }, "type": "oauth", } ] } }),
|
||||
client_metadata: fieldSchema.userClientMetadataSchema,
|
||||
client_read_only_metadata: fieldSchema.userClientReadOnlyMetadataSchema,
|
||||
server_metadata: fieldSchema.userServerMetadataSchema,
|
||||
}).required();
|
||||
|
||||
|
||||
@ -7,6 +7,7 @@ import { isUuid } from "./utils/uuids";
|
||||
const _idDescription = (identify: string) => `The unique identifier of this ${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.`;
|
||||
const _profileImageUrlDescription = (identify: string) => `URL of the profile image for ${identify}. Can be a Base64 encoded image. Please compress and crop to a square before passing in.`;
|
||||
const _serverMetaDataDescription = (identify: string) => `Server metadata. Used as a data store, only accessible from the server side. You can store secret information related to the ${identify} here.`;
|
||||
const _atMillisDescription = (identify: string) => `(the number of milliseconds since epoch, January 1, 1970, UTC)`;
|
||||
@ -198,6 +199,7 @@ export const selectedTeamIdSchema = yupString().uuid().meta({ openapiField: { de
|
||||
export const profileImageUrlSchema = yupString().meta({ openapiField: { description: _profileImageUrlDescription('user'), exampleValue: 'https://example.com/image.jpg' } });
|
||||
export const signedUpAtMillisSchema = yupNumber().meta({ openapiField: { description: _signedUpAtMillisDescription, exampleValue: 1630000000000 } });
|
||||
export const userClientMetadataSchema = jsonSchema.meta({ openapiField: { description: _clientMetaDataDescription('user'), exampleValue: { key: 'value' } } });
|
||||
export const userClientReadOnlyMetadataSchema = jsonSchema.meta({ openapiField: { description: _clientReadOnlyMetaDataDescription('user'), exampleValue: { key: 'value' } } });
|
||||
export const userServerMetadataSchema = jsonSchema.meta({ openapiField: { description: _serverMetaDataDescription('user'), exampleValue: { key: 'value' } } });
|
||||
export const userOAuthProviderSchema = yupObject({
|
||||
type: yupString().required(),
|
||||
@ -246,6 +248,7 @@ export const teamIdSchema = yupString().uuid().meta({ openapiField: { descriptio
|
||||
export const teamDisplayNameSchema = yupString().meta({ openapiField: { description: _displayNameDescription('team'), exampleValue: 'My Team' } });
|
||||
export const teamProfileImageUrlSchema = yupString().meta({ openapiField: { description: _profileImageUrlDescription('team'), exampleValue: 'https://example.com/image.jpg' } });
|
||||
export const teamClientMetadataSchema = jsonSchema.meta({ openapiField: { description: _clientMetaDataDescription('team'), exampleValue: { key: 'value' } } });
|
||||
export const teamClientReadOnlyMetadataSchema = jsonSchema.meta({ openapiField: { description: _clientReadOnlyMetaDataDescription('team'), exampleValue: { key: 'value' } } });
|
||||
export const teamServerMetadataSchema = jsonSchema.meta({ openapiField: { description: _serverMetaDataDescription('team'), exampleValue: { key: 'value' } } });
|
||||
export const teamCreatedAtMillisSchema = yupNumber().meta({ openapiField: { description: _createdAtMillisDescription('team'), exampleValue: 1630000000000 } });
|
||||
export const teamInvitationEmailSchema = emailSchema.meta({ openapiField: { description: 'The email to sign in with.', exampleValue: 'johndoe@example.com' } });
|
||||
|
||||
@ -787,6 +787,7 @@ class _StackClientAppImpl<HasTokenStore extends boolean, ProjectId extends strin
|
||||
profileImageUrl: crud.profile_image_url,
|
||||
signedUpAt: new Date(crud.signed_up_at_millis),
|
||||
clientMetadata: crud.client_metadata,
|
||||
clientReadOnlyMetadata: crud.client_read_only_metadata,
|
||||
hasPassword: crud.has_password,
|
||||
emailAuthEnabled: crud.auth_with_email,
|
||||
oauthProviders: crud.oauth_providers,
|
||||
@ -1597,6 +1598,9 @@ class _StackServerAppImpl<HasTokenStore extends boolean, ProjectId extends strin
|
||||
async setClientMetadata(metadata: Record<string, any>) {
|
||||
return await this.update({ clientMetadata: metadata });
|
||||
},
|
||||
async setClientReadOnlyMetadata(metadata: Record<string, any>) {
|
||||
return await this.update({ clientReadOnlyMetadata: metadata });
|
||||
},
|
||||
async setServerMetadata(metadata: Record<string, any>) {
|
||||
return await this.update({ serverMetadata: metadata });
|
||||
},
|
||||
@ -2292,6 +2296,7 @@ type BaseUser = {
|
||||
readonly signedUpAt: Date,
|
||||
|
||||
readonly clientMetadata: any,
|
||||
readonly clientReadOnlyMetadata: any,
|
||||
|
||||
/**
|
||||
* Whether the primary e-mail can be used for authentication.
|
||||
@ -2379,6 +2384,7 @@ type ServerBaseUser = {
|
||||
|
||||
readonly serverMetadata: any,
|
||||
setServerMetadata(metadata: any): Promise<void>,
|
||||
setClientReadOnlyMetadata(metadata: any): Promise<void>,
|
||||
|
||||
updatePassword(options: { oldPassword?: string, newPassword: string}): Promise<KnownErrors["PasswordConfirmationMismatch"] | KnownErrors["PasswordRequirementsNotMet"] | void>,
|
||||
|
||||
@ -2414,6 +2420,7 @@ type ServerUserUpdateOptions = {
|
||||
primaryEmail?: string,
|
||||
primaryEmailVerified?: boolean,
|
||||
primaryEmailAuthEnabled?: boolean,
|
||||
clientReadOnlyMetadata?: ReadonlyJson,
|
||||
serverMetadata?: ReadonlyJson,
|
||||
password?: string,
|
||||
} & UserUpdateOptions;
|
||||
@ -2422,6 +2429,7 @@ function serverUserUpdateOptionsToCrud(options: ServerUserUpdateOptions): Curren
|
||||
display_name: options.displayName,
|
||||
primary_email: options.primaryEmail,
|
||||
client_metadata: options.clientMetadata,
|
||||
client_read_only_metadata: options.clientReadOnlyMetadata,
|
||||
server_metadata: options.serverMetadata,
|
||||
selected_team_id: options.selectedTeamId,
|
||||
primary_email_auth_enabled: options.primaryEmailAuthEnabled,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user