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,41 +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';
import { Routes, Route, Navigate } from 'react-router-dom'
import { AuthProvider, useAuth } from './hooks/useAuth'
import Layout from './components/Layout'
import SusiPage from './pages/SusiPage'
import LoginKeypadPage from './pages/LoginKeypadPage'
import SignupKeypadPage from './pages/SignupKeypadPage'
import HomePage from './pages/HomePage'
import NotFoundPage from './pages/NotFoundPage'
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 ProtectedRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated } = useAuth()
if (!isAuthenticated) return <Navigate to="/" replace />
return <>{children}</>
}
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>
);
function GuestRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated } = useAuth()
if (isAuthenticated) return <Navigate to="/home" replace />
return <>{children}</>
}
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>
);
<AuthProvider>
<Routes>
<Route element={<Layout />}>
{/* Guest routes */}
<Route path="/" element={<GuestRoute><SusiPage /></GuestRoute>} />
<Route path="/login-keypad" element={<GuestRoute><LoginKeypadPage /></GuestRoute>} />
<Route path="/signup-keypad" element={<GuestRoute><SignupKeypadPage /></GuestRoute>} />
{/* Protected routes */}
<Route path="/home" element={<ProtectedRoute><HomePage /></ProtectedRoute>} />
{/* 404 */}
<Route path="*" element={<NotFoundPage />} />
</Route>
</Routes>
</AuthProvider>
)
}

View File

