@structured-id/ui-core 4.0.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": "4.0.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 = {}) {
@@ -383,6 +429,126 @@ describe("SidRegistrationForm", () => {
383
429
  });
384
430
  });
385
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
+
386
552
  it("renders footer slot", () => {
387
553
  const w = mount(SidRegistrationForm, {
388
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
  >
@@ -231,8 +243,12 @@ import {
231
243
  interface ValidatableInput {
232
244
  validate: () => boolean | Promise<boolean>;
233
245
  }
234
- import type { PrincipalType } from "../../quasar";
235
- import { normalizePrincipal, refusalMessage } from "../../index";
246
+ import type {
247
+ AcceptedPrincipalType,
248
+ PrincipalInputTexts,
249
+ PrincipalType,
250
+ } from "../../quasar";
251
+ import { normalizePrincipal, refusalMessage, rpcRefusal } from "../../index";
236
252
  import { REQUIRED_FIELD_TEXT } from "../../utils/validators";
237
253
 
238
254
  /** What the form collected besides the identifier and password. */
@@ -315,15 +331,31 @@ const props = withDefaults(
315
331
  acceptedText?: string;
316
332
  /** Show confirm-password field with match validation. Default true. */
317
333
  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. */
334
+ /**
335
+ * The shortest username an ordinary registration admits; the instance's
336
+ * registration settings give it (6 on the global namespace).
337
+ */
320
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>;
321
352
  }>(),
322
353
  {
323
354
  title: "Create account",
324
355
  subtitle: "Set up your StructuredID account",
325
356
  submitLabel: "Create account",
326
- identifierLabel: "Email, phone, or username",
357
+ // Without one the field names the admitted types.
358
+ identifierLabel: undefined,
327
359
  passwordLabel: "Password",
328
360
  confirmPasswordLabel: "Confirm password",
329
361
  passwordRequiredText: REQUIRED_FIELD_TEXT,
@@ -350,10 +382,38 @@ const props = withDefaults(
350
382
  "This installation has no administrator yet. Enter the claim token from the service log; the account you create becomes its administrator.",
351
383
  showConfirmPassword: true,
352
384
  usernameMinLength: 6,
385
+ claimUsernameMinLength: 3,
386
+ principalTypes: () => ["email", "phone", "username"],
387
+ principalTexts: () => ({}),
353
388
  acceptedText: "Account created",
354
389
  },
355
390
  );
356
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
+
357
417
  const emit = defineEmits<{
358
418
  success: [data: { identifier: string; principalType: string }];
359
419
  error: [error: Error];
@@ -404,6 +464,8 @@ watch(password, () => {
404
464
  });
405
465
 
406
466
  function onPasswordInput() {
467
+ // What the server said about the password was about its earlier value.
468
+ fieldErrors.value.password = undefined;
407
469
  // Trigger downstream confirm re-validation immediately (don't wait for blur).
408
470
  if (confirmPassword.value) confirmRef.value?.validate();
409
471
  }
@@ -426,6 +488,7 @@ async function onSubmit() {
426
488
 
427
489
  loading.value = true;
428
490
  error.value = null;
491
+ fieldErrors.value = {};
429
492
  progress.value = null;
430
493
  ceremony.value = "running";
431
494
  // A phone's keyboard would cover the ceremony.
@@ -434,7 +497,7 @@ async function onSubmit() {
434
497
  try {
435
498
  const normalized = normalizePrincipal(
436
499
  identifier.value,
437
- props.usernameMinLength,
500
+ effectiveUsernameMinLength.value,
438
501
  );
439
502
  const extras: RegistrationExtras = props.claimRequired
440
503
  ? { claimToken: claimToken.value.trim() }
@@ -453,7 +516,31 @@ async function onSubmit() {
453
516
  } catch (e) {
454
517
  success.value = false;
455
518
  const err = e instanceof Error ? e : new Error("Registration failed");
456
- 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");
457
544
  announce = () => emit("error", err);
458
545
  ceremony.value = "refused";
459
546
  } finally {
@@ -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;