Search for pages, actions, and quick links.
Sign-in, two-factor, password rules and session management. 7 blocks.
With the autocomplete tokens password managers actually need.
src/components/blocks/auth/sign-in-card.tsx"use client";
import { useState } from "react";
import Link from "next/link";
import { Button } from "@dashboardpack/core/components/ui/button";
import { Card, CardContent } from "@dashboardpack/core/components/ui/card";
import { Input } from "@dashboardpack/core/components/ui/input";
import { Label } from "@dashboardpack/core/components/ui/label";
import { Checkbox } from "@dashboardpack/core/components/ui/checkbox";
import { Eye, EyeOff } from "lucide-react";
/**
* A sign-in card with the details that are usually wrong.
*
* The heading is an `<h1>`, not a styled `<div>`. On an auth page this is the only heading, so
* without it a screen-reader user landing on the page has nothing to navigate to and no statement
* of what the page is.
*
* `autoComplete="current-password"` (not just "password") is what tells a password manager this is
* a sign-in rather than a registration, and it is why "new-password" belongs on the sign-up form
* instead. Getting these two backwards is why managers offer to generate a password on login.
*
* The reveal toggle sets `aria-pressed` and changes its accessible name, so its state is knowable
* without seeing the icon. It stays a `<button type="button">` ā inside a form, a bare `<button>`
* submits.
*/
export function SignInCard() {
const [visible, setVisible] = useState(false);
return (
<Card className="mx-auto w-full max-w-sm">
<CardContent className="space-y-5 p-6">
<div className="space-y-1.5 text-center">
<h1 className="text-xl font-bold tracking-tight">Sign in</h1>
<p className="text-sm text-muted-foreground">Welcome back. Pick up where you left off.</p>
</div>
<form className="space-y-4" onSubmit={(event) => event.preventDefault()}>
<div className="space-y-2">
<Label htmlFor="signin-email">Email</Label>
<Input
id="signin-email"
type="email"
autoComplete="email"
placeholder="[email protected]"
required
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="signin-password">Password</Label>
<Link
href="/reset-password"
className="text-xs font-medium text-primary hover:underline"
>
Forgot?
</Link>
</div>
<div className="relative">
<Input
id="signin-password"
type={visible ? "text" : "password"}
autoComplete="current-password"
required
className="pe-10"
/>
<button
type="button"
onClick={() => setVisible((prev) => !prev)}
aria-pressed={visible}
aria-label={visible ? "Hide password" : "Show password"}
className="absolute top-1/2 -translate-y-1/2 rounded-md p-1.5 text-muted-foreground transition-colors hover:text-foreground ltr:right-1 rtl:left-1"
>
{visible ? (
<EyeOff className="h-4 w-4" aria-hidden="true" />
) : (
<Eye className="h-4 w-4" aria-hidden="true" />
)}
</button>
</div>
</div>
<div className="flex items-center gap-2">
<Checkbox id="signin-remember" />
<Label htmlFor="signin-remember" className="text-sm font-normal">
Keep me signed in
</Label>
</div>
<Button type="submit" className="w-full">
Sign in
</Button>
</form>
<p className="text-center text-sm text-muted-foreground">
No account?{" "}
<Link href="/register" className="font-medium text-primary hover:underline">
Create one
</Link>
</p>
</CardContent>
</Card>
);
}Requirements stated up front and satisfied live, announced as they are met.
src/components/blocks/auth/password-strength.tsx"use client";
import { useState } from "react";
import { Input } from "@dashboardpack/core/components/ui/input";
import { Label } from "@dashboardpack/core/components/ui/label";
import { cn } from "@dashboardpack/core/lib/utils";
import { Check, X } from "lucide-react";
/**
* A password field that says what it wants before you get it wrong.
*
* Requirements are listed up front and tick as they are met, rather than being revealed by a
* rejection after submit. That is the whole point ā a strength meter with no criteria tells you
* "weak" without telling you what would fix it.
*
* The rules list is a `role="status"` region with `aria-live="polite"`, so a screen-reader user
* hears requirements being satisfied as they type. Without it the ticks are purely visual and the
* form is unusable without sight. `aria-hidden` on the icons keeps the announcement to the text.
*
* The score drives `aria-valuetext` on the meter as a word, not a number: "Strong" is meaningful
* where "3" is not.
*/
export function PasswordStrength() {
const [value, setValue] = useState("Tr0ubadour");
const rules = [
{ label: "At least 12 characters", met: value.length >= 12 },
{ label: "A number", met: /\d/.test(value) },
{ label: "An uppercase letter", met: /[A-Z]/.test(value) },
{ label: "A symbol", met: /[^\w\s]/.test(value) },
];
const score = rules.filter((rule) => rule.met).length;
const labels = ["Too short", "Weak", "Fair", "Good", "Strong"];
const tones = [
"bg-muted",
"bg-destructive",
"bg-warning",
"bg-warning",
"bg-success",
];
return (
<div className="mx-auto w-full max-w-sm space-y-4">
<div className="space-y-2">
<Label htmlFor="new-password">New password</Label>
<Input
id="new-password"
type="password"
autoComplete="new-password"
value={value}
onChange={(event) => setValue(event.target.value)}
/>
</div>
<div
role="meter"
aria-label="Password strength"
aria-valuemin={0}
aria-valuemax={4}
aria-valuenow={score}
aria-valuetext={labels[score]}
className="space-y-1.5"
>
<div className="flex gap-1">
{[0, 1, 2, 3].map((index) => (
<div
key={index}
className={cn(
"h-1.5 flex-1 rounded-full transition-colors",
index < score ? tones[score] : "bg-muted",
)}
/>
))}
</div>
<p className="text-xs font-medium text-muted-foreground">{labels[score]}</p>
</div>
{/*
The live region is a wrapper, NOT the <ul> itself.
`role="status"` on the list would REPLACE its implicit `list` role, leaving every <li>
with a parent that is not a list ā axe reports it as `listitem`, serious, and a screen
reader stops announcing "1 of 4". Wrapping keeps both: the announcement and the list.
*/}
<div role="status" aria-live="polite">
<ul className="space-y-1.5">
{rules.map((rule) => (
<li
key={rule.label}
className={cn(
"flex items-center gap-2 text-xs",
rule.met ? "text-success" : "text-muted-foreground",
)}
>
{rule.met ? (
<Check className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
) : (
<X className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
)}
{rule.label}
</li>
))}
</ul>
</div>
</div>
);
}Six digits with one-time-code autofill and a visible resend cooldown.
src/components/blocks/auth/two-factor-prompt.tsx"use client";
import { useState } from "react";
import { Button } from "@dashboardpack/core/components/ui/button";
import { Card, CardContent } from "@dashboardpack/core/components/ui/card";
import {
InputOTP,
InputOTPGroup,
InputOTPSlot,
} from "@dashboardpack/core/components/ui/input-otp";
import { ShieldCheck } from "lucide-react";
/**
* A six-digit verification step.
*
* `autoComplete="one-time-code"` is the load-bearing attribute: it is what lets iOS and Android
* offer the code from the SMS or authenticator app instead of making the user switch apps and
* memorise six digits. It is one string and it is almost always missing.
*
* The resend control is disabled with a visible countdown rather than hidden, so the option is
* discoverable while the cooldown runs. A control that vanishes reads as a bug.
*/
export function TwoFactorPrompt() {
const [code, setCode] = useState("");
const [secondsLeft, setSecondsLeft] = useState(24);
return (
<Card className="mx-auto w-full max-w-sm">
<CardContent className="space-y-5 p-6 text-center">
<div className="mx-auto flex h-11 w-11 items-center justify-center rounded-full bg-primary/10">
<ShieldCheck className="h-5 w-5 text-primary" aria-hidden="true" />
</div>
<div className="space-y-1.5">
<h1 className="text-xl font-bold tracking-tight">Two-factor code</h1>
<p className="text-sm text-muted-foreground">
Enter the six digits from your authenticator app.
</p>
</div>
<div className="flex justify-center">
<InputOTP
maxLength={6}
value={code}
onChange={setCode}
autoComplete="one-time-code"
aria-label="Six-digit verification code"
>
<InputOTPGroup>
{[0, 1, 2, 3, 4, 5].map((index) => (
<InputOTPSlot key={index} index={index} />
))}
</InputOTPGroup>
</InputOTP>
</div>
<Button className="w-full" disabled={code.length < 6}>
Verify
</Button>
<Button
variant="ghost"
size="sm"
disabled={secondsLeft > 0}
onClick={() => setSecondsLeft(24)}
className="w-full text-xs"
>
{secondsLeft > 0 ? `Resend in ${secondsLeft}s` : "Resend code"}
</Button>
</CardContent>
</Card>
);
}Devices, locations and times, with the current session marked and safe.
src/components/blocks/auth/active-sessions.tsx"use client";
import { Badge } from "@dashboardpack/core/components/ui/badge";
import { Button } from "@dashboardpack/core/components/ui/button";
import { Laptop, Smartphone, Tablet } from "lucide-react";
/**
* Where the account is signed in, and how to end a session.
*
* The current session is marked and has no revoke button ā offering "Sign out" on the row you are
* reading it from is a trap, because it looks like the same action as revoking a stranger's device
* but logs you out instead. Ending the current session belongs on a separate, clearly-labelled
* control.
*
* Locations and times are the point of the block: a user can only spot an unfamiliar session if the
* rows say where and when. "Chrome on macOS" alone is every session they have.
*/
export function ActiveSessions() {
const sessions = [
{
device: "MacBook Pro Ā· Chrome",
where: "Riga, LV",
when: "Active now",
Icon: Laptop,
current: true,
},
{
device: "iPhone 16 Ā· Safari",
where: "Riga, LV",
when: "2 hours ago",
Icon: Smartphone,
current: false,
},
{
device: "iPad Ā· Safari",
where: "Berlin, DE",
when: "4 days ago",
Icon: Tablet,
current: false,
},
];
return (
<ul className="divide-y rounded-lg border">
{sessions.map((session) => (
<li key={session.device} className="flex items-center gap-3 p-3">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground">
<session.Icon className="h-4 w-4" aria-hidden="true" />
</span>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<p className="truncate text-sm font-medium">{session.device}</p>
{session.current && (
<Badge variant="success" className="text-[10px]">
This device
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground">
{session.where} Ā· {session.when}
</p>
</div>
{!session.current && (
<Button variant="outline" size="sm">
Revoke
</Button>
)}
</li>
))}
</ul>
);
}Shows the address, names the sender, and offers a way to fix a typo.
src/components/blocks/auth/check-your-email.tsx"use client";
import { Button } from "@dashboardpack/core/components/ui/button";
import { Card, CardContent } from "@dashboardpack/core/components/ui/card";
import { MailCheck } from "lucide-react";
/**
* The screen after a magic link or reset email is sent.
*
* Three things it does that the usual version does not.
*
* It shows the address the mail went to. Half of "I never got the email" is a typo in the address,
* and the user cannot see it once the form is gone.
*
* It offers a way back to the form to correct that address, rather than only a resend that would
* send to the same wrong place again.
*
* It names the sender and the subject line, so the mail is findable in a spam folder ā the other
* half of "I never got the email".
*/
export function CheckYourEmail() {
return (
<Card className="mx-auto w-full max-w-sm">
<CardContent className="space-y-5 p-6 text-center">
<div className="mx-auto flex h-11 w-11 items-center justify-center rounded-full bg-success/10">
<MailCheck className="h-5 w-5 text-success" aria-hidden="true" />
</div>
<div className="space-y-1.5">
<h1 className="text-xl font-bold tracking-tight">Check your email</h1>
<p className="text-sm text-muted-foreground">
We sent a sign-in link to{" "}
<span className="font-medium text-foreground">[email protected]</span>. It expires in 15
minutes.
</p>
</div>
<div className="rounded-lg bg-muted/50 p-3 text-start">
<p className="text-xs text-muted-foreground">
Not there? Look for{" "}
<span className="font-medium text-foreground">“Your sign-in link”</span> from{" "}
<span className="font-medium text-foreground">[email protected]</span>, and check spam.
</p>
</div>
<div className="space-y-2">
<Button variant="outline" className="w-full">
Resend link
</Button>
<Button variant="ghost" size="sm" className="w-full text-xs">
Use a different address
</Button>
</div>
</CardContent>
</Card>
);
}No vendor logos shipped, because their marks are trademarked.
src/components/blocks/auth/sso-providers.tsx"use client";
import { Button } from "@dashboardpack/core/components/ui/button";
/**
* SSO buttons above a divider, with no vendor logos shipped.
*
* Deliberately no brand marks: Google, GitHub and Microsoft logos are trademarked and each carries
* its own usage terms, so bundling them into a resold template hands every customer a licensing
* problem they did not ask for. Text buttons work everywhere and are trivially replaced with the
* real assets from each vendor's own brand kit.
*
* The "or" divider is `aria-hidden` with the rule drawn by two borders rather than an `<hr>`, since
* it is decoration ā a screen reader announcing "or, separator" between two button groups adds
* nothing.
*/
export function SsoProviders() {
const providers = ["Google", "GitHub", "Microsoft"];
return (
<div className="mx-auto w-full max-w-sm space-y-4">
<div className="space-y-2">
{providers.map((provider) => (
<Button key={provider} variant="outline" className="w-full">
Continue with {provider}
</Button>
))}
</div>
<div aria-hidden="true" className="flex items-center gap-3">
<span className="h-px flex-1 bg-border" />
<span className="text-xs text-muted-foreground">or</span>
<span className="h-px flex-1 bg-border" />
</div>
<Button variant="ghost" className="w-full">
Sign in with email instead
</Button>
<p className="text-center text-xs text-muted-foreground">
Single sign-on is enforced for members of an organisation with SSO enabled.
</p>
</div>
);
}Gated on an acknowledgement, since they are shown once.
src/components/blocks/auth/recovery-codes.tsx"use client";
import { useState } from "react";
import { Button } from "@dashboardpack/core/components/ui/button";
import { Card, CardContent } from "@dashboardpack/core/components/ui/card";
import { Check, Copy, Download, TriangleAlert } from "lucide-react";
/**
* One-time recovery codes, gated behind an acknowledgement.
*
* "Continue" stays disabled until the checkbox is ticked. These codes are shown exactly once, and a
* user who clicks past the screen has locked themselves out of their own account ā one of the few
* places where a deliberate speed bump is clearly worth it.
*
* Used codes are struck through *and* labelled, so the state does not rest on `line-through` alone,
* which a screen reader ignores entirely.
*
* Copy pulls from the array rather than the DOM, so it captures every code regardless of what is
* scrolled into view.
*/
export function RecoveryCodes() {
const codes = [
"4f9k-2m7p", "8xr3-qw1z", "7bt6-nv4c", "2ld9-hs8y",
"6cp1-wk3m", "9zn5-tf2q", "3gv8-rb7j", "5hy4-xd6l",
];
const used = ["8xr3-qw1z", "9zn5-tf2q"];
const [acknowledged, setAcknowledged] = useState(false);
const [copied, setCopied] = useState(false);
async function copyAll() {
if (!navigator.clipboard) return;
await navigator.clipboard.writeText(codes.join("\n"));
setCopied(true);
setTimeout(() => setCopied(false), 1600);
}
return (
<Card className="mx-auto w-full max-w-md">
<CardContent className="space-y-4 p-6">
<div className="flex items-start gap-3">
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-warning/20 text-warning">
<TriangleAlert className="h-4 w-4" aria-hidden="true" />
</span>
<div className="space-y-1">
<h2 className="text-sm font-semibold">Save your recovery codes</h2>
<p className="text-sm text-muted-foreground">
Each code works once. This is the only time they will be shown ā regenerating replaces
all eight.
</p>
</div>
</div>
<ul className="grid grid-cols-2 gap-2 rounded-lg bg-muted/50 p-3">
{codes.map((code) => {
const spent = used.includes(code);
return (
<li
key={code}
className={
spent
? "font-mono text-xs text-muted-foreground line-through"
: "font-mono text-xs"
}
>
{code}
{spent && <span className="sr-only"> ā already used</span>}
</li>
);
})}
</ul>
<div className="flex gap-2">
<Button variant="outline" size="sm" className="flex-1" onClick={copyAll}>
{copied ? (
<Check className="h-4 w-4 text-success" aria-hidden="true" />
) : (
<Copy className="h-4 w-4" aria-hidden="true" />
)}
Copy all
</Button>
<Button variant="outline" size="sm" className="flex-1">
<Download className="h-4 w-4" aria-hidden="true" />
Download
</Button>
</div>
<label className="flex items-start gap-2">
<input
type="checkbox"
checked={acknowledged}
onChange={(event) => setAcknowledged(event.target.checked)}
className="mt-0.5 size-4 accent-primary"
/>
<span className="text-xs text-muted-foreground">
I have saved these codes somewhere I can reach without this account.
</span>
</label>
{/* Disabled until acknowledged: clicking past this screen locks you out. */}
<Button className="w-full" disabled={!acknowledged}>
Continue
</Button>
</CardContent>
</Card>
);
}