fix: replace frontend with Rust OPAQUE API + Flutter keypad UI

- Full OPAQUE auth flow via WASM client SDK (client-wasm crate)
- New user: Key Register → Key Login → Code Register (icon selection) → done
- Existing user: Key Login → get login-data → icon keypad → Code Login → done
- Icon-based keypad matching Flutter design:
  - 2 cols portrait, 3 cols landscape
  - Key tiles with 3-col sub-grid of icons
  - Navy border press feedback
  - Dot display with backspace + submit
- SVGs rendered as-is (no color manipulation)
- SusiPage with Login/Signup tabs
- LoginKeypadPage and SignupKeypadPage for code flows
- Secret key display/copy on signup
- Unit tests for Keypad component
- WASM pkg bundled locally (no external dep)
This commit is contained in:
2026-01-29 17:05:32 +00:00
parent 7494bf7520
commit 5c3217e3d5
36 changed files with 2045 additions and 1149 deletions

View File

@@ -1,153 +1,301 @@
import { useState, useCallback, useEffect } from 'react';
/**
* SVG Icon Keypad — matches Flutter keypad.dart + keypad_interface.dart.
* SVGs render as-is (no color application per DoDNKode branch).
*/
import { useState, useCallback, useEffect, useRef } from 'react'
interface KeypadProps {
/** Number of digits expected */
length: number;
/** Called when all digits entered */
onComplete: (digits: number[]) => void;
/** Optional: called on each digit press */
onDigit?: (digit: number, current: number[]) => void;
/** Show the entered digits (vs dots) */
showDigits?: boolean;
/** Label text above the indicator */
label?: string;
/** Disable input */
disabled?: boolean;
/** Error message to show */
error?: string;
/** Reset trigger — increment to clear */
resetKey?: number;
svgs: string[]
attrsPerKey: number
numbOfKeys: number
onComplete: (selection: number[]) => void
loading?: boolean
}
const KEYS = [
[1, 2, 3],
[4, 5, 6],
[7, 8, 9],
[null, 0, 'del'],
] as const;
function keyAspectRatio(attrsPerKey: number): number {
if (attrsPerKey <= 3) return 21 / 7
if (attrsPerKey <= 6) return 21 / 15
if (attrsPerKey <= 9) return 1
if (attrsPerKey <= 12) return 21 / 27.5
if (attrsPerKey <= 15) return 21 / 34
return 21 / 40.5
}
export function Keypad({
length,
export default function Keypad({
svgs,
attrsPerKey,
numbOfKeys,
onComplete,
onDigit,
showDigits = false,
label,
disabled = false,
error,
resetKey = 0,
loading = false,
}: KeypadProps) {
const [digits, setDigits] = useState<number[]>([]);
const [selection, setSelection] = useState<number[]>([])
const [pressedKey, setPressedKey] = useState<number | null>(null)
const [columns, setColumns] = useState(2)
const pressTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
// Reset when resetKey changes
// Reset selection when svgs change
const svgsRef = useRef(svgs)
useEffect(() => {
setDigits([]);
}, [resetKey]);
if (svgsRef.current !== svgs) {
svgsRef.current = svgs
setSelection([])
}
}, [svgs])
const handlePress = useCallback(
(key: number | 'del') => {
if (disabled) return;
if (key === 'del') {
setDigits((d) => d.slice(0, -1));
return;
}
setDigits((prev) => {
if (prev.length >= length) return prev;
const next = [...prev, key];
onDigit?.(key, next);
if (next.length === length) {
// Slight delay so the UI shows the last dot
setTimeout(() => onComplete(next), 150);
}
return next;
});
},
[length, onComplete, onDigit, disabled]
);
// Keyboard support
// Responsive: 2 cols portrait, 3 cols landscape
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (disabled) return;
const n = parseInt(e.key);
if (!isNaN(n) && n >= 0 && n <= 9) {
handlePress(n);
} else if (e.key === 'Backspace') {
handlePress('del');
const update = () => setColumns(window.innerWidth > window.innerHeight ? 3 : 2)
update()
window.addEventListener('resize', update)
return () => window.removeEventListener('resize', update)
}, [])
useEffect(() => {
return () => {
if (pressTimerRef.current) clearTimeout(pressTimerRef.current)
}
}, [])
const handlePress = useCallback((keyIndex: number) => {
setSelection(prev => [...prev, keyIndex])
setPressedKey(keyIndex)
if (pressTimerRef.current) clearTimeout(pressTimerRef.current)
pressTimerRef.current = setTimeout(() => setPressedKey(null), 200)
}, [])
const handleBackspace = useCallback(() => {
setSelection(prev => prev.slice(0, -1))
}, [])
const handleSubmit = useCallback(() => {
if (selection.length > 0) {
onComplete(selection)
}
}, [selection, onComplete])
// Keyboard: Backspace to delete, Enter to submit
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Backspace') {
e.preventDefault()
handleBackspace()
} else if (e.key === 'Enter' && selection.length > 0 && !loading) {
e.preventDefault()
handleSubmit()
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [handlePress, disabled]);
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [handleBackspace, handleSubmit, selection.length, loading])
const aspect = keyAspectRatio(attrsPerKey)
const iconRows = Math.ceil(attrsPerKey / 3)
return (
<div className="flex flex-col items-center gap-6">
{/* Label */}
{label && (
<p className="text-sm text-slate-500 dark:text-slate-400 font-medium">
{label}
</p>
)}
{/* Dot indicators */}
<div className="flex gap-3">
{Array.from({ length }).map((_, i) => (
<div
key={i}
className={`w-3.5 h-3.5 rounded-full transition-all duration-200 ${
i < digits.length
? 'bg-indigo-500 scale-110'
: 'bg-slate-200 dark:bg-slate-700'
}`}
>
{showDigits && i < digits.length && (
<span className="flex items-center justify-center w-full h-full text-[10px] text-white font-bold">
{digits[i]}
<div className="keypad-root">
{/* Input display: [• dots + ⌫] [Submit] */}
<div className="keypad-input-row">
<div className="keypad-input-box">
<div className="keypad-dots-area">
{selection.length === 0 ? (
<span className="keypad-placeholder">Tap icons to enter your nKode</span>
) : (
<span className="keypad-dot-text">
{'•'.repeat(selection.length)}
</span>
)}
</div>
))}
<button
type="button"
className="keypad-backspace-btn"
onClick={handleBackspace}
disabled={selection.length === 0 || loading}
aria-label="Delete last selection"
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 4H8l-7 8 7 8h13a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2z" />
<line x1="18" y1="9" x2="12" y2="15" />
<line x1="12" y1="9" x2="18" y2="15" />
</svg>
</button>
</div>
<button
type="button"
className="keypad-submit-btn"
onClick={handleSubmit}
disabled={selection.length === 0 || loading}
>
{loading ? '…' : 'Submit'}
</button>
</div>
{/* Error */}
{error && (
<p className="text-sm text-red-500 font-medium animate-pulse">{error}</p>
)}
{/* Key tile grid */}
<div
className="keypad-grid"
style={{ gridTemplateColumns: `repeat(${columns}, 1fr)` }}
role="group"
aria-label="nKode keypad"
>
{Array.from({ length: numbOfKeys }).map((_, keyIndex) => {
const isActive = pressedKey === keyIndex
const startIdx = keyIndex * attrsPerKey
const keyIcons = svgs.slice(startIdx, startIdx + attrsPerKey)
{/* Keypad grid */}
<div className="grid grid-cols-3 gap-4">
{KEYS.flat().map((key, i) => {
if (key === null) {
return <div key={i} />;
}
if (key === 'del') {
return (
<button
key={i}
onClick={() => handlePress('del')}
disabled={disabled || digits.length === 0}
className="keypad-btn text-lg !bg-transparent !border-transparent !shadow-none
text-slate-400 dark:text-slate-500 hover:text-slate-600 dark:hover:text-slate-300
disabled:opacity-30"
aria-label="Delete"
>
</button>
);
}
return (
<button
key={i}
onClick={() => handlePress(key)}
disabled={disabled || digits.length >= length}
className="keypad-btn disabled:opacity-30"
key={keyIndex}
type="button"
disabled={loading}
onClick={() => handlePress(keyIndex)}
onTouchStart={() => setPressedKey(keyIndex)}
onTouchEnd={() => {
if (pressTimerRef.current) clearTimeout(pressTimerRef.current)
pressTimerRef.current = setTimeout(() => setPressedKey(null), 150)
}}
className={`keypad-key ${isActive ? 'keypad-key--active' : ''}`}
style={{ aspectRatio: `${aspect}` }}
aria-label={`Key ${keyIndex + 1}`}
>
{key}
<div
className="keypad-key-icons"
style={{ gridTemplateRows: `repeat(${iconRows}, 1fr)` }}
>
{keyIcons.map((svg, iconIdx) => (
<div
key={iconIdx}
className="keypad-icon-cell"
dangerouslySetInnerHTML={{ __html: svg }}
/>
))}
</div>
</button>
);
)
})}
</div>
<style>{`
.keypad-root {
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
width: 100%;
max-width: 600px;
margin: 0 auto;
padding: 10px;
}
@media (max-width: 600px) {
.keypad-root { max-width: 95vw; }
}
@media (orientation: landscape) and (min-width: 601px) {
.keypad-root { max-width: 60vw; }
}
.keypad-input-row {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
}
.keypad-input-box {
display: flex;
align-items: center;
flex: 1;
min-height: 48px;
background: white;
border: 4px solid black;
border-radius: 12px;
padding: 0 8px;
overflow: hidden;
}
.keypad-dots-area {
flex: 1;
min-width: 0;
padding: 4px 0;
}
.keypad-placeholder {
color: #a1a1aa;
font-size: 0.8rem;
}
.keypad-dot-text {
font-size: 36px;
line-height: 1;
color: black;
letter-spacing: 2px;
word-break: break-all;
}
.keypad-backspace-btn {
flex-shrink: 0;
background: none;
border: none;
cursor: pointer;
color: #333;
padding: 4px;
display: flex;
align-items: center;
}
.keypad-backspace-btn:disabled { opacity: 0.3; cursor: not-allowed; }
.keypad-submit-btn {
flex-shrink: 0;
padding: 12px 20px;
border-radius: 12px;
background: #6366f1;
color: white;
border: none;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: background 0.15s;
white-space: nowrap;
}
.keypad-submit-btn:hover:not(:disabled) { background: #4f46e5; }
.keypad-submit-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.keypad-grid {
display: grid;
gap: 8px;
width: 100%;
}
.keypad-key {
display: flex;
align-items: center;
justify-content: center;
padding: 8px;
background: transparent;
border: 4px solid rgba(0,0,0,0.12);
border-radius: 12px;
cursor: pointer;
transition: border-width 0.05s, border-color 0.05s;
-webkit-tap-highlight-color: transparent;
touch-action: manipulation;
outline: none;
}
.keypad-key:disabled { opacity: 0.4; cursor: not-allowed; }
.keypad-key:active,
.keypad-key--active {
border-width: 20px;
border-color: #000080;
}
.keypad-key-icons {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0;
width: 100%;
height: 100%;
}
.keypad-icon-cell {
display: flex;
align-items: center;
justify-content: center;
background: white;
overflow: hidden;
}
.keypad-icon-cell svg,
.keypad-icon-cell img {
width: 100%;
height: 100%;
display: block;
object-fit: contain;
}
`}</style>
</div>
);
)
}

View File

@@ -1,70 +1,53 @@
import { Link, Outlet, useLocation } from 'react-router-dom';
import { useAuth } from '@/hooks/useAuth';
import { useTheme } from '@/hooks/useTheme';
import { ROUTES } from '@/lib/types';
/**
* App shell layout.
*/
import { Outlet, Link } from 'react-router-dom'
import { useAuth } from '../hooks/useAuth'
export function Layout() {
const { isAuthenticated, email, logout } = useAuth();
const { resolved, setTheme, theme } = useTheme();
const location = useLocation();
const isAuthPage =
location.pathname === ROUTES.LOGIN || location.pathname === ROUTES.SIGNUP;
export default function Layout() {
const { isAuthenticated, email, logout } = useAuth()
return (
<div className="min-h-screen bg-slate-50 dark:bg-slate-950 transition-colors">
<div className="min-h-screen bg-zinc-950 text-white flex flex-col">
{/* Header */}
<header className="sticky top-0 z-50 border-b border-slate-200 dark:border-slate-800 bg-white/80 dark:bg-slate-900/80 backdrop-blur-sm">
<header className="border-b border-zinc-800 bg-zinc-900/80 backdrop-blur-sm sticky top-0 z-50">
<div className="max-w-5xl mx-auto px-4 h-14 flex items-center justify-between">
<Link to="/" className="flex items-center gap-2 font-bold text-lg">
<span className="text-indigo-500">n</span>
<span className="text-slate-900 dark:text-white">Kode</span>
<Link to={isAuthenticated ? '/home' : '/'} className="flex items-center gap-2">
<div className="w-8 h-8 rounded-lg bg-emerald-600 flex items-center justify-center font-bold text-sm">
nK
</div>
<span className="font-semibold text-lg tracking-tight">nKode</span>
</Link>
<div className="flex items-center gap-3">
{/* Theme toggle */}
<button
onClick={() =>
setTheme(
theme === 'light' ? 'dark' : theme === 'dark' ? 'system' : 'light'
)
}
className="p-2 rounded-lg text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
title={`Theme: ${theme}`}
>
{resolved === 'dark' ? '🌙' : '☀️'}
</button>
<nav className="flex items-center gap-4 text-sm font-medium">
{isAuthenticated ? (
<>
<span className="text-sm text-slate-500 dark:text-slate-400 hidden sm:inline">
{email}
</span>
<span className="text-zinc-500 text-xs">{email}</span>
<button
onClick={logout}
className="text-sm px-3 py-1.5 rounded-lg text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
className="text-zinc-400 hover:text-red-400 transition-colors"
>
Sign out
Logout
</button>
</>
) : (
!isAuthPage && (
<Link
to={ROUTES.LOGIN}
className="text-sm px-4 py-1.5 rounded-lg bg-indigo-500 text-white hover:bg-indigo-600 transition-colors"
>
Sign in
</Link>
)
<Link to="/" className="text-zinc-400 hover:text-zinc-200">Login / Sign Up</Link>
)}
</div>
</nav>
</div>
</header>
{/* Content */}
<main className="max-w-5xl mx-auto px-4 py-8">
<Outlet />
{/* Main content */}
<main className="flex-1 flex flex-col">
<div className="max-w-5xl mx-auto w-full px-4 py-8 flex-1">
<Outlet />
</div>
</main>
{/* Footer */}
<footer className="border-t border-zinc-800 py-4 text-center text-xs text-zinc-600">
© {new Date().getFullYear()} nKode Passwordless Authentication
</footer>
</div>
);
)
}