@supa-media/convex 1.3.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -0
- package/package.json +1 -1
- package/src/auth/admission.ts +90 -0
- package/src/auth/index.ts +2 -0
- package/src/auth/setup.ts +27 -133
- package/src/auth/testEmail.ts +87 -0
- package/src/auth/users.ts +113 -0
package/README.md
CHANGED
|
@@ -193,6 +193,7 @@ function createSupaAuth(config?: {
|
|
|
193
193
|
appName?: string;
|
|
194
194
|
methods?: Array<"email" | "phone">; // default ["email", "phone"]
|
|
195
195
|
magicLink?: SupaAuthMagicLinkConfig; // off unless present
|
|
196
|
+
admission?: SupaAuthAdmission; // invite-only; open sign-up unless present
|
|
196
197
|
resend?: { fromAddress: string; emailSubject?: (code) => string;
|
|
197
198
|
renderHtml?: (p: { code, email }) => string };
|
|
198
199
|
twilio?: { tokenBridgePath?: string }; // default "/api/internal/phone-token"
|
|
@@ -244,6 +245,26 @@ production login code.
|
|
|
244
245
|
> `PHONE_TOKEN_BRIDGE_SECRET` is unset, the provider logs the raw token to the
|
|
245
246
|
> console and returns, so local dev degrades rather than breaks.
|
|
246
247
|
|
|
248
|
+
|
|
249
|
+
### Invite-only sign-in (`admission`)
|
|
250
|
+
|
|
251
|
+
```ts
|
|
252
|
+
createSupaAuth({
|
|
253
|
+
admission: {
|
|
254
|
+
canReceiveEmailCode: (ctx, email) => ctx.runQuery(internal.waitlist.isAdmitted, { email }),
|
|
255
|
+
canCreateUser: (ctx, { email }) => isAdmitted(ctx.db, email),
|
|
256
|
+
},
|
|
257
|
+
});
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
`canReceiveEmailCode` runs before a sign-in code is mailed; refusing means no
|
|
261
|
+
mail goes out and `signIn` throws. `canCreateUser` runs before a user row is
|
|
262
|
+
created for somebody with no account, whichever provider they came through, and
|
|
263
|
+
is never asked for an account that already exists. Both fail closed: a hook
|
|
264
|
+
that throws refuses. Ask your own "is this address let in?" before calling
|
|
265
|
+
`signIn` and draw the answer yourself; the error message is redacted in
|
|
266
|
+
production.
|
|
267
|
+
|
|
247
268
|
### Magic link (opt-in)
|
|
248
269
|
|
|
249
270
|
Set `magicLink: {}` and a **second** email provider is registered under
|
package/package.json
CHANGED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who may sign in at all: the hooks an invite-only or waitlisted app uses.
|
|
3
|
+
*
|
|
4
|
+
* Without `admission`, anybody who can read their inbox can make an account —
|
|
5
|
+
* the default, and unchanged. With it, an app answers two questions:
|
|
6
|
+
*
|
|
7
|
+
* - **May this address be mailed a code?** Asked before the email OTP
|
|
8
|
+
* provider sends anything. Refusing means no mail goes out and `signIn`
|
|
9
|
+
* throws, so a person who has not been let in never holds a code.
|
|
10
|
+
* - **May this person get a brand-new account?** Asked in
|
|
11
|
+
* `createOrUpdateUser`, only when no user exists yet. This is the backstop,
|
|
12
|
+
* and it is the one that covers every provider — the magic link, the
|
|
13
|
+
* fixed-code test accounts, a phone number — not only the email OTP.
|
|
14
|
+
* Returning users never reach it, so turning an app invite-only can never
|
|
15
|
+
* lock out an account that already exists.
|
|
16
|
+
*
|
|
17
|
+
* Both hooks fail closed: one that throws, or a send that arrives with no ctx
|
|
18
|
+
* to ask with, refuses.
|
|
19
|
+
*
|
|
20
|
+
* The refusal is one fixed message, `NOT_ADMITTED_MESSAGE`. A client should
|
|
21
|
+
* not rely on reading it — Convex redacts action errors in production — and
|
|
22
|
+
* should instead ask the app whether an address is let in *before* calling
|
|
23
|
+
* `signIn`, drawing its own "you're on the list" for the answer. These hooks
|
|
24
|
+
* are what makes that answer binding rather than advisory.
|
|
25
|
+
*/
|
|
26
|
+
import type {
|
|
27
|
+
AnyDataModel,
|
|
28
|
+
GenericActionCtx,
|
|
29
|
+
GenericMutationCtx,
|
|
30
|
+
} from "convex/server";
|
|
31
|
+
|
|
32
|
+
export interface SupaAuthAdmission {
|
|
33
|
+
/**
|
|
34
|
+
* Before a sign-in code is mailed. `email` is the address as the provider
|
|
35
|
+
* received it; normalize it the same way the app stores addresses.
|
|
36
|
+
*/
|
|
37
|
+
canReceiveEmailCode?: (
|
|
38
|
+
ctx: GenericActionCtx<AnyDataModel>,
|
|
39
|
+
email: string,
|
|
40
|
+
) => Promise<boolean>;
|
|
41
|
+
/**
|
|
42
|
+
* Before a user row is created for somebody with no account. `provider` is
|
|
43
|
+
* the auth provider's id (`"email"`, `"magic-link"`, `"test-email"`, …).
|
|
44
|
+
*/
|
|
45
|
+
canCreateUser?: (
|
|
46
|
+
ctx: GenericMutationCtx<AnyDataModel>,
|
|
47
|
+
who: { provider: string; email?: string; phone?: string },
|
|
48
|
+
) => Promise<boolean>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const NOT_ADMITTED_MESSAGE = "This address has not been let in yet.";
|
|
52
|
+
|
|
53
|
+
async function allowed(check: () => Promise<boolean>): Promise<boolean> {
|
|
54
|
+
try {
|
|
55
|
+
return (await check()) === true;
|
|
56
|
+
} catch (error) {
|
|
57
|
+
console.error("[supa-auth] admission check failed, refusing:", error);
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Throws unless `admission` lets `email` be mailed a code. */
|
|
63
|
+
export async function assertMayReceiveEmailCode(
|
|
64
|
+
admission: SupaAuthAdmission | undefined,
|
|
65
|
+
ctx: unknown,
|
|
66
|
+
email: string,
|
|
67
|
+
): Promise<void> {
|
|
68
|
+
const check = admission?.canReceiveEmailCode;
|
|
69
|
+
if (check === undefined) return;
|
|
70
|
+
const ok =
|
|
71
|
+
ctx !== undefined &&
|
|
72
|
+
ctx !== null &&
|
|
73
|
+
(await allowed(() =>
|
|
74
|
+
check(ctx as GenericActionCtx<AnyDataModel>, email),
|
|
75
|
+
));
|
|
76
|
+
if (!ok) throw new Error(NOT_ADMITTED_MESSAGE);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Throws unless `admission` lets this person have a new account. */
|
|
80
|
+
export async function assertMayCreateUser(
|
|
81
|
+
admission: SupaAuthAdmission | undefined,
|
|
82
|
+
ctx: GenericMutationCtx<AnyDataModel>,
|
|
83
|
+
who: { provider: string; email?: string; phone?: string },
|
|
84
|
+
): Promise<void> {
|
|
85
|
+
const check = admission?.canCreateUser;
|
|
86
|
+
if (check === undefined) return;
|
|
87
|
+
if (!(await allowed(() => check(ctx, who)))) {
|
|
88
|
+
throw new Error(NOT_ADMITTED_MESSAGE);
|
|
89
|
+
}
|
|
90
|
+
}
|
package/src/auth/index.ts
CHANGED
package/src/auth/setup.ts
CHANGED
|
@@ -25,7 +25,18 @@
|
|
|
25
25
|
import { convexAuth } from "@convex-dev/auth/server";
|
|
26
26
|
import { Email } from "@convex-dev/auth/providers/Email";
|
|
27
27
|
import { Phone } from "@convex-dev/auth/providers/Phone";
|
|
28
|
-
import type
|
|
28
|
+
import { assertMayReceiveEmailCode, type SupaAuthAdmission } from "./admission";
|
|
29
|
+
import { createTestEmailOtps, type SupaAuthTestEmailConfig } from "./testEmail";
|
|
30
|
+
import { userCallback } from "./users";
|
|
31
|
+
|
|
32
|
+
export { NOT_ADMITTED_MESSAGE, type SupaAuthAdmission } from "./admission";
|
|
33
|
+
|
|
34
|
+
export {
|
|
35
|
+
createTestEmailOtp,
|
|
36
|
+
createTestEmailOtps,
|
|
37
|
+
TEST_EMAIL_PROVIDER_ID,
|
|
38
|
+
type SupaAuthTestEmailConfig,
|
|
39
|
+
} from "./testEmail";
|
|
29
40
|
|
|
30
41
|
export interface SupaAuthResendConfig {
|
|
31
42
|
/** The "from" address for OTP emails. */
|
|
@@ -57,16 +68,6 @@ export interface SupaAuthTwilioConfig {
|
|
|
57
68
|
*/
|
|
58
69
|
export const MAGIC_LINK_PROVIDER_ID = "magic-link";
|
|
59
70
|
|
|
60
|
-
/** Provider id for an explicitly scoped production test account. */
|
|
61
|
-
export const TEST_EMAIL_PROVIDER_ID = "test-email";
|
|
62
|
-
|
|
63
|
-
export interface SupaAuthTestEmailConfig {
|
|
64
|
-
/** The only email address allowed to use the fixed test code. */
|
|
65
|
-
email: string;
|
|
66
|
-
/** Fixed verification code. Defaults to `000000`. */
|
|
67
|
-
code?: string;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
71
|
export interface SupaAuthMagicLinkConfig {
|
|
71
72
|
/**
|
|
72
73
|
* How long a link stays live, in seconds. Defaults to one hour.
|
|
@@ -111,11 +112,18 @@ export interface SupaAuthConfig {
|
|
|
111
112
|
*/
|
|
112
113
|
magicLink?: SupaAuthMagicLinkConfig;
|
|
113
114
|
/**
|
|
114
|
-
* Register a fixed-code provider for one exact test account
|
|
115
|
+
* Register a fixed-code provider for one exact test account, or one
|
|
116
|
+
* provider per account when given a list (each with its own `id` and code;
|
|
117
|
+
* an app store or connector-directory reviewer is the usual second one).
|
|
115
118
|
* Unlike `DEV_OTP_BYPASS`, this may be used in production because every
|
|
116
119
|
* other email address is refused by a distinct provider.
|
|
117
120
|
*/
|
|
118
|
-
testEmail?: SupaAuthTestEmailConfig;
|
|
121
|
+
testEmail?: SupaAuthTestEmailConfig | SupaAuthTestEmailConfig[];
|
|
122
|
+
/**
|
|
123
|
+
* Make the app invite-only: who may be mailed a sign-in code, and who may
|
|
124
|
+
* get a brand-new account. Omitted, anybody may sign up. See `admission.ts`.
|
|
125
|
+
*/
|
|
126
|
+
admission?: SupaAuthAdmission;
|
|
119
127
|
/** Resend email OTP configuration. */
|
|
120
128
|
resend?: SupaAuthResendConfig;
|
|
121
129
|
/** Twilio phone OTP configuration. */
|
|
@@ -164,7 +172,10 @@ function createEmailOtp(config: SupaAuthConfig) {
|
|
|
164
172
|
return Email({
|
|
165
173
|
maxAge: 10 * 60, // 10 minutes
|
|
166
174
|
generateVerificationToken: createOtpGenerator(config.productionIdentifier),
|
|
167
|
-
|
|
175
|
+
// `@convex-dev/auth` passes its action ctx as a second argument that the
|
|
176
|
+
// Auth.js type does not declare; the admission check needs it to ask.
|
|
177
|
+
sendVerificationRequest: async ({ identifier: email, token }, ctx?: unknown) => {
|
|
178
|
+
await assertMayReceiveEmailCode(config.admission, ctx, email);
|
|
168
179
|
// Dynamic import of resend — only loaded when RESEND_API_KEY is set
|
|
169
180
|
const apiKey = process.env.RESEND_API_KEY;
|
|
170
181
|
|
|
@@ -209,41 +220,6 @@ function createEmailOtp(config: SupaAuthConfig) {
|
|
|
209
220
|
});
|
|
210
221
|
}
|
|
211
222
|
|
|
212
|
-
/** Build the fixed-code provider separately so its security boundary is testable. */
|
|
213
|
-
export function createTestEmailOtp(config: SupaAuthTestEmailConfig) {
|
|
214
|
-
const email = config.email.trim().toLowerCase();
|
|
215
|
-
const code = config.code ?? DEV_BYPASS_CODE;
|
|
216
|
-
if (!/^\d{6}$/.test(code)) {
|
|
217
|
-
throw new Error("testEmail.code must be exactly six digits");
|
|
218
|
-
}
|
|
219
|
-
if (email.length === 0) {
|
|
220
|
-
throw new Error("testEmail.email must not be empty");
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
const provider = Email({
|
|
224
|
-
maxAge: 10 * 60,
|
|
225
|
-
generateVerificationToken: () => code,
|
|
226
|
-
sendVerificationRequest: async ({ identifier }) => {
|
|
227
|
-
if (identifier.trim().toLowerCase() !== email) {
|
|
228
|
-
throw new Error("This test sign-in provider is not available for that email.");
|
|
229
|
-
}
|
|
230
|
-
},
|
|
231
|
-
});
|
|
232
|
-
return {
|
|
233
|
-
...provider,
|
|
234
|
-
id: TEST_EMAIL_PROVIDER_ID,
|
|
235
|
-
authorize: async (params: Record<string, unknown>, account: { providerAccountId: string }) => {
|
|
236
|
-
if (
|
|
237
|
-
typeof params.email !== "string" ||
|
|
238
|
-
params.email.trim().toLowerCase() !== email ||
|
|
239
|
-
account.providerAccountId.trim().toLowerCase() !== email
|
|
240
|
-
) {
|
|
241
|
-
throw new Error("This test sign-in provider is not available for that email.");
|
|
242
|
-
}
|
|
243
|
-
},
|
|
244
|
-
};
|
|
245
|
-
}
|
|
246
|
-
|
|
247
223
|
/**
|
|
248
224
|
* The link-only email provider.
|
|
249
225
|
*
|
|
@@ -458,94 +434,12 @@ export function createSupaAuth(config: SupaAuthConfig = {}) {
|
|
|
458
434
|
...(methods.includes("email") && config.magicLink !== undefined
|
|
459
435
|
? [createMagicLink(config)]
|
|
460
436
|
: []),
|
|
461
|
-
...(methods.includes("email")
|
|
462
|
-
? [createTestEmailOtp(config.testEmail)]
|
|
463
|
-
: []),
|
|
437
|
+
...(methods.includes("email") ? createTestEmailOtps(config.testEmail) : []),
|
|
464
438
|
...(methods.includes("phone") ? [createPhoneOtp(config)] : []),
|
|
465
439
|
];
|
|
466
440
|
|
|
467
441
|
return convexAuth({
|
|
468
442
|
providers,
|
|
469
|
-
callbacks: {
|
|
470
|
-
async createOrUpdateUser(ctx, { existingUserId, type, profile }) {
|
|
471
|
-
// Returning user — auth account already exists
|
|
472
|
-
if (existingUserId !== null) {
|
|
473
|
-
const existingUser = await ctx.db.get(existingUserId);
|
|
474
|
-
if (existingUser) {
|
|
475
|
-
const updateData: Record<string, unknown> = {};
|
|
476
|
-
if (type === "phone" || type === "verification") {
|
|
477
|
-
updateData.phoneVerificationTime = Date.now();
|
|
478
|
-
}
|
|
479
|
-
if (type === "email" || type === "verification") {
|
|
480
|
-
updateData.emailVerificationTime = Date.now();
|
|
481
|
-
}
|
|
482
|
-
if (profile.phone) updateData.phone = profile.phone;
|
|
483
|
-
if (profile.email) updateData.email = profile.email;
|
|
484
|
-
if (profile.name) updateData.name = profile.name;
|
|
485
|
-
|
|
486
|
-
if (Object.keys(updateData).length > 0) {
|
|
487
|
-
await ctx.db.patch(existingUserId, updateData);
|
|
488
|
-
}
|
|
489
|
-
return existingUserId;
|
|
490
|
-
}
|
|
491
|
-
}
|
|
492
|
-
|
|
493
|
-
// New auth account — try to link to existing user by phone
|
|
494
|
-
if (type === "phone" && typeof profile.phone === "string") {
|
|
495
|
-
const phone = profile.phone;
|
|
496
|
-
const existingUser = await ctx.db
|
|
497
|
-
.query("users")
|
|
498
|
-
.filter((q) => q.eq(q.field("phone"), phone))
|
|
499
|
-
.first();
|
|
500
|
-
|
|
501
|
-
if (existingUser) {
|
|
502
|
-
await ctx.db.patch(existingUser._id, {
|
|
503
|
-
phoneVerificationTime: Date.now(),
|
|
504
|
-
});
|
|
505
|
-
return existingUser._id;
|
|
506
|
-
}
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
// New auth account — try to link to existing user by email
|
|
510
|
-
if (type === "email" && typeof profile.email === "string") {
|
|
511
|
-
const email = profile.email;
|
|
512
|
-
const existingUser = await ctx.db
|
|
513
|
-
.query("users")
|
|
514
|
-
.filter((q) => q.eq(q.field("email"), email))
|
|
515
|
-
.first();
|
|
516
|
-
|
|
517
|
-
if (existingUser) {
|
|
518
|
-
await ctx.db.patch(existingUser._id, {
|
|
519
|
-
emailVerificationTime: Date.now(),
|
|
520
|
-
});
|
|
521
|
-
return existingUser._id;
|
|
522
|
-
}
|
|
523
|
-
}
|
|
524
|
-
|
|
525
|
-
// No existing user — create a new one
|
|
526
|
-
const userData: Record<string, unknown> = {};
|
|
527
|
-
if (profile.email) userData.email = profile.email;
|
|
528
|
-
if (profile.phone) userData.phone = profile.phone;
|
|
529
|
-
if (profile.name) userData.name = profile.name;
|
|
530
|
-
if (profile.image) userData.image = profile.image;
|
|
531
|
-
if (profile.emailVerified || type === "email") {
|
|
532
|
-
userData.emailVerificationTime = Date.now();
|
|
533
|
-
}
|
|
534
|
-
if (profile.phoneVerified || type === "phone") {
|
|
535
|
-
userData.phoneVerificationTime = Date.now();
|
|
536
|
-
}
|
|
537
|
-
userData.isActive = true;
|
|
538
|
-
userData.createdAt = Date.now();
|
|
539
|
-
|
|
540
|
-
const userId = await ctx.db.insert(
|
|
541
|
-
"users",
|
|
542
|
-
userData as Record<string, unknown> & {
|
|
543
|
-
email?: string;
|
|
544
|
-
phone?: string;
|
|
545
|
-
},
|
|
546
|
-
);
|
|
547
|
-
return userId as GenericId<"users">;
|
|
548
|
-
},
|
|
549
|
-
},
|
|
443
|
+
callbacks: { createOrUpdateUser: userCallback(config.admission) },
|
|
550
444
|
});
|
|
551
445
|
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fixed-code sign-in for named test accounts (the CUJ account, a reviewer).
|
|
3
|
+
*
|
|
4
|
+
* Each account gets a provider of its own that refuses every other address,
|
|
5
|
+
* which is what makes a fixed code safe to register in production.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { Email } from "@convex-dev/auth/providers/Email";
|
|
9
|
+
|
|
10
|
+
/** The code a test account uses when its config names none. */
|
|
11
|
+
const DEFAULT_TEST_CODE = "000000";
|
|
12
|
+
|
|
13
|
+
/** Provider id for an explicitly scoped production test account. */
|
|
14
|
+
export const TEST_EMAIL_PROVIDER_ID = "test-email";
|
|
15
|
+
|
|
16
|
+
export interface SupaAuthTestEmailConfig {
|
|
17
|
+
/** The only email address allowed to use the fixed test code. */
|
|
18
|
+
email: string;
|
|
19
|
+
/** Fixed verification code. Defaults to `000000`. */
|
|
20
|
+
code?: string;
|
|
21
|
+
/**
|
|
22
|
+
* Provider id the client signs in with. Defaults to `TEST_EMAIL_PROVIDER_ID`.
|
|
23
|
+
*
|
|
24
|
+
* Needed only when an app has more than one fixed-code account, because
|
|
25
|
+
* `@convex-dev/auth` mints a provider's code without knowing the address,
|
|
26
|
+
* so each account needs a provider, and so an id, of its own. Must start
|
|
27
|
+
* with `TEST_EMAIL_PROVIDER_ID`, which keeps it clear of the customer
|
|
28
|
+
* `email` provider whose `authorize` it must never share.
|
|
29
|
+
*/
|
|
30
|
+
id?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Build the fixed-code provider separately so its security boundary is testable. */
|
|
34
|
+
export function createTestEmailOtp(config: SupaAuthTestEmailConfig) {
|
|
35
|
+
const email = config.email.trim().toLowerCase();
|
|
36
|
+
const code = config.code ?? DEFAULT_TEST_CODE;
|
|
37
|
+
const id = config.id ?? TEST_EMAIL_PROVIDER_ID;
|
|
38
|
+
if (!/^test-email(-[a-z0-9]+)*$/.test(id)) {
|
|
39
|
+
throw new Error(`testEmail.id must be "${TEST_EMAIL_PROVIDER_ID}" or start with "${TEST_EMAIL_PROVIDER_ID}-"`);
|
|
40
|
+
}
|
|
41
|
+
if (!/^\d{6}$/.test(code)) {
|
|
42
|
+
throw new Error("testEmail.code must be exactly six digits");
|
|
43
|
+
}
|
|
44
|
+
if (email.length === 0) {
|
|
45
|
+
throw new Error("testEmail.email must not be empty");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const provider = Email({
|
|
49
|
+
maxAge: 10 * 60,
|
|
50
|
+
generateVerificationToken: () => code,
|
|
51
|
+
sendVerificationRequest: async ({ identifier }) => {
|
|
52
|
+
if (identifier.trim().toLowerCase() !== email) {
|
|
53
|
+
throw new Error("This test sign-in provider is not available for that email.");
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
return {
|
|
58
|
+
...provider,
|
|
59
|
+
id,
|
|
60
|
+
authorize: async (params: Record<string, unknown>, account: { providerAccountId: string }) => {
|
|
61
|
+
if (
|
|
62
|
+
typeof params.email !== "string" ||
|
|
63
|
+
params.email.trim().toLowerCase() !== email ||
|
|
64
|
+
account.providerAccountId.trim().toLowerCase() !== email
|
|
65
|
+
) {
|
|
66
|
+
throw new Error("This test sign-in provider is not available for that email.");
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** One fixed-code provider per configured account, refusing a repeated id or address. */
|
|
73
|
+
export function createTestEmailOtps(
|
|
74
|
+
config: SupaAuthTestEmailConfig | SupaAuthTestEmailConfig[] | undefined,
|
|
75
|
+
) {
|
|
76
|
+
const configs = config === undefined ? [] : Array.isArray(config) ? config : [config];
|
|
77
|
+
const providers = configs.map(createTestEmailOtp);
|
|
78
|
+
const ids = new Set(providers.map((provider) => provider.id));
|
|
79
|
+
const emails = new Set(configs.map((entry) => entry.email.trim().toLowerCase()));
|
|
80
|
+
if (ids.size !== providers.length) {
|
|
81
|
+
throw new Error("testEmail entries must each have their own id");
|
|
82
|
+
}
|
|
83
|
+
if (emails.size !== configs.length) {
|
|
84
|
+
throw new Error("testEmail entries must each have their own email");
|
|
85
|
+
}
|
|
86
|
+
return providers;
|
|
87
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `createOrUpdateUser` callback `createSupaAuth` hands `convexAuth`.
|
|
3
|
+
*
|
|
4
|
+
* A returning account keeps its user. A new account is linked to an existing
|
|
5
|
+
* user with the same phone or email before anything is created, so one person
|
|
6
|
+
* who signs in two ways is one user. Only when nobody matches is a user row
|
|
7
|
+
* created — and that is the one place `admission.canCreateUser` is asked, so
|
|
8
|
+
* an invite-only app can refuse a stranger without ever locking out somebody
|
|
9
|
+
* who already has an account.
|
|
10
|
+
*/
|
|
11
|
+
import type { GenericId } from "convex/values";
|
|
12
|
+
import type { convexAuth } from "@convex-dev/auth/server";
|
|
13
|
+
|
|
14
|
+
import { assertMayCreateUser, type SupaAuthAdmission } from "./admission";
|
|
15
|
+
|
|
16
|
+
type Handler = NonNullable<
|
|
17
|
+
NonNullable<Parameters<typeof convexAuth>[0]["callbacks"]>["createOrUpdateUser"]
|
|
18
|
+
>;
|
|
19
|
+
|
|
20
|
+
export function userCallback(admission: SupaAuthAdmission | undefined): Handler {
|
|
21
|
+
async function createOrUpdateUser(
|
|
22
|
+
ctx: Parameters<Handler>[0],
|
|
23
|
+
{ existingUserId, type, provider, profile }: Parameters<Handler>[1],
|
|
24
|
+
): Promise<GenericId<"users">> {
|
|
25
|
+
// Returning user — auth account already exists
|
|
26
|
+
if (existingUserId !== null) {
|
|
27
|
+
const existingUser = await ctx.db.get(existingUserId);
|
|
28
|
+
if (existingUser) {
|
|
29
|
+
const updateData: Record<string, unknown> = {};
|
|
30
|
+
if (type === "phone" || type === "verification") {
|
|
31
|
+
updateData.phoneVerificationTime = Date.now();
|
|
32
|
+
}
|
|
33
|
+
if (type === "email" || type === "verification") {
|
|
34
|
+
updateData.emailVerificationTime = Date.now();
|
|
35
|
+
}
|
|
36
|
+
if (profile.phone) updateData.phone = profile.phone;
|
|
37
|
+
if (profile.email) updateData.email = profile.email;
|
|
38
|
+
if (profile.name) updateData.name = profile.name;
|
|
39
|
+
|
|
40
|
+
if (Object.keys(updateData).length > 0) {
|
|
41
|
+
await ctx.db.patch(existingUserId, updateData);
|
|
42
|
+
}
|
|
43
|
+
return existingUserId;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// New auth account — try to link to existing user by phone
|
|
48
|
+
if (type === "phone" && typeof profile.phone === "string") {
|
|
49
|
+
const phone = profile.phone;
|
|
50
|
+
const existingUser = await ctx.db
|
|
51
|
+
.query("users")
|
|
52
|
+
.filter((q) => q.eq(q.field("phone"), phone))
|
|
53
|
+
.first();
|
|
54
|
+
|
|
55
|
+
if (existingUser) {
|
|
56
|
+
await ctx.db.patch(existingUser._id, {
|
|
57
|
+
phoneVerificationTime: Date.now(),
|
|
58
|
+
});
|
|
59
|
+
return existingUser._id;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// New auth account — try to link to existing user by email
|
|
64
|
+
if (type === "email" && typeof profile.email === "string") {
|
|
65
|
+
const email = profile.email;
|
|
66
|
+
const existingUser = await ctx.db
|
|
67
|
+
.query("users")
|
|
68
|
+
.filter((q) => q.eq(q.field("email"), email))
|
|
69
|
+
.first();
|
|
70
|
+
|
|
71
|
+
if (existingUser) {
|
|
72
|
+
await ctx.db.patch(existingUser._id, {
|
|
73
|
+
emailVerificationTime: Date.now(),
|
|
74
|
+
});
|
|
75
|
+
return existingUser._id;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// No existing user. Ask the app first: this is the backstop that holds
|
|
80
|
+
// for every provider, not only the email OTP. See `admission.ts`.
|
|
81
|
+
await assertMayCreateUser(admission, ctx, {
|
|
82
|
+
provider: provider.id,
|
|
83
|
+
...(typeof profile.email === "string" ? { email: profile.email } : {}),
|
|
84
|
+
...(typeof profile.phone === "string" ? { phone: profile.phone } : {}),
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// No existing user — create a new one
|
|
88
|
+
const userData: Record<string, unknown> = {};
|
|
89
|
+
if (profile.email) userData.email = profile.email;
|
|
90
|
+
if (profile.phone) userData.phone = profile.phone;
|
|
91
|
+
if (profile.name) userData.name = profile.name;
|
|
92
|
+
if (profile.image) userData.image = profile.image;
|
|
93
|
+
if (profile.emailVerified || type === "email") {
|
|
94
|
+
userData.emailVerificationTime = Date.now();
|
|
95
|
+
}
|
|
96
|
+
if (profile.phoneVerified || type === "phone") {
|
|
97
|
+
userData.phoneVerificationTime = Date.now();
|
|
98
|
+
}
|
|
99
|
+
userData.isActive = true;
|
|
100
|
+
userData.createdAt = Date.now();
|
|
101
|
+
|
|
102
|
+
const userId = await ctx.db.insert(
|
|
103
|
+
"users",
|
|
104
|
+
userData as Record<string, unknown> & {
|
|
105
|
+
email?: string;
|
|
106
|
+
phone?: string;
|
|
107
|
+
},
|
|
108
|
+
);
|
|
109
|
+
return userId as GenericId<"users">;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return createOrUpdateUser;
|
|
113
|
+
}
|