@robelest/convex-auth 0.0.2-preview.1 → 0.0.2

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.
Files changed (145) hide show
  1. package/dist/bin.cjs +466 -63
  2. package/dist/client/index.d.ts +211 -30
  3. package/dist/client/index.d.ts.map +1 -1
  4. package/dist/client/index.js +673 -59
  5. package/dist/client/index.js.map +1 -1
  6. package/dist/component/_generated/api.d.ts +56 -1
  7. package/dist/component/_generated/api.d.ts.map +1 -1
  8. package/dist/component/_generated/api.js.map +1 -1
  9. package/dist/component/_generated/component.d.ts +93 -3
  10. package/dist/component/_generated/component.d.ts.map +1 -1
  11. package/dist/component/convex.config.d.ts.map +1 -1
  12. package/dist/component/convex.config.js +2 -0
  13. package/dist/component/convex.config.js.map +1 -1
  14. package/dist/component/index.d.ts +5 -3
  15. package/dist/component/index.d.ts.map +1 -1
  16. package/dist/component/index.js +5 -3
  17. package/dist/component/index.js.map +1 -1
  18. package/dist/component/portalBridge.d.ts +80 -0
  19. package/dist/component/portalBridge.d.ts.map +1 -0
  20. package/dist/component/portalBridge.js +102 -0
  21. package/dist/component/portalBridge.js.map +1 -0
  22. package/dist/component/public.d.ts +193 -9
  23. package/dist/component/public.d.ts.map +1 -1
  24. package/dist/component/public.js +204 -33
  25. package/dist/component/public.js.map +1 -1
  26. package/dist/component/schema.d.ts +89 -9
  27. package/dist/component/schema.d.ts.map +1 -1
  28. package/dist/component/schema.js +68 -7
  29. package/dist/component/schema.js.map +1 -1
  30. package/dist/providers/{Anonymous.d.ts → anonymous.d.ts} +8 -8
  31. package/dist/providers/{Anonymous.d.ts.map → anonymous.d.ts.map} +1 -1
  32. package/dist/providers/{Anonymous.js → anonymous.js} +9 -10
  33. package/dist/providers/anonymous.js.map +1 -0
  34. package/dist/providers/{ConvexCredentials.d.ts → credentials.d.ts} +11 -11
  35. package/dist/providers/credentials.d.ts.map +1 -0
  36. package/dist/providers/{ConvexCredentials.js → credentials.js} +8 -8
  37. package/dist/providers/credentials.js.map +1 -0
  38. package/dist/providers/{Email.d.ts → email.d.ts} +6 -6
  39. package/dist/providers/email.d.ts.map +1 -0
  40. package/dist/providers/{Email.js → email.js} +6 -6
  41. package/dist/providers/email.js.map +1 -0
  42. package/dist/providers/passkey.d.ts +20 -0
  43. package/dist/providers/passkey.d.ts.map +1 -0
  44. package/dist/providers/passkey.js +32 -0
  45. package/dist/providers/passkey.js.map +1 -0
  46. package/dist/providers/{Password.d.ts → password.d.ts} +10 -10
  47. package/dist/providers/{Password.d.ts.map → password.d.ts.map} +1 -1
  48. package/dist/providers/{Password.js → password.js} +19 -20
  49. package/dist/providers/password.js.map +1 -0
  50. package/dist/providers/{Phone.d.ts → phone.d.ts} +3 -3
  51. package/dist/providers/{Phone.d.ts.map → phone.d.ts.map} +1 -1
  52. package/dist/providers/{Phone.js → phone.js} +3 -3
  53. package/dist/providers/{Phone.js.map → phone.js.map} +1 -1
  54. package/dist/providers/totp.d.ts +14 -0
  55. package/dist/providers/totp.d.ts.map +1 -0
  56. package/dist/providers/totp.js +23 -0
  57. package/dist/providers/totp.js.map +1 -0
  58. package/dist/server/convex-auth.d.ts +243 -0
  59. package/dist/server/convex-auth.d.ts.map +1 -0
  60. package/dist/server/convex-auth.js +365 -0
  61. package/dist/server/convex-auth.js.map +1 -0
  62. package/dist/server/implementation/index.d.ts +153 -166
  63. package/dist/server/implementation/index.d.ts.map +1 -1
  64. package/dist/server/implementation/index.js +162 -105
  65. package/dist/server/implementation/index.js.map +1 -1
  66. package/dist/server/implementation/passkey.d.ts +33 -0
  67. package/dist/server/implementation/passkey.d.ts.map +1 -0
  68. package/dist/server/implementation/passkey.js +450 -0
  69. package/dist/server/implementation/passkey.js.map +1 -0
  70. package/dist/server/implementation/redirects.d.ts.map +1 -1
  71. package/dist/server/implementation/redirects.js +4 -9
  72. package/dist/server/implementation/redirects.js.map +1 -1
  73. package/dist/server/implementation/sessions.d.ts +2 -20
  74. package/dist/server/implementation/sessions.d.ts.map +1 -1
  75. package/dist/server/implementation/sessions.js +2 -20
  76. package/dist/server/implementation/sessions.js.map +1 -1
  77. package/dist/server/implementation/signIn.d.ts +13 -0
  78. package/dist/server/implementation/signIn.d.ts.map +1 -1
  79. package/dist/server/implementation/signIn.js +26 -1
  80. package/dist/server/implementation/signIn.js.map +1 -1
  81. package/dist/server/implementation/totp.d.ts +40 -0
  82. package/dist/server/implementation/totp.d.ts.map +1 -0
  83. package/dist/server/implementation/totp.js +211 -0
  84. package/dist/server/implementation/totp.js.map +1 -0
  85. package/dist/server/index.d.ts +18 -0
  86. package/dist/server/index.d.ts.map +1 -1
  87. package/dist/server/index.js +255 -0
  88. package/dist/server/index.js.map +1 -1
  89. package/dist/server/portal-email.d.ts +19 -0
  90. package/dist/server/portal-email.d.ts.map +1 -0
  91. package/dist/server/portal-email.js +89 -0
  92. package/dist/server/portal-email.js.map +1 -0
  93. package/dist/server/portal.d.ts +116 -0
  94. package/dist/server/portal.d.ts.map +1 -0
  95. package/dist/server/portal.js +294 -0
  96. package/dist/server/portal.js.map +1 -0
  97. package/dist/server/provider_utils.d.ts +1 -1
  98. package/dist/server/provider_utils.d.ts.map +1 -1
  99. package/dist/server/provider_utils.js +39 -1
  100. package/dist/server/provider_utils.js.map +1 -1
  101. package/dist/server/types.d.ts +128 -11
  102. package/dist/server/types.d.ts.map +1 -1
  103. package/package.json +7 -7
  104. package/src/cli/index.ts +48 -6
  105. package/src/cli/portal-link.ts +112 -0
  106. package/src/cli/portal-upload.ts +411 -0
  107. package/src/client/index.ts +823 -109
  108. package/src/component/_generated/api.ts +72 -1
  109. package/src/component/_generated/component.ts +180 -4
  110. package/src/component/convex.config.ts +3 -0
  111. package/src/component/index.ts +5 -10
  112. package/src/component/portalBridge.ts +116 -0
  113. package/src/component/public.ts +231 -37
  114. package/src/component/schema.ts +70 -7
  115. package/src/providers/{Anonymous.ts → anonymous.ts} +10 -11
  116. package/src/providers/{ConvexCredentials.ts → credentials.ts} +11 -11
  117. package/src/providers/{Email.ts → email.ts} +5 -5
  118. package/src/providers/passkey.ts +35 -0
  119. package/src/providers/{Password.ts → password.ts} +22 -27
  120. package/src/providers/{Phone.ts → phone.ts} +2 -2
  121. package/src/providers/totp.ts +26 -0
  122. package/src/server/convex-auth.ts +470 -0
  123. package/src/server/implementation/index.ts +228 -239
  124. package/src/server/implementation/passkey.ts +650 -0
  125. package/src/server/implementation/redirects.ts +4 -11
  126. package/src/server/implementation/sessions.ts +2 -20
  127. package/src/server/implementation/signIn.ts +39 -1
  128. package/src/server/implementation/totp.ts +366 -0
  129. package/src/server/index.ts +373 -0
  130. package/src/server/portal-email.ts +95 -0
  131. package/src/server/portal.ts +375 -0
  132. package/src/server/provider_utils.ts +42 -1
  133. package/src/server/types.ts +161 -10
  134. package/dist/providers/Anonymous.js.map +0 -1
  135. package/dist/providers/ConvexCredentials.d.ts.map +0 -1
  136. package/dist/providers/ConvexCredentials.js.map +0 -1
  137. package/dist/providers/Email.d.ts.map +0 -1
  138. package/dist/providers/Email.js.map +0 -1
  139. package/dist/providers/Password.js.map +0 -1
  140. package/providers/Anonymous/package.json +0 -6
  141. package/providers/ConvexCredentials/package.json +0 -6
  142. package/providers/Email/package.json +0 -6
  143. package/providers/Password/package.json +0 -6
  144. package/providers/Phone/package.json +0 -6
  145. package/server/package.json +0 -6
