@structured-id/ui-core 4.2.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 +6 -1
- package/src/profile/components/SidRegistrationForm.test.ts +6 -1
- package/src/quasar/SidPrincipalInput.autoload.test.ts +41 -0
- package/src/quasar/SidPrincipalInput.test.ts +21 -2
- package/src/quasar/SidPrincipalInput.vue +25 -3
- 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 +6 -1
- package/src/utils/normalizePrincipal.ts +8 -3
- package/src/utils/validators.test.ts +0 -23
- package/src/utils/validators.ts +0 -17
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@ Shared UI package for StructuredID applications: Vue 3 composables, Pinia stores
|
|
|
6
6
|
|
|
7
7
|
- **Composables** -- `createAuth` (password ceremonies over an injected ZKPP client), `useAuthApi`, `useAccountApi`, `useIdentityApi`, `useSecurityLevel`, transport setup (`initGrpc`, `initAccountApi`)
|
|
8
8
|
- **Components** -- `SidLoginForm`, `SidRegistrationForm`, `SidMfaChallenge`, `SidPrincipalInput`, profile management pages
|
|
9
|
-
- **Utilities** -- `normalizePrincipal`, `detectLoginInputType`, `rpcRefusal`, validators, formatters
|
|
9
|
+
- **Utilities** -- `normalizePrincipal`, `detectLoginInputType`, `rpcRefusal`, email checks (`initEmail`, `checkEmail`, `isValidEmail`), validators, formatters
|
|
10
10
|
|
|
11
11
|
The generated messages and clients, the `google.rpc` error model and the gRPC-web / WebTransport transport live in `@structured-id/proto`; import them from there (`@structured-id/proto/sid/v1/identity/identity.client`, `@structured-id/proto/transport`).
|
|
12
12
|
|
|
@@ -34,6 +34,34 @@ const identity = new IdentityServiceClient(getTransport());
|
|
|
34
34
|
const { login } = createAuth(() => loadZkppClient());
|
|
35
35
|
```
|
|
36
36
|
|
|
37
|
+
### Email addresses
|
|
38
|
+
|
|
39
|
+
Email fields are checked with [`@structured-world/structured-email-address-ts`](https://www.npmjs.com/package/@structured-world/structured-email-address-ts),
|
|
40
|
+
the TypeScript port of the parser the server uses, checked byte-identical
|
|
41
|
+
against it. Its Public Suffix List trie is the application's own asset (the
|
|
42
|
+
bundler emits `psl.bin` from `@structured-world/structured-public-domains`),
|
|
43
|
+
served by the application's host and cached in IndexedDB; where it is served
|
|
44
|
+
from is a setting (`psl.url`), never a third-party CDN. Each namespace's admission mirrors the
|
|
45
|
+
server's email policy: `LOCAL` (an installation's own accounts: internal and
|
|
46
|
+
internationalized domains), `CORPORATE_BASELINE`, `GLOBAL_PERSONAL` (ASCII, no
|
|
47
|
+
IDN, a public mail domain). The server still decides; the field refuses only
|
|
48
|
+
what that namespace never admits.
|
|
49
|
+
|
|
50
|
+
Load the parser once at start and name the application's namespace (`LOCAL`
|
|
51
|
+
when omitted). The parser is loaded lazily as its own chunk; until it is,
|
|
52
|
+
`checkEmail` answers `pending` and `SidPrincipalInput` keeps the form's submit
|
|
53
|
+
disabled. A field accepting email starts the load itself if the application
|
|
54
|
+
did not.
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
import { GLOBAL_PERSONAL, initEmail } from "@structured-id/ui-core";
|
|
58
|
+
|
|
59
|
+
// The trie from the application's own copy...
|
|
60
|
+
await initEmail({ policy: GLOBAL_PERSONAL });
|
|
61
|
+
// ...or from where the deployment's configuration says it is served.
|
|
62
|
+
await initEmail({ policy: GLOBAL_PERSONAL, psl: { url: config.pslUrl } });
|
|
63
|
+
```
|
|
64
|
+
|
|
37
65
|
## Development
|
|
38
66
|
|
|
39
67
|
The package ships its TypeScript and Vue sources; the consuming application's
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@structured-id/ui-core",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.3.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public",
|
|
@@ -43,9 +43,12 @@
|
|
|
43
43
|
"@protobuf-ts/runtime": "^2.11.1",
|
|
44
44
|
"@protobuf-ts/runtime-rpc": "^2.11.1",
|
|
45
45
|
"@structured-id/proto": "^0.3.0",
|
|
46
|
+
"@structured-world/structured-email-address-ts": "^0.0.22",
|
|
47
|
+
"@structured-world/structured-public-domains": "0.0.x",
|
|
46
48
|
"libphonenumber-js": "^1.13.15"
|
|
47
49
|
},
|
|
48
50
|
"devDependencies": {
|
|
51
|
+
"@types/node": "^26",
|
|
49
52
|
"@vitejs/plugin-vue": "^6.0.9",
|
|
50
53
|
"@vue/compiler-dom": "^3.5.43",
|
|
51
54
|
"@vue/devtools-api": "^8.2.1",
|
package/src/assets.d.ts
ADDED
package/src/index.ts
CHANGED
|
@@ -36,7 +36,16 @@ export { useAuthStore } from "./stores/auth";
|
|
|
36
36
|
|
|
37
37
|
// Utils
|
|
38
38
|
export {
|
|
39
|
+
CORPORATE_BASELINE,
|
|
40
|
+
GLOBAL_PERSONAL,
|
|
41
|
+
LOCAL,
|
|
42
|
+
checkEmail,
|
|
43
|
+
emailPolicy,
|
|
44
|
+
emailReady,
|
|
45
|
+
initEmail,
|
|
39
46
|
isValidEmail,
|
|
47
|
+
type EmailCheck,
|
|
48
|
+
type EmailPolicy,
|
|
40
49
|
isValidPhone,
|
|
41
50
|
isValidUuid,
|
|
42
51
|
isValidUsername,
|
|
@@ -1,8 +1,13 @@
|
|
|
1
|
-
import { describe, it, expect, vi } from "vitest";
|
|
1
|
+
import { beforeAll, describe, it, expect, vi } from "vitest";
|
|
2
2
|
import { mount } from "@vue/test-utils";
|
|
3
3
|
import { defineComponent } from "vue";
|
|
4
4
|
import SidLoginForm from "./SidLoginForm.vue";
|
|
5
5
|
import { REQUIRED_FIELD_TEXT } from "../../utils/validators";
|
|
6
|
+
import { initEmail } from "../../utils/email";
|
|
7
|
+
import { TEST_PSL } from "../../utils/email.testing";
|
|
8
|
+
|
|
9
|
+
// As an application does at start: the email parser loaded, LOCAL namespace.
|
|
10
|
+
beforeAll(() => initEmail({ psl: TEST_PSL }));
|
|
6
11
|
|
|
7
12
|
// Stub all Quasar components as divs
|
|
8
13
|
const quasarStubs = {
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import { describe, it, expect, vi } from "vitest";
|
|
1
|
+
import { beforeAll, describe, it, expect, vi } from "vitest";
|
|
2
2
|
import { flushPromises, mount } from "@vue/test-utils";
|
|
3
|
+
import { initEmail } from "../../utils/email";
|
|
4
|
+
import { TEST_PSL } from "../../utils/email.testing";
|
|
3
5
|
import { RpcError } from "@protobuf-ts/runtime-rpc";
|
|
4
6
|
import { base64encode } from "@protobuf-ts/runtime";
|
|
5
7
|
import { Any } from "@structured-id/proto/google/protobuf/any";
|
|
@@ -10,6 +12,9 @@ import {
|
|
|
10
12
|
} from "@structured-id/proto/google/rpc/error_details";
|
|
11
13
|
import SidRegistrationForm from "./SidRegistrationForm.vue";
|
|
12
14
|
|
|
15
|
+
// As an application does at start: the email parser loaded, LOCAL namespace.
|
|
16
|
+
beforeAll(() => initEmail({ psl: TEST_PSL }));
|
|
17
|
+
|
|
13
18
|
const quasarStubs = {
|
|
14
19
|
"q-card": { template: "<div><slot /></div>" },
|
|
15
20
|
"q-card-section": { template: "<div><slot /></div>" },
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { flushPromises, mount } from "@vue/test-utils";
|
|
3
|
+
|
|
4
|
+
// A module graph of its own: here no application started the email parser,
|
|
5
|
+
// so the field is what starts it.
|
|
6
|
+
vi.mock("../utils/email", async (importOriginal) => {
|
|
7
|
+
const actual = await importOriginal<typeof import("../utils/email")>();
|
|
8
|
+
const { TEST_PSL } = await import("../utils/email.testing");
|
|
9
|
+
return {
|
|
10
|
+
...actual,
|
|
11
|
+
// The field calls it without options; the test serves the trie itself.
|
|
12
|
+
initEmail: vi.fn(() => actual.initEmail({ psl: TEST_PSL })),
|
|
13
|
+
};
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
const stubs = {
|
|
17
|
+
"q-input": { template: "<div />" },
|
|
18
|
+
"q-icon": { template: "<span />" },
|
|
19
|
+
"q-chip": { template: "<span />" },
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
describe("SidPrincipalInput starting the parser", () => {
|
|
23
|
+
// Regression: the parser was started only on mount, so a field whose
|
|
24
|
+
// accepted types came to include email later kept every email pending
|
|
25
|
+
// and its form's submit disabled for good.
|
|
26
|
+
it("starts it when the accepted types come to include email", async () => {
|
|
27
|
+
const email = await import("../utils/email");
|
|
28
|
+
const { default: SidPrincipalInput } =
|
|
29
|
+
await import("./SidPrincipalInput.vue");
|
|
30
|
+
const w = mount(SidPrincipalInput, {
|
|
31
|
+
props: { modelValue: "alice@example.com", types: ["username"] },
|
|
32
|
+
global: { stubs },
|
|
33
|
+
});
|
|
34
|
+
expect(email.initEmail).not.toHaveBeenCalled();
|
|
35
|
+
await w.setProps({ types: ["email", "username"] });
|
|
36
|
+
await vi.waitFor(() => expect(email.emailReady.value).toBe(true));
|
|
37
|
+
await flushPromises();
|
|
38
|
+
expect(email.initEmail).toHaveBeenCalled();
|
|
39
|
+
expect(w.emitted("update:valid")?.at(-1)?.[0]).toBe(true);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -1,8 +1,13 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
1
|
+
import { beforeAll, describe, it, expect } from "vitest";
|
|
2
2
|
import { mount } from "@vue/test-utils";
|
|
3
|
-
import { defineComponent } from "vue";
|
|
3
|
+
import { defineComponent, nextTick } from "vue";
|
|
4
4
|
import SidPrincipalInput from "./SidPrincipalInput.vue";
|
|
5
5
|
import { principalRecognizable } from "./principalInputTexts";
|
|
6
|
+
import { GLOBAL_PERSONAL, LOCAL, initEmail } from "../utils/email";
|
|
7
|
+
import { TEST_PSL } from "../utils/email.testing";
|
|
8
|
+
|
|
9
|
+
// As an application does at start: the email parser loaded, LOCAL namespace.
|
|
10
|
+
beforeAll(() => initEmail({ psl: TEST_PSL }));
|
|
6
11
|
|
|
7
12
|
const stubs = {
|
|
8
13
|
"q-input": defineComponent({
|
|
@@ -236,6 +241,20 @@ describe("SidPrincipalInput", () => {
|
|
|
236
241
|
// The form disables its submit on this, before any error is shown: only a
|
|
237
242
|
// recognized, accepted, well-formed value, not refused by the server, is
|
|
238
243
|
// valid.
|
|
244
|
+
// Regression: the application's namespace was not reactive, so a field
|
|
245
|
+
// mounted under one kept the verdict of it after initEmail named another.
|
|
246
|
+
it("re-judges an email when the application's namespace changes", async () => {
|
|
247
|
+
await initEmail({ policy: GLOBAL_PERSONAL, psl: TEST_PSL });
|
|
248
|
+
const w = mount(SidPrincipalInput, {
|
|
249
|
+
props: { modelValue: "admin@printer", types: ["email"] },
|
|
250
|
+
global: { stubs },
|
|
251
|
+
});
|
|
252
|
+
expect(w.emitted("update:valid")?.at(-1)?.[0]).toBe(false);
|
|
253
|
+
await initEmail({ policy: LOCAL });
|
|
254
|
+
await nextTick();
|
|
255
|
+
expect(w.emitted("update:valid")?.at(-1)?.[0]).toBe(true);
|
|
256
|
+
});
|
|
257
|
+
|
|
239
258
|
it("tells the form whether the value can be submitted", () => {
|
|
240
259
|
const valid = (props: { modelValue: string; fieldError?: string }) => {
|
|
241
260
|
const w = mount(SidPrincipalInput, {
|
|
@@ -35,7 +35,7 @@
|
|
|
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 { checkEmail, emailReady, initEmail } from "../utils/email";
|
|
39
39
|
|
|
40
40
|
import {
|
|
41
41
|
ALL_PRINCIPAL_TYPES,
|
|
@@ -167,8 +167,13 @@ const typeRule = (val: string): string | true => {
|
|
|
167
167
|
}
|
|
168
168
|
|
|
169
169
|
switch (type) {
|
|
170
|
-
case "email":
|
|
171
|
-
|
|
170
|
+
case "email": {
|
|
171
|
+
// Read so the rule re-runs once the parser has loaded.
|
|
172
|
+
void emailReady.value;
|
|
173
|
+
const check = checkEmail(val.trim());
|
|
174
|
+
// Nothing is wrong yet while the parser loads; `valid` stays false.
|
|
175
|
+
return check !== "invalid" || text.value.invalidEmail;
|
|
176
|
+
}
|
|
172
177
|
case "phone":
|
|
173
178
|
return isValidPhoneNumber(val) || text.value.invalidPhone;
|
|
174
179
|
case "username": {
|
|
@@ -199,9 +204,26 @@ const valid = computed(
|
|
|
199
204
|
() =>
|
|
200
205
|
detectedType.value !== "unknown" &&
|
|
201
206
|
typeRule(model.value) === true &&
|
|
207
|
+
// An email is not submittable before the parser has judged it.
|
|
208
|
+
(detectedType.value !== "email" || emailReady.value) &&
|
|
202
209
|
!props.fieldError,
|
|
203
210
|
);
|
|
204
211
|
|
|
212
|
+
// The application starts the parser at boot (initEmail with its namespace);
|
|
213
|
+
// a field that accepts email starts it when nobody did, also once its
|
|
214
|
+
// accepted types come to include email.
|
|
215
|
+
watch(
|
|
216
|
+
() => accepted.value.includes("email"),
|
|
217
|
+
(takesEmail) => {
|
|
218
|
+
if (takesEmail && !emailReady.value) {
|
|
219
|
+
initEmail().catch((error: unknown) => {
|
|
220
|
+
console.error("email address parser failed to load", error);
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
},
|
|
224
|
+
{ immediate: true },
|
|
225
|
+
);
|
|
226
|
+
|
|
205
227
|
watch(valid, (v) => emit("update:valid", v), { immediate: true });
|
|
206
228
|
|
|
207
229
|
// --- Dynamic icon & type ---
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { beforeAll, describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
CORPORATE_BASELINE,
|
|
4
|
+
GLOBAL_PERSONAL,
|
|
5
|
+
LOCAL,
|
|
6
|
+
checkEmail,
|
|
7
|
+
emailPolicy,
|
|
8
|
+
initEmail,
|
|
9
|
+
isValidEmail,
|
|
10
|
+
} from "./email";
|
|
11
|
+
import { TEST_PSL } from "./email.testing";
|
|
12
|
+
|
|
13
|
+
// The admission the server applies (sid_authn::email): the same parser, the
|
|
14
|
+
// same configuration per namespace, so the field refuses what the server
|
|
15
|
+
// would refuse and accepts what it would accept.
|
|
16
|
+
|
|
17
|
+
describe("before the parser is loaded", () => {
|
|
18
|
+
// Nothing is judged without the parser: a hand-written guess would accept
|
|
19
|
+
// what the server refuses.
|
|
20
|
+
it("says pending and never valid", () => {
|
|
21
|
+
expect(checkEmail("user@example.com", LOCAL)).toBe("pending");
|
|
22
|
+
expect(isValidEmail("user@example.com", LOCAL)).toBe(false);
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe("with the parser", () => {
|
|
27
|
+
beforeAll(async () => {
|
|
28
|
+
await initEmail({ psl: TEST_PSL });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("accepts an ordinary mailbox in every namespace", () => {
|
|
32
|
+
for (const policy of [GLOBAL_PERSONAL, CORPORATE_BASELINE, LOCAL]) {
|
|
33
|
+
expect(checkEmail("a.b+c@example.com", policy)).toBe("valid");
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("refuses what is never an address", () => {
|
|
38
|
+
for (const input of [
|
|
39
|
+
"",
|
|
40
|
+
"noat",
|
|
41
|
+
"@no-local.com",
|
|
42
|
+
"user@",
|
|
43
|
+
"spaces in@email.com",
|
|
44
|
+
"user@exa mple.com",
|
|
45
|
+
]) {
|
|
46
|
+
expect(checkEmail(input, LOCAL), input).toBe("invalid");
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// RFC 5321 §4.1.2: a quoted local part is a mailbox; comments and display
|
|
51
|
+
// names are header syntax and are not.
|
|
52
|
+
it("accepts a quoted local part, refuses header syntax", () => {
|
|
53
|
+
expect(checkEmail('"a b"@example.com', LOCAL)).toBe("valid");
|
|
54
|
+
expect(checkEmail('"a@b"@example.com', LOCAL)).toBe("valid");
|
|
55
|
+
expect(checkEmail("a(comment)@example.com", LOCAL)).toBe("invalid");
|
|
56
|
+
expect(checkEmail("Name <a@example.com>", LOCAL)).toBe("invalid");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("refuses a local part left empty by the equality rules", () => {
|
|
60
|
+
expect(checkEmail("+tag@example.com", LOCAL)).toBe("invalid");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("the global Personal namespace", () => {
|
|
64
|
+
it("refuses non-ASCII in either part", () => {
|
|
65
|
+
expect(checkEmail("алиса@example.com", GLOBAL_PERSONAL)).toBe("invalid");
|
|
66
|
+
expect(checkEmail("user@münchen.de", GLOBAL_PERSONAL)).toBe("invalid");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("refuses an A-label domain", () => {
|
|
70
|
+
expect(checkEmail("user@xn--mnchen-3ya.de", GLOBAL_PERSONAL)).toBe(
|
|
71
|
+
"invalid",
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("requires a public suffix", () => {
|
|
76
|
+
expect(checkEmail("admin@printer", GLOBAL_PERSONAL)).toBe("invalid");
|
|
77
|
+
expect(checkEmail("user@example.notatld", GLOBAL_PERSONAL)).toBe(
|
|
78
|
+
"invalid",
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe("an installation's own accounts", () => {
|
|
84
|
+
it("admits internal and internationalized mail domains", () => {
|
|
85
|
+
expect(checkEmail("admin@printer", LOCAL)).toBe("valid");
|
|
86
|
+
expect(checkEmail("user@münchen.de", LOCAL)).toBe("valid");
|
|
87
|
+
expect(checkEmail("алиса@example.com", LOCAL)).toBe("valid");
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("corporate baseline admits an internal domain, ASCII only", () => {
|
|
92
|
+
expect(checkEmail("admin@printer", CORPORATE_BASELINE)).toBe("valid");
|
|
93
|
+
expect(checkEmail("user@münchen.de", CORPORATE_BASELINE)).toBe("invalid");
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe("the application's namespace", () => {
|
|
98
|
+
it("is LOCAL unless the application names its own", async () => {
|
|
99
|
+
await initEmail();
|
|
100
|
+
expect(emailPolicy()).toBe(LOCAL);
|
|
101
|
+
await initEmail({ policy: GLOBAL_PERSONAL });
|
|
102
|
+
expect(emailPolicy()).toBe(GLOBAL_PERSONAL);
|
|
103
|
+
expect(isValidEmail("admin@printer")).toBe(false);
|
|
104
|
+
await initEmail({ policy: LOCAL });
|
|
105
|
+
expect(isValidEmail("admin@printer")).toBe(true);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import type { PslSource } from "./email";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The trie the application would serve, read from the installed package, for
|
|
7
|
+
* tests running without the application's host. Not cached, so a test never
|
|
8
|
+
* reads another run's copy.
|
|
9
|
+
*/
|
|
10
|
+
export const TEST_PSL: PslSource = {
|
|
11
|
+
url: "psl.bin",
|
|
12
|
+
cache: false,
|
|
13
|
+
fetch: (async () => {
|
|
14
|
+
const path = createRequire(import.meta.url).resolve(
|
|
15
|
+
"@structured-world/structured-public-domains/psl.bin",
|
|
16
|
+
);
|
|
17
|
+
return new Response(await readFile(path));
|
|
18
|
+
}) as typeof fetch,
|
|
19
|
+
};
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// An ambient declaration cannot be imported, and a consumer compiling this
|
|
2
|
+
// source sees it only through this reference.
|
|
3
|
+
// eslint-disable-next-line @typescript-eslint/triple-slash-reference
|
|
4
|
+
/// <reference path="../assets.d.ts" />
|
|
5
|
+
import { readonly, ref, shallowRef, type Ref } from "vue";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Which mailboxes an email namespace admits, as the server's policy for it
|
|
9
|
+
* (sid_authn::email) does. Equality and the resolution key stay the
|
|
10
|
+
* server's: the field only refuses what the namespace never admits.
|
|
11
|
+
*/
|
|
12
|
+
export interface EmailPolicy {
|
|
13
|
+
/** Accept non-ASCII characters in the local part (RFC 6531). */
|
|
14
|
+
readonly unicodeLocal: boolean;
|
|
15
|
+
/** Accept internationalized domains, in Unicode or A-label spelling. */
|
|
16
|
+
readonly idnDomain: boolean;
|
|
17
|
+
/** Accept a domain without a dot (an installation-local mail domain). */
|
|
18
|
+
readonly singleLabelDomain: boolean;
|
|
19
|
+
/** Require the domain to end in a Public Suffix List suffix. */
|
|
20
|
+
readonly publicSuffix: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The global SaaS/federated Personal namespace: ASCII, no IDN, a public mail domain. */
|
|
24
|
+
export const GLOBAL_PERSONAL: EmailPolicy = Object.freeze({
|
|
25
|
+
unicodeLocal: false,
|
|
26
|
+
idnDomain: false,
|
|
27
|
+
singleLabelDomain: false,
|
|
28
|
+
publicSuffix: true,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
/** An organization's Corporate namespace before it sets its own rules. */
|
|
32
|
+
export const CORPORATE_BASELINE: EmailPolicy = Object.freeze({
|
|
33
|
+
unicodeLocal: false,
|
|
34
|
+
idnDomain: false,
|
|
35
|
+
singleLabelDomain: true,
|
|
36
|
+
publicSuffix: false,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
/** An installation's own managed accounts (standalone). */
|
|
40
|
+
export const LOCAL: EmailPolicy = Object.freeze({
|
|
41
|
+
unicodeLocal: true,
|
|
42
|
+
idnDomain: true,
|
|
43
|
+
singleLabelDomain: true,
|
|
44
|
+
publicSuffix: false,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
/** A verdict on one input; `pending` until the parser is loaded. */
|
|
48
|
+
export type EmailCheck = "valid" | "invalid" | "pending";
|
|
49
|
+
|
|
50
|
+
type Parser = typeof import("@structured-world/structured-email-address-ts");
|
|
51
|
+
|
|
52
|
+
/** How the Public Suffix List trie is fetched and cached. */
|
|
53
|
+
export type PslSource = NonNullable<
|
|
54
|
+
NonNullable<Parameters<Parser["init"]>[0]>["psl"]
|
|
55
|
+
>;
|
|
56
|
+
|
|
57
|
+
let parser: Parser | undefined;
|
|
58
|
+
let loading: Promise<Parser> | undefined;
|
|
59
|
+
// Reactive, so a verdict computed under it is recomputed when it changes.
|
|
60
|
+
const appPolicy = shallowRef<EmailPolicy>(LOCAL);
|
|
61
|
+
const loaded = ref(false);
|
|
62
|
+
|
|
63
|
+
/** Whether the parser is loaded; a rule depending on it re-runs when it is. */
|
|
64
|
+
export const emailReady: Readonly<Ref<boolean>> = readonly(loaded);
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The trie this package ships, as the application's own asset: the bundler
|
|
68
|
+
* emits it beside the bundle, so the application's host serves it.
|
|
69
|
+
*/
|
|
70
|
+
async function bundledPsl(): Promise<string> {
|
|
71
|
+
const asset =
|
|
72
|
+
await import("@structured-world/structured-public-domains/psl.bin?url");
|
|
73
|
+
return asset.default;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Load the parser (once) and set the application's email namespace, LOCAL
|
|
78
|
+
* unless named. The parser is the TypeScript port of the server's, checked
|
|
79
|
+
* byte-identical against it; its Public Suffix List trie comes from `psl`
|
|
80
|
+
* (`url`: the setting of where it is served, the application's own copy of
|
|
81
|
+
* it when unset; never a third-party CDN), cached in IndexedDB. An
|
|
82
|
+
* application calls this at start; a field starts it when nobody did.
|
|
83
|
+
*/
|
|
84
|
+
export async function initEmail(options?: {
|
|
85
|
+
policy?: EmailPolicy;
|
|
86
|
+
psl?: PslSource;
|
|
87
|
+
}): Promise<void> {
|
|
88
|
+
if (options?.policy) appPolicy.value = options.policy;
|
|
89
|
+
loading ??= import("@structured-world/structured-email-address-ts").then(
|
|
90
|
+
async (module) => {
|
|
91
|
+
const url = options?.psl?.url ?? (await bundledPsl());
|
|
92
|
+
await module.init({ psl: { ...options?.psl, url } });
|
|
93
|
+
return module;
|
|
94
|
+
},
|
|
95
|
+
);
|
|
96
|
+
try {
|
|
97
|
+
parser = await loading;
|
|
98
|
+
} catch (error) {
|
|
99
|
+
// A failed load is retried by the next call, never cached as loaded.
|
|
100
|
+
loading = undefined;
|
|
101
|
+
throw error;
|
|
102
|
+
}
|
|
103
|
+
loaded.value = true;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The application's email namespace. */
|
|
107
|
+
export function emailPolicy(): EmailPolicy {
|
|
108
|
+
return appPolicy.value;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Whether a domain label is an IDNA A-label (`xn--`). */
|
|
112
|
+
function isALabel(label: string): boolean {
|
|
113
|
+
return label.slice(0, 4).toLowerCase() === "xn--";
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Whether `value` is one mailbox the namespace admits: an RFC 5321 mailbox
|
|
118
|
+
* (dot-atom or quoted local part; no comments, display name or address
|
|
119
|
+
* literal) under the namespace's character, domain and suffix rules, with a
|
|
120
|
+
* local part that its equality rules (case, dots, `+tag`) do not empty.
|
|
121
|
+
*/
|
|
122
|
+
export function checkEmail(
|
|
123
|
+
value: string,
|
|
124
|
+
policy: EmailPolicy = appPolicy.value,
|
|
125
|
+
): EmailCheck {
|
|
126
|
+
if (!parser) return "pending";
|
|
127
|
+
if (
|
|
128
|
+
!policy.unicodeLocal &&
|
|
129
|
+
!policy.idnDomain &&
|
|
130
|
+
!/^[\x00-\x7f]*$/.test(value)
|
|
131
|
+
) {
|
|
132
|
+
return "invalid";
|
|
133
|
+
}
|
|
134
|
+
let local: string;
|
|
135
|
+
let domain: string;
|
|
136
|
+
try {
|
|
137
|
+
const mailbox = parser.parse(value, {
|
|
138
|
+
strictness: "strict",
|
|
139
|
+
allowQuotedLocalPart: true,
|
|
140
|
+
casePolicy: "preserve",
|
|
141
|
+
dotPolicy: "preserve",
|
|
142
|
+
subaddress: "preserve",
|
|
143
|
+
allowSingleLabelDomain: policy.singleLabelDomain,
|
|
144
|
+
domainCheck: policy.publicSuffix ? "psl" : "syntax",
|
|
145
|
+
});
|
|
146
|
+
local = mailbox.localPart;
|
|
147
|
+
domain = mailbox.domain;
|
|
148
|
+
} catch {
|
|
149
|
+
return "invalid";
|
|
150
|
+
}
|
|
151
|
+
if (!policy.unicodeLocal && !/^[\x00-\x7f]*$/.test(local)) return "invalid";
|
|
152
|
+
// A Unicode domain arrives IDNA-encoded, so A-labels cover both spellings.
|
|
153
|
+
if (!policy.idnDomain && domain.split(".").some(isALabel)) return "invalid";
|
|
154
|
+
const base = local.toLowerCase().split("+", 1)[0] ?? "";
|
|
155
|
+
if (base.replaceAll(".", "") === "") return "invalid";
|
|
156
|
+
return "valid";
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** {@link checkEmail} as a yes/no; `false` while the parser is loading. */
|
|
160
|
+
export function isValidEmail(
|
|
161
|
+
value: string,
|
|
162
|
+
policy: EmailPolicy = appPolicy.value,
|
|
163
|
+
): boolean {
|
|
164
|
+
return checkEmail(value, policy) === "valid";
|
|
165
|
+
}
|
package/src/utils/index.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
export {
|
|
2
|
-
isValidEmail,
|
|
3
2
|
isValidPhone,
|
|
4
3
|
isValidUuid,
|
|
5
4
|
isValidUsername,
|
|
@@ -8,6 +7,19 @@ export {
|
|
|
8
7
|
isRequired,
|
|
9
8
|
} from "./validators";
|
|
10
9
|
|
|
10
|
+
export {
|
|
11
|
+
CORPORATE_BASELINE,
|
|
12
|
+
GLOBAL_PERSONAL,
|
|
13
|
+
LOCAL,
|
|
14
|
+
checkEmail,
|
|
15
|
+
emailPolicy,
|
|
16
|
+
emailReady,
|
|
17
|
+
initEmail,
|
|
18
|
+
isValidEmail,
|
|
19
|
+
type EmailCheck,
|
|
20
|
+
type EmailPolicy,
|
|
21
|
+
} from "./email";
|
|
22
|
+
|
|
11
23
|
export {
|
|
12
24
|
formatDate,
|
|
13
25
|
formatRelativeTime,
|
|
@@ -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", () => {
|
|
@@ -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
|
|
|
@@ -100,8 +100,13 @@ export function normalizePrincipal(
|
|
|
100
100
|
switch (type) {
|
|
101
101
|
case "email": {
|
|
102
102
|
// Sent as typed: the server validates it, derives the lookup key and
|
|
103
|
-
// keeps this spelling as the address to mail.
|
|
104
|
-
|
|
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") {
|
|
105
110
|
throw new Error("Invalid email address");
|
|
106
111
|
}
|
|
107
112
|
return { value: trimmed, display: trimmed, type: "email" };
|
|
@@ -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);
|