@enigmax/primitives 0.6.0 → 0.7.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.
@@ -293,8 +293,21 @@ function createMarquee(lane, track, options = {}) {
293
293
  }
294
294
 
295
295
  // src/core/input.ts
296
- var EYE = '<svg viewBox="0 0 24 24" width="1em" height="1em" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/></svg>';
297
- var EYE_OFF = '<svg viewBox="0 0 24 24" width="1em" height="1em" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10.6 5.2A9.8 9.8 0 0 1 12 5c6.5 0 10 7 10 7a17.6 17.6 0 0 1-3.2 4.2M6.2 6.2A17.7 17.7 0 0 0 2 12s3.5 7 10 7a9.6 9.6 0 0 0 4.2-.9"/><path d="m2 2 20 20"/><path d="M9.9 9.9a3 3 0 0 0 4.2 4.2"/></svg>';
296
+ var INPUT_ICON_PATHS = {
297
+ eye: ["M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z", "M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"],
298
+ eyeOff: [
299
+ "M10.6 5.2A9.8 9.8 0 0 1 12 5c6.5 0 10 7 10 7a17.6 17.6 0 0 1-3.2 4.2M6.2 6.2A17.7 17.7 0 0 0 2 12s3.5 7 10 7a9.6 9.6 0 0 0 4.2-.9",
300
+ "m2 2 20 20",
301
+ "M9.9 9.9a3 3 0 0 0 4.2 4.2"
302
+ ],
303
+ generate: ["m12 3 1.9 4.6L18.5 9.5 13.9 11.4 12 16l-1.9-4.6L5.5 9.5l4.6-1.9Z", "M19 15l.8 2.2 2.2.8-2.2.8-.8 2.2-.8-2.2-2.2-.8 2.2-.8Z"]
304
+ };
305
+ function iconMarkup(paths) {
306
+ const body = paths.map((path) => `<path d="${path}"/>`).join("");
307
+ return `<svg viewBox="0 0 24 24" width="1em" height="1em" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${body}</svg>`;
308
+ }
309
+ var EYE = iconMarkup(INPUT_ICON_PATHS.eye);
310
+ var EYE_OFF = iconMarkup(INPUT_ICON_PATHS.eyeOff);
298
311
  function resolve(value, state) {
299
312
  return typeof value === "function" ? value(state) : value;
300
313
  }
@@ -637,4 +650,171 @@ function createButton(options = {}) {
637
650
  };
638
651
  }
639
652
 
