@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
|
@@ -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:
|
|
@@ -4,6 +4,7 @@ import { base64encode } from "@protobuf-ts/runtime";
|
|
|
4
4
|
import { Any } from "@structured-id/proto/google/protobuf/any";
|
|
5
5
|
import { Status } from "@structured-id/proto/google/rpc/status";
|
|
6
6
|
import {
|
|
7
|
+
BadRequest,
|
|
7
8
|
ErrorInfo,
|
|
8
9
|
LocalizedMessage,
|
|
9
10
|
} from "@structured-id/proto/google/rpc/error_details";
|
|
@@ -142,6 +143,47 @@ describe("rpcRefusal", () => {
|
|
|
142
143
|
);
|
|
143
144
|
});
|
|
144
145
|
|
|
146
|
+
// A refusal that names the fields it concerns lets the form show each
|
|
147
|
+
// text under its field: the server's text for people when it sent one,
|
|
148
|
+
// else its description.
|
|
149
|
+
it("reads the field violations", () => {
|
|
150
|
+
const bad = Any.pack(
|
|
151
|
+
BadRequest.create({
|
|
152
|
+
fieldViolations: [
|
|
153
|
+
{
|
|
154
|
+
field: "principal",
|
|
155
|
+
description: "registration here accepts: email",
|
|
156
|
+
reason: "PRINCIPAL_TYPE_NOT_ACCEPTED",
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
field: "claim_token",
|
|
160
|
+
description: "not the installation's token",
|
|
161
|
+
reason: "",
|
|
162
|
+
localizedMessage: LocalizedMessage.create({
|
|
163
|
+
locale: "en",
|
|
164
|
+
message: "This claim token is not valid.",
|
|
165
|
+
}),
|
|
166
|
+
},
|
|
167
|
+
],
|
|
168
|
+
}),
|
|
169
|
+
BadRequest,
|
|
170
|
+
);
|
|
171
|
+
const refusal = rpcRefusal(refused([info, bad]));
|
|
172
|
+
expect(refusal?.fieldViolations).toEqual([
|
|
173
|
+
{
|
|
174
|
+
field: "principal",
|
|
175
|
+
reason: "PRINCIPAL_TYPE_NOT_ACCEPTED",
|
|
176
|
+
text: "registration here accepts: email",
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
field: "claim_token",
|
|
180
|
+
reason: null,
|
|
181
|
+
text: "This claim token is not valid.",
|
|
182
|
+
},
|
|
183
|
+
]);
|
|
184
|
+
expect(rpcRefusal(refused([info]))?.fieldViolations).toEqual([]);
|
|
185
|
+
});
|
|
186
|
+
|
|
145
187
|
it("survives an unreadable trailer", () => {
|
|
146
188
|
const err = new RpcError("m", "INTERNAL", {
|
|
147
189
|
"grpc-status-details-bin": "!!!",
|
package/src/utils/rpcRefusal.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { base64decode } from "@protobuf-ts/runtime";
|
|
|
8
8
|
import { Any } from "@structured-id/proto/google/protobuf/any";
|
|
9
9
|
import { Status } from "@structured-id/proto/google/rpc/status";
|
|
10
10
|
import {
|
|
11
|
+
BadRequest,
|
|
11
12
|
ErrorInfo,
|
|
12
13
|
LocalizedMessage,
|
|
13
14
|
} from "@structured-id/proto/google/rpc/error_details";
|
|
@@ -28,6 +29,18 @@ export interface RpcRefusal {
|
|
|
28
29
|
message: string;
|
|
29
30
|
/** The server's text for people (`LocalizedMessage`), when it sent one. */
|
|
30
31
|
localized: string | null;
|
|
32
|
+
/** The request fields the refusal names (`BadRequest`), in the server's order. */
|
|
33
|
+
fieldViolations: FieldViolation[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** One request field a refusal names. */
|
|
37
|
+
export interface FieldViolation {
|
|
38
|
+
/** The field's path in the request, as the proto names it (`principal`). */
|
|
39
|
+
field: string;
|
|
40
|
+
/** The field-level reason, when the server gave one. */
|
|
41
|
+
reason: string | null;
|
|
42
|
+
/** What to show under the field: the server's text for people, else its description. */
|
|
43
|
+
text: string;
|
|
31
44
|
}
|
|
32
45
|
|
|
33
46
|
/**
|
|
@@ -50,6 +63,7 @@ export function rpcRefusal(err: unknown): RpcRefusal | null {
|
|
|
50
63
|
metadata: {},
|
|
51
64
|
message: decodeStatusMessage(err.message),
|
|
52
65
|
localized: null,
|
|
66
|
+
fieldViolations: [],
|
|
53
67
|
};
|
|
54
68
|
for (const detail of statusDetails(err.meta[DETAILS_KEY])) {
|
|
55
69
|
if (Any.contains(detail, ErrorInfo)) {
|
|
@@ -61,6 +75,14 @@ export function rpcRefusal(err: unknown): RpcRefusal | null {
|
|
|
61
75
|
info.domain === SID_ERROR_DOMAIN ? sidReason(info.reason) : null;
|
|
62
76
|
} else if (Any.contains(detail, LocalizedMessage)) {
|
|
63
77
|
refusal.localized = Any.unpack(detail, LocalizedMessage).message;
|
|
78
|
+
} else if (Any.contains(detail, BadRequest)) {
|
|
79
|
+
for (const v of Any.unpack(detail, BadRequest).fieldViolations) {
|
|
80
|
+
refusal.fieldViolations.push({
|
|
81
|
+
field: v.field,
|
|
82
|
+
reason: v.reason || null,
|
|
83
|
+
text: v.localizedMessage?.message || v.description,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
64
86
|
}
|
|
65
87
|
}
|
|
66
88
|
return refusal;
|