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