feat: nKode React + TypeScript SPA scaffold

- React + Vite + Tailwind CSS v4 + React Router
- Keypad component: 10-digit pad with dot indicators, keyboard support, haptic feel
- Auth pages: Login (email → keypad), Signup (email → method → keypad/key)
- Home page: session info, practice keypad
- Admin dashboard: placeholder stats + user management
- Developer dashboard: OIDC client registration UI placeholder
- WASM client wrapper: lazy loads from /wasm/, falls back to mock client
- TypeScript type declarations for nkode-client-wasm package
- Dark mode with system preference detection
- Auth state management with localStorage persistence + auto-expiry
- Code splitting: lazy-loaded route pages (~236KB main bundle)
- Inter font, clean indigo/slate design system
This commit is contained in:
2026-01-29 14:39:27 +00:00
commit a4de830313
25 changed files with 2004 additions and 0 deletions

41
src/App.tsx Normal file
View File

@@ -0,0 +1,41 @@
import { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { Layout } from '@/components/Layout';
import { AuthContext, useAuthState } from '@/hooks/useAuth';
import { ROUTES } from '@/lib/types';
const HomePage = lazy(() => import('@/pages/HomePage').then((m) => ({ default: m.HomePage })));
const LoginPage = lazy(() => import('@/pages/LoginPage').then((m) => ({ default: m.LoginPage })));
const SignupPage = lazy(() => import('@/pages/SignupPage').then((m) => ({ default: m.SignupPage })));
const AdminPage = lazy(() => import('@/pages/AdminPage').then((m) => ({ default: m.AdminPage })));
const DeveloperPage = lazy(() => import('@/pages/DeveloperPage').then((m) => ({ default: m.DeveloperPage })));
function Loading() {
return (
<div className="flex items-center justify-center min-h-[50vh]">
<div className="w-6 h-6 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin" />
</div>
);
}
export default function App() {
const auth = useAuthState();
return (
<AuthContext.Provider value={auth}>
<BrowserRouter>
<Suspense fallback={<Loading />}>
<Routes>
<Route element={<Layout />}>
<Route path={ROUTES.HOME} element={<HomePage />} />
<Route path={ROUTES.LOGIN} element={<LoginPage />} />
<Route path={ROUTES.SIGNUP} element={<SignupPage />} />
<Route path={ROUTES.ADMIN} element={<AdminPage />} />
<Route path={ROUTES.DEVELOPER} element={<DeveloperPage />} />
</Route>
</Routes>
</Suspense>
</BrowserRouter>
</AuthContext.Provider>
);
}

153
src/components/Keypad.tsx Normal file
View File

@@ -0,0 +1,153 @@
import { useState, useCallback, useEffect } 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;
}
const KEYS = [
[1, 2, 3],
[4, 5, 6],
[7, 8, 9],
[null, 0, 'del'],
] as const;
export function Keypad({
length,
onComplete,
onDigit,
showDigits = false,
label,
disabled = false,
error,
resetKey = 0,
}: KeypadProps) {
const [digits, setDigits] = useState<number[]>([]);
// Reset when resetKey changes
useEffect(() => {
setDigits([]);
}, [resetKey]);
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
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');
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [handlePress, disabled]);
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]}
</span>
)}
</div>
))}
</div>
{/* Error */}
{error && (
<p className="text-sm text-red-500 font-medium animate-pulse">{error}</p>
)}
{/* 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}
</button>
);
})}
</div>
</div>
);
}

70
src/components/Layout.tsx Normal file
View File

@@ -0,0 +1,70 @@
import { Link, Outlet, useLocation } from 'react-router-dom';
import { useAuth } from '@/hooks/useAuth';
import { useTheme } from '@/hooks/useTheme';
import { ROUTES } from '@/lib/types';
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;
return (
<div className="min-h-screen bg-slate-50 dark:bg-slate-950 transition-colors">
{/* 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">
<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>
<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>
{isAuthenticated ? (
<>
<span className="text-sm text-slate-500 dark:text-slate-400 hidden sm:inline">
{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"
>
Sign out
</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>
)
)}
</div>
</div>
</header>
{/* Content */}
<main className="max-w-5xl mx-auto px-4 py-8">
<Outlet />
</main>
</div>
);
}

64
src/hooks/useAuth.ts Normal file
View File