@@ -0,0 +1,223 @@
/**
* Auth flow integration tests — verify the sequence of API calls
* and navigation for login and signup flows.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter } from 'react-router-dom'
import SusiPage from '../pages/SusiPage'
import * as api from '../services/api'
// Mock the entire api module
vi.mock('../services/api', () => ({
loginKey: vi.fn(),
prepareCodeLogin: vi.fn(),
generateSecretKey: vi.fn(),
registerKey: vi.fn(),
prepareCodeRegistration: vi.fn(),
}))
// Mock useNavigate
const mockNavigate = vi.fn()
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual('react-router-dom')
return {
...actual,
useNavigate: () => mockNavigate,
}
})
function renderSusiPage() {
return render(
<MemoryRouter>
<SusiPage />
</MemoryRouter>,
)
}
describe('Auth Flow Integration', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Login flow', () => {
it('calls loginKey → prepareCodeLogin → navigates to /login-keypad', async () => {
const user = userEvent.setup()
const mockSession = {
sessionId: 'sess-123',
userId: 'user-abc',
createdAt: '2025-01-01',
expiresAt: '2025-01-02',
}
const mockCodeLoginData = {
keypadIndices: [0, 1, 2],
propertiesPerKey: 3,
numberOfKeys: 6,
mask: [1, 0, 1],
icons: [],
loginDataJson: '{}',
}
vi.mocked(api.loginKey).mockResolvedValue(mockSession)
vi.mocked(api.prepareCodeLogin).mockResolvedValue(mockCodeLoginData)
renderSusiPage()
// Fill in email
await user.type(screen.getByPlaceholderText('email'), 'test@example.com')
// Fill in secret key (32 hex chars)
await user.type(
screen.getByPlaceholderText('secret key (32 hex chars)'),
'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4',
)
// Submit (find the submit button, not the tab)
const loginButtons = screen.getAllByRole('button', { name: /login/i })
const submitBtn = loginButtons.find(b => b.getAttribute('type') === 'submit')!
await user.click(submitBtn)
await waitFor(() => {
// Step 1: loginKey called with email + key
expect(api.loginKey).toHaveBeenCalledWith(
'test@example.com',
'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4',
)
})
await waitFor(() => {
// Step 2: prepareCodeLogin called with userId + key
expect(api.prepareCodeLogin).toHaveBeenCalledWith(
'user-abc',
'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4',
)
})
await waitFor(() => {
// Step 3: navigate to /login-keypad with state
expect(mockNavigate).toHaveBeenCalledWith('/login-keypad', {
state: {
email: 'test@example.com',
secretKeyHex: 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4',
userId: 'user-abc',
codeLoginData: mockCodeLoginData,
},
})
})
})
it('shows error on login failure', async () => {
const user = userEvent.setup()
vi.mocked(api.loginKey).mockRejectedValue(new Error('Invalid credentials'))
renderSusiPage()
await user.type(screen.getByPlaceholderText('email'), 'test@example.com')
await user.type(
screen.getByPlaceholderText('secret key (32 hex chars)'),
'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4',
)
const loginButtons = screen.getAllByRole('button', { name: /login/i })
const submitBtn = loginButtons.find(b => b.getAttribute('type') === 'submit')!
await user.click(submitBtn)
await waitFor(() => {
expect(screen.getByText('Invalid credentials')).toBeInTheDocument()
})
})
})
describe('Signup flow', () => {
it('calls generateSecretKey → registerKey → loginKey → prepareCodeRegistration → navigates to /signup-keypad', async () => {
const user = userEvent.setup()
const mockSecretKey = 'deadbeef12345678deadbeef12345678'
const mockSession = {
sessionId: 'sess-456',
userId: 'user-xyz',
createdAt: '2025-01-01',
expiresAt: '2025-01-02',
}
const mockIcons = {
icons: [
{ file_name: 'icon1.svg', file_type: 'svg', img_data: '<svg></svg>' },
],
}
vi.mocked(api.generateSecretKey).mockReturnValue(mockSecretKey)
vi.mocked(api.registerKey).mockResolvedValue(undefined)
vi.mocked(api.loginKey).mockResolvedValue(mockSession)
vi.mocked(api.prepareCodeRegistration).mockResolvedValue(mockIcons)
renderSusiPage()
// Switch to signup tab
await user.click(screen.getByRole('button', { name: /sign up/i }))
// Fill in email
await user.type(screen.getByPlaceholderText('email'), 'newuser@example.com')
// Submit — the submit button in signup tab also says "Sign Up"
// We need the form submit button, not the tab button
const buttons = screen.getAllByRole('button', { name: /sign up/i })
// The last one is the form submit button
const submitBtn = buttons[buttons.length - 1]
await user.click(submitBtn)
await waitFor(() => {
// Step 1: generateSecretKey
expect(api.generateSecretKey).toHaveBeenCalled()
})
await waitFor(() => {
// Step 2: registerKey
expect(api.registerKey).toHaveBeenCalledWith('newuser@example.com', mockSecretKey)
})
await waitFor(() => {
// Step 3: loginKey
expect(api.loginKey).toHaveBeenCalledWith('newuser@example.com', mockSecretKey)
})
await waitFor(() => {
// Step 4: prepareCodeRegistration
expect(api.prepareCodeRegistration).toHaveBeenCalled()
})
await waitFor(() => {
// Step 5: navigate to /signup-keypad
expect(mockNavigate).toHaveBeenCalledWith('/signup-keypad', {
state: {
email: 'newuser@example.com',
secretKeyHex: mockSecretKey,
userId: 'user-xyz',
icons: mockIcons.icons,
},
})
})
})
})
describe('No method selection anywhere', () => {
it('login tab has no method selection step', () => {
renderSusiPage()
expect(screen.queryByText(/choose.*method/i)).not.toBeInTheDocument()
expect(screen.queryByText(/key-based/i)).not.toBeInTheDocument()
expect(screen.queryByText(/code-based/i)).not.toBeInTheDocument()
expect(screen.queryByRole('radio')).not.toBeInTheDocument()
})
it('signup tab has no method selection step', async () => {
const user = userEvent.setup()
renderSusiPage()
await user.click(screen.getByRole('button', { name: /sign up/i }))
expect(screen.queryByText(/choose.*method/i)).not.toBeInTheDocument()
expect(screen.queryByText(/key-based/i)).not.toBeInTheDocument()
expect(screen.queryByText(/code-based/i)).not.toBeInTheDocument()
expect(screen.queryByRole('radio')).not.toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,194 @@
/**
* Keypad component tests — verify rendering, selection, backspace, submit, SVG rendering.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import Keypad from '../components/Keypad'
// Simple SVG test data
const makeSvg = (id: number) =>
`<svg data-testid="icon-${id}" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" fill="red"/></svg>`
function makeSvgs(count: number): string[] {
return Array.from({ length: count }, (_, i) => makeSvg(i))
}
describe('Keypad', () => {
let onComplete: (selection: number[]) => void
beforeEach(() => {
onComplete = vi.fn() as unknown as (selection: number[]) => void
})
it('renders correct number of keys based on numbOfKeys prop', () => {
const numbOfKeys = 6
const attrsPerKey = 3
const svgs = makeSvgs(numbOfKeys * attrsPerKey)
render(
<Keypad
svgs={svgs}
attrsPerKey={attrsPerKey}
numbOfKeys={numbOfKeys}
onComplete={onComplete}
/>,
)
const keys = screen.getAllByRole('button', { name: /Key \d+/ })
expect(keys).toHaveLength(numbOfKeys)
})
it('each key has attrsPerKey icon cells', () => {
const numbOfKeys = 4
const attrsPerKey = 3
const svgs = makeSvgs(numbOfKeys * attrsPerKey)
render(
<Keypad
svgs={svgs}
attrsPerKey={attrsPerKey}
numbOfKeys={numbOfKeys}
onComplete={onComplete}
/>,
)
// Each key button should contain attrsPerKey icon cells
const keys = screen.getAllByRole('button', { name: /Key \d+/ })
keys.forEach((key) => {
const cells = key.querySelectorAll('.keypad-icon-cell')
expect(cells).toHaveLength(attrsPerKey)
})
})
it('pressing a key adds to selection (dot appears)', async () => {
const user = userEvent.setup()
const numbOfKeys = 4
const attrsPerKey = 3
const svgs = makeSvgs(numbOfKeys * attrsPerKey)
render(
<Keypad
svgs={svgs}
attrsPerKey={attrsPerKey}
numbOfKeys={numbOfKeys}
onComplete={onComplete}
/>,
)
// Initially shows placeholder
expect(screen.getByText(/tap icons to enter/i)).toBeInTheDocument()
// Click first key
await user.click(screen.getByRole('button', { name: 'Key 1' }))
// Placeholder gone, dot appears
expect(screen.queryByText(/tap icons to enter/i)).not.toBeInTheDocument()
expect(screen.getByText('•')).toBeInTheDocument()
// Click second key
await user.click(screen.getByRole('button', { name: 'Key 2' }))
expect(screen.getByText('••')).toBeInTheDocument()
})
it('backspace removes last selection', async () => {
const user = userEvent.setup()
const numbOfKeys = 4
const attrsPerKey = 3
const svgs = makeSvgs(numbOfKeys * attrsPerKey)
render(
<Keypad
svgs={svgs}
attrsPerKey={attrsPerKey}
numbOfKeys={numbOfKeys}
onComplete={onComplete}
/>,
)
// Add two selections
await user.click(screen.getByRole('button', { name: 'Key 1' }))
await user.click(screen.getByRole('button', { name: 'Key 2' }))
expect(screen.getByText('••')).toBeInTheDocument()
// Click backspace
await user.click(screen.getByRole('button', { name: /delete last selection/i }))
expect(screen.getByText('•')).toBeInTheDocument()
// Click backspace again
await user.click(screen.getByRole('button', { name: /delete last selection/i }))
expect(screen.getByText(/tap icons to enter/i)).toBeInTheDocument()
})
it('submit calls onComplete with selection array', async () => {
const user = userEvent.setup()
const numbOfKeys = 4
const attrsPerKey = 3
const svgs = makeSvgs(numbOfKeys * attrsPerKey)
render(
<Keypad
svgs={svgs}
attrsPerKey={attrsPerKey}
numbOfKeys={numbOfKeys}
onComplete={onComplete}
/>,
)
// Select keys 0, 2, 1
await user.click(screen.getByRole('button', { name: 'Key 1' }))
await user.click(screen.getByRole('button', { name: 'Key 3' }))
await user.click(screen.getByRole('button', { name: 'Key 2' }))
// Click submit
await user.click(screen.getByRole('button', { name: /submit/i }))
expect(onComplete).toHaveBeenCalledOnce()
expect(onComplete).toHaveBeenCalledWith([0, 2, 1])
})
it('submit is disabled when no selection', () => {
const numbOfKeys = 4
const attrsPerKey = 3
const svgs = makeSvgs(numbOfKeys * attrsPerKey)
render(
<Keypad
svgs={svgs}
attrsPerKey={attrsPerKey}
numbOfKeys={numbOfKeys}
onComplete={onComplete}
/>,
)
const submitBtn = screen.getByRole('button', { name: /submit/i })
expect(submitBtn).toBeDisabled()
})
it('renders SVGs as-is (no color manipulation)', () => {
const numbOfKeys = 2
const attrsPerKey = 1
const svgs = [
'<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" fill="#ff0000"/></svg>',
'<svg viewBox="0 0 24 24"><rect x="2" y="2" width="20" height="20" fill="blue"/></svg>',
]
render(
<Keypad
svgs={svgs}
attrsPerKey={attrsPerKey}
numbOfKeys={numbOfKeys}
onComplete={onComplete}
/>,
)
// SVGs should be rendered with original colors intact
const circles = document.querySelectorAll('circle')
expect(circles).toHaveLength(1)
expect(circles[0].getAttribute('fill')).toBe('#ff0000')
const rects = document.querySelectorAll('rect')
expect(rects).toHaveLength(1)
expect(rects[0].getAttribute('fill')).toBe('blue')
})
})

View File

@@ -0,0 +1,103 @@
/**
* SusiPage tests — verify login/signup tabs render correctly
* and that NO method selection UI exists.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter } from 'react-router-dom'
import SusiPage from '../pages/SusiPage'
// Mock the api module so no WASM/OPAQUE calls are made
vi.mock('../services/api', () => ({
loginKey: vi.fn(),
prepareCodeLogin: vi.fn(),
generateSecretKey: vi.fn(),
registerKey: vi.fn(),
prepareCodeRegistration: vi.fn(),
}))
function renderSusiPage() {
return render(
<MemoryRouter>
<SusiPage />
</MemoryRouter>,
)
}
describe('SusiPage', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Login tab (default)', () => {
it('renders email input, secret key input, and Login button', () => {
renderSusiPage()
expect(screen.getByPlaceholderText('email')).toBeInTheDocument()
expect(screen.getByPlaceholderText('secret key (32 hex chars)')).toBeInTheDocument()
// There are two "Login" buttons: tab + submit. Verify submit exists.
const loginButtons = screen.getAllByRole('button', { name: /login/i })
expect(loginButtons.length).toBeGreaterThanOrEqual(1)
const submitBtn = loginButtons.find(b => b.getAttribute('type') === 'submit')
expect(submitBtn).toBeDefined()
})
it('does NOT render any method selection UI', () => {
renderSusiPage()
expect(screen.queryByText(/key-based/i)).not.toBeInTheDocument()
expect(screen.queryByText(/code-based/i)).not.toBeInTheDocument()
expect(screen.queryByText(/choose method/i)).not.toBeInTheDocument()
expect(screen.queryByText(/select.*method/i)).not.toBeInTheDocument()
})
})
describe('Signup tab', () => {
it('renders email input and Sign Up button (no secret key input)', async () => {
const user = userEvent.setup()
renderSusiPage()
// Switch to signup tab
await user.click(screen.getByRole('button', { name: /sign up/i }))
// Signup has only email input
expect(screen.getByPlaceholderText('email')).toBeInTheDocument()
expect(screen.queryByPlaceholderText('secret key (32 hex chars)')).not.toBeInTheDocument()
// Submit button says "Sign Up" (tab + form button both say "Sign Up")
const signUpButtons = screen.getAllByRole('button', { name: /sign up/i })
const submitBtn = signUpButtons.find(b => b.getAttribute('type') === 'submit')
expect(submitBtn).toBeDefined()
})
it('does NOT render method selection or key-based/code-based options', async () => {
const user = userEvent.setup()
renderSusiPage()
await user.click(screen.getByRole('button', { name: /sign up/i }))
expect(screen.queryByText(/key-based/i)).not.toBeInTheDocument()
expect(screen.queryByText(/code-based/i)).not.toBeInTheDocument()
expect(screen.queryByText(/choose method/i)).not.toBeInTheDocument()
})
})
describe('Tab switching', () => {
it('switches between Login and Sign Up tabs', async () => {
const user = userEvent.setup()
renderSusiPage()
// Initially on Login tab — secret key field present
expect(screen.getByPlaceholderText('secret key (32 hex chars)')).toBeInTheDocument()
// Click Sign Up
await user.click(screen.getByRole('button', { name: /sign up/i }))
expect(screen.queryByPlaceholderText('secret key (32 hex chars)')).not.toBeInTheDocument()
// Click Login to switch back
await user.click(screen.getByRole('button', { name: /login/i }))
expect(screen.getByPlaceholderText('secret key (32 hex chars)')).toBeInTheDocument()
})
})
})

1
src/__tests__/setup.ts Normal file
View File

@@ -0,0 +1 @@
import '@testing-library/jest-dom'

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>
);
)
}

View File

@@ -1,64 +1,51 @@
import { useState, useCallback, useEffect, createContext, useContext } from 'react';
import type { AuthState, NKodeSession } from '@/lib/types';
/**
* Auth context — stores email + secretKey for OPAQUE-based auth.
* No tokens needed; the WASM client handles session keys internally.
*/
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react'
import { createElement } from 'react'
import type { AuthState } from '../types'
import { loadAuth, saveAuth, clearAuth } from '../services/auth'
import { resetClient } from '../services/api'
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 };
interface AuthContextValue extends AuthState {
/** Call after successful registration + code setup to persist credentials. */
login: (email: string, secretKeyHex: string, userId: string) => void
/** Clear persisted credentials. */
logout: () => void
/** True if we have stored credentials (email + secretKey). */
isAuthenticated: boolean
}
export interface AuthContextType extends AuthState {
login: (email: string, session: NKodeSession) => void;
logout: () => void;
}
const AuthContext = createContext<AuthContextValue | null>(null)
export const AuthContext = createContext<AuthContextType | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [state, setState] = useState<AuthState>(loadAuth)
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 login = useCallback((email: string, secretKeyHex: string, userId: string) => {
const next: AuthState = { email, secretKeyHex, userId }
setState(next)
saveAuth(next)
}, [])
const logout = useCallback(() => {
localStorage.removeItem(SESSION_KEY);
localStorage.removeItem(EMAIL_KEY);
setState({ isAuthenticated: false, session: null, email: null });
}, []);
setState({ email: null, secretKeyHex: null, userId: null })
clearAuth()
resetClient()
}, [])
// 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]);
const value: AuthContextValue = {
...state,
login,
logout,
isAuthenticated: !!state.email && !!state.secretKeyHex && !!state.userId,
}
return { ...state, login, logout };
return createElement(AuthContext.Provider, { value }, children)
}
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext)
if (!ctx) throw new Error('useAuth must be used within AuthProvider')
return ctx
}

