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:
68
src/App.tsx
68
src/App.tsx
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
223
src/__tests__/AuthFlow.test.tsx
Normal file
223
src/__tests__/AuthFlow.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
194
src/__tests__/Keypad.test.tsx
Normal file
194
src/__tests__/Keypad.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
103
src/__tests__/SusiPage.test.tsx
Normal file
103
src/__tests__/SusiPage.test.tsx
Normal 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
1
src/__tests__/setup.ts
Normal file
@@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom'
|
||||
@@ -1,153 +1,301 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
/**
|
||||
* SVG Icon Keypad — matches Flutter keypad.dart + keypad_interface.dart.
|
||||
* SVGs render as-is (no color application per DoDNKode branch).
|
||||
*/
|
||||
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||
|
||||
interface KeypadProps {
|
||||
/** Number of digits expected */
|
||||
length: number;
|
||||
/** Called when all digits entered */
|
||||
onComplete: (digits: number[]) => void;
|
||||
/** Optional: called on each digit press */
|
||||
onDigit?: (digit: number, current: number[]) => void;
|
||||
/** Show the entered digits (vs dots) */
|
||||
showDigits?: boolean;
|
||||
/** Label text above the indicator */
|
||||
label?: string;
|
||||
/** Disable input */
|
||||
disabled?: boolean;
|
||||
/** Error message to show */
|
||||
error?: string;
|
||||
/** Reset trigger — increment to clear */
|
||||
resetKey?: number;
|
||||
svgs: string[]
|
||||
attrsPerKey: number
|
||||
numbOfKeys: number
|
||||
onComplete: (selection: number[]) => void
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
const KEYS = [
|
||||
[1, 2, 3],
|
||||
[4, 5, 6],
|
||||
[7, 8, 9],
|
||||
[null, 0, 'del'],
|
||||
] as const;
|
||||
function keyAspectRatio(attrsPerKey: number): number {
|
||||
if (attrsPerKey <= 3) return 21 / 7
|
||||
if (attrsPerKey <= 6) return 21 / 15
|
||||
if (attrsPerKey <= 9) return 1
|
||||
if (attrsPerKey <= 12) return 21 / 27.5
|
||||
if (attrsPerKey <= 15) return 21 / 34
|
||||
return 21 / 40.5
|
||||
}
|
||||
|
||||
export function Keypad({
|
||||
length,
|
||||
export default function Keypad({
|
||||
svgs,
|
||||
attrsPerKey,
|
||||
numbOfKeys,
|
||||
onComplete,
|
||||
onDigit,
|
||||
showDigits = false,
|
||||
label,
|
||||
disabled = false,
|
||||
error,
|
||||
resetKey = 0,
|
||||
loading = false,
|
||||
}: KeypadProps) {
|
||||
const [digits, setDigits] = useState<number[]>([]);
|
||||
const [selection, setSelection] = useState<number[]>([])
|
||||
const [pressedKey, setPressedKey] = useState<number | null>(null)
|
||||
const [columns, setColumns] = useState(2)
|
||||
const pressTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
// Reset when resetKey changes
|
||||
// Reset selection when svgs change
|
||||
const svgsRef = useRef(svgs)
|
||||
useEffect(() => {
|
||||
setDigits([]);
|
||||
}, [resetKey]);
|
||||
if (svgsRef.current !== svgs) {
|
||||
svgsRef.current = svgs
|
||||
setSelection([])
|
||||
}
|
||||
}, [svgs])
|
||||
|
||||
const handlePress = useCallback(
|
||||
(key: number | 'del') => {
|
||||
if (disabled) return;
|
||||
|
||||
if (key === 'del') {
|
||||
setDigits((d) => d.slice(0, -1));
|
||||
return;
|
||||
}
|
||||
|
||||
setDigits((prev) => {
|
||||
if (prev.length >= length) return prev;
|
||||
const next = [...prev, key];
|
||||
onDigit?.(key, next);
|
||||
if (next.length === length) {
|
||||
// Slight delay so the UI shows the last dot
|
||||
setTimeout(() => onComplete(next), 150);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[length, onComplete, onDigit, disabled]
|
||||
);
|
||||
|
||||
// Keyboard support
|
||||
// Responsive: 2 cols portrait, 3 cols landscape
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (disabled) return;
|
||||
const n = parseInt(e.key);
|
||||
if (!isNaN(n) && n >= 0 && n <= 9) {
|
||||
handlePress(n);
|
||||
} else if (e.key === 'Backspace') {
|
||||
handlePress('del');
|
||||
const update = () => setColumns(window.innerWidth > window.innerHeight ? 3 : 2)
|
||||
update()
|
||||
window.addEventListener('resize', update)
|
||||
return () => window.removeEventListener('resize', update)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (pressTimerRef.current) clearTimeout(pressTimerRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handlePress = useCallback((keyIndex: number) => {
|
||||
setSelection(prev => [...prev, keyIndex])
|
||||
setPressedKey(keyIndex)
|
||||
if (pressTimerRef.current) clearTimeout(pressTimerRef.current)
|
||||
pressTimerRef.current = setTimeout(() => setPressedKey(null), 200)
|
||||
}, [])
|
||||
|
||||
const handleBackspace = useCallback(() => {
|
||||
setSelection(prev => prev.slice(0, -1))
|
||||
}, [])
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (selection.length > 0) {
|
||||
onComplete(selection)
|
||||
}
|
||||
}, [selection, onComplete])
|
||||
|
||||
// Keyboard: Backspace to delete, Enter to submit
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Backspace') {
|
||||
e.preventDefault()
|
||||
handleBackspace()
|
||||
} else if (e.key === 'Enter' && selection.length > 0 && !loading) {
|
||||
e.preventDefault()
|
||||
handleSubmit()
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handler);
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
}, [handlePress, disabled]);
|
||||
}
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [handleBackspace, handleSubmit, selection.length, loading])
|
||||
|
||||
const aspect = keyAspectRatio(attrsPerKey)
|
||||
const iconRows = Math.ceil(attrsPerKey / 3)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-6">
|
||||
{/* Label */}
|
||||
{label && (
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400 font-medium">
|
||||
{label}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Dot indicators */}
|
||||
<div className="flex gap-3">
|
||||
{Array.from({ length }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`w-3.5 h-3.5 rounded-full transition-all duration-200 ${
|
||||
i < digits.length
|
||||
? 'bg-indigo-500 scale-110'
|
||||
: 'bg-slate-200 dark:bg-slate-700'
|
||||
}`}
|
||||
>
|
||||
{showDigits && i < digits.length && (
|
||||
<span className="flex items-center justify-center w-full h-full text-[10px] text-white font-bold">
|
||||
{digits[i]}
|
||||
<div className="keypad-root">
|
||||
{/* Input display: [• dots + ⌫] [Submit] */}
|
||||
<div className="keypad-input-row">
|
||||
<div className="keypad-input-box">
|
||||
<div className="keypad-dots-area">
|
||||
{selection.length === 0 ? (
|
||||
<span className="keypad-placeholder">Tap icons to enter your nKode</span>
|
||||
) : (
|
||||
<span className="keypad-dot-text">
|
||||
{'•'.repeat(selection.length)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="keypad-backspace-btn"
|
||||
onClick={handleBackspace}
|
||||
disabled={selection.length === 0 || loading}
|
||||
aria-label="Delete last selection"
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 4H8l-7 8 7 8h13a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2z" />
|
||||
<line x1="18" y1="9" x2="12" y2="15" />
|
||||
<line x1="12" y1="9" x2="18" y2="15" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="keypad-submit-btn"
|
||||
onClick={handleSubmit}
|
||||
disabled={selection.length === 0 || loading}
|
||||
>
|
||||
{loading ? '…' : 'Submit'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<p className="text-sm text-red-500 font-medium animate-pulse">{error}</p>
|
||||
)}
|
||||
{/* Key tile grid */}
|
||||
<div
|
||||
className="keypad-grid"
|
||||
style={{ gridTemplateColumns: `repeat(${columns}, 1fr)` }}
|
||||
role="group"
|
||||
aria-label="nKode keypad"
|
||||
>
|
||||
{Array.from({ length: numbOfKeys }).map((_, keyIndex) => {
|
||||
const isActive = pressedKey === keyIndex
|
||||
const startIdx = keyIndex * attrsPerKey
|
||||
const keyIcons = svgs.slice(startIdx, startIdx + attrsPerKey)
|
||||
|
||||
{/* Keypad grid */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{KEYS.flat().map((key, i) => {
|
||||
if (key === null) {
|
||||
return <div key={i} />;
|
||||
}
|
||||
if (key === 'del') {
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => handlePress('del')}
|
||||
disabled={disabled || digits.length === 0}
|
||||
className="keypad-btn text-lg !bg-transparent !border-transparent !shadow-none
|
||||
text-slate-400 dark:text-slate-500 hover:text-slate-600 dark:hover:text-slate-300
|
||||
disabled:opacity-30"
|
||||
aria-label="Delete"
|
||||
>
|
||||
⌫
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => handlePress(key)}
|
||||
disabled={disabled || digits.length >= length}
|
||||
className="keypad-btn disabled:opacity-30"
|
||||
key={keyIndex}
|
||||
type="button"
|
||||
disabled={loading}
|
||||
onClick={() => handlePress(keyIndex)}
|
||||
onTouchStart={() => setPressedKey(keyIndex)}
|
||||
onTouchEnd={() => {
|
||||
if (pressTimerRef.current) clearTimeout(pressTimerRef.current)
|
||||
pressTimerRef.current = setTimeout(() => setPressedKey(null), 150)
|
||||
}}
|
||||
className={`keypad-key ${isActive ? 'keypad-key--active' : ''}`}
|
||||
style={{ aspectRatio: `${aspect}` }}
|
||||
aria-label={`Key ${keyIndex + 1}`}
|
||||
>
|
||||
{key}
|
||||
<div
|
||||
className="keypad-key-icons"
|
||||
style={{ gridTemplateRows: `repeat(${iconRows}, 1fr)` }}
|
||||
>
|
||||
{keyIcons.map((svg, iconIdx) => (
|
||||
<div
|
||||
key={iconIdx}
|
||||
className="keypad-icon-cell"
|
||||
dangerouslySetInnerHTML={{ __html: svg }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
.keypad-root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding: 10px;
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.keypad-root { max-width: 95vw; }
|
||||
}
|
||||
@media (orientation: landscape) and (min-width: 601px) {
|
||||
.keypad-root { max-width: 60vw; }
|
||||
}
|
||||
.keypad-input-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
.keypad-input-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
min-height: 48px;
|
||||
background: white;
|
||||
border: 4px solid black;
|
||||
border-radius: 12px;
|
||||
padding: 0 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.keypad-dots-area {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 4px 0;
|
||||
}
|
||||
.keypad-placeholder {
|
||||
color: #a1a1aa;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.keypad-dot-text {
|
||||
font-size: 36px;
|
||||
line-height: 1;
|
||||
color: black;
|
||||
letter-spacing: 2px;
|
||||
word-break: break-all;
|
||||
}
|
||||
.keypad-backspace-btn {
|
||||
flex-shrink: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: #333;
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.keypad-backspace-btn:disabled { opacity: 0.3; cursor: not-allowed; }
|
||||
.keypad-submit-btn {
|
||||
flex-shrink: 0;
|
||||
padding: 12px 20px;
|
||||
border-radius: 12px;
|
||||
background: #6366f1;
|
||||
color: white;
|
||||
border: none;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.keypad-submit-btn:hover:not(:disabled) { background: #4f46e5; }
|
||||
.keypad-submit-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.keypad-grid {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
.keypad-key {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 8px;
|
||||
background: transparent;
|
||||
border: 4px solid rgba(0,0,0,0.12);
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: border-width 0.05s, border-color 0.05s;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
touch-action: manipulation;
|
||||
outline: none;
|
||||
}
|
||||
.keypad-key:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.keypad-key:active,
|
||||
.keypad-key--active {
|
||||
border-width: 20px;
|
||||
border-color: #000080;
|
||||
}
|
||||
.keypad-key-icons {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.keypad-icon-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: white;
|
||||
overflow: hidden;
|
||||
}
|
||||
.keypad-icon-cell svg,
|
||||
.keypad-icon-cell img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
object-fit: contain;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,70 +1,53 @@
|
||||
import { Link, Outlet, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { ROUTES } from '@/lib/types';
|
||||
/**
|
||||
* App shell layout.
|
||||
*/
|
||||
import { Outlet, Link } from 'react-router-dom'
|
||||
import { useAuth } from '../hooks/useAuth'
|
||||
|
||||
export function Layout() {
|
||||
const { isAuthenticated, email, logout } = useAuth();
|
||||
const { resolved, setTheme, theme } = useTheme();
|
||||
const location = useLocation();
|
||||
|
||||
const isAuthPage =
|
||||
location.pathname === ROUTES.LOGIN || location.pathname === ROUTES.SIGNUP;
|
||||
export default function Layout() {
|
||||
const { isAuthenticated, email, logout } = useAuth()
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 dark:bg-slate-950 transition-colors">
|
||||
<div className="min-h-screen bg-zinc-950 text-white flex flex-col">
|
||||
{/* Header */}
|
||||
<header className="sticky top-0 z-50 border-b border-slate-200 dark:border-slate-800 bg-white/80 dark:bg-slate-900/80 backdrop-blur-sm">
|
||||
<header className="border-b border-zinc-800 bg-zinc-900/80 backdrop-blur-sm sticky top-0 z-50">
|
||||
<div className="max-w-5xl mx-auto px-4 h-14 flex items-center justify-between">
|
||||
<Link to="/" className="flex items-center gap-2 font-bold text-lg">
|
||||
<span className="text-indigo-500">n</span>
|
||||
<span className="text-slate-900 dark:text-white">Kode</span>
|
||||
<Link to={isAuthenticated ? '/home' : '/'} className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 rounded-lg bg-emerald-600 flex items-center justify-center font-bold text-sm">
|
||||
nK
|
||||
</div>
|
||||
<span className="font-semibold text-lg tracking-tight">nKode</span>
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Theme toggle */}
|
||||
<button
|
||||
onClick={() =>
|
||||
setTheme(
|
||||
theme === 'light' ? 'dark' : theme === 'dark' ? 'system' : 'light'
|
||||
)
|
||||
}
|
||||
className="p-2 rounded-lg text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
|
||||
title={`Theme: ${theme}`}
|
||||
>
|
||||
{resolved === 'dark' ? '🌙' : '☀️'}
|
||||
</button>
|
||||
|
||||
<nav className="flex items-center gap-4 text-sm font-medium">
|
||||
{isAuthenticated ? (
|
||||
<>
|
||||
<span className="text-sm text-slate-500 dark:text-slate-400 hidden sm:inline">
|
||||
{email}
|
||||
</span>
|
||||
<span className="text-zinc-500 text-xs">{email}</span>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="text-sm px-3 py-1.5 rounded-lg text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
|
||||
className="text-zinc-400 hover:text-red-400 transition-colors"
|
||||
>
|
||||
Sign out
|
||||
Logout
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
!isAuthPage && (
|
||||
<Link
|
||||
to={ROUTES.LOGIN}
|
||||
className="text-sm px-4 py-1.5 rounded-lg bg-indigo-500 text-white hover:bg-indigo-600 transition-colors"
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
)
|
||||
<Link to="/" className="text-zinc-400 hover:text-zinc-200">Login / Sign Up</Link>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Content */}
|
||||
<main className="max-w-5xl mx-auto px-4 py-8">
|
||||
<Outlet />
|
||||
{/* Main content */}
|
||||
<main className="flex-1 flex flex-col">
|
||||
<div className="max-w-5xl mx-auto w-full px-4 py-8 flex-1">
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="border-t border-zinc-800 py-4 text-center text-xs text-zinc-600">
|
||||
© {new Date().getFullYear()} nKode — Passwordless Authentication
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
32
src/main.tsx
32
src/main.tsx
@@ -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.'
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
114
src/pages/LoginKeypadPage.tsx
Normal file
114
src/pages/LoginKeypadPage.tsx
Normal 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}" />`
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
13
src/pages/NotFoundPage.tsx
Normal file
13
src/pages/NotFoundPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
165
src/pages/SignupKeypadPage.tsx
Normal file
165
src/pages/SignupKeypadPage.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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
187
src/pages/SusiPage.tsx
Normal 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
112
src/services/api.ts
Normal 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
23
src/services/auth.ts
Normal 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
36
src/types/index.ts
Normal 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
|
||||
}
|
||||
19
src/types/nkode-client-wasm.d.ts
vendored
19
src/types/nkode-client-wasm.d.ts
vendored
@@ -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
9
src/vite-env.d.ts
vendored
Normal 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
26
src/wasm.d.ts
vendored
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user