@structured-id/ui-core 4.1.0 → 4.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@structured-id/ui-core",
3
- "version": "4.1.0",
3
+ "version": "4.2.0",
4
4
  "type": "module",
5
5
  "publishConfig": {
6
6
  "access": "public",
@@ -23,13 +23,11 @@ const quasarStubs = {
23
23
  template:
24
24
  '<input :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />',
25
25
  }),
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
- }),
26
+ "q-btn": {
27
+ props: ["disable"],
28
+ template: '<button type="submit" :disabled="disable"><slot /></button>',
29
+ },
30
+ "q-chip": { template: "<span><slot /></span>" },
33
31
  "router-link": { template: "<a><slot /></a>" },
34
32
  };
35
33
 
@@ -94,11 +92,52 @@ describe("SidLoginForm", () => {
94
92
  ).toContain("Hello");
95
93
  });
96
94
 
95
+ // Fewer than three characters are nobody yet: the form cannot sign in
96
+ // with them, so its button stays disabled until a third is typed.
97
+ it("cannot sign in with fewer than three characters", async () => {
98
+ const w = mountLogin();
99
+ const [principal, password] = w.findAll("input");
100
+ await password.setValue("secret");
101
+ for (const [value, disabled] of [
102
+ ["al", true],
103
+ [" al ", true],
104
+ ["ali", false],
105
+ ] as const) {
106
+ await principal.setValue(value);
107
+ expect(w.find("button").attributes("disabled") !== undefined, value).toBe(
108
+ disabled,
109
+ );
110
+ }
111
+ });
112
+
113
+ // Regression: with "ali.ce" shown as not a username the button was still
114
+ // enabled, since the form only counted characters. It stays disabled until
115
+ // the identifier is a well-formed principal of an accepted type, without
116
+ // waiting for the field's error to appear.
117
+ it("cannot sign in until the identifier is well-formed", async () => {
118
+ const w = mountLogin({ principalTypes: ["email", "username"] });
119
+ const [principal, password] = w.findAll("input");
120
+ await password.setValue("secret");
121
+ for (const [value, disabled] of [
122
+ ["ali", false],
123
+ ["ali.ce", true],
124
+ ["ali ce", true],
125
+ ["+380501234567", true],
126
+ ["alice@", true],
127
+ ["alice@sid.example.com", false],
128
+ ] as const) {
129
+ await principal.setValue(value);
130
+ expect(w.find("button").attributes("disabled") !== undefined, value).toBe(
131
+ disabled,
132
+ );
133
+ }
134
+ });
135
+
97
136
  // Regression: the password turned red on the first keystroke and its empty
98
137
  // error read "Required" beside the identifier's "This field is required".
99
138
  it("validates the password once left, with the shared required text", () => {
100
139
  const w = mountLogin();
101
- const password = w.findComponent({ name: "QInputStub" });
140
+ const password = w.findAllComponents({ name: "QInputStub" })[1];
102
141
  const lazy = password.props("lazyRules");
103
142
  expect(lazy === "" || lazy === true).toBe(true);
104
143
  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);
@@ -31,7 +31,10 @@ const quasarStubs = {
31
31
  template:
32
32
  '<input :value="modelValue" :data-error="error ? errorMessage : undefined" @input="$emit(\'update:modelValue\', $event.target.value)" />',
33
33
  },
34
- "q-btn": { template: '<button type="submit"><slot /></button>' },
34
+ "q-btn": {
35
+ props: ["disable"],
36
+ template: '<button type="submit" :disabled="disable"><slot /></button>',
37
+ },
35
38
  "sid-password-ceremony": {
36
39
  name: "SidPasswordCeremony",
37
40
  props: ["outcome", "refusedText", "length", "fields"],
@@ -39,20 +42,6 @@ const quasarStubs = {
39
42
  template:
40
43
  '<div class="ceremony" :data-outcome="outcome" :data-refused="refusedText" :data-length="length" :data-fields="fields" />',
41
44
  },
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
45
  };
57
46
 
58
47
  /** A refusal as the gRPC-Web transport raises it, with field violations. */
@@ -178,6 +167,30 @@ describe("SidRegistrationForm", () => {
178
167
  );
179
168
  });
180
169
 
