diff --git a/helexa.ai/scripts/check-i18n-keys.mjs b/helexa.ai/scripts/check-i18n-keys.mjs index 048304f5..c17362e6 100644 --- a/helexa.ai/scripts/check-i18n-keys.mjs +++ b/helexa.ai/scripts/check-i18n-keys.mjs @@ -23,7 +23,7 @@ const ROOT = path.resolve( const RESOURCES_DIR = path.join(ROOT, "src", "i18n", "resources"); // Namespaces to validate. -const NAMESPACES = ["common", "mission", "chat"]; +const NAMESPACES = ["common", "mission", "chat", "account"]; // Languages to validate should track SUPPORTED_LANGUAGES in src/i18n/languages.ts. // NOTE: This list is intentionally narrower than SUPPORTED_LANGUAGES and does not diff --git a/helexa.ai/src/App.tsx b/helexa.ai/src/App.tsx index a232e3f5..11163c72 100644 --- a/helexa.ai/src/App.tsx +++ b/helexa.ai/src/App.tsx @@ -1,26 +1,58 @@ import { BrowserRouter, Routes, Route } from "react-router-dom"; import ThemeProvider from "./layout/ThemeProvider"; +import AuthProvider from "./auth/AuthProvider"; +import RequireAuth from "./auth/RequireAuth"; import Header from "./components/Header"; import Footer from "./components/Footer"; import Mission from "./pages/Mission"; import Chat from "./pages/Chat"; +import Login from "./pages/auth/Login"; +import Register from "./pages/auth/Register"; +import VerifyEmail from "./pages/auth/VerifyEmail"; +import RequestReset from "./pages/auth/RequestReset"; +import ResetPassword from "./pages/auth/ResetPassword"; +import Dashboard from "./pages/account/Dashboard"; +import ApiKeys from "./pages/account/ApiKeys"; import "./App.css"; -// Composition root: theme + router + layout shell. `/` is the chat -// workspace (F3, anonymous for now); `/mission` (F2) is the EU-sovereignty -// narrative; the auth/account routes (F4) land next. +// Composition root: theme → router → auth → layout shell. `/` is the chat +// workspace (F3); `/mission` the EU-sovereignty narrative (F2); the auth + +// account routes (F4) follow, with /account guarded. export default function App() { return ( -
-
- - } /> - } /> - -
-
+ +
+
+ + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + } + /> + + + + } + /> + +
+
); diff --git a/helexa.ai/src/api/account.ts b/helexa.ai/src/api/account.ts new file mode 100644 index 00000000..b2856358 --- /dev/null +++ b/helexa.ai/src/api/account.ts @@ -0,0 +1,213 @@ +// Account API client over helexa-upstream's /web/v1 (B4/B5). The browser +// calls a same-origin `/api` prefix (vite-proxied in dev, nginx-routed in +// prod). A MockAccountApi behind VITE_USE_MOCK_ACCOUNT_API lets the +// dashboard be built/demoed before the upstream service is reachable. + +import { + ApiError, + type AccountBalance, + type ApiKeySummary, + type CreatedKey, + type Session, +} from "./types"; + +export interface AccountApi { + register(email: string, password: string, fingerprint?: string): Promise; + verify(token: string): Promise; + login(email: string, password: string): Promise; + requestReset(email: string): Promise; + confirmReset(token: string, newPassword: string): Promise; + account(token: string): Promise; + listKeys(token: string): Promise; + createKey( + token: string, + label: string, + limitKind: "percent" | "hardcap", + limitValue: number, + ): Promise; + archiveKey(token: string, id: string): Promise; + updateKeyLimit( + token: string, + id: string, + limitKind: "percent" | "hardcap", + limitValue: number, + ): Promise; + redeem(token: string, code: string): Promise; +} + +const BASE = (import.meta.env.VITE_ACCOUNT_BASE_URL || "/api").replace(/\/$/, ""); + +async function call( + path: string, + init: RequestInit & { token?: string } = {}, +): Promise { + const headers: Record = { "content-type": "application/json" }; + if (init.token) headers.authorization = `Bearer ${init.token}`; + let resp: Response; + try { + resp = await fetch(`${BASE}${path}`, { ...init, headers }); + } catch { + throw new ApiError(0, "network_error", "Could not reach the account service."); + } + if (resp.status === 204) return undefined as T; + let body: unknown = null; + try { + body = await resp.json(); + } catch { + /* empty body */ + } + if (!resp.ok) { + const err = (body as { error?: { code?: string; message?: string } })?.error; + throw new ApiError(resp.status, err?.code ?? "error", err?.message ?? "Request failed."); + } + return body as T; +} + +class RealAccountApi implements AccountApi { + async register(email: string, password: string, fingerprint?: string) { + await call("/register", { + method: "POST", + body: JSON.stringify({ email, password, fingerprint }), + }); + } + async verify(token: string) { + await call("/verify", { method: "POST", body: JSON.stringify({ token }) }); + } + login(email: string, password: string) { + return call("/login", { + method: "POST", + body: JSON.stringify({ email, password }), + }); + } + async requestReset(email: string) { + await call("/password-reset/request", { + method: "POST", + body: JSON.stringify({ email }), + }); + } + async confirmReset(token: string, newPassword: string) { + await call("/password-reset/confirm", { + method: "POST", + body: JSON.stringify({ token, new_password: newPassword }), + }); + } + account(token: string) { + return call("/account", { token }); + } + listKeys(token: string) { + return call<{ keys: ApiKeySummary[] }>("/keys", { token }).then((r) => r.keys); + } + createKey(token: string, label: string, limit_kind: "percent" | "hardcap", limit_value: number) { + return call("/keys", { + method: "POST", + token, + body: JSON.stringify({ label, limit_kind, limit_value }), + }); + } + async archiveKey(token: string, id: string) { + await call(`/keys/${id}/archive`, { method: "POST", token, body: "{}" }); + } + async updateKeyLimit( + token: string, + id: string, + limit_kind: "percent" | "hardcap", + limit_value: number, + ) { + await call(`/keys/${id}/limit`, { + method: "PATCH", + token, + body: JSON.stringify({ limit_kind, limit_value }), + }); + } + redeem(token: string, code: string) { + return call("/redeem", { + method: "POST", + token, + body: JSON.stringify({ code }), + }); + } +} + +// ── Mock (VITE_USE_MOCK_ACCOUNT_API) ──────────────────────────────── +// Minimal in-memory account so the dashboard is fully developable offline. + +class MockAccountApi implements AccountApi { + private total = 1_000_000; + private spent = 0; + private reserved = 0; + private keys: ApiKeySummary[] = []; + private seq = 1; + + async register() {} + async verify() {} + async login(): Promise { + return { token: "mock-token", expires_in: 604800 }; + } + async requestReset() {} + async confirmReset() {} + async account(): Promise { + return { + account_id: "mock-account", + allocation_total: this.total, + allocation_spent: this.spent, + allocation_reserved: this.reserved, + }; + } + async listKeys(): Promise { + return [...this.keys]; + } + async createKey( + _t: string, + label: string, + limit_kind: "percent" | "hardcap", + limit_value: number, + ): Promise { + const id = `mock-${this.seq++}`; + const prefix = `sk-helexa-mock${this.seq}`; + this.keys.push({ + id, + prefix, + label, + status: "active", + limit_kind, + limit_value, + spent: 0, + reserved: 0, + created_at: new Date().toISOString(), + }); + return { id, key: `${prefix}-RAWSECRETSHOWNONCE`, prefix, limit_kind, limit_value }; + } + async archiveKey(_t: string, id: string) { + const k = this.keys.find((x) => x.id === id); + if (k) k.status = "archived"; + } + async updateKeyLimit( + _t: string, + id: string, + limit_kind: "percent" | "hardcap", + limit_value: number, + ) { + const k = this.keys.find((x) => x.id === id); + if (k) { + k.limit_kind = limit_kind; + k.limit_value = limit_value; + } + } + async redeem(_t: string, code: string): Promise { + if (!code.startsWith("helexa-topup-")) { + throw new ApiError(400, "bad_request", "invalid or already-redeemed code"); + } + this.total += 500_000; + return this.account(); + } +} + +let instance: AccountApi | null = null; +export function accountApi(): AccountApi { + if (!instance) { + instance = import.meta.env.VITE_USE_MOCK_ACCOUNT_API + ? new MockAccountApi() + : new RealAccountApi(); + } + return instance; +} diff --git a/helexa.ai/src/api/types.ts b/helexa.ai/src/api/types.ts new file mode 100644 index 00000000..e6f34e33 --- /dev/null +++ b/helexa.ai/src/api/types.ts @@ -0,0 +1,45 @@ +// Wire types for the helexa-upstream /web/v1 account API (B4/B5). + +export interface ApiKeySummary { + id: string; + prefix: string; + label: string; + status: "active" | "archived"; + limit_kind: "percent" | "hardcap"; + limit_value: number; + spent: number; + reserved: number; + created_at: string; +} + +export interface CreatedKey { + id: string; + /** Raw secret — shown exactly once at creation. */ + key: string; + prefix: string; + limit_kind: "percent" | "hardcap"; + limit_value: number; +} + +export interface AccountBalance { + account_id: string; + allocation_total: number; + allocation_spent: number; + allocation_reserved: number; +} + +export interface Session { + token: string; + expires_in: number; +} + +/** Typed error carrying the backend's machine-readable code. */ +export class ApiError extends Error { + code: string; + status: number; + constructor(status: number, code: string, message: string) { + super(message); + this.code = code; + this.status = status; + } +} diff --git a/helexa.ai/src/auth/AuthProvider.tsx b/helexa.ai/src/auth/AuthProvider.tsx new file mode 100644 index 00000000..f6eb9e22 --- /dev/null +++ b/helexa.ai/src/auth/AuthProvider.tsx @@ -0,0 +1,53 @@ +import { useState, type ReactNode } from "react"; +import { accountApi } from "../api/account"; +import { claimAnonymousData } from "../data/repositories"; +import { getFingerprint } from "../lib/fingerprint"; +import { AuthContext } from "./context"; + +const TOKEN_KEY = "helexa.token"; +const EMAIL_KEY = "helexa.email"; + +export default function AuthProvider({ children }: { children: ReactNode }) { + const [token, setToken] = useState(() => + localStorage.getItem(TOKEN_KEY), + ); + const [email, setEmail] = useState(() => + localStorage.getItem(EMAIL_KEY), + ); + + async function login(em: string, password: string): Promise { + const api = accountApi(); + const session = await api.login(em, password); + localStorage.setItem(TOKEN_KEY, session.token); + localStorage.setItem(EMAIL_KEY, em); + setToken(session.token); + setEmail(em); + // Claim anonymous local history into the account (stays client-side). + try { + const acct = await api.account(session.token); + await claimAnonymousData(acct.account_id); + } catch { + /* non-fatal */ + } + } + + async function register(em: string, password: string): Promise { + const fingerprint = await getFingerprint(); + await accountApi().register(em, password, fingerprint); + } + + function logout(): void { + localStorage.removeItem(TOKEN_KEY); + localStorage.removeItem(EMAIL_KEY); + setToken(null); + setEmail(null); + } + + return ( + + {children} + + ); +} diff --git a/helexa.ai/src/auth/RequireAuth.tsx b/helexa.ai/src/auth/RequireAuth.tsx new file mode 100644 index 00000000..1d849228 --- /dev/null +++ b/helexa.ai/src/auth/RequireAuth.tsx @@ -0,0 +1,14 @@ +import { type ReactNode } from "react"; +import { Navigate, useLocation } from "react-router-dom"; +import { useAuth } from "./context"; + +/** Route guard: redirect unauthenticated users to /login?next=…. */ +export default function RequireAuth({ children }: { children: ReactNode }) { + const { status } = useAuth(); + const location = useLocation(); + if (status !== "authed") { + const next = encodeURIComponent(location.pathname + location.search); + return ; + } + return <>{children}; +} diff --git a/helexa.ai/src/auth/context.ts b/helexa.ai/src/auth/context.ts new file mode 100644 index 00000000..94f9fb2c --- /dev/null +++ b/helexa.ai/src/auth/context.ts @@ -0,0 +1,23 @@ +import { createContext, useContext } from "react"; + +export interface AuthContextValue { + token: string | null; + email: string | null; + status: "anon" | "authed"; + login: (email: string, password: string) => Promise; + register: (email: string, password: string) => Promise; + logout: () => void; +} + +export const AuthContext = createContext({ + token: null, + email: null, + status: "anon", + login: async () => {}, + register: async () => {}, + logout: () => {}, +}); + +export function useAuth(): AuthContextValue { + return useContext(AuthContext); +} diff --git a/helexa.ai/src/components/Header.tsx b/helexa.ai/src/components/Header.tsx index 41b55713..f82c4abe 100644 --- a/helexa.ai/src/components/Header.tsx +++ b/helexa.ai/src/components/Header.tsx @@ -6,11 +6,12 @@ import { useTheme } from "../layout/theme"; import { useTranslation } from "react-i18next"; import { AUTONYM_MAP, type LanguageCode, isRtlLanguage } from "../i18n/languages"; import { getLanguageOptionsByUsage } from "../i18n/translation-priority"; +import { useAuth } from "../auth/context"; /** * Top navigation: brand, primary routes (chat at `/`, `/mission`), an - * auth-aware cluster (stubbed until F4 wires sessions), the theme toggle, - * and the language selector. + * auth-aware cluster (Account/Sign out when signed in, else Sign in/up), + * the theme toggle, and the language selector. * * The language picker is ordered by **estimated usage** * (getLanguageOptionsByUsage), not alphabetically — a deliberate choice that @@ -21,6 +22,7 @@ import { getLanguageOptionsByUsage } from "../i18n/translation-priority"; const Header: React.FC = () => { const { theme, toggleTheme } = useTheme(); const { t, i18n } = useTranslation("common"); + const { status, logout } = useAuth(); const currentLanguage: LanguageCode = (i18n.language.split("-")[0] || "en") as LanguageCode; @@ -75,13 +77,31 @@ const Header: React.FC = () => {
- {/* Auth cluster — plain links until F4 wires session state. */} - - {t("nav.login")} - - - {t("nav.register")} - + {/* Auth-aware cluster. */} + {status === "authed" ? ( + <> + + {t("nav.account")} + + + + ) : ( + <> + + {t("nav.login")} + + + {t("nav.register")} + + + )} +
+ + + + {keys.length === 0 ? ( +

{t("keys.none")}

+ ) : ( + + + + + + + + + + + + {keys.map((k) => ( + + + + + + + + + ))} + +
{t("keys.label")}Prefix{t("keys.limitKind")}{t("keys.usage")}{t("keys.status")} +
{k.label || "—"} + {k.prefix}… + + {k.limit_kind === "percent" ? `${k.limit_value}%` : k.limit_value.toLocaleString()} + {k.spent.toLocaleString()} + {k.status} + + {k.status === "active" && ( + + )} +
+ )} + + {/* The raw key is shown exactly once. */} + setCreated(null)} centered> + + {t("keys.createdTitle")} + + + {t("keys.createdWarn")} +
+ + +
+
+
+ + ); +} diff --git a/helexa.ai/src/pages/account/Dashboard.tsx b/helexa.ai/src/pages/account/Dashboard.tsx new file mode 100644 index 00000000..0744519f --- /dev/null +++ b/helexa.ai/src/pages/account/Dashboard.tsx @@ -0,0 +1,104 @@ +import { useCallback, useEffect, useState } from "react"; +import { Link } from "react-router-dom"; +import { Alert, Button, Card, Container, Form, ProgressBar } from "react-bootstrap"; +import { useTranslation } from "react-i18next"; +import { useAuth } from "../../auth/context"; +import { accountApi } from "../../api/account"; +import { ApiError, type AccountBalance } from "../../api/types"; + +export default function Dashboard() { + const { t } = useTranslation("account"); + const { token, logout } = useAuth(); + const [balance, setBalance] = useState(null); + const [code, setCode] = useState(""); + const [msg, setMsg] = useState(null); + const [error, setError] = useState(null); + + const load = useCallback(async () => { + if (!token) return; + try { + setBalance(await accountApi().account(token)); + } catch (err) { + if (err instanceof ApiError && err.status === 401) logout(); + else setError(t("error.generic")); + } + }, [token, logout, t]); + + useEffect(() => { + // load() is async; setState happens after await, not synchronously. + // eslint-disable-next-line react-hooks/set-state-in-effect + void load(); + }, [load]); + + async function redeem(e: React.FormEvent) { + e.preventDefault(); + if (!token) return; + setError(null); + setMsg(null); + try { + setBalance(await accountApi().redeem(token, code.trim())); + setCode(""); + setMsg(t("dashboard.redeemed")); + } catch (err) { + setError(err instanceof ApiError ? err.message : t("error.generic")); + } + } + + const remaining = balance + ? balance.allocation_total - balance.allocation_spent - balance.allocation_reserved + : 0; + const pct = balance && balance.allocation_total > 0 + ? Math.round(((balance.allocation_spent + balance.allocation_reserved) / balance.allocation_total) * 100) + : 0; + + return ( + +
+

{t("dashboard.title")}

+ +
+ + + + + {t("dashboard.balance")} + + {balance && ( + <> + +
+ {t("dashboard.total")}: {balance.allocation_total.toLocaleString()} + {t("dashboard.spent")}: {balance.allocation_spent.toLocaleString()} + {t("dashboard.reserved")}: {balance.allocation_reserved.toLocaleString()} + {t("dashboard.remaining")}: {remaining.toLocaleString()} +
+ + )} + + {t("dashboard.manageKeys")} + +
+
+ + + + {t("dashboard.redeemTitle")} + {msg && {msg}} + {error && {error}} +
+ setCode(e.target.value)} + /> + + +
+
+
+ ); +} diff --git a/helexa.ai/src/pages/auth/Login.tsx b/helexa.ai/src/pages/auth/Login.tsx new file mode 100644 index 00000000..469182a3 --- /dev/null +++ b/helexa.ai/src/pages/auth/Login.tsx @@ -0,0 +1,64 @@ +import { useState } from "react"; +import { Link, useNavigate, useSearchParams } from "react-router-dom"; +import { Alert, Button, Container, Form } from "react-bootstrap"; +import { useTranslation } from "react-i18next"; +import { useAuth } from "../../auth/context"; +import { ApiError } from "../../api/types"; + +export default function Login() { + const { t } = useTranslation("account"); + const { login } = useAuth(); + const nav = useNavigate(); + const [params] = useSearchParams(); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + async function submit(e: React.FormEvent) { + e.preventDefault(); + setBusy(true); + setError(null); + try { + await login(email, password); + nav(params.get("next") || "/account", { replace: true }); + } catch (err) { + setError(err instanceof ApiError ? err.message : t("error.generic")); + } finally { + setBusy(false); + } + } + + return ( + +

{t("login.title")}

+ {error && {error}} +
+ + {t("login.email")} + setEmail(e.target.value)} + required + /> + + + {t("login.password")} + setPassword(e.target.value)} + required + /> + + +
+

