feat: nKode React + TypeScript SPA scaffold
- React + Vite + Tailwind CSS v4 + React Router - Keypad component: 10-digit pad with dot indicators, keyboard support, haptic feel - Auth pages: Login (email → keypad), Signup (email → method → keypad/key) - Home page: session info, practice keypad - Admin dashboard: placeholder stats + user management - Developer dashboard: OIDC client registration UI placeholder - WASM client wrapper: lazy loads from /wasm/, falls back to mock client - TypeScript type declarations for nkode-client-wasm package - Dark mode with system preference detection - Auth state management with localStorage persistence + auto-expiry - Code splitting: lazy-loaded route pages (~236KB main bundle) - Inter font, clean indigo/slate design system
This commit is contained in:
41
src/App.tsx
Normal file
41
src/App.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { lazy, Suspense } from 'react';
|
||||
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
||||
import { Layout } from '@/components/Layout';
|
||||
import { AuthContext, useAuthState } from '@/hooks/useAuth';
|
||||
import { ROUTES } from '@/lib/types';
|
||||
|
||||
const HomePage = lazy(() => import('@/pages/HomePage').then((m) => ({ default: m.HomePage })));
|
||||
const LoginPage = lazy(() => import('@/pages/LoginPage').then((m) => ({ default: m.LoginPage })));
|
||||
const SignupPage = lazy(() => import('@/pages/SignupPage').then((m) => ({ default: m.SignupPage })));
|
||||
const AdminPage = lazy(() => import('@/pages/AdminPage').then((m) => ({ default: m.AdminPage })));
|
||||
const DeveloperPage = lazy(() => import('@/pages/DeveloperPage').then((m) => ({ default: m.DeveloperPage })));
|
||||
|
||||
function Loading() {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[50vh]">
|
||||
<div className="w-6 h-6 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const auth = useAuthState();
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={auth}>
|
||||
<BrowserRouter>
|
||||
<Suspense fallback={<Loading />}>
|
||||
<Routes>
|
||||
<Route element={<Layout />}>
|
||||
<Route path={ROUTES.HOME} element={<HomePage />} />
|
||||
<Route path={ROUTES.LOGIN} element={<LoginPage />} />
|
||||
<Route path={ROUTES.SIGNUP} element={<SignupPage />} />
|
||||
<Route path={ROUTES.ADMIN} element={<AdminPage />} />
|
||||
<Route path={ROUTES.DEVELOPER} element={<DeveloperPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</BrowserRouter>
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
153
src/components/Keypad.tsx
Normal file
153
src/components/Keypad.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
|
||||
interface KeypadProps {
|
||||
/** Number of digits expected */
|
||||
length: number;
|
||||
/** Called when all digits entered */
|
||||
onComplete: (digits: number[]) => void;
|
||||
/** Optional: called on each digit press */
|
||||
onDigit?: (digit: number, current: number[]) => void;
|
||||
/** Show the entered digits (vs dots) */
|
||||
showDigits?: boolean;
|
||||
/** Label text above the indicator */
|
||||
label?: string;
|
||||
/** Disable input */
|
||||
disabled?: boolean;
|
||||
/** Error message to show */
|
||||
error?: string;
|
||||
/** Reset trigger — increment to clear */
|
||||
resetKey?: number;
|
||||
}
|
||||
|
||||
const KEYS = [
|
||||
[1, 2, 3],
|
||||
[4, 5, 6],
|
||||
[7, 8, 9],
|
||||
[null, 0, 'del'],
|
||||
] as const;
|
||||
|
||||
export function Keypad({
|
||||
length,
|
||||
onComplete,
|
||||
onDigit,
|
||||
showDigits = false,
|
||||
label,
|
||||
disabled = false,
|
||||
error,
|
||||
resetKey = 0,
|
||||
}: KeypadProps) {
|
||||
const [digits, setDigits] = useState<number[]>([]);
|
||||
|
||||
// Reset when resetKey changes
|
||||
useEffect(() => {
|
||||
setDigits([]);
|
||||
}, [resetKey]);
|
||||
|
||||
const handlePress = useCallback(
|
||||
(key: number | 'del') => {
|
||||
if (disabled) return;
|
||||
|
||||
if (key === 'del') {
|
||||
setDigits((d) => d.slice(0, -1));
|
||||
return;
|
||||
}
|
||||
|
||||
setDigits((prev) => {
|
||||
if (prev.length >= length) return prev;
|
||||
const next = [...prev, key];
|
||||
onDigit?.(key, next);
|
||||
if (next.length === length) {
|
||||
// Slight delay so the UI shows the last dot
|
||||
setTimeout(() => onComplete(next), 150);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[length, onComplete, onDigit, disabled]
|
||||
);
|
||||
|
||||
// Keyboard support
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (disabled) return;
|
||||
const n = parseInt(e.key);
|
||||
if (!isNaN(n) && n >= 0 && n <= 9) {
|
||||
handlePress(n);
|
||||
} else if (e.key === 'Backspace') {
|
||||
handlePress('del');
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handler);
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
}, [handlePress, disabled]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-6">
|
||||
{/* Label */}
|
||||
{label && (
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400 font-medium">
|
||||
{label}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Dot indicators */}
|
||||
<div className="flex gap-3">
|
||||
{Array.from({ length }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`w-3.5 h-3.5 rounded-full transition-all duration-200 ${
|
||||
i < digits.length
|
||||
? 'bg-indigo-500 scale-110'
|
||||
: 'bg-slate-200 dark:bg-slate-700'
|
||||
}`}
|
||||
>
|
||||
{showDigits && i < digits.length && (
|
||||
<span className="flex items-center justify-center w-full h-full text-[10px] text-white font-bold">
|
||||
{digits[i]}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<p className="text-sm text-red-500 font-medium animate-pulse">{error}</p>
|
||||
)}
|
||||
|
||||
{/* Keypad grid */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{KEYS.flat().map((key, i) => {
|
||||
if (key === null) {
|
||||
return <div key={i} />;
|
||||
}
|
||||
if (key === 'del') {
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => handlePress('del')}
|
||||
disabled={disabled || digits.length === 0}
|
||||
className="keypad-btn text-lg !bg-transparent !border-transparent !shadow-none
|
||||
text-slate-400 dark:text-slate-500 hover:text-slate-600 dark:hover:text-slate-300
|
||||
disabled:opacity-30"
|
||||
aria-label="Delete"
|
||||
>
|
||||
⌫
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => handlePress(key)}
|
||||
disabled={disabled || digits.length >= length}
|
||||
className="keypad-btn disabled:opacity-30"
|
||||
>
|
||||
{key}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
70
src/components/Layout.tsx
Normal file
70
src/components/Layout.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { Link, Outlet, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { ROUTES } from '@/lib/types';
|
||||
|
||||
export function Layout() {
|
||||
const { isAuthenticated, email, logout } = useAuth();
|
||||
const { resolved, setTheme, theme } = useTheme();
|
||||
const location = useLocation();
|
||||
|
||||
const isAuthPage =
|
||||
location.pathname === ROUTES.LOGIN || location.pathname === ROUTES.SIGNUP;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 dark:bg-slate-950 transition-colors">
|
||||
{/* Header */}
|
||||
<header className="sticky top-0 z-50 border-b border-slate-200 dark:border-slate-800 bg-white/80 dark:bg-slate-900/80 backdrop-blur-sm">
|
||||
<div className="max-w-5xl mx-auto px-4 h-14 flex items-center justify-between">
|
||||
<Link to="/" className="flex items-center gap-2 font-bold text-lg">
|
||||
<span className="text-indigo-500">n</span>
|
||||
<span className="text-slate-900 dark:text-white">Kode</span>
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Theme toggle */}
|
||||
<button
|
||||
onClick={() =>
|
||||
setTheme(
|
||||
theme === 'light' ? 'dark' : theme === 'dark' ? 'system' : 'light'
|
||||
)
|
||||
}
|
||||
className="p-2 rounded-lg text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
|
||||
title={`Theme: ${theme}`}
|
||||
>
|
||||
{resolved === 'dark' ? '🌙' : '☀️'}
|
||||
</button>
|
||||
|
||||
{isAuthenticated ? (
|
||||
<>
|
||||
<span className="text-sm text-slate-500 dark:text-slate-400 hidden sm:inline">
|
||||
{email}
|
||||
</span>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="text-sm px-3 py-1.5 rounded-lg text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
!isAuthPage && (
|
||||
<Link
|
||||
to={ROUTES.LOGIN}
|
||||
className="text-sm px-4 py-1.5 rounded-lg bg-indigo-500 text-white hover:bg-indigo-600 transition-colors"
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Content */}
|
||||
<main className="max-w-5xl mx-auto px-4 py-8">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
64
src/hooks/useAuth.ts
Normal file
64
src/hooks/useAuth.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { useState, useCallback, useEffect, createContext, useContext } from 'react';
|
||||
import type { AuthState, NKodeSession } from '@/lib/types';
|
||||
|
||||
const SESSION_KEY = 'nkode_session';
|
||||
const EMAIL_KEY = 'nkode_email';
|
||||
|
||||
function loadStoredSession(): AuthState {
|
||||
try {
|
||||
const raw = localStorage.getItem(SESSION_KEY);
|
||||
const email = localStorage.getItem(EMAIL_KEY);
|
||||
if (raw) {
|
||||
const session: NKodeSession = JSON.parse(raw);
|
||||
// Check expiry
|
||||
if (new Date(session.expiresAt) > new Date()) {
|
||||
return { isAuthenticated: true, session, email };
|
||||
}
|
||||
localStorage.removeItem(SESSION_KEY);
|
||||
}
|
||||
} catch {}
|
||||
return { isAuthenticated: false, session: null, email: null };
|
||||
}
|
||||
|
||||
export interface AuthContextType extends AuthState {
|
||||
login: (email: string, session: NKodeSession) => void;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
export const AuthContext = createContext<AuthContextType | null>(null);
|
||||
|
||||
export function useAuth(): AuthContextType {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) throw new Error('useAuth must be inside AuthProvider');
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function useAuthState() {
|
||||
const [state, setState] = useState<AuthState>(loadStoredSession);
|
||||
|
||||
const login = useCallback((email: string, session: NKodeSession) => {
|
||||
localStorage.setItem(SESSION_KEY, JSON.stringify(session));
|
||||
localStorage.setItem(EMAIL_KEY, email);
|
||||
setState({ isAuthenticated: true, session, email });
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
localStorage.removeItem(SESSION_KEY);
|
||||
localStorage.removeItem(EMAIL_KEY);
|
||||
setState({ isAuthenticated: false, session: null, email: null });
|
||||
}, []);
|
||||
|
||||
// Auto-logout on expiry
|
||||
useEffect(() => {
|
||||
if (!state.session) return;
|
||||
const ms = new Date(state.session.expiresAt).getTime() - Date.now();
|
||||
if (ms <= 0) {
|
||||
logout();
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(logout, ms);
|
||||
return () => clearTimeout(timer);
|
||||
}, [state.session, logout]);
|
||||
|
||||
return { ...state, login, logout };
|
||||
}
|
||||
34
src/hooks/useTheme.ts
Normal file
34
src/hooks/useTheme.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
type Theme = 'light' | 'dark' | 'system';
|
||||
|
||||
function getSystemTheme(): 'light' | 'dark' {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const [theme, setThemeState] = useState<Theme>(() => {
|
||||
return (localStorage.getItem('nkode_theme') as Theme) || 'system';
|
||||
});
|
||||
|
||||
const resolved = theme === 'system' ? getSystemTheme() : theme;
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.classList.toggle('dark', resolved === 'dark');
|
||||
}, [resolved]);
|
||||
|
||||
useEffect(() => {
|
||||
if (theme !== 'system') return;
|
||||
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const handler = () => setThemeState('system'); // re-trigger
|
||||
mq.addEventListener('change', handler);
|
||||
return () => mq.removeEventListener('change', handler);
|
||||
}, [theme]);
|
||||
|
||||
const setTheme = useCallback((t: Theme) => {
|
||||
localStorage.setItem('nkode_theme', t);
|
||||
setThemeState(t);
|
||||
}, []);
|
||||
|
||||
return { theme, resolved, setTheme };
|
||||
}
|
||||
47
src/index.css
Normal file
47
src/index.css
Normal file
@@ -0,0 +1,47 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
:root {
|
||||
--nkode-primary: #6366f1;
|
||||
--nkode-primary-hover: #4f46e5;
|
||||
--nkode-surface: #ffffff;
|
||||
--nkode-surface-alt: #f8fafc;
|
||||
--nkode-border: #e2e8f0;
|
||||
--nkode-text: #0f172a;
|
||||
--nkode-text-muted: #64748b;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--nkode-primary: #818cf8;
|
||||
--nkode-primary-hover: #6366f1;
|
||||
--nkode-surface: #0f172a;
|
||||
--nkode-surface-alt: #1e293b;
|
||||
--nkode-border: #334155;
|
||||
--nkode-text: #f1f5f9;
|
||||
--nkode-text-muted: #94a3b8;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
background: var(--nkode-surface);
|
||||
color: var(--nkode-text);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* Keypad button styles */
|
||||
.keypad-btn {
|
||||
@apply w-16 h-16 rounded-full text-2xl font-semibold
|
||||
transition-all duration-150 ease-out
|
||||
active:scale-95 select-none
|
||||
bg-white dark:bg-slate-800
|
||||
text-slate-900 dark:text-slate-100
|
||||
border border-slate-200 dark:border-slate-700
|
||||
hover:bg-slate-50 dark:hover:bg-slate-700
|
||||
shadow-sm;
|
||||
}
|
||||
|
||||
.keypad-btn:active {
|
||||
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
134
src/lib/nkode-client.ts
Normal file
134
src/lib/nkode-client.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* nKode WASM client wrapper.
|
||||
*
|
||||
* This module lazily loads the WASM package and provides a typed API.
|
||||
* In development, it falls back to a mock client until the WASM package is linked.
|
||||
*/
|
||||
|
||||
import type { NKodeSession } from './types';
|
||||
|
||||
// We'll import the WASM module dynamically
|
||||
let wasmModule: any = null;
|
||||
let wasmClient: any = null;
|
||||
|
||||
const API_BASE = import.meta.env.VITE_NKODE_API_URL || 'http://localhost:3000';
|
||||
|
||||
/**
|
||||
* Try to load the WASM module from /wasm/ directory.
|
||||
* Uses dynamic import with vite-ignore to avoid bundling.
|
||||
*/
|
||||
async function tryLoadWasm(): Promise<any> {
|
||||
try {
|
||||
// Check if WASM files are available
|
||||
const check = await fetch('/wasm/nkode_client_wasm_bg.wasm', { method: 'HEAD' });
|
||||
if (!check.ok) return null;
|
||||
|
||||
// Dynamic import — vite-ignore prevents bundler from resolving
|
||||
const wasmUrl = new URL('/wasm/nkode_client_wasm.js', window.location.origin).href;
|
||||
const module = await import(/* @vite-ignore */ wasmUrl);
|
||||
await module.default('/wasm/nkode_client_wasm_bg.wasm');
|
||||
module.init();
|
||||
return module;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the WASM module. Call once at app startup.
|
||||
*/
|
||||
export async function initNKode(): Promise<void> {
|
||||
try {
|
||||
const wasm = await tryLoadWasm();
|
||||
if (wasm) {
|
||||
wasmModule = wasm;
|
||||
wasmClient = new wasm.NKodeClient(API_BASE);
|
||||
console.log('[nKode] WASM client initialized');
|
||||
} else {
|
||||
console.warn('[nKode] WASM not available, using mock client');
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[nKode] WASM init error, using mock client:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if WASM client is loaded.
|
||||
*/
|
||||
export function isWasmReady(): boolean {
|
||||
return wasmClient !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a random 16-byte secret key (hex string).
|
||||
*/
|
||||
export function generateSecretKey(): string {
|
||||
if (wasmModule) {
|
||||
return wasmModule.NKodeClient.generateSecretKey();
|
||||
}
|
||||
// Fallback: browser crypto
|
||||
const bytes = new Uint8Array(16);
|
||||
crypto.getRandomValues(bytes);
|
||||
return Array.from(bytes)
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Register with key-based OPAQUE flow.
|
||||
*/
|
||||
export async function registerKey(email: string, secretKeyHex: string): Promise<void> {
|
||||
if (wasmClient) {
|
||||
await wasmClient.registerKey(email, secretKeyHex);
|
||||
return;
|
||||
}
|
||||
// Mock
|
||||
console.log('[nKode mock] registerKey', email);
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
|
||||
/**
|
||||
* Register with code-based OPAQUE flow.
|
||||
*/
|
||||
export async function registerCode(email: string, passcode: Uint8Array): Promise<void> {
|
||||
if (wasmClient) {
|
||||
await wasmClient.registerCode(email, passcode);
|
||||
return;
|
||||
}
|
||||
console.log('[nKode mock] registerCode', email);
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
|
||||
/**
|
||||
* Login with key-based OPAQUE flow.
|
||||
*/
|
||||
export async function loginKey(email: string, secretKeyHex: string): Promise<NKodeSession> {
|
||||
if (wasmClient) {
|
||||
return await wasmClient.loginKey(email, secretKeyHex);
|
||||
}
|
||||
console.log('[nKode mock] loginKey', email);
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
return {
|
||||
sessionId: 'mock-session',
|
||||
userId: 'mock-user',
|
||||
createdAt: new Date().toISOString(),
|
||||
expiresAt: new Date(Date.now() + 3600000).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Login with code-based OPAQUE flow.
|
||||
*/
|
||||
export async function loginCode(email: string, passcode: Uint8Array): Promise<NKodeSession> {
|
||||
if (wasmClient) {
|
||||
return await wasmClient.loginCode(email, passcode);
|
||||
}
|
||||
console.log('[nKode mock] loginCode', email);
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
return {
|
||||
sessionId: 'mock-session',
|
||||
userId: 'mock-user',
|
||||
createdAt: new Date().toISOString(),
|
||||
expiresAt: new Date(Date.now() + 3600000).toISOString(),
|
||||
};
|
||||
}
|
||||
26
src/lib/types.ts
Normal file
26
src/lib/types.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/** Authentication flow type */
|
||||
export type AuthFlow = 'key' | 'code';
|
||||
|
||||
/** User session from successful login */
|
||||
export interface NKodeSession {
|
||||
sessionId: string;
|
||||
userId: string;
|
||||
createdAt: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
/** App-level auth state */
|
||||
export interface AuthState {
|
||||
isAuthenticated: boolean;
|
||||
session: NKodeSession | null;
|
||||
email: string | null;
|
||||
}
|
||||
|
||||
/** Route paths */
|
||||
export const ROUTES = {
|
||||
LOGIN: '/login',
|
||||
SIGNUP: '/signup',
|
||||
HOME: '/',
|
||||
ADMIN: '/admin',
|
||||
DEVELOPER: '/developer',
|
||||
} as const;
|
||||
14
src/main.tsx
Normal file
14
src/main.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import './index.css';
|
||||
import App from './App';
|
||||
import { initNKode } from '@/lib/nkode-client';
|
||||
|
||||
// Initialize WASM client (non-blocking)
|
||||
initNKode();
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
);
|
||||
44
src/pages/AdminPage.tsx
Normal file
44
src/pages/AdminPage.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { ROUTES } from '@/lib/types';
|
||||
|
||||
export function AdminPage() {
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to={ROUTES.LOGIN} replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">Admin Dashboard</h1>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{[
|
||||
{ label: 'Total Users', value: '—', icon: '👤' },
|
||||
{ label: 'Active Sessions', value: '—', icon: '🔐' },
|
||||
{ label: 'Registered Clients', value: '—', icon: '📱' },
|
||||
].map((stat) => (
|
||||
<div
|
||||
key={stat.label}
|
||||
className="p-4 rounded-xl bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xl">{stat.icon}</span>
|
||||
<div>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">{stat.label}</p>
|
||||
<p className="text-xl font-bold text-slate-900 dark:text-white">{stat.value}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="p-6 rounded-xl bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700">
|
||||
<p className="text-slate-500 dark:text-slate-400 text-center">
|
||||
Admin features coming soon. This will display user management, session monitoring, and OIDC client configuration.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
57
src/pages/DeveloperPage.tsx
Normal file
57
src/pages/DeveloperPage.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { ROUTES } from '@/lib/types';
|
||||
|
||||
export function DeveloperPage() {
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to={ROUTES.LOGIN} replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">Developer Dashboard</h1>
|
||||
|
||||
<div className="p-6 rounded-xl bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 space-y-4">
|
||||
<h2 className="font-semibold text-slate-900 dark:text-white">OIDC Client Setup</h2>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||
Register your application to use nKode as an identity provider. Configure redirect URIs, scopes, and authentication flows.
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
|
||||
Application Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="My App"
|
||||
className="w-full px-4 py-2 rounded-lg border border-slate-200 dark:border-slate-700
|
||||
bg-white dark:bg-slate-900 text-slate-900 dark:text-white
|
||||
placeholder:text-slate-400 dark:placeholder:text-slate-500"
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
|
||||
Redirect URI
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="https://myapp.com/callback"
|
||||
className="w-full px-4 py-2 rounded-lg border border-slate-200 dark:border-slate-700
|
||||
bg-white dark:bg-slate-900 text-slate-900 dark:text-white
|
||||
placeholder:text-slate-400 dark:placeholder:text-slate-500"
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-slate-400 dark:text-slate-500 italic">
|
||||
Client registration coming soon. This will generate client_id and client_secret for OAuth2/OIDC integration.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
85
src/pages/HomePage.tsx
Normal file
85
src/pages/HomePage.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { Keypad } from '@/components/Keypad';
|
||||
import { useState } from 'react';
|
||||
|
||||
export function HomePage() {
|
||||
const { isAuthenticated, session, email } = useAuth();
|
||||
const [practiceResult, setPracticeResult] = useState<string | null>(null);
|
||||
const [resetKey, setResetKey] = useState(0);
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[60vh] text-center">
|
||||
<h1 className="text-4xl font-bold text-slate-900 dark:text-white mb-4">
|
||||
<span className="text-indigo-500">n</span>Kode
|
||||
</h1>
|
||||
<p className="text-lg text-slate-500 dark:text-slate-400 max-w-md mb-2">
|
||||
Passwordless authentication powered by OPAQUE.
|
||||
</p>
|
||||
<p className="text-sm text-slate-400 dark:text-slate-500 max-w-md">
|
||||
Replace passwords with a memorized numeric code or a cryptographic key.
|
||||
Zero-knowledge proof means the server never sees your secret.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handlePractice = (digits: number[]) => {
|
||||
setPracticeResult(`✅ You entered: ${digits.join('')}`);
|
||||
setTimeout(() => {
|
||||
setPracticeResult(null);
|
||||
setResetKey((k) => k + 1);
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Welcome */}
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">
|
||||
Welcome back
|
||||
</h1>
|
||||
<p className="text-slate-500 dark:text-slate-400 mt-1">{email}</p>
|
||||
</div>
|
||||
|
||||
{/* Session info */}
|
||||
<div className="max-w-md mx-auto p-4 rounded-xl bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700">
|
||||
<h2 className="font-semibold text-slate-900 dark:text-white mb-3">Session</h2>
|
||||
<dl className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-slate-500 dark:text-slate-400">Session ID</dt>
|
||||
<dd className="text-slate-900 dark:text-slate-100 font-mono text-xs truncate max-w-[200px]">
|
||||
{session?.sessionId}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-slate-500 dark:text-slate-400">Expires</dt>
|
||||
<dd className="text-slate-900 dark:text-slate-100">
|
||||
{session?.expiresAt
|
||||
? new Date(session.expiresAt).toLocaleString()
|
||||
: '—'}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{/* Practice keypad */}
|
||||
<div className="max-w-md mx-auto p-6 rounded-xl bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700">
|
||||
<h2 className="font-semibold text-slate-900 dark:text-white mb-4 text-center">
|
||||
Practice your nKode
|
||||
</h2>
|
||||
<Keypad
|
||||
length={6}
|
||||
onComplete={handlePractice}
|
||||
label="Enter any 6 digits"
|
||||
resetKey={resetKey}
|
||||
/>
|
||||
{practiceResult && (
|
||||
<p className="text-center mt-4 text-sm text-green-600 dark:text-green-400 font-medium">
|
||||
{practiceResult}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
124
src/pages/LoginPage.tsx
Normal file
124
src/pages/LoginPage.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { Keypad } from '@/components/Keypad';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { loginCode } from '@/lib/nkode-client';
|
||||
import { ROUTES } from '@/lib/types';
|
||||
|
||||
type Step = 'email' | 'keypad';
|
||||
|
||||
export function LoginPage() {
|
||||
const [step, setStep] = useState<Step>('email');
|
||||
const [email, setEmail] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [resetKey, setResetKey] = useState(0);
|
||||
const { login } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleEmailSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!email.trim()) return;
|
||||
setError('');
|
||||
setStep('keypad');
|
||||
};
|
||||
|
||||
const handleKeypadComplete = useCallback(
|
||||
async (digits: number[]) => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const passcode = new Uint8Array(digits);
|
||||
const session = await loginCode(email, passcode);
|
||||
login(email, session);
|
||||
navigate(ROUTES.HOME);
|
||||
} catch (err: any) {
|
||||
setError(err?.message || 'Authentication failed');
|
||||
setResetKey((k) => k + 1);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[email, login, navigate]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[70vh]">
|
||||
<div className="w-full max-w-sm">
|
||||
{/* Logo */}
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-3xl font-bold text-slate-900 dark:text-white">
|
||||
<span className="text-indigo-500">n</span>Kode
|
||||
</h1>
|
||||
<p className="text-slate-500 dark:text-slate-400 mt-2">
|
||||
{step === 'email' ? 'Sign in to your account' : 'Enter your nKode'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{step === 'email' ? (
|
||||
<form onSubmit={handleEmailSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5"
|
||||
>
|
||||
Email address
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@example.com"
|
||||
autoFocus
|
||||
required
|
||||
className="w-full px-4 py-2.5 rounded-xl border border-slate-200 dark:border-slate-700
|
||||
bg-white dark:bg-slate-800 text-slate-900 dark:text-white
|
||||
focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500
|
||||
placeholder:text-slate-400 dark:placeholder:text-slate-500"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full py-2.5 rounded-xl bg-indigo-500 text-white font-medium
|
||||
hover:bg-indigo-600 transition-colors focus:outline-none focus:ring-2 focus:ring-indigo-500/50"
|
||||
>
|
||||
Continue
|
||||
</button>
|
||||
<p className="text-center text-sm text-slate-500 dark:text-slate-400">
|
||||
Don't have an account?{' '}
|
||||
<Link to={ROUTES.SIGNUP} className="text-indigo-500 hover:text-indigo-600 font-medium">
|
||||
Sign up
|
||||
</Link>
|
||||
</p>
|
||||
</form>
|
||||
) : (
|
||||
<div className="flex flex-col items-center">
|
||||
<button
|
||||
onClick={() => {
|
||||
setStep('email');
|
||||
setError('');
|
||||
}}
|
||||
className="self-start mb-6 text-sm text-slate-500 hover:text-slate-700 dark:hover:text-slate-300"
|
||||
>
|
||||
← {email}
|
||||
</button>
|
||||
<Keypad
|
||||
length={6}
|
||||
onComplete={handleKeypadComplete}
|
||||
label="Enter your 6-digit nKode"
|
||||
disabled={loading}
|
||||
error={error}
|
||||
resetKey={resetKey}
|
||||
/>
|
||||
{loading && (
|
||||
<p className="mt-4 text-sm text-slate-500 animate-pulse">
|
||||
Authenticating…
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
277
src/pages/SignupPage.tsx
Normal file
277
src/pages/SignupPage.tsx
Normal file
@@ -0,0 +1,277 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { Keypad } from '@/components/Keypad';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { registerCode, loginCode, generateSecretKey, registerKey } from '@/lib/nkode-client';
|
||||
import { ROUTES } from '@/lib/types';
|
||||
|
||||
type Step = 'email' | 'method' | 'keypad' | 'confirm' | 'key-show' | 'done';
|
||||
|
||||
export function SignupPage() {
|
||||
const [step, setStep] = useState<Step>('email');
|
||||
const [email, setEmail] = useState('');
|
||||
const [_method, setMethod] = useState<'code' | 'key'>('code');
|
||||
const [firstCode, setFirstCode] = useState<number[]>([]);
|
||||
const [secretKey, setSecretKey] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [resetKey, setResetKey] = useState(0);
|
||||
const { login } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleEmailSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!email.trim()) return;
|
||||
setError('');
|
||||
setStep('method');
|
||||
};
|
||||
|
||||
const handleMethodSelect = (m: 'code' | 'key') => {
|
||||
setMethod(m);
|
||||
if (m === 'code') {
|
||||
setStep('keypad');
|
||||
} else {
|
||||
// Generate key immediately
|
||||
const key = generateSecretKey();
|
||||
setSecretKey(key);
|
||||
setStep('key-show');
|
||||
}
|
||||
};
|
||||
|
||||
const handleFirstCode = useCallback((digits: number[]) => {
|
||||
setFirstCode(digits);
|
||||
setResetKey((k) => k + 1);
|
||||
setStep('confirm');
|
||||
}, []);
|
||||
|
||||
const handleConfirmCode = useCallback(
|
||||
async (digits: number[]) => {
|
||||
// Check codes match
|
||||
if (digits.join('') !== firstCode.join('')) {
|
||||
setError("Codes don't match. Try again.");
|
||||
setResetKey((k) => k + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const passcode = new Uint8Array(digits);
|
||||
await registerCode(email, passcode);
|
||||
// Auto-login after registration
|
||||
const session = await loginCode(email, passcode);
|
||||
login(email, session);
|
||||
navigate(ROUTES.HOME);
|
||||
} catch (err: any) {
|
||||
setError(err?.message || 'Registration failed');
|
||||
setResetKey((k) => k + 1);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[email, firstCode, login, navigate]
|
||||
);
|
||||
|
||||
const handleKeyRegister = async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
await registerKey(email, secretKey);
|
||||
setStep('done');
|
||||
} catch (err: any) {
|
||||
setError(err?.message || 'Registration failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[70vh]">
|
||||
<div className="w-full max-w-sm">
|
||||
{/* Logo */}
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-3xl font-bold text-slate-900 dark:text-white">
|
||||
<span className="text-indigo-500">n</span>Kode
|
||||
</h1>
|
||||
<p className="text-slate-500 dark:text-slate-400 mt-2">
|
||||
{step === 'email' && 'Create your account'}
|
||||
{step === 'method' && 'Choose your authentication method'}
|
||||
{step === 'keypad' && 'Create your nKode'}
|
||||
{step === 'confirm' && 'Confirm your nKode'}
|
||||
{step === 'key-show' && 'Save your secret key'}
|
||||
{step === 'done' && 'You\'re all set!'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{step === 'email' && (
|
||||
<form onSubmit={handleEmailSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5"
|
||||
>
|
||||
Email address
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@example.com"
|
||||
autoFocus
|
||||
required
|
||||
className="w-full px-4 py-2.5 rounded-xl border border-slate-200 dark:border-slate-700
|
||||
bg-white dark:bg-slate-800 text-slate-900 dark:text-white
|
||||
focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500
|
||||
placeholder:text-slate-400 dark:placeholder:text-slate-500"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full py-2.5 rounded-xl bg-indigo-500 text-white font-medium
|
||||
hover:bg-indigo-600 transition-colors"
|
||||
>
|
||||
Continue
|
||||
</button>
|
||||
<p className="text-center text-sm text-slate-500 dark:text-slate-400">
|
||||
Already have an account?{' '}
|
||||
<Link to={ROUTES.LOGIN} className="text-indigo-500 hover:text-indigo-600 font-medium">
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{step === 'method' && (
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
onClick={() => setStep('email')}
|
||||
className="text-sm text-slate-500 hover:text-slate-700 dark:hover:text-slate-300 mb-2"
|
||||
>
|
||||
← {email}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleMethodSelect('code')}
|
||||
className="w-full p-4 rounded-xl border border-slate-200 dark:border-slate-700
|
||||
bg-white dark:bg-slate-800 text-left hover:border-indigo-300 dark:hover:border-indigo-600 transition-colors"
|
||||
>
|
||||
<div className="font-medium text-slate-900 dark:text-white">🔢 Code-based</div>
|
||||
<div className="text-sm text-slate-500 dark:text-slate-400 mt-1">
|
||||
Enter a 6-digit code on a keypad. Easy to remember, quick to use.
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleMethodSelect('key')}
|
||||
className="w-full p-4 rounded-xl border border-slate-200 dark:border-slate-700
|
||||
bg-white dark:bg-slate-800 text-left hover:border-indigo-300 dark:hover:border-indigo-600 transition-colors"
|
||||
>
|
||||
<div className="font-medium text-slate-900 dark:text-white">🔑 Key-based</div>
|
||||
<div className="text-sm text-slate-500 dark:text-slate-400 mt-1">
|
||||
Generate a cryptographic key. Maximum security, store it safely.
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'keypad' && (
|
||||
<div className="flex flex-col items-center">
|
||||
<button
|
||||
onClick={() => setStep('method')}
|
||||
className="self-start mb-6 text-sm text-slate-500 hover:text-slate-700 dark:hover:text-slate-300"
|
||||
>
|
||||
← Back
|
||||
</button>
|
||||
<Keypad
|
||||
length={6}
|
||||
onComplete={handleFirstCode}
|
||||
label="Choose your 6-digit nKode"
|
||||
disabled={loading}
|
||||
error={error}
|
||||
resetKey={resetKey}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'confirm' && (
|
||||
<div className="flex flex-col items-center">
|
||||
<button
|
||||
onClick={() => {
|
||||
setStep('keypad');
|
||||
setResetKey((k) => k + 1);
|
||||
setError('');
|
||||
}}
|
||||
className="self-start mb-6 text-sm text-slate-500 hover:text-slate-700 dark:hover:text-slate-300"
|
||||
>
|
||||
← Start over
|
||||
</button>
|
||||
<Keypad
|
||||
length={6}
|
||||
onComplete={handleConfirmCode}
|
||||
label="Confirm your nKode"
|
||||
disabled={loading}
|
||||
error={error}
|
||||
resetKey={resetKey}
|
||||
/>
|
||||
{loading && (
|
||||
<p className="mt-4 text-sm text-slate-500 animate-pulse">
|
||||
Creating account…
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'key-show' && (
|
||||
<div className="space-y-4">
|
||||
<button
|
||||
onClick={() => setStep('method')}
|
||||
className="text-sm text-slate-500 hover:text-slate-700 dark:hover:text-slate-300"
|
||||
>
|
||||
← Back
|
||||
</button>
|
||||
<div className="p-4 rounded-xl bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800">
|
||||
<p className="text-sm font-medium text-amber-800 dark:text-amber-200 mb-2">
|
||||
⚠️ Save this key — you won't see it again!
|
||||
</p>
|
||||
<code className="block p-3 rounded-lg bg-white dark:bg-slate-800 text-sm font-mono break-all
|
||||
text-slate-900 dark:text-slate-100 border border-slate-200 dark:border-slate-700">
|
||||
{secretKey}
|
||||
</code>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => navigator.clipboard.writeText(secretKey)}
|
||||
className="w-full py-2 rounded-xl border border-slate-200 dark:border-slate-700
|
||||
text-slate-700 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors"
|
||||
>
|
||||
📋 Copy to clipboard
|
||||
</button>
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
<button
|
||||
onClick={handleKeyRegister}
|
||||
disabled={loading}
|
||||
className="w-full py-2.5 rounded-xl bg-indigo-500 text-white font-medium
|
||||
hover:bg-indigo-600 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Registering…' : 'I\'ve saved my key — Register'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'done' && (
|
||||
<div className="text-center space-y-4">
|
||||
<div className="text-5xl">✅</div>
|
||||
<p className="text-slate-600 dark:text-slate-300">
|
||||
Your account has been created with key-based auth.
|
||||
</p>
|
||||
<Link
|
||||
to={ROUTES.LOGIN}
|
||||
className="inline-block px-6 py-2.5 rounded-xl bg-indigo-500 text-white font-medium
|
||||
hover:bg-indigo-600 transition-colors"
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
19
src/types/nkode-client-wasm.d.ts
vendored
Normal file
19
src/types/nkode-client-wasm.d.ts
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Type stub for the nKode WASM package.
|
||||
* Will be replaced by real types when the package is linked via `bun link`.
|
||||
*/
|
||||
declare module 'nkode-client-wasm' {
|
||||
export class NKodeClient {
|
||||
constructor(base_url: string);
|
||||
loginCode(email: string, passcode_bytes: Uint8Array): Promise<any>;
|
||||
loginKey(email: string, secret_key_hex: string): Promise<any>;
|
||||
registerCode(email: string, passcode_bytes: Uint8Array): Promise<void>;
|
||||
registerKey(email: string, secret_key_hex: string): Promise<void>;
|
||||
static generateSecretKey(): string;
|
||||
free(): void;
|
||||
}
|
||||
|
||||
export function init(): void;
|
||||
|
||||
export default function __wbg_init(module_or_path?: any): Promise<any>;
|
||||
}
|
||||
Reference in New Issue
Block a user