@structured-id/ui-core 3.0.0 → 3.1.1
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 +2 -2
- package/src/profile/components/SidLoginForm.test.ts +15 -1
- package/src/profile/components/SidLoginForm.vue +3 -1
- package/src/profile/components/SidRegistrationForm.vue +5 -4
- package/src/profile/composables/useEnrollmentApi.test.ts +5 -3
- package/src/profile/composables/useEnrollmentApi.ts +8 -3
- package/src/quasar/SidPrincipalInput.test.ts +49 -0
- package/src/quasar/SidPrincipalInput.vue +11 -4
- package/src/utils/validators.ts +3 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@structured-id/ui-core",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.1.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public",
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
"@protobuf-ts/grpcweb-transport": "^2.11.1",
|
|
43
43
|
"@protobuf-ts/runtime": "^2.11.1",
|
|
44
44
|
"@protobuf-ts/runtime-rpc": "^2.11.1",
|
|
45
|
-
"@structured-id/proto": "^0.
|
|
45
|
+
"@structured-id/proto": "^0.3.0",
|
|
46
46
|
"libphonenumber-js": "^1.13.15"
|
|
47
47
|
},
|
|
48
48
|
"devDependencies": {
|
|
@@ -2,6 +2,7 @@ import { describe, it, expect, vi } from "vitest";
|
|
|
2
2
|
import { mount } from "@vue/test-utils";
|
|
3
3
|
import { defineComponent } from "vue";
|
|
4
4
|
import SidLoginForm from "./SidLoginForm.vue";
|
|
5
|
+
import { REQUIRED_FIELD_TEXT } from "../../utils/validators";
|
|
5
6
|
|
|
6
7
|
// Stub all Quasar components as divs
|
|
7
8
|
const quasarStubs = {
|
|
@@ -16,7 +17,8 @@ const quasarStubs = {
|
|
|
16
17
|
emits: ["submit"],
|
|
17
18
|
},
|
|
18
19
|
"q-input": defineComponent({
|
|
19
|
-
|
|
20
|
+
name: "QInputStub",
|
|
21
|
+
props: ["modelValue", "type", "label", "disable", "rules", "lazyRules"],
|
|
20
22
|
emits: ["update:modelValue"],
|
|
21
23
|
template:
|
|
22
24
|
'<input :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />',
|
|
@@ -77,4 +79,16 @@ describe("SidLoginForm", () => {
|
|
|
77
79
|
const w = mountLogin();
|
|
78
80
|
expect(w.text()).toContain("Your password never leaves this device");
|
|
79
81
|
});
|
|
82
|
+
|
|
83
|
+
// Regression: the password turned red on the first keystroke and its empty
|
|
84
|
+
// error read "Required" beside the identifier's "This field is required".
|
|
85
|
+
it("validates the password once left, with the shared required text", () => {
|
|
86
|
+
const w = mountLogin();
|
|
87
|
+
const password = w.findComponent({ name: "QInputStub" });
|
|
88
|
+
const lazy = password.props("lazyRules");
|
|
89
|
+
expect(lazy === "" || lazy === true).toBe(true);
|
|
90
|
+
const [required] = password.props("rules") as ((v: string) => unknown)[];
|
|
91
|
+
expect(required("")).toBe(REQUIRED_FIELD_TEXT);
|
|
92
|
+
expect(required("secret")).toBe(true);
|
|
93
|
+
});
|
|
80
94
|
});
|
|
@@ -48,6 +48,7 @@
|
|
|
48
48
|
:label="passwordLabel"
|
|
49
49
|
outlined
|
|
50
50
|
:disable="loading"
|
|
51
|
+
lazy-rules
|
|
51
52
|
:rules="[(v: string) => !!v || passwordRequiredText]"
|
|
52
53
|
data-test="password"
|
|
53
54
|
>
|
|
@@ -119,6 +120,7 @@ import { ref } from "vue";
|
|
|
119
120
|
import { SidPrincipalInput } from "../../quasar";
|
|
120
121
|
import type { PrincipalType } from "../../quasar";
|
|
121
122
|
import { normalizePrincipal, refusalMessage } from "../../index";
|
|
123
|
+
import { REQUIRED_FIELD_TEXT } from "../../utils/validators";
|
|
122
124
|
|
|
123
125
|
void SidPrincipalInput;
|
|
124
126
|
|
|
@@ -169,7 +171,7 @@ const props = withDefaults(
|
|
|
169
171
|
submitLabel: "Sign in",
|
|
170
172
|
identifierLabel: "Email, phone, or username",
|
|
171
173
|
passwordLabel: "Password",
|
|
172
|
-
passwordRequiredText:
|
|
174
|
+
passwordRequiredText: REQUIRED_FIELD_TEXT,
|
|
173
175
|
showForgotPassword: true,
|
|
174
176
|
forgotPasswordText: "Forgot password?",
|
|
175
177
|
forgotPasswordUrl: undefined,
|
|
@@ -225,6 +225,7 @@ interface ValidatableInput {
|
|
|
225
225
|
}
|
|
226
226
|
import type { PrincipalType } from "../../quasar";
|
|
227
227
|
import { normalizePrincipal, refusalMessage } from "../../index";
|
|
228
|
+
import { REQUIRED_FIELD_TEXT } from "../../utils/validators";
|
|
228
229
|
|
|
229
230
|
/** What the form collected besides the identifier and password. */
|
|
230
231
|
export interface RegistrationExtras {
|
|
@@ -280,9 +281,9 @@ const props = withDefaults(
|
|
|
280
281
|
/** Pre-filled invite code (e.g. from URL) */
|
|
281
282
|
initialInviteCode?: string;
|
|
282
283
|
/**
|
|
283
|
-
* The installation
|
|
284
|
-
* then asks for the claim token and the new account becomes
|
|
285
|
-
* the invite code is not asked for.
|
|
284
|
+
* The installation's local claim is open (GetInstanceStatus `OPEN`). The
|
|
285
|
+
* form then asks for the claim token and the new account becomes
|
|
286
|
+
* administrator; the invite code is not asked for.
|
|
286
287
|
*/
|
|
287
288
|
claimRequired?: boolean;
|
|
288
289
|
/** Claim token input label */
|
|
@@ -317,7 +318,7 @@ const props = withDefaults(
|
|
|
317
318
|
identifierLabel: "Email, phone, or username",
|
|
318
319
|
passwordLabel: "Password",
|
|
319
320
|
confirmPasswordLabel: "Confirm password",
|
|
320
|
-
passwordRequiredText:
|
|
321
|
+
passwordRequiredText: REQUIRED_FIELD_TEXT,
|
|
321
322
|
passwordMinLengthText: "At least 12 characters with mixed case + digit",
|
|
322
323
|
passwordMinLength: 12,
|
|
323
324
|
passwordsMismatchText: "Passwords do not match",
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
-
import { getInstanceStatus } from "./useEnrollmentApi";
|
|
2
|
+
import { AdministratorClaim, getInstanceStatus } from "./useEnrollmentApi";
|
|
3
3
|
|
|
4
4
|
const mockGetInstanceStatus = vi.fn();
|
|
5
5
|
|
|
@@ -22,12 +22,14 @@ describe("getInstanceStatus", () => {
|
|
|
22
22
|
// The registration page asks before anyone signs in, so the call must
|
|
23
23
|
// carry no credentials and return the server's answer as is.
|
|
24
24
|
it("asks without credentials and returns the status", async () => {
|
|
25
|
-
mockGetInstanceStatus.mockResolvedValue({
|
|
25
|
+
mockGetInstanceStatus.mockResolvedValue({
|
|
26
|
+
response: { claim: AdministratorClaim.OPEN },
|
|
27
|
+
});
|
|
26
28
|
|
|
27
29
|
const status = await getInstanceStatus();
|
|
28
30
|
|
|
29
31
|
expect(mockGetInstanceStatus).toHaveBeenCalledWith({});
|
|
30
|
-
expect(status).toEqual({
|
|
32
|
+
expect(status).toEqual({ claim: AdministratorClaim.OPEN });
|
|
31
33
|
});
|
|
32
34
|
|
|
33
35
|
it("propagates gRPC errors", async () => {
|
|
@@ -6,17 +6,22 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { getTransport } from "../../index";
|
|
8
8
|
import { EnrollmentServiceClient } from "@structured-id/proto/sid/v1/admin/enrollment.client";
|
|
9
|
-
import
|
|
9
|
+
import {
|
|
10
|
+
AdministratorClaim,
|
|
11
|
+
type InstanceStatus,
|
|
12
|
+
} from "@structured-id/proto/sid/v1/admin/enrollment";
|
|
10
13
|
|
|
11
14
|
function client(): EnrollmentServiceClient {
|
|
12
15
|
return new EnrollmentServiceClient(getTransport());
|
|
13
16
|
}
|
|
14
17
|
|
|
18
|
+
export { AdministratorClaim };
|
|
15
19
|
export type { InstanceStatus };
|
|
16
20
|
|
|
17
21
|
/**
|
|
18
|
-
*
|
|
19
|
-
* registration accepts only the claim token from the service log
|
|
22
|
+
* The installation's local administrator claim (`claim`). While it is
|
|
23
|
+
* `OPEN`, registration accepts only the claim token from the service log; a
|
|
24
|
+
* centrally managed installation reports `UNAVAILABLE` and takes no token.
|
|
20
25
|
*/
|
|
21
26
|
export async function getInstanceStatus(): Promise<InstanceStatus> {
|
|
22
27
|
const { response } = await client().getInstanceStatus({});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { mount } from "@vue/test-utils";
|
|
3
|
+
import { defineComponent } from "vue";
|
|
4
|
+
import SidPrincipalInput from "./SidPrincipalInput.vue";
|
|
5
|
+
import { REQUIRED_FIELD_TEXT } from "../utils/validators";
|
|
6
|
+
|
|
7
|
+
const stubs = {
|
|
8
|
+
"q-input": defineComponent({
|
|
9
|
+
name: "QInputStub",
|
|
10
|
+
props: ["modelValue", "rules", "lazyRules"],
|
|
11
|
+
template: "<div><slot name='prepend' /><slot name='append' /></div>",
|
|
12
|
+
}),
|
|
13
|
+
"q-icon": { template: "<span />" },
|
|
14
|
+
"q-chip": { template: "<span><slot /></span>" },
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
function input(props: Record<string, unknown> = {}) {
|
|
18
|
+
return mount(SidPrincipalInput, {
|
|
19
|
+
props: { modelValue: "", ...props },
|
|
20
|
+
global: { stubs },
|
|
21
|
+
}).findComponent({ name: "QInputStub" });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
type Rule = (v: string) => unknown;
|
|
25
|
+
|
|
26
|
+
describe("SidPrincipalInput", () => {
|
|
27
|
+
// Regression: the identifier turned red while still being typed; it is
|
|
28
|
+
// checked once left, unless a form asks for checking from the start.
|
|
29
|
+
it("validates once left by default", () => {
|
|
30
|
+
expect(input().props("lazyRules")).toBe(true);
|
|
31
|
+
expect(input({ lazyRules: false }).props("lazyRules")).toBe(false);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// One text for an empty required field, the same the password field uses.
|
|
35
|
+
it("says the shared text of an empty required field", () => {
|
|
36
|
+
const [required] = input().props("rules") as Rule[];
|
|
37
|
+
expect(required("")).toBe(REQUIRED_FIELD_TEXT);
|
|
38
|
+
expect(required("alice")).toBe(true);
|
|
39
|
+
const [custom] = input({ requiredText: "Enter it" }).props(
|
|
40
|
+
"rules",
|
|
41
|
+
) as Rule[];
|
|
42
|
+
expect(custom("")).toBe("Enter it");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("does not require an optional field", () => {
|
|
46
|
+
const [required] = input({ required: false }).props("rules") as Rule[];
|
|
47
|
+
expect(required("")).toBe(true);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
<script setup lang="ts">
|
|
34
34
|
import { ref, computed, watch } from "vue";
|
|
35
35
|
import { isValidPhoneNumber } from "libphonenumber-js";
|
|
36
|
-
import { isValidEmail } from "../utils/validators";
|
|
36
|
+
import { REQUIRED_FIELD_TEXT, isValidEmail } from "../utils/validators";
|
|
37
37
|
|
|
38
38
|
export type PrincipalType = "email" | "phone" | "username" | "unknown";
|
|
39
39
|
|
|
@@ -42,7 +42,13 @@ interface Props {
|
|
|
42
42
|
minUsernameLength?: number;
|
|
43
43
|
required?: boolean;
|
|
44
44
|
label?: string;
|
|
45
|
-
/**
|
|
45
|
+
/** What an empty required field says. */
|
|
46
|
+
requiredText?: string;
|
|
47
|
+
/**
|
|
48
|
+
* Validate once the field is left, then on every change (true), or on
|
|
49
|
+
* every change from the first keystroke (false). Default true: a field
|
|
50
|
+
* being typed into is not yet wrong.
|
|
51
|
+
*/
|
|
46
52
|
lazyRules?: boolean;
|
|
47
53
|
}
|
|
48
54
|
|
|
@@ -52,7 +58,8 @@ const props = withDefaults(defineProps<Props>(), {
|
|
|
52
58
|
minUsernameLength: 3,
|
|
53
59
|
required: true,
|
|
54
60
|
label: "Email, phone, or username",
|
|
55
|
-
|
|
61
|
+
requiredText: REQUIRED_FIELD_TEXT,
|
|
62
|
+
lazyRules: true,
|
|
56
63
|
});
|
|
57
64
|
|
|
58
65
|
const emit = defineEmits<{
|
|
@@ -107,7 +114,7 @@ function onInput(value: string | number | null) {
|
|
|
107
114
|
// --- Validation rules ---
|
|
108
115
|
|
|
109
116
|
const requiredRule = (val: string) =>
|
|
110
|
-
!props.required || !!val ||
|
|
117
|
+
!props.required || !!val || props.requiredText;
|
|
111
118
|
|
|
112
119
|
const typeRule = (val: string): string | true => {
|
|
113
120
|
if (!val) return true; // required rule handles empty
|
package/src/utils/validators.ts
CHANGED
|
@@ -42,6 +42,9 @@ export function maxLength(value: string, max: number): boolean {
|
|
|
42
42
|
return value.length <= max;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
/** What every form says of an empty required field, so one error reads alike. */
|
|
46
|
+
export const REQUIRED_FIELD_TEXT = "This field is required";
|
|
47
|
+
|
|
45
48
|
/** Check value is not empty/whitespace-only. */
|
|
46
49
|
export function isRequired(value: string): boolean {
|
|
47
50
|
return value.trim().length > 0;
|