feat: The Other Dude v9.0.1 — full-featured email system

ci: add GitHub Pages deployment workflow for docs site

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jason Staack
2026-03-08 17:46:37 -05:00
commit b840047e19
511 changed files with 106948 additions and 0 deletions

View File

@@ -0,0 +1,196 @@
/**
* Emergency Kit dialog shown after successful SRP registration.
*
* Displays the Secret Key (which NEVER touches the server) and provides:
* - Copy to clipboard button
* - Download Emergency Kit PDF (server-generated template without Secret Key)
* - Mandatory acknowledgment checkbox before closing
*
* The Secret Key is only shown once — if the user closes this dialog
* without saving it, they cannot recover it from the server.
*/
import { useState, useCallback } from 'react';
import { ShieldAlert, Copy, Download, Check } from 'lucide-react';
import { toast } from 'sonner';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog';
import { authApi } from '@/lib/api';
interface EmergencyKitDialogProps {
open: boolean;
onClose: () => void;
secretKey: string; // Formatted A3-XXXXXX-...
email: string;
}
export function EmergencyKitDialog({
open,
onClose,
secretKey,
email,
}: EmergencyKitDialogProps) {
const [acknowledged, setAcknowledged] = useState(false);
const [copied, setCopied] = useState(false);
const [downloading, setDownloading] = useState(false);
const [showHelp, setShowHelp] = useState(false);
const handleCopy = useCallback(async () => {
try {
await navigator.clipboard.writeText(secretKey);
setCopied(true);
toast.success('Secret Key copied to clipboard');
setTimeout(() => setCopied(false), 3000);
} catch {
// Fallback for environments without clipboard API
const textarea = document.createElement('textarea');
textarea.value = secretKey;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
setCopied(true);
toast.success('Secret Key copied to clipboard');
setTimeout(() => setCopied(false), 3000);
}
}, [secretKey]);
const handleDownloadPDF = useCallback(async () => {
setDownloading(true);
try {
const blob = await authApi.getEmergencyKitPDF();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'MikroTik-Portal-Emergency-Kit.pdf';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
toast.success('Emergency Kit PDF downloaded');
} catch {
toast.error('Failed to download Emergency Kit PDF');
} finally {
setDownloading(false);
}
}, []);
return (
<Dialog open={open} onOpenChange={() => {}}>
<DialogContent
className="max-w-lg"
onPointerDownOutside={(e) => e.preventDefault()}
onEscapeKeyDown={(e) => e.preventDefault()}
>
<DialogHeader>
<div className="flex items-center gap-3 mb-1">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-amber-500/10">
<ShieldAlert className="h-5 w-5 text-amber-500" />
</div>
<DialogTitle className="text-lg">Save Your Emergency Kit</DialogTitle>
</div>
<DialogDescription className="text-sm leading-relaxed">
Your Secret Key is shown below. This is the <strong>only time</strong> it
will be displayed. You need it when signing in from a new browser or computer.
</DialogDescription>
</DialogHeader>
{/* Secret Key Display */}
<div className="my-4 rounded-lg border-2 border-dashed border-accent/50 bg-accent/5 p-5">
<div className="mb-2 text-xs font-medium uppercase tracking-wider text-text-secondary">
Your Secret Key
</div>
<div className="font-mono text-lg font-semibold tracking-wide text-text-primary select-all break-all">
{secretKey}
</div>
<div className="mt-2 text-xs text-text-secondary">
Account: {email}
</div>
</div>
{/* Action Buttons */}
<div className="flex gap-2">
<button
onClick={handleCopy}
className="flex flex-1 items-center justify-center gap-2 rounded-md border border-border bg-surface px-4 py-2.5 text-sm font-medium text-text-primary transition-colors hover:bg-surface-hover"
>
{copied ? (
<>
<Check className="h-4 w-4 text-green-500" />
Copied
</>
) : (
<>
<Copy className="h-4 w-4" />
Copy Secret Key
</>
)}
</button>
<button
onClick={handleDownloadPDF}
disabled={downloading}
className="flex flex-1 items-center justify-center gap-2 rounded-md bg-accent px-4 py-2.5 text-sm font-medium text-accent-foreground transition-colors hover:bg-accent/90 disabled:opacity-50"
>
<Download className="h-4 w-4" />
{downloading ? 'Downloading...' : 'Download PDF'}
</button>
</div>
{/* Instructions */}
<div className="mt-3 rounded-md bg-surface-secondary p-3 text-xs text-text-secondary leading-relaxed">
Write your Secret Key on the Emergency Kit PDF after printing it, or save it
in your password manager. Do NOT store it digitally alongside your password.
</div>
{/* Help toggle */}
<button
onClick={() => setShowHelp(!showHelp)}
className="mt-2 text-xs text-accent hover:underline"
>
What is a Secret Key?
</button>
{showHelp && (
<div className="mt-2 rounded-md bg-elevated p-3 text-xs text-text-secondary leading-relaxed">
Your Secret Key is a unique code generated on your device. Combined with your password,
it creates the encryption keys that protect your data. The server never sees your Secret Key
or your password this is called zero-knowledge encryption. If you lose both your Secret Key
and your password, your data cannot be recovered.
</div>
)}
<DialogFooter className="flex-col items-stretch gap-3 sm:flex-col">
{/* Acknowledgment Checkbox */}
<label className="flex items-start gap-2.5 cursor-pointer">
<input
type="checkbox"
checked={acknowledged}
onChange={(e) => setAcknowledged(e.target.checked)}
className="mt-0.5 h-4 w-4 rounded border-border accent-accent"
/>
<span className="text-sm text-text-secondary leading-snug">
I have saved my Secret Key and understand that it cannot be recovered
if lost.
</span>
</label>
{/* Close Button */}
<button
onClick={onClose}
disabled={!acknowledged}
className="w-full rounded-md bg-accent px-4 py-2.5 text-sm font-medium text-accent-foreground transition-colors hover:bg-accent/90 disabled:cursor-not-allowed disabled:opacity-40"
>
I Have Saved My Emergency Kit
</button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,131 @@
/**
* PasswordStrengthMeter -- Visual password strength indicator using zxcvbn-ts.
*
* Evaluates password strength on every keystroke (zxcvbn is fast) and shows:
* - Colored segmented progress bar (0-4 segments)
* - Strength label: Very Weak, Weak, Fair, Strong, Very Strong
* - Feedback suggestions when score < 3
*
* Also exports getPasswordScore() helper for form validation.
*/
import { useMemo } from 'react'
import { zxcvbn, zxcvbnOptions } from '@zxcvbn-ts/core'
import * as zxcvbnCommonPackage from '@zxcvbn-ts/language-common'
import * as zxcvbnEnPackage from '@zxcvbn-ts/language-en'
import { cn } from '@/lib/utils'
// Configure zxcvbn with language dictionaries
const options = {
graphs: zxcvbnCommonPackage.adjacencyGraphs,
dictionary: {
...zxcvbnCommonPackage.dictionary,
...zxcvbnEnPackage.dictionary,
},
translations: zxcvbnEnPackage.translations,
}
zxcvbnOptions.setOptions(options)
// ---------------------------------------------------------------------------
// Exported helper for form validation
// ---------------------------------------------------------------------------
export function getPasswordScore(password: string): number {
if (!password) return 0
return zxcvbn(password).score
}
// ---------------------------------------------------------------------------
// Score configuration
// ---------------------------------------------------------------------------
const SCORE_CONFIG: Record<
number,
{ label: string; color: string; barColor: string }
> = {
0: {
label: 'Very Weak',
color: 'text-error',
barColor: 'bg-error',
},
1: {
label: 'Weak',
color: 'text-orange-500',
barColor: 'bg-orange-500',
},
2: {
label: 'Fair',
color: 'text-yellow-500',
barColor: 'bg-yellow-500',
},
3: {
label: 'Strong',
color: 'text-green-500',
barColor: 'bg-green-500',
},
4: {
label: 'Very Strong',
color: 'text-green-400',
barColor: 'bg-green-400',
},
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
interface PasswordStrengthMeterProps {
password: string
className?: string
}
export function PasswordStrengthMeter({
password,
className,
}: PasswordStrengthMeterProps) {
const result = useMemo(() => {
if (!password) return null
return zxcvbn(password)
}, [password])
if (!password || !result) return null
const { score, feedback } = result
const config = SCORE_CONFIG[score] ?? SCORE_CONFIG[0]!
return (
<div className={cn('space-y-1.5', className)}>
{/* Segmented strength bar */}
<div className="flex gap-1">
{[0, 1, 2, 3].map((segment) => (
<div
key={segment}
className={cn(
'h-1 flex-1 rounded-full transition-colors duration-200',
segment <= score ? config.barColor : 'bg-elevated',
)}
/>
))}
</div>
{/* Score label */}
<div className="flex items-center justify-between">
<span className={cn('text-xs font-medium', config.color)}>
{config.label}
</span>
</div>
{/* Feedback suggestions for weak passwords */}
{score < 3 && (feedback.warning || feedback.suggestions.length > 0) && (
<div className="text-xs text-text-muted space-y-0.5">
{feedback.warning && (
<p className="text-text-secondary">{feedback.warning}</p>
)}
{feedback.suggestions.map((suggestion, i) => (
<p key={i}>{suggestion}</p>
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,159 @@
import { useCallback, useRef, useState } from 'react'
import { Input } from '@/components/ui/input'
/**
* Valid characters for the Secret Key: 22 letters + 8 digits = 30 chars.
* Uppercase only, ambiguous characters removed (O, I, L, S, 0, 1).
*/
const VALID_CHARS = /^[ABCDEFGHJKMNPQRTUVWXYZ23456789]+$/
interface SecretKeyInputProps {
value: string
onChange: (value: string) => void
error?: boolean
}
/**
* Secret Key entry component with 5 grouped inputs matching A3-XXXXXX format.
*
* The "A3" prefix is shown as a static label. The user enters 5 groups of
* 6 characters each. Auto-advances to the next group on fill, supports
* paste of the full key across all groups, and validates characters.
*/
export function SecretKeyInput({ value, onChange, error }: SecretKeyInputProps) {
const inputRefs = useRef<(HTMLInputElement | null)[]>([])
// Parse the value into 5 groups (strip "A3-" prefix and hyphens)
const parseGroups = useCallback((raw: string): string[] => {
const cleaned = raw
.replace(/^A3[-\s]*/i, '')
.replace(/[-\s]/g, '')
.toUpperCase()
const groups: string[] = []
for (let i = 0; i < 5; i++) {
groups.push(cleaned.slice(i * 6, (i + 1) * 6))
}
return groups
}, [])
const [groups, setGroups] = useState<string[]>(() => parseGroups(value))
// Reconstruct the full key from groups
const buildKey = useCallback((g: string[]) => {
const joined = g.join('')
if (joined.length === 0) return ''
return `A3-${g.filter(Boolean).join('-')}`
}, [])
const handleGroupChange = useCallback(
(index: number, input: string) => {
// Allow only valid charset characters
const filtered = input
.toUpperCase()
.split('')
.filter((c) => VALID_CHARS.test(c))
.join('')
.slice(0, 6)
const newGroups = [...groups]
newGroups[index] = filtered
setGroups(newGroups)
onChange(buildKey(newGroups))
// Auto-advance to next group when this one is full
if (filtered.length === 6 && index < 4) {
inputRefs.current[index + 1]?.focus()
}
},
[groups, onChange, buildKey],
)
const handlePaste = useCallback(
(e: React.ClipboardEvent) => {
e.preventDefault()
const pasted = e.clipboardData.getData('text')
const parsed = parseGroups(pasted)
// Only apply if we got meaningful data
if (parsed.some((g) => g.length > 0)) {
setGroups(parsed)
onChange(buildKey(parsed))
// Focus the first incomplete group
const incompleteIdx = parsed.findIndex((g) => g.length < 6)
if (incompleteIdx >= 0) {
inputRefs.current[incompleteIdx]?.focus()
} else {
// All complete -- focus last
inputRefs.current[4]?.focus()
}
}
},
[parseGroups, onChange, buildKey],
)
const handleKeyDown = useCallback(
(index: number, e: React.KeyboardEvent<HTMLInputElement>) => {
// Navigate back on Backspace when group is empty
if (e.key === 'Backspace' && groups[index] === '' && index > 0) {
e.preventDefault()
inputRefs.current[index - 1]?.focus()
}
},
[groups],
)
// 26-char key = 4 groups of 6 + 1 group of 2
const isComplete =
groups.slice(0, 4).every((g) => g.length === 6) && groups[4].length >= 2
const hasContent = groups.some((g) => g.length > 0)
const borderColor = error
? 'border-error'
: isComplete
? 'border-success'
: hasContent
? 'border-warning'
: 'border-border'
return (
<div className="space-y-2">
<div className="flex items-center gap-1 flex-wrap" onPaste={handlePaste}>
{/* Static A3 prefix */}
<span className="text-xs font-mono font-semibold text-text-secondary select-none shrink-0">
A3
</span>
<span className="text-text-muted select-none text-xs">-</span>
{/* 5 input groups */}
{groups.map((group, idx) => (
<div key={idx} className="flex items-center gap-1">
{idx > 0 && <span className="text-text-muted select-none text-xs">-</span>}
<Input
ref={(el) => {
inputRefs.current[idx] = el
}}
type="text"
inputMode="text"
autoComplete="off"
autoCorrect="off"
autoCapitalize="characters"
spellCheck={false}
maxLength={6}
value={group}
onChange={(e) => handleGroupChange(idx, e.target.value)}
onKeyDown={(e) => handleKeyDown(idx, e)}
className={`w-[3.25rem] font-mono text-center text-xs tracking-wide uppercase px-0.5 ${borderColor}`}
placeholder="------"
/>
</div>
))}
</div>
{error && hasContent && !isComplete && (
<p className="text-xs text-error">
Enter all 30 characters of your Secret Key
</p>
)}
</div>
)
}

View File

@@ -0,0 +1,189 @@
/**
* SRP Upgrade Dialog shown when a legacy bcrypt user logs in and needs
* to register zero-knowledge SRP credentials.
*
* Flow:
* 1. User sees explanation of what's happening
* 2. Click "Upgrade Now" triggers client-side key generation
* 3. Registration data sent to /auth/register-srp
* 4. Emergency Kit dialog shown with Secret Key
* 5. After acknowledging, completeUpgrade() logs in via SRP
*/
import { useState, useCallback } from 'react'
import { ShieldCheck, Loader2 } from 'lucide-react'
import { toast } from 'sonner'
import { getErrorMessage } from '@/lib/errors'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { performRegistration, assertWebCryptoAvailable } from '@/lib/crypto/registration'
import { keyStore } from '@/lib/crypto/keyStore'
import { authApi } from '@/lib/api'
import { EmergencyKitDialog } from './EmergencyKitDialog'
interface SrpUpgradeDialogProps {
open: boolean
email: string
password: string
onComplete: () => Promise<void>
onCancel: () => void
}
type UpgradeStep = 'explain' | 'generating' | 'emergency-kit'
export function SrpUpgradeDialog({
open,
email,
password,
onComplete,
onCancel,
}: SrpUpgradeDialogProps) {
const [step, setStep] = useState<UpgradeStep>('explain')
const [secretKey, setSecretKey] = useState('')
const [error, setError] = useState<string | null>(null)
// Check if Web Crypto is available (HTTPS or localhost required)
const cryptoAvailable = typeof crypto !== 'undefined' && !!crypto.subtle
const handleUpgrade = useCallback(async () => {
setStep('generating')
setError(null)
try {
// 1. Generate all cryptographic material client-side
const result = await performRegistration(email, password)
// 2. Send SRP registration to server (user is temp-authenticated)
await authApi.registerSRP({
...result.srpRegistration,
...result.keyBundle,
})
// 3. Store Secret Key in IndexedDB for this device
await keyStore.storeSecretKey(email, result.secretKeyRaw)
// 4. Show Emergency Kit with Secret Key
setSecretKey(result.secretKey)
setStep('emergency-kit')
} catch (err) {
const msg = getErrorMessage(err, 'Security upgrade failed. Please try again.')
setError(msg)
setStep('explain')
toast.error(msg)
}
}, [email, password])
const handleEmergencyKitClose = useCallback(async () => {
// After user acknowledges Emergency Kit, complete the upgrade
try {
await onComplete()
} catch {
toast.error('Login failed after upgrade. Please try signing in again.')
onCancel()
}
}, [onComplete, onCancel])
// Emergency Kit sub-dialog
if (step === 'emergency-kit') {
return (
<EmergencyKitDialog
open={true}
onClose={() => void handleEmergencyKitClose()}
secretKey={secretKey}
email={email}
/>
)
}
return (
<Dialog open={open} onOpenChange={() => {}}>
<DialogContent
className="max-w-md"
onPointerDownOutside={(e) => e.preventDefault()}
onEscapeKeyDown={(e) => e.preventDefault()}
>
<DialogHeader>
<div className="flex items-center gap-3 mb-1">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-accent/10">
<ShieldCheck className="h-5 w-5 text-accent" />
</div>
<DialogTitle className="text-lg">Account Security Upgrade</DialogTitle>
</div>
<DialogDescription className="text-sm leading-relaxed">
We're upgrading your account security so your password is never stored on
our servers. This is a one-time process.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 my-4">
{step === 'generating' ? (
<div className="flex flex-col items-center gap-3 py-6">
<Loader2 className="h-8 w-8 animate-spin text-accent" />
<p className="text-sm text-text-secondary">
Generating encryption keys...
</p>
<p className="text-xs text-text-muted">
This may take a moment while we derive your security credentials.
</p>
</div>
) : (
<>
<div className="rounded-md bg-surface-secondary p-4 text-sm text-text-secondary leading-relaxed space-y-3">
<p>
<strong>What happens:</strong>
</p>
<ul className="list-disc pl-4 space-y-1.5">
<li>Your encryption keys are generated locally in your browser</li>
<li>A Secret Key is created that only you will have</li>
<li>Your password is never sent to or stored on the server</li>
<li>You will receive an Emergency Kit to save your Secret Key</li>
</ul>
</div>
{!cryptoAvailable && (
<div className="rounded-md bg-warning/10 border border-warning/30 px-3 py-2">
<p className="text-xs text-warning font-medium">Secure connection required</p>
<p className="text-xs text-text-secondary mt-1">
Encryption features require HTTPS or localhost. Please access the
application via a secure connection to complete this upgrade.
</p>
</div>
)}
{error && (
<div className="rounded-md bg-error/10 border border-error/30 px-3 py-2">
<p className="text-xs text-error">{error}</p>
</div>
)}
</>
)}
</div>
{step === 'explain' && (
<div className="flex gap-2">
<Button
variant="outline"
className="flex-1"
onClick={onCancel}
>
Cancel
</Button>
<Button
className="flex-1"
onClick={() => void handleUpgrade()}
disabled={!cryptoAvailable}
>
Upgrade Now
</Button>
</div>
)}
</DialogContent>
</Dialog>
)
}