@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
|
@@ -2,15 +2,22 @@ import { describe, it, expect } from "vitest";
|
|
|
2
2
|
import { mount } from "@vue/test-utils";
|
|
3
3
|
import { defineComponent } from "vue";
|
|
4
4
|
import SidPrincipalInput from "./SidPrincipalInput.vue";
|
|
5
|
-
import {
|
|
5
|
+
import { principalRecognizable } from "./principalInputTexts";
|
|
6
6
|
|
|
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
|
-
"q-icon": { template: "<span />" },
|
|
20
|
+
"q-icon": { props: ["name"], template: "<span :data-icon='name' />" },
|
|
14
21
|
"q-chip": { template: "<span><slot /></span>" },
|
|
15
22
|
};
|
|
16
23
|
|
|
@@ -31,19 +38,233 @@ describe("SidPrincipalInput", () => {
|
|
|
31
38
|
expect(input({ lazyRules: false }).props("lazyRules")).toBe(false);
|
|
32
39
|
});
|
|
33
40
|
|
|
34
|
-
//
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
const
|
|
41
|
+
// Until the input is long enough to be a principal of some type nothing
|
|
42
|
+
// is shown as wrong, an empty field included: the form keeps its button
|
|
43
|
+
// disabled instead.
|
|
44
|
+
it("shows nothing wrong before a type is recognized", () => {
|
|
45
|
+
const [type] = input().props("rules") as Rule[];
|
|
46
|
+
for (const value of ["", "a", "al"]) {
|
|
47
|
+
expect(type(value), JSON.stringify(value)).toBe(true);
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// A field that accepts only some principal types refuses the others before
|
|
52
|
+
// any call, naming what it accepts: a registration that takes email only
|
|
53
|
+
// must not let a username reach the server.
|
|
54
|
+
it("refuses a principal type it does not accept", () => {
|
|
55
|
+
const [type] = input({ types: ["email"] }).props("rules") as Rule[];
|
|
56
|
+
expect(type("alice_wonder")).toBe("Enter an email address");
|
|
57
|
+
expect(type("+380501234567")).toBe("Enter an email address");
|
|
58
|
+
expect(type("alice@sid.example.com")).toBe(true);
|
|
59
|
+
const [two] = input({ types: ["email", "username"] }).props(
|
|
60
|
+
"rules",
|
|
61
|
+
) as Rule[];
|
|
62
|
+
expect(two("+380501234567")).toBe("Enter an email address or a username");
|
|
63
|
+
expect(two("alice_wonder")).toBe(true);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// Regression: a username shorter than the minimum was not recognized as a
|
|
67
|
+
// username and the field asked for "email, phone, or username" instead of
|
|
68
|
+
// saying the minimum.
|
|
69
|
+
it("tells a too-short username the minimum length", () => {
|
|
70
|
+
const [type] = input({ minUsernameLength: 6 }).props("rules") as Rule[];
|
|
71
|
+
expect(type("sid")).toBe("A username has at least 6 characters");
|
|
72
|
+
expect(type("sidsid")).toBe(true);
|
|
73
|
+
const [login] = input().props("rules") as Rule[];
|
|
74
|
+
expect(login("sid")).toBe(true);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// One or two characters are nobody yet: no type is recognized from them,
|
|
78
|
+
// the field stays a login field with the neutral icon, and nothing is
|
|
79
|
+
// shown as wrong (the form only keeps its button disabled).
|
|
80
|
+
it("recognizes no type from fewer than three characters", () => {
|
|
81
|
+
for (const short of ["a", "al", "+3", "a@", "ЩЩ", " a "]) {
|
|
82
|
+
const field = mount(SidPrincipalInput, {
|
|
83
|
+
props: { modelValue: short, types: ["email", "username"] },
|
|
84
|
+
global: { stubs },
|
|
85
|
+
});
|
|
86
|
+
const stub = field.findComponent({ name: "QInputStub" });
|
|
87
|
+
expect(stub.props("label"), short).toBe("Login");
|
|
88
|
+
expect(field.html(), short).toContain('data-icon="sym_o_login"');
|
|
89
|
+
const [type] = stub.props("rules") as Rule[];
|
|
90
|
+
expect(type(short), short).toBe(true);
|
|
91
|
+
}
|
|
92
|
+
expect(principalRecognizable("al")).toBe(false);
|
|
93
|
+
expect(principalRecognizable(" al ")).toBe(false);
|
|
94
|
+
expect(principalRecognizable("ali")).toBe(true);
|
|
95
|
+
expect(principalRecognizable("Щук")).toBe(true);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// Regression: a username with a letter outside a-z was taken for no type
|
|
99
|
+
// and refused before the server could apply its repertoire; its length is
|
|
100
|
+
// counted in code points of its NFC form, as the server counts it.
|
|
101
|
+
it("takes a username in any script and counts its code points", () => {
|
|
102
|
+
const [type] = input({
|
|
103
|
+
types: ["email", "username"],
|
|
104
|
+
minUsernameLength: 6,
|
|
105
|
+
}).props("rules") as Rule[];
|
|
106
|
+
expect(type("Иван_Петров")).toBe(true);
|
|
107
|
+
expect(type("Щукин")).toBe("A username has at least 6 characters");
|
|
108
|
+
expect(type("Jürgen")).toBe(true); // 7 code points, 6 in NFC
|
|
109
|
+
// Whitespace, inside or around, is refused, not trimmed into a name, and
|
|
110
|
+
// the refusal says what is wrong.
|
|
111
|
+
for (const spaced of ["Иван Петров", " Иван_Петров", "Иван_Петров "]) {
|
|
112
|
+
expect(type(spaced)).toBe("A username cannot contain spaces");
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// Regression: a username with a character it cannot hold (a dot, a dash,
|
|
117
|
+
// a symbol) was taken for no type at all and told to "enter an email
|
|
118
|
+
// address or a username", which names no problem. Input without `@` and
|
|
119
|
+
// `+` is a username attempt, and the refusal names the characters allowed.
|
|
120
|
+
it("tells a username which characters it can hold", () => {
|
|
121
|
+
const [two] = input({ types: ["email", "username"] }).props(
|
|
40
122
|
"rules",
|
|
41
123
|
) as Rule[];
|
|
42
|
-
|
|
124
|
+
for (const odd of ["ali.ce", "ali-ce", "😀😀😀", "alice#acme.corp"]) {
|
|
125
|
+
expect(two(odd)).toBe(
|
|
126
|
+
"A username can contain only letters, digits and underscores",
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
// A field that takes no username still asks for what it takes.
|
|
130
|
+
const [email] = input({ types: ["email"] }).props("rules") as Rule[];
|
|
131
|
+
expect(email("ali.ce")).toBe("Enter an email address");
|
|
132
|
+
expect(
|
|
133
|
+
input({ types: ["email", "username"], modelValue: "ali.ce" }).props(
|
|
134
|
+
"label",
|
|
135
|
+
),
|
|
136
|
+
).toBe("Username");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// Regression: the label already names the recognized type, and a chip
|
|
140
|
+
// repeated it beside the icon. The chip is shown only when the form's own
|
|
141
|
+
// label does not name the type.
|
|
142
|
+
it("names the type once", () => {
|
|
143
|
+
const named = mount(SidPrincipalInput, {
|
|
144
|
+
props: { modelValue: "alice@sid.example.com" },
|
|
145
|
+
global: { stubs },
|
|
146
|
+
});
|
|
147
|
+
expect(named.findComponent({ name: "QInputStub" }).props("label")).toBe(
|
|
148
|
+
"Email",
|
|
149
|
+
);
|
|
150
|
+
expect(named.text()).not.toContain("email");
|
|
151
|
+
const own = mount(SidPrincipalInput, {
|
|
152
|
+
props: { modelValue: "alice@sid.example.com", label: "Sign in with" },
|
|
153
|
+
global: { stubs },
|
|
154
|
+
});
|
|
155
|
+
expect(own.text()).toContain("email");
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// Regression: a phone number on a field that takes no phone got the phone
|
|
159
|
+
// icon and chip, as if it were accepted. A type the field refuses shows
|
|
160
|
+
// the neutral login icon and no chip.
|
|
161
|
+
it("does not dress a refused type as accepted", () => {
|
|
162
|
+
const w = mount(SidPrincipalInput, {
|
|
163
|
+
props: {
|
|
164
|
+
modelValue: "+380501234567",
|
|
165
|
+
types: ["email", "username"],
|
|
166
|
+
label: "Sign in with",
|
|
167
|
+
},
|
|
168
|
+
global: { stubs },
|
|
169
|
+
});
|
|
170
|
+
expect(w.html()).toContain('data-icon="sym_o_login"');
|
|
171
|
+
expect(w.text()).not.toContain("phone");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// Regression: an email-only field showed the generic login icon until an
|
|
175
|
+
// address was typed, beside its "Email" label. A field of one type shows
|
|
176
|
+
// that type's icon from the start, greyed until the input is recognized.
|
|
177
|
+
it("shows a single-type field's icon from the start", () => {
|
|
178
|
+
const empty = mount(SidPrincipalInput, {
|
|
179
|
+
props: { modelValue: "", types: ["email"] },
|
|
180
|
+
global: { stubs },
|
|
181
|
+
});
|
|
182
|
+
expect(empty.html()).toContain('data-icon="sym_o_mail"');
|
|
183
|
+
expect(
|
|
184
|
+
mount(SidPrincipalInput, {
|
|
185
|
+
props: { modelValue: "", types: ["email", "username"] },
|
|
186
|
+
global: { stubs },
|
|
187
|
+
}).html(),
|
|
188
|
+
).toContain('data-icon="sym_o_login"');
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
// The label names the accepted types unless the form gives its own.
|
|
192
|
+
// A field of one type is named by it. A field of several is a login
|
|
193
|
+
// field until the input shows which type it is, then it names that type;
|
|
194
|
+
// a recognized type the field does not accept leaves it a login field.
|
|
195
|
+
it("labels the field by the types it accepts", () => {
|
|
196
|
+
expect(input({ types: ["email"] }).props("label")).toBe("Email");
|
|
197
|
+
expect(input({ types: ["username"] }).props("label")).toBe("Username");
|
|
198
|
+
expect(input().props("label")).toBe("Login");
|
|
199
|
+
const two = { types: ["email", "username"] };
|
|
200
|
+
expect(input(two).props("label")).toBe("Login");
|
|
201
|
+
expect(
|
|
202
|
+
input({ ...two, modelValue: "alice@sid.example.com" }).props("label"),
|
|
203
|
+
).toBe("Email");
|
|
204
|
+
expect(input({ ...two, modelValue: "alice_wonder" }).props("label")).toBe(
|
|
205
|
+
"Username",
|
|
206
|
+
);
|
|
207
|
+
expect(input({ ...two, modelValue: "+380501234567" }).props("label")).toBe(
|
|
208
|
+
"Login",
|
|
209
|
+
);
|
|
210
|
+
expect(input({ ...two, texts: { login: "Логин" } }).props("label")).toBe(
|
|
211
|
+
"Логин",
|
|
212
|
+
);
|
|
213
|
+
expect(
|
|
214
|
+
input({ types: ["email"], label: "Your email" }).props("label"),
|
|
215
|
+
).toBe("Your email");
|
|
216
|
+
// An explicitly empty label is the form's choice of no label.
|
|
217
|
+
expect(
|
|
218
|
+
input({ types: ["email", "username"], label: "" }).props("label"),
|
|
219
|
+
).toBe("");
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
// The chip naming the detected type is a text like the others: a
|
|
223
|
+
// translated form does not switch back to English when a type is found.
|
|
224
|
+
it("takes the type chip's text from the texts", () => {
|
|
225
|
+
const w = mount(SidPrincipalInput, {
|
|
226
|
+
props: {
|
|
227
|
+
modelValue: "alice@sid.example.com",
|
|
228
|
+
label: "Sign in with",
|
|
229
|
+
texts: { typeChip: (t: string) => `chip:${t}` },
|
|
230
|
+
},
|
|
231
|
+
global: { stubs },
|
|
232
|
+
});
|
|
233
|
+
expect(w.text()).toContain("chip:email");
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
// The form disables its submit on this, before any error is shown: only a
|
|
237
|
+
// recognized, accepted, well-formed value, not refused by the server, is
|
|
238
|
+
// valid.
|
|
239
|
+
it("tells the form whether the value can be submitted", () => {
|
|
240
|
+
const valid = (props: { modelValue: string; fieldError?: string }) => {
|
|
241
|
+
const w = mount(SidPrincipalInput, {
|
|
242
|
+
props: { types: ["email", "username"], ...props },
|
|
243
|
+
global: { stubs },
|
|
244
|
+
});
|
|
245
|
+
return w.emitted("update:valid")?.at(-1)?.[0];
|
|
246
|
+
};
|
|
247
|
+
expect(valid({ modelValue: "" })).toBe(false);
|
|
248
|
+
expect(valid({ modelValue: "al" })).toBe(false);
|
|
249
|
+
expect(valid({ modelValue: "ali" })).toBe(true);
|
|
250
|
+
expect(valid({ modelValue: "ali.ce" })).toBe(false);
|
|
251
|
+
expect(valid({ modelValue: "+380501234567" })).toBe(false);
|
|
252
|
+
expect(valid({ modelValue: "alice@" })).toBe(false);
|
|
253
|
+
expect(valid({ modelValue: "alice@sid.example.com" })).toBe(true);
|
|
254
|
+
expect(
|
|
255
|
+
valid({ modelValue: "alice@sid.example.com", fieldError: "Taken" }),
|
|
256
|
+
).toBe(false);
|
|
43
257
|
});
|
|
44
258
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
259
|
+
// A refusal the server bound to this field is shown on it.
|
|
260
|
+
it("shows an error given from outside", () => {
|
|
261
|
+
const shown = input({
|
|
262
|
+
fieldError: "Registration here takes an email address",
|
|
263
|
+
});
|
|
264
|
+
expect(shown.props("error")).toBe(true);
|
|
265
|
+
expect(shown.props("errorMessage")).toBe(
|
|
266
|
+
"Registration here takes an email address",
|
|
267
|
+
);
|
|
268
|
+
expect(input().props("error")).toBe(false);
|
|
48
269
|
});
|
|
49
270
|
});
|
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
<q-input
|
|
3
3
|
ref="inputRef"
|
|
4
4
|
v-model="model"
|
|
5
|
-
:label="
|
|
6
|
-
:rules="[
|
|
5
|
+
:label="fieldLabel"
|
|
6
|
+
:rules="[typeRule]"
|
|
7
|
+
:error="!!fieldError"
|
|
8
|
+
:error-message="fieldError ?? undefined"
|
|
7
9
|
outlined
|
|
8
10
|
type="text"
|
|
9
11
|
:inputmode="inputMode"
|
|
@@ -16,7 +18,7 @@
|
|
|
16
18
|
<template #prepend>
|
|
17
19
|
<q-icon :name="iconName" :color="iconColor" size="24px" />
|
|
18
20
|
</template>
|
|
19
|
-
<template v-if="
|
|
21
|
+
<template v-if="showChip" #append>
|
|
20
22
|
<q-chip
|
|
21
23
|
dense
|
|
22
24
|
:color="chipColor"
|
|
@@ -33,17 +35,34 @@
|
|
|
33
35
|
<script setup lang="ts">
|
|
34
36
|
import { ref, computed, watch } from "vue";
|
|
35
37
|
import { isValidPhoneNumber } from "libphonenumber-js";
|
|
36
|
-
import {
|
|
38
|
+
import { isValidEmail } from "../utils/validators";
|
|
37
39
|
|
|
38
|
-
|
|
40
|
+
import {
|
|
41
|
+
ALL_PRINCIPAL_TYPES,
|
|
42
|
+
DEFAULT_PRINCIPAL_INPUT_TEXTS,
|
|
43
|
+
principalRecognizable,
|
|
44
|
+
type AcceptedPrincipalType,
|
|
45
|
+
type PrincipalInputTexts,
|
|
46
|
+
type PrincipalType,
|
|
47
|
+
} from "./principalInputTexts";
|
|
48
|
+
|
|
49
|
+
export type { PrincipalType };
|
|
39
50
|
|
|
40
51
|
interface Props {
|
|
41
52
|
modelValue: string;
|
|
53
|
+
/**
|
|
54
|
+
* The principal types this field accepts; the others are refused before
|
|
55
|
+
* any call, with a text naming the accepted ones. The instance's settings
|
|
56
|
+
* or the scenario decide it (a registration may take email only).
|
|
57
|
+
*/
|
|
58
|
+
types?: AcceptedPrincipalType[];
|
|
42
59
|
minUsernameLength?: number;
|
|
43
|
-
|
|
60
|
+
/** The label; without one the field names the accepted types. */
|
|
44
61
|
label?: string;
|
|
45
|
-
/**
|
|
46
|
-
|
|
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>;
|
|
47
66
|
/**
|
|
48
67
|
* Validate once the field is left, then on every change (true), or on
|
|
49
68
|
* every change from the first keystroke (false). Default true: a field
|
|
@@ -54,17 +73,45 @@ interface Props {
|
|
|
54
73
|
|
|
55
74
|
const props = withDefaults(defineProps<Props>(), {
|
|
56
75
|
modelValue: "",
|
|
57
|
-
|
|
76
|
+
types: () => [...ALL_PRINCIPAL_TYPES],
|
|
77
|
+
// Code points of the NFC display form; the server's minimum decides.
|
|
58
78
|
minUsernameLength: 3,
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
79
|
+
label: undefined,
|
|
80
|
+
fieldError: null,
|
|
81
|
+
texts: () => ({}),
|
|
62
82
|
lazyRules: true,
|
|
63
83
|
});
|
|
64
84
|
|
|
85
|
+
const text = computed<PrincipalInputTexts>(() => ({
|
|
86
|
+
...DEFAULT_PRINCIPAL_INPUT_TEXTS,
|
|
87
|
+
...props.texts,
|
|
88
|
+
}));
|
|
89
|
+
|
|
90
|
+
/** The accepted types in their fixed order. */
|
|
91
|
+
const accepted = computed(() =>
|
|
92
|
+
ALL_PRINCIPAL_TYPES.filter((t) => props.types.includes(t)),
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* A field of one type is named by it; a field of several is a login field
|
|
97
|
+
* until its input is recognized as one of the accepted types.
|
|
98
|
+
*/
|
|
99
|
+
const fieldLabel = computed(() => {
|
|
100
|
+
// An empty label is the form's own choice; only an absent one is derived.
|
|
101
|
+
if (props.label != null) return props.label;
|
|
102
|
+
const [only, ...others] = accepted.value;
|
|
103
|
+
if (only && others.length === 0) return text.value.label([only]);
|
|
104
|
+
const type = detectedType.value;
|
|
105
|
+
return type !== "unknown" && accepted.value.includes(type)
|
|
106
|
+
? text.value.label([type])
|
|
107
|
+
: text.value.login;
|
|
108
|
+
});
|
|
109
|
+
|
|
65
110
|
const emit = defineEmits<{
|
|
66
111
|
"update:modelValue": [value: string];
|
|
67
112
|
"update:principalType": [type: PrincipalType];
|
|
113
|
+
/** Whether the value can be submitted: a recognized, accepted, well-formed principal. */
|
|
114
|
+
"update:valid": [valid: boolean];
|
|
68
115
|
}>();
|
|
69
116
|
|
|
70
117
|
const inputRef = ref<{ focus: () => void } | null>(null);
|
|
@@ -76,10 +123,13 @@ const model = computed({
|
|
|
76
123
|
|
|
77
124
|
const USERNAME_MAX_LENGTH = 32;
|
|
78
125
|
|
|
126
|
+
/** What a username may hold: letters of any script, marks, digits, `_`. */
|
|
127
|
+
const USERNAME_SHAPE = /^[\p{L}\p{M}\p{N}_]+$/u;
|
|
128
|
+
|
|
79
129
|
function detectType(value: string): PrincipalType {
|
|
80
|
-
|
|
130
|
+
// One or two characters are nobody yet: no type is read from them.
|
|
131
|
+
if (!principalRecognizable(value)) return "unknown";
|
|
81
132
|
const trimmed = value.trim();
|
|
82
|
-
if (!trimmed) return "unknown";
|
|
83
133
|
|
|
84
134
|
// Phone: starts with +
|
|
85
135
|
if (trimmed.startsWith("+")) return "phone";
|
|
@@ -87,17 +137,9 @@ function detectType(value: string): PrincipalType {
|
|
|
87
137
|
// Email: contains @ (checked after phone to exclude edge cases)
|
|
88
138
|
if (trimmed.includes("@")) return "email";
|
|
89
139
|
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
|
|
93
|
-
`^[a-zA-Z0-9_]{${props.minUsernameLength},${USERNAME_MAX_LENGTH}}$`,
|
|
94
|
-
);
|
|
95
|
-
if (re.test(trimmed) && !trimmed.startsWith("_") && !trimmed.endsWith("_")) {
|
|
96
|
-
return "username";
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
// Still typing — not enough chars to determine
|
|
100
|
-
return "unknown";
|
|
140
|
+
// Anything else is a username attempt, whatever it holds, so that what is
|
|
141
|
+
// wrong with it (spaces, a dot, a symbol, its length) can be said.
|
|
142
|
+
return "username";
|
|
101
143
|
}
|
|
102
144
|
|
|
103
145
|
const detectedType = computed(() => detectType(model.value));
|
|
@@ -113,39 +155,81 @@ function onInput(value: string | number | null) {
|
|
|
113
155
|
|
|
114
156
|
// --- Validation rules ---
|
|
115
157
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
158
|
+
/**
|
|
159
|
+
* Until a type is recognized nothing is wrong yet, an empty field included:
|
|
160
|
+
* the form keeps its button disabled instead (see principalRecognizable).
|
|
161
|
+
*/
|
|
119
162
|
const typeRule = (val: string): string | true => {
|
|
120
|
-
if (!val) return true; // required rule handles empty
|
|
121
163
|
const type = detectType(val);
|
|
164
|
+
if (type === "unknown") return true;
|
|
165
|
+
if (!accepted.value.includes(type)) {
|
|
166
|
+
return text.value.enterAccepted(accepted.value);
|
|
167
|
+
}
|
|
122
168
|
|
|
123
169
|
switch (type) {
|
|
124
170
|
case "email":
|
|
125
|
-
return isValidEmail(val.trim()) ||
|
|
171
|
+
return isValidEmail(val.trim()) || text.value.invalidEmail;
|
|
126
172
|
case "phone":
|
|
127
|
-
return isValidPhoneNumber(val) ||
|
|
173
|
+
return isValidPhoneNumber(val) || text.value.invalidPhone;
|
|
128
174
|
case "username": {
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
175
|
+
const name = val;
|
|
176
|
+
if (/\s/u.test(name)) return text.value.usernameSpaces;
|
|
177
|
+
if (!USERNAME_SHAPE.test(name)) return text.value.usernameCharacters;
|
|
178
|
+
// Counted in code points of the NFC form, as the server counts the
|
|
179
|
+
// display; the value itself is sent unchanged.
|
|
180
|
+
const length = [...name.normalize("NFC")].length;
|
|
181
|
+
if (length < props.minUsernameLength)
|
|
182
|
+
return text.value.usernameTooShort(props.minUsernameLength);
|
|
183
|
+
if (length > USERNAME_MAX_LENGTH)
|
|
184
|
+
return text.value.usernameTooLong(USERNAME_MAX_LENGTH);
|
|
185
|
+
if (name.startsWith("_") || name.endsWith("_"))
|
|
186
|
+
return text.value.usernameUnderscoreEdge;
|
|
187
|
+
if (name.includes("__")) return text.value.usernameDoubleUnderscore;
|
|
138
188
|
return true;
|
|
139
189
|
}
|
|
140
|
-
default:
|
|
141
|
-
return "Enter email, phone (+...), or username";
|
|
142
190
|
}
|
|
143
191
|
};
|
|
144
192
|
|
|
193
|
+
/**
|
|
194
|
+
* A form keeps its submit disabled until this holds, silently: the error a
|
|
195
|
+
* rule names appears only once the field is left, the button never waits for
|
|
196
|
+
* it. A refusal the server bound to the field holds until the value changes.
|
|
197
|
+
*/
|
|
198
|
+
const valid = computed(
|
|
199
|
+
() =>
|
|
200
|
+
detectedType.value !== "unknown" &&
|
|
201
|
+
typeRule(model.value) === true &&
|
|
202
|
+
!props.fieldError,
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
watch(valid, (v) => emit("update:valid", v), { immediate: true });
|
|
206
|
+
|
|
145
207
|
// --- Dynamic icon & type ---
|
|
146
208
|
|
|
209
|
+
/** The recognized type when the field accepts it; refused types look neutral. */
|
|
210
|
+
const shownType = computed<PrincipalType>(() => {
|
|
211
|
+
const type = detectedType.value;
|
|
212
|
+
return type !== "unknown" && accepted.value.includes(type) ? type : "unknown";
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* The chip names the type only when the label does not: a derived label
|
|
217
|
+
* already says "Email" or "Username".
|
|
218
|
+
*/
|
|
219
|
+
const showChip = computed(
|
|
220
|
+
() => props.label != null && shownType.value !== "unknown",
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
/** A field of one type shows that type's icon from the start, greyed until recognized. */
|
|
224
|
+
const iconType = computed<PrincipalType>(() => {
|
|
225
|
+
const [only, ...others] = accepted.value;
|
|
226
|
+
return shownType.value === "unknown" && only && others.length === 0
|
|
227
|
+
? only
|
|
228
|
+
: shownType.value;
|
|
229
|
+
});
|
|
230
|
+
|
|
147
231
|
const iconName = computed(() => {
|
|
148
|
-
switch (
|
|
232
|
+
switch (iconType.value) {
|
|
149
233
|
case "email":
|
|
150
234
|
return "sym_o_mail";
|
|
151
235
|
case "phone":
|
|
@@ -158,7 +242,7 @@ const iconName = computed(() => {
|
|
|
158
242
|
});
|
|
159
243
|
|
|
160
244
|
const iconColor = computed(() =>
|
|
161
|
-
|
|
245
|
+
shownType.value === "unknown" ? "grey-6" : "primary",
|
|
162
246
|
);
|
|
163
247
|
|
|
164
248
|
// inputmode controls mobile keyboard WITHOUT triggering browser autofill.
|
|
@@ -177,21 +261,12 @@ const inputMode = computed(() => {
|
|
|
177
261
|
}
|
|
178
262
|
});
|
|
179
263
|
|
|
180
|
-
const chipLabel = computed(() =>
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
return "email";
|
|
184
|
-
case "phone":
|
|
185
|
-
return "phone";
|
|
186
|
-
case "username":
|
|
187
|
-
return "username";
|
|
188
|
-
default:
|
|
189
|
-
return "";
|
|
190
|
-
}
|
|
191
|
-
});
|
|
264
|
+
const chipLabel = computed(() =>
|
|
265
|
+
shownType.value === "unknown" ? "" : text.value.typeChip(shownType.value),
|
|
266
|
+
);
|
|
192
267
|
|
|
193
268
|
const chipColor = computed(() => {
|
|
194
|
-
switch (
|
|
269
|
+
switch (shownType.value) {
|
|
195
270
|
case "email":
|
|
196
271
|
return "deep-orange";
|
|
197
272
|
case "phone":
|
|
@@ -209,5 +284,5 @@ function focus() {
|
|
|
209
284
|
inputRef.value?.focus();
|
|
210
285
|
}
|
|
211
286
|
|
|
212
|
-
defineExpose({ focus, detectedType });
|
|
287
|
+
defineExpose({ focus, detectedType, valid });
|
|
213
288
|
</script>
|
package/src/quasar/index.ts
CHANGED
|
@@ -5,6 +5,16 @@
|
|
|
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
|
+
MIN_PRINCIPAL_LENGTH,
|
|
17
|
+
principalRecognizable,
|
|
18
|
+
} from "./principalInputTexts";
|
|
9
19
|
export { default as SidPasswordCeremony } from "./SidPasswordCeremony.vue";
|
|
10
20
|
export type { CeremonyOutcome } from "./SidPasswordCeremony.vue";
|
|
@@ -0,0 +1,93 @@
|
|
|
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
|
+
/**
|
|
16
|
+
* Fewer characters than this are nobody yet: no type is recognized from them
|
|
17
|
+
* and no form can submit them. Counted in code points of the NFC form,
|
|
18
|
+
* surrounding whitespace aside.
|
|
19
|
+
*/
|
|
20
|
+
export const MIN_PRINCIPAL_LENGTH = 3;
|
|
21
|
+
|
|
22
|
+
/** Whether `value` is long enough to be anybody. */
|
|
23
|
+
export function principalRecognizable(value: string): boolean {
|
|
24
|
+
return [...value.trim().normalize("NFC")].length >= MIN_PRINCIPAL_LENGTH;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** What the field says; each text can be replaced (translated) by the form. */
|
|
28
|
+
export interface PrincipalInputTexts {
|
|
29
|
+
/**
|
|
30
|
+
* The label naming one type: the field's only accepted type, or the type
|
|
31
|
+
* the input of a several-type field was recognized as.
|
|
32
|
+
*/
|
|
33
|
+
label: (types: AcceptedPrincipalType[]) => string;
|
|
34
|
+
/** The label of a field of several types before its input shows which. */
|
|
35
|
+
login: string;
|
|
36
|
+
/** A value of a type the field does not accept, or of no type at all. */
|
|
37
|
+
enterAccepted: (types: AcceptedPrincipalType[]) => string;
|
|
38
|
+
/** The chip naming the type the value was recognized as. */
|
|
39
|
+
typeChip: (type: AcceptedPrincipalType) => string;
|
|
40
|
+
/** The login form's default subtitle for the accepted types. */
|
|
41
|
+
loginSubtitle: (types: AcceptedPrincipalType[]) => string;
|
|
42
|
+
invalidEmail: string;
|
|
43
|
+
invalidPhone: string;
|
|
44
|
+
usernameTooShort: (min: number) => string;
|
|
45
|
+
usernameTooLong: (max: number) => string;
|
|
46
|
+
usernameUnderscoreEdge: string;
|
|
47
|
+
usernameDoubleUnderscore: string;
|
|
48
|
+
/** A username with whitespace in or around it. */
|
|
49
|
+
usernameSpaces: string;
|
|
50
|
+
/** A username with a character other than a letter, digit or `_`. */
|
|
51
|
+
usernameCharacters: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** `a`, `a or b`, `a, b, or c`. */
|
|
55
|
+
function list(words: string[]): string {
|
|
56
|
+
if (words.length <= 1) return words.join("");
|
|
57
|
+
if (words.length === 2) return `${words[0]} or ${words[1]}`;
|
|
58
|
+
return `${words.slice(0, -1).join(", ")}, or ${words[words.length - 1]}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const LABEL_WORDS: Record<AcceptedPrincipalType, string> = {
|
|
62
|
+
email: "email",
|
|
63
|
+
phone: "phone",
|
|
64
|
+
username: "username",
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const ENTER_WORDS: Record<AcceptedPrincipalType, string> = {
|
|
68
|
+
email: "an email address",
|
|
69
|
+
phone: "a phone number (+...)",
|
|
70
|
+
username: "a username",
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export const DEFAULT_PRINCIPAL_INPUT_TEXTS: PrincipalInputTexts = {
|
|
74
|
+
label: (types) => {
|
|
75
|
+
const text = list(types.map((t) => LABEL_WORDS[t]));
|
|
76
|
+
return text.charAt(0).toUpperCase() + text.slice(1);
|
|
77
|
+
},
|
|
78
|
+
login: "Login",
|
|
79
|
+
enterAccepted: (types) => `Enter ${list(types.map((t) => ENTER_WORDS[t]))}`,
|
|
80
|
+
typeChip: (type) => LABEL_WORDS[type],
|
|
81
|
+
loginSubtitle: (types) =>
|
|
82
|
+
`Enter your ${list(types.map((t) => LABEL_WORDS[t]))} to continue`,
|
|
83
|
+
invalidEmail: "Invalid email address",
|
|
84
|
+
invalidPhone: "Invalid phone number",
|
|
85
|
+
usernameTooShort: (min) => `A username has at least ${min} characters`,
|
|
86
|
+
usernameTooLong: (max) => `A username has at most ${max} characters`,
|
|
87
|
+
usernameUnderscoreEdge: "A username cannot start or end with an underscore",
|
|
88
|
+
usernameDoubleUnderscore:
|
|
89
|
+
"A username cannot contain two underscores in a row",
|
|
90
|
+
usernameSpaces: "A username cannot contain spaces",
|
|
91
|
+
usernameCharacters:
|
|
92
|
+
"A username can contain only letters, digits and underscores",
|
|
93
|
+
};
|