+ {t("login.noAccount")} +

+
+ ); +} diff --git a/helexa.ai/src/pages/auth/Register.tsx b/helexa.ai/src/pages/auth/Register.tsx new file mode 100644 index 00000000..bebc43ba --- /dev/null +++ b/helexa.ai/src/pages/auth/Register.tsx @@ -0,0 +1,70 @@ +import { useState } from "react"; +import { Link } from "react-router-dom"; +import { Alert, Button, Container, Form } from "react-bootstrap"; +import { useTranslation } from "react-i18next"; +import { useAuth } from "../../auth/context"; +import { ApiError } from "../../api/types"; + +export default function Register() { + const { t } = useTranslation("account"); + const { register } = useAuth(); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [done, setDone] = useState(false); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + async function submit(e: React.FormEvent) { + e.preventDefault(); + setBusy(true); + setError(null); + try { + await register(email, password); + setDone(true); + } catch (err) { + setError(err instanceof ApiError ? err.message : t("error.generic")); + } finally { + setBusy(false); + } + } + + return ( + +

{t("register.title")}

+ {done ? ( + {t("register.checkEmail")} + ) : ( + <> + {error && {error}} +
+ + {t("register.email")} + setEmail(e.target.value)} + required + /> + + + {t("register.password")} + setPassword(e.target.value)} + required + /> + + +
+

+ {t("register.haveAccount")} +

+ + )} +
+ ); +} diff --git a/helexa.ai/src/pages/auth/RequestReset.tsx b/helexa.ai/src/pages/auth/RequestReset.tsx new file mode 100644 index 00000000..fc9103ba --- /dev/null +++ b/helexa.ai/src/pages/auth/RequestReset.tsx @@ -0,0 +1,48 @@ +import { useState } from "react"; +import { Alert, Button, Container, Form } from "react-bootstrap"; +import { useTranslation } from "react-i18next"; +import { accountApi } from "../../api/account"; + +export default function RequestReset() { + const { t } = useTranslation("account"); + const [email, setEmail] = useState(""); + const [done, setDone] = useState(false); + const [busy, setBusy] = useState(false); + + async function submit(e: React.FormEvent) { + e.preventDefault(); + setBusy(true); + // Always succeeds from the UI's view (no account enumeration). + try { + await accountApi().requestReset(email); + } catch { + /* swallow */ + } + setDone(true); + setBusy(false); + } + + return ( + +

{t("reset.requestTitle")}

+ {done ? ( + {t("reset.requestDone")} + ) : ( +
+ + {t("reset.email")} + setEmail(e.target.value)} + required + /> + + +
+ )} +
+ ); +} diff --git a/helexa.ai/src/pages/auth/ResetPassword.tsx b/helexa.ai/src/pages/auth/ResetPassword.tsx new file mode 100644 index 00000000..250f6eae --- /dev/null +++ b/helexa.ai/src/pages/auth/ResetPassword.tsx @@ -0,0 +1,64 @@ +import { useState } from "react"; +import { Link, useSearchParams } from "react-router-dom"; +import { Alert, Button, Container, Form } from "react-bootstrap"; +import { useTranslation } from "react-i18next"; +import { accountApi } from "../../api/account"; +import { ApiError } from "../../api/types"; + +export default function ResetPassword() { + const { t } = useTranslation("account"); + const [params] = useSearchParams(); + const [password, setPassword] = useState(""); + const [done, setDone] = useState(false); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + async function submit(e: React.FormEvent) { + e.preventDefault(); + const token = params.get("token"); + if (!token) { + setError(t("verify.failed")); + return; + } + setBusy(true); + setError(null); + try { + await accountApi().confirmReset(token, password); + setDone(true); + } catch (err) { + setError(err instanceof ApiError ? err.message : t("error.generic")); + } finally { + setBusy(false); + } + } + + return ( + +

{t("reset.confirmTitle")}

+ {done ? ( + + {t("reset.ok")} {t("verify.toLogin")} + + ) : ( + <> + {error && {error}} +
+ + {t("reset.newPassword")} + setPassword(e.target.value)} + required + /> + + +
+ + )} +
+ ); +} diff --git a/helexa.ai/src/pages/auth/VerifyEmail.tsx b/helexa.ai/src/pages/auth/VerifyEmail.tsx new file mode 100644 index 00000000..9446684e --- /dev/null +++ b/helexa.ai/src/pages/auth/VerifyEmail.tsx @@ -0,0 +1,40 @@ +import { useEffect, useState } from "react"; +import { Link, useSearchParams } from "react-router-dom"; +import { Alert, Container, Spinner } from "react-bootstrap"; +import { useTranslation } from "react-i18next"; +import { accountApi } from "../../api/account"; + +export default function VerifyEmail() { + const { t } = useTranslation("account"); + const [params] = useSearchParams(); + const [state, setState] = useState<"verifying" | "ok" | "failed">("verifying"); + + useEffect(() => { + const token = params.get("token"); + // Keep all setState in async callbacks (no synchronous setState in the + // effect body): a missing token resolves to a rejected promise. + const run = token ? accountApi().verify(token) : Promise.reject(new Error("no token")); + run.then(() => setState("ok")).catch(() => setState("failed")); + }, [params]); + + return ( + + {state === "verifying" && ( +

+ + {t("verify.verifying")} +

+ )} + {state === "ok" && ( + + {t("verify.ok")} {t("verify.toLogin")} + + )} + {state === "failed" && ( + + {t("verify.failed")} {t("verify.toLogin")} + + )} +
+ ); +}