@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 +1 -1
- package/src/index.ts +1 -0
- package/src/profile/components/SidLoginForm.test.ts +14 -0
- package/src/profile/components/SidLoginForm.vue +32 -6
- package/src/profile/components/SidRegistrationForm.test.ts +170 -4
- package/src/profile/components/SidRegistrationForm.vue +95 -8
- package/src/quasar/SidPrincipalInput.test.ts +72 -1
- package/src/quasar/SidPrincipalInput.vue +63 -37
- package/src/quasar/index.ts +9 -1
- package/src/quasar/principalInputTexts.ts +68 -0
- package/src/utils/rpcRefusal.test.ts +42 -0
- package/src/utils/rpcRefusal.ts +22 -0
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -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">{{
|
|
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 {
|
|
121
|
-
|
|
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
|
-
|
|
184
|
+
// Without one the subtitle names the accepted types.
|
|
185
|
+
subtitle: undefined,
|
|
171
186
|
submitLabel: "Sign in",
|
|
172
|
-
|
|
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
|
-
|
|
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="
|
|
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 {
|
|
235
|
-
|
|
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
|
-
/**
|
|
319
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: [
|
|
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="
|
|
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
|
-
|
|
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:
|
|
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
|
|
91
|
-
//
|
|
92
|
-
|
|
93
|
-
|
|
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()) ||
|
|
161
|
+
return isValidEmail(val.trim()) || text.value.invalidEmail;
|
|
126
162
|
case "phone":
|
|
127
|
-
return isValidPhoneNumber(val) ||
|
|
163
|
+
return isValidPhoneNumber(val) || text.value.invalidPhone;
|
|
128
164
|
case "username": {
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
)
|
|
133
|
-
|
|
134
|
-
if (
|
|
135
|
-
return
|
|
136
|
-
if (
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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) {
|
package/src/quasar/index.ts
CHANGED
|
@@ -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 {
|
|
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": "!!!",
|
package/src/utils/rpcRefusal.ts
CHANGED
|
@@ -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;
|