View File

@@ -1,34 +0,0 @@
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 };
}

View File

@@ -1,47 +1 @@
@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);
}

View File

@@ -1,134 +0,0 @@
/**
* 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(),
};
}

View File

@@ -1,26 +0,0 @@
/** 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;

View File

@@ -1,14 +1,20 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import './index.css';
import App from './App';
import { initNKode } from '@/lib/nkode-client';
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import './index.css'
import App from './App'
import { initClient } from './services/api'
// Initialize WASM client (non-blocking)
initNKode();
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>
);
// Initialize WASM client, then render
initClient().then(() => {
createRoot(document.getElementById('root')!).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>,
)
}).catch((err: unknown) => {
console.error('Failed to initialize WASM client:', err)
document.getElementById('root')!.textContent = 'Failed to load application. Please refresh.'
})

View File

@@ -1,44 +0,0 @@
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

@@ -1,57 +0,0 @@
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>
);
}

View File

@@ -1,85 +1,46 @@
import { useAuth } from '@/hooks/useAuth';
import { Keypad } from '@/components/Keypad';
import { useState } from 'react';
/**
* Home page — shown after full authentication (key + code login).
*/
import { useAuth } from '../hooks/useAuth'
import { useNavigate } from 'react-router-dom'
export function HomePage() {
const { isAuthenticated, session, email } = useAuth();
const [practiceResult, setPracticeResult] = useState<string | null>(null);
const [resetKey, setResetKey] = useState(0);
export default function HomePage() {
const { email, userId, logout } = useAuth()
const navigate = useNavigate()
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 handleLogout = () => {
logout()
navigate('/', { replace: true })
}
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 className="flex flex-col h-full">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-semibold">Welcome</h1>
<p className="text-zinc-400 text-sm mt-1">{email}</p>
<p className="text-zinc-500 text-xs mt-0.5">User: {userId}</p>
</div>
<button
onClick={handleLogout}
className="px-4 py-2 rounded-lg bg-zinc-800 text-zinc-400 hover:text-red-400 transition-colors"
>
Logout
</button>
</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 className="flex-1 rounded-lg border border-zinc-800 bg-zinc-900 p-6 min-h-[400px]">
<div className="flex flex-col items-center justify-center h-full text-zinc-500">
<div className="w-16 h-16 rounded-2xl bg-emerald-600/20 flex items-center justify-center mb-4">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-emerald-400">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
<polyline points="22,4 12,14.01 9,11.01" />
</svg>
</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>
)}
<p className="text-lg text-emerald-400 font-medium">Authenticated via OPAQUE</p>
<p className="text-sm text-zinc-600 mt-2">Key + Code authentication complete</p>
</div>
</div>
</div>
);
)
}

