@structured-id/ui-core 4.1.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/profile/components/SidLoginForm.test.ts +47 -8
- package/src/profile/components/SidLoginForm.vue +3 -1
- package/src/profile/components/SidRegistrationForm.test.ts +28 -15
- package/src/profile/components/SidRegistrationForm.vue +3 -1
- package/src/quasar/SidPrincipalInput.test.ts +174 -24
- package/src/quasar/SidPrincipalInput.vue +84 -35
- package/src/quasar/index.ts +2 -0
- package/src/quasar/principalInputTexts.ts +26 -1
- package/src/utils/normalizePrincipal.test.ts +40 -3
- package/src/utils/normalizePrincipal.ts +27 -28
package/package.json
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
|
|
|
@@ -94,11 +92,52 @@ describe("SidLoginForm", () => {
|
|
|
94
92
|
).toContain("Hello");
|
|
95
93
|
});
|
|
96
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
|
+
|
|
97
136
|
// Regression: the password turned red on the first keystroke and its empty
|
|
98
137
|
// error read "Required" beside the identifier's "This field is required".
|
|
99
138
|
it("validates the password once left, with the shared required text", () => {
|
|
100
139
|
const w = mountLogin();
|
|
101
|
-
const password = w.
|
|
140
|
+
const password = w.findAllComponents({ name: "QInputStub" })[1];
|
|
102
141
|
const lazy = password.props("lazyRules");
|
|
103
142
|
expect(lazy === "" || lazy === true).toBe(true);
|
|
104
143
|
const [required] = password.props("rules") as ((v: string) => unknown)[];
|
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
:texts="principalTexts"
|
|
43
43
|
data-test="principal"
|
|
44
44
|
@update:principal-type="principalType = $event"
|
|
45
|
+
@update:valid="principalValid = $event"
|
|
45
46
|
/>
|
|
46
47
|
|
|
47
48
|
<q-input
|
|
@@ -74,7 +75,7 @@
|
|
|
74
75
|
color="primary"
|
|
75
76
|
class="full-width"
|
|
76
77
|
:loading="loading"
|
|
77
|
-
:disable="!
|
|
78
|
+
:disable="!principalValid || !password"
|
|
78
79
|
data-test="submit"
|
|
79
80
|
/>
|
|
80
81
|
</q-form>
|
|
@@ -223,6 +224,7 @@ const shownSubtitle = computed(
|
|
|
223
224
|
|
|
224
225
|
const identifier = ref("");
|
|
225
226
|
const principalType = ref<PrincipalType>("unknown");
|
|
227
|
+
const principalValid = ref(false);
|
|
226
228
|
const password = ref("");
|
|
227
229
|
const showPassword = ref(false);
|
|
228
230
|
const loading = ref(false);
|
|
@@ -31,7 +31,10 @@ const quasarStubs = {
|
|
|
31
31
|
template:
|
|
32
32
|
'<input :value="modelValue" :data-error="error ? errorMessage : undefined" @input="$emit(\'update:modelValue\', $event.target.value)" />',
|
|
33
33
|
},
|
|
34
|
-
"q-btn": {
|
|
34
|
+
"q-btn": {
|
|
35
|
+
props: ["disable"],
|
|
36
|
+
template: '<button type="submit" :disabled="disable"><slot /></button>',
|
|
37
|
+
},
|
|
35
38
|
"sid-password-ceremony": {
|
|
36
39
|
name: "SidPasswordCeremony",
|
|
37
40
|
props: ["outcome", "refusedText", "length", "fields"],
|
|
@@ -39,20 +42,6 @@ const quasarStubs = {
|
|
|
39
42
|
template:
|
|
40
43
|
'<div class="ceremony" :data-outcome="outcome" :data-refused="refusedText" :data-length="length" :data-fields="fields" />',
|
|
41
44
|
},
|
|
42
|
-
"sid-principal-input": {
|
|
43
|
-
name: "SidPrincipalInput",
|
|
44
|
-
props: [
|
|
45
|
-
"modelValue",
|
|
46
|
-
"disable",
|
|
47
|
-
"label",
|
|
48
|
-
"types",
|
|
49
|
-
"minUsernameLength",
|
|
50
|
-
"fieldError",
|
|
51
|
-
],
|
|
52
|
-
emits: ["update:modelValue", "update:principal-type"],
|
|
53
|
-
template:
|
|
54
|
-
'<input :value="modelValue" :data-error="fieldError || undefined" @input="$emit(\'update:modelValue\', $event.target.value)" />',
|
|
55
|
-
},
|
|
56
45
|
};
|
|
57
46
|
|
|
58
47
|
/** A refusal as the gRPC-Web transport raises it, with field violations. */
|
|
@@ -178,6 +167,30 @@ describe("SidRegistrationForm", () => {
|
|
|
178
167
|
);
|
|
179
168
|
});
|
|
180
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
|
+
|
|
181
194
|
it("submits the invite code when the installation is claimed", async () => {
|
|
182
195
|
registerFn.mockClear();
|
|
183
196
|
const w = mountForm({ initialInviteCode: "ABCD1234" });
|
|
@@ -143,6 +143,7 @@
|
|
|
143
143
|
data-test="principal"
|
|
144
144
|
@update:model-value="fieldErrors.principal = undefined"
|
|
145
145
|
@update:principal-type="principalType = $event"
|
|
146
|
+
@update:valid="principalValid = $event"
|
|
146
147
|
/>
|
|
147
148
|
|
|
148
149
|
<slot name="extra-fields" />
|
|
@@ -200,7 +201,7 @@
|
|
|
200
201
|
class="full-width"
|
|
201
202
|
:loading="loading"
|
|
202
203
|
:disable="
|
|
203
|
-
!
|
|
204
|
+
!principalValid ||
|
|
204
205
|
!password ||
|
|
205
206
|
(showConfirmPassword && !confirmPassword) ||
|
|
206
207
|
(claimRequired && !claimToken.trim()) ||
|
|
@@ -433,6 +434,7 @@ const PAINT_FALLBACK_MS = 250;
|
|
|
433
434
|
|
|
434
435
|
const identifier = ref("");
|
|
435
436
|
const principalType = ref<PrincipalType>("unknown");
|
|
437
|
+
const principalValid = ref(false);
|
|
436
438
|
const password = ref("");
|
|
437
439
|
const confirmPassword = ref("");
|
|
438
440
|
const showPassword = ref(false);
|
|
@@ -2,7 +2,7 @@ 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({
|
|
@@ -17,7 +17,7 @@ const stubs = {
|
|
|
17
17
|
],
|
|
18
18
|
template: "<div><slot name='prepend' /><slot name='append' /></div>",
|
|
19
19
|
}),
|
|
20
|
-
"q-icon": { template: "<span />" },
|
|
20
|
+
"q-icon": { props: ["name"], template: "<span :data-icon='name' />" },
|
|
21
21
|
"q-chip": { template: "<span><slot /></span>" },
|
|
22
22
|
};
|
|
23
23
|
|
|
@@ -38,31 +38,25 @@ describe("SidPrincipalInput", () => {
|
|
|
38
38
|
expect(input({ lazyRules: false }).props("lazyRules")).toBe(false);
|
|
39
39
|
});
|
|
40
40
|
|
|
41
|
-
//
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
expect(custom("")).toBe("Enter it");
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
it("does not require an optional field", () => {
|
|
53
|
-
const [required] = input({ required: false }).props("rules") as Rule[];
|
|
54
|
-
expect(required("")).toBe(true);
|
|
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
|
+
}
|
|
55
49
|
});
|
|
56
50
|
|
|
57
51
|
// A field that accepts only some principal types refuses the others before
|
|
58
52
|
// any call, naming what it accepts: a registration that takes email only
|
|
59
53
|
// must not let a username reach the server.
|
|
60
54
|
it("refuses a principal type it does not accept", () => {
|
|
61
|
-
const [
|
|
55
|
+
const [type] = input({ types: ["email"] }).props("rules") as Rule[];
|
|
62
56
|
expect(type("alice_wonder")).toBe("Enter an email address");
|
|
63
57
|
expect(type("+380501234567")).toBe("Enter an email address");
|
|
64
58
|
expect(type("alice@sid.example.com")).toBe(true);
|
|
65
|
-
const [
|
|
59
|
+
const [two] = input({ types: ["email", "username"] }).props(
|
|
66
60
|
"rules",
|
|
67
61
|
) as Rule[];
|
|
68
62
|
expect(two("+380501234567")).toBe("Enter an email address or a username");
|
|
@@ -73,24 +67,156 @@ describe("SidPrincipalInput", () => {
|
|
|
73
67
|
// username and the field asked for "email, phone, or username" instead of
|
|
74
68
|
// saying the minimum.
|
|
75
69
|
it("tells a too-short username the minimum length", () => {
|
|
76
|
-
const [
|
|
70
|
+
const [type] = input({ minUsernameLength: 6 }).props("rules") as Rule[];
|
|
77
71
|
expect(type("sid")).toBe("A username has at least 6 characters");
|
|
78
72
|
expect(type("sidsid")).toBe(true);
|
|
79
|
-
const [
|
|
73
|
+
const [login] = input().props("rules") as Rule[];
|
|
80
74
|
expect(login("sid")).toBe(true);
|
|
81
|
-
|
|
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(
|
|
122
|
+
"rules",
|
|
123
|
+
) as Rule[];
|
|
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"');
|
|
82
189
|
});
|
|
83
190
|
|
|
84
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.
|
|
85
195
|
it("labels the field by the types it accepts", () => {
|
|
86
|
-
expect(input().props("label")).toBe("Email, phone, or username");
|
|
87
196
|
expect(input({ types: ["email"] }).props("label")).toBe("Email");
|
|
88
|
-
expect(input({ types: ["
|
|
89
|
-
|
|
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
|
+
"Логин",
|
|
90
212
|
);
|
|
91
213
|
expect(
|
|
92
214
|
input({ types: ["email"], label: "Your email" }).props("label"),
|
|
93
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("");
|
|
94
220
|
});
|
|
95
221
|
|
|
96
222
|
// The chip naming the detected type is a text like the others: a
|
|
@@ -99,6 +225,7 @@ describe("SidPrincipalInput", () => {
|
|
|
99
225
|
const w = mount(SidPrincipalInput, {
|
|
100
226
|
props: {
|
|
101
227
|
modelValue: "alice@sid.example.com",
|
|
228
|
+
label: "Sign in with",
|
|
102
229
|
texts: { typeChip: (t: string) => `chip:${t}` },
|
|
103
230
|
},
|
|
104
231
|
global: { stubs },
|
|
@@ -106,6 +233,29 @@ describe("SidPrincipalInput", () => {
|
|
|
106
233
|
expect(w.text()).toContain("chip:email");
|
|
107
234
|
});
|
|
108
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);
|
|
257
|
+
});
|
|
258
|
+
|
|
109
259
|
// A refusal the server bound to this field is shown on it.
|
|
110
260
|
it("shows an error given from outside", () => {
|
|
111
261
|
const shown = input({
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
ref="inputRef"
|
|
4
4
|
v-model="model"
|
|
5
5
|
:label="fieldLabel"
|
|
6
|
-
:rules="[
|
|
6
|
+
:rules="[typeRule]"
|
|
7
7
|
:error="!!fieldError"
|
|
8
8
|
:error-message="fieldError ?? undefined"
|
|
9
9
|
outlined
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
<template #prepend>
|
|
19
19
|
<q-icon :name="iconName" :color="iconColor" size="24px" />
|
|
20
20
|
</template>
|
|
21
|
-
<template v-if="
|
|
21
|
+
<template v-if="showChip" #append>
|
|
22
22
|
<q-chip
|
|
23
23
|
dense
|
|
24
24
|
:color="chipColor"
|
|
@@ -35,11 +35,12 @@
|
|
|
35
35
|
<script setup lang="ts">
|
|
36
36
|
import { ref, computed, watch } from "vue";
|
|
37
37
|
import { isValidPhoneNumber } from "libphonenumber-js";
|
|
38
|
-
import {
|
|
38
|
+
import { isValidEmail } from "../utils/validators";
|
|
39
39
|
|
|
40
40
|
import {
|
|
41
41
|
ALL_PRINCIPAL_TYPES,
|
|
42
42
|
DEFAULT_PRINCIPAL_INPUT_TEXTS,
|
|
43
|
+
principalRecognizable,
|
|
43
44
|
type AcceptedPrincipalType,
|
|
44
45
|
type PrincipalInputTexts,
|
|
45
46
|
type PrincipalType,
|
|
@@ -56,15 +57,12 @@ interface Props {
|
|
|
56
57
|
*/
|
|
57
58
|
types?: AcceptedPrincipalType[];
|
|
58
59
|
minUsernameLength?: number;
|
|
59
|
-
required?: boolean;
|
|
60
60
|
/** The label; without one the field names the accepted types. */
|
|
61
61
|
label?: string;
|
|
62
62
|
/** A refusal the server bound to this field, shown on it until edited. */
|
|
63
63
|
fieldError?: string | null;
|
|
64
64
|
/** Replacements for the field's texts. */
|
|
65
65
|
texts?: Partial<PrincipalInputTexts>;
|
|
66
|
-
/** What an empty required field says. */
|
|
67
|
-
requiredText?: string;
|
|
68
66
|
/**
|
|
69
67
|
* Validate once the field is left, then on every change (true), or on
|
|
70
68
|
* every change from the first keystroke (false). Default true: a field
|
|
@@ -76,13 +74,11 @@ interface Props {
|
|
|
76
74
|
const props = withDefaults(defineProps<Props>(), {
|
|
77
75
|
modelValue: "",
|
|
78
76
|
types: () => [...ALL_PRINCIPAL_TYPES],
|
|
79
|
-
//
|
|
77
|
+
// Code points of the NFC display form; the server's minimum decides.
|
|
80
78
|
minUsernameLength: 3,
|
|
81
|
-
required: true,
|
|
82
79
|
label: undefined,
|
|
83
80
|
fieldError: null,
|
|
84
81
|
texts: () => ({}),
|
|
85
|
-
requiredText: REQUIRED_FIELD_TEXT,
|
|
86
82
|
lazyRules: true,
|
|
87
83
|
});
|
|
88
84
|
|
|
@@ -96,13 +92,26 @@ const accepted = computed(() =>
|
|
|
96
92
|
ALL_PRINCIPAL_TYPES.filter((t) => props.types.includes(t)),
|
|
97
93
|
);
|
|
98
94
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
+
});
|
|
102
109
|
|
|
103
110
|
const emit = defineEmits<{
|
|
104
111
|
"update:modelValue": [value: string];
|
|
105
112
|
"update:principalType": [type: PrincipalType];
|
|
113
|
+
/** Whether the value can be submitted: a recognized, accepted, well-formed principal. */
|
|
114
|
+
"update:valid": [valid: boolean];
|
|
106
115
|
}>();
|
|
107
116
|
|
|
108
117
|
const inputRef = ref<{ focus: () => void } | null>(null);
|
|
@@ -114,10 +123,13 @@ const model = computed({
|
|
|
114
123
|
|
|
115
124
|
const USERNAME_MAX_LENGTH = 32;
|
|
116
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
|
+
|
|
117
129
|
function detectType(value: string): PrincipalType {
|
|
118
|
-
|
|
130
|
+
// One or two characters are nobody yet: no type is read from them.
|
|
131
|
+
if (!principalRecognizable(value)) return "unknown";
|
|
119
132
|
const trimmed = value.trim();
|
|
120
|
-
if (!trimmed) return "unknown";
|
|
121
133
|
|
|
122
134
|
// Phone: starts with +
|
|
123
135
|
if (trimmed.startsWith("+")) return "phone";
|
|
@@ -125,12 +137,9 @@ function detectType(value: string): PrincipalType {
|
|
|
125
137
|
// Email: contains @ (checked after phone to exclude edge cases)
|
|
126
138
|
if (trimmed.includes("@")) return "email";
|
|
127
139
|
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
|
|
131
|
-
if (/^[a-zA-Z0-9_]+$/.test(trimmed)) return "username";
|
|
132
|
-
|
|
133
|
-
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";
|
|
134
143
|
}
|
|
135
144
|
|
|
136
145
|
const detectedType = computed(() => detectType(model.value));
|
|
@@ -146,13 +155,14 @@ function onInput(value: string | number | null) {
|
|
|
146
155
|
|
|
147
156
|
// --- Validation rules ---
|
|
148
157
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
+
*/
|
|
152
162
|
const typeRule = (val: string): string | true => {
|
|
153
|
-
if (!val) return true; // required rule handles empty
|
|
154
163
|
const type = detectType(val);
|
|
155
|
-
if (type === "unknown"
|
|
164
|
+
if (type === "unknown") return true;
|
|
165
|
+
if (!accepted.value.includes(type)) {
|
|
156
166
|
return text.value.enterAccepted(accepted.value);
|
|
157
167
|
}
|
|
158
168
|
|
|
@@ -162,10 +172,15 @@ const typeRule = (val: string): string | true => {
|
|
|
162
172
|
case "phone":
|
|
163
173
|
return isValidPhoneNumber(val) || text.value.invalidPhone;
|
|
164
174
|
case "username": {
|
|
165
|
-
const name = val
|
|
166
|
-
if (name
|
|
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)
|
|
167
182
|
return text.value.usernameTooShort(props.minUsernameLength);
|
|
168
|
-
if (
|
|
183
|
+
if (length > USERNAME_MAX_LENGTH)
|
|
169
184
|
return text.value.usernameTooLong(USERNAME_MAX_LENGTH);
|
|
170
185
|
if (name.startsWith("_") || name.endsWith("_"))
|
|
171
186
|
return text.value.usernameUnderscoreEdge;
|
|
@@ -175,10 +190,46 @@ const typeRule = (val: string): string | true => {
|
|
|
175
190
|
}
|
|
176
191
|
};
|
|
177
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
|
+
|
|
178
207
|
// --- Dynamic icon & type ---
|
|
179
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
|
+
|
|
180
231
|
const iconName = computed(() => {
|
|
181
|
-
switch (
|
|
232
|
+
switch (iconType.value) {
|
|
182
233
|
case "email":
|
|
183
234
|
return "sym_o_mail";
|
|
184
235
|
case "phone":
|
|
@@ -191,7 +242,7 @@ const iconName = computed(() => {
|
|
|
191
242
|
});
|
|
192
243
|
|
|
193
244
|
const iconColor = computed(() =>
|
|
194
|
-
|
|
245
|
+
shownType.value === "unknown" ? "grey-6" : "primary",
|
|
195
246
|
);
|
|
196
247
|
|
|
197
248
|
// inputmode controls mobile keyboard WITHOUT triggering browser autofill.
|
|
@@ -211,13 +262,11 @@ const inputMode = computed(() => {
|
|
|
211
262
|
});
|
|
212
263
|
|
|
213
264
|
const chipLabel = computed(() =>
|
|
214
|
-
|
|
215
|
-
? ""
|
|
216
|
-
: text.value.typeChip(detectedType.value),
|
|
265
|
+
shownType.value === "unknown" ? "" : text.value.typeChip(shownType.value),
|
|
217
266
|
);
|
|
218
267
|
|
|
219
268
|
const chipColor = computed(() => {
|
|
220
|
-
switch (
|
|
269
|
+
switch (shownType.value) {
|
|
221
270
|
case "email":
|
|
222
271
|
return "deep-orange";
|
|
223
272
|
case "phone":
|
|
@@ -235,5 +284,5 @@ function focus() {
|
|
|
235
284
|
inputRef.value?.focus();
|
|
236
285
|
}
|
|
237
286
|
|
|
238
|
-
defineExpose({ focus, detectedType });
|
|
287
|
+
defineExpose({ focus, detectedType, valid });
|
|
239
288
|
</script>
|
package/src/quasar/index.ts
CHANGED
|
@@ -13,6 +13,8 @@ export type {
|
|
|
13
13
|
export {
|
|
14
14
|
ALL_PRINCIPAL_TYPES,
|
|
15
15
|
DEFAULT_PRINCIPAL_INPUT_TEXTS,
|
|
16
|
+
MIN_PRINCIPAL_LENGTH,
|
|
17
|
+
principalRecognizable,
|
|
16
18
|
} from "./principalInputTexts";
|
|
17
19
|
export { default as SidPasswordCeremony } from "./SidPasswordCeremony.vue";
|
|
18
20
|
export type { CeremonyOutcome } from "./SidPasswordCeremony.vue";
|
|
@@ -12,10 +12,27 @@ export const ALL_PRINCIPAL_TYPES: readonly AcceptedPrincipalType[] = [
|
|
|
12
12
|
"username",
|
|
13
13
|
];
|
|
14
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
|
+
|
|
15
27
|
/** What the field says; each text can be replaced (translated) by the form. */
|
|
16
28
|
export interface PrincipalInputTexts {
|
|
17
|
-
/**
|
|
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
|
+
*/
|
|
18
33
|
label: (types: AcceptedPrincipalType[]) => string;
|
|
34
|
+
/** The label of a field of several types before its input shows which. */
|
|
35
|
+
login: string;
|
|
19
36
|
/** A value of a type the field does not accept, or of no type at all. */
|
|
20
37
|
enterAccepted: (types: AcceptedPrincipalType[]) => string;
|
|
21
38
|
/** The chip naming the type the value was recognized as. */
|
|
@@ -28,6 +45,10 @@ export interface PrincipalInputTexts {
|
|
|
28
45
|
usernameTooLong: (max: number) => string;
|
|
29
46
|
usernameUnderscoreEdge: string;
|
|
30
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;
|
|
31
52
|
}
|
|
32
53
|
|
|
33
54
|
/** `a`, `a or b`, `a, b, or c`. */
|
|
@@ -54,6 +75,7 @@ export const DEFAULT_PRINCIPAL_INPUT_TEXTS: PrincipalInputTexts = {
|
|
|
54
75
|
const text = list(types.map((t) => LABEL_WORDS[t]));
|
|
55
76
|
return text.charAt(0).toUpperCase() + text.slice(1);
|
|
56
77
|
},
|
|
78
|
+
login: "Login",
|
|
57
79
|
enterAccepted: (types) => `Enter ${list(types.map((t) => ENTER_WORDS[t]))}`,
|
|
58
80
|
typeChip: (type) => LABEL_WORDS[type],
|
|
59
81
|
loginSubtitle: (types) =>
|
|
@@ -65,4 +87,7 @@ export const DEFAULT_PRINCIPAL_INPUT_TEXTS: PrincipalInputTexts = {
|
|
|
65
87
|
usernameUnderscoreEdge: "A username cannot start or end with an underscore",
|
|
66
88
|
usernameDoubleUnderscore:
|
|
67
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",
|
|
68
93
|
};
|
|
@@ -17,10 +17,18 @@ describe("detectLoginInputType", () => {
|
|
|
17
17
|
expect(detectLoginInputType("alice")).toBe("username");
|
|
18
18
|
expect(detectLoginInputType("bob_smith")).toBe("username");
|
|
19
19
|
expect(detectLoginInputType("dev42")).toBe("username");
|
|
20
|
-
// Uppercase accepted
|
|
20
|
+
// Uppercase accepted; the server derives the key.
|
|
21
21
|
expect(detectLoginInputType("Admin123")).toBe("username");
|
|
22
22
|
});
|
|
23
23
|
|
|
24
|
+
it("detects a username in any script, counted in code points", () => {
|
|
25
|
+
expect(detectLoginInputType("Иван")).toBe("username");
|
|
26
|
+
expect(detectLoginInputType("Иван_Петров", 6)).toBe("username");
|
|
27
|
+
expect(detectLoginInputType("Щукин", 6)).toBe("unknown"); // 5 < 6
|
|
28
|
+
expect(detectLoginInputType("ЩЩ")).toBe("unknown"); // 2 < 3
|
|
29
|
+
expect(detectLoginInputType("Иван Петров")).toBe("unknown"); // a space
|
|
30
|
+
});
|
|
31
|
+
|
|
24
32
|
it("detects username with minLength=6 (registration)", () => {
|
|
25
33
|
expect(detectLoginInputType("alice", 6)).toBe("unknown"); // too short
|
|
26
34
|
expect(detectLoginInputType("alicew", 6)).toBe("username"); // exactly 6
|
|
@@ -126,13 +134,42 @@ describe("normalizePrincipal", () => {
|
|
|
126
134
|
expect(result.display).toBe("+380501234567");
|
|
127
135
|
});
|
|
128
136
|
|
|
129
|
-
|
|
137
|
+
// Regression: the client lowercased a username before sending it, so the
|
|
138
|
+
// server never saw the spelling the user typed and could not keep it as
|
|
139
|
+
// the display form. The server derives the key, as it does for email.
|
|
140
|
+
it("sends a username as typed, case kept", () => {
|
|
130
141
|
const result = normalizePrincipal("Alice_Bob");
|
|
131
142
|
expect(result.type).toBe("username");
|
|
132
|
-
expect(result.value).toBe("
|
|
143
|
+
expect(result.value).toBe("Alice_Bob");
|
|
133
144
|
expect(result.display).toBe("Alice_Bob");
|
|
134
145
|
});
|
|
135
146
|
|
|
147
|
+
// Whitespace is never part of a name and is not repaired: a username with
|
|
148
|
+
// surrounding spaces is refused, not trimmed into another name.
|
|
149
|
+
it("refuses a username with surrounding whitespace", () => {
|
|
150
|
+
for (const input of [" Alice_Bob ", "Alice_Bob ", "\tAlice_Bob"]) {
|
|
151
|
+
expect(() => normalizePrincipal(input)).toThrow("Cannot determine");
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// Regression: a letter outside a-z made the name no type at all, so the
|
|
156
|
+
// field refused it before the server could apply its repertoire.
|
|
157
|
+
it("sends a username in any script as typed", () => {
|
|
158
|
+
for (const name of ["Иван_Петров", "Ωmega42", "Jürgen"]) {
|
|
159
|
+
const result = normalizePrincipal(name);
|
|
160
|
+
expect(result.type).toBe("username");
|
|
161
|
+
expect(result.value).toBe(name);
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("sends a username unnormalized and counts it after NFC", () => {
|
|
166
|
+
// "Jürgen" with ü as u + combining diaeresis: 7 code points, 6 in NFC.
|
|
167
|
+
const decomposed = "Jürgen";
|
|
168
|
+
const result = normalizePrincipal(decomposed, 6);
|
|
169
|
+
expect(result.value).toBe(decomposed);
|
|
170
|
+
expect(() => normalizePrincipal("Щукин", 6)).toThrow("Cannot determine");
|
|
171
|
+
});
|
|
172
|
+
|
|
136
173
|
it("rejects username with leading/trailing underscore (detected as unknown)", () => {
|
|
137
174
|
// Leading/trailing _ blocked at detection level → "Cannot determine"
|
|
138
175
|
expect(() => normalizePrincipal("_alice_bob")).toThrow("Cannot determine");
|
|
@@ -14,17 +14,18 @@ export interface NormalizedPrincipal {
|
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* - no consecutive underscores (__)
|
|
22
|
-
*
|
|
23
|
-
* For detection (prematch) we use a relaxed version:
|
|
24
|
-
* - accepts minLength (3 for login, 6 for registration)
|
|
25
|
-
* - case-insensitive (will be lowercased on normalization)
|
|
17
|
+
* A global username as the user types it: letters of any script, combining
|
|
18
|
+
* marks, digits and `_`, at most 32 code points in NFC, no `_` at either
|
|
19
|
+
* end. It is sent exactly as typed (nothing trimmed or normalized); the
|
|
20
|
+
* server applies its repertoire and minimum and derives the lookup key.
|
|
26
21
|
*/
|
|
27
22
|
const USERNAME_MAX_LENGTH = 32;
|
|
23
|
+
const USERNAME_SHAPE = /^[\p{L}\p{M}\p{N}_]+$/u;
|
|
24
|
+
|
|
25
|
+
/** The length of `name` in code points of its NFC form. */
|
|
26
|
+
function codePoints(name: string): number {
|
|
27
|
+
return [...name.normalize("NFC")].length;
|
|
28
|
+
}
|
|
28
29
|
|
|
29
30
|
/**
|
|
30
31
|
* Detect principal type from user input.
|
|
@@ -63,11 +64,16 @@ export function detectLoginInputType(
|
|
|
63
64
|
return "unknown";
|
|
64
65
|
}
|
|
65
66
|
|
|
66
|
-
// Global username: no #, no @, no
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
67
|
+
// Global username: no #, no @, no +. Judged on the input itself, not the
|
|
68
|
+
// trimmed form: whitespace is never part of a name and is never repaired.
|
|
69
|
+
const length = codePoints(input);
|
|
70
|
+
if (
|
|
71
|
+
USERNAME_SHAPE.test(input) &&
|
|
72
|
+
length >= minUsernameLength &&
|
|
73
|
+
length <= USERNAME_MAX_LENGTH &&
|
|
74
|
+
!input.startsWith("_") &&
|
|
75
|
+
!input.endsWith("_")
|
|
76
|
+
) {
|
|
71
77
|
return "username";
|
|
72
78
|
}
|
|
73
79
|
|
|
@@ -79,7 +85,7 @@ export function detectLoginInputType(
|
|
|
79
85
|
*
|
|
80
86
|
* - Email: as typed (trimmed); the server applies the email policy
|
|
81
87
|
* - Phone: E.164 format via libphonenumber-js
|
|
82
|
-
* - Username:
|
|
88
|
+
* - Username: exactly as typed; a federated one trimmed and lowercased
|
|
83
89
|
*
|
|
84
90
|
* @param minUsernameLength - minimum chars for username (3 for login, 6 for register)
|
|
85
91
|
* Throws if value doesn't pass validation for detected type.
|
|
@@ -89,7 +95,7 @@ export function normalizePrincipal(
|
|
|
89
95
|
minUsernameLength = 3,
|
|
90
96
|
): NormalizedPrincipal {
|
|
91
97
|
const trimmed = input.trim();
|
|
92
|
-
const type = detectLoginInputType(
|
|
98
|
+
const type = detectLoginInputType(input, minUsernameLength);
|
|
93
99
|
|
|
94
100
|
switch (type) {
|
|
95
101
|
case "email": {
|
|
@@ -135,20 +141,13 @@ export function normalizePrincipal(
|
|
|
135
141
|
return { value: normalized, display: trimmed, type: "username" };
|
|
136
142
|
}
|
|
137
143
|
|
|
138
|
-
// Global username
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
);
|
|
143
|
-
if (
|
|
144
|
-
!re.test(normalized) ||
|
|
145
|
-
normalized.startsWith("_") ||
|
|
146
|
-
normalized.endsWith("_") ||
|
|
147
|
-
normalized.includes("__")
|
|
148
|
-
) {
|
|
144
|
+
// Global username: detection checked its shape and length on the
|
|
145
|
+
// input itself; sent exactly as typed, the server normalizes it and
|
|
146
|
+
// derives the key.
|
|
147
|
+
if (input.includes("__")) {
|
|
149
148
|
throw new Error("Invalid username");
|
|
150
149
|
}
|
|
151
|
-
return { value:
|
|
150
|
+
return { value: input, display: input, type: "username" };
|
|
152
151
|
}
|
|
153
152
|
|
|
154
153
|
default:
|