@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 +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 +256 -4
- package/src/profile/components/SidRegistrationForm.vue +115 -22
- package/src/profile/composables/index.ts +1 -0
- package/src/profile/composables/useAuthApi.ts +7 -0
- package/src/profile/composables/usePasswordPolicy.test.ts +109 -0
- package/src/profile/composables/usePasswordPolicy.ts +83 -0
- 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 = {}) {
|
|
@@ -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="
|
|
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 {
|
|
227
|
-
|
|
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
|
-
/**
|
|
258
|
-
|
|
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
|
-
/**
|
|
311
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
323
|
-
|
|
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
|
-
|
|
384
|
-
|
|
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
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
@@ -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: [
|
|
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;
|