@structured-id/ui-core 3.2.0 → 4.1.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": "3.2.0",
3
+ "version": "4.1.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
 
@@ -80,6 +80,20 @@ describe("SidLoginForm", () => {
80
80
  expect(w.text()).toContain("Your password never leaves this device");
81
81
  });
82
82
 
83
+ // The default subtitle names only the types the field accepts: a form
84
+ // restricted to email must not invite a phone or a username.
85
+ it("names the accepted types in the default subtitle", () => {
86
+ expect(mountLogin().text()).toContain(
87
+ "Enter your email, phone, or username to continue",
88
+ );
89
+ expect(mountLogin({ principalTypes: ["email"] }).text()).toContain(
90
+ "Enter your email to continue",
91
+ );
92
+ expect(
93
+ mountLogin({ principalTypes: ["email"], subtitle: "Hello" }).text(),
94
+ ).toContain("Hello");
95
+ });
96
+
83
97
  // Regression: the password turned red on the first keystroke and its empty
84
98
  // error read "Required" beside the identifier's "This field is required".
85
99
  it("validates the password once left, with the shared required text", () => {
@@ -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,6 +38,8 @@
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"
43
45
  />
@@ -116,9 +118,17 @@
116
118
  </template>
117
119
 
118
120
  <script setup lang="ts">
119
- import { ref } from "vue";
120
- import { SidPrincipalInput } from "../../quasar";
121
- import type { PrincipalType } from "../../quasar";
121
+ import { computed, ref } from "vue";
122
+ import {
123
+ ALL_PRINCIPAL_TYPES,
124
+ DEFAULT_PRINCIPAL_INPUT_TEXTS,
125
+ SidPrincipalInput,
126
+ } from "../../quasar";
127
+ import type {
128
+ AcceptedPrincipalType,
129
+ PrincipalInputTexts,
130
+ PrincipalType,
131
+ } from "../../quasar";
122
132
  import { normalizePrincipal, refusalMessage } from "../../index";
123
133
  import { REQUIRED_FIELD_TEXT } from "../../utils/validators";
124
134
 
@@ -154,6 +164,10 @@ const props = withDefaults(
154
164
  registerUrl?: string;
155
165
  /** OPAQUE step labels (step 1-3) */
156
166
  opaqueStepLabels?: Record<number, string>;
167
+ /** The principal types this instance signs in with. Default: every type. */
168
+ principalTypes?: AcceptedPrincipalType[];
169
+ /** Replacements for the identifier field's texts. */
170
+ principalTexts?: Partial<PrincipalInputTexts>;
157
171
  /** External login function — receives (identifier, password, stepCallback) */
158
172
  loginFn: (
159
173
  identifier: string,
@@ -167,9 +181,13 @@ const props = withDefaults(
167
181
  }>(),
168
182
  {
169
183
  title: "Sign in",
170
- subtitle: "Enter your email, phone, or username to continue",
184
+ // Without one the subtitle names the accepted types.
185
+ subtitle: undefined,
171
186
  submitLabel: "Sign in",
172
- identifierLabel: "Email, phone, or username",
187
+ // Without one the field names the accepted types.
188
+ identifierLabel: undefined,
189
+ principalTypes: () => ["email", "phone", "username"],
190
+ principalTexts: () => ({}),
173
191
  passwordLabel: "Password",
174
192
  passwordRequiredText: REQUIRED_FIELD_TEXT,
175
193
  showForgotPassword: true,
@@ -195,6 +213,14 @@ const emit = defineEmits<{
195
213
  "forgot-password": [];
196
214
  }>();
197
215
 
216
+ const shownSubtitle = computed(
217
+ () =>
218
+ props.subtitle ??
219
+ { ...DEFAULT_PRINCIPAL_INPUT_TEXTS, ...props.principalTexts }.loginSubtitle(
220
+ ALL_PRINCIPAL_TYPES.filter((t) => props.principalTypes.includes(t)),
221
+ ),
222
+ );
223
+
198
224
  const identifier = ref("");
199
225
  const principalType = ref<PrincipalType>("unknown");
200
226
  const password = ref("");
@@ -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,10 +24,12 @@ 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)" />',
23
33
  },
24
34
  "q-btn": { template: '<button type="submit"><slot /></button>' },
25
35
  "sid-password-ceremony": {
@@ -30,13 +40,49 @@ const quasarStubs = {
30
40
  '<div class="ceremony" :data-outcome="outcome" :data-refused="refusedText" :data-length="length" :data-fields="fields" />',
31
41
  },
32
42
  "sid-principal-input": {
33
- props: ["modelValue", "disable", "label"],
43
+ name: "SidPrincipalInput",
44
+ props: [
45
+ "modelValue",
46
+ "disable",
47
+ "label",
48
+ "types",
49
+ "minUsernameLength",
50
+ "fieldError",
51
+ ],
34
52
  emits: ["update:modelValue", "update:principal-type"],
35
53
  template:
36
- '<input :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />',
54
+ '<input :value="modelValue" :data-error="fieldError || undefined" @input="$emit(\'update:modelValue\', $event.target.value)" />',
37
55
  },
38
56
  };
39
57
 
58
+ /** A refusal as the gRPC-Web transport raises it, with field violations. */
59
+ function refusedFields(
60
+ violations: { field: string; description: string }[],
61
+ ): RpcError {
62
+ const bad = Any.pack(
63
+ BadRequest.create({
64
+ fieldViolations: violations.map((v) => ({ ...v, reason: "" })),
65
+ }),
66
+ BadRequest,
67
+ );
68
+ const info = Any.pack(
69
+ ErrorInfo.create({
70
+ reason: "INVALID_FIELD_VALUE",
71
+ domain: "structured.id",
72
+ metadata: {},
73
+ }),
74
+ ErrorInfo,
75
+ );
76
+ const status = Status.create({
77
+ code: 3,
78
+ message: "invalid",
79
+ details: [info, bad],
80
+ });
81
+ return new RpcError("invalid", "INVALID_ARGUMENT", {
82
+ "grpc-status-details-bin": base64encode(Status.toBinary(status)),
83
+ });
84
+ }
85
+
40
86
  const registerFn = vi.fn().mockResolvedValue(undefined);
41
87
 
42
88
  function mountForm(props = {}) {
@@ -297,6 +343,212 @@ describe("SidRegistrationForm", () => {
297
343
  vi.useRealTimers();
298
344
  });
299
345
 
346
+ describe("password rules", () => {
347
+ // A q-input that shows the first rule its value fails, as QInput does.
348
+ const ruleStubs = {
349
+ ...quasarStubs,
350
+ "q-input": {
351
+ props: ["modelValue", "rules"],
352
+ emits: ["update:modelValue"],
353
+ computed: {
354
+ failed(): string {
355
+ const self = this as unknown as {
356
+ modelValue?: string;
357
+ rules?: ((v: string) => true | string)[];
358
+ };
359
+ for (const rule of self.rules ?? []) {
360
+ const result = rule(self.modelValue ?? "");
361
+ if (result !== true) return result;
362
+ }
363
+ return "";
364
+ },
365
+ },
366
+ template:
367
+ '<label><input :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" /><span class="failed">{{ failed }}</span></label>',
368
+ },
369
+ };
370
+ const ceDefault = {
371
+ minLength: 8,
372
+ minUppercase: 1,
373
+ minLowercase: 1,
374
+ minDigit: 1,
375
+ minSymbol: 0,
376
+ };
377
+
378
+ async function passwordError(
379
+ value: string,
380
+ passwordPolicyFn: () => Promise<typeof ceDefault>,
381
+ ): Promise<string> {
382
+ const w = mount(SidRegistrationForm, {
383
+ props: { registerFn, passwordPolicyFn, showConfirmPassword: false },
384
+ global: { stubs: ruleStubs },
385
+ });
386
+ await flushPromises();
387
+ const password = w.find('[data-test="password"]');
388
+ await password.find("input").setValue(value);
389
+ return password.find(".failed").text();
390
+ }
391
+
392
+ // The rules are the server's policy: an 8-character password with an
393
+ // uppercase letter, a lowercase letter and a digit meets the CE default.
394
+ it("accepts a password that meets the server policy", async () => {
395
+ expect(
396
+ await passwordError("HrenVam1", () => Promise.resolve(ceDefault)),
397
+ ).toBe("");
398
+ });
399
+
400
+ // Each minimum names itself: a missing uppercase letter is not reported
401
+ // as a short password.
402
+ it("names the rule a password fails", async () => {
403
+ const policy = () => Promise.resolve(ceDefault);
404
+ expect(await passwordError("hrenvam1", policy)).toBe(
405
+ "At least 1 uppercase letter",
406
+ );
407
+ expect(await passwordError("HrenVamX", policy)).toBe("At least 1 digit");
408
+ expect(await passwordError("HrenVa1", policy)).toBe(
409
+ "At least 8 characters",
410
+ );
411
+ });
412
+
413
+ // Symbols are the printable ASCII characters that are not letters or
414
+ // digits, as the proof counts them; a space is not one.
415
+ it("checks the symbol minimum", async () => {
416
+ const policy = () => Promise.resolve({ ...ceDefault, minSymbol: 1 });
417
+ expect(await passwordError("Hren Vam1", policy)).toBe(
418
+ "At least 1 symbol",
419
+ );
420
+ expect(await passwordError("Hren-Vam1", policy)).toBe("");
421
+ });
422
+
423
+ // Until the server answers only an empty password is refused: the
424
+ // proof, not the form, enforces the policy.
425
+ it("checks only presence until the policy is known", async () => {
426
+ const never = () => new Promise<typeof ceDefault>(() => {});
427
+ expect(await passwordError("a", never)).toBe("");
428
+ expect(await passwordError("", never)).toBe("This field is required");
429
+ });
430
+ });
431
+
432
+ describe("principal", () => {
433
+ function principalInput(props = {}) {
434
+ return mountForm(props).findComponent({ name: "SidPrincipalInput" });
435
+ }
436
+
437
+ // The field takes only the types the instance or the scenario admits.
438
+ it("passes the admitted principal types to the field", () => {
439
+ expect(
440
+ principalInput({ principalTypes: ["email"] }).props("types"),
441
+ ).toEqual(["email"]);
442
+ expect(principalInput().props("types")).toEqual([
443
+ "email",
444
+ "phone",
445
+ "username",
446
+ ]);
447
+ });
448
+
449
+ // A short username is admitted only in the registration that presents
450
+ // the installation's claim token (its first administrator); an ordinary
451
+ // registration keeps the registration minimum.
452
+ it("admits a short username only with the claim token", () => {
453
+ expect(principalInput().props("minUsernameLength")).toBe(6);
454
+ expect(
455
+ principalInput({ claimRequired: true }).props("minUsernameLength"),
456
+ ).toBe(3);
457
+ });
458
+
459
+ // Regression: a refusal that named the identifier field reached the
460
+ // user only as an undecoded banner. It is shown under the field it
461
+ // concerns, and the claim token's under the token.
462
+ it("shows each field violation under its field", async () => {
463
+ registerFn.mockRejectedValueOnce(
464
+ refusedFields([
465
+ { field: "principal", description: "Registration here takes email" },
466
+ {
467
+ field: "claim_token",
468
+ description: "Not this installation's token",
469
+ },
470
+ ]),
471
+ );
472
+ const w = mountForm({ claimRequired: true });
473
+ const [claim, principal, password, confirm] = w.findAll("input");
474
+ await claim.setValue("sidclaim_x");
475
+ await principal.setValue("sid");
476
+ await password.setValue("Secret-Pass-123");
477
+ await confirm.setValue("Secret-Pass-123");
478
+ await w.find("form").trigger("submit");
479
+ await flushPromises();
480
+ w.findComponent({ name: "SidPasswordCeremony" }).vm.$emit(
481
+ "settled",
482
+ "refused",
483
+ );
484
+ await flushPromises();
485
+
486
+ const [claimAfter, principalAfter] = w.findAll("input");
487
+ expect(principalAfter.attributes("data-error")).toBe(
488
+ "Registration here takes email",
489
+ );
490
+ expect(claimAfter.attributes("data-error")).toBe(
491
+ "Not this installation's token",
492
+ );
493
+ expect(w.text()).not.toContain("invalid");
494
+
495
+ // Editing the field clears what the server said about it.
496
+ await principalAfter.setValue("owner@sid.example.com");
497
+ expect(w.findAll("input")[1].attributes("data-error")).toBeUndefined();
498
+ });
499
+
500
+ async function refuse(
501
+ violations: { field: string; description: string }[],
502
+ props = {},
503
+ ) {
504
+ registerFn.mockRejectedValueOnce(refusedFields(violations));
505
+ const w = mountForm(props);
506
+ const [principal, password, confirm] = w.findAll("input");
507
+ await principal.setValue("user@sid.example.com");
508
+ await password.setValue("Secret-Pass-123");
509
+ await confirm.setValue("Secret-Pass-123");
510
+ await w.find("form").trigger("submit");
511
+ await flushPromises();
512
+ w.findComponent({ name: "SidPasswordCeremony" }).vm.$emit(
513
+ "settled",
514
+ "refused",
515
+ );
516
+ await flushPromises();
517
+ return w;
518
+ }
519
+
520
+ // Several requirements of one field are all shown at once, not only the
521
+ // last one the server listed.
522
+ it("keeps every violation of one field", async () => {
523
+ const w = await refuse([
524
+ { field: "password", description: "At least 1 digit" },
525
+ { field: "password", description: "At least 1 symbol" },
526
+ ]);
527
+ expect(w.findAll("input")[1].attributes("data-error")).toBe(
528
+ "At least 1 digit; At least 1 symbol",
529
+ );
530
+ });
531
+
532
+ // Regression: a refusal bound to the password stayed under it after the
533
+ // password was edited.
534
+ it("clears the password's refusal when the password changes", async () => {
535
+ const w = await refuse([
536
+ { field: "password", description: "Not accepted" },
537
+ ]);
538
+ await w.findAll("input")[1].setValue("Another-Pass-456");
539
+ expect(w.findAll("input")[1].attributes("data-error")).toBeUndefined();
540
+ });
541
+
542
+ // A violation of a field the form does not show (the invite code while
543
+ // no invite is asked for) stays in the banner instead of disappearing.
544
+ it("keeps a hidden field's violation in the banner", async () => {
545
+ const w = await refuse([
546
+ { field: "invite_code", description: "This invite has expired" },
547
+ ]);
548
+ expect(w.text()).toContain("This invite has expired");
549
+ });
550
+ });
551
+
300
552
  it("renders footer slot", () => {
301
553
  const w = mount(SidRegistrationForm, {
302
554
  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,9 +135,13 @@
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"
136
146
  />
137
147
 
@@ -146,6 +156,8 @@
146
156
  :disable="loading"
147
157
  :lazy-rules="false"
148
158
  :rules="passwordRules"
159
+ :error="!!fieldErrors.password"
160
+ :error-message="fieldErrors.password"
149
161
  data-test="password"
150
162
  @update:model-value="onPasswordInput"
151
163
  >
@@ -217,14 +229,26 @@ import { ref, computed, watch, useSlots } from "vue";
217
229
  import { SidPasswordCeremony, SidPrincipalInput } from "../../quasar";
218
230
  import type { CeremonyOutcome } from "../../quasar";
219
231
  import type { PasswordOperationProgress } from "../composables/useAuth";
232
+ import { getPasswordPolicy } from "../composables/useAuthApi";
233
+ import {
234
+ defaultPasswordRuleText,
235
+ failedPasswordRule,
236
+ usePasswordPolicy,
237
+ type PasswordPolicy,
238
+ type PasswordRuleText,
239
+ } from "../composables/usePasswordPolicy";
220
240
 
221
241
  // Minimal QInput shape — we only use validate(). Avoids importing 'quasar' at
222
242
  // type level (it's a peer dep, not present during ui-core typecheck).
223
243
  interface ValidatableInput {
224
244
  validate: () => boolean | Promise<boolean>;
225
245
  }
226
- import type { PrincipalType } from "../../quasar";
227
- import { normalizePrincipal, refusalMessage } from "../../index";
246
+ import type {
247
+ AcceptedPrincipalType,
248
+ PrincipalInputTexts,
249
+ PrincipalType,
250
+ } from "../../quasar";
251
+ import { normalizePrincipal, refusalMessage, rpcRefusal } from "../../index";
228
252
  import { REQUIRED_FIELD_TEXT } from "../../utils/validators";
229
253
 
230
254
  /** What the form collected besides the identifier and password. */
@@ -254,8 +278,10 @@ const props = withDefaults(
254
278
  confirmPasswordLabel?: string;
255
279
  /** Password required validation text */
256
280
  passwordRequiredText?: string;
257
- /** Password min length validation text */
258
- passwordMinLengthText?: string;
281
+ /** What a password that fails a policy minimum is told. */
282
+ passwordRuleText?: PasswordRuleText;
283
+ /** Loads the server's password policy. */
284
+ passwordPolicyFn?: () => Promise<PasswordPolicy>;
259
285
  /** Passwords mismatch validation text */
260
286
  passwordsMismatchText?: string;
261
287
  /** Invite code input label */
@@ -303,24 +329,38 @@ const props = withDefaults(
303
329
  ) => Promise<void>;
304
330
  /** What the ceremony says once the account is created. */
305
331
  acceptedText?: string;
306
- /** Min password length (default 12 — NIST SP 800-63B 4th draft baseline). */
307
- passwordMinLength?: number;
308
332
  /** Show confirm-password field with match validation. Default true. */
309
333
  showConfirmPassword?: boolean;
310
- /** Min username length. Registration default 6 (premium feature gating);
311
- * login flow can pass 3 since short usernames are valid post-registration. */
334
+ /**
335
+ * The shortest username an ordinary registration admits; the instance's
336
+ * registration settings give it (6 on the global namespace).
337
+ */
312
338
  usernameMinLength?: number;
339
+ /**
340
+ * The shortest username the claim registration admits: the
341
+ * installation's first administrator may take a short name, nobody else.
342
+ */
343
+ claimUsernameMinLength?: number;
344
+ /**
345
+ * The principal types this registration admits, from the instance's
346
+ * registration settings or the scenario (the platform claim at SaaS
347
+ * central takes email only). Default: every type.
348
+ */
349
+ principalTypes?: AcceptedPrincipalType[];
350
+ /** Replacements for the identifier field's texts. */
351
+ principalTexts?: Partial<PrincipalInputTexts>;
313
352
  }>(),
314
353
  {
315
354
  title: "Create account",
316
355
  subtitle: "Set up your StructuredID account",
317
356
  submitLabel: "Create account",
318
- identifierLabel: "Email, phone, or username",
357
+ // Without one the field names the admitted types.
358
+ identifierLabel: undefined,
319
359
  passwordLabel: "Password",
320
360
  confirmPasswordLabel: "Confirm password",
321
361
  passwordRequiredText: REQUIRED_FIELD_TEXT,
322
- passwordMinLengthText: "At least 12 characters with mixed case + digit",
323
- passwordMinLength: 12,
362
+ passwordRuleText: defaultPasswordRuleText,
363
+ passwordPolicyFn: getPasswordPolicy,
324
364
  passwordsMismatchText: "Passwords do not match",
325
365
  inviteCodeLabel: "Invite code",
326
366
  inviteCodeRequiredText: "Invite code required",
@@ -342,10 +382,38 @@ const props = withDefaults(
342
382
  "This installation has no administrator yet. Enter the claim token from the service log; the account you create becomes its administrator.",
343
383
  showConfirmPassword: true,
344
384
  usernameMinLength: 6,
385
+ claimUsernameMinLength: 3,
386
+ principalTypes: () => ["email", "phone", "username"],
387
+ principalTexts: () => ({}),
345
388
  acceptedText: "Account created",
346
389
  },
347
390
  );
348
391
 
392
+ // The claim registration is a scenario of its own: its admission rules are
393
+ // fixed here until the instance's flow configuration can set them.
394
+ const effectiveUsernameMinLength = computed(() =>
395
+ props.claimRequired ? props.claimUsernameMinLength : props.usernameMinLength,
396
+ );
397
+
398
+ /**
399
+ * What the server said about each field of the request, shown under it until
400
+ * the field is edited. The request fields are the registration Start's.
401
+ */
402
+ const fieldErrors = ref<{
403
+ principal?: string;
404
+ claimToken?: string;
405
+ inviteCode?: string;
406
+ password?: string;
407
+ }>({});
408
+
409
+ /** Request field → form field. */
410
+ const FIELD_OF: Record<string, keyof typeof fieldErrors.value> = {
411
+ principal: "principal",
412
+ claim_token: "claimToken",
413
+ invite_code: "inviteCode",
414
+ password: "password",
415
+ };
416
+
349
417
  const emit = defineEmits<{
350
418
  success: [data: { identifier: string; principalType: string }];
351
419
  error: [error: Error];
@@ -380,16 +448,14 @@ let announce: (() => void) | undefined;
380
448
  const passwordRef = ref<ValidatableInput | null>(null);
381
449
  const confirmRef = ref<ValidatableInput | null>(null);
382
450
 
383
- // Password complexity (matches sid-pake-core ZKPP gadget A defaults).
384
- // Server enforces via OPAQUE-ZKPP zero-knowledge proof; client also checks
385
- // to give immediate UX feedback.
451
+ const { policy } = usePasswordPolicy(props.passwordPolicyFn);
452
+
386
453
  const passwordRules = computed(() => [
387
454
  (v: string) => !!v || props.passwordRequiredText,
388
- (v: string) =>
389
- v.length >= props.passwordMinLength || props.passwordMinLengthText,
390
- (v: string) => /[a-z]/.test(v) || props.passwordMinLengthText,
391
- (v: string) => /[A-Z]/.test(v) || props.passwordMinLengthText,
392
- (v: string) => /[0-9]/.test(v) || props.passwordMinLengthText,
455
+ (v: string) => {
456
+ const failed = policy.value && failedPasswordRule(v, policy.value);
457
+ return !failed || props.passwordRuleText(failed.rule, failed.count);
458
+ },
393
459
  ]);
394
460
 
395
461
  // Re-validate confirm field whenever password changes (eager match feedback).
@@ -398,6 +464,8 @@ watch(password, () => {
398
464
  });
399
465
 
400
466
  function onPasswordInput() {
467
+ // What the server said about the password was about its earlier value.
468
+ fieldErrors.value.password = undefined;
401
469
  // Trigger downstream confirm re-validation immediately (don't wait for blur).
402
470
  if (confirmPassword.value) confirmRef.value?.validate();
403
471
  }
@@ -420,6 +488,7 @@ async function onSubmit() {
420
488
 
421
489
  loading.value = true;
422
490
  error.value = null;
491
+ fieldErrors.value = {};
423
492
  progress.value = null;
424
493
  ceremony.value = "running";
425
494
  // A phone's keyboard would cover the ceremony.
@@ -428,7 +497,7 @@ async function onSubmit() {
428
497
  try {
429
498
  const normalized = normalizePrincipal(
430
499
  identifier.value,
431
- props.usernameMinLength,
500
+ effectiveUsernameMinLength.value,
432
501
  );
433
502
  const extras: RegistrationExtras = props.claimRequired
434
503
  ? { claimToken: claimToken.value.trim() }
@@ -447,7 +516,31 @@ async function onSubmit() {
447
516
  } catch (e) {
448
517
  success.value = false;
449
518
  const err = e instanceof Error ? e : new Error("Registration failed");
450
- error.value = refusalMessage(e, "Registration failed");
519
+ // A violation of a field the form shows goes under that field, all of a
520
+ // field's together; the banner keeps the rest (a field not shown now
521
+ // included), or the general reason when nothing is left.
522
+ const shown: Record<keyof typeof fieldErrors.value, boolean> = {
523
+ principal: true,
524
+ password: true,
525
+ claimToken: props.claimRequired,
526
+ inviteCode: showInviteField.value,
527
+ };
528
+ const rest: string[] = [];
529
+ for (const v of rpcRefusal(e)?.fieldViolations ?? []) {
530
+ const field = FIELD_OF[v.field];
531
+ if (field && shown[field]) {
532
+ const before = fieldErrors.value[field];
533
+ fieldErrors.value[field] = before ? `${before}; ${v.text}` : v.text;
534
+ } else {
535
+ rest.push(v.text);
536
+ }
537
+ }
538
+ const bound = Object.keys(fieldErrors.value).length > 0;
539
+ error.value = rest.length
540
+ ? rest.join("; ")
541
+ : bound
542
+ ? null
543
+ : refusalMessage(e, "Registration failed");
451
544
  announce = () => emit("error", err);
452
545
  ceremony.value = "refused";
453
546
  } finally {
@@ -6,3 +6,4 @@ export * from "./useEnrollmentApi";
6
6
  export * from "./useIdentityApi";
7
7
  export * from "./useSecurityLevel";
8
8
  export * from "./useCurrentPasswordRequirement";
9
+ export * from "./usePasswordPolicy";
@@ -57,6 +57,7 @@ import {
57
57
  type OpaqueZkppRegistrationFinishResponse,
58
58
  type PasswordChangeChallengeResponse,
59
59
  type PasswordChangeExecuteResponse,
60
+ type GetZkppPolicyResponse,
60
61
  } from "@structured-id/proto/sid/v1/authn/auth";
61
62
  import type {
62
63
  PasswordHistoryContext,
@@ -574,6 +575,12 @@ export async function completePasswordReset(
574
575
  return response;
575
576
  }
576
577
 
578
+ /** The password policy the server's proof checks a new password against. */
579
+ export async function getPasswordPolicy(): Promise<GetZkppPolicyResponse> {
580
+ const { response } = await client().getZkppPolicy({});
581
+ return response;
582
+ }
583
+
577
584
  // Recovery shard RPCs (downloadRecoveryShard, uploadRecoveryShard, getRecoveryInfo)
578
585
  // are EE/SaaS-only — defined in proto-ee, not in CE proto.
579
586
 
@@ -0,0 +1,109 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { flushPromises } from "@vue/test-utils";
3
+ import {
4
+ defaultPasswordRuleText,
5
+ failedPasswordRule,
6
+ usePasswordPolicy,
7
+ } from "./usePasswordPolicy";
8
+
9
+ const ceDefault = {
10
+ minLength: 8,
11
+ minUppercase: 1,
12
+ minLowercase: 1,
13
+ minDigit: 1,
14
+ minSymbol: 0,
15
+ };
16
+
17
+ describe("failedPasswordRule", () => {
18
+ it("passes a password that meets every minimum", () => {
19
+ expect(failedPasswordRule("HrenVam1", ceDefault)).toBeNull();
20
+ });
21
+
22
+ // The first failed minimum is reported, in a fixed order, with the
23
+ // policy's number for it.
24
+ it("reports the first failed minimum with its number", () => {
25
+ expect(failedPasswordRule("hV1", ceDefault)).toEqual({
26
+ rule: "length",
27
+ count: 8,
28
+ });
29
+ expect(failedPasswordRule("hrenvam1", ceDefault)).toEqual({
30
+ rule: "uppercase",
31
+ count: 1,
32
+ });
33
+ expect(failedPasswordRule("HRENVAM1", ceDefault)).toEqual({
34
+ rule: "lowercase",
35
+ count: 1,
36
+ });
37
+ expect(failedPasswordRule("HrenVamX", ceDefault)).toEqual({
38
+ rule: "digit",
39
+ count: 1,
40
+ });
41
+ });
42
+
43
+ // Counts, not presence: a minimum of 2 needs two of the class.
44
+ it("counts the characters of a class", () => {
45
+ const policy = { ...ceDefault, minDigit: 2 };
46
+ expect(failedPasswordRule("HrenVam1", policy)?.rule).toBe("digit");
47
+ expect(failedPasswordRule("HrenVam12", policy)).toBeNull();
48
+ });
49
+
50
+ // The proof's classes are ASCII: a non-ASCII letter is none of them, a
51
+ // space is not a symbol, and every other printable ASCII non-alphanumeric
52
+ // character is.
53
+ it("classifies characters as the proof does", () => {
54
+ const policy = {
55
+ minLength: 0,
56
+ minUppercase: 0,
57
+ minLowercase: 0,
58
+ minDigit: 0,
59
+ minSymbol: 1,
60
+ };
61
+ expect(failedPasswordRule(" ", policy)?.rule).toBe("symbol");
62
+ expect(failedPasswordRule("Ж", policy)?.rule).toBe("symbol");
63
+ for (const symbol of "!/:@[`{~") {
64
+ expect(failedPasswordRule(symbol, policy)).toBeNull();
65
+ }
66
+ expect(
67
+ failedPasswordRule("ЖЖЖЖЖЖЖЖ", { ...ceDefault, minLength: 8 })?.rule,
68
+ ).toBe("uppercase");
69
+ });
70
+
71
+ // Length counts characters, so a character outside the BMP is one.
72
+ it("counts length in characters", () => {
73
+ const policy = { ...ceDefault, minUppercase: 0, minDigit: 0, minLength: 3 };
74
+ expect(failedPasswordRule("a😀b", policy)).toBeNull();
75
+ expect(failedPasswordRule("a😀", policy)?.rule).toBe("length");
76
+ });
77
+ });
78
+
79
+ describe("defaultPasswordRuleText", () => {
80
+ it("names the rule and its number", () => {
81
+ expect(defaultPasswordRuleText("length", 8)).toBe("At least 8 characters");
82
+ expect(defaultPasswordRuleText("uppercase", 1)).toBe(
83
+ "At least 1 uppercase letter",
84
+ );
85
+ expect(defaultPasswordRuleText("lowercase", 2)).toBe(
86
+ "At least 2 lowercase letters",
87
+ );
88
+ expect(defaultPasswordRuleText("digit", 2)).toBe("At least 2 digits");
89
+ expect(defaultPasswordRuleText("symbol", 1)).toBe("At least 1 symbol");
90
+ });
91
+ });
92
+
93
+ describe("usePasswordPolicy", () => {
94
+ it("holds the policy once loaded", async () => {
95
+ const { policy } = usePasswordPolicy(() => Promise.resolve(ceDefault));
96
+ expect(policy.value).toBeNull();
97
+ await flushPromises();
98
+ expect(policy.value).toEqual(ceDefault);
99
+ });
100
+
101
+ // A policy that cannot be loaded leaves the checks to the server.
102
+ it("stays unknown when the policy cannot be loaded", async () => {
103
+ const { policy } = usePasswordPolicy(() =>
104
+ Promise.reject(new Error("unavailable")),
105
+ );
106
+ await flushPromises();
107
+ expect(policy.value).toBeNull();
108
+ });
109
+ });
@@ -0,0 +1,83 @@
1
+ /**
2
+ * The server's password policy, as a form checks a new password against it.
3
+ * The proof enforces the policy; these checks only tell the user early.
4
+ */
5
+ import { readonly, ref } from "vue";
6
+ import type { GetZkppPolicyResponse } from "@structured-id/proto/sid/v1/authn/auth";
7
+ import { getPasswordPolicy } from "./useAuthApi";
8
+
9
+ /** The minimums of a password policy. */
10
+ export type PasswordPolicy = Pick<
11
+ GetZkppPolicyResponse,
12
+ "minLength" | "minUppercase" | "minLowercase" | "minDigit" | "minSymbol"
13
+ >;
14
+
15
+ /** A minimum a password can fail. */
16
+ export type PasswordRule =
17
+ "length" | "uppercase" | "lowercase" | "digit" | "symbol";
18
+
19
+ /** What a failed rule says, given the policy's number for it. */
20
+ export type PasswordRuleText = (rule: PasswordRule, count: number) => string;
21
+
22
+ export const defaultPasswordRuleText: PasswordRuleText = (rule, count) => {
23
+ switch (rule) {
24
+ case "length":
25
+ return `At least ${count} characters`;
26
+ case "uppercase":
27
+ return `At least ${count} uppercase ${count === 1 ? "letter" : "letters"}`;
28
+ case "lowercase":
29
+ return `At least ${count} lowercase ${count === 1 ? "letter" : "letters"}`;
30
+ case "digit":
31
+ return `At least ${count} ${count === 1 ? "digit" : "digits"}`;
32
+ case "symbol":
33
+ return `At least ${count} ${count === 1 ? "symbol" : "symbols"}`;
34
+ }
35
+ };
36
+
37
+ // The character classes the proof counts: ASCII letters and digits, and the
38
+ // printable ASCII characters that are neither (a space is not a symbol).
39
+ const UPPERCASE = /[A-Z]/g;
40
+ const LOWERCASE = /[a-z]/g;
41
+ const DIGIT = /[0-9]/g;
42
+ const SYMBOL = /[!-/:-@[-`{-~]/g;
43
+
44
+ const count = (value: string, pattern: RegExp) =>
45
+ value.match(pattern)?.length ?? 0;
46
+
47
+ /**
48
+ * The first minimum `password` fails, or null when it meets them all. Length
49
+ * is counted in characters: the proof counts UTF-8 bytes, never fewer, so a
50
+ * password that passes here is never refused there for its length.
51
+ */
52
+ export function failedPasswordRule(
53
+ password: string,
54
+ policy: PasswordPolicy,
55
+ ): { rule: PasswordRule; count: number } | null {
56
+ const checks: [PasswordRule, number, number][] = [
57
+ ["length", [...password].length, policy.minLength],
58
+ ["uppercase", count(password, UPPERCASE), policy.minUppercase],
59
+ ["lowercase", count(password, LOWERCASE), policy.minLowercase],
60
+ ["digit", count(password, DIGIT), policy.minDigit],
61
+ ["symbol", count(password, SYMBOL), policy.minSymbol],
62
+ ];
63
+ for (const [rule, has, needs] of checks) {
64
+ if (has < needs) return { rule, count: needs };
65
+ }
66
+ return null;
67
+ }
68
+
69
+ /**
70
+ * Load the policy once. Until it is known (or when it cannot be loaded)
71
+ * `policy` stays null: the operation itself then meets the server, which
72
+ * refuses a password its policy does not admit.
73
+ */
74
+ export function usePasswordPolicy(
75
+ load: () => Promise<PasswordPolicy> = getPasswordPolicy,
76
+ ) {
77
+ const policy = ref<PasswordPolicy | null>(null);
78
+ load().then(
79
+ (p) => (policy.value = p),
80
+ () => undefined,
81
+ );
82
+ return { policy: readonly(policy) };
83
+ }
@@ -7,7 +7,14 @@ import { REQUIRED_FIELD_TEXT } from "../utils/validators";
7
7
  const stubs = {
8
8
  "q-input": defineComponent({
9
9
  name: "QInputStub",
10
- props: ["modelValue", "rules", "lazyRules"],
10
+ props: [
11
+ "modelValue",
12
+ "rules",
13
+ "lazyRules",
14
+ "label",
15
+ "error",
16
+ "errorMessage",
17
+ ],
11
18
  template: "<div><slot name='prepend' /><slot name='append' /></div>",
12
19
  }),
13
20
  "q-icon": { template: "<span />" },
@@ -46,4 +53,68 @@ describe("SidPrincipalInput", () => {
46
53
  const [required] = input({ required: false }).props("rules") as Rule[];
47
54
  expect(required("")).toBe(true);
48
55
  });
56
+
57
+ // A field that accepts only some principal types refuses the others before
58
+ // any call, naming what it accepts: a registration that takes email only
59
+ // must not let a username reach the server.
60
+ it("refuses a principal type it does not accept", () => {
61
+ const [, type] = input({ types: ["email"] }).props("rules") as Rule[];
62
+ expect(type("alice_wonder")).toBe("Enter an email address");
63
+ expect(type("+380501234567")).toBe("Enter an email address");
64
+ expect(type("alice@sid.example.com")).toBe(true);
65
+ const [, two] = input({ types: ["email", "username"] }).props(
66
+ "rules",
67
+ ) as Rule[];
68
+ expect(two("+380501234567")).toBe("Enter an email address or a username");
69
+ expect(two("alice_wonder")).toBe(true);
70
+ });
71
+
72
+ // Regression: a username shorter than the minimum was not recognized as a
73
+ // username and the field asked for "email, phone, or username" instead of
74
+ // saying the minimum.
75
+ it("tells a too-short username the minimum length", () => {
76
+ const [, type] = input({ minUsernameLength: 6 }).props("rules") as Rule[];
77
+ expect(type("sid")).toBe("A username has at least 6 characters");
78
+ expect(type("sidsid")).toBe(true);
79
+ const [, login] = input().props("rules") as Rule[];
80
+ expect(login("sid")).toBe(true);
81
+ expect(login("si")).toBe("A username has at least 3 characters");
82
+ });
83
+
84
+ // The label names the accepted types unless the form gives its own.
85
+ it("labels the field by the types it accepts", () => {
86
+ expect(input().props("label")).toBe("Email, phone, or username");
87
+ expect(input({ types: ["email"] }).props("label")).toBe("Email");
88
+ expect(input({ types: ["email", "username"] }).props("label")).toBe(
89
+ "Email or username",
90
+ );
91
+ expect(
92
+ input({ types: ["email"], label: "Your email" }).props("label"),
93
+ ).toBe("Your email");
94
+ });
95
+
96
+ // The chip naming the detected type is a text like the others: a
97
+ // translated form does not switch back to English when a type is found.
98
+ it("takes the type chip's text from the texts", () => {
99
+ const w = mount(SidPrincipalInput, {
100
+ props: {
101
+ modelValue: "alice@sid.example.com",
102
+ texts: { typeChip: (t: string) => `chip:${t}` },
103
+ },
104
+ global: { stubs },
105
+ });
106
+ expect(w.text()).toContain("chip:email");
107
+ });
108
+
109
+ // A refusal the server bound to this field is shown on it.
110
+ it("shows an error given from outside", () => {
111
+ const shown = input({
112
+ fieldError: "Registration here takes an email address",
113
+ });
114
+ expect(shown.props("error")).toBe(true);
115
+ expect(shown.props("errorMessage")).toBe(
116
+ "Registration here takes an email address",
117
+ );
118
+ expect(input().props("error")).toBe(false);
119
+ });
49
120
  });
@@ -2,8 +2,10 @@
2
2
  <q-input
3
3
  ref="inputRef"
4
4
  v-model="model"
5
- :label="label"
5
+ :label="fieldLabel"
6
6
  :rules="[requiredRule, typeRule]"
7
+ :error="!!fieldError"
8
+ :error-message="fieldError ?? undefined"
7
9
  outlined
8
10
  type="text"
9
11
  :inputmode="inputMode"
@@ -35,13 +37,32 @@ import { ref, computed, watch } from "vue";
35
37
  import { isValidPhoneNumber } from "libphonenumber-js";
36
38
  import { REQUIRED_FIELD_TEXT, isValidEmail } from "../utils/validators";
37
39
 
38
- export type PrincipalType = "email" | "phone" | "username" | "unknown";
40
+ import {
41
+ ALL_PRINCIPAL_TYPES,
42
+ DEFAULT_PRINCIPAL_INPUT_TEXTS,
43
+ type AcceptedPrincipalType,
44
+ type PrincipalInputTexts,
45
+ type PrincipalType,
46
+ } from "./principalInputTexts";
47
+
48
+ export type { PrincipalType };
39
49
 
40
50
  interface Props {
41
51
  modelValue: string;
52
+ /**
53
+ * The principal types this field accepts; the others are refused before
54
+ * any call, with a text naming the accepted ones. The instance's settings
55
+ * or the scenario decide it (a registration may take email only).
56
+ */
57
+ types?: AcceptedPrincipalType[];
42
58
  minUsernameLength?: number;
43
59
  required?: boolean;
60
+ /** The label; without one the field names the accepted types. */
44
61
  label?: string;
62
+ /** A refusal the server bound to this field, shown on it until edited. */
63
+ fieldError?: string | null;
64
+ /** Replacements for the field's texts. */
65
+ texts?: Partial<PrincipalInputTexts>;
45
66
  /** What an empty required field says. */
46
67
  requiredText?: string;
47
68
  /**
@@ -54,14 +75,31 @@ interface Props {
54
75
 
55
76
  const props = withDefaults(defineProps<Props>(), {
56
77
  modelValue: "",
78
+ types: () => [...ALL_PRINCIPAL_TYPES],
57
79
  // Username rule: ^[a-z0-9_]{3,32}$ (display form ^[a-zA-Z0-9_]{3,32}$).
58
80
  minUsernameLength: 3,
59
81
  required: true,
60
- label: "Email, phone, or username",
82
+ label: undefined,
83
+ fieldError: null,
84
+ texts: () => ({}),
61
85
  requiredText: REQUIRED_FIELD_TEXT,
62
86
  lazyRules: true,
63
87
  });
64
88
 
89
+ const text = computed<PrincipalInputTexts>(() => ({
90
+ ...DEFAULT_PRINCIPAL_INPUT_TEXTS,
91
+ ...props.texts,
92
+ }));
93
+
94
+ /** The accepted types in their fixed order. */
95
+ const accepted = computed(() =>
96
+ ALL_PRINCIPAL_TYPES.filter((t) => props.types.includes(t)),
97
+ );
98
+
99
+ const fieldLabel = computed(
100
+ () => props.label ?? text.value.label(accepted.value),
101
+ );
102
+
65
103
  const emit = defineEmits<{
66
104
  "update:modelValue": [value: string];
67
105
  "update:principalType": [type: PrincipalType];
@@ -87,16 +125,11 @@ function detectType(value: string): PrincipalType {
87
125
  // Email: contains @ (checked after phone to exclude edge cases)
88
126
  if (trimmed.includes("@")) return "email";
89
127
 
90
- // Username: ^[a-z0-9_]{3,32}$, no leading/trailing _, no __
91
- // Detection is relaxed: accepts uppercase (will normalize), checks min length
92
- const re = new RegExp(
93
- `^[a-zA-Z0-9_]{${props.minUsernameLength},${USERNAME_MAX_LENGTH}}$`,
94
- );
95
- if (re.test(trimmed) && !trimmed.startsWith("_") && !trimmed.endsWith("_")) {
96
- return "username";
97
- }
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";
98
132
 
99
- // Still typing — not enough chars to determine
100
133
  return "unknown";
101
134
  }
102
135
 
@@ -119,26 +152,26 @@ const requiredRule = (val: string) =>
119
152
  const typeRule = (val: string): string | true => {
120
153
  if (!val) return true; // required rule handles empty
121
154
  const type = detectType(val);
155
+ if (type === "unknown" || !accepted.value.includes(type)) {
156
+ return text.value.enterAccepted(accepted.value);
157
+ }
122
158
 
123
159
  switch (type) {
124
160
  case "email":
125
- return isValidEmail(val.trim()) || "Invalid email address";
161
+ return isValidEmail(val.trim()) || text.value.invalidEmail;
126
162
  case "phone":
127
- return isValidPhoneNumber(val) || "Invalid phone number";
163
+ return isValidPhoneNumber(val) || text.value.invalidPhone;
128
164
  case "username": {
129
- const lower = val.toLowerCase();
130
- const re = new RegExp(
131
- `^[a-z0-9_]{${props.minUsernameLength},${USERNAME_MAX_LENGTH}}$`,
132
- );
133
- if (!re.test(lower)) return "Invalid username";
134
- if (lower.startsWith("_") || lower.endsWith("_"))
135
- return "Username cannot start or end with underscore";
136
- if (lower.includes("__"))
137
- return "Username cannot contain consecutive underscores";
165
+ const name = val.trim();
166
+ if (name.length < props.minUsernameLength)
167
+ return text.value.usernameTooShort(props.minUsernameLength);
168
+ if (name.length > USERNAME_MAX_LENGTH)
169
+ return text.value.usernameTooLong(USERNAME_MAX_LENGTH);
170
+ if (name.startsWith("_") || name.endsWith("_"))
171
+ return text.value.usernameUnderscoreEdge;
172
+ if (name.includes("__")) return text.value.usernameDoubleUnderscore;
138
173
  return true;
139
174
  }
140
- default:
141
- return "Enter email, phone (+...), or username";
142
175
  }
143
176
  };
144
177
 
@@ -177,18 +210,11 @@ const inputMode = computed(() => {
177
210
  }
178
211
  });
179
212
 
180
- const chipLabel = computed(() => {
181
- switch (detectedType.value) {
182
- case "email":
183
- return "email";
184
- case "phone":
185
- return "phone";
186
- case "username":
187
- return "username";
188
- default:
189
- return "";
190
- }
191
- });
213
+ const chipLabel = computed(() =>
214
+ detectedType.value === "unknown"
215
+ ? ""
216
+ : text.value.typeChip(detectedType.value),
217
+ );
192
218
 
193
219
  const chipColor = computed(() => {
194
220
  switch (detectedType.value) {
@@ -5,6 +5,14 @@
5
5
  * Import as: import { SidPrincipalInput } from '@structured-id/ui-core/quasar'
6
6
  */
7
7
  export { default as SidPrincipalInput } from "./SidPrincipalInput.vue";
8
- export type { PrincipalType } from "./SidPrincipalInput.vue";
8
+ export type {
9
+ AcceptedPrincipalType,
10
+ PrincipalInputTexts,
11
+ PrincipalType,
12
+ } from "./principalInputTexts";
13
+ export {
14
+ ALL_PRINCIPAL_TYPES,
15
+ DEFAULT_PRINCIPAL_INPUT_TEXTS,
16
+ } from "./principalInputTexts";
9
17
  export { default as SidPasswordCeremony } from "./SidPasswordCeremony.vue";
10
18
  export type { CeremonyOutcome } from "./SidPasswordCeremony.vue";
@@ -0,0 +1,68 @@
1
+ /** The principal types and texts of `SidPrincipalInput`. */
2
+
3
+ export type PrincipalType = "email" | "phone" | "username" | "unknown";
4
+
5
+ /** A principal type a field can accept. */
6
+ export type AcceptedPrincipalType = Exclude<PrincipalType, "unknown">;
7
+
8
+ /** Every type, in the order the label and the texts name them. */
9
+ export const ALL_PRINCIPAL_TYPES: readonly AcceptedPrincipalType[] = [
10
+ "email",
11
+ "phone",
12
+ "username",
13
+ ];
14
+
15
+ /** What the field says; each text can be replaced (translated) by the form. */
16
+ export interface PrincipalInputTexts {
17
+ /** The label for the accepted types, when the form gives none. */
18
+ label: (types: AcceptedPrincipalType[]) => string;
19
+ /** A value of a type the field does not accept, or of no type at all. */
20
+ enterAccepted: (types: AcceptedPrincipalType[]) => string;
21
+ /** The chip naming the type the value was recognized as. */
22
+ typeChip: (type: AcceptedPrincipalType) => string;
23
+ /** The login form's default subtitle for the accepted types. */
24
+ loginSubtitle: (types: AcceptedPrincipalType[]) => string;
25
+ invalidEmail: string;
26
+ invalidPhone: string;
27
+ usernameTooShort: (min: number) => string;
28
+ usernameTooLong: (max: number) => string;
29
+ usernameUnderscoreEdge: string;
30
+ usernameDoubleUnderscore: string;
31
+ }
32
+
33
+ /** `a`, `a or b`, `a, b, or c`. */
34
+ function list(words: string[]): string {
35
+ if (words.length <= 1) return words.join("");
36
+ if (words.length === 2) return `${words[0]} or ${words[1]}`;
37
+ return `${words.slice(0, -1).join(", ")}, or ${words[words.length - 1]}`;
38
+ }
39
+
40
+ const LABEL_WORDS: Record<AcceptedPrincipalType, string> = {
41
+ email: "email",
42
+ phone: "phone",
43
+ username: "username",
44
+ };
45
+
46
+ const ENTER_WORDS: Record<AcceptedPrincipalType, string> = {
47
+ email: "an email address",
48
+ phone: "a phone number (+...)",
49
+ username: "a username",
50
+ };
51
+
52
+ export const DEFAULT_PRINCIPAL_INPUT_TEXTS: PrincipalInputTexts = {
53
+ label: (types) => {
54
+ const text = list(types.map((t) => LABEL_WORDS[t]));
55
+ return text.charAt(0).toUpperCase() + text.slice(1);
56
+ },
57
+ enterAccepted: (types) => `Enter ${list(types.map((t) => ENTER_WORDS[t]))}`,
58
+ typeChip: (type) => LABEL_WORDS[type],
59
+ loginSubtitle: (types) =>
60
+ `Enter your ${list(types.map((t) => LABEL_WORDS[t]))} to continue`,
61
+ invalidEmail: "Invalid email address",
62
+ invalidPhone: "Invalid phone number",
63
+ usernameTooShort: (min) => `A username has at least ${min} characters`,
64
+ usernameTooLong: (max) => `A username has at most ${max} characters`,
65
+ usernameUnderscoreEdge: "A username cannot start or end with an underscore",
66
+ usernameDoubleUnderscore:
67
+ "A username cannot contain two underscores in a row",
68
+ };
@@ -4,6 +4,7 @@ import { base64encode } from "@protobuf-ts/runtime";
4
4
  import { Any } from "@structured-id/proto/google/protobuf/any";
5
5
  import { Status } from "@structured-id/proto/google/rpc/status";
6
6
  import {
7
+ BadRequest,
7
8
  ErrorInfo,
8
9
  LocalizedMessage,
9
10
  } from "@structured-id/proto/google/rpc/error_details";
@@ -142,6 +143,47 @@ describe("rpcRefusal", () => {
142
143
  );
143
144
  });
144
145
 
146
+ // A refusal that names the fields it concerns lets the form show each
147
+ // text under its field: the server's text for people when it sent one,
148
+ // else its description.
149
+ it("reads the field violations", () => {
150
+ const bad = Any.pack(
151
+ BadRequest.create({
152
+ fieldViolations: [
153
+ {
154
+ field: "principal",
155
+ description: "registration here accepts: email",
156
+ reason: "PRINCIPAL_TYPE_NOT_ACCEPTED",
157
+ },
158
+ {
159
+ field: "claim_token",
160
+ description: "not the installation's token",
161
+ reason: "",
162
+ localizedMessage: LocalizedMessage.create({
163
+ locale: "en",
164
+ message: "This claim token is not valid.",
165
+ }),
166
+ },
167
+ ],
168
+ }),
169
+ BadRequest,
170
+ );
171
+ const refusal = rpcRefusal(refused([info, bad]));
172
+ expect(refusal?.fieldViolations).toEqual([
173
+ {
174
+ field: "principal",
175
+ reason: "PRINCIPAL_TYPE_NOT_ACCEPTED",
176
+ text: "registration here accepts: email",
177
+ },
178
+ {
179
+ field: "claim_token",
180
+ reason: null,
181
+ text: "This claim token is not valid.",
182
+ },
183
+ ]);
184
+ expect(rpcRefusal(refused([info]))?.fieldViolations).toEqual([]);
185
+ });
186
+
145
187
  it("survives an unreadable trailer", () => {
146
188
  const err = new RpcError("m", "INTERNAL", {
147
189
  "grpc-status-details-bin": "!!!",
@@ -8,6 +8,7 @@ import { base64decode } from "@protobuf-ts/runtime";
8
8
  import { Any } from "@structured-id/proto/google/protobuf/any";
9
9
  import { Status } from "@structured-id/proto/google/rpc/status";
10
10
  import {
11
+ BadRequest,
11
12
  ErrorInfo,
12
13
  LocalizedMessage,
13
14
  } from "@structured-id/proto/google/rpc/error_details";
@@ -28,6 +29,18 @@ export interface RpcRefusal {
28
29
  message: string;
29
30
  /** The server's text for people (`LocalizedMessage`), when it sent one. */
30
31
  localized: string | null;
32
+ /** The request fields the refusal names (`BadRequest`), in the server's order. */
33
+ fieldViolations: FieldViolation[];
34
+ }
35
+
36
+ /** One request field a refusal names. */
37
+ export interface FieldViolation {
38
+ /** The field's path in the request, as the proto names it (`principal`). */
39
+ field: string;
40
+ /** The field-level reason, when the server gave one. */
41
+ reason: string | null;
42
+ /** What to show under the field: the server's text for people, else its description. */
43
+ text: string;
31
44
  }
32
45
 
33
46
  /**
@@ -50,6 +63,7 @@ export function rpcRefusal(err: unknown): RpcRefusal | null {
50
63
  metadata: {},
51
64
  message: decodeStatusMessage(err.message),
52
65
  localized: null,
66
+ fieldViolations: [],
53
67
  };
54
68
  for (const detail of statusDetails(err.meta[DETAILS_KEY])) {
55
69
  if (Any.contains(detail, ErrorInfo)) {
@@ -61,6 +75,14 @@ export function rpcRefusal(err: unknown): RpcRefusal | null {
61
75
  info.domain === SID_ERROR_DOMAIN ? sidReason(info.reason) : null;
62
76
  } else if (Any.contains(detail, LocalizedMessage)) {
63
77
  refusal.localized = Any.unpack(detail, LocalizedMessage).message;
78
+ } else if (Any.contains(detail, BadRequest)) {
79
+ for (const v of Any.unpack(detail, BadRequest).fieldViolations) {
80
+ refusal.fieldViolations.push({
81
+ field: v.field,
82
+ reason: v.reason || null,
83
+ text: v.localizedMessage?.message || v.description,
84
+ });
85
+ }
64
86
  }
65
87
  }
66
88
  return refusal;