@structured-id/ui-core 4.1.0 → 4.3.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 +29 -1
- package/package.json +4 -1
- package/src/assets.d.ts +5 -0
- package/src/index.ts +9 -0
- package/src/profile/components/SidLoginForm.test.ts +53 -9
- package/src/profile/components/SidLoginForm.vue +3 -1
- package/src/profile/components/SidRegistrationForm.test.ts +34 -16
- package/src/profile/components/SidRegistrationForm.vue +3 -1
- package/src/quasar/SidPrincipalInput.autoload.test.ts +41 -0
- package/src/quasar/SidPrincipalInput.test.ts +195 -26
- package/src/quasar/SidPrincipalInput.vue +108 -37
- package/src/quasar/index.ts +2 -0
- package/src/quasar/principalInputTexts.ts +26 -1
- package/src/utils/email.test.ts +107 -0
- package/src/utils/email.testing.ts +19 -0
- package/src/utils/email.ts +165 -0
- package/src/utils/index.ts +13 -1
- package/src/utils/normalizePrincipal.test.ts +46 -4
- package/src/utils/normalizePrincipal.ts +35 -31
- package/src/utils/validators.test.ts +0 -23
- package/src/utils/validators.ts +0 -17
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@ Shared UI package for StructuredID applications: Vue 3 composables, Pinia stores
|
|
|
6
6
|
|
|
7
7
|
- **Composables** -- `createAuth` (password ceremonies over an injected ZKPP client), `useAuthApi`, `useAccountApi`, `useIdentityApi`, `useSecurityLevel`, transport setup (`initGrpc`, `initAccountApi`)
|
|
8
8
|
- **Components** -- `SidLoginForm`, `SidRegistrationForm`, `SidMfaChallenge`, `SidPrincipalInput`, profile management pages
|
|
9
|
-
- **Utilities** -- `normalizePrincipal`, `detectLoginInputType`, `rpcRefusal`, validators, formatters
|
|
9
|
+
- **Utilities** -- `normalizePrincipal`, `detectLoginInputType`, `rpcRefusal`, email checks (`initEmail`, `checkEmail`, `isValidEmail`), validators, formatters
|
|
10
10
|
|
|
11
11
|
The generated messages and clients, the `google.rpc` error model and the gRPC-web / WebTransport transport live in `@structured-id/proto`; import them from there (`@structured-id/proto/sid/v1/identity/identity.client`, `@structured-id/proto/transport`).
|
|
12
12
|
|
|
@@ -34,6 +34,34 @@ const identity = new IdentityServiceClient(getTransport());
|
|
|
34
34
|
const { login } = createAuth(() => loadZkppClient());
|
|
35
35
|
```
|
|
36
36
|
|
|
37
|
+
### Email addresses
|
|
38
|
+
|
|
39
|
+
Email fields are checked with [`@structured-world/structured-email-address-ts`](https://www.npmjs.com/package/@structured-world/structured-email-address-ts),
|
|
40
|
+
the TypeScript port of the parser the server uses, checked byte-identical
|
|
41
|
+
against it. Its Public Suffix List trie is the application's own asset (the
|
|
42
|
+
bundler emits `psl.bin` from `@structured-world/structured-public-domains`),
|
|
43
|
+
served by the application's host and cached in IndexedDB; where it is served
|
|
44
|
+
from is a setting (`psl.url`), never a third-party CDN. Each namespace's admission mirrors the
|
|
45
|
+
server's email policy: `LOCAL` (an installation's own accounts: internal and
|
|
46
|
+
internationalized domains), `CORPORATE_BASELINE`, `GLOBAL_PERSONAL` (ASCII, no
|
|
47
|
+
IDN, a public mail domain). The server still decides; the field refuses only
|
|
48
|
+
what that namespace never admits.
|
|
49
|
+
|
|
50
|
+
Load the parser once at start and name the application's namespace (`LOCAL`
|
|
51
|
+
when omitted). The parser is loaded lazily as its own chunk; until it is,
|
|
52
|
+
`checkEmail` answers `pending` and `SidPrincipalInput` keeps the form's submit
|
|
53
|
+
disabled. A field accepting email starts the load itself if the application
|
|
54
|
+
did not.
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
import { GLOBAL_PERSONAL, initEmail } from "@structured-id/ui-core";
|
|
58
|
+
|
|
59
|
+
// The trie from the application's own copy...
|
|
60
|
+
await initEmail({ policy: GLOBAL_PERSONAL });
|
|
61
|
+
// ...or from where the deployment's configuration says it is served.
|
|
62
|
+
await initEmail({ policy: GLOBAL_PERSONAL, psl: { url: config.pslUrl } });
|
|
63
|
+
```
|
|
64
|
+
|
|
37
65
|
## Development
|
|
38
66
|
|
|
39
67
|
The package ships its TypeScript and Vue sources; the consuming application's
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@structured-id/ui-core",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.3.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public",
|
|
@@ -43,9 +43,12 @@
|
|
|
43
43
|
"@protobuf-ts/runtime": "^2.11.1",
|
|
44
44
|
"@protobuf-ts/runtime-rpc": "^2.11.1",
|
|
45
45
|
"@structured-id/proto": "^0.3.0",
|
|
46
|
+
"@structured-world/structured-email-address-ts": "^0.0.22",
|
|
47
|
+
"@structured-world/structured-public-domains": "0.0.x",
|
|
46
48
|
"libphonenumber-js": "^1.13.15"
|
|
47
49
|
},
|
|
48
50
|
"devDependencies": {
|
|
51
|
+
"@types/node": "^26",
|
|
49
52
|
"@vitejs/plugin-vue": "^6.0.9",
|
|
50
53
|
"@vue/compiler-dom": "^3.5.43",
|
|
51
54
|
"@vue/devtools-api": "^8.2.1",
|
package/src/assets.d.ts
ADDED
package/src/index.ts
CHANGED
|
@@ -36,7 +36,16 @@ export { useAuthStore } from "./stores/auth";
|
|
|
36
36
|
|
|
37
37
|
// Utils
|
|
38
38
|
export {
|
|
39
|
+
CORPORATE_BASELINE,
|
|
40
|
+
GLOBAL_PERSONAL,
|
|
41
|
+
LOCAL,
|
|
42
|
+
checkEmail,
|
|
43
|
+
emailPolicy,
|
|
44
|
+
emailReady,
|
|
45
|
+
initEmail,
|
|
39
46
|
isValidEmail,
|
|
47
|
+
type EmailCheck,
|
|
48
|
+
type EmailPolicy,
|
|
40
49
|
isValidPhone,
|
|
41
50
|
isValidUuid,
|
|
42
51
|
isValidUsername,
|
|
@@ -1,8 +1,13 @@
|
|
|
1
|
-
import { describe, it, expect, vi } from "vitest";
|
|
1
|
+
import { beforeAll, describe, it, expect, vi } from "vitest";
|
|
2
2
|
import { mount } from "@vue/test-utils";
|
|
3
3
|
import { defineComponent } from "vue";
|
|
4
4
|
import SidLoginForm from "./SidLoginForm.vue";
|
|
5
5
|
import { REQUIRED_FIELD_TEXT } from "../../utils/validators";
|
|
6
|
+
import { initEmail } from "../../utils/email";
|
|
7
|
+
import { TEST_PSL } from "../../utils/email.testing";
|
|
8
|
+
|
|
9
|
+
// As an application does at start: the email parser loaded, LOCAL namespace.
|
|
10
|
+
beforeAll(() => initEmail({ psl: TEST_PSL }));
|
|
6
11
|
|
|
7
12
|
// Stub all Quasar components as divs
|
|
8
13
|
const quasarStubs = {
|
|
@@ -23,13 +28,11 @@ const quasarStubs = {
|
|
|
23
28
|
template:
|
|
24
29
|
'<input :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />',
|
|
25
30
|
}),
|
|
26
|
-
"q-btn": {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
'<input :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />',
|
|
32
|
-
}),
|
|
31
|
+
"q-btn": {
|
|
32
|
+
props: ["disable"],
|
|
33
|
+
template: '<button type="submit" :disabled="disable"><slot /></button>',
|
|
34
|
+
},
|
|
35
|
+
"q-chip": { template: "<span><slot /></span>" },
|
|
33
36
|
"router-link": { template: "<a><slot /></a>" },
|
|
34
37
|
};
|
|
35
38
|
|
|
@@ -94,11 +97,52 @@ describe("SidLoginForm", () => {
|
|
|
94
97
|
).toContain("Hello");
|
|
95
98
|
});
|
|
96
99
|
|
|
100
|
+
// Fewer than three characters are nobody yet: the form cannot sign in
|
|
101
|
+
// with them, so its button stays disabled until a third is typed.
|
|
102
|
+
it("cannot sign in with fewer than three characters", async () => {
|
|
103
|
+
const w = mountLogin();
|
|
104
|
+
const [principal, password] = w.findAll("input");
|
|
105
|
+
await password.setValue("secret");
|
|
106
|
+
for (const [value, disabled] of [
|
|
107
|
+
["al", true],
|
|
108
|
+
[" al ", true],
|
|
109
|
+
["ali", false],
|
|
110
|
+
] as const) {
|
|
111
|
+
await principal.setValue(value);
|
|
112
|
+
expect(w.find("button").attributes("disabled") !== undefined, value).toBe(
|
|
113
|
+
disabled,
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// Regression: with "ali.ce" shown as not a username the button was still
|
|
119
|
+
// enabled, since the form only counted characters. It stays disabled until
|
|
120
|
+
// the identifier is a well-formed principal of an accepted type, without
|
|
121
|
+
// waiting for the field's error to appear.
|
|
122
|
+
it("cannot sign in until the identifier is well-formed", async () => {
|
|
123
|
+
const w = mountLogin({ principalTypes: ["email", "username"] });
|
|
124
|
+
const [principal, password] = w.findAll("input");
|
|
125
|
+
await password.setValue("secret");
|
|
126
|
+
for (const [value, disabled] of [
|
|
127
|
+
["ali", false],
|
|
128
|
+
["ali.ce", true],
|
|
129
|
+
["ali ce", true],
|
|
130
|
+
["+380501234567", true],
|
|
131
|
+
["alice@", true],
|
|
132
|
+
["alice@sid.example.com", false],
|
|
133
|
+
] as const) {
|
|
134
|
+
await principal.setValue(value);
|
|
135
|
+
expect(w.find("button").attributes("disabled") !== undefined, value).toBe(
|
|
136
|
+
disabled,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
|
|
97
141
|
// Regression: the password turned red on the first keystroke and its empty
|
|
98
142
|
// error read "Required" beside the identifier's "This field is required".
|
|
99
143
|
it("validates the password once left, with the shared required text", () => {
|
|
100
144
|
const w = mountLogin();
|
|
101
|
-
const password = w.
|
|
145
|
+
const password = w.findAllComponents({ name: "QInputStub" })[1];
|
|
102
146
|
const lazy = password.props("lazyRules");
|
|
103
147
|
expect(lazy === "" || lazy === true).toBe(true);
|
|
104
148
|
const [required] = password.props("rules") as ((v: string) => unknown)[];
|
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
:texts="principalTexts"
|
|
43
43
|
data-test="principal"
|
|
44
44
|
@update:principal-type="principalType = $event"
|
|
45
|
+
@update:valid="principalValid = $event"
|
|
45
46
|
/>
|
|
46
47
|
|
|
47
48
|
<q-input
|
|
@@ -74,7 +75,7 @@
|
|
|
74
75
|
color="primary"
|
|
75
76
|
class="full-width"
|
|
76
77
|
:loading="loading"
|
|
77
|
-
:disable="!
|
|
78
|
+
:disable="!principalValid || !password"
|
|
78
79
|
data-test="submit"
|
|
79
80
|
/>
|
|
80
81
|
</q-form>
|
|
@@ -223,6 +224,7 @@ const shownSubtitle = computed(
|
|
|
223
224
|
|
|
224
225
|
const identifier = ref("");
|
|
225
226
|
const principalType = ref<PrincipalType>("unknown");
|
|
227
|
+
const principalValid = ref(false);
|
|
226
228
|
const password = ref("");
|
|
227
229
|
const showPassword = ref(false);
|
|
228
230
|
const loading = ref(false);
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import { describe, it, expect, vi } from "vitest";
|
|
1
|
+
import { beforeAll, describe, it, expect, vi } from "vitest";
|
|
2
2
|
import { flushPromises, mount } from "@vue/test-utils";
|
|
3
|
+
import { initEmail } from "../../utils/email";
|
|
4
|
+
import { TEST_PSL } from "../../utils/email.testing";
|
|
3
5
|
import { RpcError } from "@protobuf-ts/runtime-rpc";
|
|
4
6
|
import { base64encode } from "@protobuf-ts/runtime";
|
|
5
7
|
import { Any } from "@structured-id/proto/google/protobuf/any";
|
|
@@ -10,6 +12,9 @@ import {
|
|
|
10
12
|
} from "@structured-id/proto/google/rpc/error_details";
|
|
11
13
|
import SidRegistrationForm from "./SidRegistrationForm.vue";
|
|
12
14
|
|
|
15
|
+
// As an application does at start: the email parser loaded, LOCAL namespace.
|
|
16
|
+
beforeAll(() => initEmail({ psl: TEST_PSL }));
|
|
17
|
+
|
|
13
18
|
const quasarStubs = {
|
|
14
19
|
"q-card": { template: "<div><slot /></div>" },
|
|
15
20
|
"q-card-section": { template: "<div><slot /></div>" },
|
|
@@ -31,7 +36,10 @@ const quasarStubs = {
|
|
|
31
36
|
template:
|
|
32
37
|
'<input :value="modelValue" :data-error="error ? errorMessage : undefined" @input="$emit(\'update:modelValue\', $event.target.value)" />',
|
|
33
38
|
},
|
|
34
|
-
"q-btn": {
|
|
39
|
+
"q-btn": {
|
|
40
|
+
props: ["disable"],
|
|
41
|
+
template: '<button type="submit" :disabled="disable"><slot /></button>',
|
|
42
|
+
},
|
|
35
43
|
"sid-password-ceremony": {
|
|
36
44
|
name: "SidPasswordCeremony",
|
|
37
45
|
props: ["outcome", "refusedText", "length", "fields"],
|
|
@@ -39,20 +47,6 @@ const quasarStubs = {
|
|
|
39
47
|
template:
|
|
40
48
|
'<div class="ceremony" :data-outcome="outcome" :data-refused="refusedText" :data-length="length" :data-fields="fields" />',
|
|
41
49
|
},
|
|
42
|
-
"sid-principal-input": {
|
|
43
|
-
name: "SidPrincipalInput",
|
|
44
|
-
props: [
|
|
45
|
-
"modelValue",
|
|
46
|
-
"disable",
|
|
47
|
-
"label",
|
|
48
|
-
"types",
|
|
49
|
-
"minUsernameLength",
|
|
50
|
-
"fieldError",
|
|
51
|
-
],
|
|
52
|
-
emits: ["update:modelValue", "update:principal-type"],
|
|
53
|
-
template:
|
|
54
|
-
'<input :value="modelValue" :data-error="fieldError || undefined" @input="$emit(\'update:modelValue\', $event.target.value)" />',
|
|
55
|
-
},
|
|
56
50
|
};
|
|
57
51
|
|
|
58
52
|
/** A refusal as the gRPC-Web transport raises it, with field violations. */
|
|
@@ -178,6 +172,30 @@ describe("SidRegistrationForm", () => {
|
|
|
178
172
|
);
|
|
179
173
|
});
|
|
180
174
|
|
|
175
|
+
// The registration cannot start until the identifier is a well-formed
|
|
176
|
+
// principal this scenario admits: fewer than three characters are nobody
|
|
177
|
+
// yet, and a username shorter than this registration's minimum, or with a
|
|
178
|
+
// character a username cannot hold, keeps the button disabled too.
|
|
179
|
+
it("cannot register until the identifier is well-formed", async () => {
|
|
180
|
+
const w = mountForm({ claimRequired: false });
|
|
181
|
+
const [principal, password, confirm] = w.findAll("input");
|
|
182
|
+
await password.setValue("Secret-Pass-123");
|
|
183
|
+
await confirm.setValue("Secret-Pass-123");
|
|
184
|
+
for (const [value, disabled] of [
|
|
185
|
+
["ow", true],
|
|
186
|
+
[" ow ", true],
|
|
187
|
+
["owner", true],
|
|
188
|
+
["owner.one", true],
|
|
189
|
+
["owner_one", false],
|
|
190
|
+
["owner@example.com", false],
|
|
191
|
+
] as const) {
|
|
192
|
+
await principal.setValue(value);
|
|
193
|
+
expect(w.find("button").attributes("disabled") !== undefined, value).toBe(
|
|
194
|
+
disabled,
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
|
|
181
199
|
it("submits the invite code when the installation is claimed", async () => {
|
|
182
200
|
registerFn.mockClear();
|
|
183
201
|
const w = mountForm({ initialInviteCode: "ABCD1234" });
|
|
@@ -143,6 +143,7 @@
|
|
|
143
143
|
data-test="principal"
|
|
144
144
|
@update:model-value="fieldErrors.principal = undefined"
|
|
145
145
|
@update:principal-type="principalType = $event"
|
|
146
|
+
@update:valid="principalValid = $event"
|
|
146
147
|
/>
|
|
147
148
|
|
|
148
149
|
<slot name="extra-fields" />
|
|
@@ -200,7 +201,7 @@
|
|
|
200
201
|
class="full-width"
|
|
201
202
|
:loading="loading"
|
|
202
203
|
:disable="
|
|
203
|
-
!
|
|
204
|
+
!principalValid ||
|
|
204
205
|
!password ||
|
|
205
206
|
(showConfirmPassword && !confirmPassword) ||
|
|
206
207
|
(claimRequired && !claimToken.trim()) ||
|
|
@@ -433,6 +434,7 @@ const PAINT_FALLBACK_MS = 250;
|
|
|
433
434
|
|
|
434
435
|
const identifier = ref("");
|
|
435
436
|
const principalType = ref<PrincipalType>("unknown");
|
|
437
|
+
const principalValid = ref(false);
|
|
436
438
|
const password = ref("");
|
|
437
439
|
const confirmPassword = ref("");
|
|
438
440
|
const showPassword = ref(false);
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { flushPromises, mount } from "@vue/test-utils";
|
|
3
|
+
|
|
4
|
+
// A module graph of its own: here no application started the email parser,
|
|
5
|
+
// so the field is what starts it.
|
|
6
|
+
vi.mock("../utils/email", async (importOriginal) => {
|
|
7
|
+
const actual = await importOriginal<typeof import("../utils/email")>();
|
|
8
|
+
const { TEST_PSL } = await import("../utils/email.testing");
|
|
9
|
+
return {
|
|
10
|
+
...actual,
|
|
11
|
+
// The field calls it without options; the test serves the trie itself.
|
|
12
|
+
initEmail: vi.fn(() => actual.initEmail({ psl: TEST_PSL })),
|
|
13
|
+
};
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
const stubs = {
|
|
17
|
+
"q-input": { template: "<div />" },
|
|
18
|
+
"q-icon": { template: "<span />" },
|
|
19
|
+
"q-chip": { template: "<span />" },
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
describe("SidPrincipalInput starting the parser", () => {
|
|
23
|
+
// Regression: the parser was started only on mount, so a field whose
|
|
24
|
+
// accepted types came to include email later kept every email pending
|
|
25
|
+
// and its form's submit disabled for good.
|
|
26
|
+
it("starts it when the accepted types come to include email", async () => {
|
|
27
|
+
const email = await import("../utils/email");
|
|
28
|
+
const { default: SidPrincipalInput } =
|
|
29
|
+
await import("./SidPrincipalInput.vue");
|
|
30
|
+
const w = mount(SidPrincipalInput, {
|
|
31
|
+
props: { modelValue: "alice@example.com", types: ["username"] },
|
|
32
|
+
global: { stubs },
|
|
33
|
+
});
|
|
34
|
+
expect(email.initEmail).not.toHaveBeenCalled();
|
|
35
|
+
await w.setProps({ types: ["email", "username"] });
|
|
36
|
+
await vi.waitFor(() => expect(email.emailReady.value).toBe(true));
|
|
37
|
+
await flushPromises();
|
|
38
|
+
expect(email.initEmail).toHaveBeenCalled();
|
|
39
|
+
expect(w.emitted("update:valid")?.at(-1)?.[0]).toBe(true);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -1,8 +1,13 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
1
|
+
import { beforeAll, describe, it, expect } from "vitest";
|
|
2
2
|
import { mount } from "@vue/test-utils";
|
|
3
|
-
import { defineComponent } from "vue";
|
|
3
|
+
import { defineComponent, nextTick } from "vue";
|
|
4
4
|
import SidPrincipalInput from "./SidPrincipalInput.vue";
|
|
5
|
-
import {
|
|
5
|
+
import { principalRecognizable } from "./principalInputTexts";
|
|
6
|
+
import { GLOBAL_PERSONAL, LOCAL, initEmail } from "../utils/email";
|
|
7
|
+
import { TEST_PSL } from "../utils/email.testing";
|
|
8
|
+
|
|
9
|
+
// As an application does at start: the email parser loaded, LOCAL namespace.
|
|
10
|
+
beforeAll(() => initEmail({ psl: TEST_PSL }));
|
|
6
11
|
|
|
7
12
|
const stubs = {
|
|
8
13
|
"q-input": defineComponent({
|
|
@@ -17,7 +22,7 @@ const stubs = {
|
|
|
17
22
|
],
|
|
18
23
|
template: "<div><slot name='prepend' /><slot name='append' /></div>",
|
|
19
24
|
}),
|
|
20
|
-
"q-icon": { template: "<span />" },
|
|
25
|
+
"q-icon": { props: ["name"], template: "<span :data-icon='name' />" },
|
|
21
26
|
"q-chip": { template: "<span><slot /></span>" },
|
|
22
27
|
};
|
|
23
28
|
|
|
@@ -38,31 +43,25 @@ describe("SidPrincipalInput", () => {
|
|
|
38
43
|
expect(input({ lazyRules: false }).props("lazyRules")).toBe(false);
|
|
39
44
|
});
|
|
40
45
|
|
|
41
|
-
//
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
expect(custom("")).toBe("Enter it");
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
it("does not require an optional field", () => {
|
|
53
|
-
const [required] = input({ required: false }).props("rules") as Rule[];
|
|
54
|
-
expect(required("")).toBe(true);
|
|
46
|
+
// Until the input is long enough to be a principal of some type nothing
|
|
47
|
+
// is shown as wrong, an empty field included: the form keeps its button
|
|
48
|
+
// disabled instead.
|
|
49
|
+
it("shows nothing wrong before a type is recognized", () => {
|
|
50
|
+
const [type] = input().props("rules") as Rule[];
|
|
51
|
+
for (const value of ["", "a", "al"]) {
|
|
52
|
+
expect(type(value), JSON.stringify(value)).toBe(true);
|
|
53
|
+
}
|
|
55
54
|
});
|
|
56
55
|
|
|
57
56
|
// A field that accepts only some principal types refuses the others before
|
|
58
57
|
// any call, naming what it accepts: a registration that takes email only
|
|
59
58
|
// must not let a username reach the server.
|
|
60
59
|
it("refuses a principal type it does not accept", () => {
|
|
61
|
-
const [
|
|
60
|
+
const [type] = input({ types: ["email"] }).props("rules") as Rule[];
|
|
62
61
|
expect(type("alice_wonder")).toBe("Enter an email address");
|
|
63
62
|
expect(type("+380501234567")).toBe("Enter an email address");
|
|
64
63
|
expect(type("alice@sid.example.com")).toBe(true);
|
|
65
|
-
const [
|
|
64
|
+
const [two] = input({ types: ["email", "username"] }).props(
|
|
66
65
|
"rules",
|
|
67
66
|
) as Rule[];
|
|
68
67
|
expect(two("+380501234567")).toBe("Enter an email address or a username");
|
|
@@ -73,24 +72,156 @@ describe("SidPrincipalInput", () => {
|
|
|
73
72
|
// username and the field asked for "email, phone, or username" instead of
|
|
74
73
|
// saying the minimum.
|
|
75
74
|
it("tells a too-short username the minimum length", () => {
|
|
76
|
-
const [
|
|
75
|
+
const [type] = input({ minUsernameLength: 6 }).props("rules") as Rule[];
|
|
77
76
|
expect(type("sid")).toBe("A username has at least 6 characters");
|
|
78
77
|
expect(type("sidsid")).toBe(true);
|
|
79
|
-
const [
|
|
78
|
+
const [login] = input().props("rules") as Rule[];
|
|
80
79
|
expect(login("sid")).toBe(true);
|
|
81
|
-
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// One or two characters are nobody yet: no type is recognized from them,
|
|
83
|
+
// the field stays a login field with the neutral icon, and nothing is
|
|
84
|
+
// shown as wrong (the form only keeps its button disabled).
|
|
85
|
+
it("recognizes no type from fewer than three characters", () => {
|
|
86
|
+
for (const short of ["a", "al", "+3", "a@", "ЩЩ", " a "]) {
|
|
87
|
+
const field = mount(SidPrincipalInput, {
|
|
88
|
+
props: { modelValue: short, types: ["email", "username"] },
|
|
89
|
+
global: { stubs },
|
|
90
|
+
});
|
|
91
|
+
const stub = field.findComponent({ name: "QInputStub" });
|
|
92
|
+
expect(stub.props("label"), short).toBe("Login");
|
|
93
|
+
expect(field.html(), short).toContain('data-icon="sym_o_login"');
|
|
94
|
+
const [type] = stub.props("rules") as Rule[];
|
|
95
|
+
expect(type(short), short).toBe(true);
|
|
96
|
+
}
|
|
97
|
+
expect(principalRecognizable("al")).toBe(false);
|
|
98
|
+
expect(principalRecognizable(" al ")).toBe(false);
|
|
99
|
+
expect(principalRecognizable("ali")).toBe(true);
|
|
100
|
+
expect(principalRecognizable("Щук")).toBe(true);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// Regression: a username with a letter outside a-z was taken for no type
|
|
104
|
+
// and refused before the server could apply its repertoire; its length is
|
|
105
|
+
// counted in code points of its NFC form, as the server counts it.
|
|
106
|
+
it("takes a username in any script and counts its code points", () => {
|
|
107
|
+
const [type] = input({
|
|
108
|
+
types: ["email", "username"],
|
|
109
|
+
minUsernameLength: 6,
|
|
110
|
+
}).props("rules") as Rule[];
|
|
111
|
+
expect(type("Иван_Петров")).toBe(true);
|
|
112
|
+
expect(type("Щукин")).toBe("A username has at least 6 characters");
|
|
113
|
+
expect(type("Jürgen")).toBe(true); // 7 code points, 6 in NFC
|
|
114
|
+
// Whitespace, inside or around, is refused, not trimmed into a name, and
|
|
115
|
+
// the refusal says what is wrong.
|
|
116
|
+
for (const spaced of ["Иван Петров", " Иван_Петров", "Иван_Петров "]) {
|
|
117
|
+
expect(type(spaced)).toBe("A username cannot contain spaces");
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// Regression: a username with a character it cannot hold (a dot, a dash,
|
|
122
|
+
// a symbol) was taken for no type at all and told to "enter an email
|
|
123
|
+
// address or a username", which names no problem. Input without `@` and
|
|
124
|
+
// `+` is a username attempt, and the refusal names the characters allowed.
|
|
125
|
+
it("tells a username which characters it can hold", () => {
|
|
126
|
+
const [two] = input({ types: ["email", "username"] }).props(
|
|
127
|
+
"rules",
|
|
128
|
+
) as Rule[];
|
|
129
|
+
for (const odd of ["ali.ce", "ali-ce", "😀😀😀", "alice#acme.corp"]) {
|
|
130
|
+
expect(two(odd)).toBe(
|
|
131
|
+
"A username can contain only letters, digits and underscores",
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
// A field that takes no username still asks for what it takes.
|
|
135
|
+
const [email] = input({ types: ["email"] }).props("rules") as Rule[];
|
|
136
|
+
expect(email("ali.ce")).toBe("Enter an email address");
|
|
137
|
+
expect(
|
|
138
|
+
input({ types: ["email", "username"], modelValue: "ali.ce" }).props(
|
|
139
|
+
"label",
|
|
140
|
+
),
|
|
141
|
+
).toBe("Username");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// Regression: the label already names the recognized type, and a chip
|
|
145
|
+
// repeated it beside the icon. The chip is shown only when the form's own
|
|
146
|
+
// label does not name the type.
|
|
147
|
+
it("names the type once", () => {
|
|
148
|
+
const named = mount(SidPrincipalInput, {
|
|
149
|
+
props: { modelValue: "alice@sid.example.com" },
|
|
150
|
+
global: { stubs },
|
|
151
|
+
});
|
|
152
|
+
expect(named.findComponent({ name: "QInputStub" }).props("label")).toBe(
|
|
153
|
+
"Email",
|
|
154
|
+
);
|
|
155
|
+
expect(named.text()).not.toContain("email");
|
|
156
|
+
const own = mount(SidPrincipalInput, {
|
|
157
|
+
props: { modelValue: "alice@sid.example.com", label: "Sign in with" },
|
|
158
|
+
global: { stubs },
|
|
159
|
+
});
|
|
160
|
+
expect(own.text()).toContain("email");
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
// Regression: a phone number on a field that takes no phone got the phone
|
|
164
|
+
// icon and chip, as if it were accepted. A type the field refuses shows
|
|
165
|
+
// the neutral login icon and no chip.
|
|
166
|
+
it("does not dress a refused type as accepted", () => {
|
|
167
|
+
const w = mount(SidPrincipalInput, {
|
|
168
|
+
props: {
|
|
169
|
+
modelValue: "+380501234567",
|
|
170
|
+
types: ["email", "username"],
|
|
171
|
+
label: "Sign in with",
|
|
172
|
+
},
|
|
173
|
+
global: { stubs },
|
|
174
|
+
});
|
|
175
|
+
expect(w.html()).toContain('data-icon="sym_o_login"');
|
|
176
|
+
expect(w.text()).not.toContain("phone");
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// Regression: an email-only field showed the generic login icon until an
|
|
180
|
+
// address was typed, beside its "Email" label. A field of one type shows
|
|
181
|
+
// that type's icon from the start, greyed until the input is recognized.
|
|
182
|
+
it("shows a single-type field's icon from the start", () => {
|
|
183
|
+
const empty = mount(SidPrincipalInput, {
|
|
184
|
+
props: { modelValue: "", types: ["email"] },
|
|
185
|
+
global: { stubs },
|
|
186
|
+
});
|
|
187
|
+
expect(empty.html()).toContain('data-icon="sym_o_mail"');
|
|
188
|
+
expect(
|
|
189
|
+
mount(SidPrincipalInput, {
|
|
190
|
+
props: { modelValue: "", types: ["email", "username"] },
|
|
191
|
+
global: { stubs },
|
|
192
|
+
}).html(),
|
|
193
|
+
).toContain('data-icon="sym_o_login"');
|
|
82
194
|
});
|
|
83
195
|
|
|
84
196
|
// The label names the accepted types unless the form gives its own.
|
|
197
|
+
// A field of one type is named by it. A field of several is a login
|
|
198
|
+
// field until the input shows which type it is, then it names that type;
|
|
199
|
+
// a recognized type the field does not accept leaves it a login field.
|
|
85
200
|
it("labels the field by the types it accepts", () => {
|
|
86
|
-
expect(input().props("label")).toBe("Email, phone, or username");
|
|
87
201
|
expect(input({ types: ["email"] }).props("label")).toBe("Email");
|
|
88
|
-
expect(input({ types: ["
|
|
89
|
-
|
|
202
|
+
expect(input({ types: ["username"] }).props("label")).toBe("Username");
|
|
203
|
+
expect(input().props("label")).toBe("Login");
|
|
204
|
+
const two = { types: ["email", "username"] };
|
|
205
|
+
expect(input(two).props("label")).toBe("Login");
|
|
206
|
+
expect(
|
|
207
|
+
input({ ...two, modelValue: "alice@sid.example.com" }).props("label"),
|
|
208
|
+
).toBe("Email");
|
|
209
|
+
expect(input({ ...two, modelValue: "alice_wonder" }).props("label")).toBe(
|
|
210
|
+
"Username",
|
|
211
|
+
);
|
|
212
|
+
expect(input({ ...two, modelValue: "+380501234567" }).props("label")).toBe(
|
|
213
|
+
"Login",
|
|
214
|
+
);
|
|
215
|
+
expect(input({ ...two, texts: { login: "Логин" } }).props("label")).toBe(
|
|
216
|
+
"Логин",
|
|
90
217
|
);
|
|
91
218
|
expect(
|
|
92
219
|
input({ types: ["email"], label: "Your email" }).props("label"),
|
|
93
220
|
).toBe("Your email");
|
|
221
|
+
// An explicitly empty label is the form's choice of no label.
|
|
222
|
+
expect(
|
|
223
|
+
input({ types: ["email", "username"], label: "" }).props("label"),
|
|
224
|
+
).toBe("");
|
|
94
225
|
});
|
|
95
226
|
|
|
96
227
|
// The chip naming the detected type is a text like the others: a
|
|
@@ -99,6 +230,7 @@ describe("SidPrincipalInput", () => {
|
|
|
99
230
|
const w = mount(SidPrincipalInput, {
|
|
100
231
|
props: {
|
|
101
232
|
modelValue: "alice@sid.example.com",
|
|
233
|
+
label: "Sign in with",
|
|
102
234
|
texts: { typeChip: (t: string) => `chip:${t}` },
|
|
103
235
|
},
|
|
104
236
|
global: { stubs },
|
|
@@ -106,6 +238,43 @@ describe("SidPrincipalInput", () => {
|
|
|
106
238
|
expect(w.text()).toContain("chip:email");
|
|
107
239
|
});
|
|
108
240
|
|
|
241
|
+
// The form disables its submit on this, before any error is shown: only a
|
|
242
|
+
// recognized, accepted, well-formed value, not refused by the server, is
|
|
243
|
+
// valid.
|
|
244
|
+
// Regression: the application's namespace was not reactive, so a field
|
|
245
|
+
// mounted under one kept the verdict of it after initEmail named another.
|
|
246
|
+
it("re-judges an email when the application's namespace changes", async () => {
|
|
247
|
+
await initEmail({ policy: GLOBAL_PERSONAL, psl: TEST_PSL });
|
|
248
|
+
const w = mount(SidPrincipalInput, {
|
|
249
|
+
props: { modelValue: "admin@printer", types: ["email"] },
|
|
250
|
+
global: { stubs },
|
|
251
|
+
});
|
|
252
|
+
expect(w.emitted("update:valid")?.at(-1)?.[0]).toBe(false);
|
|
253
|
+
await initEmail({ policy: LOCAL });
|
|
254
|
+
await nextTick();
|
|
255
|
+
expect(w.emitted("update:valid")?.at(-1)?.[0]).toBe(true);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it("tells the form whether the value can be submitted", () => {
|
|
259
|
+
const valid = (props: { modelValue: string; fieldError?: string }) => {
|
|
260
|
+
const w = mount(SidPrincipalInput, {
|
|
261
|
+
props: { types: ["email", "username"], ...props },
|
|
262
|
+
global: { stubs },
|
|
263
|
+
});
|
|
264
|
+
return w.emitted("update:valid")?.at(-1)?.[0];
|
|
265
|
+
};
|
|
266
|
+
expect(valid({ modelValue: "" })).toBe(false);
|
|
267
|
+
expect(valid({ modelValue: "al" })).toBe(false);
|
|
268
|
+
expect(valid({ modelValue: "ali" })).toBe(true);
|
|
269
|
+
expect(valid({ modelValue: "ali.ce" })).toBe(false);
|
|
270
|
+
expect(valid({ modelValue: "+380501234567" })).toBe(false);
|
|
271
|
+
expect(valid({ modelValue: "alice@" })).toBe(false);
|
|
272
|
+
expect(valid({ modelValue: "alice@sid.example.com" })).toBe(true);
|
|
273
|
+
expect(
|
|
274
|
+
valid({ modelValue: "alice@sid.example.com", fieldError: "Taken" }),
|
|
275
|
+
).toBe(false);
|
|
276
|
+
});
|
|
277
|
+
|
|
109
278
|
// A refusal the server bound to this field is shown on it.
|
|
110
279
|
it("shows an error given from outside", () => {
|
|
111
280
|
const shown = input({
|