View File

@@ -0,0 +1,114 @@
/**
* Login keypad page — code-based OPAQUE login.
* Displays keypad from prepareCodeLogin data, user taps their nKode,
* then deciphers selection and performs code login.
*/
import { useState } from 'react'
import { useLocation, useNavigate, Navigate } from 'react-router-dom'
import Keypad from '../components/Keypad'
import { useAuth } from '../hooks/useAuth'
import * as api from '../services/api'
import type { CodeLoginData, NKodeIcon } from '../types'
interface LocationState {
email: string
secretKeyHex: string
userId: string
codeLoginData: CodeLoginData
}
export default function LoginKeypadPage() {
const location = useLocation()
const navigate = useNavigate()
const auth = useAuth()
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const state = location.state as LocationState | null
if (!state?.email || !state?.codeLoginData) {
return <Navigate to="/" replace />
}
const { email, secretKeyHex, userId, codeLoginData } = state
// Build SVG strings from icons for the keypad.
// Icons are already ordered by keypadIndices from prepareCodeLogin.
const svgs = buildSvgsFromCodeLoginData(codeLoginData)
const handleLogin = async (pressedKeys: number[]) => {
setLoading(true)
setError(null)
try {
// Decipher key selections into passcode bytes
const passcodeBytes = api.decipherSelection(
secretKeyHex,
codeLoginData.loginDataJson,
pressedKeys,
)
// OPAQUE code login
await api.loginCode(email, passcodeBytes)
// Save credentials and navigate home
auth.login(email, secretKeyHex, userId)
navigate('/home', { replace: true })
} catch (err) {
setError(err instanceof Error ? err.message : 'Login failed')
} finally {
setLoading(false)
}
}
return (
<div className="flex flex-col items-center">
<div className="w-full max-w-[600px] px-4 pt-4">
<div className="flex items-center gap-4 mb-4">
<button
onClick={() => navigate('/')}
className="text-zinc-400 hover:text-white transition-colors"
>
Back
</button>
<h1 className="text-2xl font-semibold text-white">Login</h1>
</div>
<p className="text-lg text-zinc-300 text-center mb-4">{email}</p>
</div>
{error && (
<div className="w-full max-w-[600px] px-4 mb-4">
<div className="bg-red-900/50 border border-red-700 rounded-lg p-3 text-red-300 text-sm">
{error}
</div>
</div>
)}
<Keypad
svgs={svgs}
attrsPerKey={codeLoginData.propertiesPerKey}
numbOfKeys={codeLoginData.numberOfKeys}
onComplete={handleLogin}
loading={loading}
/>
</div>
)
}
/** Build SVG strings array from CodeLoginData for the Keypad component. */
function buildSvgsFromCodeLoginData(data: CodeLoginData): string[] {
const { keypadIndices, icons } = data
// keypadIndices maps each position to an icon index
return keypadIndices.map((iconIdx: number) => iconToSvg(icons[iconIdx]))
}
/** Convert an icon object to an SVG/HTML string for rendering. */
function iconToSvg(icon: NKodeIcon): string {
if (!icon) return '<svg></svg>'
if (icon.file_type === 'svg') {
return icon.img_data
}
// For non-SVG: render as img with base64
const mime = icon.file_type === 'png' ? 'image/png'
: icon.file_type === 'webp' ? 'image/webp'
: 'image/jpeg'
return `<img src="data:${mime};base64,${icon.img_data}" alt="${icon.file_name}" />`
}

