@vonall/auth 0.4.0 → 0.6.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.
Files changed (36) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +14 -7
  3. package/dist/index.d.ts +1 -1
  4. package/dist/messages.d.ts +460 -16
  5. package/dist/messages.js +4 -14
  6. package/dist/remote/app_user.remote.d.ts +2 -2
  7. package/dist/remote/app_user.remote.js +13 -13
  8. package/dist/remote/einladung.remote.js +15 -12
  9. package/dist/server/{registry.d.ts → context.d.ts} +13 -16
  10. package/dist/server/context.js +22 -0
  11. package/dist/server/create.d.ts +27 -22
  12. package/dist/server/create.js +50 -33
  13. package/dist/server/index.d.ts +7 -7
  14. package/dist/server/index.js +6 -6
  15. package/dist/server/loads.d.ts +14 -2
  16. package/dist/server/loads.js +19 -14
  17. package/dist/server/repositories/EinladungRepo.d.ts +4 -1
  18. package/dist/server/repositories/EinladungRepo.js +66 -73
  19. package/dist/server/repositories/UserRepo.d.ts +4 -1
  20. package/dist/server/repositories/UserRepo.js +49 -57
  21. package/dist/server/service/einladung.service.d.ts +6 -2
  22. package/dist/server/service/einladung.service.js +56 -51
  23. package/dist/server/service/mail.d.ts +16 -12
  24. package/dist/server/service/mail.js +32 -34
  25. package/dist/testing/index.d.ts +6 -8
  26. package/dist/testing/index.js +16 -19
  27. package/dist/ui/AdminView.svelte +2 -1
  28. package/dist/ui/InvitationsTab.svelte +2 -1
  29. package/dist/ui/LoginForm.svelte +1 -1
  30. package/dist/ui/ResetPasswordForm.svelte +2 -2
  31. package/dist/ui/context.d.ts +6 -7
  32. package/dist/ui/context.js +15 -9
  33. package/dist/util/signup.d.ts +2 -0
  34. package/dist/util/signup.js +2 -0
  35. package/package.json +3 -2
  36. package/dist/server/registry.js +0 -22
@@ -1,22 +1,24 @@
1
1
  /**
2
2
  * The `load` functions of the auth routes, for a Product's `+page.server.ts`/`+layout.server.ts` to re-export:
3
- * `export { loginLoad as load } from "@vonall/auth/server";`
3
+ * `export { loginLoad as load } from "@vonall/auth/server";`. The layouts' loads also carry what the components need
4
+ * to know about the app (`authUi`: its name, locale and signup mode), so the Product passes nothing twice.
4
5
  */
5
6
  import { error, redirect } from "@sveltejs/kit";
6
7
  import { m } from "../messages.js";
7
- import { authInstance, registry } from "./registry.js";
8
- import { EINLADUNG_COOKIE, EinladungService } from "./service/einladung.service.js";
8
+ import { contextOf } from "./context.js";
9
+ import { EINLADUNG_COOKIE, einladungService } from "./service/einladung.service.js";
9
10
  import { isStale, SESSION_NOT_FRESH } from "../util/fresh.js";
10
11
  import { sameSitePath } from "../util/sameSite.js";
11
12
  import { isAdmin, mayInvite } from "../util/signup.js";
13
+ const uiData = ({ appName, locale, signupMode }) => ({ appName, locale, signupMode });
12
14
  /** `/auth`: which social logins have keys, and whether a password reset mail can be sent (the link is hidden otherwise). */
