@pramen/auth 0.0.19 → 0.0.21

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/index.js CHANGED
@@ -250,6 +250,34 @@ export function createMagicLinkAuth(opts) {
250
250
  const sessionTtl = opts.sessionTtlSeconds ?? TOKEN_TTL_SECONDS;
251
251
  const defaultRoles = opts.defaultRoles ?? DEFAULT_ROLES;
252
252
  const handlers = {
253
+ /** Admin-only: create a passwordless user with the given roles (defaults if omitted)
254
+ * and email them a fresh magic link. Idempotent — inviting an existing user just
255
+ * resends the link and leaves their roles alone (admin uses setUserRoles for changes).
256
+ * Piggybacks on the sendMagicLinkEmail task, so the send happens outside the
257
+ * mutation's storage transaction. */
258
+ inviteUser: mutation(async (ctx, input) => {
259
+ const { email } = parseEmail(input);
260
+ const roles = Array.isArray(input?.roles) && input.roles.every((r) => typeof r === "string" && r.length > 0)
261
+ ? input.roles
262
+ : defaultRoles;
263
+ const now = Date.now();
264
+ // Existence check on username (== email, per magic-link convention); a re-invite
265
+ // MUST NOT overwrite roles — that's setUserRoles's job.
266
+ const existing = await ctx.db.exec("SELECT username FROM auth_users WHERE username = ? LIMIT 1", email);
267
+ if (existing.length === 0) {
268
+ // Same shape as loginWithMagicLink's find-or-create path: username-only, no
269
+ // `email` column set (it stays a pure contact attribute, set via changeEmail).
270
+ await ctx.db.exec("INSERT INTO auth_users (username, passwordHash, roles, active, createdAt) VALUES (?, ?, ?, ?, ?)", email, "", JSON.stringify(roles), 1, now);
271
+ }
272
+ // Reuse magic-link machinery so login flow is identical whether the user was
273
+ // self-signed-up or admin-invited.
274
+ const token = mintToken();
275
+ const tokenHash = await sha256Hex(token);
276
+ await ctx.db.exec("DELETE FROM auth_magic_links WHERE email = ?", email);
277
+ await ctx.db.exec("INSERT INTO auth_magic_links (tokenHash, email, expiresAt, createdAt) VALUES (?, ?, ?, ?)", tokenHash, email, now + linkTtlMs, now);
278
+ await ctx.tasks.enqueue({ kind: "sendMagicLinkEmail", payload: { email, token } });
279
+ return { ok: true, created: existing.length === 0 };
280
+ }, { auth: ["admin"] }),
253
281
  requestMagicLink: mutation(async (ctx, input) => {
254
282
  const token = mintToken();
255
283
  const tokenHash = await sha256Hex(token);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pramen/auth",
3
- "version": "0.0.19",
3
+ "version": "0.0.21",
4
4
  "description": "Optional credential→JWT login for pramen — signup/login/me + PBKDF2 hashing, issuing HS256 tokens the pramen verifier accepts.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -34,6 +34,6 @@
34
34
  "access": "public"
35
35
  },
36
36
  "dependencies": {
37
- "@pramen/server": "0.0.19"
37
+ "@pramen/server": "0.0.21"
38
38
  }
39
39
  }
package/src/index.ts CHANGED
@@ -315,6 +315,52 @@ export function createMagicLinkAuth(opts: MagicLinkOptions): { handlers: Handler
315
315
  const defaultRoles = opts.defaultRoles ?? DEFAULT_ROLES;
316
316
 
317
317
  const handlers: HandlerMap = {
318
+ /** Admin-only: create a passwordless user with the given roles (defaults if omitted)
319
+ * and email them a fresh magic link. Idempotent — inviting an existing user just
320
+ * resends the link and leaves their roles alone (admin uses setUserRoles for changes).
321
+ * Piggybacks on the sendMagicLinkEmail task, so the send happens outside the
322
+ * mutation's storage transaction. */
323
+ inviteUser: mutation(
324
+ async (ctx, input: { email: string; roles?: string[] }) => {
325
+ const { email } = parseEmail(input);
326
+ const roles =
327
+ Array.isArray(input?.roles) && input.roles.every((r) => typeof r === "string" && r.length > 0)
328
+ ? input.roles
329
+ : defaultRoles;
330
+ const now = Date.now();
331
+ // Existence check on username (== email, per magic-link convention); a re-invite
332
+ // MUST NOT overwrite roles — that's setUserRoles's job.
333
+ const existing = await ctx.db.exec("SELECT username FROM auth_users WHERE username = ? LIMIT 1", email);
334
+ if (existing.length === 0) {
335
+ // Same shape as loginWithMagicLink's find-or-create path: username-only, no
336
+ // `email` column set (it stays a pure contact attribute, set via changeEmail).
337
+ await ctx.db.exec(
338
+ "INSERT INTO auth_users (username, passwordHash, roles, active, createdAt) VALUES (?, ?, ?, ?, ?)",
339
+ email,
340
+ "",
341
+ JSON.stringify(roles),
342
+ 1,
343
+ now,
344
+ );
345
+ }
346
+ // Reuse magic-link machinery so login flow is identical whether the user was
347
+ // self-signed-up or admin-invited.
348
+ const token = mintToken();
349
+ const tokenHash = await sha256Hex(token);
350
+ await ctx.db.exec("DELETE FROM auth_magic_links WHERE email = ?", email);
351
+ await ctx.db.exec(
352
+ "INSERT INTO auth_magic_links (tokenHash, email, expiresAt, createdAt) VALUES (?, ?, ?, ?)",
353
+ tokenHash,
354
+ email,
355
+ now + linkTtlMs,
356
+ now,
357
+ );
358
+ await ctx.tasks.enqueue({ kind: "sendMagicLinkEmail", payload: { email, token } });
359
+ return { ok: true, created: existing.length === 0 };
360
+ },
361
+ { auth: ["admin"] },
362
+ ),
363
+
318
364
  requestMagicLink: mutation(
319
365
  async (ctx, input: { email: string }) => {
320
366
  const token = mintToken();