170
+ // The registration cannot start until the identifier is a well-formed
171
+ // principal this scenario admits: fewer than three characters are nobody
172
+ // yet, and a username shorter than this registration's minimum, or with a
173
+ // character a username cannot hold, keeps the button disabled too.
174
+ it("cannot register until the identifier is well-formed", async () => {
175
+ const w = mountForm({ claimRequired: false });
176
+ const [principal, password, confirm] = w.findAll("input");
177
+ await password.setValue("Secret-Pass-123");
178
+ await confirm.setValue("Secret-Pass-123");
179
+ for (const [value, disabled] of [
180
+ ["ow", true],
181
+ [" ow ", true],
182
+ ["owner", true],
183
+ ["owner.one", true],
184
+ ["owner_one", false],
185
+ ["owner@example.com", false],
186
+ ] as const) {
187
+ await principal.setValue(value);
188
+ expect(w.find("button").attributes("disabled") !== undefined, value).toBe(
189
+ disabled,
190
+ );
191
+ }
192
+ });
193
+
181
194
  it("submits the invite code when the installation is claimed", async () => {
182
195
  registerFn.mockClear();
183
196
  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);
@@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest";
2
2
  import { mount } from "@vue/test-utils";
3
3
  import { defineComponent } from "vue";
4
4
  import SidPrincipalInput from "./SidPrincipalInput.vue";
5
- import { REQUIRED_FIELD_TEXT } from "../utils/validators";
5
+ import { principalRecognizable } from "./principalInputTexts";
6
6
 
7
7
  const stubs = {
8
8
  "q-input": defineComponent({
@@ -17,7 +17,7 @@ const stubs = {
17
17
  ],
18
18
  template: "<div><slot name='prepend' /><slot name='append' /></div>",
19
19
  }),
20
- "q-icon": { template: "<span />" },
20
+ "q-icon": { props: ["name"], template: "<span :data-icon='name' />" },
21
21
  "q-chip": { template: "<span><slot /></span>" },
22
22
  };
23
23
 
@@ -38,31 +38,25 @@ describe("SidPrincipalInput", () => {
38
38
  expect(input({ lazyRules: false }).props("lazyRules")).toBe(false);
39
39
  });
40
40
 
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);
41
+ // Until the input is long enough to be a principal of some type nothing
42
+ // is shown as wrong, an empty field included: the form keeps its button
43
+ // disabled instead.
44
+ it("shows nothing wrong before a type is recognized", () => {
45
+ const [type] = input().props("rules") as Rule[];
46
+ for (const value of ["", "a", "al"]) {
47
+ expect(type(value), JSON.stringify(value)).toBe(true);
48
+ }
55
49
  });
56
50
 
57
51
  // A field that accepts only some principal types refuses the others before
58
52
  // any call, naming what it accepts: a registration that takes email only
59
53
  // must not let a username reach the server.
