@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.
- package/CHANGELOG.md +18 -0
- package/README.md +14 -7
- package/dist/index.d.ts +1 -1
- package/dist/messages.d.ts +460 -16
- package/dist/messages.js +4 -14
- package/dist/remote/app_user.remote.d.ts +2 -2
- package/dist/remote/app_user.remote.js +13 -13
- package/dist/remote/einladung.remote.js +15 -12
- package/dist/server/{registry.d.ts → context.d.ts} +13 -16
- package/dist/server/context.js +22 -0
- package/dist/server/create.d.ts +27 -22
- package/dist/server/create.js +50 -33
- package/dist/server/index.d.ts +7 -7
- package/dist/server/index.js +6 -6
- package/dist/server/loads.d.ts +14 -2
- package/dist/server/loads.js +19 -14
- package/dist/server/repositories/EinladungRepo.d.ts +4 -1
- package/dist/server/repositories/EinladungRepo.js +66 -73
- package/dist/server/repositories/UserRepo.d.ts +4 -1
- package/dist/server/repositories/UserRepo.js +49 -57
- package/dist/server/service/einladung.service.d.ts +6 -2
- package/dist/server/service/einladung.service.js +56 -51
- package/dist/server/service/mail.d.ts +16 -12
- package/dist/server/service/mail.js +32 -34
- package/dist/testing/index.d.ts +6 -8
- package/dist/testing/index.js +16 -19
- package/dist/ui/AdminView.svelte +2 -1
- package/dist/ui/InvitationsTab.svelte +2 -1
- package/dist/ui/LoginForm.svelte +1 -1
- package/dist/ui/ResetPasswordForm.svelte +2 -2
- package/dist/ui/context.d.ts +6 -7
- package/dist/ui/context.js +15 -9
- package/dist/util/signup.d.ts +2 -0
- package/dist/util/signup.js +2 -0
- package/package.json +3 -2
- package/dist/server/registry.js +0 -22
package/dist/server/loads.js
CHANGED
|
@@ -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 {
|
|
8
|
-
import { EINLADUNG_COOKIE,
|
|
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
|
|
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 =
|
|
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
|
|
41
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
3
|
-
export
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
|
9
|
-
const { appName, origin } =
|
|
10
|
-
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
}
|