@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 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.1.0",
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",
@@ -0,0 +1,5 @@
1
+ // A Vite asset imported for its served URL (`import url from "./x?url"`).
2
+ declare module "*?url" {
3
+ const url: string;
4
+ export default url;
5
+ }
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": { template: '<button type="submit"><slot /></button>' },
27
- "sid-principal-input": defineComponent({
28
- props: ["modelValue", "disable"],
29
- emits: ["update:modelValue", "update:principal-type"],
30
- template:
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.findComponent({ name: "QInputStub" });
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="!identifier || !password"
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": { template: '<button type="submit"><slot /></button>' },
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
- !identifier ||
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 { REQUIRED_FIELD_TEXT } from "../utils/validators";
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
- // One text for an empty required field, the same the password field uses.
42
- it("says the shared text of an empty required field", () => {
43
- const [required] = input().props("rules") as Rule[];
44
- expect(required("")).toBe(REQUIRED_FIELD_TEXT);
45
- expect(required("alice")).toBe(true);
46
- const [custom] = input({ requiredText: "Enter it" }).props(
47
- "rules",
48
- ) as Rule[];
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 [, type] = input({ types: ["email"] }).props("rules") as Rule[];
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 [, two] = input({ types: ["email", "username"] }).props(
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 [, type] = input({ minUsernameLength: 6 }).props("rules") as Rule[];
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 [, login] = input().props("rules") as Rule[];
78
+ const [login] = input().props("rules") as Rule[];
80
79
  expect(login("sid")).toBe(true);
81
- expect(login("si")).toBe("A username has at least 3 characters");
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: ["email", "username"] }).props("label")).toBe(
89
- "Email or username",
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({