@extantape/payload-plugin-signup 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/flow.js ADDED
@@ -0,0 +1,424 @@
1
+ import { MODES } from './types.js';
2
+ import { asReq, cookiesOf, withOwnResponseHeaders, hashKey, hashToken, idOf, newToken, normalizeEmail, requestFor, signupOf, SignupError, transaction, validEmail } from './util.js';
3
+ const sys = { overrideAccess: true, depth: 0 };
4
+ const internal = { signupInternal: true };
5
+ const HOUR = 60 * 60 * 1000;
6
+ const DAY = 24 * HOUR;
7
+ /** What the form says for every request it takes, whatever happened, so it never tells who has an account. */
8
+ export const RECEIVED = 'Thanks. Check your email to continue.';
9
+ // ---------------------------------------------------------------------------------------------
10
+ // Mode
11
+ /**
12
+ * The mode now. The site's lock (`forceMode`, or `SIGNUP_MODE`) wins, then the admin setting. Anything that
13
+ * can't be read or isn't a known mode is `closed`: sign-up fails shut.
14
+ */
15
+ export async function currentMode(payload) {
16
+ const o = signupOf(payload);
17
+ try {
18
+ const settings = (await payload.findGlobal({ slug: o.settings, ...sys }));
19
+ const mode = (o.forceMode ?? settings?.mode);
20
+ if (!MODES.includes(mode))
21
+ return { mode: 'closed', message: null };
22
+ const message = mode === 'invite-only' ? settings?.inviteOnlyMessage || 'Sign-up is by invitation for now.' : mode === 'closed' ? settings?.closedMessage || 'Sign-up is closed for now.' : null;
23
+ return { mode, message };
24
+ }
25
+ catch (error) {
26
+ payload.logger.error({ err: error, msg: 'signup: settings not readable, so sign-up is closed' });
27
+ return { mode: 'closed', message: 'Sign-up is closed for now.' };
28
+ }
29
+ }
30
+ // ---------------------------------------------------------------------------------------------
31
+ // Rate limits
32
+ /** True to allow. The site's limiter if it gave one, else the built-in one. Any error refuses. */
33
+ async function allowed(payload, kind, key, req) {
34
+ const o = signupOf(payload);
35
+ try {
36
+ if (o.rateLimit)
37
+ return (await o.rateLimit(kind, key, req)) === true;
38
+ const keyHash = hashKey(payload.secret, kind, key);
39
+ const since = new Date(Date.now() - HOUR).toISOString();
40
+ const { totalDocs } = await payload.count({ collection: o.attempts, where: { and: [{ kind: { equals: kind } }, { keyHash: { equals: keyHash } }, { at: { greater_than: since } }] }, overrideAccess: true });
41
+ if (totalDocs >= o.limits[kind])
42
+ return false;
43
+ await payload.create({ collection: o.attempts, data: { kind, keyHash, at: new Date().toISOString() }, ...sys });
44
+ return true;
45
+ }
46
+ catch (error) {
47
+ payload.logger.error({ err: error, msg: `signup: rate limit (${kind}) not checked, so refused` });
48
+ return false;
49
+ }
50
+ }
51
+ // ---------------------------------------------------------------------------------------------
52
+ // Emails, after the work is saved: a failed email never undoes it, and is logged.
53
+ async function send(payload, to, build) {
54
+ try {
55
+ const mail = await build();
56
+ await payload.sendEmail({ to, subject: mail.subject, html: mail.html, text: mail.text });
57
+ }
58
+ catch (error) {
59
+ payload.logger.error({ err: error, msg: 'signup: an email was not sent' });
60
+ }
61
+ }
62
+ const origin = (payload) => (payload.config.serverURL || '').replace(/\/$/, '');
63
+ const urls = (payload) => {
64
+ const o = signupOf(payload);
65
+ return {
66
+ verify: (token) => o.urls?.verify?.(token) ?? `${origin(payload)}/api/signup/verify?token=${encodeURIComponent(token)}`,
67
+ signIn: o.urls?.signIn ?? `${origin(payload)}/admin/login`,
68
+ reset: o.urls?.resetPassword ?? `${origin(payload)}/admin/forgot`,
69
+ };
70
+ };
71
+ /**
72
+ * Take a sign-up request. No account and no password yet: the account is made when the address's owner confirms
73
+ * and chooses a password (`verifySignup`), so nobody can set up an account under someone else's address.
74
+ *
75
+ * Refusals the person can act on (mode, a bad email, a site check, too many tries) throw a `SignupError`.
76
+ * Everything else ends in the same `{ ok: true }` and an email, whether the address is new, already has an
77
+ * account, or is already waiting: the form never tells. Each case does the same lookups.
78
+ *
79
+ * Cookies the checks ask for come back as `setCookies`, on the result or on the refusal (`SignupError`), for the
80
+ * response. Sign-up's own endpoint sets them; a site's own form should too.
81
+ */
82
+ export async function submitSignup(payload, input, req = {}) {
83
+ const o = signupOf(payload);
84
+ const r = await requestFor(payload, req);
85
+ const { mode, message } = await currentMode(payload);
86
+ if (mode === 'closed' || mode === 'invite-only')
87
+ throw new SignupError(mode === 'closed' ? 'closed' : 'inviteOnly', message ?? 'Sign-up is closed for now.', 403);
88
+ const address = o.clientAddress(r);
89
+ if (o.verifyHuman) {
90
+ let human = false;
91
+ try {
92
+ human = (await o.verifyHuman({ token: input.humanToken, address, req: r })) === true;
93
+ }
94
+ catch (error) {
95
+ payload.logger.error({ err: error, msg: 'signup: the human check failed, so refused' });
96
+ }
97
+ if (!human)
98
+ throw new SignupError('human', 'Please confirm you\'re a person, then try again.');
99
+ }
100
+ // Without a known address, every such request shares one "unknown" count rather than skipping the limit.
101
+ if (!(await allowed(payload, 'site', 'all', r)) || !(await allowed(payload, 'address', address ?? 'unknown', r)))
102
+ throw new SignupError('busy', 'Too many sign-up requests right now. Please try again in an hour.', 429);
103
+ const email = normalizeEmail(input.email);
104
+ if (!validEmail(email))
105
+ throw new SignupError('email', 'Please enter a valid email address.');
106
+ const asObject = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? v : {});
107
+ const details = asObject(input.details);
108
+ const answers = asObject(input.answers);
109
+ // Site checks (an age gate, for example). A refusal is shown; a check that breaks refuses (fail closed).
110
+ // Cookies a check asks for (a "stay blocked" cookie, say) go out with the answer, refusals included.
111
+ const flags = [];
112
+ const setCookies = [];
113
+ const withCookies = (error) => (setCookies.length ? Object.assign(error, { setCookies: [...setCookies] }) : error);
114
+ const taken = () => ({ ok: true, message: RECEIVED, ...(setCookies.length ? { setCookies: [...setCookies] } : {}) });
115
+ let userData = {};
116
+ for (const check of o.checks) {
117
+ let result;
118
+ try {
119
+ result = (await withOwnResponseHeaders(r, () => check({ data: details, answers, email, req: r, payload }))) ?? {};
120
+ setCookies.push(...cookiesOf(result.setCookies));
121
+ }
122
+ catch (error) {
123
+ payload.logger.error({ err: { name: error?.name }, msg: 'signup: a site check failed, so refused' });
124
+ throw withCookies(new SignupError('checkFailed', 'We couldn\'t take your request just now. Please try again later.', 503));
125
+ }
126
+ if (result.refuse)
127
+ throw withCookies(new SignupError('refused', result.refuse));
128
+ if (result.flag)
129
+ flags.push(result.flag);
130
+ if (result.userData)
131
+ userData = { ...userData, ...result.userData };
132
+ }
133
+ const scope = o.scopeField ? await (o.scopeFor?.(r) ?? null) : null;
134
+ if (o.scopeField && (scope === null || scope === undefined))
135
+ throw withCookies(new SignupError('closed', 'Sign-up is closed for now.', 403));
136
+ // Per-email limit: past it, quietly do nothing (the same answer as any other request).
137
+ if (!(await allowed(payload, 'email', email, r)))
138
+ return taken();
139
+ const token = newToken();
140
+ const outcome = await transaction(payload, r, async (tx) => {
141
+ const rq = asReq(payload, tx);
142
+ // The same three lookups in every case, so the answer's timing says little about the address.
143
+ const existing = (await payload.find({ collection: o.users, where: { email: { equals: email } }, limit: 1, pagination: false, ...sys, req: rq })).docs[0];
144
+ const declined = await payload.count({ collection: o.requests, where: { and: [{ email: { equals: email } }, { status: { equals: 'declined' } }] }, overrideAccess: true, req: rq });
145
+ const open = (await payload.find({ collection: o.requests, where: { and: [{ email: { equals: email } }, { status: { equals: 'unverified' } }] }, limit: 1, pagination: false, ...sys, req: rq })).docs[0];
146
+ if (existing)
147
+ return (existing.signupStatus ?? 'active') === 'active' ? 'hasAccount' : 'nothing';
148
+ // A decline stands for its retention time: asking again in that time changes nothing.
149
+ if (declined.totalDocs)
150
+ return 'nothing';
151
+ const request = {
152
+ email,
153
+ status: 'unverified',
154
+ details,
155
+ flag: flags.join('; ') || null,
156
+ userData,
157
+ user: null,
158
+ tokenHash: hashToken(payload.secret, token),
159
+ tokenExpires: new Date(Date.now() + o.verifyHours * HOUR).toISOString(),
160
+ ...(o.scopeField ? { [o.scopeField]: scope } : {}),
161
+ };
162
+ // One open request per address: a repeat before confirming replaces the earlier one (new details, new link).
163
+ if (open)
164
+ await payload.update({ collection: o.requests, id: open.id, data: request, ...sys, context: internal, req: rq });
165
+ else
166
+ await payload.create({ collection: o.requests, data: request, ...sys, context: internal, req: rq });
167
+ return 'verify';
168
+ });
169
+ const u = urls(payload);
170
+ const name = typeof details.name === 'string' ? details.name : undefined;
171
+ if (outcome === 'verify')
172
+ await send(payload, email, () => o.emails.verify({ email, name, url: u.verify(token), hours: o.verifyHours, req: r, payload }));
173
+ if (outcome === 'hasAccount')
174
+ await send(payload, email, () => o.emails.alreadyHasAccount({ email, name, signInURL: u.signIn, resetURL: u.reset, req: r, payload }));
175
+ return taken();
176
+ }
177
+ /** A field's label as written in the config, in plain text. Labels given per language use English, then any. */
178
+ function labelOf(field) {
179
+ const l = field.label;
180
+ if (typeof l === 'string' && l)
181
+ return l;
182
+ if (l && typeof l === 'object') {
183
+ const text = [l.en, ...Object.values(l)].find((v) => typeof v === 'string' && v);
184
+ if (text)
185
+ return text;
186
+ }
187
+ return String(field.name).replace(/([a-z])([A-Z])/g, '$1 $2').replace(/^./, (c) => c.toUpperCase());
188
+ }
189
+ /** A stored value as words: select values show their option's label, yes/no for checkboxes, dates as dates. */
190
+ function showValue(field, value) {
191
+ if (value === null || value === undefined || value === '')
192
+ return null;
193
+ const one = (v) => {
194
+ const option = Array.isArray(field.options) ? field.options.find((x) => (typeof x === 'object' && x ? x.value : x) === v) : undefined;
195
+ if (option && typeof option === 'object')
196
+ return typeof option.label === 'string' ? option.label : String(v);
197
+ return String(v);
198
+ };
199
+ if (field.type === 'checkbox')
200
+ return value ? 'Yes' : 'No';
201
+ if (field.type === 'date' && typeof value === 'string') {
202
+ const d = new Date(value);
203
+ return Number.isNaN(d.getTime()) ? value : d.toISOString().slice(0, 10);
204
+ }
205
+ if (Array.isArray(value))
206
+ return value.every((v) => ['string', 'number'].includes(typeof v)) ? value.map(one).join(', ') : null;
207
+ if (typeof value === 'string' || typeof value === 'number' || typeof value === 'bigint')
208
+ return one(value);
209
+ return null;
210
+ }
211
+ /**
212
+ * What the person asked for, from the link, so the confirm page can show it before they press the button: the
213
+ * email, and the fields the site listed in `confirmShows`, as words. Only those: a mistyped address sends the link
214
+ * to a stranger. Hidden fields and fields with their own read access are never shown, even when listed. Answers
215
+ * meant only for checks were never stored, and what checks set for the account isn't shown. It only reads: the
216
+ * link still works afterwards. An unknown, used or expired link gets the same `badLink` refusal as confirming would.
217
+ * `countAttempt: false` is for a page that just counted one through `verifySignup`.
218
+ */
219
+ export async function readSignupRequest(payload, input, req = {}, { countAttempt = true } = {}) {
220
+ const o = signupOf(payload);
221
+ const r = await requestFor(payload, req);
222
+ if (countAttempt && !(await allowed(payload, 'verify', o.clientAddress(r) ?? 'unknown', r)))
223
+ throw new SignupError('busy', 'Too many tries. Please try again in an hour.', 429);
224
+ const bad = new SignupError('badLink', 'This link has expired or was already used. You can ask again from the sign-up page.');
225
+ const token = input?.token;
226
+ if (typeof token !== 'string' || token.length < 20 || token.length > 200)
227
+ throw bad;
228
+ const where = { and: [{ tokenHash: { equals: hashToken(payload.secret, token) } }, { status: { equals: 'unverified' } }] };
229
+ const request = (await payload.find({ collection: o.requests, where, limit: 1, pagination: false, ...sys, req: asReq(payload, r) })).docs[0];
230
+ if (!request || !request.tokenExpires || new Date(request.tokenExpires).getTime() < Date.now())
231
+ throw bad;
232
+ const stored = (request.details ?? {});
233
+ const details = [];
234
+ const listed = new Set(o.confirmShows);
235
+ for (const field of o.fields) { // eslint-disable-line @typescript-eslint/no-explicit-any
236
+ if (typeof field.name !== 'string' || !listed.has(field.name) || field.admin?.hidden || field.hidden || field.access?.read)
237
+ continue;
238
+ const value = showValue(field, stored[field.name]);
239
+ if (value !== null)
240
+ details.push({ name: field.name, label: labelOf(field), value });
241
+ }
242
+ return { email: request.email, details };
243
+ }
244
+ /**
245
+ * Confirm the email from the link, choosing the account's password: only the person with the inbox can do this,
246
+ * so the account belongs to the address's owner. The account is made now: waiting for approval, or (open mode)
247
+ * active at once. If sign-up was shut since they asked, the request still waits for staff.
248
+ */
249
+ export async function verifySignup(payload, input, req = {}) {
250
+ const o = signupOf(payload);
251
+ const r = await requestFor(payload, req);
252
+ const address = o.clientAddress(r);
253
+ if (!(await allowed(payload, 'verify', address ?? 'unknown', r)))
254
+ throw new SignupError('busy', 'Too many tries. Please try again in an hour.', 429);
255
+ const bad = new SignupError('badLink', 'This link has expired or was already used. You can ask again from the sign-up page.');
256
+ const token = input?.token;
257
+ if (typeof token !== 'string' || token.length < 20 || token.length > 200)
258
+ throw bad;
259
+ const password = typeof input?.password === 'string' ? input.password : '';
260
+ if (password.length < o.minPasswordLength)
261
+ throw new SignupError('password', `Please choose a password of at least ${o.minPasswordLength} characters.`);
262
+ if (password.length > 256)
263
+ throw new SignupError('password', 'Please choose a password of 256 characters or fewer.');
264
+ const { mode } = await currentMode(payload);
265
+ // Cookies confirm checks ask for go out with the answer, refusals included, as at request time.
266
+ const setCookies = [];
267
+ const withCookies = (error) => (setCookies.length ? Object.assign(error, { setCookies: [...setCookies] }) : error);
268
+ let queued = null;
269
+ const result = await transaction(payload, r, async (tx) => {
270
+ const rq = asReq(payload, tx);
271
+ const where = { and: [{ tokenHash: { equals: hashToken(payload.secret, token) } }, { status: { equals: 'unverified' } }] };
272
+ const request = (await payload.find({ collection: o.requests, where, limit: 1, pagination: false, ...sys, req: rq })).docs[0];
273
+ if (!request || !request.tokenExpires || new Date(request.tokenExpires).getTime() < Date.now())
274
+ throw bad;
275
+ // An account made for this address since they asked (an invitation, say): theirs already; nothing to make.
276
+ const existing = await payload.count({ collection: o.users, where: { email: { equals: request.email } }, overrideAccess: true, req: rq });
277
+ if (existing.totalDocs)
278
+ throw new SignupError('hasAccount', 'This address already has an account. Sign in, or set a new password if you\'ve forgotten it.', 409);
279
+ const details = (request.details ?? {});
280
+ // The site's checks again, now that the person is here: what changed since they asked counts.
281
+ let flag = request.flag ?? null;
282
+ let userData = (request.userData ?? {});
283
+ for (const check of o.confirmChecks) {
284
+ let result;
285
+ try {
286
+ result = (await withOwnResponseHeaders(r, () => check({ data: details, email: request.email, flag, req: r, payload }))) ?? {};
287
+ setCookies.push(...cookiesOf(result.setCookies));
288
+ }
289
+ catch (error) {
290
+ // Fail closed, and leave the request as it was, so the same link works once the check does.
291
+ payload.logger.error({ err: { name: error?.name }, msg: 'signup: a confirm check failed, so refused for now' });
292
+ throw withCookies(new SignupError('checkFailed', 'We couldn\'t finish this just now. Please try the same link again later.', 503));
293
+ }
294
+ if (result.refuse) {
295
+ // A refusal is a decline: the link stops working and asking again changes nothing for the retention time.
296
+ await payload.update({ collection: o.requests, id: request.id, data: { status: 'declined', decidedAt: new Date().toISOString(), tokenHash: null, tokenExpires: null }, ...sys, context: internal, req: rq });
297
+ return { status: 'refused', request, message: result.refuse };
298
+ }
299
+ if (result.flag)
300
+ flag = [flag, result.flag].filter(Boolean).join('; ');
301
+ if (result.userData)
302
+ userData = { ...userData, ...result.userData };
303
+ }
304
+ const user = (await payload.create({
305
+ collection: o.users,
306
+ data: { ...o.toUser(details), ...userData, email: request.email, password, signupStatus: 'pending' },
307
+ ...sys,
308
+ context: internal,
309
+ req: rq,
310
+ }));
311
+ const verified = { ...request, flag, userData, status: 'pending', user: user.id, verifiedAt: new Date().toISOString(), tokenHash: null, tokenExpires: null };
312
+ if (mode === 'open') {
313
+ const decided = await decide(payload, verified, 'approved', tx);
314
+ queued = tx;
315
+ await payload.update({ collection: o.requests, id: request.id, data: decided, ...sys, context: internal, req: rq });
316
+ return { status: 'active', request };
317
+ }
318
+ await payload.update({ collection: o.requests, id: request.id, data: verified, ...sys, context: internal, req: rq });
319
+ return { status: 'pending', request };
320
+ }).catch(async (error) => {
321
+ // Two confirms of one link at once (a double click): the database refuses the slower one. Once the faster one
322
+ // has used the link, the slower one is told so, as any used link is, instead of a 500.
323
+ if (!(error instanceof SignupError) && (await linkUsed(payload, token)))
324
+ throw bad;
325
+ throw error;
326
+ });
327
+ // Refused at confirm: the decline is committed, then the person is told why. No account was made.
328
+ if (result.status === 'refused')
329
+ throw withCookies(new SignupError('refused', result.message));
330
+ const name = typeof result.request.details?.name === 'string' ? result.request.details.name : undefined;
331
+ const email = result.request.email;
332
+ if (result.status === 'pending')
333
+ await send(payload, email, () => o.emails.pending({ email, name, req: r, payload }));
334
+ // Approved (open mode): `decide` queued the email; it goes now that the transaction has committed.
335
+ else if (queued)
336
+ await flushAfterCommit(queued);
337
+ return { status: result.status, ...(setCookies.length ? { setCookies: [...setCookies] } : {}) };
338
+ }
339
+ /**
340
+ * Whether a link that just failed to confirm was used by another confirm. Waits up to about two seconds for that
341
+ * confirm to finish; a link still open after that wasn't used, and the original error stands.
342
+ */
343
+ async function linkUsed(payload, token) {
344
+ const o = signupOf(payload);
345
+ const where = { and: [{ tokenHash: { equals: hashToken(payload.secret, token) } }, { status: { equals: 'unverified' } }] };
346
+ for (let i = 0; i < 20; i++) {
347
+ try {
348
+ if ((await payload.count({ collection: o.requests, where, overrideAccess: true })).totalDocs === 0)
349
+ return true;
350
+ }
351
+ catch {
352
+ return false;
353
+ }
354
+ await new Promise((resolve) => setTimeout(resolve, 100));
355
+ }
356
+ return false;
357
+ }
358
+ // ---------------------------------------------------------------------------------------------
359
+ // Decide
360
+ /**
361
+ * Approve or decline a waiting request, inside the caller's transaction: approving activates the account and runs
362
+ * the site's `onApprove`; declining deletes the account (it never signed in). Returns the request's new data. The
363
+ * email goes after the transaction commits; a failed email is logged and doesn't undo the decision.
364
+ */
365
+ export async function decide(payload, request, to, req) {
366
+ const o = signupOf(payload);
367
+ const userID = idOf(request.user);
368
+ const by = req.user && req.user.collection === o.users ? idOf(req.user) : null;
369
+ const r = asReq(payload, req);
370
+ const name = typeof request.details?.name === 'string' ? request.details.name : undefined;
371
+ if (to === 'approved') {
372
+ if (userID === null)
373
+ throw new SignupError('noAccount', 'This request has no account to approve.');
374
+ const user = (await payload.update({ collection: o.users, id: userID, data: { signupStatus: 'active' }, ...sys, req: r }));
375
+ await o.onApprove?.({ user, request, req: r, payload });
376
+ after(req, () => send(payload, request.email, () => o.emails.approved({ email: request.email, name, signInURL: urls(payload).signIn, req: r, payload })));
377
+ }
378
+ else {
379
+ if (userID !== null)
380
+ await payload.delete({ collection: o.users, id: userID, ...sys, req: r });
381
+ const note = typeof request.note === 'string' && request.note.trim() ? request.note.trim() : undefined;
382
+ after(req, () => send(payload, request.email, () => o.emails.declined({ email: request.email, name, note, req: r, payload })));
383
+ }
384
+ return { ...request, status: to, decidedAt: new Date().toISOString(), decidedBy: by, user: to === 'declined' ? null : userID, tokenHash: null, tokenExpires: null };
385
+ }
386
+ /**
387
+ * Emails `decide` leaves for after the work is saved. They're kept on the request object itself (Payload gives
388
+ * each hook its own context object, but the same req), and sent by the requests collection's afterChange, or by
389
+ * the plugin's own flows once their transaction commits. Work that rolls back never reaches afterChange.
390
+ */
391
+ const QUEUE = Symbol.for('signup.afterCommit');
392
+ function after(req, fn) {
393
+ const r = req;
394
+ (r[QUEUE] ??= []).push(fn);
395
+ }
396
+ /** Send what `decide` queued on this request. */
397
+ export async function flushAfterCommit(req) {
398
+ const jobs = (req[QUEUE] ?? []).splice(0);
399
+ for (const job of jobs)
400
+ await job();
401
+ }
402
+ // ---------------------------------------------------------------------------------------------
403
+ // Retention
404
+ /**
405
+ * Delete what's past its time: requests decided, abandoned or never confirmed more than `retentionDays` ago, and
406
+ * rate-limit counts older than a day. Waiting requests are kept until
407
+ * someone decides them. Run it daily, from the site's scheduled job.
408
+ */
409
+ export async function pruneSignupRequests(payload, now = new Date()) {
410
+ const o = signupOf(payload);
411
+ const cutoff = new Date(now.getTime() - o.retentionDays * DAY).toISOString();
412
+ const where = {
413
+ or: [
414
+ { and: [{ status: { in: ['approved', 'declined'] } }, { decidedAt: { less_than: cutoff } }] },
415
+ { and: [{ status: { equals: 'unverified' } }, { createdAt: { less_than: cutoff } }] },
416
+ ],
417
+ };
418
+ const old = (await payload.find({ collection: o.requests, where, pagination: false, ...sys })).docs;
419
+ // Unconfirmed requests have no account (one is made only on confirming); declined ones lost theirs at the decision.
420
+ for (const request of old)
421
+ await payload.delete({ collection: o.requests, id: request.id, ...sys });
422
+ const attempts = await payload.delete({ collection: o.attempts, where: { at: { less_than: new Date(now.getTime() - DAY).toISOString() } }, ...sys });
423
+ return { requests: old.length, attempts: attempts.docs.length };
424
+ }
@@ -0,0 +1,6 @@
1
+ export { signupPlugin } from './plugin.js';
2
+ export { currentMode, decide, pruneSignupRequests, readSignupRequest, RECEIVED, submitSignup, verifySignup, type RequestDetail, type SignupInput, type SubmitResult, type VerifyInput, type VerifyResult } from './flow.js';
3
+ export { defaultEmails } from './emails.js';
4
+ export { SignupError } from './util.js';
5
+ export type { Check, ConfirmCheck, CheckResult, Email, EmailContext, Emails, LimitKind, RateLimit, RequestStatus, SignupMode, SignupPluginOptions, SignupStatus, } from './types.js';
6
+ export { MODES } from './types.js';
package/dist/index.js ADDED
@@ -0,0 +1,5 @@
1
+ export { signupPlugin } from './plugin.js';
2
+ export { currentMode, decide, pruneSignupRequests, readSignupRequest, RECEIVED, submitSignup, verifySignup } from './flow.js';
3
+ export { defaultEmails } from './emails.js';
4
+ export { SignupError } from './util.js';
5
+ export { MODES } from './types.js';
@@ -0,0 +1,8 @@
1
+ import { type Plugin } from 'payload';
2
+ import { type SignupPluginOptions } from './types.js';
3
+ /**
4
+ * Sign-up: a request form's back end with four modes (open, approval, invite-only, closed), email verification,
5
+ * a staff queue, and retention. It adds `signupStatus` to the auth collection, and refuses sign-in to any account
6
+ * that isn't active.
7
+ */
8
+ export declare const signupPlugin: (options: SignupPluginOptions) => Plugin;
package/dist/plugin.js ADDED
@@ -0,0 +1,99 @@
1
+ import { APIError, Forbidden } from 'payload';
2
+ import { attemptsCollection, requestsCollection, settingsGlobal, signupStatusField } from './collections.js';
3
+ import { defaultEmails } from './emails.js';
4
+ import { signupEndpoints } from './endpoints.js';
5
+ import { flushAfterCommit } from './flow.js';
6
+ import { MODES } from './types.js';
7
+ import { defaultClientAddress } from './util.js';
8
+ /** The site's lock on the mode: an option, else `SIGNUP_MODE`. An unknown value locks it closed (fail shut). */
9
+ const lockedMode = (forced) => {
10
+ const v = forced === undefined ? process.env.SIGNUP_MODE?.trim() : forced;
11
+ if (!v)
12
+ return null;
13
+ return MODES.includes(v) ? v : 'closed';
14
+ };
15
+ /**
16
+ * Sign-up: a request form's back end with four modes (open, approval, invite-only, closed), email verification,
17
+ * a staff queue, and retention. It adds `signupStatus` to the auth collection, and refuses sign-in to any account
18
+ * that isn't active.
19
+ */
20
+ export const signupPlugin = (options) => (config) => {
21
+ const users = options.users ?? config.admin?.user ?? 'users';
22
+ const o = {
23
+ users,
24
+ requests: options.slugs?.requests ?? 'signup-requests',
25
+ settings: options.slugs?.settings ?? 'signup-settings',
26
+ attempts: options.slugs?.attempts ?? 'signup-attempts',
27
+ fields: options.fields ?? [],
28
+ checks: options.checks ?? [],
29
+ confirmChecks: options.confirmChecks ?? [],
30
+ toUser: options.toUser ?? ((data) => (typeof data.name === 'string' ? { name: data.name } : {})),
31
+ onApprove: options.onApprove,
32
+ emails: { ...defaultEmails, ...options.emails },
33
+ urls: options.urls,
34
+ rateLimit: options.rateLimit,
35
+ limits: { address: 5, email: 3, site: 200, verify: 20, ...options.limits },
36
+ clientAddress: options.clientAddress ?? defaultClientAddress,
37
+ verifyHuman: options.verifyHuman,
38
+ manage: options?.access?.manage,
39
+ scopeField: options.scopeField,
40
+ scopeFor: options.scopeFor,
41
+ retentionDays: options.retentionDays ?? 90,
42
+ verifyHours: options.verifyHours ?? 48,
43
+ confirmShows: options.confirmShows ?? [],
44
+ minPasswordLength: options.minPasswordLength ?? 10,
45
+ forceMode: lockedMode(options.forceMode),
46
+ adminGroup: options.adminGroup ?? 'Sign-up',
47
+ };
48
+ // Fail closed: without it, every signed-in member (children included) could read and decide the queue.
49
+ if (typeof o.manage !== 'function')
50
+ throw new Error('Sign-up: give `access.manage`, saying who may see and decide sign-up requests and change the mode.');
51
+ if (o.scopeField && !o.scopeFor)
52
+ throw new Error('Sign-up: with `scopeField`, give `scopeFor` so each request knows its site.');
53
+ const collections = config.collections ?? [];
54
+ const auth = collections.find((c) => c.slug === users);
55
+ if (!auth?.auth)
56
+ throw new Error(`Sign-up: "${users}" isn't an auth collection in this config. Set \`users\`.`);
57
+ const clash = collections.filter((c) => [o.requests, o.attempts].includes(c.slug)).map((c) => c.slug);
58
+ if (clash.length || (config.globals ?? []).some((g) => g.slug === o.settings))
59
+ throw new Error(`Sign-up: ${[...clash, o.settings].join(', ')} already exist in this config. Give Sign-up other slugs with \`slugs\`.`);
60
+ const withStatus = {
61
+ ...auth,
62
+ fields: [...auth.fields, signupStatusField(o)],
63
+ hooks: {
64
+ ...auth.hooks,
65
+ // Accounts come through sign-up (or from staff and the site's own code), never from a signed-out API call,
66
+ // which would skip verifying and approval. The very first user is still allowed, so a new site can start.
67
+ // Sign-up's own create on confirming is marked internal: it runs inside a REST request too.
68
+ beforeOperation: [
69
+ async ({ operation, req, context }) => {
70
+ if (operation !== 'create' || req.user || req.payloadAPI === 'local' || context?.signupInternal === true)
71
+ return;
72
+ const { totalDocs } = await req.payload.count({ collection: users, overrideAccess: true });
73
+ if (totalDocs > 0)
74
+ throw new Forbidden(req.t);
75
+ },
76
+ ...(auth.hooks?.beforeOperation ?? []),
77
+ ],
78
+ // Payload runs this only after the password matched, so only the account's owner learns it's waiting.
79
+ beforeLogin: [
80
+ ({ user }) => {
81
+ const status = user.signupStatus ?? 'active';
82
+ if (status === 'active')
83
+ return user;
84
+ throw new APIError(status === 'pending' ? 'Your account is waiting for approval. We\'ll email you when it\'s ready.' : 'This account can\'t sign in yet.', 403, { code: `signup-${status}` }, true);
85
+ },
86
+ ...(auth.hooks?.beforeLogin ?? []),
87
+ ],
88
+ },
89
+ };
90
+ const requests = requestsCollection(o);
91
+ requests.hooks = { ...requests.hooks, afterChange: [async ({ req, doc }) => (await flushAfterCommit(req), doc)] };
92
+ return {
93
+ ...config,
94
+ custom: { ...config.custom, signup: o },
95
+ collections: [...collections.map((c) => (c.slug === users ? withStatus : c)), requests, attemptsCollection(o)],
96
+ globals: [...(config.globals ?? []), settingsGlobal(o)],
97
+ endpoints: [...(config.endpoints ?? []), ...signupEndpoints],
98
+ };
99
+ };