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:
@@ -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>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user