640
- export { createButton, createInput, createMarquee };
653
+ // src/core/password.ts
654
+ var LOWERCASE = "abcdefghijklmnopqrstuvwxyz";
655
+ var UPPERCASE = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
656
+ var DIGITS = "0123456789";
657
+ var SYMBOLS = "!@#$%^&*()-_=+[]{};:,.?";
658
+ var AMBIGUOUS = "Il1O0";
659
+ function randomIndex(bound) {
660
+ const random = globalThis.crypto?.getRandomValues?.bind(globalThis.crypto);
661
+ if (!random) {
662
+ throw new Error("Generating a password needs crypto.getRandomValues, which browsers only expose over HTTPS (or on localhost).");
663
+ }
664
+ const limit = Math.floor(2 ** 32 / bound) * bound;
665
+ const buffer = new Uint32Array(1);
666
+ let value;
667
+ do {
668
+ random(buffer);
669
+ value = buffer[0];
670
+ } while (value >= limit);
671
+ return value % bound;
672
+ }
673
+ function pick(alphabet) {
674
+ return alphabet[randomIndex(alphabet.length)];
675
+ }
676
+ function classes(options) {
677
+ const { lowercase = true, uppercase = true, digits = true, symbols = true, excludeAmbiguous = false, exclude = "" } = options;
678
+ const banned = /* @__PURE__ */ new Set([...excludeAmbiguous ? AMBIGUOUS : "", ...exclude]);
679
+ const clean = (source) => [...source].filter((character) => !banned.has(character)).join("");
680
+ return [
681
+ lowercase ? clean(LOWERCASE) : "",
682
+ uppercase ? clean(UPPERCASE) : "",
683
+ digits ? clean(DIGITS) : "",
684
+ symbols ? clean(SYMBOLS) : ""
685
+ ].filter(Boolean);
686
+ }
687
+ function shuffle(characters) {
688
+ for (let index = characters.length - 1; index > 0; index--) {
689
+ const swap = randomIndex(index + 1);
690
+ [characters[index], characters[swap]] = [characters[swap], characters[index]];
691
+ }
692
+ return characters;
693
+ }
694
+ function generatePassword(options = {}) {
695
+ const { length = 20, requireEachClass = true } = options;
696
+ const pools = classes(options);
697
+ if (!pools.length) throw new Error("generatePassword: every character class was excluded.");
698
+ if (length < 1) throw new Error("generatePassword: length must be at least 1.");
699
+ if (requireEachClass && length < pools.length) {
700
+ throw new Error(`generatePassword: length ${length} cannot hold one character from each of the ${pools.length} classes requested.`);
701
+ }
702
+ const everything = pools.join("");
703
+ const required = requireEachClass ? pools.map(pick) : [];
704
+ const rest = Array.from({ length: length - required.length }, () => pick(everything));
705
+ return shuffle([...required, ...rest]).join("");
706
+ }
707
+ var COMMON = /* @__PURE__ */ new Set([
708
+ "password",
709
+ "passwd",
710
+ "123456",
711
+ "12345678",
712
+ "123456789",
713
+ "1234567890",
714
+ "qwerty",
715
+ "qwertyuiop",
716
+ "abc123",
717
+ "111111",
718
+ "123123",
719
+ "admin",
720
+ "letmein",
721
+ "welcome",
722
+ "monkey",
723
+ "dragon",
724
+ "sunshine",
725
+ "iloveyou",
726
+ "princess",
727
+ "football",
728
+ "baseball",
729
+ "master",
730
+ "shadow",
731
+ "superman",
732
+ "batman",
733
+ "trustno1",
734
+ "hello",
735
+ "freedom",
736
+ "whatever",
737
+ "starwars",
738
+ "changeme",
739
+ "secret",
740
+ "login",
741
+ "root",
742
+ "toor",
743
+ "test",
744
+ "guest",
745
+ "azerty",
746
+ "1q2w3e4r",
747
+ "zaq12wsx"
748
+ ]);
749
+ var SEQUENCES = ["abcdefghijklmnopqrstuvwxyz", "0123456789", "qwertyuiop", "asdfghjkl", "zxcvbnm"];
750
+ function poolSize(password) {
751
+ let size = 0;
752
+ if (/[a-z]/.test(password)) size += 26;
753
+ if (/[A-Z]/.test(password)) size += 26;
754
+ if (/\d/.test(password)) size += 10;
755
+ if (/[^\w\s]|_/.test(password)) size += SYMBOLS.length;
756
+ if (/\s/.test(password)) size += 1;
757
+ return size || 1;
758
+ }
759
+ function longestRun(password) {
760
+ let longest = 1, run = 1;
761
+ for (let index = 1; index < password.length; index++) {
762
+ run = password[index] === password[index - 1] ? run + 1 : 1;
763
+ longest = Math.max(longest, run);
764
+ }
765
+ return longest;
766
+ }
767
+ function longestSequence(password) {
768
+ const lower = password.toLowerCase();
769
+ let longest = 0;
770
+ for (const line of SEQUENCES) {
771
+ const reversed = [...line].reverse().join("");
772
+ for (const source of [line, reversed]) {
773
+ for (let start = 0; start < source.length; start++) {
774
+ for (let end = source.length; end > start + longest; end--) {
775
+ if (lower.includes(source.slice(start, end))) {
776
+ longest = Math.max(longest, end - start);
777
+ break;
778
+ }
779
+ }
780
+ }
781
+ }
782
+ }
783
+ return longest;
784
+ }
785
+ function core(password) {
786
+ return password.toLowerCase().replace(/^[^a-z]+/, "").replace(/[^a-z]+$/, "").replace(/[0!@$]/g, (character) => ({ "0": "o", "!": "i", "@": "a", "$": "s" })[character] ?? character);
787
+ }
788
+ function estimatePasswordStrength(password, options = {}) {
789
+ if (!password) return { score: 0, bits: 0, warnings: [], empty: true };
790
+ const warnings = [];
791
+ let bits = password.length * Math.log2(poolSize(password));
792
+ const stripped = core(password);
793
+ if (COMMON.has(password.toLowerCase()) || COMMON.has(stripped)) {
794
+ bits = Math.min(bits, 8);
795
+ warnings.push("This is one of the most common passwords there is.");
796
+ }
797
+ for (const input of options.userInputs ?? []) {
798
+ const needle = input.trim().toLowerCase();
799
+ if (needle.length < 4 || !password.toLowerCase().includes(needle)) continue;
800
+ bits = Math.min(bits, 16);
801
+ warnings.push("It contains something you already typed on this form.");
802
+ break;
803
+ }
804
+ const run = longestRun(password);
805
+ if (run >= 3) {
806
+ bits -= (run - 2) * Math.log2(poolSize(password));
807
+ warnings.push("A character repeats several times in a row.");
808
+ }
809
+ const sequence = longestSequence(password);
810
+ if (sequence >= 4) {
811
+ bits -= sequence * Math.log2(poolSize(password)) * 0.75;
812
+ warnings.push("Part of it runs straight along the keyboard or the alphabet.");
813
+ }
814
+ if (password.length < 8) warnings.push("Short passwords fall to a brute force whatever they contain.");
815
+ bits = Math.max(0, Math.round(bits));
816
+ const score = bits < 28 ? 0 : bits < 40 ? 1 : bits < 60 ? 2 : bits < 80 ? 3 : 4;
817
+ return { score, bits, warnings, empty: false };
818
+ }
819
+
820
+ export { INPUT_ICON_PATHS, createButton, createInput, createMarquee, estimatePasswordStrength, generatePassword, iconMarkup };
package/dist/index.d.ts CHANGED
@@ -155,6 +155,21 @@ interface InputInstance {
155
155
  update(options: Partial<InputOptions>): void;
156
156
  destroy(): void;
157
157
  }