View File

@@ -1,124 +0,0 @@
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>
);
}

View File

@@ -0,0 +1,13 @@
import { Link } from 'react-router-dom'
export default function NotFoundPage() {
return (
<div className="flex flex-col items-center justify-center py-20">
<h1 className="text-4xl font-bold mb-4">404</h1>
<p className="text-zinc-400 mb-6">Page not found</p>
<Link to="/" className="text-emerald-400 hover:text-emerald-300">
Back to Home
</Link>
</div>
)
}

View File

@@ -0,0 +1,165 @@
/**
* Signup keypad page — code registration flow.
* Displays icons for the user to select their nKode sequence.
* On submit, completes OPAQUE code registration and stores login data.
*/
import { useState, useCallback } from 'react'
import { useLocation, useNavigate, Navigate } from 'react-router-dom'
import { useAuth } from '../hooks/useAuth'
import * as api from '../services/api'
import type { NKodeIcon } from '../types'
interface LocationState {
email: string
secretKeyHex: string
userId: string
icons: NKodeIcon[]
}
/** Default keypad dimensions (matching Flutter: 9 props/key, 6 keys) */
const ATTRS_PER_KEY = 9
const NUM_KEYS = 6
export default function SignupKeypadPage() {
const location = useLocation()
const navigate = useNavigate()
const auth = useAuth()
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [secretKeyCopied, setSecretKeyCopied] = useState(false)
const state = location.state as LocationState | null
if (!state?.email || !state?.icons) {
return <Navigate to="/" replace />
}
const { email, secretKeyHex, userId, icons } = state
// Build SVG strings from icons for the keypad
const svgs = icons.map((icon: NKodeIcon) => iconToSvg(icon))
const handleCopyKey = useCallback(async () => {
try {
await navigator.clipboard.writeText(secretKeyHex)
setSecretKeyCopied(true)
setTimeout(() => setSecretKeyCopied(false), 3000)
} catch {
// Fallback: select text
setSecretKeyCopied(false)
}
}, [secretKeyHex])
const handleSetNKode = async (pressedKeys: number[]) => {
setLoading(true)
setError(null)
try {
// Convert key presses to global icon indices
// Each key press corresponds to keys on the keypad.
// The selectedIndices for completeCodeRegistration are the KEY indices pressed.
// The WASM SDK handles mapping keys to icon indices internally.
await api.completeCodeRegistrationWithEmail(email, pressedKeys)
// Save credentials and navigate home
auth.login(email, secretKeyHex, userId)
navigate('/home', { replace: true })
} catch (err) {
setError(err instanceof Error ? err.message : 'Registration failed')
} finally {
setLoading(false)
}
}
return (
<div className="flex flex-col items-center">
<div className="w-full max-w-[600px] px-4 pt-4">
<div className="flex items-center gap-4 mb-4">
<button
onClick={() => navigate('/')}
className="text-zinc-400 hover:text-white transition-colors"
>
Back
</button>
<h1 className="text-2xl font-semibold text-white">Set Your nKode</h1>
</div>
<p className="text-lg text-zinc-300 text-center mb-2">{email}</p>
{/* Secret key display — user must save this */}
<div className="mb-4 p-4 bg-zinc-800 border border-zinc-600 rounded-lg">
<p className="text-sm text-zinc-400 mb-2">
Save your secret key you'll need it to log in:
</p>
<div className="flex items-center gap-2">
<code className="flex-1 text-emerald-400 font-mono text-sm break-all select-all">
{secretKeyHex}
</code>
<button
type="button"
onClick={handleCopyKey}
className="px-3 py-1 rounded bg-zinc-700 text-zinc-300 text-sm hover:bg-zinc-600 transition-colors flex-shrink-0"
>
{secretKeyCopied ? ' Copied' : 'Copy'}
</button>
</div>
</div>
<p className="text-sm text-zinc-500 text-center mb-4">
Tap keys to create your nKode pattern, then submit.
</p>
</div>
{error && (
<div className="w-full max-w-[600px] px-4 mb-4">
<div className="bg-red-900/50 border border-red-700 rounded-lg p-3 text-red-300 text-sm">
{error}
</div>
</div>
)}
<SignupKeypad
svgs={svgs}
attrsPerKey={ATTRS_PER_KEY}
numbOfKeys={NUM_KEYS}
onComplete={handleSetNKode}
loading={loading}
/>
</div>
)
}
/** Convert an icon object to an SVG/HTML string. */
function iconToSvg(icon: NKodeIcon): string {
if (!icon) return '<svg></svg>'
if (icon.file_type === 'svg') {
return icon.img_data
}
const mime = icon.file_type === 'png' ? 'image/png'
: icon.file_type === 'webp' ? 'image/webp'
: 'image/jpeg'
return `<img src="data:${mime};base64,${icon.img_data}" alt="${icon.file_name}" />`
}
/**
* Signup keypad — reuses the same keypad UI but allows selecting
* icons to define the nKode pattern.
*/
import Keypad from '../components/Keypad'
interface SignupKeypadProps {
svgs: string[]
attrsPerKey: number
numbOfKeys: number
onComplete: (selection: number[]) => void
loading: boolean
}
function SignupKeypad({ svgs, attrsPerKey, numbOfKeys, onComplete, loading }: SignupKeypadProps) {
return (
<Keypad
svgs={svgs}
attrsPerKey={attrsPerKey}
numbOfKeys={numbOfKeys}
onComplete={onComplete}
loading={loading}
/>
)
}