@@ -0,0 +1,64 @@
import { useState, useCallback, useEffect, createContext, useContext } from 'react';
import type { AuthState, NKodeSession } from '@/lib/types';
const SESSION_KEY = 'nkode_session';
const EMAIL_KEY = 'nkode_email';
function loadStoredSession(): AuthState {
try {
const raw = localStorage.getItem(SESSION_KEY);
const email = localStorage.getItem(EMAIL_KEY);
if (raw) {
const session: NKodeSession = JSON.parse(raw);
// Check expiry
if (new Date(session.expiresAt) > new Date()) {
return { isAuthenticated: true, session, email };
}
localStorage.removeItem(SESSION_KEY);
}
} catch {}
return { isAuthenticated: false, session: null, email: null };
}
export interface AuthContextType extends AuthState {
login: (email: string, session: NKodeSession) => void;
logout: () => void;
}
export const AuthContext = createContext<AuthContextType | null>(null);
export function useAuth(): AuthContextType {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth must be inside AuthProvider');
return ctx;
}
export function useAuthState() {
const [state, setState] = useState<AuthState>(loadStoredSession);
const login = useCallback((email: string, session: NKodeSession) => {
localStorage.setItem(SESSION_KEY, JSON.stringify(session));
localStorage.setItem(EMAIL_KEY, email);
setState({ isAuthenticated: true, session, email });
}, []);
const logout = useCallback(() => {
localStorage.removeItem(SESSION_KEY);
localStorage.removeItem(EMAIL_KEY);
setState({ isAuthenticated: false, session: null, email: null });
}, []);
// Auto-logout on expiry
useEffect(() => {
if (!state.session) return;
const ms = new Date(state.session.expiresAt).getTime() - Date.now();
if (ms <= 0) {
logout();
return;
}
const timer = setTimeout(logout, ms);
return () => clearTimeout(timer);
}, [state.session, logout]);
return { ...state, login, logout };
}

34
src/hooks/useTheme.ts Normal file
View File

@@ -0,0 +1,34 @@
import { useState, useEffect, useCallback } from 'react';
type Theme = 'light' | 'dark' | 'system';
function getSystemTheme(): 'light' | 'dark' {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
export function useTheme() {
const [theme, setThemeState] = useState<Theme>(() => {
return (localStorage.getItem('nkode_theme') as Theme) || 'system';
});
const resolved = theme === 'system' ? getSystemTheme() : theme;
useEffect(() => {
document.documentElement.classList.toggle('dark', resolved === 'dark');
}, [resolved]);
useEffect(() => {
if (theme !== 'system') return;
const mq = window.matchMedia('(prefers-color-scheme: dark)');
const handler = () => setThemeState('system'); // re-trigger
mq.addEventListener('change', handler);
return () => mq.removeEventListener('change', handler);
}, [theme]);
const setTheme = useCallback((t: Theme) => {
localStorage.setItem('nkode_theme', t);
setThemeState(t);
}, []);
return { theme, resolved, setTheme };
}

47
src/index.css Normal file
View File

@@ -0,0 +1,47 @@
@import "tailwindcss";
@custom-variant dark (&:is(.dark *));
:root {
--nkode-primary: #6366f1;
--nkode-primary-hover: #4f46e5;
--nkode-surface: #ffffff;
--nkode-surface-alt: #f8fafc;
--nkode-border: #e2e8f0;
--nkode-text: #0f172a;
--nkode-text-muted: #64748b;
}
.dark {
--nkode-primary: #818cf8;
--nkode-primary-hover: #6366f1;
--nkode-surface: #0f172a;
--nkode-surface-alt: #1e293b;
--nkode-border: #334155;
--nkode-text: #f1f5f9;
--nkode-text-muted: #94a3b8;
}
body {
margin: 0;
font-family: 'Inter', system-ui, -apple-system, sans-serif;
background: var(--nkode-surface);
color: var(--nkode-text);
-webkit-font-smoothing: antialiased;
}
/* Keypad button styles */
.keypad-btn {
@apply w-16 h-16 rounded-full text-2xl font-semibold
transition-all duration-150 ease-out
active:scale-95 select-none
bg-white dark:bg-slate-800
text-slate-900 dark:text-slate-100
border border-slate-200 dark:border-slate-700
hover:bg-slate-50 dark:hover:bg-slate-700
shadow-sm;
}
.keypad-btn:active {
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.1);
}

134
src/lib/nkode-client.ts Normal file
View File

