@structured-id/ui-core 4.1.0 → 4.3.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/README.md +29 -1
- package/package.json +4 -1
- package/src/assets.d.ts +5 -0
- package/src/index.ts +9 -0
- package/src/profile/components/SidLoginForm.test.ts +53 -9
- package/src/profile/components/SidLoginForm.vue +3 -1
- package/src/profile/components/SidRegistrationForm.test.ts +34 -16
- package/src/profile/components/SidRegistrationForm.vue +3 -1
- package/src/quasar/SidPrincipalInput.autoload.test.ts +41 -0
- package/src/quasar/SidPrincipalInput.test.ts +195 -26
- package/src/quasar/SidPrincipalInput.vue +108 -37
- package/src/quasar/index.ts +2 -0
- package/src/quasar/principalInputTexts.ts +26 -1
- package/src/utils/email.test.ts +107 -0
- package/src/utils/email.testing.ts +19 -0
- package/src/utils/email.ts +165 -0
- package/src/utils/index.ts +13 -1
- package/src/utils/normalizePrincipal.test.ts +46 -4
- package/src/utils/normalizePrincipal.ts +35 -31
- package/src/utils/validators.test.ts +0 -23
- package/src/utils/validators.ts +0 -17
|
@@ -1,5 +1,10 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
1
|
+
import { beforeAll, describe, it, expect } from "vitest";
|
|
2
2
|
import { detectLoginInputType, normalizePrincipal } from "./normalizePrincipal";
|
|
3
|
+
import { initEmail } from "./email";
|
|
4
|
+
import { TEST_PSL } from "./email.testing";
|
|
5
|
+
|
|
6
|
+
// As an application does at start: the email parser loaded, LOCAL namespace.
|
|
7
|
+
beforeAll(() => initEmail({ psl: TEST_PSL }));
|
|
3
8
|
|
|
4
9
|
describe("detectLoginInputType", () => {
|
|
5
10
|
it("detects email", () => {
|
|
@@ -17,10 +22,18 @@ describe("detectLoginInputType", () => {
|
|
|
17
22
|
expect(detectLoginInputType("alice")).toBe("username");
|
|
18
23
|
expect(detectLoginInputType("bob_smith")).toBe("username");
|
|
19
24
|
expect(detectLoginInputType("dev42")).toBe("username");
|
|
20
|
-
// Uppercase accepted
|
|
25
|
+
// Uppercase accepted; the server derives the key.
|
|
21
26
|
expect(detectLoginInputType("Admin123")).toBe("username");
|
|
22
27
|
});
|
|
23
28
|
|
|
29
|
+
it("detects a username in any script, counted in code points", () => {
|
|
30
|
+
expect(detectLoginInputType("Иван")).toBe("username");
|
|
31
|
+
expect(detectLoginInputType("Иван_Петров", 6)).toBe("username");
|
|
32
|
+
expect(detectLoginInputType("Щукин", 6)).toBe("unknown"); // 5 < 6
|
|
33
|
+
expect(detectLoginInputType("ЩЩ")).toBe("unknown"); // 2 < 3
|
|
34
|
+
expect(detectLoginInputType("Иван Петров")).toBe("unknown"); // a space
|
|
35
|
+
});
|
|
36
|
+
|
|
24
37
|
it("detects username with minLength=6 (registration)", () => {
|
|
25
38
|
expect(detectLoginInputType("alice", 6)).toBe("unknown"); // too short
|
|
26
39
|
expect(detectLoginInputType("alicew", 6)).toBe("username"); // exactly 6
|
|
@@ -126,13 +139,42 @@ describe("normalizePrincipal", () => {
|
|
|
126
139
|
expect(result.display).toBe("+380501234567");
|
|
127
140
|
});
|
|
128
141
|
|
|
129
|
-
|
|
142
|
+
// Regression: the client lowercased a username before sending it, so the
|
|
143
|
+
// server never saw the spelling the user typed and could not keep it as
|
|
144
|
+
// the display form. The server derives the key, as it does for email.
|
|
145
|
+
it("sends a username as typed, case kept", () => {
|
|
130
146
|
const result = normalizePrincipal("Alice_Bob");
|
|
131
147
|
expect(result.type).toBe("username");
|
|
132
|
-
expect(result.value).toBe("
|
|
148
|
+
expect(result.value).toBe("Alice_Bob");
|
|
133
149
|
expect(result.display).toBe("Alice_Bob");
|
|
134
150
|
});
|
|
135
151
|
|
|
152
|
+
// Whitespace is never part of a name and is not repaired: a username with
|
|
153
|
+
// surrounding spaces is refused, not trimmed into another name.
|
|
154
|
+
it("refuses a username with surrounding whitespace", () => {
|
|
155
|
+
for (const input of [" Alice_Bob ", "Alice_Bob ", "\tAlice_Bob"]) {
|
|
156
|
+
expect(() => normalizePrincipal(input)).toThrow("Cannot determine");
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// Regression: a letter outside a-z made the name no type at all, so the
|
|
161
|
+
// field refused it before the server could apply its repertoire.
|
|
162
|
+
it("sends a username in any script as typed", () => {
|
|
163
|
+
for (const name of ["Иван_Петров", "Ωmega42", "Jürgen"]) {
|
|
164
|
+
const result = normalizePrincipal(name);
|
|
165
|
+
expect(result.type).toBe("username");
|
|
166
|
+
expect(result.value).toBe(name);
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("sends a username unnormalized and counts it after NFC", () => {
|
|
171
|
+
// "Jürgen" with ü as u + combining diaeresis: 7 code points, 6 in NFC.
|
|
172
|
+
const decomposed = "Jürgen";
|
|
173
|
+
const result = normalizePrincipal(decomposed, 6);
|
|
174
|
+
expect(result.value).toBe(decomposed);
|
|
175
|
+
expect(() => normalizePrincipal("Щукин", 6)).toThrow("Cannot determine");
|
|
176
|
+
});
|
|
177
|
+
|
|
136
178
|
it("rejects username with leading/trailing underscore (detected as unknown)", () => {
|
|
137
179
|
// Leading/trailing _ blocked at detection level → "Cannot determine"
|
|
138
180
|
expect(() => normalizePrincipal("_alice_bob")).toThrow("Cannot determine");
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import parsePhoneNumberFromString, {
|
|
2
2
|
isValidPhoneNumber,
|
|
3
3
|
} from "libphonenumber-js";
|
|
4
|
-
import {
|
|
4
|
+
import { checkEmail } from "./email";
|
|
5
5
|
|
|
6
6
|
export type LoginInputType = "email" | "phone" | "username" | "unknown";
|
|
7
7
|
|
|
@@ -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,13 +95,18 @@ 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": {
|
|
96
102
|
// Sent as typed: the server validates it, derives the lookup key and
|
|
97
|
-
// keeps this spelling as the address to mail.
|
|
98
|
-
|
|
103
|
+
// keeps this spelling as the address to mail. Checked here under the
|
|
104
|
+
// application's namespace (initEmail), as the server would.
|
|
105
|
+
const check = checkEmail(trimmed);
|
|
106
|
+
if (check === "pending") {
|
|
107
|
+
throw new Error("Email check not loaded: call initEmail() first");
|
|
108
|
+
}
|
|
109
|
+
if (check === "invalid") {
|
|
99
110
|
throw new Error("Invalid email address");
|
|
100
111
|
}
|
|
101
112
|
return { value: trimmed, display: trimmed, type: "email" };
|
|
@@ -135,20 +146,13 @@ export function normalizePrincipal(
|
|
|
135
146
|
return { value: normalized, display: trimmed, type: "username" };
|
|
136
147
|
}
|
|
137
148
|
|
|
138
|
-
// Global username
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
);
|
|
143
|
-
if (
|
|
144
|
-
!re.test(normalized) ||
|
|
145
|
-
normalized.startsWith("_") ||
|
|
146
|
-
normalized.endsWith("_") ||
|
|
147
|
-
normalized.includes("__")
|
|
148
|
-
) {
|
|
149
|
+
// Global username: detection checked its shape and length on the
|
|
150
|
+
// input itself; sent exactly as typed, the server normalizes it and
|
|
151
|
+
// derives the key.
|
|
152
|
+
if (input.includes("__")) {
|
|
149
153
|
throw new Error("Invalid username");
|
|
150
154
|
}
|
|
151
|
-
return { value:
|
|
155
|
+
return { value: input, display: input, type: "username" };
|
|
152
156
|
}
|
|
153
157
|
|
|
154
158
|
default:
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest";
|
|
2
2
|
import {
|
|
3
|
-
isValidEmail,
|
|
4
3
|
isValidPhone,
|
|
5
4
|
isValidUuid,
|
|
6
5
|
isValidUsername,
|
|
@@ -9,28 +8,6 @@ import {
|
|
|
9
8
|
isRequired,
|
|
10
9
|
} from "./validators";
|
|
11
10
|
|
|
12
|
-
describe("isValidEmail", () => {
|
|
13
|
-
it("accepts valid emails", () => {
|
|
14
|
-
expect(isValidEmail("user@example.com")).toBe(true);
|
|
15
|
-
expect(isValidEmail("a.b+c@d.co")).toBe(true);
|
|
16
|
-
});
|
|
17
|
-
it("rejects invalid emails", () => {
|
|
18
|
-
expect(isValidEmail("")).toBe(false);
|
|
19
|
-
expect(isValidEmail("noat")).toBe(false);
|
|
20
|
-
expect(isValidEmail("@no-local.com")).toBe(false);
|
|
21
|
-
expect(isValidEmail("spaces in@email.com")).toBe(false);
|
|
22
|
-
expect(isValidEmail("user@")).toBe(false);
|
|
23
|
-
expect(isValidEmail("user@exa mple.com")).toBe(false);
|
|
24
|
-
});
|
|
25
|
-
// Regression: a dot was required in the domain and quoted local parts were
|
|
26
|
-
// refused, so the client turned away addresses the server's policy admits.
|
|
27
|
-
it("leaves admission to the server's email policy", () => {
|
|
28
|
-
expect(isValidEmail("admin@printer")).toBe(true);
|
|
29
|
-
expect(isValidEmail('"a b"@example.com')).toBe(true);
|
|
30
|
-
expect(isValidEmail('"a@b"@example.com')).toBe(true);
|
|
31
|
-
});
|
|
32
|
-
});
|
|
33
|
-
|
|
34
11
|
describe("isValidPhone", () => {
|
|
35
12
|
it("accepts E.164 format", () => {
|
|
36
13
|
expect(isValidPhone("+380501234567")).toBe(true);
|
package/src/utils/validators.ts
CHANGED
|
@@ -1,20 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Whether `value` has the shape of one mailbox: a local part and a domain
|
|
3
|
-
* around the last `@`, whitespace only inside a quoted local part. Advisory:
|
|
4
|
-
* the server's email policy decides admission (internal single-label domains
|
|
5
|
-
* and quoted local parts may be admitted), so this refuses only what can
|
|
6
|
-
* never be an address.
|
|
7
|
-
*/
|
|
8
|
-
export function isValidEmail(value: string): boolean {
|
|
9
|
-
const at = value.lastIndexOf("@");
|
|
10
|
-
if (at <= 0 || at === value.length - 1) return false;
|
|
11
|
-
const local = value.slice(0, at);
|
|
12
|
-
const domain = value.slice(at + 1);
|
|
13
|
-
const quoted =
|
|
14
|
-
local.length > 1 && local.startsWith('"') && local.endsWith('"');
|
|
15
|
-
return !/\s/.test(domain) && (quoted || !/\s/.test(local));
|
|
16
|
-
}
|
|
17
|
-
|
|
18
1
|
/** Phone number validation (E.164 format). */
|
|
19
2
|
export function isValidPhone(value: string): boolean {
|
|
20
3
|
return /^\+[1-9]\d{6,14}$/.test(value);
|