@tollwyn/baukasten 0.0.2 → 1.2.2
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/README.md +254 -1
- package/dist/bin/check.d.ts +2 -0
- package/dist/bin/check.js +38 -0
- package/dist/bin/migrate.d.ts +2 -0
- package/dist/bin/migrate.js +111 -0
- package/dist/bin/stubs.d.ts +2 -0
- package/dist/bin/stubs.js +38 -0
- package/dist/bin/test-accounts.d.ts +2 -0
- package/dist/bin/test-accounts.js +62 -0
- package/dist/check.d.ts +20 -0
- package/dist/check.js +285 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +9 -0
- package/dist/product.d.ts +161 -0
- package/dist/product.js +152 -0
- package/dist/server/accounts.d.ts +128 -0
- package/dist/server/accounts.js +282 -0
- package/dist/server/adapter/db.d.ts +5 -0
- package/dist/server/adapter/db.js +63 -0
- package/dist/server/adapter/log.d.ts +3 -0
- package/dist/server/adapter/log.js +12 -0
- package/dist/server/adapter/mailer.d.ts +4 -0
- package/dist/server/adapter/mailer.js +56 -0
- package/dist/server/adapter/migrate.d.ts +21 -0
- package/dist/server/adapter/migrate.js +113 -0
- package/dist/server/adapter/secrets.d.ts +2 -0
- package/dist/server/adapter/secrets.js +40 -0
- package/dist/server/adapter/stripe.d.ts +11 -0
- package/dist/server/adapter/stripe.js +333 -0
- package/dist/server/api.d.ts +25 -0
- package/dist/server/api.js +98 -0
- package/dist/server/baukasten-migrations.d.ts +4 -0
- package/dist/server/baukasten-migrations.js +126 -0
- package/dist/server/billing.d.ts +94 -0
- package/dist/server/billing.js +349 -0
- package/dist/server/catalog.d.ts +39 -0
- package/dist/server/catalog.js +193 -0
- package/dist/server/config.d.ts +108 -0
- package/dist/server/config.js +244 -0
- package/dist/server/endpoints.d.ts +44 -0
- package/dist/server/endpoints.js +159 -0
- package/dist/server/fields.d.ts +7 -0
- package/dist/server/fields.js +18 -0
- package/dist/server/headers.d.ts +1 -0
- package/dist/server/headers.js +9 -0
- package/dist/server/health.d.ts +34 -0
- package/dist/server/health.js +116 -0
- package/dist/server/hook.d.ts +92 -0
- package/dist/server/hook.js +293 -0
- package/dist/server/index.d.ts +18 -0
- package/dist/server/index.js +11 -0
- package/dist/server/legal/de.d.ts +2 -0
- package/dist/server/legal/de.js +251 -0
- package/dist/server/legal/document.d.ts +37 -0
- package/dist/server/legal/document.js +30 -0
- package/dist/server/legal/en.d.ts +2 -0
- package/dist/server/legal/en.js +251 -0
- package/dist/server/legal/fleet.d.ts +114 -0
- package/dist/server/legal/fleet.js +89 -0
- package/dist/server/legal/index.d.ts +3 -0
- package/dist/server/legal/index.js +11 -0
- package/dist/server/limits.d.ts +58 -0
- package/dist/server/limits.js +76 -0
- package/dist/server/log.d.ts +26 -0
- package/dist/server/log.js +61 -0
- package/dist/server/machine.d.ts +4 -0
- package/dist/server/machine.js +57 -0
- package/dist/server/mail.d.ts +20 -0
- package/dist/server/mail.js +29 -0
- package/dist/server/messages.d.ts +105 -0
- package/dist/server/messages.js +232 -0
- package/dist/server/migrations.d.ts +28 -0
- package/dist/server/migrations.js +59 -0
- package/dist/server/openapi.d.ts +24 -0
- package/dist/server/openapi.js +173 -0
- package/dist/server/pages.d.ts +158 -0
- package/dist/server/pages.js +491 -0
- package/dist/server/ports.d.ts +273 -0
- package/dist/server/ports.js +1 -0
- package/dist/server/runs.d.ts +30 -0
- package/dist/server/runs.js +55 -0
- package/dist/server/sales.d.ts +38 -0
- package/dist/server/sales.js +132 -0
- package/dist/server/services.d.ts +57 -0
- package/dist/server/services.js +44 -0
- package/dist/server/start.d.ts +14 -0
- package/dist/server/start.js +65 -0
- package/dist/server/stripe.d.ts +33 -0
- package/dist/server/stripe.js +29 -0
- package/dist/server/terms.d.ts +7 -0
- package/dist/server/terms.js +9 -0
- package/dist/server/test-accounts.d.ts +28 -0
- package/dist/server/test-accounts.js +62 -0
- package/dist/server/via.d.ts +9 -0
- package/dist/server/via.js +15 -0
- package/dist/server/web.d.ts +48 -0
- package/dist/server/web.js +105 -0
- package/dist/shopify/adapter/shopify.d.ts +8 -0
- package/dist/shopify/adapter/shopify.js +232 -0
- package/dist/shopify/admin.d.ts +33 -0
- package/dist/shopify/admin.js +47 -0
- package/dist/shopify/app.d.ts +60 -0
- package/dist/shopify/app.js +67 -0
- package/dist/shopify/config.d.ts +53 -0
- package/dist/shopify/config.js +133 -0
- package/dist/shopify/constants.d.ts +27 -0
- package/dist/shopify/constants.js +27 -0
- package/dist/shopify/headers.d.ts +1 -0
- package/dist/shopify/headers.js +8 -0
- package/dist/shopify/hmac.d.ts +1 -0
- package/dist/shopify/hmac.js +9 -0
- package/dist/shopify/hook.d.ts +60 -0
- package/dist/shopify/hook.js +508 -0
- package/dist/shopify/id-token.d.ts +12 -0
- package/dist/shopify/id-token.js +66 -0
- package/dist/shopify/index.d.ts +19 -0
- package/dist/shopify/index.js +15 -0
- package/dist/shopify/legal/de.d.ts +3 -0
- package/dist/shopify/legal/de.js +123 -0
- package/dist/shopify/legal/en.d.ts +3 -0
- package/dist/shopify/legal/en.js +121 -0
- package/dist/shopify/legal/index.d.ts +19 -0
- package/dist/shopify/legal/index.js +8 -0
- package/dist/shopify/legal/shopify.d.ts +16 -0
- package/dist/shopify/legal/shopify.js +21 -0
- package/dist/shopify/migrations.d.ts +4 -0
- package/dist/shopify/migrations.js +67 -0
- package/dist/shopify/pages.d.ts +45 -0
- package/dist/shopify/pages.js +146 -0
- package/dist/shopify/ports.d.ts +77 -0
- package/dist/shopify/ports.js +1 -0
- package/dist/shopify/sales.d.ts +4 -0
- package/dist/shopify/sales.js +27 -0
- package/dist/shopify/scopes.d.ts +2 -0
- package/dist/shopify/scopes.js +13 -0
- package/dist/shopify/seal.d.ts +12 -0
- package/dist/shopify/seal.js +60 -0
- package/dist/shopify/shop.d.ts +4 -0
- package/dist/shopify/shop.js +15 -0
- package/dist/shopify/shops.d.ts +76 -0
- package/dist/shopify/shops.js +256 -0
- package/dist/shopify/sveltekit.d.ts +19 -0
- package/dist/shopify/sveltekit.js +18 -0
- package/dist/shopify/texts.d.ts +28 -0
- package/dist/shopify/texts.js +49 -0
- package/dist/shopify/toml.d.ts +22 -0
- package/dist/shopify/toml.js +125 -0
- package/dist/shopify/webhooks.d.ts +65 -0
- package/dist/shopify/webhooks.js +220 -0
- package/dist/stubs.d.ts +15 -0
- package/dist/stubs.js +287 -0
- package/dist/sveltekit.d.ts +20 -0
- package/dist/sveltekit.js +18 -0
- package/dist/testing/app.d.ts +36 -0
- package/dist/testing/app.js +155 -0
- package/dist/testing/browser.d.ts +21 -0
- package/dist/testing/browser.js +21 -0
- package/dist/testing/connection.d.ts +7 -0
- package/dist/testing/connection.js +303 -0
- package/dist/testing/database.d.ts +21 -0
- package/dist/testing/database.js +54 -0
- package/dist/testing/env-example.d.ts +4 -0
- package/dist/testing/env-example.js +33 -0
- package/dist/testing/index.d.ts +9 -0
- package/dist/testing/index.js +9 -0
- package/dist/testing/shopify-connection.d.ts +8 -0
- package/dist/testing/shopify-connection.js +313 -0
- package/dist/testing/shopify.d.ts +71 -0
- package/dist/testing/shopify.js +278 -0
- package/dist/testing/smtp.d.ts +38 -0
- package/dist/testing/smtp.js +165 -0
- package/dist/testing/stripe.d.ts +128 -0
- package/dist/testing/stripe.js +511 -0
- package/dist/texts.d.ts +139 -0
- package/dist/texts.js +265 -0
- package/dist/ui/AccountPage.svelte +143 -0
- package/dist/ui/AccountPage.svelte.d.ts +10 -0
- package/dist/ui/ConfirmEmailPage.svelte +21 -0
- package/dist/ui/ConfirmEmailPage.svelte.d.ts +10 -0
- package/dist/ui/ErrorPage.svelte +14 -0
- package/dist/ui/ErrorPage.svelte.d.ts +3 -0
- package/dist/ui/Field.svelte +12 -0
- package/dist/ui/Field.svelte.d.ts +10 -0
- package/dist/ui/ForgotPasswordPage.svelte +32 -0
- package/dist/ui/ForgotPasswordPage.svelte.d.ts +7 -0
- package/dist/ui/Form.svelte +30 -0
- package/dist/ui/Form.svelte.d.ts +11 -0
- package/dist/ui/Frame.svelte +84 -0
- package/dist/ui/Frame.svelte.d.ts +12 -0
- package/dist/ui/LegalPage.svelte +38 -0
- package/dist/ui/LegalPage.svelte.d.ts +9 -0
- package/dist/ui/Linked.svelte +5 -0
- package/dist/ui/Linked.svelte.d.ts +8 -0
- package/dist/ui/LoginPage.svelte +41 -0
- package/dist/ui/LoginPage.svelte.d.ts +10 -0
- package/dist/ui/LogoutPage.svelte +19 -0
- package/dist/ui/LogoutPage.svelte.d.ts +9 -0
- package/dist/ui/ResetPasswordPage.svelte +30 -0
- package/dist/ui/ResetPasswordPage.svelte.d.ts +10 -0
- package/dist/ui/SignupPage.svelte +52 -0
- package/dist/ui/SignupPage.svelte.d.ts +10 -0
- package/dist/ui/VerifyEmailPage.svelte +50 -0
- package/dist/ui/VerifyEmailPage.svelte.d.ts +10 -0
- package/dist/ui/base.css +287 -0
- package/dist/ui/data.d.ts +167 -0
- package/dist/ui/data.js +1 -0
- package/dist/ui/frame.d.ts +17 -0
- package/dist/ui/frame.js +27 -0
- package/dist/ui/index.d.ts +15 -0
- package/dist/ui/index.js +15 -0
- package/dist/variables.d.ts +20 -0
- package/dist/variables.js +62 -0
- package/package.json +88 -4
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
import { fail, redirect } from '@sveltejs/kit';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { EmailTaken, normalizeEmail, sha256 } from './accounts.js';
|
|
4
|
+
import { emailField, field, passwordField, problem } from './fields.js';
|
|
5
|
+
import { LIMITS } from './limits.js';
|
|
6
|
+
import { deliver } from './mail.js';
|
|
7
|
+
import { sendLink, signUp } from './services.js';
|
|
8
|
+
import { parseVia } from './via.js';
|
|
9
|
+
import { clearSessionCookie, requireAccount, safeNext, SESSION_COOKIE, setSessionCookie, tooMany } from './web.js';
|
|
10
|
+
/** The marks of the product as the frame shows them: the picture of a shared link at its whole address. */
|
|
11
|
+
export function frameBrand(brand, origin) {
|
|
12
|
+
if (brand === undefined)
|
|
13
|
+
return null;
|
|
14
|
+
return {
|
|
15
|
+
logo: brand.logo,
|
|
16
|
+
icon: { href: brand.icon, type: brand.icon.endsWith('.svg') ? 'image/svg+xml' : 'image/png' },
|
|
17
|
+
image: new URL(brand.image, origin).href
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
const TO = {
|
|
21
|
+
verifyUnsent: '/verify-email?unsent',
|
|
22
|
+
accountVerified: '/account?verified',
|
|
23
|
+
accountEmailChanged: '/account?email-changed',
|
|
24
|
+
loginVerified: '/login?verified',
|
|
25
|
+
loginEmailChanged: '/login?email-changed',
|
|
26
|
+
loggedOut: '/logout',
|
|
27
|
+
deleted: '/logout?deleted'
|
|
28
|
+
};
|
|
29
|
+
export function createPages(services) {
|
|
30
|
+
async function logIn(event, s, accountId) {
|
|
31
|
+
setSessionCookie(event.cookies, await s.accounts.createSession(accountId));
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* A check of the current password of a logged-in account counts like a login, per account and
|
|
35
|
+
* before the check, so that a session cannot guess the password; a right one clears the count.
|
|
36
|
+
*/
|
|
37
|
+
function passwordAttempt(s, account) {
|
|
38
|
+
const key = `password:${account.id}`;
|
|
39
|
+
return { seconds: s.limits.take(key, LIMITS.login), passed: () => s.limits.reset(key) };
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
layout: {
|
|
43
|
+
load: ({ locals, url }) => {
|
|
44
|
+
const s = services();
|
|
45
|
+
return {
|
|
46
|
+
frame: {
|
|
47
|
+
language: s.config.language,
|
|
48
|
+
name: s.config.name,
|
|
49
|
+
navigation: s.product.navigation,
|
|
50
|
+
via: parseVia(url.searchParams.get('via')),
|
|
51
|
+
account: locals.account === null
|
|
52
|
+
? null
|
|
53
|
+
: { email: locals.account.email, emailVerified: locals.account.emailVerified },
|
|
54
|
+
brand: frameBrand(s.product.brand, s.config.publicOrigin)
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
imprint: { load: () => ({ legal: services().legal.imprint }) },
|
|
60
|
+
privacy: { load: () => ({ legal: services().legal.privacy }) },
|
|
61
|
+
terms: { load: () => ({ legal: services().legal.terms }) },
|
|
62
|
+
signup: {
|
|
63
|
+
load: ({ locals, url }) => {
|
|
64
|
+
const s = services();
|
|
65
|
+
if (locals.account !== null)
|
|
66
|
+
redirect(303, s.product.accounts.home);
|
|
67
|
+
return {
|
|
68
|
+
signup: { ...s.product.accounts.signup, via: parseVia(url.searchParams.get('via')) }
|
|
69
|
+
};
|
|
70
|
+
},
|
|
71
|
+
actions: {
|
|
72
|
+
default: async (event) => {
|
|
73
|
+
const s = services();
|
|
74
|
+
const m = s.messages;
|
|
75
|
+
const form = await event.request.formData();
|
|
76
|
+
const email = field(form, 'email').trim();
|
|
77
|
+
const password = field(form, 'password');
|
|
78
|
+
const error = problem(emailField(m), email) ??
|
|
79
|
+
problem(passwordField(m), password) ??
|
|
80
|
+
(field(form, 'terms') === 'accepted' ? null : m.signup.acceptTerms) ??
|
|
81
|
+
(field(form, 'business') === 'confirmed' ? null : m.signup.confirmBusiness);
|
|
82
|
+
if (error !== null)
|
|
83
|
+
return fail(400, { email, error });
|
|
84
|
+
const seconds = s.limits.take(`signup:${s.address(event)}`, LIMITS.signup);
|
|
85
|
+
if (seconds > 0)
|
|
86
|
+
return tooMany(event, seconds, { email, error: m.tooMany(seconds) });
|
|
87
|
+
let account;
|
|
88
|
+
try {
|
|
89
|
+
account = await s.accounts.create({
|
|
90
|
+
// The form carries the referral tag of the page; one that is none counts as without.
|
|
91
|
+
...signUp(s, email, parseVia(field(form, 'via'))),
|
|
92
|
+
password
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
catch (caught) {
|
|
96
|
+
if (caught instanceof EmailTaken) {
|
|
97
|
+
return fail(409, { email, error: m.signup.taken, taken: true });
|
|
98
|
+
}
|
|
99
|
+
throw caught;
|
|
100
|
+
}
|
|
101
|
+
await logIn(event, s, account.id);
|
|
102
|
+
const sent = await sendLink(s, account, { purpose: 'verify' });
|
|
103
|
+
redirect(303, sent.kind === 'sent' ? s.product.accounts.home : TO.verifyUnsent);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
login: {
|
|
108
|
+
load: ({ locals, url }) => {
|
|
109
|
+
const s = services();
|
|
110
|
+
if (locals.account !== null) {
|
|
111
|
+
redirect(303, safeNext(url.searchParams.get('next'), s.product.accounts.home));
|
|
112
|
+
}
|
|
113
|
+
const notice = url.searchParams.has('verified')
|
|
114
|
+
? 'verified'
|
|
115
|
+
: url.searchParams.has('email-changed')
|
|
116
|
+
? 'emailChanged'
|
|
117
|
+
: null;
|
|
118
|
+
return { login: { notice } };
|
|
119
|
+
},
|
|
120
|
+
actions: {
|
|
121
|
+
default: async (event) => {
|
|
122
|
+
const s = services();
|
|
123
|
+
const m = s.messages;
|
|
124
|
+
const form = await event.request.formData();
|
|
125
|
+
const email = field(form, 'email').trim();
|
|
126
|
+
const password = field(form, 'password');
|
|
127
|
+
if (problem(emailField(m), email) !== null || password === '' || password.length > 200) {
|
|
128
|
+
return fail(400, { email, error: m.login.wrong });
|
|
129
|
+
}
|
|
130
|
+
// Every try counts before the password is checked: parallel guesses cannot slip by. The
|
|
131
|
+
// address counts on its own, against one password tried on many accounts and against
|
|
132
|
+
// a flood of scrypt.
|
|
133
|
+
const address = s.address(event);
|
|
134
|
+
const key = `login:${address}:${sha256(normalizeEmail(email)).toString('hex')}`;
|
|
135
|
+
const seconds = Math.max(s.limits.take(`login-address:${address}`, LIMITS.loginAddress), s.limits.take(key, LIMITS.login));
|
|
136
|
+
if (seconds > 0)
|
|
137
|
+
return tooMany(event, seconds, { email, error: m.tooMany(seconds) });
|
|
138
|
+
const account = await s.accounts.authenticate(email, password);
|
|
139
|
+
if (account === null)
|
|
140
|
+
return fail(400, { email, error: m.login.wrong });
|
|
141
|
+
s.limits.reset(key);
|
|
142
|
+
await s.accounts.removeExpired();
|
|
143
|
+
await logIn(event, s, account.id);
|
|
144
|
+
redirect(303, safeNext(event.url.searchParams.get('next'), s.product.accounts.home));
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
},
|
|
148
|
+
logout: {
|
|
149
|
+
load: ({ locals, url }) => ({
|
|
150
|
+
logout: { loggedIn: locals.account !== null, deleted: url.searchParams.has('deleted') }
|
|
151
|
+
}),
|
|
152
|
+
actions: {
|
|
153
|
+
default: async ({ cookies }) => {
|
|
154
|
+
const token = cookies.get(SESSION_COOKIE);
|
|
155
|
+
if (token !== undefined)
|
|
156
|
+
await services().accounts.endSession(token);
|
|
157
|
+
clearSessionCookie(cookies);
|
|
158
|
+
redirect(303, TO.loggedOut);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
forgotPassword: {
|
|
163
|
+
actions: {
|
|
164
|
+
default: async (event) => {
|
|
165
|
+
const s = services();
|
|
166
|
+
const m = s.messages;
|
|
167
|
+
const email = field(await event.request.formData(), 'email').trim();
|
|
168
|
+
const error = problem(emailField(m), email);
|
|
169
|
+
if (error !== null)
|
|
170
|
+
return fail(400, { email, error });
|
|
171
|
+
const seconds = s.limits.take(`forgot:${s.address(event)}`, LIMITS.forgotPassword);
|
|
172
|
+
if (seconds > 0)
|
|
173
|
+
return tooMany(event, seconds, { email, error: m.tooMany(seconds) });
|
|
174
|
+
// The answer is the same with or without an account, as fast, and when the mail did
|
|
175
|
+
// not go: the link goes out after the answer, and a failure is in the log.
|
|
176
|
+
const account = await s.accounts.findByEmail(email);
|
|
177
|
+
if (account !== null) {
|
|
178
|
+
sendLink(s, account, { purpose: 'reset' }).catch((error) => {
|
|
179
|
+
s.log.error('mail failed', { kind: 'reset', account: account.id, error });
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
return { email, sent: true };
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
},
|
|
186
|
+
resetPassword: {
|
|
187
|
+
load: async ({ url }) => {
|
|
188
|
+
const token = url.searchParams.get('token');
|
|
189
|
+
if (token === null)
|
|
190
|
+
return { reset: { token: '', valid: false } };
|
|
191
|
+
const valid = (await services().accounts.peekLink(token, 'reset')) !== null;
|
|
192
|
+
return { reset: { token, valid } };
|
|
193
|
+
},
|
|
194
|
+
actions: {
|
|
195
|
+
default: async (event) => {
|
|
196
|
+
const s = services();
|
|
197
|
+
const m = s.messages;
|
|
198
|
+
const form = await event.request.formData();
|
|
199
|
+
const password = field(form, 'password');
|
|
200
|
+
const error = problem(passwordField(m), password);
|
|
201
|
+
if (error !== null)
|
|
202
|
+
return fail(400, { error });
|
|
203
|
+
const accountId = await s.accounts.resetPassword(field(form, 'token'), password);
|
|
204
|
+
if (accountId === null)
|
|
205
|
+
return fail(400, { error: m.link.invalid });
|
|
206
|
+
await s.accounts.removeExpired();
|
|
207
|
+
await logIn(event, s, accountId);
|
|
208
|
+
redirect(303, s.product.accounts.home);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
},
|
|
212
|
+
/**
|
|
213
|
+
* The link of the mail opens a page with a button instead of confirming on GET, so that a mail
|
|
214
|
+
* scanner that opens links does not use it up. Confirming never logs into an account that has
|
|
215
|
+
* a password: whoever holds a mistyped address must not get in. An account from the API has
|
|
216
|
+
* none; it may set one here and is logged in.
|
|
217
|
+
*/
|
|
218
|
+
verifyEmail: {
|
|
219
|
+
load: async ({ locals, url }) => {
|
|
220
|
+
const token = url.searchParams.get('token');
|
|
221
|
+
if (token !== null) {
|
|
222
|
+
const use = await services().accounts.peekLink(token, 'verify');
|
|
223
|
+
return {
|
|
224
|
+
verify: {
|
|
225
|
+
kind: 'link',
|
|
226
|
+
token,
|
|
227
|
+
valid: use !== null,
|
|
228
|
+
needsPassword: use !== null && !use.hasPassword
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
if (locals.account === null)
|
|
233
|
+
return { verify: { kind: 'none' } };
|
|
234
|
+
if (locals.account.emailVerified)
|
|
235
|
+
return { verify: { kind: 'confirmed' } };
|
|
236
|
+
return {
|
|
237
|
+
verify: {
|
|
238
|
+
kind: 'pending',
|
|
239
|
+
email: locals.account.email,
|
|
240
|
+
unsent: url.searchParams.has('unsent')
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
},
|
|
244
|
+
actions: {
|
|
245
|
+
confirm: async (event) => {
|
|
246
|
+
const s = services();
|
|
247
|
+
const m = s.messages;
|
|
248
|
+
const form = await event.request.formData();
|
|
249
|
+
const password = field(form, 'password');
|
|
250
|
+
if (password !== '') {
|
|
251
|
+
const error = problem(passwordField(m), password);
|
|
252
|
+
if (error !== null)
|
|
253
|
+
return fail(400, { error });
|
|
254
|
+
}
|
|
255
|
+
const use = await s.accounts.confirmEmail(field(form, 'token'), password === '' ? null : password);
|
|
256
|
+
if (use === null)
|
|
257
|
+
return fail(400, { error: m.link.invalid });
|
|
258
|
+
if (event.locals.account !== null && event.locals.account.id === use.accountId) {
|
|
259
|
+
redirect(303, TO.accountVerified);
|
|
260
|
+
}
|
|
261
|
+
if (use.hasPassword)
|
|
262
|
+
redirect(303, TO.loginVerified);
|
|
263
|
+
await logIn(event, s, use.accountId);
|
|
264
|
+
redirect(303, TO.accountVerified);
|
|
265
|
+
},
|
|
266
|
+
resend: async (event) => {
|
|
267
|
+
const s = services();
|
|
268
|
+
const account = requireAccount(event);
|
|
269
|
+
if (account.emailVerified)
|
|
270
|
+
redirect(303, '/verify-email');
|
|
271
|
+
const sent = await sendLink(s, account, { purpose: 'verify' });
|
|
272
|
+
switch (sent.kind) {
|
|
273
|
+
case 'limited':
|
|
274
|
+
return tooMany(event, sent.seconds, { error: s.messages.tooMany(sent.seconds) });
|
|
275
|
+
case 'failed':
|
|
276
|
+
return fail(502, { error: s.messages.mailFailed });
|
|
277
|
+
case 'sent':
|
|
278
|
+
return { resent: account.email };
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
},
|
|
283
|
+
confirmEmail: {
|
|
284
|
+
load: async ({ url }) => {
|
|
285
|
+
const token = url.searchParams.get('token');
|
|
286
|
+
if (token === null)
|
|
287
|
+
return { confirm: { token: '', newEmail: null } };
|
|
288
|
+
const use = await services().accounts.peekLink(token, 'change_email');
|
|
289
|
+
return { confirm: { token, newEmail: use === null ? null : use.newEmail } };
|
|
290
|
+
},
|
|
291
|
+
actions: {
|
|
292
|
+
default: async (event) => {
|
|
293
|
+
const s = services();
|
|
294
|
+
const result = await s.accounts.confirmEmailChange(field(await event.request.formData(), 'token'));
|
|
295
|
+
switch (result.kind) {
|
|
296
|
+
case 'invalid':
|
|
297
|
+
return fail(400, { error: s.messages.link.invalid });
|
|
298
|
+
case 'taken':
|
|
299
|
+
return fail(409, { error: s.messages.account.taken });
|
|
300
|
+
case 'changed': {
|
|
301
|
+
// The address has changed; a notice that did not go is in the log.
|
|
302
|
+
const notice = s.messages.mails.changed({
|
|
303
|
+
name: s.config.name,
|
|
304
|
+
origin: s.config.publicOrigin,
|
|
305
|
+
newEmail: result.newEmail
|
|
306
|
+
});
|
|
307
|
+
await deliver(s.mail, s.log, { to: result.oldEmail, ...notice }, { kind: 'email_changed', account: result.accountId });
|
|
308
|
+
// The address has changed; receipts that still go to the old one are in the log.
|
|
309
|
+
await s.billing
|
|
310
|
+
.followEmail(result.accountId, result.newEmail)
|
|
311
|
+
.catch((error) => {
|
|
312
|
+
s.log.error('stripe: the customer kept the old address', {
|
|
313
|
+
account: result.accountId,
|
|
314
|
+
error
|
|
315
|
+
});
|
|
316
|
+
});
|
|
317
|
+
const own = event.locals.account !== null && event.locals.account.id === result.accountId;
|
|
318
|
+
redirect(303, own ? TO.accountEmailChanged : TO.loginEmailChanged);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
},
|
|
324
|
+
account: {
|
|
325
|
+
load: async (event) => {
|
|
326
|
+
const s = services();
|
|
327
|
+
const account = requireAccount(event);
|
|
328
|
+
const keys = await s.accounts.listApiKeys(account.id);
|
|
329
|
+
const customer = await s.billing.hasCustomer(account.id);
|
|
330
|
+
const notice = event.url.searchParams.has('verified')
|
|
331
|
+
? 'verified'
|
|
332
|
+
: event.url.searchParams.has('email-changed')
|
|
333
|
+
? 'emailChanged'
|
|
334
|
+
: null;
|
|
335
|
+
return {
|
|
336
|
+
account: {
|
|
337
|
+
email: account.email,
|
|
338
|
+
emailVerified: account.emailVerified,
|
|
339
|
+
hasPassword: account.hasPassword,
|
|
340
|
+
keyExample: `${s.product.accounts.keyPrefix}_…`,
|
|
341
|
+
customer,
|
|
342
|
+
keys: keys.map((key) => ({
|
|
343
|
+
id: key.id,
|
|
344
|
+
name: key.name,
|
|
345
|
+
prefix: key.prefix,
|
|
346
|
+
created: key.createdAt.toISOString().slice(0, 10)
|
|
347
|
+
})),
|
|
348
|
+
notice
|
|
349
|
+
}
|
|
350
|
+
};
|
|
351
|
+
},
|
|
352
|
+
actions: {
|
|
353
|
+
// Only a confirmed address makes keys: a key of an address not one's own would outlast
|
|
354
|
+
// the day its owner takes the account.
|
|
355
|
+
createKey: async (event) => {
|
|
356
|
+
const s = services();
|
|
357
|
+
const account = requireAccount(event);
|
|
358
|
+
if (!account.emailVerified) {
|
|
359
|
+
return fail(403, {
|
|
360
|
+
action: 'createKey',
|
|
361
|
+
error: s.messages.account.confirmFirst
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
const typed = field(await event.request.formData(), 'name')
|
|
365
|
+
.trim()
|
|
366
|
+
.slice(0, 80);
|
|
367
|
+
const name = typed === '' ? s.messages.account.keyName : typed;
|
|
368
|
+
const { key } = await s.accounts.createApiKey(account.id, name, s.product.accounts.keyPrefix);
|
|
369
|
+
return { action: 'createKey', key };
|
|
370
|
+
},
|
|
371
|
+
revokeKey: async (event) => {
|
|
372
|
+
const s = services();
|
|
373
|
+
const account = requireAccount(event);
|
|
374
|
+
const id = field(await event.request.formData(), 'id');
|
|
375
|
+
if (z.uuid().safeParse(id).success)
|
|
376
|
+
await s.accounts.revokeApiKey(account.id, id);
|
|
377
|
+
return { action: 'revokeKey' };
|
|
378
|
+
},
|
|
379
|
+
changePassword: async (event) => {
|
|
380
|
+
const s = services();
|
|
381
|
+
const m = s.messages;
|
|
382
|
+
const account = requireAccount(event);
|
|
383
|
+
const form = await event.request.formData();
|
|
384
|
+
const next = field(form, 'next');
|
|
385
|
+
const error = problem(passwordField(m), next);
|
|
386
|
+
if (error !== null)
|
|
387
|
+
return fail(400, { action: 'changePassword', error });
|
|
388
|
+
const attempt = passwordAttempt(s, account);
|
|
389
|
+
if (attempt.seconds > 0) {
|
|
390
|
+
return tooMany(event, attempt.seconds, {
|
|
391
|
+
action: 'changePassword',
|
|
392
|
+
error: m.tooMany(attempt.seconds)
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
const current = account.hasPassword ? field(form, 'current') : null;
|
|
396
|
+
if (!(await s.accounts.changePassword(account.id, current, next))) {
|
|
397
|
+
return fail(400, { action: 'changePassword', error: m.account.wrongCurrent });
|
|
398
|
+
}
|
|
399
|
+
attempt.passed();
|
|
400
|
+
const token = event.cookies.get(SESSION_COOKIE);
|
|
401
|
+
if (token === undefined)
|
|
402
|
+
throw new Error('an account without the cookie of its session');
|
|
403
|
+
await s.accounts.endOtherSessions(account.id, token);
|
|
404
|
+
return { action: 'changePassword', done: true };
|
|
405
|
+
},
|
|
406
|
+
changeEmail: async (event) => {
|
|
407
|
+
const s = services();
|
|
408
|
+
const m = s.messages;
|
|
409
|
+
const account = requireAccount(event);
|
|
410
|
+
const form = await event.request.formData();
|
|
411
|
+
const email = field(form, 'email').trim();
|
|
412
|
+
const failed = (status, error) => fail(status, { action: 'changeEmail', email, error });
|
|
413
|
+
const error = problem(emailField(m), email);
|
|
414
|
+
if (error !== null)
|
|
415
|
+
return failed(400, error);
|
|
416
|
+
if (normalizeEmail(email) === account.email)
|
|
417
|
+
return failed(400, m.account.sameEmail);
|
|
418
|
+
if (account.hasPassword) {
|
|
419
|
+
const attempt = passwordAttempt(s, account);
|
|
420
|
+
if (attempt.seconds > 0) {
|
|
421
|
+
return tooMany(event, attempt.seconds, {
|
|
422
|
+
action: 'changeEmail',
|
|
423
|
+
email,
|
|
424
|
+
error: m.tooMany(attempt.seconds)
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
if ((await s.accounts.authenticate(account.email, field(form, 'password'))) === null) {
|
|
428
|
+
return failed(400, m.account.wrongPassword);
|
|
429
|
+
}
|
|
430
|
+
attempt.passed();
|
|
431
|
+
}
|
|
432
|
+
const seconds = s.limits.take(`email-change:${account.id}`, LIMITS.emailChange);
|
|
433
|
+
if (seconds > 0) {
|
|
434
|
+
return tooMany(event, seconds, {
|
|
435
|
+
action: 'changeEmail',
|
|
436
|
+
email,
|
|
437
|
+
error: m.tooMany(seconds)
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
if ((await s.accounts.findByEmail(email)) !== null)
|
|
441
|
+
return failed(409, m.account.taken);
|
|
442
|
+
const sent = await sendLink(s, account, { purpose: 'change_email', newEmail: email });
|
|
443
|
+
switch (sent.kind) {
|
|
444
|
+
case 'limited':
|
|
445
|
+
return tooMany(event, sent.seconds, {
|
|
446
|
+
action: 'changeEmail',
|
|
447
|
+
email,
|
|
448
|
+
error: m.tooMany(sent.seconds)
|
|
449
|
+
});
|
|
450
|
+
case 'failed':
|
|
451
|
+
return failed(502, m.mailFailed);
|
|
452
|
+
case 'sent':
|
|
453
|
+
return {
|
|
454
|
+
action: 'changeEmail',
|
|
455
|
+
email: '',
|
|
456
|
+
sentTo: normalizeEmail(email)
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
},
|
|
460
|
+
/**
|
|
461
|
+
* Only a customer has a portal; opening it must not make one, which with the live key
|
|
462
|
+
* makes the account live.
|
|
463
|
+
*/
|
|
464
|
+
portal: async (event) => {
|
|
465
|
+
const s = services();
|
|
466
|
+
const account = requireAccount(event);
|
|
467
|
+
if (!(await s.billing.hasCustomer(account.id)))
|
|
468
|
+
redirect(303, '/account');
|
|
469
|
+
redirect(303, await s.billing.portal(account, '/account'));
|
|
470
|
+
},
|
|
471
|
+
delete: async (event) => {
|
|
472
|
+
const s = services();
|
|
473
|
+
const account = requireAccount(event);
|
|
474
|
+
const typed = normalizeEmail(field(await event.request.formData(), 'confirm'));
|
|
475
|
+
if (typed !== account.email) {
|
|
476
|
+
return fail(400, {
|
|
477
|
+
action: 'delete',
|
|
478
|
+
error: s.messages.account.confirmDelete
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
// A deleted account pays for nothing: its subscriptions end first, and a failure at
|
|
482
|
+
// Stripe leaves the account as it is.
|
|
483
|
+
await s.billing.endSubscriptions(account.id);
|
|
484
|
+
await s.accounts.delete(account.id, s.deleteHook);
|
|
485
|
+
clearSessionCookie(event.cookies);
|
|
486
|
+
redirect(303, TO.deleted);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
};
|
|
491
|
+
}
|