@@ -0,0 +1,134 @@
/**
* nKode WASM client wrapper.
*
* This module lazily loads the WASM package and provides a typed API.
* In development, it falls back to a mock client until the WASM package is linked.
*/
import type { NKodeSession } from './types';
// We'll import the WASM module dynamically
let wasmModule: any = null;
let wasmClient: any = null;
const API_BASE = import.meta.env.VITE_NKODE_API_URL || 'http://localhost:3000';
/**
* Try to load the WASM module from /wasm/ directory.
* Uses dynamic import with vite-ignore to avoid bundling.
*/
async function tryLoadWasm(): Promise<any> {
try {
// Check if WASM files are available
const check = await fetch('/wasm/nkode_client_wasm_bg.wasm', { method: 'HEAD' });
if (!check.ok) return null;
// Dynamic import — vite-ignore prevents bundler from resolving
const wasmUrl = new URL('/wasm/nkode_client_wasm.js', window.location.origin).href;
const module = await import(/* @vite-ignore */ wasmUrl);
await module.default('/wasm/nkode_client_wasm_bg.wasm');
module.init();
return module;
} catch {
return null;
}
}
/**
* Initialize the WASM module. Call once at app startup.
*/
export async function initNKode(): Promise<void> {
try {
const wasm = await tryLoadWasm();
if (wasm) {
wasmModule = wasm;
wasmClient = new wasm.NKodeClient(API_BASE);
console.log('[nKode] WASM client initialized');
} else {
console.warn('[nKode] WASM not available, using mock client');
}
} catch (e) {
console.warn('[nKode] WASM init error, using mock client:', e);
}
}
/**
* Check if WASM client is loaded.
*/
export function isWasmReady(): boolean {
return wasmClient !== null;
}
/**
* Generate a random 16-byte secret key (hex string).
*/
export function generateSecretKey(): string {
if (wasmModule) {
return wasmModule.NKodeClient.generateSecretKey();
}
// Fallback: browser crypto
const bytes = new Uint8Array(16);
crypto.getRandomValues(bytes);
return Array.from(bytes)
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}
/**
* Register with key-based OPAQUE flow.
*/
export async function registerKey(email: string, secretKeyHex: string): Promise<void> {
if (wasmClient) {
await wasmClient.registerKey(email, secretKeyHex);
return;
}
// Mock
console.log('[nKode mock] registerKey', email);
await new Promise((r) => setTimeout(r, 500));
}
/**
* Register with code-based OPAQUE flow.
*/
export async function registerCode(email: string, passcode: Uint8Array): Promise<void> {
if (wasmClient) {
await wasmClient.registerCode(email, passcode);
return;
}
console.log('[nKode mock] registerCode', email);
await new Promise((r) => setTimeout(r, 500));
}
/**
* Login with key-based OPAQUE flow.
*/
export async function loginKey(email: string, secretKeyHex: string): Promise<NKodeSession> {
if (wasmClient) {
return await wasmClient.loginKey(email, secretKeyHex);
}
console.log('[nKode mock] loginKey', email);
await new Promise((r) => setTimeout(r, 500));
return {
sessionId: 'mock-session',
userId: 'mock-user',
createdAt: new Date().toISOString(),
expiresAt: new Date(Date.now() + 3600000).toISOString(),
};
}
/**
* Login with code-based OPAQUE flow.
*/
export async function loginCode(email: string, passcode: Uint8Array): Promise<NKodeSession> {
if (wasmClient) {
return await wasmClient.loginCode(email, passcode);
}
console.log('[nKode mock] loginCode', email);
await new Promise((r) => setTimeout(r, 500));
return {
sessionId: 'mock-session',
userId: 'mock-user',
createdAt: new Date().toISOString(),
expiresAt: new Date(Date.now() + 3600000).toISOString(),
};
}

26
src/lib/types.ts Normal file
View File

@@ -0,0 +1,26 @@
/** Authentication flow type */
export type AuthFlow = 'key' | 'code';
/** User session from successful login */
export interface NKodeSession {
sessionId: string;
userId: string;
createdAt: string;
expiresAt: string;
}
/** App-level auth state */
export interface AuthState {
isAuthenticated: boolean;
session: NKodeSession | null;
email: string | null;
}
/** Route paths */
export const ROUTES = {
LOGIN: '/login',
SIGNUP: '/signup',
HOME: '/',
ADMIN: '/admin',
DEVELOPER: '/developer',
} as const;

14
src/main.tsx Normal file
View File

