@olympusoss/canvas 2.10.0 → 2.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/components/atoms/spinner.tsx +47 -0
- package/src/components/molecules/auth-shell.tsx +95 -0
- package/src/components/molecules/client-brand.tsx +95 -0
- package/src/components/molecules/countdown-button.tsx +92 -0
- package/src/components/molecules/password-input.tsx +83 -0
- package/src/components/molecules/password-strength-meter.tsx +104 -0
- package/src/index.ts +24 -0
package/package.json
CHANGED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Loader2 } from "lucide-react";
|
|
4
|
+
import * as React from "react";
|
|
5
|
+
|
|
6
|
+
import { cn } from "../../lib/utils";
|
|
7
|
+
|
|
8
|
+
export interface SpinnerProps extends React.SVGAttributes<SVGSVGElement> {
|
|
9
|
+
/**
|
|
10
|
+
* Pixel size of the rendered SVG.
|
|
11
|
+
* @default 16
|
|
12
|
+
*/
|
|
13
|
+
size?: number;
|
|
14
|
+
/** Tailwind / CSS classes merged via `cn()`. */
|
|
15
|
+
className?: string;
|
|
16
|
+
/**
|
|
17
|
+
* Accessible label announced by screen readers. Defaults to `"Loading"`.
|
|
18
|
+
* Pass an empty string to mark as purely decorative when the surrounding
|
|
19
|
+
* label already announces the loading state.
|
|
20
|
+
*/
|
|
21
|
+
label?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Inline loading indicator. Wraps `Loader2` from lucide-react with the
|
|
26
|
+
* canvas `animate-spin` keyframe and an accessible label.
|
|
27
|
+
*
|
|
28
|
+
* Use inside buttons (`{loading ? <Spinner/> : 'Submit'}`), next to inline
|
|
29
|
+
* labels, or anywhere a small spinning indicator is appropriate. For a
|
|
30
|
+
* full-section loader with a message, use `LoadingState` (molecule).
|
|
31
|
+
*/
|
|
32
|
+
const Spinner = React.forwardRef<SVGSVGElement, SpinnerProps>(
|
|
33
|
+
({ size = 16, className, label = "Loading", ...props }, ref) => (
|
|
34
|
+
<Loader2
|
|
35
|
+
ref={ref}
|
|
36
|
+
size={size}
|
|
37
|
+
className={cn("animate-spin shrink-0", className)}
|
|
38
|
+
role={label ? "status" : undefined}
|
|
39
|
+
aria-label={label || undefined}
|
|
40
|
+
aria-hidden={label ? undefined : true}
|
|
41
|
+
{...props}
|
|
42
|
+
/>
|
|
43
|
+
),
|
|
44
|
+
);
|
|
45
|
+
Spinner.displayName = "Spinner";
|
|
46
|
+
|
|
47
|
+
export { Spinner };
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
|
|
5
|
+
import { cn } from "../../lib/utils";
|
|
6
|
+
import { Card, CardContent, CardHeader } from "./card";
|
|
7
|
+
|
|
8
|
+
export interface AuthShellProps {
|
|
9
|
+
/**
|
|
10
|
+
* Brand lockup rendered above the card. Compose a `ClientBrand` (or
|
|
11
|
+
* your own component) and pass it in. Slot is left empty to keep this
|
|
12
|
+
* shell unaware of OAuth2 / Hydra specifics.
|
|
13
|
+
*/
|
|
14
|
+
brand?: React.ReactNode;
|
|
15
|
+
/** Card title. Pairs with `subtitle`. */
|
|
16
|
+
title?: React.ReactNode;
|
|
17
|
+
/** Optional one-line subtitle shown beneath `title`. */
|
|
18
|
+
subtitle?: React.ReactNode;
|
|
19
|
+
/** Card body content. */
|
|
20
|
+
children?: React.ReactNode;
|
|
21
|
+
/**
|
|
22
|
+
* Optional footer line beneath the card (legal / privacy / support).
|
|
23
|
+
*/
|
|
24
|
+
footer?: React.ReactNode;
|
|
25
|
+
/**
|
|
26
|
+
* Card width preset. `default` = 408px (single-column flows like login,
|
|
27
|
+
* recovery), `wide` = 460px (multi-step or denser flows like consent).
|
|
28
|
+
* @default "default"
|
|
29
|
+
*/
|
|
30
|
+
width?: "default" | "wide";
|
|
31
|
+
/**
|
|
32
|
+
* Allow children to break the card frame (useful for full-bleed flow
|
|
33
|
+
* pickers or media). When `false` the card content gets the standard
|
|
34
|
+
* `p-6` padding.
|
|
35
|
+
* @default false
|
|
36
|
+
*/
|
|
37
|
+
flush?: boolean;
|
|
38
|
+
/** Tailwind / CSS classes merged onto the outer wrapper via `cn()`. */
|
|
39
|
+
className?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Centered single-card layout for auth flows.
|
|
44
|
+
*
|
|
45
|
+
* Renders, top to bottom:
|
|
46
|
+
* 1. Brand slot (`brand`) above the card
|
|
47
|
+
* 2. Card with optional centred title + subtitle, then `children`
|
|
48
|
+
* 3. Optional footer line beneath the card
|
|
49
|
+
*
|
|
50
|
+
* Layout is intentionally narrow and prescriptive: this is the
|
|
51
|
+
* agreed-upon shape for every Olympus auth screen (login, register,
|
|
52
|
+
* recovery, reset, verify, OTP, consent, logout, status). For app shells
|
|
53
|
+
* with sidebars or multi-pane layouts, compose your own flex shell
|
|
54
|
+
* directly (see `Sidebar` + `SidebarInset`).
|
|
55
|
+
*/
|
|
56
|
+
const AuthShell = React.forwardRef<HTMLDivElement, AuthShellProps>(
|
|
57
|
+
(
|
|
58
|
+
{ brand, title, subtitle, children, footer, width = "default", flush = false, className },
|
|
59
|
+
ref,
|
|
60
|
+
) => (
|
|
61
|
+
<div
|
|
62
|
+
ref={ref}
|
|
63
|
+
className={cn(
|
|
64
|
+
"relative z-10 flex min-h-screen flex-col items-center justify-center px-6 py-12",
|
|
65
|
+
className,
|
|
66
|
+
)}
|
|
67
|
+
>
|
|
68
|
+
{brand && <div className="mb-7">{brand}</div>}
|
|
69
|
+
|
|
70
|
+
<Card
|
|
71
|
+
className={cn(
|
|
72
|
+
"w-full shadow-lg shadow-black/5 dark:shadow-black/30",
|
|
73
|
+
width === "wide" ? "max-w-[460px]" : "max-w-[408px]",
|
|
74
|
+
)}
|
|
75
|
+
>
|
|
76
|
+
{title && (
|
|
77
|
+
<CardHeader className={cn("text-center", subtitle ? "pb-3" : "pb-4")}>
|
|
78
|
+
<h1 className="text-xl font-semibold tracking-tight">{title}</h1>
|
|
79
|
+
{subtitle && <p className="mt-1.5 text-sm text-muted-foreground">{subtitle}</p>}
|
|
80
|
+
</CardHeader>
|
|
81
|
+
)}
|
|
82
|
+
<CardContent className={cn(flush ? "p-0" : "pt-0", !title && !flush && "pt-6")}>
|
|
83
|
+
{children}
|
|
84
|
+
</CardContent>
|
|
85
|
+
</Card>
|
|
86
|
+
|
|
87
|
+
{footer && (
|
|
88
|
+
<div className="mt-6 max-w-[460px] text-center text-xs text-muted-foreground">{footer}</div>
|
|
89
|
+
)}
|
|
90
|
+
</div>
|
|
91
|
+
),
|
|
92
|
+
);
|
|
93
|
+
AuthShell.displayName = "AuthShell";
|
|
94
|
+
|
|
95
|
+
export { AuthShell };
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
|
|
5
|
+
import { cn } from "../../lib/utils";
|
|
6
|
+
|
|
7
|
+
export interface ClientBrandProps {
|
|
8
|
+
/** OAuth2 client display name (e.g. "Questrade", "Athena"). */
|
|
9
|
+
clientName: string;
|
|
10
|
+
/**
|
|
11
|
+
* Optional logo URI. Renders as an `<img>` next to the wordmark when
|
|
12
|
+
* present. When absent, a tinted monogram tile is rendered using the
|
|
13
|
+
* first letter of `clientName`.
|
|
14
|
+
*/
|
|
15
|
+
clientLogoUri?: string;
|
|
16
|
+
/**
|
|
17
|
+
* Pixel size of the logo/monogram tile.
|
|
18
|
+
* @default 36
|
|
19
|
+
*/
|
|
20
|
+
size?: number;
|
|
21
|
+
/**
|
|
22
|
+
* Show the wordmark next to the logo.
|
|
23
|
+
* @default true
|
|
24
|
+
*/
|
|
25
|
+
showWordmark?: boolean;
|
|
26
|
+
/**
|
|
27
|
+
* Background colour applied to the monogram fallback tile. Honours
|
|
28
|
+
* Tailwind classes or a raw CSS colour.
|
|
29
|
+
* @default "bg-foreground text-background"
|
|
30
|
+
*/
|
|
31
|
+
monogramClassName?: string;
|
|
32
|
+
/** Tailwind / CSS classes merged onto the root via `cn()`. */
|
|
33
|
+
className?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* OAuth2 client lockup. Renders the requesting application's logo + name
|
|
38
|
+
* so users see "you're signing in to [App]", not the identity-provider
|
|
39
|
+
* platform underneath.
|
|
40
|
+
*
|
|
41
|
+
* Provide `clientLogoUri` to render the client's logo image. Falls back to
|
|
42
|
+
* a monogram tile when no URI is supplied or the image fails to load.
|
|
43
|
+
*
|
|
44
|
+
* Designed for the brand slot of `AuthShell` / sign-in pages. Stays a thin
|
|
45
|
+
* presentation primitive: no client-registry lookups, no fetches. Pass the
|
|
46
|
+
* shaped data in from your auth backend.
|
|
47
|
+
*/
|
|
48
|
+
const ClientBrand = React.forwardRef<HTMLDivElement, ClientBrandProps>(
|
|
49
|
+
(
|
|
50
|
+
{ clientName, clientLogoUri, size = 36, showWordmark = true, monogramClassName, className },
|
|
51
|
+
ref,
|
|
52
|
+
) => {
|
|
53
|
+
const [imgFailed, setImgFailed] = React.useState(false);
|
|
54
|
+
const showImage = !!clientLogoUri && !imgFailed;
|
|
55
|
+
const initial = (clientName?.trim()?.[0] || "?").toUpperCase();
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<div
|
|
59
|
+
ref={ref}
|
|
60
|
+
role="img"
|
|
61
|
+
aria-label={`Signing in to ${clientName}`}
|
|
62
|
+
className={cn("flex items-center gap-2.5", className)}
|
|
63
|
+
>
|
|
64
|
+
{showImage ? (
|
|
65
|
+
<img
|
|
66
|
+
src={clientLogoUri}
|
|
67
|
+
alt=""
|
|
68
|
+
width={size}
|
|
69
|
+
height={size}
|
|
70
|
+
className="shrink-0 rounded-lg object-contain"
|
|
71
|
+
style={{ width: size, height: size }}
|
|
72
|
+
onError={() => setImgFailed(true)}
|
|
73
|
+
/>
|
|
74
|
+
) : (
|
|
75
|
+
<div
|
|
76
|
+
className={cn(
|
|
77
|
+
"flex shrink-0 items-center justify-center rounded-lg font-semibold",
|
|
78
|
+
monogramClassName || "bg-foreground text-background",
|
|
79
|
+
)}
|
|
80
|
+
style={{ width: size, height: size, fontSize: Math.round(size * 0.5) }}
|
|
81
|
+
aria-hidden
|
|
82
|
+
>
|
|
83
|
+
{initial}
|
|
84
|
+
</div>
|
|
85
|
+
)}
|
|
86
|
+
{showWordmark && (
|
|
87
|
+
<span className="text-lg font-semibold tracking-tight text-foreground">{clientName}</span>
|
|
88
|
+
)}
|
|
89
|
+
</div>
|
|
90
|
+
);
|
|
91
|
+
},
|
|
92
|
+
);
|
|
93
|
+
ClientBrand.displayName = "ClientBrand";
|
|
94
|
+
|
|
95
|
+
export { ClientBrand };
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
|
|
5
|
+
import { cn } from "../../lib/utils";
|
|
6
|
+
import { Button, type ButtonProps } from "../atoms/button";
|
|
7
|
+
|
|
8
|
+
export interface CountdownButtonProps
|
|
9
|
+
extends Omit<ButtonProps, "disabled" | "onClick" | "children"> {
|
|
10
|
+
/**
|
|
11
|
+
* Seconds to wait before the button becomes clickable. The countdown
|
|
12
|
+
* starts when the component mounts and resets every time `triggerKey`
|
|
13
|
+
* changes.
|
|
14
|
+
* @default 30
|
|
15
|
+
*/
|
|
16
|
+
duration?: number;
|
|
17
|
+
/**
|
|
18
|
+
* Bump this value to restart the countdown (e.g. after a successful
|
|
19
|
+
* resend, set it to `Date.now()` so the button locks out again).
|
|
20
|
+
*/
|
|
21
|
+
triggerKey?: string | number;
|
|
22
|
+
/** Called when the button is clicked and the countdown has elapsed. */
|
|
23
|
+
onClick?: () => void;
|
|
24
|
+
/**
|
|
25
|
+
* Render-prop for the button label. Receives `secondsLeft` (0 when
|
|
26
|
+
* ready). Default renders `"Resend"` / `"Resend in 23s"`.
|
|
27
|
+
*/
|
|
28
|
+
children?: React.ReactNode | ((secondsLeft: number) => React.ReactNode);
|
|
29
|
+
/**
|
|
30
|
+
* Action verb shown in the default label. Used as `${verb}` when ready
|
|
31
|
+
* and `${verb} in Ns` while counting down.
|
|
32
|
+
* @default "Resend"
|
|
33
|
+
*/
|
|
34
|
+
verb?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Button that locks out for a duration after mount or after `triggerKey`
|
|
39
|
+
* changes. Renders countdown text while disabled.
|
|
40
|
+
*
|
|
41
|
+
* Use for "resend code" / "resend email" flows where the user shouldn't be
|
|
42
|
+
* able to spam the action. Triggering a new send from elsewhere should
|
|
43
|
+
* update `triggerKey` to restart the lockout.
|
|
44
|
+
*/
|
|
45
|
+
const CountdownButton = React.forwardRef<HTMLButtonElement, CountdownButtonProps>(
|
|
46
|
+
(
|
|
47
|
+
{ duration = 30, triggerKey, onClick, children, verb = "Resend", className, ...buttonProps },
|
|
48
|
+
ref,
|
|
49
|
+
) => {
|
|
50
|
+
const [secondsLeft, setSecondsLeft] = React.useState(duration);
|
|
51
|
+
|
|
52
|
+
React.useEffect(() => {
|
|
53
|
+
setSecondsLeft(duration);
|
|
54
|
+
const interval = setInterval(() => {
|
|
55
|
+
setSecondsLeft((s) => {
|
|
56
|
+
if (s <= 1) {
|
|
57
|
+
clearInterval(interval);
|
|
58
|
+
return 0;
|
|
59
|
+
}
|
|
60
|
+
return s - 1;
|
|
61
|
+
});
|
|
62
|
+
}, 1000);
|
|
63
|
+
return () => clearInterval(interval);
|
|
64
|
+
}, [duration, triggerKey]);
|
|
65
|
+
|
|
66
|
+
const ready = secondsLeft <= 0;
|
|
67
|
+
const labelNode =
|
|
68
|
+
typeof children === "function"
|
|
69
|
+
? children(secondsLeft)
|
|
70
|
+
: children !== undefined
|
|
71
|
+
? children
|
|
72
|
+
: ready
|
|
73
|
+
? verb
|
|
74
|
+
: `${verb} in ${secondsLeft}s`;
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<Button
|
|
78
|
+
ref={ref}
|
|
79
|
+
type="button"
|
|
80
|
+
disabled={!ready}
|
|
81
|
+
onClick={() => ready && onClick?.()}
|
|
82
|
+
className={cn(className)}
|
|
83
|
+
{...buttonProps}
|
|
84
|
+
>
|
|
85
|
+
{labelNode}
|
|
86
|
+
</Button>
|
|
87
|
+
);
|
|
88
|
+
},
|
|
89
|
+
);
|
|
90
|
+
CountdownButton.displayName = "CountdownButton";
|
|
91
|
+
|
|
92
|
+
export { CountdownButton };
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Eye, EyeOff } from "lucide-react";
|
|
4
|
+
import * as React from "react";
|
|
5
|
+
|
|
6
|
+
import { cn } from "../../lib/utils";
|
|
7
|
+
import { Button } from "../atoms/button";
|
|
8
|
+
import { Input } from "../atoms/input";
|
|
9
|
+
|
|
10
|
+
export interface PasswordInputProps
|
|
11
|
+
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "type"> {
|
|
12
|
+
/**
|
|
13
|
+
* Initial visibility state. Pair with `onVisibilityChange` for a
|
|
14
|
+
* controlled input.
|
|
15
|
+
* @default false
|
|
16
|
+
*/
|
|
17
|
+
defaultVisible?: boolean;
|
|
18
|
+
/** Controlled visibility. Overrides `defaultVisible`. */
|
|
19
|
+
visible?: boolean;
|
|
20
|
+
/** Fired when the user clicks the eye toggle. */
|
|
21
|
+
onVisibilityChange?: (visible: boolean) => void;
|
|
22
|
+
/**
|
|
23
|
+
* Accessible label on the eye toggle button.
|
|
24
|
+
* @default "Toggle password visibility"
|
|
25
|
+
*/
|
|
26
|
+
toggleLabel?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Password input with a show/hide eye toggle. Renders as an `Input` (atom)
|
|
31
|
+
* with a `Button` (atom) absolutely positioned at the trailing edge.
|
|
32
|
+
*
|
|
33
|
+
* Use anywhere a password field is needed: login, registration, settings,
|
|
34
|
+
* reset flows.
|
|
35
|
+
*/
|
|
36
|
+
const PasswordInput = React.forwardRef<HTMLInputElement, PasswordInputProps>(
|
|
37
|
+
(
|
|
38
|
+
{
|
|
39
|
+
defaultVisible = false,
|
|
40
|
+
visible: controlledVisible,
|
|
41
|
+
onVisibilityChange,
|
|
42
|
+
toggleLabel = "Toggle password visibility",
|
|
43
|
+
className,
|
|
44
|
+
...props
|
|
45
|
+
},
|
|
46
|
+
ref,
|
|
47
|
+
) => {
|
|
48
|
+
const [internalVisible, setInternalVisible] = React.useState(defaultVisible);
|
|
49
|
+
const visible = controlledVisible ?? internalVisible;
|
|
50
|
+
|
|
51
|
+
const toggle = () => {
|
|
52
|
+
const next = !visible;
|
|
53
|
+
if (controlledVisible === undefined) setInternalVisible(next);
|
|
54
|
+
onVisibilityChange?.(next);
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<div className="relative">
|
|
59
|
+
<Input
|
|
60
|
+
ref={ref}
|
|
61
|
+
type={visible ? "text" : "password"}
|
|
62
|
+
className={cn("pr-10", className)}
|
|
63
|
+
{...props}
|
|
64
|
+
/>
|
|
65
|
+
<Button
|
|
66
|
+
type="button"
|
|
67
|
+
variant="ghost"
|
|
68
|
+
size="icon"
|
|
69
|
+
onClick={toggle}
|
|
70
|
+
aria-label={toggleLabel}
|
|
71
|
+
aria-pressed={visible}
|
|
72
|
+
className="absolute right-1 top-1/2 h-7 w-7 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
|
73
|
+
tabIndex={-1}
|
|
74
|
+
>
|
|
75
|
+
{visible ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
|
76
|
+
</Button>
|
|
77
|
+
</div>
|
|
78
|
+
);
|
|
79
|
+
},
|
|
80
|
+
);
|
|
81
|
+
PasswordInput.displayName = "PasswordInput";
|
|
82
|
+
|
|
83
|
+
export { PasswordInput };
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
|
|
5
|
+
import { cn } from "../../lib/utils";
|
|
6
|
+
|
|
7
|
+
export type PasswordStrengthLevel = 0 | 1 | 2 | 3 | 4;
|
|
8
|
+
|
|
9
|
+
export interface PasswordStrength {
|
|
10
|
+
/** 0 = empty, 1 = weak, 2 = fair, 3 = good, 4 = strong. */
|
|
11
|
+
level: PasswordStrengthLevel;
|
|
12
|
+
/** Short human label shown next to the meter. */
|
|
13
|
+
label: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Score a password 0-4 based on length + character variety. Returns the
|
|
18
|
+
* level plus a short label. Heuristic-only; for a true strength score use a
|
|
19
|
+
* dictionary-aware estimator like `@zxcvbn-ts/core` and map its score here.
|
|
20
|
+
*/
|
|
21
|
+
export function scorePassword(pw: string): PasswordStrength {
|
|
22
|
+
if (!pw) return { level: 0, label: "" };
|
|
23
|
+
let score = 0;
|
|
24
|
+
if (pw.length >= 8) score++;
|
|
25
|
+
if (pw.length >= 12) score++;
|
|
26
|
+
if (/[a-z]/.test(pw) && /[A-Z]/.test(pw)) score++;
|
|
27
|
+
if (/\d/.test(pw)) score++;
|
|
28
|
+
if (/[^A-Za-z0-9]/.test(pw)) score++;
|
|
29
|
+
if (pw.length < 6) score = Math.min(score, 1);
|
|
30
|
+
|
|
31
|
+
// score is clamped into [1, 4]: `Math.max(1, …)` provides the floor,
|
|
32
|
+
// `Math.min(4, …)` provides the ceiling. Higher-tier scorers can map
|
|
33
|
+
// dictionary results into the same shape.
|
|
34
|
+
const level = Math.min(4, Math.max(1, score)) as PasswordStrengthLevel;
|
|
35
|
+
const labels = ["", "Weak", "Fair", "Good", "Strong"];
|
|
36
|
+
return { level, label: labels[level] };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface PasswordStrengthMeterProps {
|
|
40
|
+
/** Password value to score. Empty string renders nothing. */
|
|
41
|
+
value: string;
|
|
42
|
+
/**
|
|
43
|
+
* Custom scorer. Override the default heuristic with a zxcvbn-based
|
|
44
|
+
* scorer for production use.
|
|
45
|
+
*/
|
|
46
|
+
score?: (pw: string) => PasswordStrength;
|
|
47
|
+
/** Tailwind / CSS classes merged onto the root via `cn()`. */
|
|
48
|
+
className?: string;
|
|
49
|
+
/**
|
|
50
|
+
* Hide the strength label text and show only the bar.
|
|
51
|
+
* @default false
|
|
52
|
+
*/
|
|
53
|
+
hideLabel?: boolean;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Four-segment strength bar for a password input. Renders nothing for an
|
|
58
|
+
* empty value. Use beneath a password input in registration and reset
|
|
59
|
+
* flows.
|
|
60
|
+
*/
|
|
61
|
+
const PasswordStrengthMeter = React.forwardRef<HTMLDivElement, PasswordStrengthMeterProps>(
|
|
62
|
+
({ value, score = scorePassword, className, hideLabel = false }, ref) => {
|
|
63
|
+
const { level, label } = score(value);
|
|
64
|
+
if (level === 0) return null;
|
|
65
|
+
const segmentColors = [
|
|
66
|
+
"bg-muted",
|
|
67
|
+
"bg-destructive",
|
|
68
|
+
"bg-amber-500",
|
|
69
|
+
"bg-emerald-500",
|
|
70
|
+
"bg-emerald-600",
|
|
71
|
+
];
|
|
72
|
+
const labelColor =
|
|
73
|
+
level === 1
|
|
74
|
+
? "text-destructive"
|
|
75
|
+
: level === 2
|
|
76
|
+
? "text-amber-600 dark:text-amber-500"
|
|
77
|
+
: "text-emerald-600 dark:text-emerald-500";
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<div ref={ref} className={cn("flex items-center gap-2", className)}>
|
|
81
|
+
<div className="flex flex-1 gap-1" aria-hidden>
|
|
82
|
+
{[1, 2, 3, 4].map((seg) => (
|
|
83
|
+
<div
|
|
84
|
+
key={seg}
|
|
85
|
+
className={cn(
|
|
86
|
+
"h-1 flex-1 rounded-full transition-colors",
|
|
87
|
+
seg <= level ? segmentColors[level] : "bg-muted",
|
|
88
|
+
)}
|
|
89
|
+
/>
|
|
90
|
+
))}
|
|
91
|
+
</div>
|
|
92
|
+
{!hideLabel && (
|
|
93
|
+
<span className={cn("text-xs font-medium tabular-nums", labelColor)}>{label}</span>
|
|
94
|
+
)}
|
|
95
|
+
<span className="sr-only" role="status">
|
|
96
|
+
Password strength: {label}
|
|
97
|
+
</span>
|
|
98
|
+
</div>
|
|
99
|
+
);
|
|
100
|
+
},
|
|
101
|
+
);
|
|
102
|
+
PasswordStrengthMeter.displayName = "PasswordStrengthMeter";
|
|
103
|
+
|
|
104
|
+
export { PasswordStrengthMeter };
|
package/src/index.ts
CHANGED
|
@@ -27,6 +27,7 @@ export { Section, type SectionProps } from "./components/atoms/section";
|
|
|
27
27
|
export { Separator } from "./components/atoms/separator";
|
|
28
28
|
export { Skeleton } from "./components/atoms/skeleton";
|
|
29
29
|
export { Slider } from "./components/atoms/slider";
|
|
30
|
+
export { Spinner, type SpinnerProps } from "./components/atoms/spinner";
|
|
30
31
|
export { Switch } from "./components/atoms/switch";
|
|
31
32
|
export { Textarea } from "./components/atoms/textarea";
|
|
32
33
|
export { Toggle, toggleVariants } from "./components/atoms/toggle";
|
|
@@ -148,6 +149,10 @@ export {
|
|
|
148
149
|
type AnimatedBackgroundOrb,
|
|
149
150
|
type AnimatedBackgroundProps,
|
|
150
151
|
} from "./components/molecules/animated-background";
|
|
152
|
+
export {
|
|
153
|
+
AuthShell,
|
|
154
|
+
type AuthShellProps,
|
|
155
|
+
} from "./components/molecules/auth-shell";
|
|
151
156
|
export {
|
|
152
157
|
BrandLockup,
|
|
153
158
|
type BrandLockupProps,
|
|
@@ -176,7 +181,15 @@ export {
|
|
|
176
181
|
CardHeader,
|
|
177
182
|
CardTitle,
|
|
178
183
|
} from "./components/molecules/card";
|
|
184
|
+
export {
|
|
185
|
+
ClientBrand,
|
|
186
|
+
type ClientBrandProps,
|
|
187
|
+
} from "./components/molecules/client-brand";
|
|
179
188
|
export { CodeBlock, type CodeBlockProps } from "./components/molecules/code-block";
|
|
189
|
+
export {
|
|
190
|
+
CountdownButton,
|
|
191
|
+
type CountdownButtonProps,
|
|
192
|
+
} from "./components/molecules/countdown-button";
|
|
180
193
|
export { EmptyState, type EmptyStateProps } from "./components/molecules/empty-state";
|
|
181
194
|
export { ErrorState, type ErrorStateProps } from "./components/molecules/error-state";
|
|
182
195
|
export {
|
|
@@ -229,6 +242,17 @@ export {
|
|
|
229
242
|
PaginationNext,
|
|
230
243
|
PaginationPrevious,
|
|
231
244
|
} from "./components/molecules/pagination";
|
|
245
|
+
export {
|
|
246
|
+
PasswordInput,
|
|
247
|
+
type PasswordInputProps,
|
|
248
|
+
} from "./components/molecules/password-input";
|
|
249
|
+
export {
|
|
250
|
+
type PasswordStrength,
|
|
251
|
+
type PasswordStrengthLevel,
|
|
252
|
+
PasswordStrengthMeter,
|
|
253
|
+
type PasswordStrengthMeterProps,
|
|
254
|
+
scorePassword,
|
|
255
|
+
} from "./components/molecules/password-strength-meter";
|
|
232
256
|
export {
|
|
233
257
|
PhoneInput,
|
|
234
258
|
type PhoneInputProps,
|