@vonall/auth 0.0.0-stage → 0.1.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 +12 -0
- package/LICENSE +21 -0
- package/README.md +33 -2
- package/dist/client/index.d.ts +17 -0
- package/dist/client/index.js +18 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +10 -0
- package/dist/messages.d.ts +50 -0
- package/dist/messages.js +88 -0
- package/dist/permissions.d.ts +48 -0
- package/dist/permissions.js +17 -0
- package/dist/remote/app_user.remote.d.ts +66 -0
- package/dist/remote/app_user.remote.js +91 -0
- package/dist/remote/einladung.remote.d.ts +27 -0
- package/dist/remote/einladung.remote.js +56 -0
- package/dist/remote/index.d.ts +2 -0
- package/dist/remote/index.js +2 -0
- package/dist/schema/index.d.ts +3683 -0
- package/dist/schema/index.js +265 -0
- package/dist/server/create.d.ts +87 -0
- package/dist/server/create.js +158 -0
- package/dist/server/crypto.d.ts +5 -0
- package/dist/server/crypto.js +15 -0
- package/dist/server/helpers.d.ts +18 -0
- package/dist/server/helpers.js +62 -0
- package/dist/server/index.d.ts +9 -0
- package/dist/server/index.js +9 -0
- package/dist/server/loads.d.ts +57 -0
- package/dist/server/loads.js +83 -0
- package/dist/server/registry.d.ts +39 -0
- package/dist/server/registry.js +15 -0
- package/dist/server/repositories/EinladungRepo.d.ts +36 -0
- package/dist/server/repositories/EinladungRepo.js +74 -0
- package/dist/server/repositories/UserRepo.d.ts +19 -0
- package/dist/server/repositories/UserRepo.js +67 -0
- package/dist/server/service/einladung.service.d.ts +48 -0
- package/dist/server/service/einladung.service.js +76 -0
- package/dist/server/service/konto.service.d.ts +7 -0
- package/dist/server/service/konto.service.js +11 -0
- package/dist/server/service/mail.d.ts +12 -0
- package/dist/server/service/mail.js +40 -0
- package/dist/templates/InvitationEmail.svelte +18 -0
- package/dist/templates/InvitationEmail.svelte.d.ts +10 -0
- package/dist/templates/ResetPasswordEmail.svelte +16 -0
- package/dist/templates/ResetPasswordEmail.svelte.d.ts +9 -0
- package/dist/templates/VerifyEmailEmail.svelte +16 -0
- package/dist/templates/VerifyEmailEmail.svelte.d.ts +9 -0
- package/dist/testing/index.d.ts +6 -0
- package/dist/testing/index.js +26 -0
- package/dist/types.d.ts +38 -0
- package/dist/types.js +21 -0
- package/dist/util/fresh.d.ts +11 -0
- package/dist/util/fresh.js +20 -0
- package/dist/util/messageOf.d.ts +5 -0
- package/dist/util/messageOf.js +12 -0
- package/dist/util/names.d.ts +10 -0
- package/dist/util/names.js +19 -0
- package/dist/util/sameSite.d.ts +5 -0
- package/dist/util/sameSite.js +15 -0
- package/dist/util/signup.d.ts +15 -0
- package/dist/util/signup.js +23 -0
- package/package.json +96 -3
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
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";`
|
|
4
|
+
*/
|
|
5
|
+
import { type ServerLoadEvent } from "@sveltejs/kit";
|
|
6
|
+
type Event = Pick<ServerLoadEvent, "locals" | "url" | "request" | "cookies" | "params">;
|
|
7
|
+
/** `/auth`: which social logins have keys, and whether a password reset mail can be sent (the link is hidden otherwise). */
|
|
8
|
+
export declare function authLayoutLoad(): Promise<{
|
|
9
|
+
providers: {
|
|
10
|
+
github: boolean;
|
|
11
|
+
google: boolean;
|
|
12
|
+
};
|
|
13
|
+
mail: boolean;
|
|
14
|
+
}>;
|
|
15
|
+
/** `/auth/login`. */
|
|
16
|
+
export declare function loginLoad({ locals, url, request }: Event): Promise<{}>;
|
|
17
|
+
/** `/auth/signup`: the signup mode decides; the very first account (the admin) needs nothing. */
|
|
18
|
+
export declare function signupLoad({ locals, cookies }: Event): Promise<{
|
|
19
|
+
state: "allowed" | "needs_einladung" | "no_mail";
|
|
20
|
+
mode: "invite" | "open" | "admin";
|
|
21
|
+
einladung: {
|
|
22
|
+
inviterName: string;
|
|
23
|
+
} | null;
|
|
24
|
+
}>;
|
|
25
|
+
/**
|
|
26
|
+
* `/einladung/[token]`: the link of an Einladung. A valid one is remembered in a cookie so it survives signup with
|
|
27
|
+
* email, Google or GitHub (the signup hook takes it from there).
|
|
28
|
+
*/
|
|
29
|
+
export declare function einladungLoad({ params, cookies, locals, url }: Event): Promise<{
|
|
30
|
+
fehler: string;
|
|
31
|
+
einladung: null;
|
|
32
|
+
eingeloggt: boolean;
|
|
33
|
+
} | {
|
|
34
|
+
fehler: null;
|
|
35
|
+
einladung: {
|
|
36
|
+
inviterName: string;
|
|
37
|
+
};
|
|
38
|
+
eingeloggt: boolean;
|
|
39
|
+
}>;
|
|
40
|
+
/** `/home`: login required; the user, the session (never its token), the providers and whether the user may invite. */
|
|
41
|
+
export declare function homeLayoutLoad({ locals }: Event): {
|
|
42
|
+
user: any;
|
|
43
|
+
session: {
|
|
44
|
+
id: any;
|
|
45
|
+
expiresAt: any;
|
|
46
|
+
};
|
|
47
|
+
providers: {
|
|
48
|
+
github: boolean;
|
|
49
|
+
google: boolean;
|
|
50
|
+
};
|
|
51
|
+
einladen: boolean;
|
|
52
|
+
};
|
|
53
|
+
/** `/home/manage_user` (the Admin option): admins only. */
|
|
54
|
+
export declare function manageUserLoad({ locals }: Event): {
|
|
55
|
+
currentUserId: any;
|
|
56
|
+
};
|
|
57
|
+
export {};
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
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";`
|
|
4
|
+
*/
|
|
5
|
+
import { error, redirect } from "@sveltejs/kit";
|
|
6
|
+
import { m } from "../messages.js";
|
|
7
|
+
import { registry } from "./registry.js";
|
|
8
|
+
import { EINLADUNG_COOKIE, EinladungService } from "./service/einladung.service.js";
|
|
9
|
+
import { isStale, SESSION_NOT_FRESH } from "../util/fresh.js";
|
|
10
|
+
import { sameSitePath } from "../util/sameSite.js";
|
|
11
|
+
import { isAdmin, mayInvite } from "../util/signup.js";
|
|
12
|
+
/** `/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 };
|
|
16
|
+
}
|
|
17
|
+
/** `/auth/login`. */
|
|
18
|
+
export async function loginLoad({ locals, url, request }) {
|
|
19
|
+
const { auth } = registry();
|
|
20
|
+
// An action refused the session as too old to be trusted (reauthPath): sign it out, so the form shows. Only a session
|
|
21
|
+
// that really is that old, so a link can't log anyone out.
|
|
22
|
+
if (locals.session && url.searchParams.get("error") === SESSION_NOT_FRESH.toLowerCase()) {
|
|
23
|
+
const { freshAge } = (await auth.$context).sessionConfig;
|
|
24
|
+
if (isStale(new Date(locals.session.createdAt), freshAge)) {
|
|
25
|
+
await auth.api.signOut({ headers: request.headers });
|
|
26
|
+
locals.session = null;
|
|
27
|
+
locals.user = null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
// An agent authorisation (signed query) may ask to log in again even with a session; then the form shows
|
|
31
|
+
if (locals.session && !url.searchParams.has("sig")) {
|
|
32
|
+
throw redirect(302, sameSitePath(url.searchParams.get("redirectTo"), url.origin) ?? "/home");
|
|
33
|
+
}
|
|
34
|
+
return {};
|
|
35
|
+
}
|
|
36
|
+
/** `/auth/signup`: the signup mode decides; the very first account (the admin) needs nothing. */
|
|
37
|
+
export async function signupLoad({ locals, cookies }) {
|
|
38
|
+
if (locals.session)
|
|
39
|
+
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
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* `/einladung/[token]`: the link of an Einladung. A valid one is remembered in a cookie so it survives signup with
|
|
45
|
+
* email, Google or GitHub (the signup hook takes it from there).
|
|
46
|
+
*/
|
|
47
|
+
export async function einladungLoad({ params, cookies, locals, url }) {
|
|
48
|
+
const token = params.token;
|
|
49
|
+
if (!token)
|
|
50
|
+
throw error(404, { message: m().einladungUnbekannt, id: "unbekannt", timestamp: new Date().toISOString() });
|
|
51
|
+
const pruefung = await EinladungService.pruefen(token);
|
|
52
|
+
if (pruefung.isErr())
|
|
53
|
+
return { fehler: pruefung.error.message, einladung: null, eingeloggt: !!locals.user };
|
|
54
|
+
const einladung = pruefung.value;
|
|
55
|
+
cookies.set(EINLADUNG_COOKIE, token, {
|
|
56
|
+
path: "/",
|
|
57
|
+
httpOnly: true,
|
|
58
|
+
sameSite: "lax",
|
|
59
|
+
// Secure only over HTTPS: WebKit drops Secure cookies on http://localhost (the e2e preview)
|
|
60
|
+
secure: url.protocol === "https:",
|
|
61
|
+
expires: einladung.expiresAt,
|
|
62
|
+
});
|
|
63
|
+
return { fehler: null, einladung: { inviterName: einladung.inviterName }, eingeloggt: !!locals.user };
|
|
64
|
+
}
|
|
65
|
+
/** `/home`: login required; the user, the session (never its token), the providers and whether the user may invite. */
|
|
66
|
+
export function homeLayoutLoad({ locals }) {
|
|
67
|
+
if (!locals.session || !locals.user)
|
|
68
|
+
throw redirect(302, "/auth/login");
|
|
69
|
+
const { providers, signupMode } = registry();
|
|
70
|
+
const user = { ...locals.user };
|
|
71
|
+
// Never the session token itself: page data ends up in the HTML and in client JS
|
|
72
|
+
const session = { id: locals.session.id, expiresAt: locals.session.expiresAt };
|
|
73
|
+
// Whether the Invitations tab shows: the signup mode decides who creates Einladungen
|
|
74
|
+
return { user, session, providers, einladen: mayInvite(signupMode, isAdmin(locals.user)) };
|
|
75
|
+
}
|
|
76
|
+
/** `/home/manage_user` (the Admin option): admins only. */
|
|
77
|
+
export function manageUserLoad({ locals }) {
|
|
78
|
+
if (!locals.session || !locals.user)
|
|
79
|
+
throw redirect(302, "/auth/login");
|
|
80
|
+
if (!isAdmin(locals.user))
|
|
81
|
+
throw redirect(302, "/home");
|
|
82
|
+
return { currentUserId: locals.user.id };
|
|
83
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What `createAuth()` set up, for the package's repositories, services and remote functions: the Product's database,
|
|
3
|
+
* its tables, the options and the better-auth instance. One app per process, so a module-level value; the Product's
|
|
4
|
+
* wiring (`src/lib/auth.ts`) runs before any request.
|
|
5
|
+
*/
|
|
6
|
+
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
7
|
+
import type { Mailer } from "@vonall/mail";
|
|
8
|
+
import type { FullSchema } from "../schema/index.js";
|
|
9
|
+
import type { Auth } from "./create.js";
|
|
10
|
+
import type { SignupMode } from "../types.js";
|
|
11
|
+
export type Db = NodePgDatabase<any>;
|
|
12
|
+
/** Where the package reports; a Product passes its logger, the default is the console. */
|
|
13
|
+
export type AuthLog = {
|
|
14
|
+
debug(message: string, context?: Record<string, unknown>): void;
|
|
15
|
+
info(message: string, context?: Record<string, unknown>): void;
|
|
16
|
+
warn(message: string, context?: Record<string, unknown>): void;
|
|
17
|
+
error(message: string, context?: Record<string, unknown>, error?: unknown): void;
|
|
18
|
+
};
|
|
19
|
+
export type Registry = {
|
|
20
|
+
db: Db;
|
|
21
|
+
schema: FullSchema;
|
|
22
|
+
appName: string;
|
|
23
|
+
origin: string;
|
|
24
|
+
/** The app's locale for dates in mails, e.g. "de-CH". */
|
|
25
|
+
locale: string;
|
|
26
|
+
signupMode: SignupMode;
|
|
27
|
+
mailer: Mailer;
|
|
28
|
+
admin: boolean;
|
|
29
|
+
log: AuthLog;
|
|
30
|
+
auth: Auth;
|
|
31
|
+
/** The social logins that have keys. */
|
|
32
|
+
providers: {
|
|
33
|
+
github: boolean;
|
|
34
|
+
google: boolean;
|
|
35
|
+
};
|
|
36
|
+
};
|
|
37
|
+
export declare function setRegistry(registry: Registry): void;
|
|
38
|
+
export declare function registry(): Registry;
|
|
39
|
+
export declare const consoleLog: AuthLog;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
let current = null;
|
|
2
|
+
export function setRegistry(registry) {
|
|
3
|
+
current = registry;
|
|
4
|
+
}
|
|
5
|
+
export function registry() {
|
|
6
|
+
if (!current)
|
|
7
|
+
throw new Error("@vonall/auth: createAuth() has not run; import the app's auth wiring (src/lib/auth.ts) first");
|
|
8
|
+
return current;
|
|
9
|
+
}
|
|
10
|
+
export const consoleLog = {
|
|
11
|
+
debug: () => { },
|
|
12
|
+
info: (message, context) => console.info(message, context ?? ""),
|
|
13
|
+
warn: (message, context) => console.warn(message, context ?? ""),
|
|
14
|
+
error: (message, context, error) => console.error(message, context ?? "", error ?? ""),
|
|
15
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export declare const EinladungRepo: {
|
|
2
|
+
insert(row: {
|
|
3
|
+
id: string;
|
|
4
|
+
tokenHash: string;
|
|
5
|
+
createdBy: string;
|
|
6
|
+
email: string | null;
|
|
7
|
+
expiresAt: Date;
|
|
8
|
+
}): Promise<void>;
|
|
9
|
+
/** The Einladung with the name of its inviter, by the hash of its token. */
|
|
10
|
+
getByTokenHash(tokenHash: string): Promise<{
|
|
11
|
+
id: string;
|
|
12
|
+
createdBy: string;
|
|
13
|
+
inviterName: string;
|
|
14
|
+
expiresAt: Date;
|
|
15
|
+
acceptedAt: Date | null;
|
|
16
|
+
}>;
|
|
17
|
+
/** Marks the Einladung as accepted, unless it already was or has expired; false when it couldn't be taken. */
|
|
18
|
+
accept(id: string, userId: string, now: Date): Promise<boolean>;
|
|
19
|
+
/** The user's Einladungen that are still open (not accepted, not expired), newest first. */
|
|
20
|
+
listOpenBy(userId: string, now: Date): Promise<{
|
|
21
|
+
id: string;
|
|
22
|
+
email: string | null;
|
|
23
|
+
expiresAt: Date;
|
|
24
|
+
createdAt: Date;
|
|
25
|
+
}[]>;
|
|
26
|
+
/** All open Einladungen with their inviter, for the admin. */
|
|
27
|
+
listAllOpen(now: Date): Promise<{
|
|
28
|
+
id: string;
|
|
29
|
+
email: string | null;
|
|
30
|
+
inviterName: string;
|
|
31
|
+
expiresAt: Date;
|
|
32
|
+
createdAt: Date;
|
|
33
|
+
}[]>;
|
|
34
|
+
/** Deletes an open Einladung of the user (or any, with `userId` null for the admin); false when there was none. */
|
|
35
|
+
deleteOpen(id: string, userId: string | null): Promise<boolean>;
|
|
36
|
+
};
|
|
@@ -0,0 +1,74 @@
|
|
|
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
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { AppUserClient, UserInsert } from "../../types.js";
|
|
2
|
+
export declare const UserRepo: {
|
|
3
|
+
/** Every account, oldest first (the admin's list). */
|
|
4
|
+
getAllUsers(): Promise<AppUserClient[]>;
|
|
5
|
+
getUserById(id: string): Promise<AppUserClient | null>;
|
|
6
|
+
getUserFromEmail(email: string): Promise<AppUserClient | null>;
|
|
7
|
+
countUsers(): Promise<number>;
|
|
8
|
+
/** Admin option only. */
|
|
9
|
+
setRole(userId: string, role: string): Promise<void>;
|
|
10
|
+
/** Creates a user outside better-auth's signup (tests, scripts); the first one becomes admin like any other. */
|
|
11
|
+
insertUser(data: UserInsert): Promise<void>;
|
|
12
|
+
getUserAccounts(userId: string): Promise<{
|
|
13
|
+
id: string;
|
|
14
|
+
providerId: string;
|
|
15
|
+
updatedAt: Date;
|
|
16
|
+
}[]>;
|
|
17
|
+
/** Deletes the user; every row that belongs to them goes with it (foreign keys cascade). */
|
|
18
|
+
deleteUser(userId: string): Promise<void>;
|
|
19
|
+
};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { asc, count, eq } from "drizzle-orm";
|
|
2
|
+
import { registry } from "../registry.js";
|
|
3
|
+
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;
|
|
7
|
+
const base = {
|
|
8
|
+
id: user.id,
|
|
9
|
+
email: user.email,
|
|
10
|
+
name: user.name,
|
|
11
|
+
createdAt: user.createdAt,
|
|
12
|
+
updatedAt: user.updatedAt,
|
|
13
|
+
twoFactorEnabled: user.twoFactorEnabled,
|
|
14
|
+
emailVerified: user.emailVerified,
|
|
15
|
+
image: user.image,
|
|
16
|
+
};
|
|
17
|
+
return registry().admin ? { ...base, role: user.role, banned: user.banned, banReason: user.banReason, banExpires: user.banExpires } : base;
|
|
18
|
+
}
|
|
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
|
+
/** Deletes the user; every row that belongs to them goes with it (foreign keys cascade). */
|
|
63
|
+
async deleteUser(userId) {
|
|
64
|
+
const { db, schema } = registry();
|
|
65
|
+
await db.delete(schema.user).where(eq(schema.user.id, userId));
|
|
66
|
+
},
|
|
67
|
+
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { type Result } from "neverthrow";
|
|
2
|
+
import type { EinladungInfo, OffeneEinladung, SignupMode, SignupState } from "../../types.js";
|
|
3
|
+
export declare const EINLADUNG_GUELTIG_TAGE = 14;
|
|
4
|
+
/** The cookie that carries an Einladung's token from its link through signup (email, Google or GitHub). */
|
|
5
|
+
export declare const EINLADUNG_COOKIE = "einladung";
|
|
6
|
+
export type EinladungError = {
|
|
7
|
+
kind: "unbekannt" | "abgelaufen" | "angenommen";
|
|
8
|
+
message: string;
|
|
9
|
+
};
|
|
10
|
+
export declare const EinladungService: {
|
|
11
|
+
/** Creates an Einladung and returns its token, shown only this once. */
|
|
12
|
+
erstellen(userId: string, input?: {
|
|
13
|
+
email?: string | null;
|
|
14
|
+
}, now?: Date): Promise<{
|
|
15
|
+
id: string;
|
|
16
|
+
token: string;
|
|
17
|
+
expiresAt: Date;
|
|
18
|
+
}>;
|
|
19
|
+
/** What the link offers, if it can still be used. */
|
|
20
|
+
pruefen(token: string, now?: Date): Promise<Result<EinladungInfo, EinladungError>>;
|
|
21
|
+
/**
|
|
22
|
+
* Whether someone may create an account now, and if not, why (the signup mode decides, `signupState`); whether it
|
|
23
|
+
* would be the very first account; and the Einladung the token stands for, if it is usable. One count, one lookup.
|
|
24
|
+
*/
|
|
25
|
+
signup(token: string | undefined, options?: {
|
|
26
|
+
now?: Date;
|
|
27
|
+
mode?: SignupMode;
|
|
28
|
+
mail?: boolean;
|
|
29
|
+
}): Promise<{
|
|
30
|
+
state: SignupState;
|
|
31
|
+
firstAccount: boolean;
|
|
32
|
+
einladung: EinladungInfo | null;
|
|
33
|
+
}>;
|
|
34
|
+
/** Uses the Einladung for the new user: marks it as taken. */
|
|
35
|
+
annehmen(userId: string, token: string, now?: Date): Promise<Result<void, EinladungError>>;
|
|
36
|
+
offene(userId: string, now?: Date): Promise<OffeneEinladung[]>;
|
|
37
|
+
/** Withdraws an open Einladung of the user. */
|
|
38
|
+
zurueckziehen(userId: string, einladungId: string): Promise<Result<void, EinladungError>>;
|
|
39
|
+
/** For the admin: every open Einladung, and withdrawing any of them. */
|
|
40
|
+
alleOffenen(now?: Date): Promise<{
|
|
41
|
+
id: string;
|
|
42
|
+
email: string | null;
|
|
43
|
+
inviterName: string;
|
|
44
|
+
expiresAt: Date;
|
|
45
|
+
createdAt: Date;
|
|
46
|
+
}[]>;
|
|
47
|
+
zurueckziehenAlsAdmin(einladungId: string): Promise<Result<void, EinladungError>>;
|
|
48
|
+
};
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Einladungen: the only way into the app once the first account (the admin) exists. A link works once and expires
|
|
3
|
+
* after 14 days. Only the token's hash is stored.
|
|
4
|
+
*/
|
|
5
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
6
|
+
import { err, ok } from "neverthrow";
|
|
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;
|
|
13
|
+
/** The cookie that carries an Einladung's token from its link through signup (email, Google or GitHub). */
|
|
14
|
+
export const EINLADUNG_COOKIE = "einladung";
|
|
15
|
+
const hash = (token) => createHash("sha256").update(token).digest("hex");
|
|
16
|
+
const fehler = {
|
|
17
|
+
unbekannt: () => ({ kind: "unbekannt", message: m().einladungUnbekannt }),
|
|
18
|
+
abgelaufen: () => ({ kind: "abgelaufen", message: m().einladungAbgelaufen }),
|
|
19
|
+
angenommen: () => ({ kind: "angenommen", message: m().einladungAngenommen }),
|
|
20
|
+
};
|
|
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
|
+
async offene(userId, now = new Date()) {
|
|
63
|
+
return await EinladungRepo.listOpenBy(userId, now);
|
|
64
|
+
},
|
|
65
|
+
/** Withdraws an open Einladung of the user. */
|
|
66
|
+
async zurueckziehen(userId, einladungId) {
|
|
67
|
+
return (await EinladungRepo.deleteOpen(einladungId, userId)) ? ok(undefined) : err(fehler.unbekannt());
|
|
68
|
+
},
|
|
69
|
+
/** For the admin: every open Einladung, and withdrawing any of them. */
|
|
70
|
+
async alleOffenen(now = new Date()) {
|
|
71
|
+
return await EinladungRepo.listAllOpen(now);
|
|
72
|
+
},
|
|
73
|
+
async zurueckziehenAlsAdmin(einladungId) {
|
|
74
|
+
return (await EinladungRepo.deleteOpen(einladungId, null)) ? ok(undefined) : err(fehler.unbekannt());
|
|
75
|
+
},
|
|
76
|
+
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export declare const KontoService: {
|
|
2
|
+
/**
|
|
3
|
+
* Deletes the account with everything that belongs to it: the foreign keys of every user-owned table cascade, so a
|
|
4
|
+
* Product's own tables reference `user.id` with `onDelete: "cascade"`.
|
|
5
|
+
*/
|
|
6
|
+
loeschen(userId: string): Promise<void>;
|
|
7
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** The user's account as a whole. */
|
|
2
|
+
import { UserRepo } from "../repositories/UserRepo.js";
|
|
3
|
+
export const KontoService = {
|
|
4
|
+
/**
|
|
5
|
+
* Deletes the account with everything that belongs to it: the foreign keys of every user-owned table cascade, so a
|
|
6
|
+
* Product's own tables reference `user.id` with `onDelete: "cascade"`.
|
|
7
|
+
*/
|
|
8
|
+
async loeschen(userId) {
|
|
9
|
+
await UserRepo.deleteUser(userId);
|
|
10
|
+
},
|
|
11
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
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>;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/** The three mails of accounts, through the Product's mailer (`@vonall/mail`). */
|
|
2
|
+
import { renderMail } from "@vonall/mail";
|
|
3
|
+
import { m } from "../../messages.js";
|
|
4
|
+
import { registry } from "../registry.js";
|
|
5
|
+
import InvitationEmail from "../../templates/InvitationEmail.svelte";
|
|
6
|
+
import ResetPasswordEmail from "../../templates/ResetPasswordEmail.svelte";
|
|
7
|
+
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
|
+
});
|
|
40
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { EmailLayout } from "@vonall/mail";
|
|
3
|
+
import { m } from "../messages.js";
|
|
4
|
+
|
|
5
|
+
let { inviterName, link, validUntil, appName, appLink }: {
|
|
6
|
+
inviterName: string;
|
|
7
|
+
link: string;
|
|
8
|
+
validUntil: string;
|
|
9
|
+
appName: string;
|
|
10
|
+
appLink: string;
|
|
11
|
+
} = $props();
|
|
12
|
+
</script>
|
|
13
|
+
|
|
14
|
+
<EmailLayout title={m().einladungTitle(appName)} {link} buttonLabel={m().einladungButton} {appName} {appLink}>
|
|
15
|
+
<p>{m().einladungHello}</p>
|
|
16
|
+
<p>{m().einladungBody(inviterName, appName)}</p>
|
|
17
|
+
<p>{m().einladungValid(validUntil)}</p>
|
|
18
|
+
</EmailLayout>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
type $$ComponentProps = {
|
|
2
|
+
inviterName: string;
|
|
3
|
+
link: string;
|
|
4
|
+
validUntil: string;
|
|
5
|
+
appName: string;
|
|
6
|
+
appLink: string;
|
|
7
|
+
};
|
|
8
|
+
declare const InvitationEmail: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
9
|
+
type InvitationEmail = ReturnType<typeof InvitationEmail>;
|
|
10
|
+
export default InvitationEmail;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { EmailLayout } from "@vonall/mail";
|
|
3
|
+
import { m } from "../messages.js";
|
|
4
|
+
|
|
5
|
+
let { recipientName, link, appName, appLink }: {
|
|
6
|
+
recipientName: string;
|
|
7
|
+
link: string;
|
|
8
|
+
appName: string;
|
|
9
|
+
appLink: string;
|
|
10
|
+
} = $props();
|
|
11
|
+
</script>
|
|
12
|
+
|
|
13
|
+
<EmailLayout title={m().hello(recipientName)} {link} buttonLabel={m().resetButton} {appName} {appLink}>
|
|
14
|
+
<p>{m().resetBody(appName)}</p>
|
|
15
|
+
<p>{m().resetNotYou}</p>
|
|
16
|
+
</EmailLayout>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
type $$ComponentProps = {
|
|
2
|
+
recipientName: string;
|
|
3
|
+
link: string;
|
|
4
|
+
appName: string;
|
|
5
|
+
appLink: string;
|
|
6
|
+
};
|
|
7
|
+
declare const ResetPasswordEmail: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
8
|
+
type ResetPasswordEmail = ReturnType<typeof ResetPasswordEmail>;
|
|
9
|
+
export default ResetPasswordEmail;
|