158
+ /**
159
+ * The built-in glyphs, as path data.
160
+ *
161
+ * Path data rather than markup because there are two renderers: this file writes an SVG
162
+ * string into a button it created, and the React component builds elements. Keeping the
163
+ * shapes here means one definition, and a theme that replaces an icon replaces it in both.
164
+ * Everything is stroked with `currentColor` at 1em, so an icon inherits the field's text.
165
+ */
166
+ declare const INPUT_ICON_PATHS: {
167
+ readonly eye: readonly ["M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z", "M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"];
168
+ readonly eyeOff: readonly ["M10.6 5.2A9.8 9.8 0 0 1 12 5c6.5 0 10 7 10 7a17.6 17.6 0 0 1-3.2 4.2M6.2 6.2A17.7 17.7 0 0 0 2 12s3.5 7 10 7a9.6 9.6 0 0 0 4.2-.9", "m2 2 20 20", "M9.9 9.9a3 3 0 0 0 4.2 4.2"];
169
+ readonly generate: readonly ["m12 3 1.9 4.6L18.5 9.5 13.9 11.4 12 16l-1.9-4.6L5.5 9.5l4.6-1.9Z", "M19 15l.8 2.2 2.2.8-2.2.8-.8 2.2-.8-2.2-2.2-.8 2.2-.8Z"];
170
+ };
171
+ /** The same shapes as a standalone SVG string, for the DOM renderer below. */
172
+ declare function iconMarkup(paths: readonly string[]): string;
158
173
  /**
159
174
  * Wire a field's in-field actions.
160
175
  *
@@ -224,4 +239,74 @@ interface ButtonInstance {
224
239
  }
225
240
  declare function createButton(options?: ButtonOptions): ButtonInstance;
226
241
 
227
- export { type ButtonCooldown, type ButtonElement, type ButtonInstance, type ButtonOptions, type ButtonState, type InputAction, type InputActionState, type InputIcon, type InputInstance, type InputOptions, type MarqueeHover, type MarqueeInstance, type MarqueeOptions, createButton, createInput, createMarquee };
242
+ /**
243
+ * Making a password, and judging one.
244
+ *
245
+ * Both are opt-in. A sign-in form wants neither: offering to generate a password where one
246
+ * already exists is noise, and scoring one the visitor cannot change is worse. They belong
247
+ * on a registration form and a change-password form, which is where an agent should switch
248
+ * them on.
249
+ */
250
+ /** Character classes a generated password can draw from. */
251
+ interface PasswordAlphabet {
252
+ lowercase?: boolean;
253
+ uppercase?: boolean;
254
+ digits?: boolean;
255
+ symbols?: boolean;
256
+ }
257
+ interface GeneratePasswordOptions extends PasswordAlphabet {
258
+ /** Default 20. Long beats clever: length is the only term that scales. */
259
+ length?: number;
260
+ /**
261
+ * Drop the characters that are read wrong off a screen or off paper - I l 1 O 0.
262
+ * Worth it when the password will be typed by hand, not worth the entropy otherwise.
263
+ */
264
+ excludeAmbiguous?: boolean;
265
+ /** Characters to remove from every class, e.g. ones your backend rejects. */
266
+ exclude?: string;
267
+ /**
268
+ * Guarantee at least one character from every class asked for. Most password policies
269
+ * demand it; it costs a little entropy, because it removes every password that happens
270
+ * to lack one.
271
+ */
272
+ requireEachClass?: boolean;
273
+ }
274
+ /**
275
+ * A random password from the classes asked for.
276
+ *
277
+ * @throws when the runtime has no CSPRNG, or when the options ask for something impossible
278
+ * (every class excluded, or a length too short to hold one of each).
279
+ */
280
+ declare function generatePassword(options?: GeneratePasswordOptions): string;
281
+ type PasswordScore = 0 | 1 | 2 | 3 | 4;
282
+ interface PasswordStrengthReport {
283
+ /** 0 worst, 4 best. What the bars under the field render. */
284
+ score: PasswordScore;
285
+ /** Estimated bits of entropy after the penalties below. */
286
+ bits: number;
287
+ /**
288
+ * Why it scored what it scored, worst first. Show the first one; showing all of them
289
+ * turns a hint into a lecture.
290
+ */
291
+ warnings: string[];
292
+ /** Empty field. Render nothing rather than a zero score, which reads as a failure. */
293
+ empty: boolean;
294
+ }
295
+ interface EstimateOptions {
296
+ /**
297
+ * Values the visitor has already typed elsewhere - email, name, company. A password
298
+ * containing one of them is guessable by anyone who has the sign-up form in front of
299
+ * them, and no character-class rule catches it.
300
+ */
301
+ userInputs?: string[];
302
+ }
303
+ /**
304
+ * Score a password.
305
+ *
306
+ * The bits are an estimate and the bands are a convention, not a measurement - they exist
307
+ * to move a bar, not to certify anything. Swap this out for zxcvbn where the number has to
308
+ * mean something, and check the breach corpus for the cases no estimator can see.
309
+ */
310
+ declare function estimatePasswordStrength(password: string, options?: EstimateOptions): PasswordStrengthReport;
311
+
312
+ export { type ButtonCooldown, type ButtonElement, type ButtonInstance, type ButtonOptions, type ButtonState, type EstimateOptions, type GeneratePasswordOptions, INPUT_ICON_PATHS, type InputAction, type InputActionState, type InputIcon, type InputInstance, type InputOptions, type MarqueeHover, type MarqueeInstance, type MarqueeOptions, type PasswordAlphabet, type PasswordScore, type PasswordStrengthReport, createButton, createInput, createMarquee, estimatePasswordStrength, generatePassword, iconMarkup };
package/dist/index.js CHANGED
@@ -1,2 +1,2 @@
1
- export { createButton, createInput, createMarquee } from './chunk-WCB7V7XO.js';
1
+ export { INPUT_ICON_PATHS, createButton, createInput, createMarquee, estimatePasswordStrength, generatePassword, iconMarkup } from './chunk-42Y7OOJM.js';
2
2
  export { createSearch } from './chunk-UZFEEFMF.js';
