@gopherium/react-auth 0.13.0 → 0.14.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/README.md +39 -1
- package/dist/account.d.ts +12 -0
- package/dist/account.js +11 -0
- package/dist/admin/index.d.ts +2 -9
- package/dist/admin/index.js +3 -10
- package/dist/api.d.ts +23 -4
- package/dist/api.js +21 -8
- package/dist/index.d.ts +3 -0
- package/dist/index.js +1 -0
- package/dist/languages/es-ES.json +1 -1
- package/dist/languages/languages/es-ES.json +1 -1
- package/dist/setup.d.ts +46 -0
- package/dist/setup.js +132 -0
- package/dist/testing/index.d.ts +1 -0
- package/dist/testing/index.js +1 -0
- package/dist/testing/setup.d.ts +63 -0
- package/dist/testing/setup.js +119 -0
- package/dist/transport.d.ts +4 -0
- package/dist/wpds/SetupAccountStep.d.ts +35 -0
- package/dist/wpds/SetupAccountStep.js +367 -0
- package/dist/wpds/SetupCodeStep.d.ts +18 -0
- package/dist/wpds/SetupCodeStep.js +72 -0
- package/dist/wpds/index.d.ts +2 -0
- package/dist/wpds/index.js +2 -0
- package/dist/wpds/password.d.ts +6 -0
- package/dist/wpds/password.js +35 -0
- package/dist/wpds/setupWords.d.ts +13 -0
- package/dist/wpds/setupWords.js +39 -0
- package/dist/wpds/strength.d.ts +26 -0
- package/dist/wpds/strength.js +45 -0
- package/dist/wpds/style.css +205 -0
- package/package.json +4 -2
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
import { HttpResponse, http } from 'msw';
|
|
3
|
+
/** createdAccount is the canned first account a setup creates. */
|
|
4
|
+
export const createdAccount = {
|
|
5
|
+
id: '0198b2f0-0000-7000-8000-000000000002',
|
|
6
|
+
email: 'maria@example.com',
|
|
7
|
+
name: 'Maria Perez',
|
|
8
|
+
disabled: false,
|
|
9
|
+
role: 'owner',
|
|
10
|
+
created_at: new Date('2026-10-19T10:00:00Z'),
|
|
11
|
+
};
|
|
12
|
+
/** The answer the site gives for each reason it refuses a setup code. */
|
|
13
|
+
const codeRefused = {
|
|
14
|
+
invalid: () => HttpResponse.json({ error: 'setup code invalid', code: 'setup_code_invalid' }, { status: 401 }),
|
|
15
|
+
expired: () => HttpResponse.json({ error: 'setup code expired', code: 'setup_code_expired' }, { status: 401 }),
|
|
16
|
+
file: () => HttpResponse.json({ error: 'setup code file unusable', code: 'setup_code_file' }, { status: 500 }),
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* Answers a setup request as already set up.
|
|
20
|
+
* @returns The msw response.
|
|
21
|
+
*/
|
|
22
|
+
function setupDone() {
|
|
23
|
+
return HttpResponse.json({ error: 'setup already done', code: 'setup_done' }, { status: 409 });
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Answers a setup request as rate limited.
|
|
27
|
+
* @returns The msw response.
|
|
28
|
+
*/
|
|
29
|
+
function setupThrottled() {
|
|
30
|
+
return HttpResponse.json({ error: 'too many login attempts, try again later', code: 'login_rate_limited' }, { status: 429 });
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Answers a setup request as a site that is still starting.
|
|
34
|
+
* @returns The msw response.
|
|
35
|
+
*/
|
|
36
|
+
function siteStarting() {
|
|
37
|
+
return new HttpResponse(null, { status: 503 });
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Answers the setup state.
|
|
41
|
+
* @param open - Whether the site still waits for its first account.
|
|
42
|
+
* @returns The msw handler.
|
|
43
|
+
*/
|
|
44
|
+
export function setupStatus(open) {
|
|
45
|
+
return http.get('/api/auth/setup', () => HttpResponse.json({ open }));
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Accepts a setup code check.
|
|
49
|
+
* @returns The msw handler.
|
|
50
|
+
*/
|
|
51
|
+
export function setupCodeOk() {
|
|
52
|
+
return http.post('/api/auth/setup/code', () => new HttpResponse(null, { status: 204 }));
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Refuses a setup code check for the given reason.
|
|
56
|
+
* @param reason - Why the site refuses the code.
|
|
57
|
+
* @returns The msw handler.
|
|
58
|
+
*/
|
|
59
|
+
export function setupCodeRefused(reason) {
|
|
60
|
+
return http.post('/api/auth/setup/code', codeRefused[reason]);
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Refuses a setup code check as already set up.
|
|
64
|
+
* @returns The msw handler.
|
|
65
|
+
*/
|
|
66
|
+
export function setupCodeClosed() {
|
|
67
|
+
return http.post('/api/auth/setup/code', setupDone);
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Refuses a setup code check as rate limited.
|
|
71
|
+
* @returns The msw handler.
|
|
72
|
+
*/
|
|
73
|
+
export function setupCodeRateLimited() {
|
|
74
|
+
return http.post('/api/auth/setup/code', setupThrottled);
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Answers a setup code check as a site that is still starting.
|
|
78
|
+
* @returns The msw handler.
|
|
79
|
+
*/
|
|
80
|
+
export function setupCodeStarting() {
|
|
81
|
+
return http.post('/api/auth/setup/code', siteStarting);
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Creates the first account as the given account.
|
|
85
|
+
* @param account - The account the site answers.
|
|
86
|
+
* @returns The msw handler.
|
|
87
|
+
*/
|
|
88
|
+
export function firstAccountOk(account = createdAccount) {
|
|
89
|
+
return http.post('/api/auth/setup', () => HttpResponse.json(account, { status: 201 }));
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Refuses the first account's setup code for the given reason.
|
|
93
|
+
* @param reason - Why the site refuses the code.
|
|
94
|
+
* @returns The msw handler.
|
|
95
|
+
*/
|
|
96
|
+
export function firstAccountCodeRefused(reason) {
|
|
97
|
+
return http.post('/api/auth/setup', codeRefused[reason]);
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Refuses the first account as already set up.
|
|
101
|
+
* @returns The msw handler.
|
|
102
|
+
*/
|
|
103
|
+
export function firstAccountClosed() {
|
|
104
|
+
return http.post('/api/auth/setup', setupDone);
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Refuses the first account as rate limited.
|
|
108
|
+
* @returns The msw handler.
|
|
109
|
+
*/
|
|
110
|
+
export function firstAccountRateLimited() {
|
|
111
|
+
return http.post('/api/auth/setup', setupThrottled);
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Answers the first account as a site that is still starting.
|
|
115
|
+
* @returns The msw handler.
|
|
116
|
+
*/
|
|
117
|
+
export function firstAccountStarting() {
|
|
118
|
+
return http.post('/api/auth/setup', siteStarting);
|
|
119
|
+
}
|
package/dist/transport.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Invitation, NewInvite, User as Account, NewUser } from './admin/index.js';
|
|
2
2
|
import type { User } from './api.js';
|
|
3
|
+
import type { FirstAccount } from './setup.js';
|
|
3
4
|
/**
|
|
4
5
|
* AuthTransport carries every backend call of the package, so a consumer
|
|
5
6
|
* can replace the REST transport with its own while keeping the screens,
|
|
@@ -19,6 +20,9 @@ export interface AuthTransport {
|
|
|
19
20
|
acceptInvite(token: string, password: string): Promise<User>;
|
|
20
21
|
requestPasswordReset(email: string): Promise<void>;
|
|
21
22
|
resetPassword(token: string, password: string): Promise<void>;
|
|
23
|
+
isSetupOpen(signal?: AbortSignal): Promise<boolean>;
|
|
24
|
+
checkSetupCode(code: string): Promise<void>;
|
|
25
|
+
createFirstAccount(input: FirstAccount): Promise<Account>;
|
|
22
26
|
}
|
|
23
27
|
/**
|
|
24
28
|
* Replaces backend calls with the given implementations, keeping the REST
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import type { Account } from '../account.js';
|
|
3
|
+
import { SetupCodeError } from '../setup.js';
|
|
4
|
+
import type { FirstAccount } from '../setup.js';
|
|
5
|
+
/** The wizard's words for an error the step does not know. */
|
|
6
|
+
type ErrorWords = (error: unknown) => ReactNode | undefined;
|
|
7
|
+
/**
|
|
8
|
+
* Renders the first account's Name, Password and Your Email rows inside an application's setup wizard.
|
|
9
|
+
* @param code - The one-time setup code the wizard collected before this step.
|
|
10
|
+
* @param submitLabel - The wizard's words for the submit button.
|
|
11
|
+
* @param onCreated - Called with the account once the site created it.
|
|
12
|
+
* @param onSubmit - Sends the account through the wizard's own route, in place of the transport.
|
|
13
|
+
* @param onError - The wizard's words for an error the step does not know.
|
|
14
|
+
* @param onCodeRefused - Called with the reason when the site refuses the setup code.
|
|
15
|
+
* @param onClosed - Called when the site already has its first account.
|
|
16
|
+
* @param generatedPasswordLength - The length of the password the step starts with.
|
|
17
|
+
* @param userInputs - The wizard's own values the meter treats as easy to guess.
|
|
18
|
+
* @param before - The wizard's rows above Name.
|
|
19
|
+
* @param after - The wizard's rows below Your Email.
|
|
20
|
+
* @returns The setup account step element.
|
|
21
|
+
*/
|
|
22
|
+
export declare function SetupAccountStep({ code, submitLabel, onCreated, onSubmit, onError, onCodeRefused, onClosed, generatedPasswordLength, userInputs, before, after, }: {
|
|
23
|
+
code: string;
|
|
24
|
+
submitLabel: string;
|
|
25
|
+
onCreated: (account: Account) => void | Promise<void>;
|
|
26
|
+
onSubmit?: (input: FirstAccount) => Promise<Account>;
|
|
27
|
+
onError?: ErrorWords;
|
|
28
|
+
onCodeRefused?: (reason: SetupCodeError['reason']) => void;
|
|
29
|
+
onClosed?: () => void;
|
|
30
|
+
generatedPasswordLength?: number;
|
|
31
|
+
userInputs?: string[];
|
|
32
|
+
before?: ReactNode;
|
|
33
|
+
after?: ReactNode;
|
|
34
|
+
}): import("react").JSX.Element;
|
|
35
|
+
export {};
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
import { useMutation } from '@tanstack/react-query';
|
|
4
|
+
import { __, _n, _x, sprintf } from '@wordpress/i18n';
|
|
5
|
+
import { Button, CheckboxControl, Field, Input, Text } from '@wordpress/ui';
|
|
6
|
+
import { useEffect, useEffectEvent, useId, useRef, useState } from 'react';
|
|
7
|
+
import { ValidationError } from '../api.js';
|
|
8
|
+
import { DOMAIN } from '../domain.js';
|
|
9
|
+
import { SetupClosedError, SetupCodeError, SiteStartingError, createFirstAccount, isSetupOpen } from '../setup.js';
|
|
10
|
+
import { generatePassword } from './password.js';
|
|
11
|
+
import { setupRefusal } from './setupWords.js';
|
|
12
|
+
import { disallowedWords, levelOf, loadMeter } from './strength.js';
|
|
13
|
+
/** The length of the password the step starts with, unless the wizard asks for another. */
|
|
14
|
+
const GENERATED_PASSWORD_LENGTH = 18;
|
|
15
|
+
/** The word the meter box holds for an empty password. */
|
|
16
|
+
const NO_WORD = '\u00a0';
|
|
17
|
+
/** The arrow drawn before the Caps Lock words. */
|
|
18
|
+
const CAPS_ARROW = (_jsx("span", { "aria-hidden": "true", className: "gopherium-setup__caps-icon", children: _jsxs("svg", { viewBox: "0 0 24 26", fill: "currentColor", stroke: "currentColor", strokeWidth: "0.5", children: [_jsx("path", { d: "M12 5L19 15H16V19H8V15H5L12 5Z" }), _jsx("rect", { x: "8", y: "21", width: "8", height: "1.5", rx: "0.75" })] }) }));
|
|
19
|
+
/**
|
|
20
|
+
* Renders a sentence with the part between its code tags as code.
|
|
21
|
+
* @param sentence - The translated sentence.
|
|
22
|
+
* @returns The sentence with its code part as markup.
|
|
23
|
+
*/
|
|
24
|
+
function withCode(sentence) {
|
|
25
|
+
return sentence.split(/<\/?code>/).map((part, index) => (index % 2 === 1 ? _jsx("code", { children: part }, index) : part));
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Returns the password hint with the server's floor as its number.
|
|
29
|
+
* @param min - The fewest characters the server accepts.
|
|
30
|
+
* @returns The hint.
|
|
31
|
+
*/
|
|
32
|
+
function passwordHint(min) {
|
|
33
|
+
const hint = _n('Hint: The password should be at least %d character long. ' +
|
|
34
|
+
'To make it stronger, use upper and lower case letters, numbers, and symbols like ! " ? $ %% ^ & ).', 'Hint: The password should be at least %d characters long. ' +
|
|
35
|
+
'To make it stronger, use upper and lower case letters, numbers, and symbols like ! " ? $ %% ^ & ).', min, DOMAIN);
|
|
36
|
+
return sprintf(hint, min);
|
|
37
|
+
}
|
|
38
|
+
const validationWords = new Map([
|
|
39
|
+
[
|
|
40
|
+
'email_invalid',
|
|
41
|
+
() => withCode(__('Sorry, that is not a valid email address. ' +
|
|
42
|
+
'Email addresses look like <code>username@example.com</code>.', DOMAIN)),
|
|
43
|
+
],
|
|
44
|
+
['name_required', () => __('Please provide a name.', DOMAIN)],
|
|
45
|
+
['name_too_long', () => __('This name is too long.', DOMAIN)],
|
|
46
|
+
['password_too_short', (meta) => (typeof meta.min === 'number' ? passwordHint(meta.min) : undefined)],
|
|
47
|
+
['password_too_long', () => __('This password is too long.', DOMAIN)],
|
|
48
|
+
]);
|
|
49
|
+
/**
|
|
50
|
+
* Returns the sentence for a validation refusal the step knows.
|
|
51
|
+
* @param error - The error the submit raised.
|
|
52
|
+
* @returns The sentence, or undefined for any other error.
|
|
53
|
+
*/
|
|
54
|
+
function validationRefusal(error) {
|
|
55
|
+
return error instanceof ValidationError ? validationWords.get(error.code ?? '')?.(error.meta ?? {}) : undefined;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Returns the sentence the step shows for a refused submit.
|
|
59
|
+
* @param error - The error the submit raised.
|
|
60
|
+
* @param onError - The wizard's words for an error the step does not know, if it has any.
|
|
61
|
+
* @returns The sentence to show.
|
|
62
|
+
*/
|
|
63
|
+
function refusalMessage(error, onError) {
|
|
64
|
+
return (setupRefusal(error) ??
|
|
65
|
+
validationRefusal(error) ??
|
|
66
|
+
onError?.(error) ??
|
|
67
|
+
__('The account could not be created, please try again.', DOMAIN));
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Reports whether a failed submit may already have made the account.
|
|
71
|
+
* @param error - The error the submit raised.
|
|
72
|
+
* @returns True after a 503 or a network failure.
|
|
73
|
+
*/
|
|
74
|
+
function mayHaveCreated(error) {
|
|
75
|
+
return error instanceof SiteStartingError || error instanceof TypeError;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Asks the site once whether it still waits for its first account, and tells the wizard when it does not.
|
|
79
|
+
* @param onClosed - Called when the site answers that setup is closed.
|
|
80
|
+
*/
|
|
81
|
+
async function probeClosed(onClosed) {
|
|
82
|
+
const open = await isSetupOpen().catch(() => true);
|
|
83
|
+
if (!open) {
|
|
84
|
+
onClosed?.();
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Tells the wizard what a refused submit means for it.
|
|
89
|
+
* @param error - The error the submit raised.
|
|
90
|
+
* @param hooks - The wizard's optional callbacks.
|
|
91
|
+
*/
|
|
92
|
+
async function reportRefusal(error, hooks) {
|
|
93
|
+
if (error instanceof SetupCodeError) {
|
|
94
|
+
hooks.onCodeRefused?.(error.reason);
|
|
95
|
+
}
|
|
96
|
+
else if (error instanceof SetupClosedError) {
|
|
97
|
+
hooks.onClosed?.();
|
|
98
|
+
}
|
|
99
|
+
else if (mayHaveCreated(error)) {
|
|
100
|
+
await probeClosed(hooks.onClosed);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Returns what the meter knows of a password at the moment it is scored.
|
|
105
|
+
* @param password - The password as typed.
|
|
106
|
+
* @param meter - The meter, once it has landed.
|
|
107
|
+
* @param words - Reads the words the meter treats as easy to guess.
|
|
108
|
+
* @returns The strength, -1 while the meter has not landed.
|
|
109
|
+
*/
|
|
110
|
+
function strengthOf(password, meter, words) {
|
|
111
|
+
if (password.trim() === '') {
|
|
112
|
+
return 'empty';
|
|
113
|
+
}
|
|
114
|
+
return meter === undefined ? -1 : meter(password, words());
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Returns the meter's tone for a strength.
|
|
118
|
+
* @param strength - What the meter knows of the password.
|
|
119
|
+
* @returns The tone, or undefined while nothing was scored or the field is empty.
|
|
120
|
+
*/
|
|
121
|
+
function toneOf(strength) {
|
|
122
|
+
return typeof strength === 'number' ? levelOf(strength).tone : undefined;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Reports whether a password this strong needs its use confirmed.
|
|
126
|
+
* @param strength - What the meter knows of the password.
|
|
127
|
+
* @returns True for the very weak, weak and unknown levels.
|
|
128
|
+
*/
|
|
129
|
+
function needsConfirmation(strength) {
|
|
130
|
+
const tone = toneOf(strength);
|
|
131
|
+
return tone === 'short' || tone === 'bad';
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Returns the word the meter box holds.
|
|
135
|
+
* @param strength - What the meter knows of the password.
|
|
136
|
+
* @returns Nothing before scoring, a no-break space for an empty field, or the level's word.
|
|
137
|
+
*/
|
|
138
|
+
function meterWord(strength) {
|
|
139
|
+
if (strength === undefined) {
|
|
140
|
+
return '';
|
|
141
|
+
}
|
|
142
|
+
return strength === 'empty' ? NO_WORD : levelOf(strength).word;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Returns a class with its tone modifier when it has a tone.
|
|
146
|
+
* @param block - The class without a tone.
|
|
147
|
+
* @param tone - The tone to add, if any.
|
|
148
|
+
* @returns The class names.
|
|
149
|
+
*/
|
|
150
|
+
function withTone(block, tone) {
|
|
151
|
+
return tone === undefined ? block : `${block} ${block}--${tone}`;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Reports whether a key types a character, the only keys Caps Lock is read from.
|
|
155
|
+
* @param event - The key the person pressed.
|
|
156
|
+
* @returns True for a printable key pressed without Control, Alt or Meta.
|
|
157
|
+
*/
|
|
158
|
+
function isPrintable(event) {
|
|
159
|
+
return event.key.length === 1 && !event.ctrlKey && !event.altKey && !event.metaKey;
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Returns the words the meter treats as easy to guess, read from the fields when it scores.
|
|
163
|
+
* @param name - The name as typed.
|
|
164
|
+
* @param email - The email as typed.
|
|
165
|
+
* @param userInputs - The wizard's own values, if it gave any.
|
|
166
|
+
* @returns The distinct words of four characters or more.
|
|
167
|
+
*/
|
|
168
|
+
function wordsFor(name, email, userInputs = []) {
|
|
169
|
+
return disallowedWords([document.title, location.href, name, email, ...userInputs]);
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Reports whether the step may send the account, the meter's confirmation included.
|
|
173
|
+
* @param name - The name as typed.
|
|
174
|
+
* @param email - The email as typed.
|
|
175
|
+
* @param held - The password and what the meter knows of it.
|
|
176
|
+
* @returns True once every field holds more than spaces and a weak password is confirmed.
|
|
177
|
+
*/
|
|
178
|
+
function isReady(name, email, held) {
|
|
179
|
+
return (name.trim() !== '' &&
|
|
180
|
+
email.trim() !== '' &&
|
|
181
|
+
held.password.trim() !== '' &&
|
|
182
|
+
(held.confirmed || !needsConfirmation(held.strength)));
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Holds the password, scores it when the meter lands and on each change, and keeps the weak use confirmation.
|
|
186
|
+
* @param length - The length of the password the step starts with, if the wizard chose one.
|
|
187
|
+
* @param words - Reads the words the meter treats as easy to guess.
|
|
188
|
+
* @returns The password, its strength, the confirmation and their setters.
|
|
189
|
+
*/
|
|
190
|
+
function usePassword(length, words) {
|
|
191
|
+
const [password, setPassword] = useState(() => generatePassword(length ?? GENERATED_PASSWORD_LENGTH));
|
|
192
|
+
const [strength, setStrength] = useState(undefined);
|
|
193
|
+
const [confirmed, setConfirmed] = useState(false);
|
|
194
|
+
const meter = useRef(undefined);
|
|
195
|
+
const landed = useEffectEvent((loaded) => {
|
|
196
|
+
meter.current = loaded;
|
|
197
|
+
setStrength(strengthOf(password, loaded, words));
|
|
198
|
+
});
|
|
199
|
+
useEffect(() => {
|
|
200
|
+
loadMeter().then((loaded) => landed(loaded), () => undefined);
|
|
201
|
+
}, []);
|
|
202
|
+
const change = (value) => {
|
|
203
|
+
setPassword(value);
|
|
204
|
+
setStrength(strengthOf(value, meter.current, words));
|
|
205
|
+
if (value.trim() === '') {
|
|
206
|
+
setConfirmed(false);
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
return { password, strength, confirmed, change, setConfirmed };
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Sends the first account once and hands it to the wizard, retrying only the hand-over.
|
|
213
|
+
* @param send - Sends the account, through the wizard's route or the transport.
|
|
214
|
+
* @param onCreated - Called with the account once the site created it.
|
|
215
|
+
* @param onRefused - Called first after every refused submit.
|
|
216
|
+
* @param hooks - The wizard's optional callbacks for a refusal.
|
|
217
|
+
* @returns The submit, its refusal, its busy and locked states, and the retry once a hand-over failed.
|
|
218
|
+
*/
|
|
219
|
+
function useFirstAccount(send, onCreated, onRefused, hooks) {
|
|
220
|
+
const [created, setCreated] = useState(null);
|
|
221
|
+
const [handoffFailed, setHandoffFailed] = useState(false);
|
|
222
|
+
const finish = useMutation({
|
|
223
|
+
mutationFn: async (account) => {
|
|
224
|
+
await onCreated(account);
|
|
225
|
+
},
|
|
226
|
+
onError: () => setHandoffFailed(true),
|
|
227
|
+
onSuccess: () => setHandoffFailed(false),
|
|
228
|
+
});
|
|
229
|
+
const create = useMutation({
|
|
230
|
+
mutationFn: send,
|
|
231
|
+
onSuccess: (account) => {
|
|
232
|
+
setCreated(account);
|
|
233
|
+
finish.mutate(account);
|
|
234
|
+
},
|
|
235
|
+
onError: async (error) => {
|
|
236
|
+
onRefused();
|
|
237
|
+
await reportRefusal(error, hooks);
|
|
238
|
+
},
|
|
239
|
+
});
|
|
240
|
+
return {
|
|
241
|
+
submit: () => create.mutate(),
|
|
242
|
+
error: create.error,
|
|
243
|
+
busy: create.isPending || finish.isPending,
|
|
244
|
+
locked: create.isPending || create.isSuccess,
|
|
245
|
+
retry: created !== null && handoffFailed ? () => finish.mutate(created) : undefined,
|
|
246
|
+
retrying: finish.isPending,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Renders a sentence in the message box above the rows.
|
|
251
|
+
* @param children - The sentence.
|
|
252
|
+
* @returns The message box element.
|
|
253
|
+
*/
|
|
254
|
+
function MessageBox({ children }) {
|
|
255
|
+
return (_jsx(Text, { role: "alert", render: _jsx("p", { className: "gopherium-setup__message" }), children: children }));
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Renders the sentence for a refused submit, if there is one.
|
|
259
|
+
* @param error - The error the last submit raised, if any.
|
|
260
|
+
* @param onError - The wizard's words for an error the step does not know.
|
|
261
|
+
* @returns The message box element, or nothing.
|
|
262
|
+
*/
|
|
263
|
+
function RefusalMessage({ error, onError }) {
|
|
264
|
+
return error === null ? null : _jsx(MessageBox, { children: refusalMessage(error, onError) });
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Renders one labelled field row of the step, with its help line when it has one.
|
|
268
|
+
* @param label - The field's label.
|
|
269
|
+
* @param help - The line under the field, if any.
|
|
270
|
+
* @returns The row element.
|
|
271
|
+
*/
|
|
272
|
+
function TextRow({ label, help, ...field }) {
|
|
273
|
+
return (_jsxs(Field.Root, { className: "gopherium-setup__row", render: _jsx("div", {}), children: [_jsx(Field.Label, { className: "gopherium-setup__label", children: label }), _jsxs("div", { children: [_jsx(Input, { ...field }), help === undefined ? null : _jsx(Field.Description, { className: "gopherium-setup__help", children: help })] })] }));
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Renders the button that shows or hides the password.
|
|
277
|
+
* @param masked - Whether the password is hidden.
|
|
278
|
+
* @param onToggle - Called when the button is pressed.
|
|
279
|
+
* @returns The toggle element.
|
|
280
|
+
*/
|
|
281
|
+
function MaskToggle({ masked, onToggle }) {
|
|
282
|
+
const show = __('Show', DOMAIN);
|
|
283
|
+
const hide = __('Hide', DOMAIN);
|
|
284
|
+
return (_jsx(Button, { type: "button", variant: "outline", className: "gopherium-setup__toggle", "data-other-word": masked ? hide : show, "aria-label": masked ? __('Show password', DOMAIN) : __('Hide password', DOMAIN), onClick: onToggle, children: masked ? show : hide }));
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Renders the box that shows the meter's word in its tone.
|
|
288
|
+
* @param id - The id the password field is described by.
|
|
289
|
+
* @param strength - What the meter knows of the password.
|
|
290
|
+
* @returns The meter box element.
|
|
291
|
+
*/
|
|
292
|
+
function MeterBox({ id, strength }) {
|
|
293
|
+
return (_jsx("div", { id: id, "aria-live": "polite", className: withTone('gopherium-setup__meter', toneOf(strength)), children: meterWord(strength) }));
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Renders the password row with the meter box, the Caps Lock line and the line asking to store it.
|
|
297
|
+
* @param password - The password as typed.
|
|
298
|
+
* @param strength - What the meter knows of the password.
|
|
299
|
+
* @param masked - Whether the password is hidden.
|
|
300
|
+
* @param readOnly - Whether the password stays as sent while the account is created.
|
|
301
|
+
* @param onPasswordChange - Called with the password on every change.
|
|
302
|
+
* @param onToggle - Called when the show or hide button is pressed.
|
|
303
|
+
* @returns The row element.
|
|
304
|
+
*/
|
|
305
|
+
function PasswordRow({ password, strength, masked, readOnly, onPasswordChange, onToggle, }) {
|
|
306
|
+
const [capsLock, setCapsLock] = useState(false);
|
|
307
|
+
const meterId = useId();
|
|
308
|
+
const helpId = useId();
|
|
309
|
+
return (_jsxs(Field.Root, { className: "gopherium-setup__row", render: _jsx("div", {}), children: [_jsx(Field.Label, { className: "gopherium-setup__label", children: __('Password', DOMAIN) }), _jsxs("div", { children: [_jsxs("div", { className: "gopherium-setup__password", children: [_jsxs("div", { className: "gopherium-setup__password-field", children: [_jsx(Input, { type: masked ? 'password' : 'text', className: withTone('gopherium-setup__input', toneOf(strength)), value: password, readOnly: readOnly, onChange: (event) => onPasswordChange(event.target.value), onKeyDown: (event) => {
|
|
310
|
+
if (isPrintable(event)) {
|
|
311
|
+
setCapsLock(event.getModifierState('CapsLock'));
|
|
312
|
+
}
|
|
313
|
+
}, autoComplete: "new-password", spellCheck: false, "aria-describedby": `${meterId} ${helpId}` }), _jsx(MeterBox, { id: meterId, strength: strength }), _jsx("div", { "aria-live": "assertive", className: "gopherium-setup__caps", children: capsLock ? (_jsxs(_Fragment, { children: [CAPS_ARROW, _jsx("span", { className: "gopherium-setup__caps-text", children: __('Caps lock is on.', DOMAIN) })] })) : null })] }), _jsx(MaskToggle, { masked: masked, onToggle: onToggle })] }), _jsxs(Text, { render: _jsx("p", { id: helpId, className: "gopherium-setup__help" }), children: [_jsx("strong", { children: __('Important:', DOMAIN) }), ' ', __('You will need this password to log\u00a0in. Please store it in a secure location.', DOMAIN)] })] })] }));
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* Renders the row asking to confirm the use of a weak password.
|
|
317
|
+
* @param checked - Whether the use is confirmed.
|
|
318
|
+
* @param readOnly - Whether the choice stays as sent while the account is created.
|
|
319
|
+
* @param onCheckedChange - Called with the new choice.
|
|
320
|
+
* @returns The row element.
|
|
321
|
+
*/
|
|
322
|
+
function ConfirmRow({ checked, readOnly, onCheckedChange, }) {
|
|
323
|
+
return (_jsxs("div", { className: "gopherium-setup__row", children: [_jsx(Field.VisualLabel, { className: "gopherium-setup__label", children: __('Confirm Password', DOMAIN) }), _jsx("div", { children: _jsx(CheckboxControl, { className: "gopherium-setup__checkbox", label: __('Confirm use of weak password', DOMAIN), checked: checked, readOnly: readOnly, onCheckedChange: (next) => onCheckedChange(next) }) })] }));
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* Renders the sentence and the button that hand the created account to the wizard again.
|
|
327
|
+
* @param pending - Whether the hand-over runs.
|
|
328
|
+
* @param onRetry - Called when the button is pressed.
|
|
329
|
+
* @returns The hand-over failure element.
|
|
330
|
+
*/
|
|
331
|
+
function HandoffFailure({ pending, onRetry }) {
|
|
332
|
+
return (_jsxs("div", { className: "gopherium-setup", children: [_jsx(MessageBox, { children: __('Something went wrong.', DOMAIN) }), _jsx("p", { className: "gopherium-setup__step", children: _jsx(Button, { type: "button", disabled: pending, loading: pending, onClick: onRetry, children: __('Try again', DOMAIN) }) })] }));
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Renders the first account's Name, Password and Your Email rows inside an application's setup wizard.
|
|
336
|
+
* @param code - The one-time setup code the wizard collected before this step.
|
|
337
|
+
* @param submitLabel - The wizard's words for the submit button.
|
|
338
|
+
* @param onCreated - Called with the account once the site created it.
|
|
339
|
+
* @param onSubmit - Sends the account through the wizard's own route, in place of the transport.
|
|
340
|
+
* @param onError - The wizard's words for an error the step does not know.
|
|
341
|
+
* @param onCodeRefused - Called with the reason when the site refuses the setup code.
|
|
342
|
+
* @param onClosed - Called when the site already has its first account.
|
|
343
|
+
* @param generatedPasswordLength - The length of the password the step starts with.
|
|
344
|
+
* @param userInputs - The wizard's own values the meter treats as easy to guess.
|
|
345
|
+
* @param before - The wizard's rows above Name.
|
|
346
|
+
* @param after - The wizard's rows below Your Email.
|
|
347
|
+
* @returns The setup account step element.
|
|
348
|
+
*/
|
|
349
|
+
export function SetupAccountStep({ code, submitLabel, onCreated, onSubmit, onError, onCodeRefused, onClosed, generatedPasswordLength, userInputs, before, after, }) {
|
|
350
|
+
const [name, setName] = useState('');
|
|
351
|
+
const [email, setEmail] = useState('');
|
|
352
|
+
const [masked, setMasked] = useState(false);
|
|
353
|
+
const held = usePassword(generatedPasswordLength, () => wordsFor(name, email, userInputs));
|
|
354
|
+
const account = useFirstAccount(() => (onSubmit ?? createFirstAccount)({ code, email: email.trim(), name: name.trim(), password: held.password }), onCreated, () => setMasked(false), { onCodeRefused, onClosed });
|
|
355
|
+
if (account.retry !== undefined) {
|
|
356
|
+
return _jsx(HandoffFailure, { pending: account.retrying, onRetry: account.retry });
|
|
357
|
+
}
|
|
358
|
+
const canSend = !account.locked && isReady(name, email, held);
|
|
359
|
+
return (_jsxs("form", { className: "gopherium-setup", noValidate: true, onSubmit: (event) => {
|
|
360
|
+
event.preventDefault();
|
|
361
|
+
if (!canSend) {
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
setMasked(true);
|
|
365
|
+
account.submit();
|
|
366
|
+
}, children: [_jsx(RefusalMessage, { error: account.error, onError: onError }), before, _jsx(TextRow, { label: _x('Name', 'field label', DOMAIN), type: "text", autoComplete: "name", value: name, readOnly: account.locked, onChange: (event) => setName(event.target.value) }), _jsx(PasswordRow, { password: held.password, strength: held.strength, masked: masked, readOnly: account.locked, onPasswordChange: held.change, onToggle: () => setMasked(!masked) }), needsConfirmation(held.strength) ? (_jsx(ConfirmRow, { checked: held.confirmed, readOnly: account.locked, onCheckedChange: held.setConfirmed })) : null, _jsx(TextRow, { label: __('Your Email', DOMAIN), type: "email", autoComplete: "username", value: email, readOnly: account.locked, onChange: (event) => setEmail(event.target.value), help: __('Double-check your email address before continuing.', DOMAIN) }), after, _jsx("p", { className: "gopherium-setup__step", children: _jsx(Button, { type: "submit", disabled: !canSend, loading: account.busy, children: submitLabel }) })] }));
|
|
367
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import type { SetupCodeError } from '../setup.js';
|
|
3
|
+
/**
|
|
4
|
+
* Renders the step asking for the one-time setup code and hands the accepted code upward.
|
|
5
|
+
* @param brand - The product name in the heading.
|
|
6
|
+
* @param fileHint - The application's words for where its code file lives.
|
|
7
|
+
* @param onUnlocked - Called with the code as typed once the site accepts it.
|
|
8
|
+
* @param onClosed - Called when the site already has its first account.
|
|
9
|
+
* @param refused - The reason a later step sent the wizard back, shown on arrival.
|
|
10
|
+
* @returns The setup code step element.
|
|
11
|
+
*/
|
|
12
|
+
export declare function SetupCodeStep({ brand, fileHint, onUnlocked, onClosed, refused, }: {
|
|
13
|
+
brand: string;
|
|
14
|
+
fileHint: ReactNode;
|
|
15
|
+
onUnlocked: (code: string) => void | Promise<void>;
|
|
16
|
+
onClosed?: () => void;
|
|
17
|
+
refused?: SetupCodeError['reason'];
|
|
18
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
import { useMutation } from '@tanstack/react-query';
|
|
4
|
+
import { __, sprintf } from '@wordpress/i18n';
|
|
5
|
+
import { Button, Field, Input, Text } from '@wordpress/ui';
|
|
6
|
+
import { useEffect, useId, useRef, useState } from 'react';
|
|
7
|
+
import { DOMAIN } from '../domain.js';
|
|
8
|
+
import { SetupClosedError, checkSetupCode } from '../setup.js';
|
|
9
|
+
import { codeRefusal, setupRefusal } from './setupWords.js';
|
|
10
|
+
/**
|
|
11
|
+
* Returns the sentence the code step shows, if any.
|
|
12
|
+
* @param failure - The error the last check or hand-over raised, if any.
|
|
13
|
+
* @param idle - Whether no check has run yet.
|
|
14
|
+
* @param refused - The reason a later step sent the wizard back, if any.
|
|
15
|
+
* @returns The sentence to show, or undefined for none.
|
|
16
|
+
*/
|
|
17
|
+
function stepMessage(failure, idle, refused) {
|
|
18
|
+
if (failure !== null) {
|
|
19
|
+
return setupRefusal(failure) ?? __('The request failed, please try again.', DOMAIN);
|
|
20
|
+
}
|
|
21
|
+
if (idle && refused !== undefined) {
|
|
22
|
+
return codeRefusal(refused);
|
|
23
|
+
}
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Reports whether the field takes focus once it is in the page, which it does unless the pointer is coarse.
|
|
28
|
+
* @returns False on a touch screen, true elsewhere.
|
|
29
|
+
*/
|
|
30
|
+
function focusesOnMount() {
|
|
31
|
+
return window.matchMedia?.('(pointer: coarse)').matches !== true;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Renders the step asking for the one-time setup code and hands the accepted code upward.
|
|
35
|
+
* @param brand - The product name in the heading.
|
|
36
|
+
* @param fileHint - The application's words for where its code file lives.
|
|
37
|
+
* @param onUnlocked - Called with the code as typed once the site accepts it.
|
|
38
|
+
* @param onClosed - Called when the site already has its first account.
|
|
39
|
+
* @param refused - The reason a later step sent the wizard back, shown on arrival.
|
|
40
|
+
* @returns The setup code step element.
|
|
41
|
+
*/
|
|
42
|
+
export function SetupCodeStep({ brand, fileHint, onUnlocked, onClosed, refused, }) {
|
|
43
|
+
const [code, setCode] = useState('');
|
|
44
|
+
const field = useRef(null);
|
|
45
|
+
const hintId = useId();
|
|
46
|
+
const alertId = useId();
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
if (focusesOnMount()) {
|
|
49
|
+
field.current?.focus();
|
|
50
|
+
}
|
|
51
|
+
}, []);
|
|
52
|
+
const attempt = useMutation({
|
|
53
|
+
mutationFn: async () => {
|
|
54
|
+
await checkSetupCode(code);
|
|
55
|
+
await onUnlocked(code);
|
|
56
|
+
},
|
|
57
|
+
onError: (error) => {
|
|
58
|
+
if (error instanceof SetupClosedError) {
|
|
59
|
+
onClosed?.();
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
const message = stepMessage(attempt.error, attempt.isIdle, refused);
|
|
64
|
+
const canCheck = code.trim() !== '' && !attempt.isPending;
|
|
65
|
+
return (_jsxs("form", { className: "gopherium-setup", onSubmit: (event) => {
|
|
66
|
+
event.preventDefault();
|
|
67
|
+
if (!canCheck) {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
attempt.mutate();
|
|
71
|
+
}, children: [_jsx(Text, { variant: "heading-lg", render: _jsx("h1", { className: "gopherium-setup__heading" }), children: sprintf(__('Unlock %s', DOMAIN), brand) }), _jsx(Text, { render: _jsx("p", { className: "gopherium-setup__text" }), children: __('To ensure this site is securely set up by its owner, a setup code has been written to a file on the server.', DOMAIN) }), _jsx(Text, { render: _jsx("div", { id: hintId, className: "gopherium-setup__file" }), children: fileHint }), _jsx(Text, { render: _jsx("p", { className: "gopherium-setup__text" }), children: __('Please copy the setup code from the file and paste it below.', DOMAIN) }), message === undefined ? null : (_jsx(Text, { role: "alert", id: alertId, render: _jsx("p", { className: "gopherium-setup__message" }), children: message })), _jsxs(Field.Root, { className: "gopherium-setup__row", render: _jsx("div", {}), children: [_jsx(Field.Label, { className: "gopherium-setup__label", children: __('Setup code', DOMAIN) }), _jsx("div", { children: _jsx(Input, { ref: field, type: "text", value: code, onChange: (event) => setCode(event.target.value), autoComplete: "off", spellCheck: false, "aria-describedby": message === undefined ? hintId : `${alertId} ${hintId}` }) })] }), _jsx("p", { className: "gopherium-setup__step", children: _jsx(Button, { type: "submit", disabled: !canCheck, loading: attempt.isPending, children: __('Continue', DOMAIN) }) })] }));
|
|
72
|
+
}
|
package/dist/wpds/index.d.ts
CHANGED
|
@@ -5,4 +5,6 @@ export { NewUserScreen } from './NewUserScreen.js';
|
|
|
5
5
|
export { RequestResetScreen } from './RequestResetScreen.js';
|
|
6
6
|
export { ResetPasswordScreen } from './ResetPasswordScreen.js';
|
|
7
7
|
export { SetPasswordScreen } from './SetPasswordScreen.js';
|
|
8
|
+
export { SetupAccountStep } from './SetupAccountStep.js';
|
|
9
|
+
export { SetupCodeStep } from './SetupCodeStep.js';
|
|
8
10
|
export { UsersScreen } from './UsersScreen.js';
|
package/dist/wpds/index.js
CHANGED
|
@@ -6,4 +6,6 @@ export { NewUserScreen } from './NewUserScreen.js';
|
|
|
6
6
|
export { RequestResetScreen } from './RequestResetScreen.js';
|
|
7
7
|
export { ResetPasswordScreen } from './ResetPasswordScreen.js';
|
|
8
8
|
export { SetPasswordScreen } from './SetPasswordScreen.js';
|
|
9
|
+
export { SetupAccountStep } from './SetupAccountStep.js';
|
|
10
|
+
export { SetupCodeStep } from './SetupCodeStep.js';
|
|
9
11
|
export { UsersScreen } from './UsersScreen.js';
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Returns a random password from the generator's characters, each equally likely.
|
|
3
|
+
* @param length - The number of characters the password holds, a whole number from 1 to 65536.
|
|
4
|
+
* @returns The password.
|
|
5
|
+
*/
|
|
6
|
+
export declare function generatePassword(length: number): string;
|