@@ -0,0 +1,14 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import './index.css';
import App from './App';
import { initNKode } from '@/lib/nkode-client';
// Initialize WASM client (non-blocking)
initNKode();
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>
);

44
src/pages/AdminPage.tsx Normal file
View File

@@ -0,0 +1,44 @@
import { useAuth } from '@/hooks/useAuth';
import { Navigate } from 'react-router-dom';
import { ROUTES } from '@/lib/types';
export function AdminPage() {
const { isAuthenticated } = useAuth();
if (!isAuthenticated) {
return <Navigate to={ROUTES.LOGIN} replace />;
}
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">Admin Dashboard</h1>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{[
{ label: 'Total Users', value: '—', icon: '👤' },
{ label: 'Active Sessions', value: '—', icon: '🔐' },
{ label: 'Registered Clients', value: '—', icon: '📱' },
].map((stat) => (
<div
key={stat.label}
className="p-4 rounded-xl bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700"
>
<div className="flex items-center gap-3">
<span className="text-2xl">{stat.icon}</span>
<div>
<p className="text-sm text-slate-500 dark:text-slate-400">{stat.label}</p>
<p className="text-xl font-bold text-slate-900 dark:text-white">{stat.value}</p>
</div>
</div>
</div>
))}
</div>
<div className="p-6 rounded-xl bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700">
<p className="text-slate-500 dark:text-slate-400 text-center">
Admin features coming soon. This will display user management, session monitoring, and OIDC client configuration.
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,57 @@
import { useAuth } from '@/hooks/useAuth';
import { Navigate } from 'react-router-dom';
import { ROUTES } from '@/lib/types';
export function DeveloperPage() {
const { isAuthenticated } = useAuth();
if (!isAuthenticated) {
return <Navigate to={ROUTES.LOGIN} replace />;
}
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">Developer Dashboard</h1>
<div className="p-6 rounded-xl bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 space-y-4">
<h2 className="font-semibold text-slate-900 dark:text-white">OIDC Client Setup</h2>
<p className="text-sm text-slate-500 dark:text-slate-400">
Register your application to use nKode as an identity provider. Configure redirect URIs, scopes, and authentication flows.
</p>
<div className="space-y-3">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
Application Name
</label>
<input
type="text"
placeholder="My App"
className="w-full px-4 py-2 rounded-lg border border-slate-200 dark:border-slate-700
bg-white dark:bg-slate-900 text-slate-900 dark:text-white
placeholder:text-slate-400 dark:placeholder:text-slate-500"
disabled
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
Redirect URI
</label>
<input
type="text"
placeholder="https://myapp.com/callback"
className="w-full px-4 py-2 rounded-lg border border-slate-200 dark:border-slate-700
bg-white dark:bg-slate-900 text-slate-900 dark:text-white
placeholder:text-slate-400 dark:placeholder:text-slate-500"
disabled
/>
</div>
</div>
<p className="text-xs text-slate-400 dark:text-slate-500 italic">
Client registration coming soon. This will generate client_id and client_secret for OAuth2/OIDC integration.
</p>
</div>
</div>
);
}

85
src/pages/HomePage.tsx Normal file
View File