@@ -1,4 +1,10 @@
1
+ import { ConvexHttpClient } from "convex/browser";
2
+ import { jwtDecode } from "jwt-decode";
1
3
  import { parse, serialize } from "cookie";
4
+ import type {
5
+ SignInAction,
6
+ SignOutAction,
7
+ } from "./implementation/index.js";
2
8
  import { isLocalHost } from "./utils.js";
3
9
 
4
10
  export type AuthCookieConfig = {
@@ -11,6 +17,20 @@ export type AuthCookies = {
11
17
  verifier: string | null;
12
18
  };
13
19
 
20
+ export type ServerOptions = {
21
+ /** Convex deployment URL. */
22
+ url: string;
23
+ apiRoute?: string;
24
+ cookieMaxAge?: number | null;
25
+ verbose?: boolean;
26
+ shouldHandleCode?: ((request: Request) => boolean | Promise<boolean>) | boolean;
27
+ };
28
+
29
+ export type RefreshResult = {
30
+ response?: Response;
31
+ cookies?: string[];
32
+ };
33
+
14
34
  export function authCookieNames(host?: string) {
15
35
  const prefix = isLocalHost(host) ? "" : "__Host-";
16
36
  return {
@@ -72,3 +92,356 @@ export function shouldProxyAuthAction(pathname: string, apiRoute: string) {
72
92
  }
73
93
  return pathname === apiRoute || pathname === `${apiRoute}/`;
74
94
  }
95
+
96
+ const REQUIRED_TOKEN_LIFETIME_MS = 60_000;
97
+ const MINIMUM_REQUIRED_TOKEN_LIFETIME_MS = 10_000;
98
+
99
+ type DecodedToken = { exp?: number; iat?: number };
100
+
101
+ export function server(options: ServerOptions) {
102
+ const convexUrl = options.url;
103
+ const apiRoute = options.apiRoute ?? "/api/auth";
104
+ const cookieConfig = { maxAge: options.cookieMaxAge ?? null };
105
+ const verbose = options.verbose ?? false;
106
+
107
+ const logVerbose = (message: string) => {
108
+ if (!verbose) {
109
+ return;
110
+ }
111
+ console.debug(
112
+ `${new Date().toISOString()} [convex-auth/server] ${message}`,
113
+ );
114
+ };
115
+
116
+ const cookieHost = (request: Request) => {
117
+ return request.headers.get("host") ?? new URL(request.url).host;
118
+ };
119
+
120
+ const parseRequestCookies = (request: Request) => {
121
+ return parseAuthCookies(request.headers.get("cookie"), cookieHost(request));
122
+ };
123
+
124
+ const attachCookies = (response: Response, cookies: string[]) => {
125
+ for (const value of cookies) {
126
+ response.headers.append("Set-Cookie", value);
127
+ }
128
+ return response;
129
+ };
130
+
131
+ const jsonResponse = (body: unknown, status = 200) => {
132
+ return new Response(JSON.stringify(body), {
133
+ status,
134
+ headers: {
135
+ "Content-Type": "application/json",
136
+ },
137
+ });
138
+ };
139
+
140
+ const isCorsRequest = (request: Request) => {
141
+ const originHeader = request.headers.get("origin");
142
+ if (originHeader === null) {
143
+ return false;
144
+ }
145
+ const requestUrl = new URL(request.url);
146
+ const originUrl = new URL(originHeader);
147
+ return (
148
+ originUrl.host !== requestUrl.host ||
149
+ originUrl.protocol !== requestUrl.protocol
150
+ );
151
+ };
152
+
153
+ const decodeToken = (token: string): DecodedToken | null => {
154
+ try {
155
+ return jwtDecode<DecodedToken>(token);
156
+ } catch {
157
+ return null;
158
+ }
159
+ };
160
+
161
+ const convexClient = (token?: string | null) => {
162
+ const client = new ConvexHttpClient(convexUrl);
163
+ if (token !== undefined && token !== null) {
164
+ client.setAuth(token);
165
+ }
166
+ return client;
167
+ };
168
+
169
+ const refreshTokens = async (
170
+ request: Request,
171
+ ): Promise<{ token: string; refreshToken: string } | null | undefined> => {
172
+ const cookies = parseRequestCookies(request);
173
+ const { token, refreshToken } = cookies;
174
+ if (refreshToken === null && token === null) {
175
+ logVerbose("No auth cookies found, skipping refresh");
176
+ return undefined;
177
+ }
178
+ if (refreshToken === null || token === null) {
179
+ logVerbose("Only one auth cookie present, clearing auth cookies");
180
+ return null;
181
+ }
182
+ const decodedToken = decodeToken(token);
183
+ if (decodedToken?.exp === undefined || decodedToken.iat === undefined) {
184
+ logVerbose("Failed to decode token, clearing auth cookies");
185
+ return null;
186
+ }
187
+ const totalTokenLifetimeMs = decodedToken.exp * 1000 - decodedToken.iat * 1000;
188
+ const minimumExpiration =
189
+ Date.now() +
190
+ Math.min(
191
+ REQUIRED_TOKEN_LIFETIME_MS,
192
+ Math.max(MINIMUM_REQUIRED_TOKEN_LIFETIME_MS, totalTokenLifetimeMs / 10),
193
+ );
194
+ if (decodedToken.exp * 1000 > minimumExpiration) {
195
+ logVerbose("Token valid long enough, skipping refresh");
196
+ return undefined;
197
+ }
198
+
199
+ try {
200
+ const result = await convexClient().action(
201
+ "auth:signIn" as unknown as SignInAction,
202
+ {
203
+ refreshToken,
204
+ },
205
+ );
206
+ if (result.tokens === undefined) {
207
+ throw new Error("Invalid `auth:signIn` result for token refresh");
208
+ }
209
+ logVerbose(`Refreshed tokens, null=${result.tokens === null}`);
210
+ return result.tokens;
211
+ } catch (error) {
212
+ console.error(error);
213
+ logVerbose("Token refresh failed, clearing auth cookies");
214
+ return null;
215
+ }
216
+ };
217
+
218
+ return {
219
+ token(request: Request): string | null {
220
+ return parseRequestCookies(request).token;
221
+ },
222
+
223
+ async verify(request: Request): Promise<boolean> {
224
+ const token = parseRequestCookies(request).token;
225
+ if (token === null) {
226
+ return false;
227
+ }
228
+ const decodedToken = decodeToken(token);
229
+ if (decodedToken?.exp === undefined) {
230
+ return false;
231
+ }
232
+ return decodedToken.exp * 1000 > Date.now();
233
+ },
234
+
235
+ async proxy(request: Request): Promise<Response> {
236
+ const requestUrl = new URL(request.url);
237
+ if (!shouldProxyAuthAction(requestUrl.pathname, apiRoute)) {
238
+ return new Response("Invalid route", { status: 404 });
239
+ }
240
+ if (request.method !== "POST") {
241
+ return new Response("Invalid method", { status: 405 });
242
+ }
243
+ if (isCorsRequest(request)) {
244
+ return new Response("Invalid origin", { status: 403 });
245
+ }
246
+
247
+ const body = await request.json();
248
+ const action = body.action as string;
249
+ const args = (body.args ?? {}) as Record<string, any>;
250
+
251
+ if (action !== "auth:signIn" && action !== "auth:signOut") {
252
+ return new Response("Invalid action", { status: 400 });
253
+ }
254
+
255
+ const currentCookies = parseRequestCookies(request);
256
+ const host = cookieHost(request);
257
+
258
+ if (action === "auth:signIn") {
259
+ if (args.refreshToken !== undefined) {
260
+ if (currentCookies.refreshToken === null) {
261
+ return jsonResponse({ tokens: null });
262
+ }
263
+ args.refreshToken = currentCookies.refreshToken;
264
+ }
265
+ const client = convexClient(
266
+ args.refreshToken !== undefined || args.params?.code !== undefined
267
+ ? null
268
+ : currentCookies.token,
269
+ );
270
+
271
+ try {
272
+ const result = await client.action(
273
+ "auth:signIn" as unknown as SignInAction,
274
+ args,
275
+ );
276
+ if (result.redirect !== undefined) {
277
+ const response = jsonResponse({ redirect: result.redirect });
278
+ return attachCookies(
279
+ response,
280
+ serializeAuthCookies(
281
+ {
282
+ ...currentCookies,
283
+ verifier: result.verifier ?? null,
284
+ },
285
+ host,
286
+ cookieConfig,
287
+ ),
288
+ );
289
+ }
290
+ if (result.tokens !== undefined) {
291
+ const response = jsonResponse({
292
+ tokens:
293
+ result.tokens === null
294
+ ? null
295
+ : { token: result.tokens.token, refreshToken: "dummy" },
296
+ });
297
+ return attachCookies(
298
+ response,
299
+ serializeAuthCookies(
300
+ {
301
+ token: result.tokens?.token ?? null,
302
+ refreshToken: result.tokens?.refreshToken ?? null,
303
+ verifier: null,
304
+ },
305
+ host,
306
+ cookieConfig,
307
+ ),
308
+ );
309
+ }
310
+ return jsonResponse(result);
311
+ } catch (error) {
312
+ const response = jsonResponse({ error: (error as Error).message }, 400);
313
+ return attachCookies(
314
+ response,
315
+ serializeAuthCookies(
316
+ {
317
+ token: null,
318
+ refreshToken: null,
319
+ verifier: null,
320
+ },
321
+ host,
322
+ cookieConfig,
323
+ ),
324
+ );
325
+ }
326
+ }
327
+
328
+ try {
329
+ await convexClient(currentCookies.token).action(
330
+ "auth:signOut" as unknown as SignOutAction,
331
+ );
332
+ } catch (error) {
333
+ console.error(error);
334
+ }
335
+ return attachCookies(
336
+ jsonResponse(null),
337
+ serializeAuthCookies(
338
+ {
339
+ token: null,
340
+ refreshToken: null,
341
+ verifier: null,
342
+ },
343
+ host,
344
+ cookieConfig,
345
+ ),
346
+ );
347
+ },
348
+
349
+ async refresh(request: Request): Promise<RefreshResult> {
350
+ const host = cookieHost(request);
351
+
352
+ if (isCorsRequest(request)) {
353
+ return {
354
+ cookies: serializeAuthCookies(
355
+ {
356
+ token: null,
357
+ refreshToken: null,
358
+ verifier: null,
359
+ },
360
+ host,
361
+ cookieConfig,
362
+ ),
363
+ };
364
+ }
365
+
366
+ const requestUrl = new URL(request.url);
367
+ const code = requestUrl.searchParams.get("code");
368
+ const shouldHandleCode =
369
+ options.shouldHandleCode === undefined
370
+ ? true
371
+ : typeof options.shouldHandleCode === "function"
372
+ ? await options.shouldHandleCode(request)
373
+ : options.shouldHandleCode;
374
+
375
+ if (
376
+ code !== null &&
377
+ request.method === "GET" &&
378
+ request.headers.get("accept")?.includes("text/html") &&
379
+ shouldHandleCode
380
+ ) {
381
+ const requestCookies = parseRequestCookies(request);
382
+ const redirectUrl = new URL(requestUrl);
383
+ redirectUrl.searchParams.delete("code");
384
+ try {
385
+ const result = await convexClient().action(
386
+ "auth:signIn" as unknown as SignInAction,
387
+ {
388
+ params: { code },
389
+ verifier: requestCookies.verifier ?? undefined,
390
+ },
391
+ );
392
+ if (result.tokens === undefined) {
393
+ throw new Error("Invalid `auth:signIn` result for code exchange");
394
+ }
395
+ const response = Response.redirect(redirectUrl.toString(), 302);
396
+ return {
397
+ response: attachCookies(
398
+ response,
399
+ serializeAuthCookies(
400
+ {
401
+ token: result.tokens?.token ?? null,
402
+ refreshToken: result.tokens?.refreshToken ?? null,
403
+ verifier: null,
404
+ },
405
+ host,
406
+ cookieConfig,
407
+ ),
408
+ ),
409
+ };
410
+ } catch (error) {
411
+ console.error(error);
412
+ const response = Response.redirect(redirectUrl.toString(), 302);
413
+ return {
414
+ response: attachCookies(
415
+ response,
416
+ serializeAuthCookies(
417
+ {
418
+ token: null,
419
+ refreshToken: null,
420
+ verifier: null,
421
+ },
422
+ host,
423
+ cookieConfig,
424
+ ),
425
+ ),
426
+ };
427
+ }
428
+ }
429
+
430
+ const tokens = await refreshTokens(request);
431
+ if (tokens === undefined) {
432
+ return {};
433
+ }
434
+ return {
435
+ cookies: serializeAuthCookies(
436
+ {
437
+ token: tokens?.token ?? null,
438
+ refreshToken: tokens?.refreshToken ?? null,
439
+ verifier: null,
440
+ },
441
+ host,
442
+ cookieConfig,
443
+ ),
444
+ };
445
+ },
446
+ };
447
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Styled dark-theme magic link email template for the Convex Auth Portal.
3
+ *
4
+ * Matches the portal's design system:
5
+ * - Background: #1e1c1a (body), #2a2825 (card)
6
+ * - Accent: #63a8f8 (Convex blue)
7
+ * - Text: #ffffff (headings), #b9b1aa (secondary), #8f8780 (muted)
8
+ * - Border: #4a4743
9
+ *
10
+ * @module
11
+ */
12
+
13
+ const SHIELD_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="#63a8f8" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/></svg>`;
14
+
15
+ /**
16
+ * Generate the styled magic link email HTML.
17
+ *
18
+ * @param url - The magic link URL the user clicks to sign in.
19
+ * @param expiresInHours - How long the link is valid (shown in the email).
20
+ */
21
+ export function portalMagicLinkEmail(
22
+ url: string,
23
+ expiresInHours: number = 24,
24
+ ): string {
25
+ const expiryText =
26
+ expiresInHours >= 24
27
+ ? `${Math.floor(expiresInHours / 24)} day${Math.floor(expiresInHours / 24) > 1 ? "s" : ""}`
28
+ : `${expiresInHours} hour${expiresInHours > 1 ? "s" : ""}`;
29
+
30
+ return `<!DOCTYPE html>
31
+ <html lang="en">
32
+ <head>
33
+ <meta charset="utf-8" />
34
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
35
+ <meta name="color-scheme" content="dark" />
36
+ <meta name="supported-color-schemes" content="dark" />
37
+ <title>Sign in to Convex Auth Portal</title>
38
+ </head>
39
+ <body style="margin:0;padding:0;background-color:#1e1c1a;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;">
40
+ <table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color:#1e1c1a;padding:40px 16px;">
41
+ <tr>
42
+ <td align="center">
43
+ <table role="presentation" width="480" cellpadding="0" cellspacing="0" style="background-color:#2a2825;border:1px solid #4a4743;border-radius:8px;overflow:hidden;">
44
+ <!-- Header -->
45
+ <tr>
46
+ <td style="padding:32px 32px 0 32px;text-align:center;">
47
+ ${SHIELD_SVG}
48
+ <h1 style="margin:16px 0 0 0;font-size:20px;font-weight:600;color:#ffffff;line-height:1.3;">
49
+ Convex Auth Portal
50
+ </h1>
51
+ </td>
52
+ </tr>
53
+
54
+ <!-- Body -->
55
+ <tr>
56
+ <td style="padding:24px 32px;">
57
+ <p style="margin:0 0 20px 0;font-size:15px;line-height:1.6;color:#b9b1aa;">
58
+ Click the button below to sign in to the admin portal. This link will expire in ${expiryText}.
59
+ </p>
60
+
61
+ <!-- CTA Button -->
62
+ <table role="presentation" width="100%" cellpadding="0" cellspacing="0">
63
+ <tr>
64
+ <td align="center" style="padding:8px 0 24px 0;">
65
+ <a href="${url}" target="_blank" style="display:inline-block;background-color:#63a8f8;color:#0a0a0b;font-size:15px;font-weight:600;text-decoration:none;padding:12px 32px;border-radius:6px;line-height:1;">
66
+ Sign in to Portal
67
+ </a>
68
+ </td>
69
+ </tr>
70
+ </table>
71
+
72
+ <p style="margin:0 0 16px 0;font-size:13px;line-height:1.6;color:#8f8780;">
73
+ If the button doesn't work, copy and paste this URL into your browser:
74
+ </p>
75
+ <p style="margin:0;font-size:13px;line-height:1.5;color:#63a8f8;word-break:break-all;">
76
+ ${url}
77
+ </p>
78
+ </td>
79
+ </tr>
80
+
81
+ <!-- Footer -->
82
+ <tr>
83
+ <td style="padding:20px 32px;border-top:1px solid #4a4743;">
84
+ <p style="margin:0;font-size:12px;line-height:1.5;color:#8f8780;text-align:center;">
85
+ If you didn't request this email, you can safely ignore it.
86
+ </p>
87
+ </td>
88
+ </tr>
89
+ </table>
90
+ </td>
91
+ </tr>
92
+ </table>
93
+ </body>
94
+ </html>`;
95
+ }