@structured-id/ui-core 4.0.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.0.0",
3
+ "version": "4.2.0",
4
4
  "type": "module",
5
5
  "publishConfig": {
6
6
  "access": "public",
package/src/index.ts CHANGED
@@ -62,6 +62,7 @@ export {
62
62
  refusalMessage,
63
63
  REFUSAL_TEXTS,
64
64
  SID_ERROR_DOMAIN,
65
+ type FieldViolation,
65
66
  type RpcRefusal,
66
67
  } from "./utils/rpcRefusal";
67
68
 
@@ -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
 
@@ -80,11 +78,66 @@ describe("SidLoginForm", () => {
80
78
  expect(w.text()).toContain("Your password never leaves this device");
81
79
  });
82
80
 
81
+ // The default subtitle names only the types the field accepts: a form
82
+ // restricted to email must not invite a phone or a username.
83
+ it("names the accepted types in the default subtitle", () => {
84
+ expect(mountLogin().text()).toContain(
85
+ "Enter your email, phone, or username to continue",
86
+ );
87
+ expect(mountLogin({ principalTypes: ["email"] }).text()).toContain(
88
+ "Enter your email to continue",
89
+ );
90
+ expect(
91
+ mountLogin({ principalTypes: ["email"], subtitle: "Hello" }).text(),
92
+ ).toContain("Hello");
93
+ });
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
+
83
136
  // Regression: the password turned red on the first keystroke and its empty
84
137
  // error read "Required" beside the identifier's "This field is required".
85
138
  it("validates the password once left, with the shared required text", () => {
86
139
  const w = mountLogin();
87
- const password = w.findComponent({ name: "QInputStub" });
140
+ const password = w.findAllComponents({ name: "QInputStub" })[1];
88
141
  const lazy = password.props("lazyRules");
89
142
  expect(lazy === "" || lazy === true).toBe(true);
90
143
  const [required] = password.props("rules") as ((v: string) => unknown)[];
@@ -3,7 +3,7 @@
3
3
  <q-card-section>
4
4
  <slot name="header">
5
5
  <div class="text-h6">{{ title }}</div>
6
- <div class="text-caption text-grey">{{ subtitle }}</div>
6
+ <div class="text-caption text-grey">{{ shownSubtitle }}</div>
7
7
  </slot>
8
8
  </q-card-section>
9
9
 
@@ -38,8 +38,11 @@
38
38
  v-model="identifier"
39
39
  :disable="loading"
40
40
  :label="identifierLabel"
41
+ :types="principalTypes"
42
+ :texts="principalTexts"
41
43
  data-test="principal"
42
44
  @update:principal-type="principalType = $event"
45
+ @update:valid="principalValid = $event"
43
46
  />
44
47
 
45
48
  <q-input
@@ -72,7 +75,7 @@
72
75
  color="primary"
73
76
  class="full-width"
74
77
  :loading="loading"
75
- :disable="!identifier || !password"
78
+ :disable="!principalValid || !password"
76
79
  data-test="submit"
77
80
  />
78
81
  </q-form>
@@ -116,9 +119,17 @@
116
119
  </template>
117
120
 
118
121
  <script setup lang="ts">
119
- import { ref } from "vue";
120
- import { SidPrincipalInput } from "../../quasar";
121
- import type { PrincipalType } from "../../quasar";
122
+ import { computed, ref } from "vue";
123
+ import {
124
+ ALL_PRINCIPAL_TYPES,
125
+ DEFAULT_PRINCIPAL_INPUT_TEXTS,
126
+ SidPrincipalInput,
127
+ } from "../../quasar";
128
+ import type {
129
+ AcceptedPrincipalType,
130
+ PrincipalInputTexts,
131
+ PrincipalType,
132
+ } from "../../quasar";
122
133
  import { normalizePrincipal, refusalMessage } from "../../index";
123
134
  import { REQUIRED_FIELD_TEXT } from "../../utils/validators";
124
135
 
@@ -154,6 +165,10 @@ const props = withDefaults(
154
165
  registerUrl?: string;
155
166
  /** OPAQUE step labels (step 1-3) */
156
167
  opaqueStepLabels?: Record<number, string>;
168
+ /** The principal types this instance signs in with. Default: every type. */
169
+ principalTypes?: AcceptedPrincipalType[];
170
+ /** Replacements for the identifier field's texts. */
171
+ principalTexts?: Partial<PrincipalInputTexts>;
157
172
  /** External login function — receives (identifier, password, stepCallback) */
158
173
  loginFn: (
159
174
  identifier: string,
@@ -167,9 +182,13 @@ const props = withDefaults(
167
182
  }>(),
168
183
  {
169
184
  title: "Sign in",
170
- subtitle: "Enter your email, phone, or username to continue",
185
+ // Without one the subtitle names the accepted types.
186
+ subtitle: undefined,
171
187
  submitLabel: "Sign in",
172
- identifierLabel: "Email, phone, or username",
188
+ // Without one the field names the accepted types.
189
+ identifierLabel: undefined,
190
+ principalTypes: () => ["email", "phone", "username"],
191
+ principalTexts: () => ({}),
173
192
  passwordLabel: "Password",
174
193
  passwordRequiredText: REQUIRED_FIELD_TEXT,
175
194
  showForgotPassword: true,
@@ -195,8 +214,17 @@ const emit = defineEmits<{
195
214
  "forgot-password": [];
196
215
  }>();
197
216
 
217
+ const shownSubtitle = computed(
218
+ () =>
219
+ props.subtitle ??
220
+ { ...DEFAULT_PRINCIPAL_INPUT_TEXTS, ...props.principalTexts }.loginSubtitle(
221
+ ALL_PRINCIPAL_TYPES.filter((t) => props.principalTypes.includes(t)),
222
+ ),
223
+ );
224
+
198
225
  const identifier = ref("");
199
226
  const principalType = ref<PrincipalType>("unknown");
227
+ const principalValid = ref(false);
200
228
  const password = ref("");
201
229
  const showPassword = ref(false);
202
230
  const loading = ref(false);
@@ -1,5 +1,13 @@
1
1
  import { describe, it, expect, vi } from "vitest";
2
2
  import { flushPromises, mount } from "@vue/test-utils";
3
+ import { RpcError } from "@protobuf-ts/runtime-rpc";
4
+ import { base64encode } from "@protobuf-ts/runtime";
5
+ import { Any } from "@structured-id/proto/google/protobuf/any";
6
+ import { Status } from "@structured-id/proto/google/rpc/status";
7
+ import {
8
+ BadRequest,
9
+ ErrorInfo,
10
+ } from "@structured-id/proto/google/rpc/error_details";
3
11
  import SidRegistrationForm from "./SidRegistrationForm.vue";
4
12
 
5
13
  const quasarStubs = {
@@ -16,12 +24,17 @@ const quasarStubs = {
16
24
  emits: ["submit"],
17
25
  },
18
26
  "q-input": {
19
- props: ["modelValue"],
27
+ props: ["modelValue", "error", "errorMessage"],
20
28
  emits: ["update:modelValue"],
29
+ // The form re-validates the confirmation through the QInput API.
30
+ methods: { validate: () => true },
21
31
  template:
22
- '<input :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />',
32
+ '<input :value="modelValue" :data-error="error ? errorMessage : undefined" @input="$emit(\'update:modelValue\', $event.target.value)" />',
33
+ },
34
+ "q-btn": {
35
+ props: ["disable"],
36
+ template: '<button type="submit" :disabled="disable"><slot /></button>',
23
37
  },
24
- "q-btn": { template: '<button type="submit"><slot /></button>' },
25
38
  "sid-password-ceremony": {
26
39
  name: "SidPasswordCeremony",
27
40
  props: ["outcome", "refusedText", "length", "fields"],
@@ -29,14 +42,36 @@ const quasarStubs = {
29
42
  template:
30
43
  '<div class="ceremony" :data-outcome="outcome" :data-refused="refusedText" :data-length="length" :data-fields="fields" />',
31
44
  },
32
- "sid-principal-input": {
33
- props: ["modelValue", "disable", "label"],
34
- emits: ["update:modelValue", "update:principal-type"],
35
- template:
36
- '<input :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />',
37
- },
38
45
  };
39
46
 
47
+ /** A refusal as the gRPC-Web transport raises it, with field violations. */
48
+ function refusedFields(
49
+ violations: { field: string; description: string }[],
50
+ ): RpcError {
51
+ const bad = Any.pack(
52
+ BadRequest.create({
53
+ fieldViolations: violations.map((v) => ({ ...v, reason: "" })),
54
+ }),
55
+ BadRequest,
56
+ );
57
+ const info = Any.pack(
58
+ ErrorInfo.create({
59
+ reason: "INVALID_FIELD_VALUE",
60
+ domain: "structured.id",
61
+ metadata: {},
62
+ }),
63
+ ErrorInfo,
64
+ );
65
+ const status = Status.create({
66
+ code: 3,
67
+ message: "invalid",
68
+ details: [info, bad],
69
+ });
70
+ return new RpcError("invalid", "INVALID_ARGUMENT", {
71
+ "grpc-status-details-bin": base64encode(Status.toBinary(status)),
72
+ });
73
+ }
74
+
40
75
  const registerFn = vi.fn().mockResolvedValue(undefined);
41
76
 
42
77
  function mountForm(props = {}) {
@@ -132,6 +167,30 @@ describe("SidRegistrationForm", () => {
132
167
  );
133
168
  });
134
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
+
135
194
  it("submits the invite code when the installation is claimed", async () => {
136
195
  registerFn.mockClear();
137
196
  const w = mountForm({ initialInviteCode: "ABCD1234" });
@@ -383,6 +442,126 @@ describe("SidRegistrationForm", () => {
383
442
  });
384
443
  });
385
444
 
445
+ describe("principal", () => {
446
+ function principalInput(props = {}) {
447
+ return mountForm(props).findComponent({ name: "SidPrincipalInput" });
448
+ }
449
+
450
+ // The field takes only the types the instance or the scenario admits.
451
+ it("passes the admitted principal types to the field", () => {
452
+ expect(
453
+ principalInput({ principalTypes: ["email"] }).props("types"),
454
+ ).toEqual(["email"]);
455
+ expect(principalInput().props("types")).toEqual([
456
+ "email",
457
+ "phone",
458
+ "username",
459
+ ]);
460
+ });
461
+
462
+ // A short username is admitted only in the registration that presents
463
+ // the installation's claim token (its first administrator); an ordinary
464
+ // registration keeps the registration minimum.
465
+ it("admits a short username only with the claim token", () => {
466
+ expect(principalInput().props("minUsernameLength")).toBe(6);
467
+ expect(
468
+ principalInput({ claimRequired: true }).props("minUsernameLength"),
469
+ ).toBe(3);
470
+ });
471
+
472
+ // Regression: a refusal that named the identifier field reached the
473
+ // user only as an undecoded banner. It is shown under the field it
474
+ // concerns, and the claim token's under the token.
475
+ it("shows each field violation under its field", async () => {
476
+ registerFn.mockRejectedValueOnce(
477
+ refusedFields([
478
+ { field: "principal", description: "Registration here takes email" },
479
+ {
480
+ field: "claim_token",
481
+ description: "Not this installation's token",
482
+ },
483
+ ]),
484
+ );
485
+ const w = mountForm({ claimRequired: true });
486
+ const [claim, principal, password, confirm] = w.findAll("input");
487
+ await claim.setValue("sidclaim_x");
488
+ await principal.setValue("sid");
489
+ await password.setValue("Secret-Pass-123");
490
+ await confirm.setValue("Secret-Pass-123");
491
+ await w.find("form").trigger("submit");
492
+ await flushPromises();
493
+ w.findComponent({ name: "SidPasswordCeremony" }).vm.$emit(
494
+ "settled",
495
+ "refused",
496
+ );
497
+ await flushPromises();
498
+
499
+ const [claimAfter, principalAfter] = w.findAll("input");
500
+ expect(principalAfter.attributes("data-error")).toBe(
501
+ "Registration here takes email",
502
+ );
503
+ expect(claimAfter.attributes("data-error")).toBe(
504
+ "Not this installation's token",
505
+ );
506
+ expect(w.text()).not.toContain("invalid");
507
+
508
+ // Editing the field clears what the server said about it.
509
+ await principalAfter.setValue("owner@sid.example.com");
510
+ expect(w.findAll("input")[1].attributes("data-error")).toBeUndefined();
511
+ });
512
+
513
+ async function refuse(
514
+ violations: { field: string; description: string }[],
515
+ props = {},
516
+ ) {
517
+ registerFn.mockRejectedValueOnce(refusedFields(violations));
518
+ const w = mountForm(props);
519
+ const [principal, password, confirm] = w.findAll("input");
520
+ await principal.setValue("user@sid.example.com");
521
+ await password.setValue("Secret-Pass-123");
522
+ await confirm.setValue("Secret-Pass-123");
523
+ await w.find("form").trigger("submit");
524
+ await flushPromises();
525
+ w.findComponent({ name: "SidPasswordCeremony" }).vm.$emit(
526
+ "settled",
527
+ "refused",
528
+ );
529
+ await flushPromises();
530
+ return w;
531
+ }
532
+
533
+ // Several requirements of one field are all shown at once, not only the
534
+ // last one the server listed.
535
+ it("keeps every violation of one field", async () => {
536
+ const w = await refuse([
537
+ { field: "password", description: "At least 1 digit" },
538
+ { field: "password", description: "At least 1 symbol" },
539
+ ]);
540
+ expect(w.findAll("input")[1].attributes("data-error")).toBe(
541
+ "At least 1 digit; At least 1 symbol",
542
+ );
543
+ });
544
+
545
+ // Regression: a refusal bound to the password stayed under it after the
546
+ // password was edited.
547
+ it("clears the password's refusal when the password changes", async () => {
548
+ const w = await refuse([
549
+ { field: "password", description: "Not accepted" },
550
+ ]);
551
+ await w.findAll("input")[1].setValue("Another-Pass-456");
552
+ expect(w.findAll("input")[1].attributes("data-error")).toBeUndefined();
553
+ });
554
+
555
+ // A violation of a field the form does not show (the invite code while
556
+ // no invite is asked for) stays in the banner instead of disappearing.
557
+ it("keeps a hidden field's violation in the banner", async () => {
558
+ const w = await refuse([
559
+ { field: "invite_code", description: "This invite has expired" },
560
+ ]);
561
+ expect(w.text()).toContain("This invite has expired");
562
+ });
563
+ });
564
+
386
565
  it("renders footer slot", () => {
387
566
  const w = mount(SidRegistrationForm, {
388
567
  props: { registerFn },
@@ -80,7 +80,10 @@
80
80
  spellcheck="false"
81
81
  :disable="loading"
82
82
  :rules="[(v: string) => !!v.trim() || claimTokenRequiredText]"
83
+ :error="!!fieldErrors.claimToken"
84
+ :error-message="fieldErrors.claimToken"
83
85
  data-test="claim-token"
86
+ @update:model-value="fieldErrors.claimToken = undefined"
84
87
  >
85
88
  <template v-slot:prepend>
86
89
  <q-icon name="sym_o_key" />
@@ -108,6 +111,9 @@
108
111
  inviteRequired ? [(v: string) => !!v || inviteCodeRequiredText] : []
109
112
  "
110
113
  :hint="inviteCodeHint"
114
+ :error="!!fieldErrors.inviteCode"
115
+ :error-message="fieldErrors.inviteCode"
116
+ @update:model-value="fieldErrors.inviteCode = undefined"
111
117
  >
112
118
  <template v-slot:prepend>
113
119
  <q-icon name="sym_o_vpn_key" />
@@ -129,10 +135,15 @@
129
135
  v-model="identifier"
130
136
  :disable="loading"
131
137
  :label="identifierLabel"
138
+ :types="principalTypes"
139
+ :texts="principalTexts"
132
140
  :lazy-rules="false"
133
- :min-username-length="usernameMinLength"
141
+ :min-username-length="effectiveUsernameMinLength"
142
+ :field-error="fieldErrors.principal"
134
143
  data-test="principal"
144
+ @update:model-value="fieldErrors.principal = undefined"
135
145
  @update:principal-type="principalType = $event"
146
+ @update:valid="principalValid = $event"
136
147
  />
137
148
 
138
149
  <slot name="extra-fields" />
@@ -146,6 +157,8 @@
146
157
  :disable="loading"
147
158
  :lazy-rules="false"
148
159
  :rules="passwordRules"
160
+ :error="!!fieldErrors.password"
161
+ :error-message="fieldErrors.password"
149
162
  data-test="password"
150
163
  @update:model-value="onPasswordInput"
151
164
  >
@@ -188,7 +201,7 @@
188
201
  class="full-width"
189
202
  :loading="loading"
190
203
  :disable="
191
- !identifier ||
204
+ !principalValid ||
192
205
  !password ||
193
206
  (showConfirmPassword && !confirmPassword) ||
194
207
  (claimRequired && !claimToken.trim()) ||
@@ -231,8 +244,12 @@ import {
231
244
  interface ValidatableInput {
232
245
  validate: () => boolean | Promise<boolean>;
233
246
  }
234
- import type { PrincipalType } from "../../quasar";
235
- import { normalizePrincipal, refusalMessage } from "../../index";
247
+ import type {
248
+ AcceptedPrincipalType,
249
+ PrincipalInputTexts,
250
+ PrincipalType,
251
+ } from "../../quasar";
252
+ import { normalizePrincipal, refusalMessage, rpcRefusal } from "../../index";
236
253
  import { REQUIRED_FIELD_TEXT } from "../../utils/validators";
237
254
 
238
255
  /** What the form collected besides the identifier and password. */
@@ -315,15 +332,31 @@ const props = withDefaults(
315
332
  acceptedText?: string;
316
333
  /** Show confirm-password field with match validation. Default true. */
317
334
  showConfirmPassword?: boolean;
318
- /** Min username length. Registration default 6 (premium feature gating);
319
- * login flow can pass 3 since short usernames are valid post-registration. */
335
+ /**
336
+ * The shortest username an ordinary registration admits; the instance's
337
+ * registration settings give it (6 on the global namespace).
338
+ */
320
339
  usernameMinLength?: number;
340
+ /**
341
+ * The shortest username the claim registration admits: the
342
+ * installation's first administrator may take a short name, nobody else.
343
+ */
344
+ claimUsernameMinLength?: number;
345
+ /**
346
+ * The principal types this registration admits, from the instance's
347
+ * registration settings or the scenario (the platform claim at SaaS
348
+ * central takes email only). Default: every type.
349
+ */
350
+ principalTypes?: AcceptedPrincipalType[];
351
+ /** Replacements for the identifier field's texts. */
352
+ principalTexts?: Partial<PrincipalInputTexts>;
321
353
  }>(),
322
354
  {
323
355
  title: "Create account",
324
356
  subtitle: "Set up your StructuredID account",
325
357
  submitLabel: "Create account",
326
- identifierLabel: "Email, phone, or username",
358
+ // Without one the field names the admitted types.
359
+ identifierLabel: undefined,
327
360
  passwordLabel: "Password",
328
361
  confirmPasswordLabel: "Confirm password",
329
362
  passwordRequiredText: REQUIRED_FIELD_TEXT,
@@ -350,10 +383,38 @@ const props = withDefaults(
350
383
  "This installation has no administrator yet. Enter the claim token from the service log; the account you create becomes its administrator.",
351
384
  showConfirmPassword: true,
352
385
  usernameMinLength: 6,
386
+ claimUsernameMinLength: 3,
387
+ principalTypes: () => ["email", "phone", "username"],
388
+ principalTexts: () => ({}),
353
389
  acceptedText: "Account created",
354
390
  },
355
391
  );
356
392
 
393
+ // The claim registration is a scenario of its own: its admission rules are
394
+ // fixed here until the instance's flow configuration can set them.
395
+ const effectiveUsernameMinLength = computed(() =>
396
+ props.claimRequired ? props.claimUsernameMinLength : props.usernameMinLength,
397
+ );
398
+
399
+ /**
400
+ * What the server said about each field of the request, shown under it until
401
+ * the field is edited. The request fields are the registration Start's.
402
+ */
403
+ const fieldErrors = ref<{
404
+ principal?: string;
405
+ claimToken?: string;
406
+ inviteCode?: string;
407
+ password?: string;
408
+ }>({});
409
+
410
+ /** Request field → form field. */
411
+ const FIELD_OF: Record<string, keyof typeof fieldErrors.value> = {
412
+ principal: "principal",
413
+ claim_token: "claimToken",
414
+ invite_code: "inviteCode",
415
+ password: "password",
416
+ };
417
+
357
418
  const emit = defineEmits<{
358
419
  success: [data: { identifier: string; principalType: string }];
359
420
  error: [error: Error];
@@ -373,6 +434,7 @@ const PAINT_FALLBACK_MS = 250;
373
434
 
374
435
  const identifier = ref("");
375
436
  const principalType = ref<PrincipalType>("unknown");
437
+ const principalValid = ref(false);
376
438
  const password = ref("");
377
439
  const confirmPassword = ref("");
378
440
  const showPassword = ref(false);
@@ -404,6 +466,8 @@ watch(password, () => {
404
466
  });
405
467
 
406
468
  function onPasswordInput() {
469
+ // What the server said about the password was about its earlier value.
470
+ fieldErrors.value.password = undefined;
407
471
  // Trigger downstream confirm re-validation immediately (don't wait for blur).
408
472
  if (confirmPassword.value) confirmRef.value?.validate();
409
473
  }
@@ -426,6 +490,7 @@ async function onSubmit() {
426
490
 
427
491
  loading.value = true;
428
492
  error.value = null;
493
+ fieldErrors.value = {};
429
494
  progress.value = null;
430
495
  ceremony.value = "running";
431
496
  // A phone's keyboard would cover the ceremony.
@@ -434,7 +499,7 @@ async function onSubmit() {
434
499
  try {
435
500
  const normalized = normalizePrincipal(
436
501
  identifier.value,
437
- props.usernameMinLength,
502
+ effectiveUsernameMinLength.value,
438
503
  );
439
504
  const extras: RegistrationExtras = props.claimRequired
440
505
  ? { claimToken: claimToken.value.trim() }
@@ -453,7 +518,31 @@ async function onSubmit() {
453
518
  } catch (e) {
454
519
  success.value = false;
455
520
  const err = e instanceof Error ? e : new Error("Registration failed");
456
- error.value = refusalMessage(e, "Registration failed");
521
+ // A violation of a field the form shows goes under that field, all of a
522
+ // field's together; the banner keeps the rest (a field not shown now
523
+ // included), or the general reason when nothing is left.
524
+ const shown: Record<keyof typeof fieldErrors.value, boolean> = {
525
+ principal: true,
526
+ password: true,
527
+ claimToken: props.claimRequired,
528
+ inviteCode: showInviteField.value,
529
+ };
530
+ const rest: string[] = [];
531
+ for (const v of rpcRefusal(e)?.fieldViolations ?? []) {
532
+ const field = FIELD_OF[v.field];
533
+ if (field && shown[field]) {
534
+ const before = fieldErrors.value[field];
535
+ fieldErrors.value[field] = before ? `${before}; ${v.text}` : v.text;
536
+ } else {
537
+ rest.push(v.text);
538
+ }
539
+ }
540
+ const bound = Object.keys(fieldErrors.value).length > 0;
541
+ error.value = rest.length
542
+ ? rest.join("; ")
543
+ : bound
544
+ ? null
545
+ : refusalMessage(e, "Registration failed");
457
546
  announce = () => emit("error", err);
458
547
  ceremony.value = "refused";
459
548
  } finally {