View File

@@ -1,277 +0,0 @@
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>
);
}

187
src/pages/SusiPage.tsx Normal file
View File

@@ -0,0 +1,187 @@
/**
* SUSI (Sign Up / Sign In) page.
* Login tab: email → OPAQUE key login → navigate to keypad for code login.
* Signup tab: email → OPAQUE key register → key login → navigate to keypad for code registration.
*/
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import * as api from '../services/api'
export default function SusiPage() {
const [tab, setTab] = useState<'login' | 'signup'>('login')
return (
<div className="flex flex-col items-center">
{/* Logo area */}
<div className="text-center mb-8">
<div className="w-24 h-24 rounded-2xl bg-emerald-600 flex items-center justify-center font-bold text-3xl mx-auto mb-4">
nK
</div>
<h1 className="text-3xl font-semibold">nKode</h1>
<p className="text-zinc-400 mt-1">Passwordless Authentication</p>
</div>
{/* Tab bar */}
<div className="flex border-b border-zinc-700 mb-6 w-full max-w-md">
<button
className={`flex-1 py-3 text-center font-medium transition-colors ${
tab === 'login'
? 'text-emerald-400 border-b-2 border-emerald-400'
: 'text-zinc-400 hover:text-zinc-200'
}`}
onClick={() => setTab('login')}
>
Login
</button>
<button
className={`flex-1 py-3 text-center font-medium transition-colors ${
tab === 'signup'
? 'text-emerald-400 border-b-2 border-emerald-400'
: 'text-zinc-400 hover:text-zinc-200'
}`}
onClick={() => setTab('signup')}
>
Sign Up
</button>
</div>
{/* Tab content */}
{tab === 'login' ? <LoginTab /> : <SignupTab />}
</div>
)
}
/** Login tab — email + secret key → OPAQUE key login → code login keypad */
function LoginTab() {
const [email, setEmail] = useState('')
const [secretKeyHex, setSecretKeyHex] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const navigate = useNavigate()
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
const trimmedEmail = email.trim()
const trimmedKey = secretKeyHex.trim()
if (!trimmedEmail) { setError('Enter an email'); return }
if (!trimmedKey || trimmedKey.length !== 32) {
setError('Enter your 32-character secret key (hex)')
return
}
setLoading(true)
setError(null)
try {
// Step 1: OPAQUE key login
const session = await api.loginKey(trimmedEmail, trimmedKey)
// Step 2: Prepare code login (fetch login data + reconstruct keypad)
const codeLoginData = await api.prepareCodeLogin(session.userId, trimmedKey)
// Navigate to keypad page for code login
navigate('/login-keypad', {
state: {
email: trimmedEmail,
secretKeyHex: trimmedKey,
userId: session.userId,
codeLoginData,
},
})
} catch (err) {
setError(err instanceof Error ? err.message : 'Login failed')
} finally {
setLoading(false)
}
}
return (
<form onSubmit={handleSubmit} className="w-full max-w-md flex flex-col gap-5">
<input
type="email"
placeholder="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full px-4 py-3 rounded-lg bg-zinc-800 border border-zinc-700 text-white placeholder-zinc-500 focus:outline-none focus:border-emerald-500"
/>
<input
type="password"
placeholder="secret key (32 hex chars)"
value={secretKeyHex}
onChange={(e) => setSecretKeyHex(e.target.value)}
className="w-full px-4 py-3 rounded-lg bg-zinc-800 border border-zinc-700 text-white placeholder-zinc-500 focus:outline-none focus:border-emerald-500 font-mono"
/>
{error && <p className="text-red-400 text-sm">{error}</p>}
<button
type="submit"
disabled={loading}
className="w-full py-3 rounded-lg bg-emerald-600 hover:bg-emerald-500 disabled:opacity-50 font-medium transition-colors"
>
{loading ? 'Authenticating…' : 'Login'}
</button>
</form>
)
}
/** Sign Up tab — email → generate key → OPAQUE key register + key login → code registration keypad */
function SignupTab() {
const [email, setEmail] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const navigate = useNavigate()
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
const trimmedEmail = email.trim()
if (!trimmedEmail) { setError('Enter an email'); return }
setLoading(true)
setError(null)
try {
// Step 1: Generate secret key
const secretKeyHex = api.generateSecretKey()
// Step 2: OPAQUE key registration
await api.registerKey(trimmedEmail, secretKeyHex)
// Step 3: OPAQUE key login (needed for authenticated endpoints)
const session = await api.loginKey(trimmedEmail, secretKeyHex)
// Step 4: Prepare code registration (fetch icons)
const iconsData = await api.prepareCodeRegistration()
// Navigate to keypad page for code registration
navigate('/signup-keypad', {
state: {
email: trimmedEmail,
secretKeyHex,
userId: session.userId,
icons: iconsData.icons,
},
})
} catch (err) {
setError(err instanceof Error ? err.message : 'Registration failed')
} finally {
setLoading(false)
}
}
return (
<form onSubmit={handleSubmit} className="w-full max-w-md flex flex-col gap-5">
<input
type="email"
placeholder="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full px-4 py-3 rounded-lg bg-zinc-800 border border-zinc-700 text-white placeholder-zinc-500 focus:outline-none focus:border-emerald-500"
/>
{error && <p className="text-red-400 text-sm">{error}</p>}
<button
type="submit"
disabled={loading}
className="w-full py-3 rounded-lg bg-emerald-600 hover:bg-emerald-500 disabled:opacity-50 font-medium transition-colors"
>
{loading ? 'Setting up…' : 'Sign Up'}
</button>
</form>
)
}

