Skip to content

Commit 5b9ee57

Browse files
committed
Improved user creation handlers
1 parent 10112a1 commit 5b9ee57

File tree

7 files changed

+525
-19
lines changed

7 files changed

+525
-19
lines changed

apps/backend/src/app/api/v1/auth/otp/send-sign-in-code/route.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { adaptSchema, clientOrHigherAuthTypeSchema, emailOtpSignInCallbackUrlSch
55
import { StackAssertionError, StatusError } from "@stackframe/stack-shared/dist/utils/errors";
66
import { usersCrudHandlers } from "../../../users/crud";
77
import { signInVerificationCodeHandler } from "../sign-in/verification-code-handler";
8+
import { KnownErrors } from "@stackframe/stack-shared";
89

910
export const POST = createSmartRouteHandler({
1011
metadata: {
@@ -54,6 +55,7 @@ export const POST = createSmartRouteHandler({
5455
primary_email: email,
5556
primary_email_verified: false,
5657
},
58+
allowedErrorTypes: [KnownErrors.UserEmailAlreadyExists],
5759
});
5860
userObj = {
5961
projectUserId: createdUser.id,

apps/backend/src/app/api/v1/auth/password/sign-up/route.tsx

Lines changed: 1 addition & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -45,19 +45,6 @@ export const POST = createSmartRouteHandler({
4545
throw passwordError;
4646
}
4747

48-
// TODO: make this a transaction
49-
const users = await prismaClient.projectUser.findMany({
50-
where: {
51-
projectId: project.id,
52-
primaryEmail: email,
53-
authWithEmail: true,
54-
},
55-
});
56-
57-
if (users.length > 0) {
58-
throw new KnownErrors.UserEmailAlreadyExists();
59-
}
60-
6148
const createdUser = await usersCrudHandlers.adminCreate({
6249
project,
6350
data: {
@@ -66,6 +53,7 @@ export const POST = createSmartRouteHandler({
6653
primary_email_verified: false,
6754
password,
6855
},
56+
allowedErrorTypes: [KnownErrors.UserEmailAlreadyExists],
6957
});
7058

7159
await contactChannelVerificationCodeHandler.sendCode({

apps/backend/src/app/api/v1/team-invitations/accept/verification-code-handler.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { VerificationCodeType } from "@prisma/client";
66
import { UsersCrud } from "@stackframe/stack-shared/dist/interface/crud/users";
77
import { yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
88
import { teamsCrudHandlers } from "../../teams/crud";
9+
import { KnownErrors } from "@stackframe/stack-shared";
910

1011
export const teamInvitationCodeHandler = createVerificationCodeHandler({
1112
metadata: {

apps/backend/src/app/api/v1/users/crud.tsx

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,12 @@ import { KnownErrors } from "@stackframe/stack-shared";
55
import { currentUserCrud } from "@stackframe/stack-shared/dist/interface/crud/current-user";
66
import { UsersCrud, usersCrud } from "@stackframe/stack-shared/dist/interface/crud/users";
77
import { userIdOrMeSchema, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
8-
import { StackAssertionError, captureError, throwErr } from "@stackframe/stack-shared/dist/utils/errors";
8+
import { StackAssertionError, StatusError, captureError, throwErr } from "@stackframe/stack-shared/dist/utils/errors";
99
import { hashPassword } from "@stackframe/stack-shared/dist/utils/password";
1010
import { createLazyProxy } from "@stackframe/stack-shared/dist/utils/proxies";
1111
import { teamPrismaToCrud } from "../teams/crud";
1212
import { sendUserCreatedWebhook, sendUserDeletedWebhook, sendUserUpdatedWebhook } from "@/lib/webhooks";
13+
import { getPasswordError } from "@stackframe/stack-shared/dist/helpers/password";
1314

1415
const fullInclude = {
1516
projectUserOAuthAccounts: {
@@ -40,7 +41,7 @@ const prismaToCrud = (prisma: Prisma.ProjectUserGetPayload<{ include: typeof ful
4041
captureError("prismaToCrud", new StackAssertionError("User has authWithEmail but no primary email; this is an assertion error that should never happen", { prisma }));
4142
}
4243
const authMethods: UsersCrud["Admin"]["Read"]["auth_methods"] = [
43-
...prisma.passwordHash ? [{
44+
...prisma.authWithEmail && prisma.passwordHash ? [{
4445
type: 'password',
4546
identifier: prisma.primaryEmail ?? "",
4647
}] as const : [],
@@ -138,6 +139,28 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC
138139
};
139140
},
140141
onCreate: async ({ auth, data }) => {
142+
if (!data.primary_email && data.primary_email_auth_enabled) {
143+
throw new StatusError(400, "primary_email_auth_enabled cannot be true without primary_email");
144+
}
145+
if (!data.primary_email_auth_enabled && data.password) {
146+
throw new StatusError(400, "password cannot be set without primary_email_auth_enabled");
147+
}
148+
if (data.primary_email_auth_enabled) {
149+
// TODO: make this a transaction
150+
const users = await prismaClient.projectUser.findMany({
151+
where: {
152+
projectId: auth.project.id,
153+
primaryEmail: data.primary_email,
154+
authWithEmail: true,
155+
},
156+
});
157+
158+
if (users.length > 0) {
159+
throw new KnownErrors.UserEmailAlreadyExists();
160+
}
161+
}
162+
163+
141164
const db = await prismaClient.projectUser.create({
142165
data: {
143166
projectId: auth.project.id,

apps/backend/src/route-handlers/crud-handler.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ type CrudHandlerDirectByAccess<
7373
& {
7474
project: ProjectsCrud["Admin"]["Read"],
7575
user?: UsersCrud["Admin"]["Read"],
76+
allowedErrorTypes?: (new (...args: any) => any)[],
7677
}
7778
& ({} extends yup.InferType<QS> ? {} : { query: yup.InferType<QS> })
7879
& (L extends "Create" | "List" ? Partial<yup.InferType<PS>> : yup.InferType<PS>)
@@ -231,11 +232,12 @@ export function createCrudHandlers<
231232
...[...aat].map(([accessType, { invoke }]) => (
232233
[
233234
`${accessType}${crudOperation}`,
234-
async ({ user, project, data, query, ...params }: yup.InferType<PS> & {
235+
async ({ user, project, data, query, allowedErrorTypes, ...params }: yup.InferType<PS> & {
235236
query?: yup.InferType<QS>,
236237
project: ProjectsCrud["Admin"]["Read"],
237238
user?: UsersCrud["Admin"]["Read"],
238239
data: any,
240+
allowedErrorTypes?: (new (...args: any) => any)[],
239241
}) => {
240242
try {
241243
return await invoke({
@@ -249,6 +251,9 @@ export function createCrudHandlers<
249251
},
250252
});
251253
} catch (error) {
254+
if (allowedErrorTypes?.some((a) => error instanceof a)) {
255+
throw error;
256+
}
252257
throw new CrudHandlerInvocationError(error);
253258
}
254259
},

apps/e2e/tests/backend/endpoints/api/v1/auth/otp/sign-in.test.ts

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { it } from "../../../../../../helpers";
2-
import { Auth } from "../../../../../backend-helpers";
2+
import { Auth, backendContext, niceBackendFetch } from "../../../../../backend-helpers";
33

44
it("should sign up new users and sign in existing users", async ({ expect }) => {
55
const res1 = await Auth.Otp.signIn();
@@ -30,6 +30,55 @@ it("should sign up new users and sign in existing users", async ({ expect }) =>
3030
`);
3131
});
3232

33+
it("should sign in users created with the server API", async ({ expect }) => {
34+
const response = await niceBackendFetch("/api/v1/users", {
35+
accessType: "server",
36+
method: "POST",
37+
body: {
38+
primary_email: backendContext.value.mailbox.emailAddress,
39+
primary_email_auth_enabled: true,
40+
},
41+
});
42+
const res2 = await Auth.Otp.signIn();
43+
expect(res2.signInResponse).toMatchInlineSnapshot(`
44+
NiceResponse {
45+
"status": 200,
46+
"body": {
47+
"access_token": <stripped field 'access_token'>,
48+
"is_new_user": false,
49+
"refresh_token": <stripped field 'refresh_token'>,
50+
"user_id": "<stripped UUID>",
51+
},
52+
"headers": Headers { <some fields may have been hidden> },
53+
}
54+
`);
55+
});
56+
57+
it("should sign up a new user even if one already exists with email auth disabled", async ({ expect }) => {
58+
const response = await niceBackendFetch("/api/v1/users", {
59+
accessType: "server",
60+
method: "POST",
61+
body: {
62+
primary_email: backendContext.value.mailbox.emailAddress,
63+
primary_email_auth_enabled: false,
64+
},
65+
});
66+
const res2 = await Auth.Otp.signIn();
67+
expect(res2.signInResponse).toMatchInlineSnapshot(`
68+
NiceResponse {
69+
"status": 200,
70+
"body": {
71+
"access_token": <stripped field 'access_token'>,
72+
"is_new_user": true,
73+
"refresh_token": <stripped field 'refresh_token'>,
74+
"user_id": "<stripped UUID>",
75+
},
76+
"headers": Headers { <some fields may have been hidden> },
77+
}
78+
`);
79+
});
80+
81+
3382
it.todo("should not sign in if primary e-mail changed since sign-in code was sent");
3483

3584
it.todo("should verify primary e-mail");

0 commit comments

Comments
 (0)