@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
|
@@ -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 { checkEmail, emailReady, initEmail } from "../utils/email";
|
|
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,26 +155,37 @@ 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
|
|
|
159
169
|
switch (type) {
|
|
160
|
-
case "email":
|
|
161
|
-
|
|
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
|
+
}
|
|
162
177
|
case "phone":
|
|
163
178
|
return isValidPhoneNumber(val) || text.value.invalidPhone;
|
|
164
179
|
case "username": {
|
|
165
|
-
const name = val
|
|
166
|
-
if (name
|
|
180
|
+
const name = val;
|
|
181
|
+
if (/\s/u.test(name)) return text.value.usernameSpaces;
|
|
182
|
+
if (!USERNAME_SHAPE.test(name)) return text.value.usernameCharacters;
|
|
183
|
+
// Counted in code points of the NFC form, as the server counts the
|
|
184
|
+
// display; the value itself is sent unchanged.
|
|
185
|
+
const length = [...name.normalize("NFC")].length;
|
|
186
|
+
if (length < props.minUsernameLength)
|
|
167
187
|
return text.value.usernameTooShort(props.minUsernameLength);
|
|
168
|
-
if (
|
|
188
|
+
if (length > USERNAME_MAX_LENGTH)
|
|
169
189
|
return text.value.usernameTooLong(USERNAME_MAX_LENGTH);
|
|
170
190
|
if (name.startsWith("_") || name.endsWith("_"))
|
|
171
191
|
return text.value.usernameUnderscoreEdge;
|
|
@@ -175,10 +195,63 @@ const typeRule = (val: string): string | true => {
|
|
|
175
195
|
}
|
|
176
196
|
};
|
|
177
197
|
|
|
198
|
+
/**
|
|
199
|
+
* A form keeps its submit disabled until this holds, silently: the error a
|
|
200
|
+
* rule names appears only once the field is left, the button never waits for
|
|
201
|
+
* it. A refusal the server bound to the field holds until the value changes.
|
|
202
|
+
*/
|
|
203
|
+
const valid = computed(
|
|
204
|
+
() =>
|
|
205
|
+
detectedType.value !== "unknown" &&
|
|
206
|
+
typeRule(model.value) === true &&
|
|
207
|
+
// An email is not submittable before the parser has judged it.
|
|
208
|
+
(detectedType.value !== "email" || emailReady.value) &&
|
|
209
|
+
!props.fieldError,
|
|
210
|
+
);
|
|
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
|
+
|
|
227
|
+
watch(valid, (v) => emit("update:valid", v), { immediate: true });
|
|
228
|
+
|
|
178
229
|
// --- Dynamic icon & type ---
|
|
179
230
|
|
|
231
|
+
/** The recognized type when the field accepts it; refused types look neutral. */
|
|
232
|
+
const shownType = computed<PrincipalType>(() => {
|
|
233
|
+
const type = detectedType.value;
|
|
234
|
+
return type !== "unknown" && accepted.value.includes(type) ? type : "unknown";
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* The chip names the type only when the label does not: a derived label
|
|
239
|
+
* already says "Email" or "Username".
|
|
240
|
+
*/
|
|
241
|
+
const showChip = computed(
|
|
242
|
+
() => props.label != null && shownType.value !== "unknown",
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
/** A field of one type shows that type's icon from the start, greyed until recognized. */
|
|
246
|
+
const iconType = computed<PrincipalType>(() => {
|
|
247
|
+
const [only, ...others] = accepted.value;
|
|
248
|
+
return shownType.value === "unknown" && only && others.length === 0
|
|
249
|
+
? only
|
|
250
|
+
: shownType.value;
|
|
251
|
+
});
|
|
252
|
+
|
|
180
253
|
const iconName = computed(() => {
|
|
181
|
-
switch (
|
|
254
|
+
switch (iconType.value) {
|
|
182
255
|
case "email":
|
|
183
256
|
return "sym_o_mail";
|
|
184
257
|
case "phone":
|
|
@@ -191,7 +264,7 @@ const iconName = computed(() => {
|
|
|
191
264
|
});
|
|
192
265
|
|
|
193
266
|
const iconColor = computed(() =>
|
|
194
|
-
|
|
267
|
+
shownType.value === "unknown" ? "grey-6" : "primary",
|
|
195
268
|
);
|
|
196
269
|
|
|
197
270
|
// inputmode controls mobile keyboard WITHOUT triggering browser autofill.
|
|
@@ -211,13 +284,11 @@ const inputMode = computed(() => {
|
|
|
211
284
|
});
|
|
212
285
|
|
|
213
286
|
const chipLabel = computed(() =>
|
|
214
|
-
|
|
215
|
-
? ""
|
|
216
|
-
: text.value.typeChip(detectedType.value),
|
|
287
|
+
shownType.value === "unknown" ? "" : text.value.typeChip(shownType.value),
|
|
217
288
|
);
|
|
218
289
|
|
|
219
290
|
const chipColor = computed(() => {
|
|
220
|
-
switch (
|
|
291
|
+
switch (shownType.value) {
|
|
221
292
|
case "email":
|
|
222
293
|
return "deep-orange";
|
|
223
294
|
case "phone":
|
|
@@ -235,5 +306,5 @@ function focus() {
|
|
|
235
306
|
inputRef.value?.focus();
|
|
236
307
|
}
|
|
237
308
|
|
|
238
|
-
defineExpose({ focus, detectedType });
|
|
309
|
+
defineExpose({ focus, detectedType, valid });
|
|
239
310
|
</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
|
};
|
|
@@ -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,
|