@@ -0,0 +1,85 @@
import { useAuth } from '@/hooks/useAuth';
import { Keypad } from '@/components/Keypad';
import { useState } from 'react';
export function HomePage() {
const { isAuthenticated, session, email } = useAuth();
const [practiceResult, setPracticeResult] = useState<string | null>(null);
const [resetKey, setResetKey] = useState(0);
if (!isAuthenticated) {
return (
<div className="flex flex-col items-center justify-center min-h-[60vh] text-center">
<h1 className="text-4xl font-bold text-slate-900 dark:text-white mb-4">
<span className="text-indigo-500">n</span>Kode
</h1>
<p className="text-lg text-slate-500 dark:text-slate-400 max-w-md mb-2">
Passwordless authentication powered by OPAQUE.
</p>
<p className="text-sm text-slate-400 dark:text-slate-500 max-w-md">
Replace passwords with a memorized numeric code or a cryptographic key.
Zero-knowledge proof means the server never sees your secret.
</p>
</div>
);
}
const handlePractice = (digits: number[]) => {
setPracticeResult(`✅ You entered: ${digits.join('')}`);
setTimeout(() => {
setPracticeResult(null);
setResetKey((k) => k + 1);
}, 2000);
};
return (
<div className="space-y-8">
{/* Welcome */}
<div className="text-center">
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">
Welcome back
</h1>
<p className="text-slate-500 dark:text-slate-400 mt-1">{email}</p>
</div>
{/* Session info */}
<div className="max-w-md mx-auto p-4 rounded-xl bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700">
<h2 className="font-semibold text-slate-900 dark:text-white mb-3">Session</h2>
<dl className="space-y-2 text-sm">
<div className="flex justify-between">
<dt className="text-slate-500 dark:text-slate-400">Session ID</dt>
<dd className="text-slate-900 dark:text-slate-100 font-mono text-xs truncate max-w-[200px]">
{session?.sessionId}
</dd>
</div>
<div className="flex justify-between">
<dt className="text-slate-500 dark:text-slate-400">Expires</dt>
<dd className="text-slate-900 dark:text-slate-100">
{session?.expiresAt
? new Date(session.expiresAt).toLocaleString()
: '—'}
</dd>
</div>
</dl>
</div>
{/* Practice keypad */}
<div className="max-w-md mx-auto p-6 rounded-xl bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700">
<h2 className="font-semibold text-slate-900 dark:text-white mb-4 text-center">
Practice your nKode
</h2>
<Keypad
length={6}
onComplete={handlePractice}
label="Enter any 6 digits"
resetKey={resetKey}
/>
{practiceResult && (
<p className="text-center mt-4 text-sm text-green-600 dark:text-green-400 font-medium">
{practiceResult}
</p>
)}
</div>
</div>
);
}

124
src/pages/LoginPage.tsx Normal file
View File

@@ -0,0 +1,124 @@
import { useState, useCallback } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { Keypad } from '@/components/Keypad';
import { useAuth } from '@/hooks/useAuth';
import { loginCode } from '@/lib/nkode-client';
import { ROUTES } from '@/lib/types';
type Step = 'email' | 'keypad';
export function LoginPage() {
const [step, setStep] = useState<Step>('email');
const [email, setEmail] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [resetKey, setResetKey] = useState(0);
const { login } = useAuth();
const navigate = useNavigate();
const handleEmailSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!email.trim()) return;
setError('');
setStep('keypad');
};
const handleKeypadComplete = useCallback(
async (digits: number[]) => {
setLoading(true);
setError('');
try {
const passcode = new Uint8Array(digits);
const session = await loginCode(email, passcode);
login(email, session);
navigate(ROUTES.HOME);
} catch (err: any) {
setError(err?.message || 'Authentication failed');
setResetKey((k) => k + 1);
} finally {
setLoading(false);
}
},
[email, login, navigate]
);
return (
<div className="flex flex-col items-center justify-center min-h-[70vh]">
<div className="w-full max-w-sm">
{/* Logo */}
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-slate-900 dark:text-white">
<span className="text-indigo-500">n</span>Kode
</h1>
<p className="text-slate-500 dark:text-slate-400 mt-2">
{step === 'email' ? 'Sign in to your account' : 'Enter your nKode'}
</p>
</div>
{step === 'email' ? (
<form onSubmit={handleEmailSubmit} className="space-y-4">
<div>
<label
htmlFor="email"
className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5"
>
Email address
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
autoFocus
required
className="w-full px-4 py-2.5 rounded-xl border border-slate-200 dark:border-slate-700
bg-white dark:bg-slate-800 text-slate-900 dark:text-white
focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500
placeholder:text-slate-400 dark:placeholder:text-slate-500"
/>
</div>
<button
type="submit"
className="w-full py-2.5 rounded-xl bg-indigo-500 text-white font-medium
hover:bg-indigo-600 transition-colors focus:outline-none focus:ring-2 focus:ring-indigo-500/50"
>
Continue
</button>
<p className="text-center text-sm text-slate-500 dark:text-slate-400">
Don't have an account?{' '}
<Link to={ROUTES.SIGNUP} className="text-indigo-500 hover:text-indigo-600 font-medium">
Sign up
</Link>
</p>
</form>
) : (
<div className="flex flex-col items-center">
<button
onClick={() => {
setStep('email');
setError('');
}}
className="self-start mb-6 text-sm text-slate-500 hover:text-slate-700 dark:hover:text-slate-300"
>
{email}
</button>
<Keypad
length={6}
onComplete={handleKeypadComplete}
label="Enter your 6-digit nKode"
disabled={loading}
error={error}
resetKey={resetKey}
/>
{loading && (
<p className="mt-4 text-sm text-slate-500 animate-pulse">
Authenticating
</p>
)}
</div>
)}
</div>
</div>
);
}