112
src/services/api.ts Normal file
View File

@@ -0,0 +1,112 @@
/**
* nKode API service using WASM client SDK for OPAQUE crypto.
* All OPAQUE operations are performed client-side via WebAssembly.
*/
import { NKodeClient } from 'nkode-client-wasm'
import type { NKodeSession, IconsResponse, CodeLoginData } from '../types'
const API_BASE = import.meta.env.VITE_API_URL || ''
let client: NKodeClient | null = null
/** Initialize WASM module and create client. Must be called once before use. */
export async function initClient(): Promise<NKodeClient> {
// With vite-plugin-wasm + top-level-await, WASM is auto-initialized on import.
if (!client) {
client = new NKodeClient(API_BASE)
}
return client
}
/** Get or create a fresh client (call after initClient). */
export function getClient(): NKodeClient {
if (!client) throw new Error('Call initClient() first')
return client
}
/** Reset the client (e.g. on logout). */
export function resetClient(): void {
if (client) {
client.clearSession()
client.free()
}
client = null
}
/** Generate a new random secret key (hex string). */
export function generateSecretKey(): string {
return NKodeClient.generateSecretKey()
}
// ── Registration Flow ──
/** Register key-based auth via OPAQUE. */
export async function registerKey(email: string, secretKeyHex: string): Promise<void> {
const c = getClient()
await c.registerKey(email, secretKeyHex)
}
/** Login key-based auth via OPAQUE. Returns session info. */
export async function loginKey(email: string, secretKeyHex: string): Promise<NKodeSession> {
const c = getClient()
return await c.loginKey(email, secretKeyHex) as NKodeSession
}
/** Register code-based auth via OPAQUE. */
export async function registerCode(email: string, passcodeBytes: Uint8Array): Promise<void> {
const c = getClient()
await c.registerCode(email, passcodeBytes)
}
/** Login code-based auth via OPAQUE. Returns session info. */
export async function loginCode(email: string, passcodeBytes: Uint8Array): Promise<NKodeSession> {
const c = getClient()
return await c.loginCode(email, passcodeBytes) as NKodeSession
}
// ── Icon & Login Data Flows ──
/** Prepare icons for code registration (after key login). */
export async function prepareCodeRegistration(): Promise<IconsResponse> {
const c = getClient()
return await c.prepareCodeRegistration() as IconsResponse
}
/** Complete code registration with selected icon indices. */
export async function completeCodeRegistrationWithEmail(
email: string,
selectedIndices: number[],
): Promise<void> {
const c = getClient()
await c.completeCodeRegistrationWithEmail(email, new Uint32Array(selectedIndices))
}
/** Prepare code login: fetch login data, reconstruct keypad. */
export async function prepareCodeLogin(
userId: string,
secretKeyHex: string,
): Promise<CodeLoginData> {
const c = getClient()
return await c.prepareCodeLogin(userId, secretKeyHex) as CodeLoginData
}
/** Decipher key selections into passcode bytes for code login. */
export function decipherSelection(
secretKeyHex: string,
loginDataJson: string,
keySelections: number[],
): Uint8Array {
const c = getClient()
return c.decipherSelection(secretKeyHex, loginDataJson, new Uint32Array(keySelections))
}
/** Get user ID from current session. */
export function getUserId(): string | undefined {
const c = getClient()
return c.getUserId()
}
/** Check if client has an active session. */
export function hasSession(): boolean {
return client?.hasSession() ?? false
}