13
- export async function authLayoutLoad() {
14
- const { providers, mailer } = registry();
15
- return { providers, mail: mailer.enabled };
15
+ export async function authLayoutLoad({ locals }) {
16
+ const ctx = contextOf(locals);
17
+ return { providers: ctx.providers, mail: ctx.mailer.enabled, authUi: uiData(ctx) };
16
18
  }
17
19
  /** `/auth/login`. */
18
20
  export async function loginLoad({ locals, url, request }) {
19
- const auth = authInstance();
21
+ const { auth } = contextOf(locals);
20
22
  // An action refused the session as too old to be trusted (reauthPath): sign it out, so the form shows. Only a session
21
23
  // that really is that old, so a link can't log anyone out.
22
24
  if (locals.session && url.searchParams.get("error") === SESSION_NOT_FRESH.toLowerCase()) {
@@ -37,20 +39,23 @@ export async function loginLoad({ locals, url, request }) {
37
39
  export async function signupLoad({ locals, cookies }) {
38
40
  if (locals.session)
39
41
  throw redirect(302, "/home");
40
- const { state, einladung } = await EinladungService.signup(cookies.get(EINLADUNG_COOKIE));
41
- return { state, mode: registry().signupMode, einladung: einladung ? { inviterName: einladung.inviterName } : null };
42
+ const ctx = contextOf(locals);
43
+ const { state, einladung } = await einladungService(ctx).signup(cookies.get(EINLADUNG_COOKIE));
44
+ return { state, mode: ctx.signupMode, einladung: einladung ? { inviterName: einladung.inviterName } : null };
42
45
  }
43
46
  /**
44
47
  * `/einladung/[token]`: the link of an Einladung. A valid one is remembered in a cookie so it survives signup with
45
48
  * email, Google or GitHub (the signup hook takes it from there).
46
49
  */
47
50
  export async function einladungLoad({ params, cookies, locals, url }) {
51
+ const ctx = contextOf(locals);
48
52
  const token = params.token;
49
53
  if (!token)
50
54
  throw error(404, { message: m().einladungUnbekannt, id: "unbekannt", timestamp: new Date().toISOString() });
51
- const pruefung = await EinladungService.pruefen(token);
55
+ const pruefung = await einladungService(ctx).pruefen(token);
56
+ const authUi = uiData(ctx);
52
57
  if (pruefung.isErr())
53
- return { fehler: pruefung.error.message, einladung: null, eingeloggt: !!locals.user };
58
+ return { fehler: pruefung.error.message, einladung: null, eingeloggt: !!locals.user, authUi };
54
59
  const einladung = pruefung.value;
55
60
  cookies.set(EINLADUNG_COOKIE, token, {
56
61
  path: "/",
@@ -60,18 +65,18 @@ export async function einladungLoad({ params, cookies, locals, url }) {
60
65
  secure: url.protocol === "https:",
61
66
  expires: einladung.expiresAt,
62
67
  });
63
- return { fehler: null, einladung: { inviterName: einladung.inviterName }, eingeloggt: !!locals.user };
68
+ return { fehler: null, einladung: { inviterName: einladung.inviterName }, eingeloggt: !!locals.user, authUi };
64
69
  }
65
70
  /** `/home`: login required; the user, the session (never its token), the providers and whether the user may invite. */
66
71
  export function homeLayoutLoad({ locals }) {
67
72
  if (!locals.session || !locals.user)
68
73
  throw redirect(302, "/auth/login");
69
- const { providers, signupMode } = registry();
74
+ const ctx = contextOf(locals);
70
75
  const user = { ...locals.user };
71
76
  // Never the session token itself: page data ends up in the HTML and in client JS
72
77
  const session = { id: locals.session.id, expiresAt: locals.session.expiresAt };
73
78
  // Whether the Invitations tab shows: the signup mode decides who creates Einladungen
74
- return { user, session, providers, einladen: mayInvite(signupMode, isAdmin(locals.user)) };
79
+ return { user, session, providers: ctx.providers, einladen: mayInvite(ctx.signupMode, isAdmin(locals.user)), authUi: uiData(ctx) };
75
80
  }
76
81
  /** `/home/manage_user` (the Admin option): admins only. */
77
82
  export function manageUserLoad({ locals }) {
@@ -1,4 +1,6 @@
1
- export declare const EinladungRepo: {
1
+ import type { AuthContext } from "../context.js";
2
+ /** The Einladungen of the app the context belongs to. */
3
+ export declare function einladungRepo(ctx: AuthContext): {
2
4
  insert(row: {
3
5
  id: string;
4
6
  tokenHash: string;
@@ -34,3 +36,4 @@ export declare const EinladungRepo: {
34
36
  /** Deletes an open Einladung of the user (or any, with `userId` null for the admin); false when there was none. */
35
37
  deleteOpen(id: string, userId: string | null): Promise<boolean>;
36
38
  };
39
+ export type EinladungRepo = ReturnType<typeof einladungRepo>;
@@ -1,74 +1,67 @@
1
1
  import { and, desc, eq, gt, isNull } from "drizzle-orm";
2
- import { registry } from "../registry.js";
3
- export const EinladungRepo = {
4
- async insert(row) {
5
- const { db, schema } = registry();
6
- await db.insert(schema.einladung).values(row);
7
- },
8
- /** The Einladung with the name of its inviter, by the hash of its token. */
9
- async getByTokenHash(tokenHash) {
10
- const { db, schema } = registry();
11
- const { einladung, user } = schema;
12
- const rows = await db
13
- .select({
14
- id: einladung.id,
15
- createdBy: einladung.createdBy,
16
- inviterName: user.name,
17
- expiresAt: einladung.expiresAt,
18
- acceptedAt: einladung.acceptedAt,
19
- })
20
- .from(einladung)
21
- .innerJoin(user, eq(einladung.createdBy, user.id))
22
- .where(eq(einladung.tokenHash, tokenHash))
23
- .limit(1);
24
- return rows[0] ?? null;
25
- },
26
- /** Marks the Einladung as accepted, unless it already was or has expired; false when it couldn't be taken. */
27
- async accept(id, userId, now) {
28
- const { db, schema } = registry();
29
- const { einladung } = schema;
30
- const rows = await db
31
- .update(einladung)
32
- .set({ acceptedAt: now, acceptedBy: userId })
33
- .where(and(eq(einladung.id, id), isNull(einladung.acceptedAt), gt(einladung.expiresAt, now)))
34
- .returning({ id: einladung.id });
35
- return rows.length === 1;
36
- },
37
- /** The user's Einladungen that are still open (not accepted, not expired), newest first. */
38
- async listOpenBy(userId, now) {
39
- const { db, schema } = registry();
40
- const { einladung } = schema;
41
- return await db
42
- .select({ id: einladung.id, email: einladung.email, expiresAt: einladung.expiresAt, createdAt: einladung.createdAt })
43
- .from(einladung)
44
- .where(and(eq(einladung.createdBy, userId), isNull(einladung.acceptedAt), gt(einladung.expiresAt, now)))
45
- .orderBy(desc(einladung.createdAt));
46
- },
47
- /** All open Einladungen with their inviter, for the admin. */
48
- async listAllOpen(now) {
49
- const { db, schema } = registry();
50
- const { einladung, user } = schema;
51
- return await db
52
- .select({
53
- id: einladung.id,
54
- email: einladung.email,
55
- inviterName: user.name,
56
- expiresAt: einladung.expiresAt,
57
- createdAt: einladung.createdAt,
58
- })
59
- .from(einladung)
60
- .innerJoin(user, eq(einladung.createdBy, user.id))
61
- .where(and(isNull(einladung.acceptedAt), gt(einladung.expiresAt, now)))
62
- .orderBy(desc(einladung.createdAt));
63
- },
64
- /** Deletes an open Einladung of the user (or any, with `userId` null for the admin); false when there was none. */
65
- async deleteOpen(id, userId) {
66
- const { db, schema } = registry();
67
- const { einladung } = schema;
68
- const rows = await db
69
- .delete(einladung)
70
- .where(and(eq(einladung.id, id), isNull(einladung.acceptedAt), userId ? eq(einladung.createdBy, userId) : undefined))
71
- .returning({ id: einladung.id });
72
- return rows.length === 1;
73
- },
74
- };
2
+ /** The Einladungen of the app the context belongs to. */
3
+ export function einladungRepo(ctx) {
4
+ const { db } = ctx;
5
+ const { einladung, user } = ctx.schema;
6
+ return {
7
+ async insert(row) {
8
+ await db.insert(einladung).values(row);
9
+ },
10
+ /** The Einladung with the name of its inviter, by the hash of its token. */
11
+ async getByTokenHash(tokenHash) {
12
+ const rows = await db
13
+ .select({
14
+ id: einladung.id,
15
+ createdBy: einladung.createdBy,
16
+ inviterName: user.name,
17
+ expiresAt: einladung.expiresAt,
18
+ acceptedAt: einladung.acceptedAt,
19
+ })
20
+ .from(einladung)
21
+ .innerJoin(user, eq(einladung.createdBy, user.id))
22
+ .where(eq(einladung.tokenHash, tokenHash))
23
+ .limit(1);
24
+ return rows[0] ?? null;
25
+ },
26
+ /** Marks the Einladung as accepted, unless it already was or has expired; false when it couldn't be taken. */
27
+ async accept(id, userId, now) {
28
+ const rows = await db
29
+ .update(einladung)
30
+ .set({ acceptedAt: now, acceptedBy: userId })
31
+ .where(and(eq(einladung.id, id), isNull(einladung.acceptedAt), gt(einladung.expiresAt, now)))
32
+ .returning({ id: einladung.id });
33
+ return rows.length === 1;
34
+ },
35
+ /** The user's Einladungen that are still open (not accepted, not expired), newest first. */
36
+ async listOpenBy(userId, now) {
37
+ return await db
38
+ .select({ id: einladung.id, email: einladung.email, expiresAt: einladung.expiresAt, createdAt: einladung.createdAt })
39
+ .from(einladung)
40
+ .where(and(eq(einladung.createdBy, userId), isNull(einladung.acceptedAt), gt(einladung.expiresAt, now)))
41
+ .orderBy(desc(einladung.createdAt));
42
+ },
43
+ /** All open Einladungen with their inviter, for the admin. */
44
+ async listAllOpen(now) {
45
+ return await db
46
+ .select({
47
+ id: einladung.id,
48
+ email: einladung.email,
49
+ inviterName: user.name,
50
+ expiresAt: einladung.expiresAt,
51
+ createdAt: einladung.createdAt,
52
+ })
53
+ .from(einladung)
54
+ .innerJoin(user, eq(einladung.createdBy, user.id))
55
+ .where(and(isNull(einladung.acceptedAt), gt(einladung.expiresAt, now)))
56
+ .orderBy(desc(einladung.createdAt));
57
+ },
58
+ /** Deletes an open Einladung of the user (or any, with `userId` null for the admin); false when there was none. */
59
+ async deleteOpen(id, userId) {
60
+ const rows = await db
61
+ .delete(einladung)
62
+ .where(and(eq(einladung.id, id), isNull(einladung.acceptedAt), userId ? eq(einladung.createdBy, userId) : undefined))
63
+ .returning({ id: einladung.id });
64
+ return rows.length === 1;
65
+ },
66
+ };
67
+ }
@@ -1,5 +1,7 @@
1
+ import type { AuthContext } from "../context.js";
1
2
  import type { AppUserClient, UserInsert } from "../../types.js";
2
- export declare const UserRepo: {
3
+ /** The accounts of the app the context belongs to. */
4
+ export declare function userRepo(ctx: AuthContext): {
3
5
  /** Every account, oldest first (the admin's list). */
4
6
  getAllUsers(): Promise<AppUserClient[]>;
5
7
  getUserById(id: string): Promise<AppUserClient | null>;
@@ -20,3 +22,4 @@ export declare const UserRepo: {
20
22
  */
21
23
  kontoLoeschen(userId: string): Promise<void>;
22
24
  };
25
+ export type UserRepo = ReturnType<typeof userRepo>;
@@ -1,9 +1,12 @@
1
1
  import { asc, count, eq } from "drizzle-orm";
2
- import { registry } from "../registry.js";
3
2
  import { nameFromEmail } from "../../util/names.js";
4
- /** The columns the client gets; the admin ones exist only with the admin option. */
5
- function selection() {
6
- const { user } = registry().schema;
3
+ const rows = (list) => list;
4
+ /** The accounts of the app the context belongs to. */
5
+ export function userRepo(ctx) {
6
+ const { db, admin } = ctx;
7
+ // Every column on the type; the admin ones are only read with the option (`ctx.admin`)
8
+ const { user, account } = ctx.schema;
9
+ /** The columns the client gets; the admin ones exist only with the admin option. */
7
10
  const base = {
8
11
  id: user.id,
9
12
  email: user.email,
@@ -14,57 +17,46 @@ function selection() {
14
17
  emailVerified: user.emailVerified,
15
18
  image: user.image,
16
19
  };
17
- return registry().admin ? { ...base, role: user.role, banned: user.banned, banReason: user.banReason, banExpires: user.banExpires } : base;
20
+ const selection = admin ? { ...base, role: user.role, banned: user.banned, banReason: user.banReason, banExpires: user.banExpires } : base;
21
+ const repo = {
22
+ /** Every account, oldest first (the admin's list). */
23
+ async getAllUsers() {
24
+ return rows(await db.select(selection).from(user).orderBy(asc(user.createdAt)));
25
+ },
26
+ async getUserById(id) {
27
+ const list = rows(await db.select(selection).from(user).where(eq(user.id, id)).limit(1));
28
+ return list[0] ?? null;
29
+ },
30
+ async getUserFromEmail(email) {
31
+ const list = rows(await db.select(selection).from(user).where(eq(user.email, email)).limit(1));
32
+ return list[0] ?? null;
33
+ },
34
+ async countUsers() {
35
+ const [row] = await db.select({ n: count() }).from(user);
36
+ return row.n;
37
+ },
38
+ /** Admin option only. */
39
+ async setRole(userId, role) {
40
+ await db.update(user).set({ role }).where(eq(user.id, userId));
41
+ },
42
+ /** Creates a user outside better-auth's signup (tests, scripts); the first one becomes admin like any other. */
43
+ async insertUser(data) {
44
+ if (!data.name && data.email)
45
+ data.name = nameFromEmail(data.email);
46
+ await db.insert(user).values(data);
47
+ if (admin && data.id && (await repo.countUsers()) === 1)
48
+ await repo.setRole(data.id, "admin");
49
+ },
50
+ async getUserAccounts(userId) {
51
+ return await db.select({ id: account.id, providerId: account.providerId, updatedAt: account.updatedAt }).from(account).where(eq(account.userId, userId));
52
+ },
53
+ /**
54
+ * Deletes the Konto: the user with everything that belongs to them (the foreign keys of every user-owned table
55
+ * cascade, so a Product's own tables reference `user.id` with `onDelete: "cascade"`).
56
+ */
57
+ async kontoLoeschen(userId) {
58
+ await db.delete(user).where(eq(user.id, userId));
59
+ },
60
+ };
61
+ return repo;
18
62
  }
19
- const rows = (list) => list;
20
- export const UserRepo = {
21
- /** Every account, oldest first (the admin's list). */
22
- async getAllUsers() {
23
- const { db, schema } = registry();
24
- return rows(await db.select(selection()).from(schema.user).orderBy(asc(schema.user.createdAt)));
25
- },
26
- async getUserById(id) {
27
- const { db, schema } = registry();
28
- const list = rows(await db.select(selection()).from(schema.user).where(eq(schema.user.id, id)).limit(1));
29
- return list[0] ?? null;
30
- },
31
- async getUserFromEmail(email) {
32
- const { db, schema } = registry();
33
- const list = rows(await db.select(selection()).from(schema.user).where(eq(schema.user.email, email)).limit(1));
34
- return list[0] ?? null;
35
- },
36
- async countUsers() {
37
- const { db, schema } = registry();
38
- const [row] = await db.select({ n: count() }).from(schema.user);
39
- return row.n;
40
- },
41
- /** Admin option only. */
42
- async setRole(userId, role) {
43
- const { db, schema } = registry();
44
- await db.update(schema.user).set({ role }).where(eq(schema.user.id, userId));
45
- },
46
- /** Creates a user outside better-auth's signup (tests, scripts); the first one becomes admin like any other. */
47
- async insertUser(data) {
48
- const { db, schema, admin } = registry();
49
- if (!data.name && data.email)
50
- data.name = nameFromEmail(data.email);
51
- await db.insert(schema.user).values(data);
52
- if (admin && data.id && (await UserRepo.countUsers()) === 1)
53
- await UserRepo.setRole(data.id, "admin");
54
- },
55
- async getUserAccounts(userId) {
56
- const { db, schema } = registry();
57
- return await db
58
- .select({ id: schema.account.id, providerId: schema.account.providerId, updatedAt: schema.account.updatedAt })
59
- .from(schema.account)
60
- .where(eq(schema.account.userId, userId));
61
- },
62
- /**
63
- * Deletes the Konto: the user with everything that belongs to them (the foreign keys of every user-owned table
64
- * cascade, so a Product's own tables reference `user.id` with `onDelete: "cascade"`).
65
- */
66
- async kontoLoeschen(userId) {
67
- const { db, schema } = registry();
68
- await db.delete(schema.user).where(eq(schema.user.id, userId));
69
- },
70
- };
@@ -1,13 +1,16 @@
1
1
  import { type Result } from "neverthrow";
2
+ import type { AuthContext } from "../context.js";
2
3
  import type { EinladungInfo, SignupMode, SignupState } from "../../types.js";
3
- export declare const EINLADUNG_GUELTIG_TAGE = 14;
4
+ import { EINLADUNG_GUELTIG_TAGE } from "../../util/signup.js";
5
+ export { EINLADUNG_GUELTIG_TAGE };
4
6
  /** The cookie that carries an Einladung's token from its link through signup (email, Google or GitHub). */
5
7
  export declare const EINLADUNG_COOKIE = "einladung";
6
8
  export type EinladungError = {
7
9
  kind: "unbekannt" | "abgelaufen" | "angenommen";
8
10
  message: string;
9
11
  };
10
- export declare const EinladungService: {
12
+ /** The Einladungen of the app the context belongs to. */
13
+ export declare function einladungService(ctx: AuthContext): {
11
14
  /** Creates an Einladung and returns its token, shown only this once. */
12
15
  erstellen(userId: string, input?: {
13
16
  email?: string | null;
@@ -36,3 +39,4 @@ export declare const EinladungService: {
36
39
  /** Withdraws an open Einladung of the user, or any open one when `userId` is null (the admin). */
37
40
  zurueckziehen(userId: string | null, einladungId: string): Promise<Result<void, EinladungError>>;
38
41
  };
42
+ export type EinladungService = ReturnType<typeof einladungService>;
@@ -5,11 +5,10 @@
5
5
  import { createHash, randomBytes } from "node:crypto";
6
6
  import { err, ok } from "neverthrow";
7
7
  import { m } from "../../messages.js";
8
- import { registry } from "../registry.js";
9
- import { EinladungRepo } from "../repositories/EinladungRepo.js";
10
- import { UserRepo } from "../repositories/UserRepo.js";
11
- import { signupState } from "../../util/signup.js";
12
- export const EINLADUNG_GUELTIG_TAGE = 14;
8
+ import { einladungRepo } from "../repositories/EinladungRepo.js";
9
+ import { userRepo } from "../repositories/UserRepo.js";
10
+ import { EINLADUNG_GUELTIG_TAGE, signupState } from "../../util/signup.js";
11
+ export { EINLADUNG_GUELTIG_TAGE };
13
12
  /** The cookie that carries an Einladung's token from its link through signup (email, Google or GitHub). */
14
13
  export const EINLADUNG_COOKIE = "einladung";
15
14
  const hash = (token) => createHash("sha256").update(token).digest("hex");
@@ -18,49 +17,55 @@ const fehler = {
18
17
  abgelaufen: () => ({ kind: "abgelaufen", message: m().einladungAbgelaufen }),
19
18
  angenommen: () => ({ kind: "angenommen", message: m().einladungAngenommen }),
20
19
  };
21
- export const EinladungService = {
22
- /** Creates an Einladung and returns its token, shown only this once. */
23
- async erstellen(userId, input = {}, now = new Date()) {
24
- const token = randomBytes(24).toString("base64url");
25
- const id = crypto.randomUUID();
26
- const expiresAt = new Date(now.getTime() + EINLADUNG_GUELTIG_TAGE * 24 * 60 * 60 * 1000);
27
- await EinladungRepo.insert({ id, tokenHash: hash(token), createdBy: userId, email: input.email ?? null, expiresAt });
28
- return { id, token, expiresAt };
29
- },
30
- /** What the link offers, if it can still be used. */
31
- async pruefen(token, now = new Date()) {
32
- const row = await EinladungRepo.getByTokenHash(hash(token));
33
- if (!row)
34
- return err(fehler.unbekannt());
35
- if (row.acceptedAt)
36
- return err(fehler.angenommen());
37
- if (row.expiresAt <= now)
38
- return err(fehler.abgelaufen());
39
- return ok({ id: row.id, inviterName: row.inviterName, expiresAt: row.expiresAt });
40
- },
41
- /**
42
- * Whether someone may create an account now, and if not, why (the signup mode decides, `signupState`); whether it
43
- * would be the very first account; and the Einladung the token stands for, if it is usable. One count, one lookup.
44
- */
45
- async signup(token, options = {}) {
46
- const { now = new Date(), mode = registry().signupMode, mail = registry().mailer.enabled } = options;
47
- const accounts = await UserRepo.countUsers();
48
- const geprueft = token ? await EinladungService.pruefen(token, now) : null;
49
- const einladung = geprueft?.isOk() ? geprueft.value : null;
50
- const einladungUsable = accounts > 0 && mode !== "open" && einladung !== null;
51
- return { state: signupState({ mode, accounts, einladungUsable, mail }), firstAccount: accounts === 0, einladung };
52
- },
53
- /** Uses the Einladung for the new user: marks it as taken. */
54
- async annehmen(userId, token, now = new Date()) {
55
- const info = await EinladungService.pruefen(token, now);
56
- if (info.isErr())
57
- return err(info.error);
58
- if (!(await EinladungRepo.accept(info.value.id, userId, now)))
59
- return err(fehler.angenommen());
60
- return ok(undefined);
61
- },
62
- /** Withdraws an open Einladung of the user, or any open one when `userId` is null (the admin). */
63
- async zurueckziehen(userId, einladungId) {
64
- return (await EinladungRepo.deleteOpen(einladungId, userId)) ? ok(undefined) : err(fehler.unbekannt());
65
- },
66
- };
20
+ /** The Einladungen of the app the context belongs to. */
21
+ export function einladungService(ctx) {
22
+ const repo = einladungRepo(ctx);
23
+ const users = userRepo(ctx);
24
+ const service = {
25
+ /** Creates an Einladung and returns its token, shown only this once. */
26
+ async erstellen(userId, input = {}, now = new Date()) {
27
+ const token = randomBytes(24).toString("base64url");
28
+ const id = crypto.randomUUID();
29
+ const expiresAt = new Date(now.getTime() + EINLADUNG_GUELTIG_TAGE * 24 * 60 * 60 * 1000);
30
+ await repo.insert({ id, tokenHash: hash(token), createdBy: userId, email: input.email ?? null, expiresAt });
31
+ return { id, token, expiresAt };
32
+ },
33
+ /** What the link offers, if it can still be used. */
34
+ async pruefen(token, now = new Date()) {
35
+ const row = await repo.getByTokenHash(hash(token));
36
+ if (!row)
37
+ return err(fehler.unbekannt());
38
+ if (row.acceptedAt)
39
+ return err(fehler.angenommen());
40
+ if (row.expiresAt <= now)
41
+ return err(fehler.abgelaufen());
42
+ return ok({ id: row.id, inviterName: row.inviterName, expiresAt: row.expiresAt });
43
+ },
44
+ /**
45
+ * Whether someone may create an account now, and if not, why (the signup mode decides, `signupState`); whether it
46
+ * would be the very first account; and the Einladung the token stands for, if it is usable. One count, one lookup.
47
+ */
48
+ async signup(token, options = {}) {
49
+ const { now = new Date(), mode = ctx.signupMode, mail = ctx.mailer.enabled } = options;
50
+ const accounts = await users.countUsers();
51
+ const geprueft = token ? await service.pruefen(token, now) : null;
52
+ const einladung = geprueft?.isOk() ? geprueft.value : null;
53
+ const einladungUsable = accounts > 0 && mode !== "open" && einladung !== null;
54
+ return { state: signupState({ mode, accounts, einladungUsable, mail }), firstAccount: accounts === 0, einladung };
55
+ },
56
+ /** Uses the Einladung for the new user: marks it as taken. */
57
+ async annehmen(userId, token, now = new Date()) {
58
+ const info = await service.pruefen(token, now);
59
+ if (info.isErr())
60
+ return err(info.error);
61
+ if (!(await repo.accept(info.value.id, userId, now)))
62
+ return err(fehler.angenommen());
63
+ return ok(undefined);
64
+ },
65
+ /** Withdraws an open Einladung of the user, or any open one when `userId` is null (the admin). */
66
+ async zurueckziehen(userId, einladungId) {
67
+ return (await repo.deleteOpen(einladungId, userId)) ? ok(undefined) : err(fehler.unbekannt());
68
+ },
69
+ };
70
+ return service;
71
+ }
@@ -1,12 +1,16 @@
1
- export declare function sendPasswordResetEmail(user: {
2
- email: string;
3
- name: string;
4
- }, url: string): Promise<void>;
5
- export declare function sendVerifyEmail(user: {
6
- email: string;
7
- name: string;
8
- }, url: string): Promise<void>;
9
- export declare function sendEinladungEmail(inviterName: string, to: string, einladung: {
10
- link: string;
11
- expiresAt: Date;
12
- }, locale: string): Promise<void>;
1
+ import type { AuthContext } from "../context.js";
2
+ export declare function authMails(ctx: AuthContext): {
3
+ passwordReset(user: {
4
+ email: string;
5
+ name: string;
6
+ }, url: string): Promise<void>;
7
+ verify(user: {
8
+ email: string;
9
+ name: string;
10
+ }, url: string): Promise<void>;
11
+ einladung(inviterName: string, to: string, einladung: {
12
+ link: string;
13
+ expiresAt: Date;
14
+ }): Promise<void>;
15
+ };
16
+ export type AuthMails = ReturnType<typeof authMails>;
@@ -1,40 +1,38 @@
1
- /** The three mails of accounts, through the Product's mailer (`@vonall/mail`). */
1
+ /** The three mails of accounts, through the Product's mailer (`@vonall/mail`), in the app's name and locale. */
2
2
  import { renderMail } from "@vonall/mail";
3
3
  import { m } from "../../messages.js";
4
- import { registry } from "../registry.js";
5
4
  import InvitationEmail from "../../templates/InvitationEmail.svelte";
6
5
  import ResetPasswordEmail from "../../templates/ResetPasswordEmail.svelte";
7
6
  import VerifyEmailEmail from "../../templates/VerifyEmailEmail.svelte";
8
- function footer() {
9
- const { appName, origin } = registry();
10
- return { appName, appLink: origin };
11
- }
12
- const footerText = (appName) => `\n---\n${appName}\n`;
13
- export async function sendPasswordResetEmail(user, url) {
14
- const { mailer, appName } = registry();
15
- await mailer.send({
16
- to: user.email,
17
- subject: m().resetSubject(appName),
18
- text: m().resetText(user.name, appName, url) + footerText(appName),
19
- html: renderMail(ResetPasswordEmail, { recipientName: user.name, link: url, ...footer() }),
20
- });
21
- }
22
- export async function sendVerifyEmail(user, url) {
23
- const { mailer, appName } = registry();
24
- await mailer.send({
25
- to: user.email,
26
- subject: m().verifySubject(appName),
27
- text: m().verifyText(user.name, appName, url) + footerText(appName),
28
- html: renderMail(VerifyEmailEmail, { recipientName: user.name, link: url, ...footer() }),
29
- });
30
- }
31
- export async function sendEinladungEmail(inviterName, to, einladung, locale) {
32
- const { mailer, appName } = registry();
33
- const validUntil = new Intl.DateTimeFormat(locale, { dateStyle: "long" }).format(einladung.expiresAt);
34
- await mailer.send({
35
- to,
36
- subject: m().einladungSubject(inviterName, appName),
37
- text: m().einladungText(inviterName, appName, einladung.link, validUntil) + footerText(appName),
38
- html: renderMail(InvitationEmail, { inviterName, link: einladung.link, validUntil, ...footer() }),
39
- });
7
+ export function authMails(ctx) {
8
+ const { mailer, appName, origin, locale } = ctx;
9
+ const footer = { appName, appLink: origin };
10
+ const footerText = `\n---\n${appName}\n`;
11
+ return {
12
+ async passwordReset(user, url) {
13
+ await mailer.send({
14
+ to: user.email,
15
+ subject: m().resetSubject(appName),
16
+ text: m().resetText(user.name, appName, url) + footerText,
17
+ html: renderMail(ResetPasswordEmail, { recipientName: user.name, link: url, ...footer }),
18
+ });
19
+ },
20
+ async verify(user, url) {
21
+ await mailer.send({
22
+ to: user.email,
23
+ subject: m().verifySubject(appName),
24
+ text: m().verifyText(user.name, appName, url) + footerText,
25
+ html: renderMail(VerifyEmailEmail, { recipientName: user.name, link: url, ...footer }),
26
+ });
27
+ },
28
+ async einladung(inviterName, to, einladung) {
29
+ const validUntil = new Intl.DateTimeFormat(locale, { dateStyle: "long" }).format(einladung.expiresAt);
30
+ await mailer.send({
31
+ to,
32
+ subject: m().einladungSubject(inviterName, appName),
33
+ text: m().einladungText(inviterName, appName, einladung.link, validUntil) + footerText,
34
+ html: renderMail(InvitationEmail, { inviterName, link: einladung.link, validUntil, ...footer }),
35
+ });
36
+ },
37
+ };
40
38
  }