277
src/pages/SignupPage.tsx Normal file
View File

@@ -0,0 +1,277 @@
import { useState, useCallback } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { Keypad } from '@/components/Keypad';
import { useAuth } from '@/hooks/useAuth';
import { registerCode, loginCode, generateSecretKey, registerKey } from '@/lib/nkode-client';
import { ROUTES } from '@/lib/types';
type Step = 'email' | 'method' | 'keypad' | 'confirm' | 'key-show' | 'done';
export function SignupPage() {
const [step, setStep] = useState<Step>('email');
const [email, setEmail] = useState('');
const [_method, setMethod] = useState<'code' | 'key'>('code');
const [firstCode, setFirstCode] = useState<number[]>([]);
const [secretKey, setSecretKey] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [resetKey, setResetKey] = useState(0);
const { login } = useAuth();
const navigate = useNavigate();
const handleEmailSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!email.trim()) return;
setError('');
setStep('method');
};
const handleMethodSelect = (m: 'code' | 'key') => {
setMethod(m);
if (m === 'code') {
setStep('keypad');
} else {
// Generate key immediately
const key = generateSecretKey();
setSecretKey(key);
setStep('key-show');
}
};
const handleFirstCode = useCallback((digits: number[]) => {
setFirstCode(digits);
setResetKey((k) => k + 1);
setStep('confirm');
}, []);
const handleConfirmCode = useCallback(
async (digits: number[]) => {
// Check codes match
if (digits.join('') !== firstCode.join('')) {
setError("Codes don't match. Try again.");
setResetKey((k) => k + 1);
return;
}
setLoading(true);
setError('');
try {
const passcode = new Uint8Array(digits);
await registerCode(email, passcode);
// Auto-login after registration
const session = await loginCode(email, passcode);
login(email, session);
navigate(ROUTES.HOME);
} catch (err: any) {
setError(err?.message || 'Registration failed');
setResetKey((k) => k + 1);
} finally {
setLoading(false);
}
},
[email, firstCode, login, navigate]
);
const handleKeyRegister = async () => {
setLoading(true);
setError('');
try {
await registerKey(email, secretKey);
setStep('done');
} catch (err: any) {
setError(err?.message || 'Registration failed');
} finally {
setLoading(false);
}
};
return (
<div className="flex flex-col items-center justify-center min-h-[70vh]">
<div className="w-full max-w-sm">
{/* Logo */}
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-slate-900 dark:text-white">
<span className="text-indigo-500">n</span>Kode
</h1>
<p className="text-slate-500 dark:text-slate-400 mt-2">
{step === 'email' && 'Create your account'}
{step === 'method' && 'Choose your authentication method'}
{step === 'keypad' && 'Create your nKode'}
{step === 'confirm' && 'Confirm your nKode'}
{step === 'key-show' && 'Save your secret key'}
{step === 'done' && 'You\'re all set!'}
</p>
</div>
{step === 'email' && (
<form onSubmit={handleEmailSubmit} className="space-y-4">
<div>
<label
htmlFor="email"
className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5"
>
Email address
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
autoFocus
required
className="w-full px-4 py-2.5 rounded-xl border border-slate-200 dark:border-slate-700
bg-white dark:bg-slate-800 text-slate-900 dark:text-white
focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500
placeholder:text-slate-400 dark:placeholder:text-slate-500"
/>
</div>
<button
type="submit"
className="w-full py-2.5 rounded-xl bg-indigo-500 text-white font-medium
hover:bg-indigo-600 transition-colors"
>
Continue
</button>
<p className="text-center text-sm text-slate-500 dark:text-slate-400">
Already have an account?{' '}
<Link to={ROUTES.LOGIN} className="text-indigo-500 hover:text-indigo-600 font-medium">
Sign in
</Link>
</p>
</form>
)}
{step === 'method' && (
<div className="space-y-3">
<button
onClick={() => setStep('email')}
className="text-sm text-slate-500 hover:text-slate-700 dark:hover:text-slate-300 mb-2"
>
{email}
</button>
<button
onClick={() => handleMethodSelect('code')}
className="w-full p-4 rounded-xl border border-slate-200 dark:border-slate-700
bg-white dark:bg-slate-800 text-left hover:border-indigo-300 dark:hover:border-indigo-600 transition-colors"
>
<div className="font-medium text-slate-900 dark:text-white">🔢 Code-based</div>
<div className="text-sm text-slate-500 dark:text-slate-400 mt-1">
Enter a 6-digit code on a keypad. Easy to remember, quick to use.
</div>
</button>
<button
onClick={() => handleMethodSelect('key')}
className="w-full p-4 rounded-xl border border-slate-200 dark:border-slate-700
bg-white dark:bg-slate-800 text-left hover:border-indigo-300 dark:hover:border-indigo-600 transition-colors"
>
<div className="font-medium text-slate-900 dark:text-white">🔑 Key-based</div>
<div className="text-sm text-slate-500 dark:text-slate-400 mt-1">
Generate a cryptographic key. Maximum security, store it safely.
</div>
</button>
</div>
)}
{step === 'keypad' && (
<div className="flex flex-col items-center">
<button
onClick={() => setStep('method')}
className="self-start mb-6 text-sm text-slate-500 hover:text-slate-700 dark:hover:text-slate-300"
>
Back
</button>
<Keypad
length={6}
onComplete={handleFirstCode}
label="Choose your 6-digit nKode"
disabled={loading}
error={error}
resetKey={resetKey}
/>
</div>
)}
{step === 'confirm' && (
<div className="flex flex-col items-center">
<button
onClick={() => {
setStep('keypad');
setResetKey((k) => k + 1);
setError('');
}}
className="self-start mb-6 text-sm text-slate-500 hover:text-slate-700 dark:hover:text-slate-300"
>
Start over
</button>
<Keypad
length={6}
onComplete={handleConfirmCode}
label="Confirm your nKode"
disabled={loading}
error={error}
resetKey={resetKey}
/>
{loading && (
<p className="mt-4 text-sm text-slate-500 animate-pulse">
Creating account
</p>
)}
</div>
)}
{step === 'key-show' && (
<div className="space-y-4">
<button
onClick={() => setStep('method')}
className="text-sm text-slate-500 hover:text-slate-700 dark:hover:text-slate-300"
>
Back
</button>
<div className="p-4 rounded-xl bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800">
<p className="text-sm font-medium text-amber-800 dark:text-amber-200 mb-2">
Save this key you won't see it again!
</p>
<code className="block p-3 rounded-lg bg-white dark:bg-slate-800 text-sm font-mono break-all
text-slate-900 dark:text-slate-100 border border-slate-200 dark:border-slate-700">
{secretKey}
</code>
</div>
<button
onClick={() => navigator.clipboard.writeText(secretKey)}
className="w-full py-2 rounded-xl border border-slate-200 dark:border-slate-700
text-slate-700 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors"
>
📋 Copy to clipboard
</button>
{error && <p className="text-sm text-red-500">{error}</p>}
<button
onClick={handleKeyRegister}
disabled={loading}
className="w-full py-2.5 rounded-xl bg-indigo-500 text-white font-medium
hover:bg-indigo-600 transition-colors disabled:opacity-50"
>
{loading ? 'Registering' : 'I\'ve saved my key — Register'}
</button>
</div>
)}
{step === 'done' && (
<div className="text-center space-y-4">
<div className="text-5xl"></div>
<p className="text-slate-600 dark:text-slate-300">
Your account has been created with key-based auth.
</p>
<Link
to={ROUTES.LOGIN}
className="inline-block px-6 py-2.5 rounded-xl bg-indigo-500 text-white font-medium
hover:bg-indigo-600 transition-colors"
>
Sign in
</Link>
</div>
)}
</div>
</div>
);
}

19
src/types/nkode-client-wasm.d.ts vendored Normal file
View File

@@ -0,0 +1,19 @@
/**
* Type stub for the nKode WASM package.
* Will be replaced by real types when the package is linked via `bun link`.
*/
declare module 'nkode-client-wasm' {
export class NKodeClient {
constructor(base_url: string);
loginCode(email: string, passcode_bytes: Uint8Array): Promise<any>;
loginKey(email: string, secret_key_hex: string): Promise<any>;
registerCode(email: string, passcode_bytes: Uint8Array): Promise<void>;
registerKey(email: string, secret_key_hex: string): Promise<void>;
static generateSecretKey(): string;
free(): void;
}
export function init(): void;
export default function __wbg_init(module_or_path?: any): Promise<any>;
}