23
src/services/auth.ts Normal file
View File

@@ -0,0 +1,23 @@
/**
* Auth state management — persists email + secretKey to localStorage.
* Uses OPAQUE-based auth via WASM client (no tokens stored).
*/
import type { AuthState } from '../types'
const STORAGE_KEY = 'nkode_auth'
export function loadAuth(): AuthState {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (raw) return JSON.parse(raw) as AuthState
} catch { /* ignore corrupt data */ }
return { email: null, secretKeyHex: null, userId: null }
}
export function saveAuth(state: AuthState): void {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state))
}
export function clearAuth(): void {
localStorage.removeItem(STORAGE_KEY)
}

36
src/types/index.ts Normal file
View File

@@ -0,0 +1,36 @@
/** Auth state stored in localStorage */
export interface AuthState {
email: string | null
secretKeyHex: string | null
userId: string | null
}
/** Icon from the WASM client */
export interface NKodeIcon {
file_name: string
file_type: string
img_data: string
}
/** Response from prepareCodeRegistration */
export interface IconsResponse {
icons: NKodeIcon[]
}
/** Response from prepareCodeLogin */
export interface CodeLoginData {
keypadIndices: number[]
propertiesPerKey: number
numberOfKeys: number
mask: number[]
icons: NKodeIcon[]
loginDataJson: string
}
/** Session returned from loginKey / loginCode */
export interface NKodeSession {
sessionId: string
userId: string
createdAt: string
expiresAt: string
}

View File

@@ -1,19 +0,0 @@
/**
* 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>;
}

9
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_BASE_URL: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}

26
src/wasm.d.ts vendored Normal file
View File

@@ -0,0 +1,26 @@
declare module 'nkode-client-wasm' {
export class NKodeClient {
constructor(base_url: string)
static generateSecretKey(): string
registerKey(email: string, secret_key_hex: string): Promise<void>
loginKey(email: string, secret_key_hex: string): Promise<unknown>
registerCode(email: string, passcode_bytes: Uint8Array): Promise<void>
loginCode(email: string, passcode_bytes: Uint8Array): Promise<unknown>
hasSession(): boolean
getUserId(): string | undefined
clearSession(): void
free(): void
getNewIcons(count: number): Promise<unknown>
setIcons(icons_json: string): Promise<void>
getLoginData(user_id: string): Promise<unknown>
postLoginData(login_data_json: string): Promise<void>
updateLoginData(login_data_json: string): Promise<void>
prepareCodeRegistration(): Promise<unknown>
completeCodeRegistration(selected_indices: Uint32Array): Promise<void>
completeCodeRegistrationWithEmail(email: string, selected_indices: Uint32Array): Promise<void>
prepareCodeLogin(user_id: string, secret_key_hex: string): Promise<unknown>
decipherSelection(secret_key_hex: string, login_data_json: string, key_selections: Uint32Array): Uint8Array
}
export function init(): void
}