@robelest/convex-auth 0.0.2-preview.2 → 0.0.3-preview
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/dist/bin.cjs +467 -64
- package/dist/client/index.d.ts +127 -0
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +424 -1
- package/dist/client/index.js.map +1 -1
- package/dist/component/_generated/api.d.ts +56 -1
- package/dist/component/_generated/api.d.ts.map +1 -1
- package/dist/component/_generated/api.js.map +1 -1
- package/dist/component/_generated/component.d.ts +141 -3
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/convex.config.d.ts.map +1 -1
- package/dist/component/convex.config.js +2 -0
- package/dist/component/convex.config.js.map +1 -1
- package/dist/component/index.d.ts +5 -4
- package/dist/component/index.d.ts.map +1 -1
- package/dist/component/index.js +4 -3
- package/dist/component/index.js.map +1 -1
- package/dist/component/portalBridge.d.ts +80 -0
- package/dist/component/portalBridge.d.ts.map +1 -0
- package/dist/component/portalBridge.js +102 -0
- package/dist/component/portalBridge.js.map +1 -0
- package/dist/component/public.d.ts +353 -9
- package/dist/component/public.d.ts.map +1 -1
- package/dist/component/public.js +328 -33
- package/dist/component/public.js.map +1 -1
- package/dist/component/schema.d.ts +168 -9
- package/dist/component/schema.d.ts.map +1 -1
- package/dist/component/schema.js +113 -7
- package/dist/component/schema.js.map +1 -1
- package/dist/providers/passkey.d.ts +20 -0
- package/dist/providers/passkey.d.ts.map +1 -0
- package/dist/providers/passkey.js +32 -0
- package/dist/providers/passkey.js.map +1 -0
- package/dist/providers/totp.d.ts +14 -0
- package/dist/providers/totp.d.ts.map +1 -0
- package/dist/providers/totp.js +23 -0
- package/dist/providers/totp.js.map +1 -0
- package/dist/server/convex-auth.d.ts +296 -0
- package/dist/server/convex-auth.d.ts.map +1 -0
- package/dist/server/convex-auth.js +480 -0
- package/dist/server/convex-auth.js.map +1 -0
- package/dist/server/email-templates.d.ts +18 -0
- package/dist/server/email-templates.d.ts.map +1 -0
- package/dist/server/email-templates.js +74 -0
- package/dist/server/email-templates.js.map +1 -0
- package/dist/server/implementation/apiKey.d.ts +74 -0
- package/dist/server/implementation/apiKey.d.ts.map +1 -0
- package/dist/server/implementation/apiKey.js +140 -0
- package/dist/server/implementation/apiKey.js.map +1 -0
- package/dist/server/implementation/index.d.ts +169 -7
- package/dist/server/implementation/index.d.ts.map +1 -1
- package/dist/server/implementation/index.js +220 -5
- package/dist/server/implementation/index.js.map +1 -1
- package/dist/server/implementation/passkey.d.ts +33 -0
- package/dist/server/implementation/passkey.d.ts.map +1 -0
- package/dist/server/implementation/passkey.js +450 -0
- package/dist/server/implementation/passkey.js.map +1 -0
- package/dist/server/implementation/redirects.d.ts.map +1 -1
- package/dist/server/implementation/redirects.js +4 -9
- package/dist/server/implementation/redirects.js.map +1 -1
- package/dist/server/implementation/signIn.d.ts +13 -0
- package/dist/server/implementation/signIn.d.ts.map +1 -1
- package/dist/server/implementation/signIn.js +29 -15
- package/dist/server/implementation/signIn.js.map +1 -1
- package/dist/server/implementation/totp.d.ts +40 -0
- package/dist/server/implementation/totp.d.ts.map +1 -0
- package/dist/server/implementation/totp.js +211 -0
- package/dist/server/implementation/totp.js.map +1 -0
- package/dist/server/index.d.ts +26 -2
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +63 -16
- package/dist/server/index.js.map +1 -1
- package/dist/server/portal-email.d.ts +19 -0
- package/dist/server/portal-email.d.ts.map +1 -0
- package/dist/server/portal-email.js +89 -0
- package/dist/server/portal-email.js.map +1 -0
- package/dist/server/provider_utils.d.ts +3 -1
- package/dist/server/provider_utils.d.ts.map +1 -1
- package/dist/server/provider_utils.js +39 -1
- package/dist/server/provider_utils.js.map +1 -1
- package/dist/server/types.d.ts +263 -4
- package/dist/server/types.d.ts.map +1 -1
- package/dist/server/version.d.ts +2 -0
- package/dist/server/version.d.ts.map +1 -0
- package/dist/server/version.js +3 -0
- package/dist/server/version.js.map +1 -0
- package/package.json +7 -3
- package/src/cli/index.ts +49 -7
- package/src/cli/portal-link.ts +112 -0
- package/src/cli/portal-upload.ts +411 -0
- package/src/cli/utils.ts +248 -0
- package/src/client/index.ts +489 -1
- package/src/component/_generated/api.ts +72 -1
- package/src/component/_generated/component.ts +241 -4
- package/src/component/convex.config.ts +3 -0
- package/src/component/index.ts +8 -3
- package/src/component/portalBridge.ts +116 -0
- package/src/component/public.ts +373 -37
- package/src/component/schema.ts +122 -7
- package/src/providers/passkey.ts +35 -0
- package/src/providers/totp.ts +26 -0
- package/src/server/convex-auth.ts +602 -0
- package/src/server/email-templates.ts +77 -0
- package/src/server/implementation/apiKey.ts +185 -0
- package/src/server/implementation/index.ts +301 -8
- package/src/server/implementation/passkey.ts +650 -0
- package/src/server/implementation/redirects.ts +4 -11
- package/src/server/implementation/signIn.ts +41 -13
- package/src/server/implementation/totp.ts +366 -0
- package/src/server/index.ts +98 -34
- package/src/server/portal-email.ts +95 -0
- package/src/server/provider_utils.ts +42 -1
- package/src/server/types.ts +285 -4
- package/src/server/version.ts +2 -0
|
@@ -0,0 +1,602 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `Auth` class — the main entry point for Convex Auth.
|
|
3
|
+
*
|
|
4
|
+
* Combines authentication and portal admin functionality:
|
|
5
|
+
*
|
|
6
|
+
* ```ts
|
|
7
|
+
* // convex/auth.ts
|
|
8
|
+
* import { Auth, Portal } from "@robelest/convex-auth/component";
|
|
9
|
+
* import google from "@auth/core/providers/google";
|
|
10
|
+
* import { components } from "./_generated/api";
|
|
11
|
+
*
|
|
12
|
+
* export const auth = new Auth(components.auth, {
|
|
13
|
+
* providers: [google],
|
|
14
|
+
* email: {
|
|
15
|
+
* from: "My App <noreply@example.com>",
|
|
16
|
+
* send: async (_ctx, { from, to, subject, html }) => {
|
|
17
|
+
* await fetch("https://api.resend.com/emails", {
|
|
18
|
+
* method: "POST",
|
|
19
|
+
* headers: {
|
|
20
|
+
* Authorization: `Bearer ${process.env.AUTH_RESEND_KEY}`,
|
|
21
|
+
* "Content-Type": "application/json",
|
|
22
|
+
* },
|
|
23
|
+
* body: JSON.stringify({ from, to, subject, html }),
|
|
24
|
+
* });
|
|
25
|
+
* },
|
|
26
|
+
* },
|
|
27
|
+
* });
|
|
28
|
+
* export const { signIn, signOut, store } = auth;
|
|
29
|
+
* export const { portalQuery, portalMutation, portalInternal } = Portal(auth);
|
|
30
|
+
* ```
|
|
31
|
+
*
|
|
32
|
+
* @module
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import {
|
|
36
|
+
queryGeneric,
|
|
37
|
+
mutationGeneric,
|
|
38
|
+
internalMutationGeneric,
|
|
39
|
+
httpActionGeneric,
|
|
40
|
+
} from "convex/server";
|
|
41
|
+
import type { HttpRouter } from "convex/server";
|
|
42
|
+
import { v } from "convex/values";
|
|
43
|
+
import type { ComponentApi as AuthComponentApi } from "../component/_generated/component.js";
|
|
44
|
+
import { Auth as AuthFactory } from "./implementation/index.js";
|
|
45
|
+
import type { ConvexAuthConfig, EmailTransport } from "./types.js";
|
|
46
|
+
import { registerStaticRoutes } from "@convex-dev/self-hosting";
|
|
47
|
+
import { portalMagicLinkEmail } from "./portal-email.js";
|
|
48
|
+
import { defaultMagicLinkEmail } from "./email-templates.js";
|
|
49
|
+
import emailProvider from "../providers/email.js";
|
|
50
|
+
import { AUTH_VERSION } from "./version.js";
|
|
51
|
+
|
|
52
|
+
// ============================================================================
|
|
53
|
+
// Types
|
|
54
|
+
// ============================================================================
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Config for the Auth class. Extends the standard auth config
|
|
58
|
+
* minus `component` (which is passed as the first constructor argument).
|
|
59
|
+
*
|
|
60
|
+
* When `email` is configured, the library auto-registers:
|
|
61
|
+
* - A magic link provider (`id: "email"`) for user-facing sign-in
|
|
62
|
+
* - A portal provider (`id: "portal"`) for admin dashboard sign-in
|
|
63
|
+
*
|
|
64
|
+
* Portal functionality is always available — no configuration flag
|
|
65
|
+
* needed. The portal UI works when you export `portalQuery`,
|
|
66
|
+
* `portalMutation`, `portalInternal` from your `convex/auth.ts`
|
|
67
|
+
* and upload the portal static files via CLI.
|
|
68
|
+
*/
|
|
69
|
+
export type AuthClassConfig = Omit<ConvexAuthConfig, "component">;
|
|
70
|
+
|
|
71
|
+
// ============================================================================
|
|
72
|
+
// Helpers
|
|
73
|
+
// ============================================================================
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Check if the authenticated user is a portal admin.
|
|
77
|
+
* Uses the new index `roleAndStatusAndAcceptedByUserId` for efficient lookup.
|
|
78
|
+
*/
|
|
79
|
+
async function requirePortalAdmin(
|
|
80
|
+
ctx: any,
|
|
81
|
+
authComponent: AuthComponentApi,
|
|
82
|
+
userId: string,
|
|
83
|
+
): Promise<void> {
|
|
84
|
+
// Use inviteList with status filter, then check role + userId in-memory.
|
|
85
|
+
// The new index makes the status filter efficient.
|
|
86
|
+
const invites = await ctx.runQuery(authComponent.public.inviteList, {
|
|
87
|
+
status: "accepted",
|
|
88
|
+
});
|
|
89
|
+
const isAdmin = invites.some(
|
|
90
|
+
(invite: any) =>
|
|
91
|
+
invite.role === "portalAdmin" && invite.acceptedByUserId === userId,
|
|
92
|
+
);
|
|
93
|
+
if (!isAdmin) {
|
|
94
|
+
throw new Error("Not authorized: portal admin access required");
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ============================================================================
|
|
99
|
+
// Auth class
|
|
100
|
+
// ============================================================================
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Main entry point for Convex Auth. Instantiate with your component
|
|
104
|
+
* reference and config to get all the exports you need.
|
|
105
|
+
*
|
|
106
|
+
* ```ts
|
|
107
|
+
* export const auth = new Auth(components.auth, {
|
|
108
|
+
* providers: [google, password],
|
|
109
|
+
* email: {
|
|
110
|
+
* from: "My App <noreply@example.com>",
|
|
111
|
+
* send: (ctx, params) => resend.sendEmail(ctx, params),
|
|
112
|
+
* },
|
|
113
|
+
* });
|
|
114
|
+
* export const { signIn, signOut, store } = auth;
|
|
115
|
+
* export const { portalQuery, portalMutation, portalInternal } = Portal(auth);
|
|
116
|
+
* ```
|
|
117
|
+
*/
|
|
118
|
+
export class Auth {
|
|
119
|
+
/** The inner `auth` helper object from AuthFactory() */
|
|
120
|
+
private readonly _auth: ReturnType<typeof AuthFactory>["auth"];
|
|
121
|
+
/** The signIn action — export this from your convex/auth.ts */
|
|
122
|
+
public readonly signIn: ReturnType<typeof AuthFactory>["signIn"];
|
|
123
|
+
/** The signOut action — export this from your convex/auth.ts */
|
|
124
|
+
public readonly signOut: ReturnType<typeof AuthFactory>["signOut"];
|
|
125
|
+
/** The store internal mutation — export this from your convex/auth.ts */
|
|
126
|
+
public readonly store: ReturnType<typeof AuthFactory>["store"];
|
|
127
|
+
|
|
128
|
+
/** @internal */
|
|
129
|
+
readonly component: AuthComponentApi;
|
|
130
|
+
/** @internal */
|
|
131
|
+
readonly portalUrl: string;
|
|
132
|
+
|
|
133
|
+
// ---- Proxied auth helper sub-objects ----
|
|
134
|
+
/** User helpers: `.current(ctx)`, `.require(ctx)`, `.get(ctx, userId)`, `.viewer(ctx)` */
|
|
135
|
+
get user() { return this._auth.user; }
|
|
136
|
+
/** Session helpers */
|
|
137
|
+
get session() { return this._auth.session; }
|
|
138
|
+
/** Provider helpers */
|
|
139
|
+
get provider() { return this._auth.provider; }
|
|
140
|
+
/** Account helpers */
|
|
141
|
+
get account() { return this._auth.account; }
|
|
142
|
+
/** Group helpers */
|
|
143
|
+
get group() { return this._auth.group; }
|
|
144
|
+
/** Invite helpers */
|
|
145
|
+
get invite() { return this._auth.invite; }
|
|
146
|
+
/** Passkey helpers */
|
|
147
|
+
get passkey() { return this._auth.passkey; }
|
|
148
|
+
/** TOTP helpers */
|
|
149
|
+
get totp() { return this._auth.totp; }
|
|
150
|
+
/** API key helpers: `.create(ctx, ...)`, `.verify(ctx, ...)`, `.list(ctx, ...)`, `.revoke(ctx, ...)` */
|
|
151
|
+
get key() { return this._auth.key; }
|
|
152
|
+
|
|
153
|
+
constructor(component: AuthComponentApi, config: AuthClassConfig) {
|
|
154
|
+
this.component = component;
|
|
155
|
+
|
|
156
|
+
// Derive portal URL from CONVEX_SITE_URL
|
|
157
|
+
this.portalUrl = process.env.CONVEX_SITE_URL
|
|
158
|
+
? `${process.env.CONVEX_SITE_URL.replace(/\/$/, "")}/auth`
|
|
159
|
+
: "/auth";
|
|
160
|
+
|
|
161
|
+
const emailTransport = config.email;
|
|
162
|
+
const providers = [...config.providers];
|
|
163
|
+
|
|
164
|
+
// Auto-register user-facing magic link provider when email is configured.
|
|
165
|
+
// Skipped if the user already registered their own provider with id "email".
|
|
166
|
+
const hasUserEmailProvider = providers.some(
|
|
167
|
+
(p) => typeof p === "object" && "id" in p && p.id === "email",
|
|
168
|
+
);
|
|
169
|
+
if (emailTransport && !hasUserEmailProvider) {
|
|
170
|
+
providers.push(
|
|
171
|
+
emailProvider({
|
|
172
|
+
id: "email",
|
|
173
|
+
maxAge: 60 * 60 * 24, // 24 hours
|
|
174
|
+
authorize: undefined, // Magic link — no OTP email check needed
|
|
175
|
+
async sendVerificationRequest({ identifier, url }, ctx) {
|
|
176
|
+
if (!ctx) {
|
|
177
|
+
throw new Error("Action context is required for email delivery");
|
|
178
|
+
}
|
|
179
|
+
const { host } = new URL(url);
|
|
180
|
+
await emailTransport.send(ctx, {
|
|
181
|
+
from: emailTransport.from,
|
|
182
|
+
to: identifier,
|
|
183
|
+
subject: `Sign in to ${host}`,
|
|
184
|
+
html: defaultMagicLinkEmail(url, host),
|
|
185
|
+
});
|
|
186
|
+
},
|
|
187
|
+
}),
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Auto-register portal admin magic link provider.
|
|
192
|
+
// Uses its own styled dark-theme email template.
|
|
193
|
+
providers.push(
|
|
194
|
+
emailProvider({
|
|
195
|
+
id: "portal",
|
|
196
|
+
maxAge: 60 * 60 * 24, // 24 hours
|
|
197
|
+
authorize: undefined, // Magic link — no OTP email check needed
|
|
198
|
+
async sendVerificationRequest({ identifier, url, expires }, ctx) {
|
|
199
|
+
if (!emailTransport) {
|
|
200
|
+
throw new Error(
|
|
201
|
+
"Auth email config is required for the portal. " +
|
|
202
|
+
"Configure email: { from, send } in your Auth constructor.",
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
if (!ctx) {
|
|
206
|
+
throw new Error("Action context is required for email delivery");
|
|
207
|
+
}
|
|
208
|
+
const hours = Math.max(
|
|
209
|
+
1,
|
|
210
|
+
Math.floor((+expires - Date.now()) / (60 * 60 * 1000)),
|
|
211
|
+
);
|
|
212
|
+
await emailTransport.send(ctx, {
|
|
213
|
+
from: emailTransport.from,
|
|
214
|
+
to: identifier,
|
|
215
|
+
subject: "Sign in to Auth Portal",
|
|
216
|
+
html: portalMagicLinkEmail(url, hours),
|
|
217
|
+
});
|
|
218
|
+
},
|
|
219
|
+
}),
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
// Initialize the core AuthFactory()
|
|
223
|
+
const authResult = AuthFactory({
|
|
224
|
+
...config,
|
|
225
|
+
component,
|
|
226
|
+
providers,
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
this._auth = authResult.auth;
|
|
230
|
+
this.signIn = authResult.signIn;
|
|
231
|
+
this.signOut = authResult.signOut;
|
|
232
|
+
this.store = authResult.store;
|
|
233
|
+
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Register HTTP routes for OAuth, JWT well-known endpoints, and portal
|
|
238
|
+
* static file serving.
|
|
239
|
+
*
|
|
240
|
+
* ```ts
|
|
241
|
+
* // convex/http.ts
|
|
242
|
+
* import { httpRouter } from "convex/server";
|
|
243
|
+
* import { auth } from "./auth";
|
|
244
|
+
*
|
|
245
|
+
* const http = httpRouter();
|
|
246
|
+
* auth.addHttpRoutes(http);
|
|
247
|
+
* export default http;
|
|
248
|
+
* ```
|
|
249
|
+
*/
|
|
250
|
+
addHttpRoutes(
|
|
251
|
+
http: HttpRouter,
|
|
252
|
+
opts?: { pathPrefix?: string; spaFallback?: boolean },
|
|
253
|
+
): void {
|
|
254
|
+
// Core auth routes (OAuth, JWKS, etc.)
|
|
255
|
+
this._auth.addHttpRoutes(http);
|
|
256
|
+
|
|
257
|
+
const prefix = opts?.pathPrefix ?? "/auth";
|
|
258
|
+
|
|
259
|
+
// Portal configuration endpoint — serves Convex URLs + version info.
|
|
260
|
+
// The portal SPA fetches this at startup to discover its Convex backend,
|
|
261
|
+
// which is critical for custom domain deployments where the hostname
|
|
262
|
+
// alone doesn't reveal the Convex cloud URL.
|
|
263
|
+
// Registered as an exact path match before the static file prefix catch-all.
|
|
264
|
+
http.route({
|
|
265
|
+
path: `${prefix}/.well-known/portal-config`,
|
|
266
|
+
method: "GET",
|
|
267
|
+
handler: httpActionGeneric(async () => {
|
|
268
|
+
return new Response(
|
|
269
|
+
JSON.stringify({
|
|
270
|
+
convexUrl: process.env.CONVEX_CLOUD_URL,
|
|
271
|
+
siteUrl: process.env.CONVEX_SITE_URL,
|
|
272
|
+
version: AUTH_VERSION,
|
|
273
|
+
}),
|
|
274
|
+
{
|
|
275
|
+
status: 200,
|
|
276
|
+
headers: {
|
|
277
|
+
"Content-Type": "application/json",
|
|
278
|
+
"Cache-Control":
|
|
279
|
+
"public, max-age=60, stale-while-revalidate=60",
|
|
280
|
+
"Access-Control-Allow-Origin": "*",
|
|
281
|
+
},
|
|
282
|
+
},
|
|
283
|
+
);
|
|
284
|
+
}),
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
// Create a shim that maps the self-hosting ComponentApi shape
|
|
288
|
+
// to the auth component's portalBridge functions
|
|
289
|
+
const selfHostingShim = {
|
|
290
|
+
lib: {
|
|
291
|
+
getByPath: this.component.portalBridge.getByPath,
|
|
292
|
+
getCurrentDeployment: this.component.portalBridge.getCurrentDeployment,
|
|
293
|
+
listAssets: this.component.portalBridge.listAssets,
|
|
294
|
+
recordAsset: this.component.portalBridge.recordAsset,
|
|
295
|
+
gcOldAssets: this.component.portalBridge.gcOldAssets,
|
|
296
|
+
setCurrentDeployment: this.component.portalBridge.setCurrentDeployment,
|
|
297
|
+
// generateUploadUrl is not needed — we use app storage directly
|
|
298
|
+
generateUploadUrl: undefined as any,
|
|
299
|
+
},
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
registerStaticRoutes(http, selfHostingShim as any, {
|
|
303
|
+
pathPrefix: prefix,
|
|
304
|
+
spaFallback: opts?.spaFallback ?? true,
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// ============================================================================
|
|
310
|
+
// Portal exports (standalone function)
|
|
311
|
+
// ============================================================================
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Create portal function definitions from a ConvexAuth instance.
|
|
315
|
+
*
|
|
316
|
+
* This is a standalone function (not a class method) because Convex's
|
|
317
|
+
* bundler can trace through `export const { x } = fn(instance)` but
|
|
318
|
+
* cannot trace through `instance.method()`.
|
|
319
|
+
*
|
|
320
|
+
* ```ts
|
|
321
|
+
* export const { portalQuery, portalMutation, portalInternal } = Portal(auth);
|
|
322
|
+
* ```
|
|
323
|
+
*/
|
|
324
|
+
export function Portal(auth: Auth) {
|
|
325
|
+
const authComponent = auth.component;
|
|
326
|
+
const authHelper = (auth as any)._auth;
|
|
327
|
+
const portalUrl = auth.portalUrl;
|
|
328
|
+
|
|
329
|
+
const portalQuery = queryGeneric({
|
|
330
|
+
args: {
|
|
331
|
+
action: v.string(),
|
|
332
|
+
userId: v.optional(v.string()),
|
|
333
|
+
},
|
|
334
|
+
handler: async (
|
|
335
|
+
ctx: any,
|
|
336
|
+
{ action, userId }: { action: string; userId?: string },
|
|
337
|
+
) => {
|
|
338
|
+
const currentUserId = await authHelper.user.require(ctx);
|
|
339
|
+
|
|
340
|
+
// Allow isAdmin check without admin requirement
|
|
341
|
+
if (action === "isAdmin") {
|
|
342
|
+
try {
|
|
343
|
+
await requirePortalAdmin(ctx, authComponent, currentUserId);
|
|
344
|
+
return true;
|
|
345
|
+
} catch {
|
|
346
|
+
return false;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
await requirePortalAdmin(ctx, authComponent, currentUserId);
|
|
351
|
+
|
|
352
|
+
switch (action) {
|
|
353
|
+
case "listUsers":
|
|
354
|
+
return await ctx.runQuery(authComponent.public.userList);
|
|
355
|
+
|
|
356
|
+
case "listSessions":
|
|
357
|
+
return await ctx.runQuery(authComponent.public.sessionList);
|
|
358
|
+
|
|
359
|
+
case "getUser":
|
|
360
|
+
return await ctx.runQuery(authComponent.public.userGetById, {
|
|
361
|
+
userId: userId!,
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
case "getUserSessions":
|
|
365
|
+
return await ctx.runQuery(authComponent.public.sessionListByUser, {
|
|
366
|
+
userId: userId!,
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
case "getUserAccounts": {
|
|
370
|
+
const accounts = await ctx.runQuery(
|
|
371
|
+
authComponent.public.accountListByUser,
|
|
372
|
+
{ userId: userId! },
|
|
373
|
+
);
|
|
374
|
+
// Strip secrets — never send password hashes to the frontend
|
|
375
|
+
return accounts.map(({ secret: _, ...rest }: any) => rest);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Invite validation (public within portal context)
|
|
379
|
+
case "validateInvite": {
|
|
380
|
+
// userId param repurposed as tokenHash for this action
|
|
381
|
+
const tokenHash = userId;
|
|
382
|
+
if (!tokenHash) throw new Error("tokenHash required");
|
|
383
|
+
const invite = await ctx.runQuery(
|
|
384
|
+
authComponent.public.inviteGetByTokenHash,
|
|
385
|
+
{ tokenHash },
|
|
386
|
+
);
|
|
387
|
+
if (!invite || invite.status !== "pending") {
|
|
388
|
+
return null;
|
|
389
|
+
}
|
|
390
|
+
if (invite.expiresTime && invite.expiresTime < Date.now()) {
|
|
391
|
+
return null;
|
|
392
|
+
}
|
|
393
|
+
return { _id: invite._id, role: invite.role };
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
case "getCurrentDeployment":
|
|
397
|
+
return await ctx.runQuery(
|
|
398
|
+
authComponent.portalBridge.getCurrentDeployment,
|
|
399
|
+
);
|
|
400
|
+
|
|
401
|
+
// ---- API Keys (portal admin) ----
|
|
402
|
+
case "listKeys":
|
|
403
|
+
return await ctx.runQuery(authComponent.public.keyList);
|
|
404
|
+
|
|
405
|
+
case "getUserKeys":
|
|
406
|
+
return await ctx.runQuery(authComponent.public.keyListByUserId, {
|
|
407
|
+
userId: userId!,
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
case "getKey":
|
|
411
|
+
return await ctx.runQuery(authComponent.public.keyGetById, {
|
|
412
|
+
keyId: userId!, // userId param repurposed as keyId
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
default:
|
|
416
|
+
throw new Error(`Unknown portal query action: ${action}`);
|
|
417
|
+
}
|
|
418
|
+
},
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
const portalMutation = mutationGeneric({
|
|
422
|
+
args: {
|
|
423
|
+
action: v.string(),
|
|
424
|
+
sessionId: v.optional(v.string()),
|
|
425
|
+
tokenHash: v.optional(v.string()),
|
|
426
|
+
// API key fields
|
|
427
|
+
keyId: v.optional(v.string()),
|
|
428
|
+
keyUserId: v.optional(v.string()),
|
|
429
|
+
keyName: v.optional(v.string()),
|
|
430
|
+
keyScopes: v.optional(
|
|
431
|
+
v.array(
|
|
432
|
+
v.object({
|
|
433
|
+
resource: v.string(),
|
|
434
|
+
actions: v.array(v.string()),
|
|
435
|
+
}),
|
|
436
|
+
),
|
|
437
|
+
),
|
|
438
|
+
keyRateLimit: v.optional(
|
|
439
|
+
v.object({
|
|
440
|
+
maxRequests: v.number(),
|
|
441
|
+
windowMs: v.number(),
|
|
442
|
+
}),
|
|
443
|
+
),
|
|
444
|
+
keyExpiresAt: v.optional(v.number()),
|
|
445
|
+
},
|
|
446
|
+
handler: async (ctx: any, args: any) => {
|
|
447
|
+
const currentUserId = await authHelper.user.require(ctx);
|
|
448
|
+
|
|
449
|
+
switch (args.action) {
|
|
450
|
+
case "acceptInvite": {
|
|
451
|
+
if (!args.tokenHash) throw new Error("tokenHash required");
|
|
452
|
+
const invite = await ctx.runQuery(
|
|
453
|
+
authComponent.public.inviteGetByTokenHash,
|
|
454
|
+
{ tokenHash: args.tokenHash },
|
|
455
|
+
);
|
|
456
|
+
if (!invite) throw new Error("Invalid invite token");
|
|
457
|
+
if (invite.status !== "pending") {
|
|
458
|
+
throw new Error(`Invite already ${invite.status}`);
|
|
459
|
+
}
|
|
460
|
+
if (invite.expiresTime && invite.expiresTime < Date.now()) {
|
|
461
|
+
throw new Error("Invite has expired");
|
|
462
|
+
}
|
|
463
|
+
await ctx.runMutation(authComponent.public.inviteAccept, {
|
|
464
|
+
inviteId: invite._id,
|
|
465
|
+
acceptedByUserId: currentUserId,
|
|
466
|
+
});
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
case "revokeSession": {
|
|
471
|
+
await requirePortalAdmin(ctx, authComponent, currentUserId);
|
|
472
|
+
await ctx.runMutation(authComponent.public.sessionDelete, {
|
|
473
|
+
sessionId: args.sessionId!,
|
|
474
|
+
});
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// ---- API Keys (portal admin) ----
|
|
479
|
+
case "createKey": {
|
|
480
|
+
await requirePortalAdmin(ctx, authComponent, currentUserId);
|
|
481
|
+
const result = await authHelper.key.create(ctx, {
|
|
482
|
+
userId: args.keyUserId!,
|
|
483
|
+
name: args.keyName!,
|
|
484
|
+
scopes: args.keyScopes ?? [],
|
|
485
|
+
rateLimit: args.keyRateLimit,
|
|
486
|
+
expiresAt: args.keyExpiresAt,
|
|
487
|
+
});
|
|
488
|
+
// Return the raw key — portal will show it once
|
|
489
|
+
return result;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
case "revokeKey": {
|
|
493
|
+
await requirePortalAdmin(ctx, authComponent, currentUserId);
|
|
494
|
+
await authHelper.key.revoke(ctx, args.keyId!);
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
case "deleteKey": {
|
|
499
|
+
await requirePortalAdmin(ctx, authComponent, currentUserId);
|
|
500
|
+
await authHelper.key.remove(ctx, args.keyId!);
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
case "updateKey": {
|
|
505
|
+
await requirePortalAdmin(ctx, authComponent, currentUserId);
|
|
506
|
+
const data: Record<string, any> = {};
|
|
507
|
+
if (args.keyName) data.name = args.keyName;
|
|
508
|
+
if (args.keyScopes) data.scopes = args.keyScopes;
|
|
509
|
+
if (args.keyRateLimit) data.rateLimit = args.keyRateLimit;
|
|
510
|
+
await authHelper.key.update(ctx, args.keyId!, data);
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
default:
|
|
515
|
+
throw new Error(`Unknown portal mutation action: ${args.action}`);
|
|
516
|
+
}
|
|
517
|
+
},
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
const portalInternal = internalMutationGeneric({
|
|
521
|
+
args: {
|
|
522
|
+
action: v.string(),
|
|
523
|
+
tokenHash: v.optional(v.string()),
|
|
524
|
+
path: v.optional(v.string()),
|
|
525
|
+
storageId: v.optional(v.string()),
|
|
526
|
+
blobId: v.optional(v.string()),
|
|
527
|
+
contentType: v.optional(v.string()),
|
|
528
|
+
deploymentId: v.optional(v.string()),
|
|
529
|
+
currentDeploymentId: v.optional(v.string()),
|
|
530
|
+
limit: v.optional(v.number()),
|
|
531
|
+
},
|
|
532
|
+
handler: async (ctx: any, args: any) => {
|
|
533
|
+
switch (args.action) {
|
|
534
|
+
// ---- Invite management (CLI) ----
|
|
535
|
+
case "createPortalInvite": {
|
|
536
|
+
await ctx.runMutation(authComponent.public.inviteCreate, {
|
|
537
|
+
tokenHash: args.tokenHash,
|
|
538
|
+
role: "portalAdmin",
|
|
539
|
+
status: "pending" as const,
|
|
540
|
+
});
|
|
541
|
+
return { portalUrl };
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// ---- Static hosting (CLI upload) ----
|
|
545
|
+
case "generateUploadUrl": {
|
|
546
|
+
return await ctx.storage.generateUploadUrl();
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
case "recordAsset": {
|
|
550
|
+
const { oldStorageId, oldBlobId } = await ctx.runMutation(
|
|
551
|
+
authComponent.portalBridge.recordAsset,
|
|
552
|
+
{
|
|
553
|
+
path: args.path,
|
|
554
|
+
...(args.storageId ? { storageId: args.storageId } : {}),
|
|
555
|
+
...(args.blobId ? { blobId: args.blobId } : {}),
|
|
556
|
+
contentType: args.contentType,
|
|
557
|
+
deploymentId: args.deploymentId,
|
|
558
|
+
},
|
|
559
|
+
);
|
|
560
|
+
if (oldStorageId) {
|
|
561
|
+
try {
|
|
562
|
+
await ctx.storage.delete(oldStorageId);
|
|
563
|
+
} catch {
|
|
564
|
+
// Ignore — old file may have been in different storage
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
return oldBlobId ?? null;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
case "gcOldAssets": {
|
|
571
|
+
const { storageIds, blobIds } = await ctx.runMutation(
|
|
572
|
+
authComponent.portalBridge.gcOldAssets,
|
|
573
|
+
{ currentDeploymentId: args.currentDeploymentId },
|
|
574
|
+
);
|
|
575
|
+
for (const storageId of storageIds) {
|
|
576
|
+
try {
|
|
577
|
+
await ctx.storage.delete(storageId);
|
|
578
|
+
} catch {
|
|
579
|
+
// Ignore
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
await ctx.runMutation(
|
|
583
|
+
authComponent.portalBridge.setCurrentDeployment,
|
|
584
|
+
{ deploymentId: args.currentDeploymentId },
|
|
585
|
+
);
|
|
586
|
+
return { deleted: storageIds.length, blobIds };
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
case "listAssets": {
|
|
590
|
+
return await ctx.runQuery(authComponent.portalBridge.listAssets, {
|
|
591
|
+
limit: args.limit,
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
default:
|
|
596
|
+
throw new Error(`Unknown portalInternal action: ${args.action}`);
|
|
597
|
+
}
|
|
598
|
+
},
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
return { portalQuery, portalMutation, portalInternal };
|
|
602
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default email templates generated by the Auth library.
|
|
3
|
+
*
|
|
4
|
+
* These are used when the library sends emails on behalf of the developer
|
|
5
|
+
* (magic links, portal admin sign-in). The developer provides the transport
|
|
6
|
+
* via `email.send`; the library provides the content.
|
|
7
|
+
*
|
|
8
|
+
* @module
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Default magic link email template.
|
|
13
|
+
*
|
|
14
|
+
* Clean, minimal design that works across email clients.
|
|
15
|
+
* Used by the auto-registered `email` provider when `email` is
|
|
16
|
+
* configured in the Auth constructor.
|
|
17
|
+
*/
|
|
18
|
+
export function defaultMagicLinkEmail(url: string, host: string): string {
|
|
19
|
+
const escapedHost = host.replace(/[&<>"']/g, (c) =>
|
|
20
|
+
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]!,
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
return `<!DOCTYPE html>
|
|
24
|
+
<html lang="en">
|
|
25
|
+
<head>
|
|
26
|
+
<meta charset="utf-8" />
|
|
27
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
28
|
+
<title>Sign in to ${escapedHost}</title>
|
|
29
|
+
</head>
|
|
30
|
+
<body style="margin:0;padding:0;background-color:#f9fafb;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;">
|
|
31
|
+
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color:#f9fafb;padding:40px 16px;">
|
|
32
|
+
<tr>
|
|
33
|
+
<td align="center">
|
|
34
|
+
<table role="presentation" width="480" cellpadding="0" cellspacing="0" style="background-color:#ffffff;border:1px solid #e5e7eb;border-radius:8px;overflow:hidden;">
|
|
35
|
+
<tr>
|
|
36
|
+
<td style="padding:32px 32px 0 32px;text-align:center;">
|
|
37
|
+
<h1 style="margin:0 0 8px 0;font-size:20px;font-weight:600;color:#111827;line-height:1.3;">
|
|
38
|
+
Sign in to ${escapedHost}
|
|
39
|
+
</h1>
|
|
40
|
+
</td>
|
|
41
|
+
</tr>
|
|
42
|
+
<tr>
|
|
43
|
+
<td style="padding:24px 32px;">
|
|
44
|
+
<p style="margin:0 0 24px 0;font-size:15px;line-height:1.6;color:#4b5563;text-align:center;">
|
|
45
|
+
Click the button below to sign in. This link will expire shortly.
|
|
46
|
+
</p>
|
|
47
|
+
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
|
|
48
|
+
<tr>
|
|
49
|
+
<td align="center" style="padding:0 0 24px 0;">
|
|
50
|
+
<a href="${url}" target="_blank" style="display:inline-block;background-color:#111827;color:#ffffff;font-size:15px;font-weight:600;text-decoration:none;padding:12px 32px;border-radius:6px;line-height:1;">
|
|
51
|
+
Sign in
|
|
52
|
+
</a>
|
|
53
|
+
</td>
|
|
54
|
+
</tr>
|
|
55
|
+
</table>
|
|
56
|
+
<p style="margin:0 0 12px 0;font-size:13px;line-height:1.6;color:#9ca3af;">
|
|
57
|
+
If the button doesn't work, copy and paste this URL into your browser:
|
|
58
|
+
</p>
|
|
59
|
+
<p style="margin:0;font-size:13px;line-height:1.5;color:#6b7280;word-break:break-all;">
|
|
60
|
+
${url}
|
|
61
|
+
</p>
|
|
62
|
+
</td>
|
|
63
|
+
</tr>
|
|
64
|
+
<tr>
|
|
65
|
+
<td style="padding:20px 32px;border-top:1px solid #e5e7eb;">
|
|
66
|
+
<p style="margin:0;font-size:12px;line-height:1.5;color:#9ca3af;text-align:center;">
|
|
67
|
+
If you didn't request this email, you can safely ignore it.
|
|
68
|
+
</p>
|
|
69
|
+
</td>
|
|
70
|
+
</tr>
|
|
71
|
+
</table>
|
|
72
|
+
</td>
|
|
73
|
+
</tr>
|
|
74
|
+
</table>
|
|
75
|
+
</body>
|
|
76
|
+
</html>`;
|
|
77
|
+
}
|