@@ -1,6 +1,7 @@
1
- import { RefObject } from 'react';
2
- import { MarqueeOptions, InputOptions, ButtonState, ButtonOptions } from '../index.js';
3
- export { InputAction, InputIcon, MarqueeHover, MarqueeInstance } from '../index.js';
1
+ import * as react from 'react';
2
+ import { RefObject, ReactNode, ComponentPropsWithoutRef } from 'react';
3
+ import { MarqueeOptions, InputOptions, ButtonState, ButtonOptions, GeneratePasswordOptions, EstimateOptions, PasswordStrengthReport } from '../index.js';
4
+ export { InputAction, InputIcon, MarqueeHover, MarqueeInstance, PasswordScore, estimatePasswordStrength, generatePassword } from '../index.js';
4
5
  import { b as SearchMatch, c as SearchOptions } from '../search-CsO3L1Lw.js';
5
6
  export { F as FuseConstructor } from '../search-CsO3L1Lw.js';
6
7
 
@@ -128,4 +129,114 @@ interface UseButtonResult extends ButtonState {
128
129
  */
129
130
  declare function useButton(options?: ButtonOptions): UseButtonResult;
130
131
 
131
- export { ButtonOptions, ButtonState, InputOptions, MarqueeOptions, SearchMatch, SearchOptions, type UseButtonResult, type UseInputResult, type UseMarqueeResult, type UseSearchResult, useButton, useInput, useMarquee, useSearch };
132
+ /** An extra button inside the field. The built-ins are the same shape. */
133
+ interface FieldAction {
134
+ /** Stable id. Lands on `data-enigma-input-action`, and replaces a built-in of the same name. */
135
+ name: string;
136
+ /** Accessible name. Becomes `aria-label` and `title`. */
137
+ label: string;
138
+ icon: ReactNode;
139
+ onSelect: () => void;
140
+ /** Renders `aria-pressed`. Omit for actions that are not toggles. */
141
+ pressed?: boolean;
142
+ /** Default true. A false action is not rendered at all. */
143
+ visible?: boolean;
144
+ }
145
+ type BreachStatus = "idle" | "checking" | "safe" | "breached" | "error";
146
+ interface BreachState {
147
+ status: BreachStatus;
148
+ /** How many breaches the password appears in. 0 unless `status` is "breached". */
149
+ count: number;
150
+ /** Whatever the checker threw. The form decides whether that is worth showing. */
151
+ error: unknown;
152
+ }
153
+ type BreachChecker = (password: string, options: {
154
+ signal: AbortSignal;
155
+ }) => Promise<{
156
+ breached: boolean;
157
+ count: number;
158
+ }>;
159
+ interface InputProps extends Omit<ComponentPropsWithoutRef<"input">, "children"> {
160
+ /** The reveal toggle. Defaults to on for `type="password"` and off for everything else. */
161
+ reveal?: boolean;
162
+ revealLabels?: {
163
+ show?: string;
164
+ hide?: string;
165
+ };
166
+ /** Offer to generate a password. `true` for the defaults, or the generator's options. */
167
+ generate?: boolean | GeneratePasswordOptions;
168
+ generateLabel?: string;
169
+ /** Show what was generated. On by default - a password nobody can read is not usable. */
170
+ revealOnGenerate?: boolean;
171
+ /**
172
+ * Also copy it to the clipboard. OFF by default and worth leaving off: the clipboard is
173
+ * shared with every other app on the machine and is not cleared.
174
+ */
175
+ copyOnGenerate?: boolean;
176
+ onGenerate?: (password: string) => void;
177
+ /** Extra buttons, or a replacement for `reveal` / `generate` by name. */
178
+ actions?: FieldAction[];
179
+ /** Which end the buttons sit at. Position them yourself; this only orders the markup. */
180
+ position?: "start" | "end";
181
+ /** Score the password as it is typed, and render the meter under the field. */
182
+ strength?: boolean | EstimateOptions;
183
+ onStrengthChange?: (report: PasswordStrengthReport) => void;
184
+ /**
185
+ * Check the password against a breach corpus - pass `checkPasswordBreach` from
186
+ * @enigmax/utils, or your own. It is a prop rather than a built-in because it makes a
187
+ * network request, and that is not a decision a field should take on its own.
188
+ */
189
+ breach?: BreachChecker;
190
+ /** Quiet time before a check fires, in ms. Default 500. */
191
+ breachDelay?: number;
192
+ onBreachChange?: (state: BreachState) => void;
193
+ /** Props for the element wrapping the field, its buttons and the meter. */
194
+ wrapperProps?: ComponentPropsWithoutRef<"div">;
195
+ /** Props for the row holding the field and its buttons - this is what you position. */
196
+ fieldProps?: ComponentPropsWithoutRef<"div">;
197
+ /**
198
+ * Classes for the parts you cannot reach with a ref, which is what Tailwind needs.
199
+ * `className` still goes to the `<input>` itself, where you would expect it.
200
+ */
201
+ classNames?: {
202
+ actions?: string;
203
+ action?: string;
204
+ strength?: PasswordStrengthProps["classNames"];
205
+ };
206
+ /** Rendered inside the wrapper, after the meter. Your error message goes here. */
207
+ children?: ReactNode;
208
+ }
209
+ declare const Input: react.ForwardRefExoticComponent<InputProps & react.RefAttributes<HTMLInputElement>>;
210
+ interface PasswordStrengthProps extends Omit<ComponentPropsWithoutRef<"div">, "children"> {
211
+ /** The password to score. Ignored when `report` is given. */
212
+ value?: string;
213
+ /** A report you already have, e.g. from `<Input onStrengthChange>`. */
214
+ report?: PasswordStrengthReport;
215
+ userInputs?: string[];
216
+ /** Bars to draw. Five, so each score has one of its own. */
217
+ segments?: number;
218
+ /** Your own wording, worst first. */
219
+ labels?: readonly string[];
220
+ /** Show the top warning under the bars. On by default; it is the useful half. */
221
+ showWarning?: boolean;
222
+ /**
223
+ * Classes for the inner parts. The score is on the ROOT, so a segment colours itself
224
+ * with a group variant - `group-data-[score=0]/strength:bg-red-600` and so on.
225
+ */
226
+ classNames?: {
227
+ track?: string;
228
+ segment?: string;
229
+ label?: string;
230
+ warning?: string;
231
+ };
232
+ }
233
+ /**
234
+ * The bars under a password field.
235
+ *
236
+ * Structure and state only - `data-score` on the root and `data-filled` per segment are
237
+ * where the colours attach. The component picks no colours, because red-through-green is a
238
+ * palette decision and this package does not own one.
239
+ */
240
+ declare function PasswordStrength({ value, report, userInputs, segments, labels, showWarning, classNames, ...props }: PasswordStrengthProps): ReactNode;
241
+
242
+ export { type BreachChecker, type BreachState, type BreachStatus, ButtonOptions, ButtonState, EstimateOptions, type FieldAction, GeneratePasswordOptions, Input, InputOptions, type InputProps, MarqueeOptions, PasswordStrength, type PasswordStrengthProps, PasswordStrengthReport, SearchMatch, SearchOptions, type UseButtonResult, type UseInputResult, type UseMarqueeResult, type UseSearchResult, useButton, useInput, useMarquee, useSearch };