60
54
  it("refuses a principal type it does not accept", () => {
61
- const [, type] = input({ types: ["email"] }).props("rules") as Rule[];
55
+ const [type] = input({ types: ["email"] }).props("rules") as Rule[];
62
56
  expect(type("alice_wonder")).toBe("Enter an email address");
63
57
  expect(type("+380501234567")).toBe("Enter an email address");
64
58
  expect(type("alice@sid.example.com")).toBe(true);
65
- const [, two] = input({ types: ["email", "username"] }).props(
59
+ const [two] = input({ types: ["email", "username"] }).props(
66
60
  "rules",
67
61
  ) as Rule[];
68
62
  expect(two("+380501234567")).toBe("Enter an email address or a username");
@@ -73,24 +67,156 @@ describe("SidPrincipalInput", () => {
73
67
  // username and the field asked for "email, phone, or username" instead of
74
68
  // saying the minimum.
75
69
  it("tells a too-short username the minimum length", () => {
76
- const [, type] = input({ minUsernameLength: 6 }).props("rules") as Rule[];
70
+ const [type] = input({ minUsernameLength: 6 }).props("rules") as Rule[];
77
71
  expect(type("sid")).toBe("A username has at least 6 characters");
78
72
  expect(type("sidsid")).toBe(true);
79
- const [, login] = input().props("rules") as Rule[];
73
+ const [login] = input().props("rules") as Rule[];
80
74
  expect(login("sid")).toBe(true);
81
- expect(login("si")).toBe("A username has at least 3 characters");
75
+ });
76
+
77
+ // One or two characters are nobody yet: no type is recognized from them,
78
+ // the field stays a login field with the neutral icon, and nothing is
79
+ // shown as wrong (the form only keeps its button disabled).
80
+ it("recognizes no type from fewer than three characters", () => {
81
+ for (const short of ["a", "al", "+3", "a@", "ЩЩ", " a "]) {
82
+ const field = mount(SidPrincipalInput, {
83
+ props: { modelValue: short, types: ["email", "username"] },
84
+ global: { stubs },
85
+ });
86
+ const stub = field.findComponent({ name: "QInputStub" });
87
+ expect(stub.props("label"), short).toBe("Login");
88
+ expect(field.html(), short).toContain('data-icon="sym_o_login"');
89
+ const [type] = stub.props("rules") as Rule[];
90
+ expect(type(short), short).toBe(true);
91
+ }
92
+ expect(principalRecognizable("al")).toBe(false);
93
+ expect(principalRecognizable(" al ")).toBe(false);
94
+ expect(principalRecognizable("ali")).toBe(true);
95
+ expect(principalRecognizable("Щук")).toBe(true);
96
+ });
97
+
98
+ // Regression: a username with a letter outside a-z was taken for no type
99
+ // and refused before the server could apply its repertoire; its length is
100
+ // counted in code points of its NFC form, as the server counts it.
101
+ it("takes a username in any script and counts its code points", () => {
102
+ const [type] = input({
103
+ types: ["email", "username"],
104
+ minUsernameLength: 6,
105
+ }).props("rules") as Rule[];
106
+ expect(type("Иван_Петров")).toBe(true);
107
+ expect(type("Щукин")).toBe("A username has at least 6 characters");
108
+ expect(type("Jürgen")).toBe(true); // 7 code points, 6 in NFC
109
+ // Whitespace, inside or around, is refused, not trimmed into a name, and
110
+ // the refusal says what is wrong.
111
+ for (const spaced of ["Иван Петров", " Иван_Петров", "Иван_Петров "]) {
112
+ expect(type(spaced)).toBe("A username cannot contain spaces");
113
+ }
114
+ });
115
+
116
+ // Regression: a username with a character it cannot hold (a dot, a dash,
117
+ // a symbol) was taken for no type at all and told to "enter an email
118
+ // address or a username", which names no problem. Input without `@` and
119
+ // `+` is a username attempt, and the refusal names the characters allowed.
120
+ it("tells a username which characters it can hold", () => {
121
+ const [two] = input({ types: ["email", "username"] }).props(
122
+ "rules",
123
+ ) as Rule[];
124
+ for (const odd of ["ali.ce", "ali-ce", "😀😀😀", "alice#acme.corp"]) {
125
+ expect(two(odd)).toBe(
126
+ "A username can contain only letters, digits and underscores",
127
+ );
128
+ }
129
+ // A field that takes no username still asks for what it takes.
130
+ const [email] = input({ types: ["email"] }).props("rules") as Rule[];
131
+ expect(email("ali.ce")).toBe("Enter an email address");
132
+ expect(
133
+ input({ types: ["email", "username"], modelValue: "ali.ce" }).props(
134
+ "label",
135
+ ),
136
+ ).toBe("Username");
137
+ });
138
+
139
+ // Regression: the label already names the recognized type, and a chip
140
+ // repeated it beside the icon. The chip is shown only when the form's own
141
+ // label does not name the type.
142
+ it("names the type once", () => {
143
+ const named = mount(SidPrincipalInput, {
144
+ props: { modelValue: "alice@sid.example.com" },
145
+ global: { stubs },
146
+ });
147
+ expect(named.findComponent({ name: "QInputStub" }).props("label")).toBe(
148
+ "Email",
149
+ );
150
+ expect(named.text()).not.toContain("email");
151
+ const own = mount(SidPrincipalInput, {
152
+ props: { modelValue: "alice@sid.example.com", label: "Sign in with" },
153
+ global: { stubs },
154
+ });
155
+ expect(own.text()).toContain("email");
156
+ });
157
+
158
+ // Regression: a phone number on a field that takes no phone got the phone
159
+ // icon and chip, as if it were accepted. A type the field refuses shows
160
+ // the neutral login icon and no chip.
161
+ it("does not dress a refused type as accepted", () => {
162
+ const w = mount(SidPrincipalInput, {
163
+ props: {
164
+ modelValue: "+380501234567",
165
+ types: ["email", "username"],
166
+ label: "Sign in with",
167
+ },
168
+ global: { stubs },
169
+ });
170
+ expect(w.html()).toContain('data-icon="sym_o_login"');
171
+ expect(w.text()).not.toContain("phone");
172
+ });
173
+
174
+ // Regression: an email-only field showed the generic login icon until an
175
+ // address was typed, beside its "Email" label. A field of one type shows
176
+ // that type's icon from the start, greyed until the input is recognized.
177
+ it("shows a single-type field's icon from the start", () => {
178
+ const empty = mount(SidPrincipalInput, {
179
+ props: { modelValue: "", types: ["email"] },
180
+ global: { stubs },
181
+ });
182
+ expect(empty.html()).toContain('data-icon="sym_o_mail"');
183
+ expect(
184
+ mount(SidPrincipalInput, {
185
+ props: { modelValue: "", types: ["email", "username"] },
186
+ global: { stubs },
187
+ }).html(),
188
+ ).toContain('data-icon="sym_o_login"');
82
189
  });
83
190
 
84
191
  // The label names the accepted types unless the form gives its own.
192
+ // A field of one type is named by it. A field of several is a login
193
+ // field until the input shows which type it is, then it names that type;
194
+ // a recognized type the field does not accept leaves it a login field.
85
195
  it("labels the field by the types it accepts", () => {
86
- expect(input().props("label")).toBe("Email, phone, or username");
87
196
  expect(input({ types: ["email"] }).props("label")).toBe("Email");
88
- expect(input({ types: ["email", "username"] }).props("label")).toBe(
89
- "Email or username",
197
+ expect(input({ types: ["username"] }).props("label")).toBe("Username");
198
+ expect(input().props("label")).toBe("Login");
199
+ const two = { types: ["email", "username"] };
200
+ expect(input(two).props("label")).toBe("Login");
201
+ expect(
202
+ input({ ...two, modelValue: "alice@sid.example.com" }).props("label"),
203
+ ).toBe("Email");
204
+ expect(input({ ...two, modelValue: "alice_wonder" }).props("label")).toBe(
205
+ "Username",
206
+ );
207
+ expect(input({ ...two, modelValue: "+380501234567" }).props("label")).toBe(
208
+ "Login",
209
+ );
210
+ expect(input({ ...two, texts: { login: "Логин" } }).props("label")).toBe(
211
+ "Логин",
90
212
  );
91
213
  expect(
92
214
  input({ types: ["email"], label: "Your email" }).props("label"),
93
215
  ).toBe("Your email");
216
+ // An explicitly empty label is the form's choice of no label.
217
+ expect(
218
+ input({ types: ["email", "username"], label: "" }).props("label"),
219
+ ).toBe("");
94
220
  });
95
221
 
96
222
  // The chip naming the detected type is a text like the others: a
@@ -99,6 +225,7 @@ describe("SidPrincipalInput", () => {
99
225
  const w = mount(SidPrincipalInput, {
100
226
  props: {
101
227
  modelValue: "alice@sid.example.com",
228
+ label: "Sign in with",
102
229
  texts: { typeChip: (t: string) => `chip:${t}` },
103
230
  },
104
231
  global: { stubs },
@@ -106,6 +233,29 @@ describe("SidPrincipalInput", () => {
106
233
  expect(w.text()).toContain("chip:email");
107
234
  });
108
235
 
236
+ // The form disables its submit on this, before any error is shown: only a
237
+ // recognized, accepted, well-formed value, not refused by the server, is
238
+ // valid.
239
+ it("tells the form whether the value can be submitted", () => {
240
+ const valid = (props: { modelValue: string; fieldError?: string }) => {
241
+ const w = mount(SidPrincipalInput, {
242
+ props: { types: ["email", "username"], ...props },
243
+ global: { stubs },
244
+ });
245
+ return w.emitted("update:valid")?.at(-1)?.[0];
246
+ };
247
+ expect(valid({ modelValue: "" })).toBe(false);
248
+ expect(valid({ modelValue: "al" })).toBe(false);
249
+ expect(valid({ modelValue: "ali" })).toBe(true);
250
+ expect(valid({ modelValue: "ali.ce" })).toBe(false);
251
+ expect(valid({ modelValue: "+380501234567" })).toBe(false);
252
+ expect(valid({ modelValue: "alice@" })).toBe(false);
253
+ expect(valid({ modelValue: "alice@sid.example.com" })).toBe(true);
254
+ expect(
255
+ valid({ modelValue: "alice@sid.example.com", fieldError: "Taken" }),
256
+ ).toBe(false);
257
+ });
258
+
109
259
  // A refusal the server bound to this field is shown on it.
110
260
  it("shows an error given from outside", () => {
111
261
  const shown = input({
@@ -3,7 +3,7 @@
3
3
  ref="inputRef"
4
4
  v-model="model"
5
5
  :label="fieldLabel"
6
- :rules="[requiredRule, typeRule]"
6
+ :rules="[typeRule]"
7
7
  :error="!!fieldError"
8
8
  :error-message="fieldError ?? undefined"
9
9
  outlined
@@ -18,7 +18,7 @@
18
18
  <template #prepend>
19
19
  <q-icon :name="iconName" :color="iconColor" size="24px" />
20
20
  </template>
21
- <template v-if="model && detectedType !== 'unknown'" #append>
21
+ <template v-if="showChip" #append>
22
22
  <q-chip
23
23
  dense
24
24
  :color="chipColor"
@@ -35,11 +35,12 @@
35
35
  <script setup lang="ts">
36
36
  import { ref, computed, watch } from "vue";
37
37
  import { isValidPhoneNumber } from "libphonenumber-js";
38
- import { REQUIRED_FIELD_TEXT, isValidEmail } from "../utils/validators";
38
+ import { isValidEmail } from "../utils/validators";
39
39
 
40
40
  import {
41
41
  ALL_PRINCIPAL_TYPES,
42
42
  DEFAULT_PRINCIPAL_INPUT_TEXTS,
43
+ principalRecognizable,
43
44
  type AcceptedPrincipalType,
44
45
  type PrincipalInputTexts,
45
46
  type PrincipalType,
@@ -56,15 +57,12 @@ interface Props {
56
57
  */
57
58
  types?: AcceptedPrincipalType[];
58
59
  minUsernameLength?: number;
59
- required?: boolean;
60
60
  /** The label; without one the field names the accepted types. */
61
61
  label?: string;
62
62
  /** A refusal the server bound to this field, shown on it until edited. */
63
63
  fieldError?: string | null;
64
64
  /** Replacements for the field's texts. */
65
65
  texts?: Partial<PrincipalInputTexts>;
66
- /** What an empty required field says. */
67
- requiredText?: string;
68
66
  /**
69
67
  * Validate once the field is left, then on every change (true), or on
70
68
  * every change from the first keystroke (false). Default true: a field
@@ -76,13 +74,11 @@ interface Props {
76
74
  const props = withDefaults(defineProps<Props>(), {
77
75
  modelValue: "",
78
76
  types: () => [...ALL_PRINCIPAL_TYPES],
79
- // Username rule: ^[a-z0-9_]{3,32}$ (display form ^[a-zA-Z0-9_]{3,32}$).
77
+ // Code points of the NFC display form; the server's minimum decides.
80
78
  minUsernameLength: 3,
81
- required: true,
82
79
  label: undefined,
83
80
  fieldError: null,
84
81
  texts: () => ({}),
85
- requiredText: REQUIRED_FIELD_TEXT,
86
82
  lazyRules: true,
87
83
  });
88
84
 
@@ -96,13 +92,26 @@ const accepted = computed(() =>
96
92
  ALL_PRINCIPAL_TYPES.filter((t) => props.types.includes(t)),
97
93
  );
98
94
 
99
- const fieldLabel = computed(
100
- () => props.label ?? text.value.label(accepted.value),
101
- );
95
+ /**
96
+ * A field of one type is named by it; a field of several is a login field
97
+ * until its input is recognized as one of the accepted types.
98
+ */
99
+ const fieldLabel = computed(() => {
100
+ // An empty label is the form's own choice; only an absent one is derived.
101
+ if (props.label != null) return props.label;
102
+ const [only, ...others] = accepted.value;
103
+ if (only && others.length === 0) return text.value.label([only]);
104
+ const type = detectedType.value;
105
+ return type !== "unknown" && accepted.value.includes(type)
106
+ ? text.value.label([type])
107
+ : text.value.login;
108
+ });
102
109
 
103
110
  const emit = defineEmits<{
104
111
  "update:modelValue": [value: string];
105
112
  "update:principalType": [type: PrincipalType];
113
+ /** Whether the value can be submitted: a recognized, accepted, well-formed principal. */
114
+ "update:valid": [valid: boolean];
106
115
  }>();
107
116
 
108
117
  const inputRef = ref<{ focus: () => void } | null>(null);
@@ -114,10 +123,13 @@ const model = computed({
114
123
 
115
124
  const USERNAME_MAX_LENGTH = 32;
116
125
 
126
+ /** What a username may hold: letters of any script, marks, digits, `_`. */
127
+ const USERNAME_SHAPE = /^[\p{L}\p{M}\p{N}_]+$/u;
128
+
117
129
  function detectType(value: string): PrincipalType {
118
- if (!value) return "unknown";
130
+ // One or two characters are nobody yet: no type is read from them.
131
+ if (!principalRecognizable(value)) return "unknown";
119
132
  const trimmed = value.trim();
120
- if (!trimmed) return "unknown";
121
133
 
122
134
  // Phone: starts with +
123
135
  if (trimmed.startsWith("+")) return "phone";
@@ -125,12 +137,9 @@ function detectType(value: string): PrincipalType {
125
137
  // Email: contains @ (checked after phone to exclude edge cases)
126
138
  if (trimmed.includes("@")) return "email";
127
139
 
128
- // Username by its shape at any length, so that a short one is told the
129
- // minimum instead of being taken for no type at all. Uppercase is accepted
130
- // (the server derives the key); length and underscores are the rule's.
131
- if (/^[a-zA-Z0-9_]+$/.test(trimmed)) return "username";
132
-
133
- return "unknown";
140
+ // Anything else is a username attempt, whatever it holds, so that what is
141
+ // wrong with it (spaces, a dot, a symbol, its length) can be said.
142
+ return "username";
134
143
  }
135
144
 
136
145
  const detectedType = computed(() => detectType(model.value));
@@ -146,13 +155,14 @@ function onInput(value: string | number | null) {
146
155
 
147
156
  // --- Validation rules ---
148
157
 
149
- const requiredRule = (val: string) =>
150
- !props.required || !!val || props.requiredText;
151
-
158
+ /**
159
+ * Until a type is recognized nothing is wrong yet, an empty field included:
160
+ * the form keeps its button disabled instead (see principalRecognizable).
161
+ */
152
162
  const typeRule = (val: string): string | true => {
153
- if (!val) return true; // required rule handles empty
154
163
  const type = detectType(val);
155
- if (type === "unknown" || !accepted.value.includes(type)) {
164
+ if (type === "unknown") return true;
165
+ if (!accepted.value.includes(type)) {
156
166
  return text.value.enterAccepted(accepted.value);
157
167
  }
158
168
 
@@ -162,10 +172,15 @@ const typeRule = (val: string): string | true => {
162
172
  case "phone":
163
173
  return isValidPhoneNumber(val) || text.value.invalidPhone;
164
174
  case "username": {
165
- const name = val.trim();
166
- if (name.length < props.minUsernameLength)
175
+ const name = val;
176
+ if (/\s/u.test(name)) return text.value.usernameSpaces;
177
+ if (!USERNAME_SHAPE.test(name)) return text.value.usernameCharacters;
178
+ // Counted in code points of the NFC form, as the server counts the
179
+ // display; the value itself is sent unchanged.
180
+ const length = [...name.normalize("NFC")].length;
181
+ if (length < props.minUsernameLength)
167
182
  return text.value.usernameTooShort(props.minUsernameLength);
168
- if (name.length > USERNAME_MAX_LENGTH)
183
+ if (length > USERNAME_MAX_LENGTH)
169
184
  return text.value.usernameTooLong(USERNAME_MAX_LENGTH);
170
185
  if (name.startsWith("_") || name.endsWith("_"))
171
186
  return text.value.usernameUnderscoreEdge;
@@ -175,10 +190,46 @@ const typeRule = (val: string): string | true => {
175
190
  }
176
191
  };
177
192
 
193
+ /**
194
+ * A form keeps its submit disabled until this holds, silently: the error a
195
+ * rule names appears only once the field is left, the button never waits for
196
+ * it. A refusal the server bound to the field holds until the value changes.
197
+ */
198
+ const valid = computed(
199
+ () =>
200
+ detectedType.value !== "unknown" &&
201
+ typeRule(model.value) === true &&
202
+ !props.fieldError,
203
+ );
204
+
205
+ watch(valid, (v) => emit("update:valid", v), { immediate: true });
206
+
178
207
  // --- Dynamic icon & type ---
179
208
 
209
+ /** The recognized type when the field accepts it; refused types look neutral. */
210
+ const shownType = computed<PrincipalType>(() => {
211
+ const type = detectedType.value;
212
+ return type !== "unknown" && accepted.value.includes(type) ? type : "unknown";
213
+ });
214
+
215
+ /**
216
+ * The chip names the type only when the label does not: a derived label
217
+ * already says "Email" or "Username".
218
+ */
219
+ const showChip = computed(
220
+ () => props.label != null && shownType.value !== "unknown",
221
+ );
222
+
223
+ /** A field of one type shows that type's icon from the start, greyed until recognized. */
224
+ const iconType = computed<PrincipalType>(() => {
225
+ const [only, ...others] = accepted.value;
226
+ return shownType.value === "unknown" && only && others.length === 0
227
+ ? only
228
+ : shownType.value;
229
+ });
230
+
180
231
  const iconName = computed(() => {
181
- switch (detectedType.value) {
232
+ switch (iconType.value) {
182
233
  case "email":
183
234
  return "sym_o_mail";
184
235
  case "phone":
@@ -191,7 +242,7 @@ const iconName = computed(() => {
191
242
  });
192
243
 
193
244
  const iconColor = computed(() =>
194
- detectedType.value === "unknown" ? "grey-6" : "primary",
245
+ shownType.value === "unknown" ? "grey-6" : "primary",
195
246
  );
196
247
 
197
248
  // inputmode controls mobile keyboard WITHOUT triggering browser autofill.
@@ -211,13 +262,11 @@ const inputMode = computed(() => {
211
262
  });
212
263
 
213
264
  const chipLabel = computed(() =>
214
- detectedType.value === "unknown"
215
- ? ""
216
- : text.value.typeChip(detectedType.value),
265
+ shownType.value === "unknown" ? "" : text.value.typeChip(shownType.value),
217
266
  );
218
267
 
219
268
  const chipColor = computed(() => {
220
- switch (detectedType.value) {
269
+ switch (shownType.value) {
221
270
  case "email":
222
271
  return "deep-orange";
223
272
  case "phone":
@@ -235,5 +284,5 @@ function focus() {
235
284
  inputRef.value?.focus();
236
285
  }
237
286
 
238
- defineExpose({ focus, detectedType });
287
+ defineExpose({ focus, detectedType, valid });
239
288
  </script>
@@ -13,6 +13,8 @@ export type {
13
13
  export {
14
14
  ALL_PRINCIPAL_TYPES,
15
15
  DEFAULT_PRINCIPAL_INPUT_TEXTS,
16
+ MIN_PRINCIPAL_LENGTH,
17
+ principalRecognizable,
16
18
  } from "./principalInputTexts";
17
19
  export { default as SidPasswordCeremony } from "./SidPasswordCeremony.vue";
18
20
  export type { CeremonyOutcome } from "./SidPasswordCeremony.vue";
@@ -12,10 +12,27 @@ export const ALL_PRINCIPAL_TYPES: readonly AcceptedPrincipalType[] = [
12
12
  "username",
13
13
  ];
14
14
 
15
+ /**
16
+ * Fewer characters than this are nobody yet: no type is recognized from them
17
+ * and no form can submit them. Counted in code points of the NFC form,
18
+ * surrounding whitespace aside.
19
+ */
20
+ export const MIN_PRINCIPAL_LENGTH = 3;
21
+
22
+ /** Whether `value` is long enough to be anybody. */
23
+ export function principalRecognizable(value: string): boolean {
24
+ return [...value.trim().normalize("NFC")].length >= MIN_PRINCIPAL_LENGTH;
25
+ }
26
+
15
27
  /** What the field says; each text can be replaced (translated) by the form. */
16
28
  export interface PrincipalInputTexts {
17
- /** The label for the accepted types, when the form gives none. */
29
+ /**
30
+ * The label naming one type: the field's only accepted type, or the type
31
+ * the input of a several-type field was recognized as.
32
+ */
18
33
  label: (types: AcceptedPrincipalType[]) => string;
34
+ /** The label of a field of several types before its input shows which. */
35
+ login: string;
19
36
  /** A value of a type the field does not accept, or of no type at all. */
20
37
  enterAccepted: (types: AcceptedPrincipalType[]) => string;
21
38
  /** The chip naming the type the value was recognized as. */
@@ -28,6 +45,10 @@ export interface PrincipalInputTexts {
28
45
  usernameTooLong: (max: number) => string;
29
46
  usernameUnderscoreEdge: string;
30
47
  usernameDoubleUnderscore: string;
48
+ /** A username with whitespace in or around it. */
49
+ usernameSpaces: string;
50
+ /** A username with a character other than a letter, digit or `_`. */
51
+ usernameCharacters: string;
31
52
  }
32
53
 
33
54
  /** `a`, `a or b`, `a, b, or c`. */
@@ -54,6 +75,7 @@ export const DEFAULT_PRINCIPAL_INPUT_TEXTS: PrincipalInputTexts = {
54
75
  const text = list(types.map((t) => LABEL_WORDS[t]));
55
76
  return text.charAt(0).toUpperCase() + text.slice(1);
56
77
  },
78
+ login: "Login",
57
79
  enterAccepted: (types) => `Enter ${list(types.map((t) => ENTER_WORDS[t]))}`,
58
80
  typeChip: (type) => LABEL_WORDS[type],
59
81
  loginSubtitle: (types) =>
@@ -65,4 +87,7 @@ export const DEFAULT_PRINCIPAL_INPUT_TEXTS: PrincipalInputTexts = {
65
87
  usernameUnderscoreEdge: "A username cannot start or end with an underscore",
66
88
  usernameDoubleUnderscore:
67
89
  "A username cannot contain two underscores in a row",
90
+ usernameSpaces: "A username cannot contain spaces",
91
+ usernameCharacters:
92
+ "A username can contain only letters, digits and underscores",
68
93
  };
@@ -17,10 +17,18 @@ describe("detectLoginInputType", () => {
17
17
  expect(detectLoginInputType("alice")).toBe("username");
18
18
  expect(detectLoginInputType("bob_smith")).toBe("username");
19
19
  expect(detectLoginInputType("dev42")).toBe("username");
20
- // Uppercase accepted in detection (will be lowercased on normalize)
20
+ // Uppercase accepted; the server derives the key.
21
21
  expect(detectLoginInputType("Admin123")).toBe("username");
22
22
  });
23
23
 
24
+ it("detects a username in any script, counted in code points", () => {
25
+ expect(detectLoginInputType("Иван")).toBe("username");
26
+ expect(detectLoginInputType("Иван_Петров", 6)).toBe("username");
27
+ expect(detectLoginInputType("Щукин", 6)).toBe("unknown"); // 5 < 6
28
+ expect(detectLoginInputType("ЩЩ")).toBe("unknown"); // 2 < 3
29
+ expect(detectLoginInputType("Иван Петров")).toBe("unknown"); // a space
30
+ });
31
+
24
32
  it("detects username with minLength=6 (registration)", () => {
25
33
  expect(detectLoginInputType("alice", 6)).toBe("unknown"); // too short
26
34
  expect(detectLoginInputType("alicew", 6)).toBe("username"); // exactly 6
@@ -126,13 +134,42 @@ describe("normalizePrincipal", () => {
126
134
  expect(result.display).toBe("+380501234567");
127
135
  });
128
136
 
129
- it("normalizes username: value=lowercase, display=original casing", () => {
137
+ // Regression: the client lowercased a username before sending it, so the
138
+ // server never saw the spelling the user typed and could not keep it as
139
+ // the display form. The server derives the key, as it does for email.
140
+ it("sends a username as typed, case kept", () => {
130
141
  const result = normalizePrincipal("Alice_Bob");
131
142
  expect(result.type).toBe("username");
132
- expect(result.value).toBe("alice_bob");
143
+ expect(result.value).toBe("Alice_Bob");
133
144
  expect(result.display).toBe("Alice_Bob");
134
145
  });
135
146
 
147
+ // Whitespace is never part of a name and is not repaired: a username with
148
+ // surrounding spaces is refused, not trimmed into another name.
149
+ it("refuses a username with surrounding whitespace", () => {
150
+ for (const input of [" Alice_Bob ", "Alice_Bob ", "\tAlice_Bob"]) {
151
+ expect(() => normalizePrincipal(input)).toThrow("Cannot determine");
152
+ }
153
+ });
154
+
155
+ // Regression: a letter outside a-z made the name no type at all, so the
156
+ // field refused it before the server could apply its repertoire.
157
+ it("sends a username in any script as typed", () => {
158
+ for (const name of ["Иван_Петров", "Ωmega42", "Jürgen"]) {
159
+ const result = normalizePrincipal(name);
160
+ expect(result.type).toBe("username");
161
+ expect(result.value).toBe(name);
162
+ }
163
+ });
164
+
165
+ it("sends a username unnormalized and counts it after NFC", () => {
166
+ // "Jürgen" with ü as u + combining diaeresis: 7 code points, 6 in NFC.
167
+ const decomposed = "Jürgen";
168
+ const result = normalizePrincipal(decomposed, 6);
169
+ expect(result.value).toBe(decomposed);
170
+ expect(() => normalizePrincipal("Щукин", 6)).toThrow("Cannot determine");
171
+ });
172
+
136
173
  it("rejects username with leading/trailing underscore (detected as unknown)", () => {
137
174
  // Leading/trailing _ blocked at detection level → "Cannot determine"
138
175
  expect(() => normalizePrincipal("_alice_bob")).toThrow("Cannot determine");
@@ -14,17 +14,18 @@ export interface NormalizedPrincipal {
14
14
  }
15
15
 
16
16
  /**
17
- * Global username rules:
18
- * Display: ^[a-zA-Z0-9_]{3,32}$ — user's original input, mixed case preserved
19
- * Normalized: ^[a-z0-9_]{3,32}$ — lowercase, for storage + uniqueness + login
20
- * - cannot start or end with underscore
21
- * - no consecutive underscores (__)
22
- *
23
- * For detection (prematch) we use a relaxed version:
24
- * - accepts minLength (3 for login, 6 for registration)
25
- * - case-insensitive (will be lowercased on normalization)
17
+ * A global username as the user types it: letters of any script, combining
18
+ * marks, digits and `_`, at most 32 code points in NFC, no `_` at either
19
+ * end. It is sent exactly as typed (nothing trimmed or normalized); the
20
+ * server applies its repertoire and minimum and derives the lookup key.
26
21
  */
27
22
  const USERNAME_MAX_LENGTH = 32;
23
+ const USERNAME_SHAPE = /^[\p{L}\p{M}\p{N}_]+$/u;
24
+
25
+ /** The length of `name` in code points of its NFC form. */
26
+ function codePoints(name: string): number {
27
+ return [...name.normalize("NFC")].length;
28
+ }
28
29
 
29
30
  /**
30
31
  * Detect principal type from user input.
@@ -63,11 +64,16 @@ export function detectLoginInputType(
63
64
  return "unknown";
64
65
  }
65
66
 
66
- // Global username: no #, no @, no +
67
- const re = new RegExp(
68
- `^[a-zA-Z0-9_]{${minUsernameLength},${USERNAME_MAX_LENGTH}}$`,
69
- );
70
- if (re.test(trimmed) && !trimmed.startsWith("_") && !trimmed.endsWith("_")) {
67
+ // Global username: no #, no @, no +. Judged on the input itself, not the
68
+ // trimmed form: whitespace is never part of a name and is never repaired.
69
+ const length = codePoints(input);
70
+ if (
71
+ USERNAME_SHAPE.test(input) &&
72
+ length >= minUsernameLength &&
73
+ length <= USERNAME_MAX_LENGTH &&
74
+ !input.startsWith("_") &&
75
+ !input.endsWith("_")
76
+ ) {
71
77
  return "username";
72
78
  }
73
79
 
@@ -79,7 +85,7 @@ export function detectLoginInputType(
79
85
  *
80
86
  * - Email: as typed (trimmed); the server applies the email policy
81
87
  * - Phone: E.164 format via libphonenumber-js
82
- * - Username: lowercase, validate per arch rules
88
+ * - Username: exactly as typed; a federated one trimmed and lowercased
83
89
  *
84
90
  * @param minUsernameLength - minimum chars for username (3 for login, 6 for register)
85
91
  * Throws if value doesn't pass validation for detected type.
@@ -89,7 +95,7 @@ export function normalizePrincipal(
89
95
  minUsernameLength = 3,
90
96
  ): NormalizedPrincipal {
91
97
  const trimmed = input.trim();
92
- const type = detectLoginInputType(trimmed, minUsernameLength);
98
+ const type = detectLoginInputType(input, minUsernameLength);
93
99
 
94
100
  switch (type) {
95
101
  case "email": {
@@ -135,20 +141,13 @@ export function normalizePrincipal(
135
141
  return { value: normalized, display: trimmed, type: "username" };
136
142
  }
137
143
 
138
- // Global username
139
- const normalized = trimmed.toLowerCase();
140
- const re = new RegExp(
141
- `^[a-z0-9_]{${minUsernameLength},${USERNAME_MAX_LENGTH}}$`,
142
- );
143
- if (
144
- !re.test(normalized) ||
145
- normalized.startsWith("_") ||
146
- normalized.endsWith("_") ||
147
- normalized.includes("__")
148
- ) {
144
+ // Global username: detection checked its shape and length on the
145
+ // input itself; sent exactly as typed, the server normalizes it and
146
+ // derives the key.
147
+ if (input.includes("__")) {
149
148
  throw new Error("Invalid username");
150
149
  }
151
- return { value: normalized, display: trimmed, type: "username" };
150
+ return { value: input, display: input, type: "username" };
152
151
  }
153
152
 
154
153
  default: