mevebu 2.1.0 → 2.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.
@@ -0,0 +1,161 @@
1
+ // Bcrypt, for testing a login. The hash and the check both run in this tab with a
2
+ // vendored pure JavaScript build, so no password leaves the page. The cost factor
3
+ // is the point: each step doubles the work, and the timing shows it.
4
+ import {
5
+ el, toast, bench, stack, panel, textInput, numberInput, button, actions,
6
+ toolcard, notice, copyButton, output, chipRow,
7
+ } from "../core.js";
8
+
9
+ const COST_MIN = 4;
10
+ const COST_MAX = 14;
11
+
12
+ function bcryptLib() {
13
+ const lib = globalThis.dcodeIO?.bcrypt ?? globalThis.bcrypt;
14
+ if (!lib) throw new Error("The bcrypt engine did not load.");
15
+ return lib;
16
+ }
17
+
18
+ function loadEngine() {
19
+ if (globalThis.dcodeIO?.bcrypt || globalThis.bcrypt) return Promise.resolve(bcryptLib());
20
+ return new Promise((resolve, reject) => {
21
+ const script = document.createElement("script");
22
+ script.src = new URL("../vendor/bcrypt.min.js", import.meta.url).href;
23
+ script.onload = () => {
24
+ try {
25
+ resolve(bcryptLib());
26
+ } catch (caught) {
27
+ reject(caught);
28
+ }
29
+ };
30
+ script.onerror = () => reject(new Error("The bcrypt engine could not be loaded."));
31
+ document.head.append(script);
32
+ });
33
+ }
34
+
35
+ const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
36
+
37
+ export function mount(slot) {
38
+ let hashed = "";
39
+ let busy = false;
40
+ let token = 0;
41
+
42
+ const password = textInput({
43
+ type: "password",
44
+ placeholder: "The password to hash",
45
+ "aria-label": "Password",
46
+ autocomplete: "new-password",
47
+ });
48
+ const show = el("input", { type: "checkbox", class: "m-0 size-4 accent-[var(--jade)]" });
49
+ const cost = numberInput({ min: String(COST_MIN), max: String(COST_MAX), step: "1", value: "10", "aria-label": "Cost factor" });
50
+ const costLabel = el("span", { class: "text-sm font-semibold text-ink-2 tabular-nums" }, "");
51
+
52
+ const existing = textInput({
53
+ placeholder: "$2b$10$...",
54
+ "aria-label": "Hash to check",
55
+ class: "font-mono text-sm",
56
+ autocomplete: "off",
57
+ spellcheck: "false",
58
+ });
59
+
60
+ const error = notice("warn");
61
+ const summary = el("p", { class: "text-sm text-ink-2" });
62
+ const hashOut = output({ rows: 3 });
63
+ const verdict = el("p", { class: "m-0 text-sm font-semibold" });
64
+
65
+ function syncCost() {
66
+ const value = Math.max(COST_MIN, Math.min(COST_MAX, Number(cost.value) || 10));
67
+ costLabel.textContent = `${value} · about ${2 ** value} rounds`;
68
+ }
69
+
70
+ async function hash() {
71
+ if (busy) return;
72
+ if (!password.value) {
73
+ error.write("Type a password first.");
74
+ return;
75
+ }
76
+ busy = true;
77
+ const current = ++token;
78
+ error.write("");
79
+ summary.textContent = "Hashing";
80
+ try {
81
+ const lib = await loadEngine();
82
+ const rounds = Math.max(COST_MIN, Math.min(COST_MAX, Number(cost.value) || 10));
83
+ await tick();
84
+ const started = performance.now();
85
+ const salt = lib.genSaltSync(rounds);
86
+ const result = lib.hashSync(password.value, salt);
87
+ const took = performance.now() - started;
88
+ if (current !== token) return;
89
+ hashed = result;
90
+ hashOut.write(result);
91
+ summary.textContent = `cost ${rounds} · ${Math.round(took)} ms on this device`;
92
+ toast("Hash ready");
93
+ } catch (caught) {
94
+ if (current !== token) return;
95
+ error.write(caught?.message || "The hash could not be made.");
96
+ } finally {
97
+ busy = false;
98
+ }
99
+ }
100
+
101
+ async function check() {
102
+ const stored = existing.value.trim();
103
+ verdict.textContent = "";
104
+ if (!stored) return;
105
+ if (!/^\$2[abxy]\$\d{2}\$.{53}$/.test(stored)) {
106
+ verdict.textContent = "That is not a bcrypt hash. It should start with $2b$ and be 60 characters.";
107
+ verdict.className = "m-0 text-sm font-semibold text-danger";
108
+ return;
109
+ }
110
+ if (!password.value) {
111
+ verdict.textContent = "Type the password to check against that hash.";
112
+ verdict.className = "m-0 text-sm font-semibold text-ink-2";
113
+ return;
114
+ }
115
+ try {
116
+ const lib = await loadEngine();
117
+ const ok = lib.compareSync(password.value, stored);
118
+ verdict.textContent = ok ? "Match: this password produces that hash." : "No match for that password.";
119
+ verdict.className = ok ? "m-0 text-sm font-semibold text-jade" : "m-0 text-sm font-semibold text-danger";
120
+ } catch (caught) {
121
+ verdict.textContent = caught?.message || "The check could not run.";
122
+ verdict.className = "m-0 text-sm font-semibold text-danger";
123
+ }
124
+ }
125
+
126
+ show.addEventListener("change", () => {
127
+ password.type = show.checked ? "text" : "password";
128
+ });
129
+ password.addEventListener("input", check);
130
+ existing.addEventListener("input", check);
131
+ cost.addEventListener("input", syncCost);
132
+
133
+ const copy = copyButton(() => hashed, "Copy hash");
134
+ const hashButton = button("Hash password", { icon: "hash", kind: "fill", onclick: hash });
135
+
136
+ syncCost();
137
+
138
+ const shell = toolcard({
139
+ body: bench(
140
+ stack(
141
+ panel("Password", password),
142
+ el("label", { class: "inline-flex min-h-9 cursor-pointer items-center gap-2 text-sm text-ink-2" }, show, "Show password"),
143
+ panel("Cost factor", cost, "Each step doubles the work. 10 is a common default; 12 is slower and safer."),
144
+ costLabel,
145
+ panel("Hash to check", existing, "Paste a stored hash to see whether the password matches it."),
146
+ verdict,
147
+ error
148
+ ),
149
+ stack(summary, hashOut,
150
+ el("p", { class: "m-0 text-2xs leading-[1.6] text-ink-3" },
151
+ "Bcrypt is a password hash, not encryption. The salt is in the hash, so the same password gives a different result each time. Use the timing to pick a cost your server can afford."))
152
+ ),
153
+ foot: actions(copy, hashButton),
154
+ });
155
+
156
+ slot.append(shell.node);
157
+
158
+ return {};
159
+ }
160
+
161
+ export default { mount };
@@ -0,0 +1,154 @@
1
+ // The hash of a file, to compare against a published checksum. The file is read
2
+ // here with Web Crypto, so a download can be verified before it is opened.
3
+ import {
4
+ el, icon, toast, bench, stack, panel, textInput, button, actions, toolcard,
5
+ blank, notice, copyButton, output,
6
+ } from "../core.js";
7
+ import { dropzone, formatBytes } from "../files.js";
8
+
9
+ const ALGORITHMS = [
10
+ { id: "SHA-1", label: "SHA-1" },
11
+ { id: "SHA-256", label: "SHA-256" },
12
+ { id: "SHA-512", label: "SHA-512" },
13
+ ];
14
+
15
+ function hex(bytes) {
16
+ return [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
17
+ }
18
+
19
+ export function mount(slot) {
20
+ let file = null;
21
+ let digests = {};
22
+
23
+ const expected = textInput({
24
+ placeholder: "Paste the published checksum",
25
+ "aria-label": "Expected checksum",
26
+ class: "font-mono text-sm",
27
+ autocomplete: "off",
28
+ spellcheck: "false",
29
+ });
30
+
31
+ const error = notice("warn");
32
+ const summary = el("p", { class: "text-sm text-ink-2" });
33
+ const verdictTitle = el("p", { class: "m-0 text-lg font-bold" }, "Waiting");
34
+ const verdictBody = el("p", { class: "m-0 text-sm text-ink-2" }, "Drop a file, then paste the checksum you were given.");
35
+ const verdict = el("div", { class: "grid gap-1 rounded-r2 border border-orbit bg-deck-2 p-4" }, verdictTitle, verdictBody);
36
+ const rows = el("div", { class: "grid gap-2" });
37
+ const empty = blank("file-key", "Drop a file to hash it.");
38
+
39
+ const drop = dropzone({ label: "Drop a file here", accept: "", iconName: "file-key" });
40
+ const clearButton = button("Clear", { icon: "trash", kind: "quiet" });
41
+
42
+ function paint(state, title, body) {
43
+ const tone = state === "ok"
44
+ ? "border-jade bg-jade-soft"
45
+ : state === "bad"
46
+ ? "border-danger/40 bg-danger/10"
47
+ : "border-orbit bg-deck-2";
48
+ verdict.className = `grid gap-1 rounded-r2 border p-4 ${tone}`;
49
+ verdictTitle.textContent = title;
50
+ verdictTitle.className = `m-0 text-lg font-bold ${state === "ok" ? "text-jade" : state === "bad" ? "text-danger" : ""}`;
51
+ verdictBody.textContent = body;
52
+ verdictBody.className = state === "ok" || state === "bad" ? "m-0 text-sm" : "m-0 text-sm text-ink-2";
53
+ }
54
+
55
+ function compare() {
56
+ if (!file) {
57
+ paint("idle", "Waiting", "Drop a file, then paste the checksum you were given.");
58
+ return;
59
+ }
60
+ const wanted = expected.value.replace(/\s+/g, "").toLowerCase();
61
+ if (!wanted) {
62
+ paint("idle", "Ready", "Paste a checksum to compare, or copy one below.");
63
+ return;
64
+ }
65
+ const match = Object.entries(digests).find(([, value]) => value === wanted);
66
+ if (match) {
67
+ paint("ok", "Match", `The file matches the ${match[0]} you pasted.`);
68
+ } else {
69
+ const length = wanted.length;
70
+ const sameLength = Object.entries(digests).find(([, value]) => value.length === length);
71
+ paint(
72
+ "bad",
73
+ "No match",
74
+ sameLength
75
+ ? `That is not the ${sameLength[0]} of this file. Either the file or the checksum is wrong.`
76
+ : `A checksum of ${length} characters is not a SHA-1, SHA-256 or SHA-512 digest.`
77
+ );
78
+ }
79
+ }
80
+
81
+ function renderRows() {
82
+ rows.replaceChildren(
83
+ ...ALGORITHMS.map((algorithm) =>
84
+ el("div", { class: "grid gap-1.5 rounded-r1 border border-orbit bg-deck-2 p-2.5" },
85
+ el("div", { class: "flex items-center justify-between gap-2" },
86
+ el("span", { class: "text-2xs font-bold tracking-[0.06em] text-ink-3 uppercase" }, algorithm.label),
87
+ copyButton(() => digests[algorithm.id] ?? "", "Copy")),
88
+ el("p", { class: "m-0 wrap-anywhere font-mono text-2xs leading-[1.6] text-ink" }, digests[algorithm.id] ?? "")))
89
+ );
90
+ }
91
+
92
+ async function open(files) {
93
+ const picked = Array.from(files)[0];
94
+ if (!picked) return;
95
+ error.write("");
96
+ summary.textContent = `Reading ${picked.name}`;
97
+ try {
98
+ const bytes = await picked.arrayBuffer();
99
+ const next = {};
100
+ for (const algorithm of ALGORITHMS) {
101
+ const digest = await crypto.subtle.digest(algorithm.id, bytes);
102
+ next[algorithm.id] = hex(new Uint8Array(digest));
103
+ }
104
+ file = picked;
105
+ digests = next;
106
+ summary.textContent = `${picked.name} · ${formatBytes(picked.size)}`;
107
+ empty.hidden = true;
108
+ rows.hidden = false;
109
+ renderRows();
110
+ compare();
111
+ } catch (caught) {
112
+ summary.textContent = "";
113
+ error.write(caught?.message || "This file could not be read.");
114
+ }
115
+ }
116
+
117
+ drop.onFiles(open);
118
+ expected.addEventListener("input", compare);
119
+ clearButton.addEventListener("click", () => {
120
+ file = null;
121
+ digests = {};
122
+ expected.value = "";
123
+ summary.textContent = "";
124
+ error.write("");
125
+ rows.replaceChildren();
126
+ rows.hidden = true;
127
+ empty.hidden = false;
128
+ paint("idle", "Waiting", "Drop a file, then paste the checksum you were given.");
129
+ });
130
+
131
+ paint("idle", "Waiting", "Drop a file, then paste the checksum you were given.");
132
+ rows.hidden = true;
133
+
134
+ const shell = toolcard({
135
+ body: bench(
136
+ stack(
137
+ drop,
138
+ panel("Expected checksum", expected, "SHA-1, SHA-256 or SHA-512. Case and spaces are ignored."),
139
+ verdict,
140
+ error,
141
+ el("p", { class: "m-0 text-2xs leading-[1.6] text-ink-3" },
142
+ "The file is read in this tab. Nothing is uploaded, and the size limit is whatever the browser can hold in memory.")
143
+ ),
144
+ stack(summary, empty, rows)
145
+ ),
146
+ foot: actions(clearButton),
147
+ });
148
+
149
+ slot.append(shell.node);
150
+
151
+ return {};
152
+ }
153
+
154
+ export default { mount };
@@ -0,0 +1,149 @@
1
+ // A passphrase from a bundled word list. The entropy reading is arithmetic on
2
+ // the size of that list, not an estimate: log2(length) bits per word.
3
+ import {
4
+ el, icon, toast, bench, stack, panel, numberInput, selectInput, button,
5
+ actions, toolcard, notice, checkbox, rangeInput, copyButton, chipRow,
6
+ } from "../core.js";
7
+
8
+ const WORDS = `acorn amber anchor apple arrow aspen attic autumn badge bagel baker bamboo
9
+ banjo barrel basil basket beach beacon bean bear beaver bell belt bench berry birch biscuit
10
+ bison blade blanket bloom board boat bolt bonfire book boot bottle boulder bowl box braid branch
11
+ brass bread brick bridge broom brush bubble bucket buffalo bugle bulb bunny burrow bus butter
12
+ button cabin cactus camel candle candy canoe canvas canyon carbon cargo carpet carrot cart castle
13
+ cedar chalk cherry chess chest chicken chisel cider cinema circle city clamp clay cliff cloak clock
14
+ cloud clover coach coast cobra cocoa comet compass copper coral cork corn cotton couch cougar
15
+ country court cove crab crane crater crayon creek crest cricket crimson crow crown crystal cube
16
+ cupboard curl curtain cycle daisy dance dawn deer delta denim desert desk diamond dice diner dock
17
+ dolphin dome donkey door dove dragon drift drum dune dusk eagle earth easel echo egg elbow elder
18
+ elk ember engine envelope falcon fang farm feather fence fern ferry field fig finch fire fish flag
19
+ flame flask fleet flint float flock flute foam fog forest fork fossil fountain fox frame frost fruit
20
+ galley garden garlic gate gecko gem ginger glacier glass glove goat gold goose gourd grain grape
21
+ grass gravel green grove guitar gull gust hammer harbor harp harvest hawk hay hedge helmet heron
22
+ hill hinge hive holly honey hoof hook horn horse hotel hut iceberg ink inlet iron island ivory ivy
23
+ jacket jade jaguar jar jasmine jelly jewel jigsaw jungle juniper kayak kettle key kite kitten kiwi
24
+ knife knot koala ladder lagoon lake lamp lantern lapel laser latch lattice leaf ledge lemon lens
25
+ letter lever lily lime linen lion lizard lobster lock log lotus lumber lunar lynx magnet magneto
26
+ maize mango maple marble march marsh mask mast meadow medal melon mesa metal meteor mint mirror
27
+ mist mitten mole monkey moon moose mortar moss moth motor mound mountain mouse mushroom music
28
+ napkin needle nest nettle nickel night noodle north note nugget oak oasis ocean olive onion opal
29
+ orange orbit orchid otter oven owl oxide oyster paddle page palm panda panel paper parcel parrot
30
+ pasture patio peach peanut pear pebble pelican pen pencil pepper perch petal piano picnic pier
31
+ pigeon pillar pine piston pitcher plain plane planet plank plant plaster plate plaza plum pocket
32
+ pond pony poppy porch portal potato prairie prism pumpkin puzzle quail quarry quartz quill quiver
33
+ rabbit radar raft rail rain rake ram ranch raven ravine ray razor reef relay ribbon rice ridge
34
+ rifle ring river road robin rocket rod roof root rope rose rowboat ruby rug ruler saddle sage sail
35
+ salmon sand sapphire satchel saw scarf school scooter scout sea seal seed shade shark sheep shelf
36
+ shell shield ship shoe shore shovel shrub silk silver siren skate sketch sky sled slope snail snake
37
+ snow soap sock sofa solar song soup sparrow spice spider spike spine sponge spoon spring spruce
38
+ square squid stable stack star statue steam steel stem step stick stone stool storm stove strap
39
+ stream street string sugar summer sun swan sweat sweet swift swing table tackle tail talon tank
40
+ tapir tea teal temple tent thistle thorn thread thunder tide tiger timber tin toad toast tomato
41
+ tone tool tooth topaz torch tower track trail train trap tray tree trek trench tribe trout truck
42
+ trumpet trunk tulip tunnel turtle twig valley vanilla vase velvet vine violet violin vista wagon
43
+ walnut walrus wand wave wax wheat wheel whip whisper willow wind window wing winter wire wolf wood
44
+ wool wrench yacht yarn yellow yew zebra zinc zipper zone`.split(/\s+/).filter(Boolean);
45
+
46
+ const SEPARATORS = [
47
+ { value: "-", label: "hyphen" },
48
+ { value: " ", label: "space" },
49
+ { value: ".", label: "dot" },
50
+ { value: "_", label: "underscore" },
51
+ ];
52
+
53
+ export function mount(slot) {
54
+ let phrase = "";
55
+
56
+ const count = rangeInput({ min: "2", max: "12", step: "1", value: "5", "aria-label": "Words" });
57
+ const countLabel = el("span", { class: "text-sm font-semibold text-ink-2 tabular-nums" }, "5 words");
58
+ const separator = selectInput(SEPARATORS, { value: "-", "aria-label": "Separator" });
59
+ const capitalize = checkbox("Capitalize each word", { checked: false });
60
+ const number = checkbox("Add a number", { checked: false });
61
+ const symbol = checkbox("Add a symbol", { checked: false });
62
+
63
+ const error = notice("warn");
64
+ const output = el("p", {
65
+ class: "m-0 wrap-anywhere font-mono text-[clamp(20px,3.4vw,28px)] font-bold leading-[1.4] tracking-[-0.01em] text-ink",
66
+ "aria-live": "polite",
67
+ }, "");
68
+ const entropy = el("p", { class: "m-0 text-sm text-ink-2" });
69
+ const panelBody = el("div", { class: "grid gap-2 rounded-r2 border border-orbit bg-deck-2 p-4" }, output, entropy);
70
+
71
+ function make() {
72
+ error.write("");
73
+ if (!WORDS.length) {
74
+ error.write("The word list is empty.");
75
+ return;
76
+ }
77
+ const size = Math.max(2, Math.min(12, Number(count.value) || 5));
78
+ const random = crypto.getRandomValues(new Uint32Array(size + 2));
79
+ const parts = [];
80
+ for (let i = 0; i < size; i++) {
81
+ let word = WORDS[random[i] % WORDS.length];
82
+ if (capitalize.input.checked) word = word[0].toUpperCase() + word.slice(1);
83
+ parts.push(word);
84
+ }
85
+ if (number.input.checked) parts.push(String(random[size] % 100));
86
+ if (symbol.input.checked) parts.push("!@#$%&*?"[random[size + 1] % 9]);
87
+
88
+ const join = separator.value === " " ? " " : separator.value;
89
+ phrase = parts.join(join);
90
+
91
+ // Each word is log2(WORDS.length) bits, because every index is equally
92
+ // likely. The optional number and symbol add about 6.6 and 3.2 bits.
93
+ const bits = size * Math.log2(WORDS.length) + (number.input.checked ? Math.log2(100) : 0) + (symbol.input.checked ? Math.log2(9) : 0);
94
+ // Half the space on average, at 100 billion guesses a second.
95
+ const seconds = 2 ** (bits - 1) / 1e11;
96
+ entropy.textContent = `${Math.round(bits)} bits · ${formatTime(seconds)} to guess at 100 billion tries a second`;
97
+ output.textContent = phrase;
98
+ }
99
+
100
+ function formatTime(seconds) {
101
+ if (seconds < 1) return "under a second";
102
+ if (seconds < 60) return `about ${Math.round(seconds)} seconds`;
103
+ const years = seconds / 31_557_600;
104
+ if (seconds < 3600) return `about ${Math.max(1, Math.round(seconds / 60))} minutes`;
105
+ if (seconds < 86400) return `about ${Math.round(seconds / 3600)} hours`;
106
+ if (years < 1) return `about ${Math.round(seconds / 86400)} days`;
107
+ if (years < 1000) return `about ${Math.round(years)} years`;
108
+ if (years < 1e6) return `about ${Math.round(years / 1000)} thousand years`;
109
+ if (years < 1e9) return `about ${Math.round(years / 1e6)} million years`;
110
+ return "longer than the universe has existed";
111
+ }
112
+
113
+ count.addEventListener("input", () => {
114
+ countLabel.textContent = `${count.value} word${count.value === "1" ? "" : "s"}`;
115
+ make();
116
+ });
117
+ separator.addEventListener("change", make);
118
+ capitalize.input.addEventListener("change", make);
119
+ number.input.addEventListener("change", make);
120
+ symbol.input.addEventListener("change", make);
121
+
122
+ const regenerate = button("Generate", { icon: "refresh-cw", kind: "fill", onclick: make });
123
+ const copy = copyButton(() => phrase, "Copy passphrase");
124
+
125
+ make();
126
+
127
+ const shell = toolcard({
128
+ body: bench(
129
+ stack(
130
+ el("div", { class: "grid gap-1.5" }, el("span", { class: "text-sm font-semibold text-ink-2" }, "Words"), count, countLabel),
131
+ panel("Separator", separator),
132
+ capitalize,
133
+ number,
134
+ symbol,
135
+ error
136
+ ),
137
+ stack(panelBody,
138
+ el("p", { class: "m-0 text-2xs leading-[1.6] text-ink-3" },
139
+ "The word list is bundled with the app, so this works offline. Each word is worth about 9 bits; five words is about 46 bits, which suits a login with rate limiting. Use more words for anything that protects money."))
140
+ ),
141
+ foot: actions(regenerate, copy),
142
+ });
143
+
144
+ slot.append(shell.node);
145
+
146
+ return {};
147
+ }
148
+
149
+ export default { mount };
@@ -0,0 +1,139 @@
1
+ // Turns a written password rule into the three things a team needs: the rule in
2
+ // plain words, a regex that enforces it, and a checklist that tests a sample.
3
+ // The regex is built from the same settings as the text, so the two never drift.
4
+ import {
5
+ el, toast, bench, stack, panel, numberInput, checkbox, textInput, button,
6
+ actions, toolcard, notice, copyButton, output,
7
+ } from "../core.js";
8
+
9
+ function escapeClass(chars) {
10
+ return chars.replace(/[\]\\^-]/g, "\\$&");
11
+ }
12
+
13
+ export function mount(slot) {
14
+ const minimum = numberInput({ min: "1", max: "128", step: "1", value: "12", "aria-label": "Minimum length" });
15
+ const maximum = numberInput({ min: "0", max: "512", step: "1", value: "0", "aria-label": "Maximum length, 0 for none" });
16
+ const lower = checkbox("Lowercase letters", { checked: true });
17
+ const upper = checkbox("Uppercase letters", { checked: true });
18
+ const digits = checkbox("Digits", { checked: true });
19
+ const symbols = checkbox("Symbols", { checked: false });
20
+ const noSpaces = checkbox("No spaces", { checked: true });
21
+ const noRepeat = numberInput({ min: "0", max: "10", step: "1", value: "0", "aria-label": "Max repeats in a row, 0 for none" });
22
+ const symbolSet = textInput({ value: "!@#$%^&*()-_=+[]{};:,.?", "aria-label": "Allowed symbols", class: "font-mono text-sm" });
23
+ const sample = textInput({ placeholder: "Type a password to test it", "aria-label": "Sample password", class: "font-mono", autocomplete: "off" });
24
+
25
+ const error = notice("warn");
26
+ const rule = el("p", { class: "m-0 text-sm leading-[1.6] text-ink" });
27
+ const pattern = output({ rows: 2 });
28
+ const checks = el("div", { class: "grid gap-1.5" });
29
+ const verdict = el("p", { class: "m-0 text-sm font-semibold" });
30
+
31
+ function classes() {
32
+ const out = [];
33
+ if (lower.input.checked) out.push({ label: "lowercase letter", test: (s) => /[a-z]/.test(s), rx: "(?=.*[a-z])" });
34
+ if (upper.input.checked) out.push({ label: "uppercase letter", test: (s) => /[A-Z]/.test(s), rx: "(?=.*[A-Z])" });
35
+ if (digits.input.checked) out.push({ label: "digit", test: (s) => /\d/.test(s), rx: "(?=.*\\d)" });
36
+ if (symbols.input.checked) {
37
+ const set = symbolSet.value;
38
+ const safe = escapeClass(set);
39
+ out.push({
40
+ label: `symbol from ${set || "the set"}`,
41
+ test: (s) => [...s].some((char) => set.includes(char)),
42
+ rx: `(?=.*[${safe}])`,
43
+ });
44
+ }
45
+ return out;
46
+ }
47
+
48
+ function build() {
49
+ error.write("");
50
+ const min = Math.max(1, Number(minimum.value) || 1);
51
+ const max = Number(maximum.value) || 0;
52
+ const kinds = classes();
53
+ const repeat = Number(noRepeat.value) || 0;
54
+
55
+ if (max && max < min) error.write("The maximum is below the minimum, so no password can pass.");
56
+ if (!kinds.length && !noSpaces.input.checked) error.write("Pick at least one character class, or the rule accepts anything.");
57
+
58
+ const parts = [`at least ${min} characters`];
59
+ if (max) parts.push(`at most ${max} characters`);
60
+ for (const kind of kinds) parts.push(`at least one ${kind.label}`);
61
+ if (noSpaces.input.checked) parts.push("no spaces");
62
+ if (repeat) parts.push(`no more than ${repeat} identical characters in a row`);
63
+ rule.textContent = `A password needs ${listSentence(parts)}.`;
64
+
65
+ const lookaheads = kinds.map((kind) => kind.rx).join("");
66
+ const length = max ? `{${min},${max}}` : `{${min},}`;
67
+ const space = noSpaces.input.checked ? "(?!.*\\s)" : "";
68
+ const repeats = repeat ? `(?!.*(.)\\1{${repeat}})` : "";
69
+ const regex = `^${lookaheads}${space}${repeats}.${length}$`;
70
+ pattern.write(regex);
71
+
72
+ checks.replaceChildren();
73
+ const samples = sample.value;
74
+ const lengthOk = samples.length >= min && (!max || samples.length <= max);
75
+ const rows = [
76
+ { label: `${min}${max ? `–${max}` : "+"} characters`, ok: samples.length > 0 ? lengthOk : null },
77
+ ...kinds.map((kind) => ({ label: kind.label, ok: samples ? kind.test(samples) : null })),
78
+ ];
79
+ if (noSpaces.input.checked) rows.push({ label: "no spaces", ok: samples ? !/\s/.test(samples) : null });
80
+ if (repeat) rows.push({ label: `no more than ${repeat} repeats in a row`, ok: samples ? !new RegExp(`(.)\\1{${repeat}}`).test(samples) : null });
81
+
82
+ checks.replaceChildren(
83
+ ...rows.map((row) =>
84
+ el("div", { class: "flex items-center gap-2 text-sm" },
85
+ el("span", {
86
+ class: `inline-flex size-5 flex-none items-center justify-center rounded-full text-2xs font-bold ${row.ok === null ? "bg-deck-2 text-ink-3" : row.ok ? "bg-jade-soft text-jade" : "bg-danger/15 text-danger"}`,
87
+ }, row.ok === null ? "·" : row.ok ? "✓" : "×"),
88
+ el("span", { class: row.ok === false ? "text-danger" : "text-ink-2" }, row.label)))
89
+ );
90
+
91
+ if (!samples) verdict.textContent = "";
92
+ else {
93
+ const failed = rows.filter((row) => row.ok === false).length;
94
+ verdict.textContent = failed === 0 ? "This password passes the rule." : `This password fails ${failed} part${failed === 1 ? "" : "s"} of the rule.`;
95
+ verdict.className = `m-0 text-sm font-semibold ${failed === 0 ? "text-jade" : "text-danger"}`;
96
+ }
97
+ }
98
+
99
+ function listSentence(items) {
100
+ if (items.length <= 1) return items.join("");
101
+ return `${items.slice(0, -1).join(", ")}, and ${items[items.length - 1]}`;
102
+ }
103
+
104
+ for (const node of [minimum, maximum, noRepeat, symbolSet, sample]) node.addEventListener("input", build);
105
+ for (const node of [lower, upper, digits, symbols, noSpaces]) node.input.addEventListener("change", build);
106
+
107
+ const copyRule = copyButton(() => `${rule.textContent}\nRegex: ${pattern.textContent}`, "Copy policy");
108
+
109
+ build();
110
+
111
+ const shell = toolcard({
112
+ body: bench(
113
+ stack(
114
+ el("div", { class: "grid grid-cols-2 gap-3" }, panel("Minimum", minimum), panel("Maximum", maximum)),
115
+ el("div", { class: "grid gap-1.5" },
116
+ el("span", { class: "text-sm font-semibold text-ink-2" }, "Character classes"),
117
+ el("div", { class: "grid grid-cols-2 gap-2" }, lower, upper, digits, symbols),
118
+ noSpaces),
119
+ panel("Allowed symbols", symbolSet),
120
+ panel("Max repeats in a row", noRepeat, "0 means no limit."),
121
+ error
122
+ ),
123
+ stack(
124
+ el("div", { class: "grid gap-2 rounded-r2 border border-orbit bg-deck-2 p-4" }, rule),
125
+ panel("Regex", pattern),
126
+ panel("Test a password", sample, "Shown in the page only. Nothing is sent."),
127
+ checks,
128
+ verdict
129
+ )
130
+ ),
131
+ foot: actions(copyRule),
132
+ });
133
+
134
+ slot.append(shell.node);
135
+
136
+ return {};
137
+ }
138
+
139
+ export default { mount };