Compare commits

...

4 Commits

Author SHA1 Message Date
452d7d9b3d feat(B7): packaging + CI for helexa-upstream
All checks were successful
CI / CUDA type-check (push) Successful in 1m32s
CI / Format (push) Successful in 57s
CI / Clippy (push) Successful in 2m51s
CI / Test (push) Successful in 8m0s
CI / Build cortex SRPM (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
Ships the mesh authority as an RPM on the same prebuilt-binary pipeline as
helexa-bench.

- rpm/helexa-upstream-prerelease.spec: wraps the CI-built binary; installs
  the systemd unit, sysusers, firewalld service (tcp/8090), and
  /etc/helexa-upstream/helexa-upstream.toml (config noreplace).
- data/helexa-upstream.{service,sysusers.conf,firewalld.xml}: Type=simple
  unit (serve --config), dedicated system user with a StateDirectory home,
  inbound 8090 (/authz/v1 + /web/v1). PostgreSQL is reached outbound; the
  schema migrates on startup.
- build-prerelease.yml: build-upstream + package-upstream jobs with
  change-detection over crates/helexa-upstream/ (UPSTREAM_RE), gated into
  publish. SQLX_OFFLINE=true is set defensively — helexa-upstream uses the
  sqlx runtime query API (no compile-time macros), so it builds with no
  database and no .sqlx cache; DB integration tests stay gated behind
  UPSTREAM_TEST_DATABASE_URL.

Validated: workflow YAML parses, rpmspec expands, and
`SQLX_OFFLINE=true cargo build --release -p helexa-upstream` succeeds.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F6o3ddqmYNh9kzdwq6eowh
2026-06-23 12:00:41 +03:00
21eb211d6a Merge feat/B6-served-usage: served-usage ledger + reconciliation (B6, #58)
Some checks failed
build-prerelease / Test (push) Blocked by required conditions
build-prerelease / Package cortex RPM (push) Blocked by required conditions
build-prerelease / Package helexa-bench RPM (push) Blocked by required conditions
build-prerelease / Resolve version stamps + change detection (push) Successful in 37s
build-prerelease / Build neuron-blackwell (push) Successful in 1m25s
build-prerelease / Build neuron-ada (push) Successful in 2m3s
build-prerelease / Build neuron-ampere (push) Successful in 2m10s
build-prerelease / Build helexa-bench binary (push) Successful in 2m13s
build-prerelease / Build cortex binary (push) Successful in 3m25s
build-prerelease / Lint (fmt + clippy) (push) Successful in 3m45s
build-prerelease / Package helexa-neuron-ada RPM (push) Successful in 1m51s
build-prerelease / Package helexa-neuron-ampere RPM (push) Successful in 1m50s
build-prerelease / Package helexa-neuron-blackwell RPM (push) Successful in 1m52s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Has been cancelled
2026-06-23 11:56:06 +03:00
0de99a8cc7 Merge feat/F4-account-dashboard: auth + account dashboard (F4)
Some checks failed
build-prerelease / Test (push) Blocked by required conditions
build-prerelease / Resolve version stamps + change detection (push) Successful in 47s
build-prerelease / Build neuron-blackwell (push) Has been skipped
build-prerelease / Build neuron-ampere (push) Has been skipped
build-prerelease / Build neuron-ada (push) Has been skipped
build-prerelease / Package helexa-neuron-ada RPM (push) Has been skipped
build-prerelease / Package helexa-neuron-ampere RPM (push) Has been skipped
build-prerelease / Package helexa-neuron-blackwell RPM (push) Has been skipped
build-prerelease / Lint (fmt + clippy) (push) Successful in 3m16s
build-prerelease / Build cortex binary (push) Has been skipped
build-prerelease / Build helexa-bench binary (push) Has been skipped
build-prerelease / Package cortex RPM (push) Has been skipped
build-prerelease / Package helexa-bench RPM (push) Has been skipped
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Has been cancelled
2026-06-23 11:46:14 +03:00
1bf3348c8c feat(F4): auth + account dashboard (mockable client)
All checks were successful
CI / Format (push) Successful in 42s
CI / CUDA type-check (push) Successful in 1m41s
CI / Clippy (push) Successful in 3m9s
CI / Test (push) Successful in 6m30s
CI / Build cortex SRPM (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
The self-service account surface consuming helexa-upstream's /web/v1 (B4/B5),
with a mock so it works before/independent of the live backend.

- api/types.ts + api/account.ts: typed AccountApi over a same-origin `/api`
  prefix (vite-proxied in dev, nginx in prod) covering register/verify/
  login/password-reset/keys(list,create,archive,limit)/account/redeem;
  ApiError carries the backend code. MockAccountApi behind
  VITE_USE_MOCK_ACCOUNT_API (in-memory account, raw-key-once, redeem).
- auth/: context + useAuth, AuthProvider (JWT in localStorage, login fetches
  the account and runs claimAnonymousData → anon IndexedDB history is
  re-owned to the account, still client-side), RequireAuth guard
  (→ /login?next=).
- pages/auth/: Login, Register (sends the FingerprintJS visitor id →
  triggers the silent abuse detection), VerifyEmail (?token), RequestReset,
  ResetPassword (?token, matches the backend /reset?token= link).
- pages/account/: Dashboard (allocation balance + usage bar, redeem top-up,
  logout) and ApiKeys (list, create-modal showing the raw key ONCE with
  copy, per-key limit editor percent↔hardcap, archive). 401 → logout.
- App wraps AuthProvider + routes (account guarded); Header auth cluster
  reflects useAuth (Account/Sign out vs Sign in/up).
- `account` i18n namespace (53 keys) added + wired across all 32 langs.

Validated: lint, typecheck, build, i18n:check, lang-labels all green.
Explicit-path commit; no node_modules/dist.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F6o3ddqmYNh9kzdwq6eowh
2026-06-23 11:31:43 +03:00
53 changed files with 3471 additions and 26 deletions

View File

@@ -66,6 +66,7 @@ jobs:
build_cortex: ${{ steps.changes.outputs.build_cortex }} build_cortex: ${{ steps.changes.outputs.build_cortex }}
build_neuron: ${{ steps.changes.outputs.build_neuron }} build_neuron: ${{ steps.changes.outputs.build_neuron }}
build_bench: ${{ steps.changes.outputs.build_bench }} build_bench: ${{ steps.changes.outputs.build_bench }}
build_upstream: ${{ steps.changes.outputs.build_upstream }}
check_rust: ${{ steps.changes.outputs.check_rust }} check_rust: ${{ steps.changes.outputs.check_rust }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -104,6 +105,7 @@ jobs:
BUILD_CORTEX=true BUILD_CORTEX=true
BUILD_NEURON=true BUILD_NEURON=true
BUILD_BENCH=true BUILD_BENCH=true
BUILD_UPSTREAM=true
CHECK_RUST=true CHECK_RUST=true
if [ "${GITHUB_EVENT_NAME}" = "push" ]; then if [ "${GITHUB_EVENT_NAME}" = "push" ]; then
@@ -149,6 +151,7 @@ jobs:
NEURON_RE='^crates/neuron/|^crates/cortex-core/|^Cargo\.toml$|^Cargo\.lock$|^rpm/helexa-neuron-prerelease\.spec$|^data/neuron|^neuron\.example\.toml$|^\.gitea/workflows/build-prerelease\.yml$' NEURON_RE='^crates/neuron/|^crates/cortex-core/|^Cargo\.toml$|^Cargo\.lock$|^rpm/helexa-neuron-prerelease\.spec$|^data/neuron|^neuron\.example\.toml$|^\.gitea/workflows/build-prerelease\.yml$'
CORTEX_RE='^crates/cortex-gateway/|^crates/cortex-cli/|^crates/cortex-core/|^Cargo\.toml$|^Cargo\.lock$|^rpm/cortex-prerelease\.spec$|^data/cortex|^cortex\.example\.toml$|^models\.example\.toml$|^\.gitea/workflows/build-prerelease\.yml$' CORTEX_RE='^crates/cortex-gateway/|^crates/cortex-cli/|^crates/cortex-core/|^Cargo\.toml$|^Cargo\.lock$|^rpm/cortex-prerelease\.spec$|^data/cortex|^cortex\.example\.toml$|^models\.example\.toml$|^\.gitea/workflows/build-prerelease\.yml$'
BENCH_RE='^crates/helexa-bench/|^crates/cortex-core/|^Cargo\.toml$|^Cargo\.lock$|^rpm/helexa-bench-prerelease\.spec$|^data/helexa-bench|^helexa-bench\.example\.toml$|^\.gitea/workflows/build-prerelease\.yml$' BENCH_RE='^crates/helexa-bench/|^crates/cortex-core/|^Cargo\.toml$|^Cargo\.lock$|^rpm/helexa-bench-prerelease\.spec$|^data/helexa-bench|^helexa-bench\.example\.toml$|^\.gitea/workflows/build-prerelease\.yml$'
UPSTREAM_RE='^crates/helexa-upstream/|^crates/cortex-core/|^Cargo\.toml$|^Cargo\.lock$|^rpm/helexa-upstream-prerelease\.spec$|^data/helexa-upstream|^helexa-upstream\.example\.toml$|^\.gitea/workflows/build-prerelease\.yml$'
# Any Rust change (incl. crates not packaged here, e.g. # Any Rust change (incl. crates not packaged here, e.g.
# helexa-acp) still needs lint+test on main. # helexa-acp) still needs lint+test on main.
RUST_RE='\.rs$|^crates/|Cargo\.toml$|^Cargo\.lock$' RUST_RE='\.rs$|^crates/|Cargo\.toml$|^Cargo\.lock$'
@@ -156,10 +159,12 @@ jobs:
CORTEX_BASE=$(base_for cortex) CORTEX_BASE=$(base_for cortex)
NEURON_BASE=$(base_for helexa-neuron-blackwell) NEURON_BASE=$(base_for helexa-neuron-blackwell)
BENCH_BASE=$(base_for helexa-bench) BENCH_BASE=$(base_for helexa-bench)
UPSTREAM_BASE=$(base_for helexa-upstream)
BUILD_CORTEX=$(decide "$CORTEX_BASE" "$CORTEX_RE") BUILD_CORTEX=$(decide "$CORTEX_BASE" "$CORTEX_RE")
BUILD_NEURON=$(decide "$NEURON_BASE" "$NEURON_RE") BUILD_NEURON=$(decide "$NEURON_BASE" "$NEURON_RE")
BUILD_BENCH=$(decide "$BENCH_BASE" "$BENCH_RE") BUILD_BENCH=$(decide "$BENCH_BASE" "$BENCH_RE")
if [ "$BUILD_CORTEX" = "true" ] || [ "$BUILD_NEURON" = "true" ] || [ "$BUILD_BENCH" = "true" ]; then BUILD_UPSTREAM=$(decide "$UPSTREAM_BASE" "$UPSTREAM_RE")
if [ "$BUILD_CORTEX" = "true" ] || [ "$BUILD_NEURON" = "true" ] || [ "$BUILD_BENCH" = "true" ] || [ "$BUILD_UPSTREAM" = "true" ]; then
CHECK_RUST=true CHECK_RUST=true
else else
CHECK_RUST=$(decide "$CORTEX_BASE" "$RUST_RE") CHECK_RUST=$(decide "$CORTEX_BASE" "$RUST_RE")
@@ -170,8 +175,9 @@ jobs:
echo "build_cortex=${BUILD_CORTEX}" >> "$GITHUB_OUTPUT" echo "build_cortex=${BUILD_CORTEX}" >> "$GITHUB_OUTPUT"
echo "build_neuron=${BUILD_NEURON}" >> "$GITHUB_OUTPUT" echo "build_neuron=${BUILD_NEURON}" >> "$GITHUB_OUTPUT"
echo "build_bench=${BUILD_BENCH}" >> "$GITHUB_OUTPUT" echo "build_bench=${BUILD_BENCH}" >> "$GITHUB_OUTPUT"
echo "build_upstream=${BUILD_UPSTREAM}" >> "$GITHUB_OUTPUT"
echo "check_rust=${CHECK_RUST}" >> "$GITHUB_OUTPUT" echo "check_rust=${CHECK_RUST}" >> "$GITHUB_OUTPUT"
echo "### change detection: build_cortex=${BUILD_CORTEX} build_neuron=${BUILD_NEURON} build_bench=${BUILD_BENCH} check_rust=${CHECK_RUST}" echo "### change detection: build_cortex=${BUILD_CORTEX} build_neuron=${BUILD_NEURON} build_bench=${BUILD_BENCH} build_upstream=${BUILD_UPSTREAM} check_rust=${CHECK_RUST}"
# fmt + clippy + test moved here from ci.yml for main pushes so the # fmt + clippy + test moved here from ci.yml for main pushes so the
# two workflows stop queueing against each other (ci.yml's checks # two workflows stop queueing against each other (ci.yml's checks
@@ -303,6 +309,45 @@ jobs:
path: artifacts/helexa-bench path: artifacts/helexa-bench
retention-days: 1 retention-days: 1
build-upstream:
name: Build helexa-upstream binary
timeout-minutes: 25
needs: prepare
if: needs.prepare.outputs.build_upstream == 'true'
# Pure-Rust, non-CUDA binary — same runner as cortex/bench.
runs-on: rust
env:
RUSTC_WRAPPER: sccache
SCCACHE_BUCKET: sccache
SCCACHE_ENDPOINT: http://caveman.kosherinata.internal:9000
SCCACHE_REGION: auto
SCCACHE_S3_USE_SSL: "false"
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_S3_ACCESS_KEY }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_S3_SECRET_KEY }}
# helexa-upstream uses the sqlx runtime query API (no compile-time
# query macros), so it builds without a database or a .sqlx cache.
# Set OFFLINE defensively so a stray macro can never reach for a DB.
SQLX_OFFLINE: "true"
steps:
- uses: actions/checkout@v4
with:
ref: ${{ inputs.ref }}
- name: Build helexa-upstream (release, sccache escalation)
run: script/ci-cargo-escalate.sh cargo build --release -p helexa-upstream
- name: Stage binary
run: |
mkdir --parents artifacts
cp target/release/helexa-upstream artifacts/helexa-upstream
./artifacts/helexa-upstream --version || true
- uses: actions/upload-artifact@v3
with:
name: upstream-fc43
path: artifacts/helexa-upstream
retention-days: 1
build-neuron: build-neuron:
name: Build neuron-${{ matrix.flavour }} name: Build neuron-${{ matrix.flavour }}
timeout-minutes: 35 timeout-minutes: 35
@@ -459,6 +504,44 @@ jobs:
path: ~/rpmbuild/RPMS/x86_64/*.rpm path: ~/rpmbuild/RPMS/x86_64/*.rpm
retention-days: 7 retention-days: 7
package-upstream:
name: Package helexa-upstream RPM
timeout-minutes: 20
needs: [prepare, build-upstream]
runs-on: rpm
steps:
- uses: actions/checkout@v4
with:
ref: ${{ inputs.ref }}
- uses: actions/download-artifact@v3
with:
name: upstream-fc43
path: artifacts/
- name: Build RPM
run: |
set -eux
rm -f ~/.rpmmacros
rpmdev-setuptree
cp artifacts/helexa-upstream ~/rpmbuild/SOURCES/
cp data/helexa-upstream.service ~/rpmbuild/SOURCES/
cp data/helexa-upstream-sysusers.conf ~/rpmbuild/SOURCES/
cp data/helexa-upstream-firewalld.xml ~/rpmbuild/SOURCES/
cp helexa-upstream.example.toml ~/rpmbuild/SOURCES/
cp LICENSE ~/rpmbuild/SOURCES/
rpmbuild -bb rpm/helexa-upstream-prerelease.spec \
--define "upstream_version ${{ needs.prepare.outputs.version }}" \
--define "upstream_prerelease ${{ needs.prepare.outputs.release }}" \
--undefine dist \
--define "dist .fc43"
- uses: actions/upload-artifact@v3
with:
name: rpm-upstream-fc43
path: ~/rpmbuild/RPMS/x86_64/*.rpm
retention-days: 7
package-neuron: package-neuron:
name: Package helexa-neuron-${{ matrix.flavour }} RPM name: Package helexa-neuron-${{ matrix.flavour }} RPM
timeout-minutes: 20 timeout-minutes: 20
@@ -508,7 +591,7 @@ jobs:
publish: publish:
name: Publish to rpm.lair.cafe (unstable) name: Publish to rpm.lair.cafe (unstable)
timeout-minutes: 25 timeout-minutes: 25
needs: [lint, test, package-cortex, package-neuron, package-bench] needs: [lint, test, package-cortex, package-neuron, package-bench, package-upstream]
# Runs when at least one package was built and nothing failed. # Runs when at least one package was built and nothing failed.
# lint/test may be skipped (docs-only refs never get here because # lint/test may be skipped (docs-only refs never get here because
# no packages build), but a real failure in any blocks the # no packages build), but a real failure in any blocks the
@@ -518,10 +601,11 @@ jobs:
!cancelled() !cancelled()
&& (needs.lint.result == 'success' || needs.lint.result == 'skipped') && (needs.lint.result == 'success' || needs.lint.result == 'skipped')
&& (needs.test.result == 'success' || needs.test.result == 'skipped') && (needs.test.result == 'success' || needs.test.result == 'skipped')
&& (needs.package-cortex.result == 'success' || needs.package-neuron.result == 'success' || needs.package-bench.result == 'success') && (needs.package-cortex.result == 'success' || needs.package-neuron.result == 'success' || needs.package-bench.result == 'success' || needs.package-upstream.result == 'success')
&& needs.package-cortex.result != 'failure' && needs.package-cortex.result != 'failure'
&& needs.package-neuron.result != 'failure' && needs.package-neuron.result != 'failure'
&& needs.package-bench.result != 'failure' && needs.package-bench.result != 'failure'
&& needs.package-upstream.result != 'failure'
}} }}
runs-on: rpm runs-on: rpm
concurrency: concurrency:

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<service>
<short>helexa-upstream</short>
<description>helexa-upstream — mesh account + budget authority API (/authz/v1 for cortex, /web/v1 for the frontend)</description>
<port protocol="tcp" port="8090"/>
</service>

View File

@@ -0,0 +1,3 @@
g helexa-upstream - -
u helexa-upstream - "helexa-upstream authority" /var/lib/helexa-upstream /sbin/nologin
m helexa-upstream helexa-upstream

View File

@@ -0,0 +1,22 @@
[Unit]
Description=helexa-upstream — mesh account + budget authority (accounts, API keys, allocation ledger, top-up codes)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/bin/helexa-upstream serve --config /etc/helexa-upstream/helexa-upstream.toml
# HTTP authority for cortex (/authz/v1) and the frontend (/web/v1); restart
# unconditionally if it ever exits. It connects out to PostgreSQL (the
# system of record) and runs schema migrations on startup.
Restart=always
RestartSec=10
User=helexa-upstream
Group=helexa-upstream
# Service user home; no local state (PostgreSQL holds everything), but
# StateDirectory gives the user a writable, correctly-owned home.
StateDirectory=helexa-upstream
StateDirectoryMode=0755
[Install]
WantedBy=multi-user.target

View File

@@ -23,7 +23,7 @@ const ROOT = path.resolve(
const RESOURCES_DIR = path.join(ROOT, "src", "i18n", "resources"); const RESOURCES_DIR = path.join(ROOT, "src", "i18n", "resources");
// Namespaces to validate. // 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. // Languages to validate should track SUPPORTED_LANGUAGES in src/i18n/languages.ts.
// NOTE: This list is intentionally narrower than SUPPORTED_LANGUAGES and does not // NOTE: This list is intentionally narrower than SUPPORTED_LANGUAGES and does not

View File

@@ -1,26 +1,58 @@
import { BrowserRouter, Routes, Route } from "react-router-dom"; import { BrowserRouter, Routes, Route } from "react-router-dom";
import ThemeProvider from "./layout/ThemeProvider"; import ThemeProvider from "./layout/ThemeProvider";
import AuthProvider from "./auth/AuthProvider";
import RequireAuth from "./auth/RequireAuth";
import Header from "./components/Header"; import Header from "./components/Header";
import Footer from "./components/Footer"; import Footer from "./components/Footer";
import Mission from "./pages/Mission"; import Mission from "./pages/Mission";
import Chat from "./pages/Chat"; 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"; import "./App.css";
// Composition root: theme + router + layout shell. `/` is the chat // Composition root: theme router → auth → layout shell. `/` is the chat
// workspace (F3, anonymous for now); `/mission` (F2) is the EU-sovereignty // workspace (F3); `/mission` the EU-sovereignty narrative (F2); the auth +
// narrative; the auth/account routes (F4) land next. // account routes (F4) follow, with /account guarded.
export default function App() { export default function App() {
return ( return (
<ThemeProvider> <ThemeProvider>
<BrowserRouter> <BrowserRouter>
<div className="d-flex flex-column min-vh-100"> <AuthProvider>
<Header /> <div className="d-flex flex-column min-vh-100">
<Routes> <Header />
<Route path="/" element={<Chat />} /> <Routes>
<Route path="/mission" element={<Mission />} /> <Route path="/" element={<Chat />} />
</Routes> <Route path="/mission" element={<Mission />} />
<Footer /> <Route path="/login" element={<Login />} />
</div> <Route path="/register" element={<Register />} />
<Route path="/verify" element={<VerifyEmail />} />
<Route path="/forgot" element={<RequestReset />} />
<Route path="/reset" element={<ResetPassword />} />
<Route
path="/account"
element={
<RequireAuth>
<Dashboard />
</RequireAuth>
}
/>
<Route
path="/account/keys"
element={
<RequireAuth>
<ApiKeys />
</RequireAuth>
}
/>
</Routes>
<Footer />
</div>
</AuthProvider>
</BrowserRouter> </BrowserRouter>
</ThemeProvider> </ThemeProvider>
); );

View File

@@ -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<void>;
verify(token: string): Promise<void>;
login(email: string, password: string): Promise<Session>;
requestReset(email: string): Promise<void>;
confirmReset(token: string, newPassword: string): Promise<void>;
account(token: string): Promise<AccountBalance>;
listKeys(token: string): Promise<ApiKeySummary[]>;
createKey(
token: string,
label: string,
limitKind: "percent" | "hardcap",
limitValue: number,
): Promise<CreatedKey>;
archiveKey(token: string, id: string): Promise<void>;
updateKeyLimit(
token: string,
id: string,
limitKind: "percent" | "hardcap",
limitValue: number,
): Promise<void>;
redeem(token: string, code: string): Promise<AccountBalance>;
}
const BASE = (import.meta.env.VITE_ACCOUNT_BASE_URL || "/api").replace(/\/$/, "");
async function call<T>(
path: string,
init: RequestInit & { token?: string } = {},
): Promise<T> {
const headers: Record<string, string> = { "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<Session>("/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<AccountBalance>("/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<CreatedKey>("/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<AccountBalance>("/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<Session> {
return { token: "mock-token", expires_in: 604800 };
}
async requestReset() {}
async confirmReset() {}
async account(): Promise<AccountBalance> {
return {
account_id: "mock-account",
allocation_total: this.total,
allocation_spent: this.spent,
allocation_reserved: this.reserved,
};
}
async listKeys(): Promise<ApiKeySummary[]> {
return [...this.keys];
}
async createKey(
_t: string,
label: string,
limit_kind: "percent" | "hardcap",
limit_value: number,
): Promise<CreatedKey> {
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<AccountBalance> {
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;
}

View File

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

View File

@@ -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<string | null>(() =>
localStorage.getItem(TOKEN_KEY),
);
const [email, setEmail] = useState<string | null>(() =>
localStorage.getItem(EMAIL_KEY),
);
async function login(em: string, password: string): Promise<void> {
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<void> {
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 (
<AuthContext.Provider
value={{ token, email, status: token ? "authed" : "anon", login, register, logout }}
>
{children}
</AuthContext.Provider>
);
}

View File

@@ -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 <Navigate to={`/login?next=${next}`} replace />;
}
return <>{children}</>;
}

View File

@@ -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<void>;
register: (email: string, password: string) => Promise<void>;
logout: () => void;
}
export const AuthContext = createContext<AuthContextValue>({
token: null,
email: null,
status: "anon",
login: async () => {},
register: async () => {},
logout: () => {},
});
export function useAuth(): AuthContextValue {
return useContext(AuthContext);
}

View File

@@ -6,11 +6,12 @@ import { useTheme } from "../layout/theme";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { AUTONYM_MAP, type LanguageCode, isRtlLanguage } from "../i18n/languages"; import { AUTONYM_MAP, type LanguageCode, isRtlLanguage } from "../i18n/languages";
import { getLanguageOptionsByUsage } from "../i18n/translation-priority"; import { getLanguageOptionsByUsage } from "../i18n/translation-priority";
import { useAuth } from "../auth/context";
/** /**
* Top navigation: brand, primary routes (chat at `/`, `/mission`), an * Top navigation: brand, primary routes (chat at `/`, `/mission`), an
* auth-aware cluster (stubbed until F4 wires sessions), the theme toggle, * auth-aware cluster (Account/Sign out when signed in, else Sign in/up),
* and the language selector. * the theme toggle, and the language selector.
* *
* The language picker is ordered by **estimated usage** * The language picker is ordered by **estimated usage**
* (getLanguageOptionsByUsage), not alphabetically — a deliberate choice that * (getLanguageOptionsByUsage), not alphabetically — a deliberate choice that
@@ -21,6 +22,7 @@ import { getLanguageOptionsByUsage } from "../i18n/translation-priority";
const Header: React.FC = () => { const Header: React.FC = () => {
const { theme, toggleTheme } = useTheme(); const { theme, toggleTheme } = useTheme();
const { t, i18n } = useTranslation("common"); const { t, i18n } = useTranslation("common");
const { status, logout } = useAuth();
const currentLanguage: LanguageCode = (i18n.language.split("-")[0] || const currentLanguage: LanguageCode = (i18n.language.split("-")[0] ||
"en") as LanguageCode; "en") as LanguageCode;
@@ -75,13 +77,31 @@ const Header: React.FC = () => {
</Nav> </Nav>
<div className="d-flex align-items-center gap-2"> <div className="d-flex align-items-center gap-2">
{/* Auth cluster — plain links until F4 wires session state. */} {/* Auth-aware cluster. */}
<NavLink to="/login" className="nav-link"> {status === "authed" ? (
{t("nav.login")} <>
</NavLink> <NavLink to="/account" className="nav-link">
<NavLink to="/register" className="nav-link"> {t("nav.account")}
{t("nav.register")} </NavLink>
</NavLink> <Button
size="sm"
variant="outline-secondary"
onClick={logout}
className="me-1"
>
{t("nav.logout")}
</Button>
</>
) : (
<>
<NavLink to="/login" className="nav-link">
{t("nav.login")}
</NavLink>
<NavLink to="/register" className="nav-link">
{t("nav.register")}
</NavLink>
</>
)}
<Button <Button
size="sm" size="sm"

View File

@@ -13,131 +13,163 @@ import ruCommon from "./resources/ru/common.json";
import enMission from "./resources/en/mission.json"; import enMission from "./resources/en/mission.json";
import ruMission from "./resources/ru/mission.json"; import ruMission from "./resources/ru/mission.json";
import enChat from "./resources/en/chat.json"; import enChat from "./resources/en/chat.json";
import enAccount from "./resources/en/account.json";
import ruChat from "./resources/ru/chat.json"; import ruChat from "./resources/ru/chat.json";
import ruAccount from "./resources/ru/account.json";
// Scandinavian & Nordic languages // Scandinavian & Nordic languages
import daCommon from "./resources/da/common.json"; import daCommon from "./resources/da/common.json";
import daMission from "./resources/da/mission.json"; import daMission from "./resources/da/mission.json";
import daChat from "./resources/da/chat.json"; import daChat from "./resources/da/chat.json";
import daAccount from "./resources/da/account.json";
import fiCommon from "./resources/fi/common.json"; import fiCommon from "./resources/fi/common.json";
import fiMission from "./resources/fi/mission.json"; import fiMission from "./resources/fi/mission.json";
import fiChat from "./resources/fi/chat.json"; import fiChat from "./resources/fi/chat.json";
import fiAccount from "./resources/fi/account.json";
import noCommon from "./resources/no/common.json"; import noCommon from "./resources/no/common.json";
import noMission from "./resources/no/mission.json"; import noMission from "./resources/no/mission.json";
import noChat from "./resources/no/chat.json"; import noChat from "./resources/no/chat.json";
import noAccount from "./resources/no/account.json";
import svCommon from "./resources/sv/common.json"; import svCommon from "./resources/sv/common.json";
import svMission from "./resources/sv/mission.json"; import svMission from "./resources/sv/mission.json";
import svChat from "./resources/sv/chat.json"; import svChat from "./resources/sv/chat.json";
import svAccount from "./resources/sv/account.json";
import bgCommon from "./resources/bg/common.json"; import bgCommon from "./resources/bg/common.json";
import bgMission from "./resources/bg/mission.json"; import bgMission from "./resources/bg/mission.json";
import bgChat from "./resources/bg/chat.json"; import bgChat from "./resources/bg/chat.json";
import bgAccount from "./resources/bg/account.json";
import etCommon from "./resources/et/common.json"; import etCommon from "./resources/et/common.json";
import etMission from "./resources/et/mission.json"; import etMission from "./resources/et/mission.json";
import etChat from "./resources/et/chat.json"; import etChat from "./resources/et/chat.json";
import etAccount from "./resources/et/account.json";
// African & MENA languages // African & MENA languages
import swCommon from "./resources/sw/common.json"; import swCommon from "./resources/sw/common.json";
import swMission from "./resources/sw/mission.json"; import swMission from "./resources/sw/mission.json";
import swChat from "./resources/sw/chat.json"; import swChat from "./resources/sw/chat.json";
import swAccount from "./resources/sw/account.json";
import arCommon from "./resources/ar/common.json"; import arCommon from "./resources/ar/common.json";
import arMission from "./resources/ar/mission.json"; import arMission from "./resources/ar/mission.json";
import arChat from "./resources/ar/chat.json"; import arChat from "./resources/ar/chat.json";
import arAccount from "./resources/ar/account.json";
import faCommon from "./resources/fa/common.json"; import faCommon from "./resources/fa/common.json";
import faMission from "./resources/fa/mission.json"; import faMission from "./resources/fa/mission.json";
import faChat from "./resources/fa/chat.json"; import faChat from "./resources/fa/chat.json";
import faAccount from "./resources/fa/account.json";
import haCommon from "./resources/ha/common.json"; import haCommon from "./resources/ha/common.json";
import haMission from "./resources/ha/mission.json"; import haMission from "./resources/ha/mission.json";
import haChat from "./resources/ha/chat.json"; import haChat from "./resources/ha/chat.json";
import haAccount from "./resources/ha/account.json";
import amCommon from "./resources/am/common.json"; import amCommon from "./resources/am/common.json";
import amMission from "./resources/am/mission.json"; import amMission from "./resources/am/mission.json";
import amChat from "./resources/am/chat.json"; import amChat from "./resources/am/chat.json";
import amAccount from "./resources/am/account.json";
import yoCommon from "./resources/yo/common.json"; import yoCommon from "./resources/yo/common.json";
import yoMission from "./resources/yo/mission.json"; import yoMission from "./resources/yo/mission.json";
import yoChat from "./resources/yo/chat.json"; import yoChat from "./resources/yo/chat.json";
import yoAccount from "./resources/yo/account.json";
import zuCommon from "./resources/zu/common.json"; import zuCommon from "./resources/zu/common.json";
import zuMission from "./resources/zu/mission.json"; import zuMission from "./resources/zu/mission.json";
import zuChat from "./resources/zu/chat.json"; import zuChat from "./resources/zu/chat.json";
import zuAccount from "./resources/zu/account.json";
// Darija (Moroccan Arabic) // Darija (Moroccan Arabic)
import maCommon from "./resources/ma/common.json"; import maCommon from "./resources/ma/common.json";
import maMission from "./resources/ma/mission.json"; import maMission from "./resources/ma/mission.json";
import maChat from "./resources/ma/chat.json"; import maChat from "./resources/ma/chat.json";
import maAccount from "./resources/ma/account.json";
// European / other languages // European / other languages
import esCommon from "./resources/es/common.json"; import esCommon from "./resources/es/common.json";
import esMission from "./resources/es/mission.json"; import esMission from "./resources/es/mission.json";
import esChat from "./resources/es/chat.json"; import esChat from "./resources/es/chat.json";
import esAccount from "./resources/es/account.json";
import frCommon from "./resources/fr/common.json"; import frCommon from "./resources/fr/common.json";
import frMission from "./resources/fr/mission.json"; import frMission from "./resources/fr/mission.json";
import frChat from "./resources/fr/chat.json"; import frChat from "./resources/fr/chat.json";
import frAccount from "./resources/fr/account.json";
import deCommon from "./resources/de/common.json"; import deCommon from "./resources/de/common.json";
import deMission from "./resources/de/mission.json"; import deMission from "./resources/de/mission.json";
import deChat from "./resources/de/chat.json"; import deChat from "./resources/de/chat.json";
import deAccount from "./resources/de/account.json";
import elCommon from "./resources/el/common.json"; import elCommon from "./resources/el/common.json";
import elMission from "./resources/el/mission.json"; import elMission from "./resources/el/mission.json";
import elChat from "./resources/el/chat.json"; import elChat from "./resources/el/chat.json";
import elAccount from "./resources/el/account.json";
import itCommon from "./resources/it/common.json"; import itCommon from "./resources/it/common.json";
import itMission from "./resources/it/mission.json"; import itMission from "./resources/it/mission.json";
import itChat from "./resources/it/chat.json"; import itChat from "./resources/it/chat.json";
import itAccount from "./resources/it/account.json";
import heCommon from "./resources/he/common.json"; import heCommon from "./resources/he/common.json";
import heMission from "./resources/he/mission.json"; import heMission from "./resources/he/mission.json";
import heChat from "./resources/he/chat.json"; import heChat from "./resources/he/chat.json";
import heAccount from "./resources/he/account.json";
import ptCommon from "./resources/pt/common.json"; import ptCommon from "./resources/pt/common.json";
import ptMission from "./resources/pt/mission.json"; import ptMission from "./resources/pt/mission.json";
import ptChat from "./resources/pt/chat.json"; import ptChat from "./resources/pt/chat.json";
import ptAccount from "./resources/pt/account.json";
import roCommon from "./resources/ro/common.json"; import roCommon from "./resources/ro/common.json";
import roMission from "./resources/ro/mission.json"; import roMission from "./resources/ro/mission.json";
import roChat from "./resources/ro/chat.json"; import roChat from "./resources/ro/chat.json";
import roAccount from "./resources/ro/account.json";
import kaCommon from "./resources/ka/common.json"; import kaCommon from "./resources/ka/common.json";
import kaMission from "./resources/ka/mission.json"; import kaMission from "./resources/ka/mission.json";
import kaChat from "./resources/ka/chat.json"; import kaChat from "./resources/ka/chat.json";
import kaAccount from "./resources/ka/account.json";
import trCommon from "./resources/tr/common.json"; import trCommon from "./resources/tr/common.json";
import trMission from "./resources/tr/mission.json"; import trMission from "./resources/tr/mission.json";
import trChat from "./resources/tr/chat.json"; import trChat from "./resources/tr/chat.json";
import trAccount from "./resources/tr/account.json";
import plCommon from "./resources/pl/common.json"; import plCommon from "./resources/pl/common.json";
import plMission from "./resources/pl/mission.json"; import plMission from "./resources/pl/mission.json";
import plChat from "./resources/pl/chat.json"; import plChat from "./resources/pl/chat.json";
import plAccount from "./resources/pl/account.json";
import ukCommon from "./resources/uk/common.json"; import ukCommon from "./resources/uk/common.json";
import ukMission from "./resources/uk/mission.json"; import ukMission from "./resources/uk/mission.json";
import ukChat from "./resources/uk/chat.json"; import ukChat from "./resources/uk/chat.json";
import ukAccount from "./resources/uk/account.json";
import nlCommon from "./resources/nl/common.json"; import nlCommon from "./resources/nl/common.json";
import nlMission from "./resources/nl/mission.json"; import nlMission from "./resources/nl/mission.json";
import nlChat from "./resources/nl/chat.json"; import nlChat from "./resources/nl/chat.json";
import nlAccount from "./resources/nl/account.json";
import srCommon from "./resources/sr/common.json"; import srCommon from "./resources/sr/common.json";
import srMission from "./resources/sr/mission.json"; import srMission from "./resources/sr/mission.json";
import srChat from "./resources/sr/chat.json"; import srChat from "./resources/sr/chat.json";
import srAccount from "./resources/sr/account.json";
import kkCommon from "./resources/kk/common.json"; import kkCommon from "./resources/kk/common.json";
import kkMission from "./resources/kk/mission.json"; import kkMission from "./resources/kk/mission.json";
import kkChat from "./resources/kk/chat.json"; import kkChat from "./resources/kk/chat.json";
import kkAccount from "./resources/kk/account.json";
import uzCommon from "./resources/uz/common.json"; import uzCommon from "./resources/uz/common.json";
import uzMission from "./resources/uz/mission.json"; import uzMission from "./resources/uz/mission.json";
import uzChat from "./resources/uz/chat.json"; import uzChat from "./resources/uz/chat.json";
import uzAccount from "./resources/uz/account.json";
/** /**
* Application translation resources, split by language and namespace. * Application translation resources, split by language and namespace.
@@ -151,41 +183,49 @@ const resources: Resource = {
common: enCommon, common: enCommon,
mission: enMission, mission: enMission,
chat: enChat, chat: enChat,
account: enAccount,
}, },
ru: { ru: {
common: ruCommon, common: ruCommon,
mission: ruMission, mission: ruMission,
chat: ruChat, chat: ruChat,
account: ruAccount,
}, },
bg: { bg: {
common: bgCommon, common: bgCommon,
mission: bgMission, mission: bgMission,
chat: bgChat, chat: bgChat,
account: bgAccount,
}, },
da: { da: {
common: daCommon, common: daCommon,
mission: daMission, mission: daMission,
chat: daChat, chat: daChat,
account: daAccount,
}, },
et: { et: {
common: etCommon, common: etCommon,
mission: etMission, mission: etMission,
chat: etChat, chat: etChat,
account: etAccount,
}, },
fi: { fi: {
common: fiCommon, common: fiCommon,
mission: fiMission, mission: fiMission,
chat: fiChat, chat: fiChat,
account: fiAccount,
}, },
kk: { kk: {
common: kkCommon, common: kkCommon,
mission: kkMission, mission: kkMission,
chat: kkChat, chat: kkChat,
account: kkAccount,
}, },
uz: { uz: {
common: uzCommon, common: uzCommon,
mission: uzMission, mission: uzMission,
chat: uzChat, chat: uzChat,
account: uzAccount,
}, },
// African & MENA languages (LTR unless marked RTL via isRtlLanguage) // African & MENA languages (LTR unless marked RTL via isRtlLanguage)
@@ -193,41 +233,49 @@ const resources: Resource = {
common: swCommon, common: swCommon,
mission: swMission, mission: swMission,
chat: swChat, chat: swChat,
account: swAccount,
}, },
ar: { ar: {
common: arCommon, common: arCommon,
mission: arMission, mission: arMission,
chat: arChat, chat: arChat,
account: arAccount,
}, },
fa: { fa: {
common: faCommon, common: faCommon,
mission: faMission, mission: faMission,
chat: faChat, chat: faChat,
account: faAccount,
}, },
ha: { ha: {
common: haCommon, common: haCommon,
mission: haMission, mission: haMission,
chat: haChat, chat: haChat,
account: haAccount,
}, },
am: { am: {
common: amCommon, common: amCommon,
mission: amMission, mission: amMission,
chat: amChat, chat: amChat,
account: amAccount,
}, },
yo: { yo: {
common: yoCommon, common: yoCommon,
mission: yoMission, mission: yoMission,
chat: yoChat, chat: yoChat,
account: yoAccount,
}, },
zu: { zu: {
common: zuCommon, common: zuCommon,
mission: zuMission, mission: zuMission,
chat: zuChat, chat: zuChat,
account: zuAccount,
}, },
ma: { ma: {
common: maCommon, common: maCommon,
mission: maMission, mission: maMission,
chat: maChat, chat: maChat,
account: maAccount,
}, },
// European & other languages // European & other languages
@@ -235,81 +283,97 @@ const resources: Resource = {
common: esCommon, common: esCommon,
mission: esMission, mission: esMission,
chat: esChat, chat: esChat,
account: esAccount,
}, },
fr: { fr: {
common: frCommon, common: frCommon,
mission: frMission, mission: frMission,
chat: frChat, chat: frChat,
account: frAccount,
}, },
de: { de: {
common: deCommon, common: deCommon,
mission: deMission, mission: deMission,
chat: deChat, chat: deChat,
account: deAccount,
}, },
el: { el: {
common: elCommon, common: elCommon,
mission: elMission, mission: elMission,
chat: elChat, chat: elChat,
account: elAccount,
}, },
it: { it: {
common: itCommon, common: itCommon,
mission: itMission, mission: itMission,
chat: itChat, chat: itChat,
account: itAccount,
}, },
he: { he: {
common: heCommon, common: heCommon,
mission: heMission, mission: heMission,
chat: heChat, chat: heChat,
account: heAccount,
}, },
pt: { pt: {
common: ptCommon, common: ptCommon,
mission: ptMission, mission: ptMission,
chat: ptChat, chat: ptChat,
account: ptAccount,
}, },
ro: { ro: {
common: roCommon, common: roCommon,
mission: roMission, mission: roMission,
chat: roChat, chat: roChat,
account: roAccount,
}, },
ka: { ka: {
common: kaCommon, common: kaCommon,
mission: kaMission, mission: kaMission,
chat: kaChat, chat: kaChat,
account: kaAccount,
}, },
tr: { tr: {
common: trCommon, common: trCommon,
mission: trMission, mission: trMission,
chat: trChat, chat: trChat,
account: trAccount,
}, },
pl: { pl: {
common: plCommon, common: plCommon,
mission: plMission, mission: plMission,
chat: plChat, chat: plChat,
account: plAccount,
}, },
uk: { uk: {
common: ukCommon, common: ukCommon,
mission: ukMission, mission: ukMission,
chat: ukChat, chat: ukChat,
account: ukAccount,
}, },
nl: { nl: {
common: nlCommon, common: nlCommon,
mission: nlMission, mission: nlMission,
chat: nlChat, chat: nlChat,
account: nlAccount,
}, },
sr: { sr: {
common: srCommon, common: srCommon,
mission: srMission, mission: srMission,
chat: srChat, chat: srChat,
account: srAccount,
}, },
no: { no: {
common: noCommon, common: noCommon,
mission: noMission, mission: noMission,
chat: noChat, chat: noChat,
account: noAccount,
}, },
sv: { sv: {
common: svCommon, common: svCommon,
mission: svMission, mission: svMission,
chat: svChat, chat: svChat,
account: svAccount,
}, },
}; };
@@ -335,7 +399,7 @@ i18n.use(initReactI18next).init({
lng: browserLang, lng: browserLang,
fallbackLng: "en", fallbackLng: "en",
supportedLngs: SUPPORTED_LANGUAGES, supportedLngs: SUPPORTED_LANGUAGES,
ns: ["common", "mission", "chat"], ns: ["common", "mission", "chat", "account"],
defaultNS: "common", defaultNS: "common",
// Because we control the keys and interpolate only simple values. // Because we control the keys and interpolate only simple values.
interpolation: { interpolation: {

View File

@@ -0,0 +1,69 @@
{
"login": {
"title": "Sign in",
"email": "Email",
"password": "Password",
"submit": "Sign in",
"noAccount": "No account? Sign up"
},
"register": {
"title": "Create your account",
"email": "Email",
"password": "Password",
"submit": "Sign up",
"haveAccount": "Already have an account? Sign in",
"checkEmail": "Almost there — check your email to verify your account."
},
"verify": {
"verifying": "Verifying…",
"ok": "Email verified. You can now sign in.",
"failed": "This verification link is invalid or has expired.",
"toLogin": "Go to sign in"
},
"reset": {
"requestTitle": "Reset your password",
"email": "Email",
"requestSubmit": "Send reset link",
"requestDone": "If that email has an account, a reset link is on its way.",
"confirmTitle": "Choose a new password",
"newPassword": "New password",
"confirmSubmit": "Set password",
"ok": "Password updated. You can now sign in."
},
"dashboard": {
"title": "Account",
"balance": "Allocation",
"total": "Total",
"spent": "Spent",
"reserved": "Reserved",
"remaining": "Remaining",
"manageKeys": "Manage API keys",
"redeemTitle": "Redeem a top-up code",
"redeemPlaceholder": "helexa-topup-…",
"redeem": "Redeem",
"redeemed": "Code redeemed.",
"logout": "Sign out"
},
"keys": {
"title": "API keys",
"create": "Create key",
"label": "Label",
"limitKind": "Limit",
"percent": "% of allocation",
"hardcap": "Hard cap (tokens)",
"value": "Value",
"none": "No keys yet.",
"createdTitle": "Your new API key",
"createdWarn": "Copy it now — you won't see it again.",
"copy": "Copy",
"copied": "Copied",
"archive": "Archive",
"save": "Save",
"status": "Status",
"usage": "Used"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -0,0 +1,69 @@
{
"login": {
"title": "Sign in",
"email": "Email",
"password": "Password",
"submit": "Sign in",
"noAccount": "No account? Sign up"
},
"register": {
"title": "Create your account",
"email": "Email",
"password": "Password",
"submit": "Sign up",
"haveAccount": "Already have an account? Sign in",
"checkEmail": "Almost there — check your email to verify your account."
},
"verify": {
"verifying": "Verifying…",
"ok": "Email verified. You can now sign in.",
"failed": "This verification link is invalid or has expired.",
"toLogin": "Go to sign in"
},
"reset": {
"requestTitle": "Reset your password",
"email": "Email",
"requestSubmit": "Send reset link",
"requestDone": "If that email has an account, a reset link is on its way.",
"confirmTitle": "Choose a new password",
"newPassword": "New password",
"confirmSubmit": "Set password",
"ok": "Password updated. You can now sign in."
},
"dashboard": {
"title": "Account",
"balance": "Allocation",
"total": "Total",
"spent": "Spent",
"reserved": "Reserved",
"remaining": "Remaining",
"manageKeys": "Manage API keys",
"redeemTitle": "Redeem a top-up code",
"redeemPlaceholder": "helexa-topup-…",
"redeem": "Redeem",
"redeemed": "Code redeemed.",
"logout": "Sign out"
},
"keys": {
"title": "API keys",
"create": "Create key",
"label": "Label",
"limitKind": "Limit",
"percent": "% of allocation",
"hardcap": "Hard cap (tokens)",
"value": "Value",
"none": "No keys yet.",
"createdTitle": "Your new API key",
"createdWarn": "Copy it now — you won't see it again.",
"copy": "Copy",
"copied": "Copied",
"archive": "Archive",
"save": "Save",
"status": "Status",
"usage": "Used"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -0,0 +1,69 @@
{
"login": {
"title": "Sign in",
"email": "Email",
"password": "Password",
"submit": "Sign in",
"noAccount": "No account? Sign up"
},
"register": {
"title": "Create your account",
"email": "Email",
"password": "Password",
"submit": "Sign up",
"haveAccount": "Already have an account? Sign in",
"checkEmail": "Almost there — check your email to verify your account."
},
"verify": {
"verifying": "Verifying…",
"ok": "Email verified. You can now sign in.",
"failed": "This verification link is invalid or has expired.",
"toLogin": "Go to sign in"
},
"reset": {
"requestTitle": "Reset your password",
"email": "Email",
"requestSubmit": "Send reset link",
"requestDone": "If that email has an account, a reset link is on its way.",
"confirmTitle": "Choose a new password",
"newPassword": "New password",
"confirmSubmit": "Set password",
"ok": "Password updated. You can now sign in."
},
"dashboard": {
"title": "Account",
"balance": "Allocation",
"total": "Total",
"spent": "Spent",
"reserved": "Reserved",
"remaining": "Remaining",
"manageKeys": "Manage API keys",
"redeemTitle": "Redeem a top-up code",
"redeemPlaceholder": "helexa-topup-…",
"redeem": "Redeem",
"redeemed": "Code redeemed.",
"logout": "Sign out"
},
"keys": {
"title": "API keys",
"create": "Create key",
"label": "Label",
"limitKind": "Limit",
"percent": "% of allocation",
"hardcap": "Hard cap (tokens)",
"value": "Value",
"none": "No keys yet.",
"createdTitle": "Your new API key",
"createdWarn": "Copy it now — you won't see it again.",
"copy": "Copy",
"copied": "Copied",
"archive": "Archive",
"save": "Save",
"status": "Status",
"usage": "Used"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -0,0 +1,69 @@
{
"login": {
"title": "Sign in",
"email": "Email",
"password": "Password",
"submit": "Sign in",
"noAccount": "No account? Sign up"
},
"register": {
"title": "Create your account",
"email": "Email",
"password": "Password",
"submit": "Sign up",
"haveAccount": "Already have an account? Sign in",
"checkEmail": "Almost there — check your email to verify your account."
},
"verify": {
"verifying": "Verifying…",
"ok": "Email verified. You can now sign in.",
"failed": "This verification link is invalid or has expired.",
"toLogin": "Go to sign in"
},
"reset": {
"requestTitle": "Reset your password",
"email": "Email",
"requestSubmit": "Send reset link",
"requestDone": "If that email has an account, a reset link is on its way.",
"confirmTitle": "Choose a new password",
"newPassword": "New password",
"confirmSubmit": "Set password",
"ok": "Password updated. You can now sign in."
},
"dashboard": {
"title": "Account",
"balance": "Allocation",
"total": "Total",
"spent": "Spent",
"reserved": "Reserved",
"remaining": "Remaining",
"manageKeys": "Manage API keys",
"redeemTitle": "Redeem a top-up code",
"redeemPlaceholder": "helexa-topup-…",
"redeem": "Redeem",
"redeemed": "Code redeemed.",
"logout": "Sign out"
},
"keys": {
"title": "API keys",
"create": "Create key",
"label": "Label",
"limitKind": "Limit",
"percent": "% of allocation",
"hardcap": "Hard cap (tokens)",
"value": "Value",
"none": "No keys yet.",
"createdTitle": "Your new API key",
"createdWarn": "Copy it now — you won't see it again.",
"copy": "Copy",
"copied": "Copied",
"archive": "Archive",
"save": "Save",
"status": "Status",
"usage": "Used"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -0,0 +1,69 @@
{
"login": {
"title": "Sign in",
"email": "Email",
"password": "Password",
"submit": "Sign in",
"noAccount": "No account? Sign up"
},
"register": {
"title": "Create your account",
"email": "Email",
"password": "Password",
"submit": "Sign up",
"haveAccount": "Already have an account? Sign in",
"checkEmail": "Almost there — check your email to verify your account."
},
"verify": {
"verifying": "Verifying…",
"ok": "Email verified. You can now sign in.",
"failed": "This verification link is invalid or has expired.",
"toLogin": "Go to sign in"
},
"reset": {
"requestTitle": "Reset your password",
"email": "Email",
"requestSubmit": "Send reset link",
"requestDone": "If that email has an account, a reset link is on its way.",
"confirmTitle": "Choose a new password",
"newPassword": "New password",
"confirmSubmit": "Set password",
"ok": "Password updated. You can now sign in."
},
"dashboard": {
"title": "Account",
"balance": "Allocation",
"total": "Total",
"spent": "Spent",
"reserved": "Reserved",
"remaining": "Remaining",
"manageKeys": "Manage API keys",
"redeemTitle": "Redeem a top-up code",
"redeemPlaceholder": "helexa-topup-…",
"redeem": "Redeem",
"redeemed": "Code redeemed.",
"logout": "Sign out"
},
"keys": {
"title": "API keys",
"create": "Create key",
"label": "Label",
"limitKind": "Limit",
"percent": "% of allocation",
"hardcap": "Hard cap (tokens)",
"value": "Value",
"none": "No keys yet.",
"createdTitle": "Your new API key",
"createdWarn": "Copy it now — you won't see it again.",
"copy": "Copy",
"copied": "Copied",
"archive": "Archive",
"save": "Save",
"status": "Status",
"usage": "Used"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -0,0 +1,69 @@
{
"login": {
"title": "Sign in",
"email": "Email",
"password": "Password",
"submit": "Sign in",
"noAccount": "No account? Sign up"
},
"register": {
"title": "Create your account",
"email": "Email",
"password": "Password",
"submit": "Sign up",
"haveAccount": "Already have an account? Sign in",
"checkEmail": "Almost there — check your email to verify your account."
},
"verify": {
"verifying": "Verifying…",
"ok": "Email verified. You can now sign in.",
"failed": "This verification link is invalid or has expired.",
"toLogin": "Go to sign in"
},
"reset": {
"requestTitle": "Reset your password",
"email": "Email",
"requestSubmit": "Send reset link",
"requestDone": "If that email has an account, a reset link is on its way.",
"confirmTitle": "Choose a new password",
"newPassword": "New password",
"confirmSubmit": "Set password",
"ok": "Password updated. You can now sign in."
},
"dashboard": {
"title": "Account",
"balance": "Allocation",
"total": "Total",
"spent": "Spent",
"reserved": "Reserved",
"remaining": "Remaining",
"manageKeys": "Manage API keys",
"redeemTitle": "Redeem a top-up code",
"redeemPlaceholder": "helexa-topup-…",
"redeem": "Redeem",
"redeemed": "Code redeemed.",
"logout": "Sign out"
},
"keys": {
"title": "API keys",
"create": "Create key",
"label": "Label",
"limitKind": "Limit",
"percent": "% of allocation",
"hardcap": "Hard cap (tokens)",
"value": "Value",
"none": "No keys yet.",
"createdTitle": "Your new API key",
"createdWarn": "Copy it now — you won't see it again.",
"copy": "Copy",
"copied": "Copied",
"archive": "Archive",
"save": "Save",
"status": "Status",
"usage": "Used"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -0,0 +1,69 @@
{
"login": {
"title": "Sign in",
"email": "Email",
"password": "Password",
"submit": "Sign in",
"noAccount": "No account? Sign up"
},
"register": {
"title": "Create your account",
"email": "Email",
"password": "Password",
"submit": "Sign up",
"haveAccount": "Already have an account? Sign in",
"checkEmail": "Almost there — check your email to verify your account."
},
"verify": {
"verifying": "Verifying…",
"ok": "Email verified. You can now sign in.",
"failed": "This verification link is invalid or has expired.",
"toLogin": "Go to sign in"
},
"reset": {
"requestTitle": "Reset your password",
"email": "Email",
"requestSubmit": "Send reset link",
"requestDone": "If that email has an account, a reset link is on its way.",
"confirmTitle": "Choose a new password",
"newPassword": "New password",
"confirmSubmit": "Set password",
"ok": "Password updated. You can now sign in."
},
"dashboard": {
"title": "Account",
"balance": "Allocation",
"total": "Total",
"spent": "Spent",
"reserved": "Reserved",
"remaining": "Remaining",
"manageKeys": "Manage API keys",
"redeemTitle": "Redeem a top-up code",
"redeemPlaceholder": "helexa-topup-…",
"redeem": "Redeem",
"redeemed": "Code redeemed.",
"logout": "Sign out"
},
"keys": {
"title": "API keys",
"create": "Create key",
"label": "Label",
"limitKind": "Limit",
"percent": "% of allocation",
"hardcap": "Hard cap (tokens)",
"value": "Value",
"none": "No keys yet.",
"createdTitle": "Your new API key",
"createdWarn": "Copy it now — you won't see it again.",
"copy": "Copy",
"copied": "Copied",
"archive": "Archive",
"save": "Save",
"status": "Status",
"usage": "Used"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -0,0 +1,69 @@
{
"login": {
"title": "Sign in",
"email": "Email",
"password": "Password",
"submit": "Sign in",
"noAccount": "No account? Sign up"
},
"register": {
"title": "Create your account",
"email": "Email",
"password": "Password",
"submit": "Sign up",
"haveAccount": "Already have an account? Sign in",
"checkEmail": "Almost there — check your email to verify your account."
},
"verify": {
"verifying": "Verifying…",
"ok": "Email verified. You can now sign in.",
"failed": "This verification link is invalid or has expired.",
"toLogin": "Go to sign in"
},
"reset": {
"requestTitle": "Reset your password",
"email": "Email",
"requestSubmit": "Send reset link",
"requestDone": "If that email has an account, a reset link is on its way.",
"confirmTitle": "Choose a new password",
"newPassword": "New password",
"confirmSubmit": "Set password",
"ok": "Password updated. You can now sign in."
},
"dashboard": {
"title": "Account",
"balance": "Allocation",
"total": "Total",
"spent": "Spent",
"reserved": "Reserved",
"remaining": "Remaining",
"manageKeys": "Manage API keys",
"redeemTitle": "Redeem a top-up code",
"redeemPlaceholder": "helexa-topup-…",
"redeem": "Redeem",
"redeemed": "Code redeemed.",
"logout": "Sign out"
},
"keys": {
"title": "API keys",
"create": "Create key",
"label": "Label",
"limitKind": "Limit",
"percent": "% of allocation",
"hardcap": "Hard cap (tokens)",
"value": "Value",
"none": "No keys yet.",
"createdTitle": "Your new API key",
"createdWarn": "Copy it now — you won't see it again.",
"copy": "Copy",
"copied": "Copied",
"archive": "Archive",
"save": "Save",
"status": "Status",
"usage": "Used"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -0,0 +1,69 @@
{
"login": {
"title": "Sign in",
"email": "Email",
"password": "Password",
"submit": "Sign in",
"noAccount": "No account? Sign up"
},
"register": {
"title": "Create your account",
"email": "Email",
"password": "Password",
"submit": "Sign up",
"haveAccount": "Already have an account? Sign in",
"checkEmail": "Almost there — check your email to verify your account."
},
"verify": {
"verifying": "Verifying…",
"ok": "Email verified. You can now sign in.",
"failed": "This verification link is invalid or has expired.",
"toLogin": "Go to sign in"
},
"reset": {
"requestTitle": "Reset your password",
"email": "Email",
"requestSubmit": "Send reset link",
"requestDone": "If that email has an account, a reset link is on its way.",
"confirmTitle": "Choose a new password",
"newPassword": "New password",
"confirmSubmit": "Set password",
"ok": "Password updated. You can now sign in."
},
"dashboard": {
"title": "Account",
"balance": "Allocation",
"total": "Total",
"spent": "Spent",
"reserved": "Reserved",
"remaining": "Remaining",
"manageKeys": "Manage API keys",
"redeemTitle": "Redeem a top-up code",
"redeemPlaceholder": "helexa-topup-…",
"redeem": "Redeem",
"redeemed": "Code redeemed.",
"logout": "Sign out"
},
"keys": {
"title": "API keys",
"create": "Create key",
"label": "Label",
"limitKind": "Limit",
"percent": "% of allocation",
"hardcap": "Hard cap (tokens)",
"value": "Value",
"none": "No keys yet.",
"createdTitle": "Your new API key",
"createdWarn": "Copy it now — you won't see it again.",
"copy": "Copy",
"copied": "Copied",
"archive": "Archive",
"save": "Save",
"status": "Status",
"usage": "Used"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -0,0 +1,69 @@
{
"login": {
"title": "Sign in",
"email": "Email",
"password": "Password",
"submit": "Sign in",
"noAccount": "No account? Sign up"
},
"register": {
"title": "Create your account",
"email": "Email",
"password": "Password",
"submit": "Sign up",
"haveAccount": "Already have an account? Sign in",
"checkEmail": "Almost there — check your email to verify your account."
},
"verify": {
"verifying": "Verifying…",
"ok": "Email verified. You can now sign in.",
"failed": "This verification link is invalid or has expired.",
"toLogin": "Go to sign in"
},
"reset": {
"requestTitle": "Reset your password",
"email": "Email",
"requestSubmit": "Send reset link",
"requestDone": "If that email has an account, a reset link is on its way.",
"confirmTitle": "Choose a new password",
"newPassword": "New password",
"confirmSubmit": "Set password",
"ok": "Password updated. You can now sign in."
},
"dashboard": {
"title": "Account",
"balance": "Allocation",
"total": "Total",
"spent": "Spent",
"reserved": "Reserved",
"remaining": "Remaining",
"manageKeys": "Manage API keys",
"redeemTitle": "Redeem a top-up code",
"redeemPlaceholder": "helexa-topup-…",
"redeem": "Redeem",
"redeemed": "Code redeemed.",
"logout": "Sign out"
},
"keys": {
"title": "API keys",
"create": "Create key",
"label": "Label",
"limitKind": "Limit",
"percent": "% of allocation",
"hardcap": "Hard cap (tokens)",
"value": "Value",
"none": "No keys yet.",
"createdTitle": "Your new API key",
"createdWarn": "Copy it now — you won't see it again.",
"copy": "Copy",
"copied": "Copied",
"archive": "Archive",
"save": "Save",
"status": "Status",
"usage": "Used"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -0,0 +1,69 @@
{
"login": {
"title": "Sign in",
"email": "Email",
"password": "Password",
"submit": "Sign in",
"noAccount": "No account? Sign up"
},
"register": {
"title": "Create your account",
"email": "Email",
"password": "Password",
"submit": "Sign up",
"haveAccount": "Already have an account? Sign in",
"checkEmail": "Almost there — check your email to verify your account."
},
"verify": {
"verifying": "Verifying…",
"ok": "Email verified. You can now sign in.",
"failed": "This verification link is invalid or has expired.",
"toLogin": "Go to sign in"
},
"reset": {
"requestTitle": "Reset your password",
"email": "Email",
"requestSubmit": "Send reset link",
"requestDone": "If that email has an account, a reset link is on its way.",
"confirmTitle": "Choose a new password",
"newPassword": "New password",
"confirmSubmit": "Set password",
"ok": "Password updated. You can now sign in."
},
"dashboard": {
"title": "Account",
"balance": "Allocation",
"total": "Total",
"spent": "Spent",
"reserved": "Reserved",
"remaining": "Remaining",
"manageKeys": "Manage API keys",
"redeemTitle": "Redeem a top-up code",
"redeemPlaceholder": "helexa-topup-…",
"redeem": "Redeem",
"redeemed": "Code redeemed.",
"logout": "Sign out"
},
"keys": {
"title": "API keys",
"create": "Create key",
"label": "Label",
"limitKind": "Limit",
"percent": "% of allocation",
"hardcap": "Hard cap (tokens)",
"value": "Value",
"none": "No keys yet.",
"createdTitle": "Your new API key",
"createdWarn": "Copy it now — you won't see it again.",
"copy": "Copy",
"copied": "Copied",
"archive": "Archive",
"save": "Save",
"status": "Status",
"usage": "Used"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -0,0 +1,69 @@
{
"login": {
"title": "Sign in",
"email": "Email",
"password": "Password",
"submit": "Sign in",
"noAccount": "No account? Sign up"
},
"register": {
"title": "Create your account",
"email": "Email",
"password": "Password",
"submit": "Sign up",
"haveAccount": "Already have an account? Sign in",
"checkEmail": "Almost there — check your email to verify your account."
},
"verify": {
"verifying": "Verifying…",
"ok": "Email verified. You can now sign in.",
"failed": "This verification link is invalid or has expired.",
"toLogin": "Go to sign in"
},
"reset": {
"requestTitle": "Reset your password",
"email": "Email",
"requestSubmit": "Send reset link",
"requestDone": "If that email has an account, a reset link is on its way.",
"confirmTitle": "Choose a new password",
"newPassword": "New password",
"confirmSubmit": "Set password",
"ok": "Password updated. You can now sign in."
},
"dashboard": {
"title": "Account",
"balance": "Allocation",
"total": "Total",
"spent": "Spent",
"reserved": "Reserved",
"remaining": "Remaining",
"manageKeys": "Manage API keys",
"redeemTitle": "Redeem a top-up code",
"redeemPlaceholder": "helexa-topup-…",
"redeem": "Redeem",
"redeemed": "Code redeemed.",
"logout": "Sign out"
},
"keys": {
"title": "API keys",
"create": "Create key",
"label": "Label",
"limitKind": "Limit",
"percent": "% of allocation",
"hardcap": "Hard cap (tokens)",
"value": "Value",
"none": "No keys yet.",
"createdTitle": "Your new API key",
"createdWarn": "Copy it now — you won't see it again.",
"copy": "Copy",
"copied": "Copied",
"archive": "Archive",
"save": "Save",
"status": "Status",
"usage": "Used"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -0,0 +1,69 @@
{
"login": {
"title": "Sign in",
"email": "Email",
"password": "Password",
"submit": "Sign in",
"noAccount": "No account? Sign up"
},
"register": {
"title": "Create your account",
"email": "Email",
"password": "Password",
"submit": "Sign up",
"haveAccount": "Already have an account? Sign in",
"checkEmail": "Almost there — check your email to verify your account."
},
"verify": {
"verifying": "Verifying…",
"ok": "Email verified. You can now sign in.",
"failed": "This verification link is invalid or has expired.",
"toLogin": "Go to sign in"
},
"reset": {
"requestTitle": "Reset your password",
"email": "Email",
"requestSubmit": "Send reset link",
"requestDone": "If that email has an account, a reset link is on its way.",
"confirmTitle": "Choose a new password",
"newPassword": "New password",
"confirmSubmit": "Set password",
"ok": "Password updated. You can now sign in."
},
"dashboard": {
"title": "Account",
"balance": "Allocation",
"total": "Total",
"spent": "Spent",
"reserved": "Reserved",
"remaining": "Remaining",
"manageKeys": "Manage API keys",
"redeemTitle": "Redeem a top-up code",
"redeemPlaceholder": "helexa-topup-…",
"redeem": "Redeem",
"redeemed": "Code redeemed.",
"logout": "Sign out"
},
"keys": {
"title": "API keys",
"create": "Create key",
"label": "Label",
"limitKind": "Limit",
"percent": "% of allocation",
"hardcap": "Hard cap (tokens)",
"value": "Value",
"none": "No keys yet.",
"createdTitle": "Your new API key",
"createdWarn": "Copy it now — you won't see it again.",
"copy": "Copy",
"copied": "Copied",
"archive": "Archive",
"save": "Save",
"status": "Status",
"usage": "Used"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -0,0 +1,69 @@
{
"login": {
"title": "Sign in",
"email": "Email",
"password": "Password",
"submit": "Sign in",
"noAccount": "No account? Sign up"
},
"register": {
"title": "Create your account",
"email": "Email",
"password": "Password",
"submit": "Sign up",
"haveAccount": "Already have an account? Sign in",
"checkEmail": "Almost there — check your email to verify your account."
},
"verify": {
"verifying": "Verifying…",
"ok": "Email verified. You can now sign in.",
"failed": "This verification link is invalid or has expired.",
"toLogin": "Go to sign in"
},
"reset": {
"requestTitle": "Reset your password",
"email": "Email",
"requestSubmit": "Send reset link",
"requestDone": "If that email has an account, a reset link is on its way.",
"confirmTitle": "Choose a new password",
"newPassword": "New password",
"confirmSubmit": "Set password",
"ok": "Password updated. You can now sign in."
},
"dashboard": {
"title": "Account",
"balance": "Allocation",
"total": "Total",
"spent": "Spent",
"reserved": "Reserved",
"remaining": "Remaining",
"manageKeys": "Manage API keys",
"redeemTitle": "Redeem a top-up code",
"redeemPlaceholder": "helexa-topup-…",
"redeem": "Redeem",
"redeemed": "Code redeemed.",
"logout": "Sign out"
},
"keys": {
"title": "API keys",
"create": "Create key",
"label": "Label",
"limitKind": "Limit",
"percent": "% of allocation",
"hardcap": "Hard cap (tokens)",
"value": "Value",
"none": "No keys yet.",
"createdTitle": "Your new API key",
"createdWarn": "Copy it now — you won't see it again.",
"copy": "Copy",
"copied": "Copied",
"archive": "Archive",
"save": "Save",
"status": "Status",
"usage": "Used"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -0,0 +1,69 @@
{
"login": {
"title": "Sign in",
"email": "Email",
"password": "Password",
"submit": "Sign in",
"noAccount": "No account? Sign up"
},
"register": {
"title": "Create your account",
"email": "Email",
"password": "Password",
"submit": "Sign up",
"haveAccount": "Already have an account? Sign in",
"checkEmail": "Almost there — check your email to verify your account."
},
"verify": {
"verifying": "Verifying…",
"ok": "Email verified. You can now sign in.",
"failed": "This verification link is invalid or has expired.",
"toLogin": "Go to sign in"
},
"reset": {
"requestTitle": "Reset your password",
"email": "Email",
"requestSubmit": "Send reset link",
"requestDone": "If that email has an account, a reset link is on its way.",
"confirmTitle": "Choose a new password",
"newPassword": "New password",
"confirmSubmit": "Set password",
"ok": "Password updated. You can now sign in."
},
"dashboard": {
"title": "Account",
"balance": "Allocation",
"total": "Total",
"spent": "Spent",
"reserved": "Reserved",
"remaining": "Remaining",
"manageKeys": "Manage API keys",
"redeemTitle": "Redeem a top-up code",
"redeemPlaceholder": "helexa-topup-…",
"redeem": "Redeem",
"redeemed": "Code redeemed.",
"logout": "Sign out"
},
"keys": {
"title": "API keys",
"create": "Create key",
"label": "Label",
"limitKind": "Limit",
"percent": "% of allocation",
"hardcap": "Hard cap (tokens)",
"value": "Value",
"none": "No keys yet.",
"createdTitle": "Your new API key",
"createdWarn": "Copy it now — you won't see it again.",
"copy": "Copy",
"copied": "Copied",
"archive": "Archive",
"save": "Save",
"status": "Status",
"usage": "Used"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -0,0 +1,69 @@
{
"login": {
"title": "Sign in",
"email": "Email",
"password": "Password",
"submit": "Sign in",
"noAccount": "No account? Sign up"
},
"register": {
"title": "Create your account",
"email": "Email",
"password": "Password",
"submit": "Sign up",
"haveAccount": "Already have an account? Sign in",
"checkEmail": "Almost there — check your email to verify your account."
},
"verify": {
"verifying": "Verifying…",
"ok": "Email verified. You can now sign in.",
"failed": "This verification link is invalid or has expired.",
"toLogin": "Go to sign in"
},
"reset": {
"requestTitle": "Reset your password",
"email": "Email",
"requestSubmit": "Send reset link",
"requestDone": "If that email has an account, a reset link is on its way.",
"confirmTitle": "Choose a new password",
"newPassword": "New password",
"confirmSubmit": "Set password",
"ok": "Password updated. You can now sign in."
},
"dashboard": {
"title": "Account",
"balance": "Allocation",
"total": "Total",
"spent": "Spent",
"reserved": "Reserved",
"remaining": "Remaining",
"manageKeys": "Manage API keys",
"redeemTitle": "Redeem a top-up code",
"redeemPlaceholder": "helexa-topup-…",
"redeem": "Redeem",
"redeemed": "Code redeemed.",
"logout": "Sign out"
},
"keys": {
"title": "API keys",
"create": "Create key",
"label": "Label",
"limitKind": "Limit",
"percent": "% of allocation",
"hardcap": "Hard cap (tokens)",
"value": "Value",
"none": "No keys yet.",
"createdTitle": "Your new API key",
"createdWarn": "Copy it now — you won't see it again.",
"copy": "Copy",
"copied": "Copied",
"archive": "Archive",
"save": "Save",
"status": "Status",
"usage": "Used"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -0,0 +1,69 @@
{
"login": {
"title": "Sign in",
"email": "Email",
"password": "Password",
"submit": "Sign in",
"noAccount": "No account? Sign up"
},
"register": {
"title": "Create your account",
"email": "Email",
"password": "Password",
"submit": "Sign up",
"haveAccount": "Already have an account? Sign in",
"checkEmail": "Almost there — check your email to verify your account."
},
"verify": {
"verifying": "Verifying…",
"ok": "Email verified. You can now sign in.",
"failed": "This verification link is invalid or has expired.",
"toLogin": "Go to sign in"
},
"reset": {
"requestTitle": "Reset your password",
"email": "Email",
"requestSubmit": "Send reset link",
"requestDone": "If that email has an account, a reset link is on its way.",
"confirmTitle": "Choose a new password",
"newPassword": "New password",
"confirmSubmit": "Set password",
"ok": "Password updated. You can now sign in."
},
"dashboard": {
"title": "Account",
"balance": "Allocation",
"total": "Total",
"spent": "Spent",
"reserved": "Reserved",
"remaining": "Remaining",
"manageKeys": "Manage API keys",
"redeemTitle": "Redeem a top-up code",
"redeemPlaceholder": "helexa-topup-…",
"redeem": "Redeem",
"redeemed": "Code redeemed.",
"logout": "Sign out"
},
"keys": {
"title": "API keys",
"create": "Create key",
"label": "Label",
"limitKind": "Limit",
"percent": "% of allocation",
"hardcap": "Hard cap (tokens)",
"value": "Value",
"none": "No keys yet.",
"createdTitle": "Your new API key",
"createdWarn": "Copy it now — you won't see it again.",
"copy": "Copy",
"copied": "Copied",
"archive": "Archive",
"save": "Save",
"status": "Status",
"usage": "Used"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -0,0 +1,69 @@
{
"login": {
"title": "Sign in",
"email": "Email",
"password": "Password",
"submit": "Sign in",
"noAccount": "No account? Sign up"
},
"register": {
"title": "Create your account",
"email": "Email",
"password": "Password",
"submit": "Sign up",
"haveAccount": "Already have an account? Sign in",
"checkEmail": "Almost there — check your email to verify your account."
},
"verify": {
"verifying": "Verifying…",
"ok": "Email verified. You can now sign in.",
"failed": "This verification link is invalid or has expired.",
"toLogin": "Go to sign in"
},
"reset": {
"requestTitle": "Reset your password",
"email": "Email",
"requestSubmit": "Send reset link",
"requestDone": "If that email has an account, a reset link is on its way.",
"confirmTitle": "Choose a new password",
"newPassword": "New password",
"confirmSubmit": "Set password",
"ok": "Password updated. You can now sign in."
},
"dashboard": {
"title": "Account",
"balance": "Allocation",
"total": "Total",
"spent": "Spent",
"reserved": "Reserved",
"remaining": "Remaining",
"manageKeys": "Manage API keys",
"redeemTitle": "Redeem a top-up code",
"redeemPlaceholder": "helexa-topup-…",
"redeem": "Redeem",
"redeemed": "Code redeemed.",
"logout": "Sign out"
},
"keys": {
"title": "API keys",
"create": "Create key",
"label": "Label",
"limitKind": "Limit",
"percent": "% of allocation",
"hardcap": "Hard cap (tokens)",
"value": "Value",
"none": "No keys yet.",
"createdTitle": "Your new API key",
"createdWarn": "Copy it now — you won't see it again.",
"copy": "Copy",
"copied": "Copied",
"archive": "Archive",
"save": "Save",
"status": "Status",
"usage": "Used"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -0,0 +1,69 @@
{
"login": {
"title": "Sign in",
"email": "Email",
"password": "Password",
"submit": "Sign in",
"noAccount": "No account? Sign up"
},
"register": {
"title": "Create your account",
"email": "Email",
"password": "Password",
"submit": "Sign up",
"haveAccount": "Already have an account? Sign in",
"checkEmail": "Almost there — check your email to verify your account."
},
"verify": {
"verifying": "Verifying…",
"ok": "Email verified. You can now sign in.",
"failed": "This verification link is invalid or has expired.",
"toLogin": "Go to sign in"
},
"reset": {
"requestTitle": "Reset your password",
"email": "Email",
"requestSubmit": "Send reset link",
"requestDone": "If that email has an account, a reset link is on its way.",
"confirmTitle": "Choose a new password",
"newPassword": "New password",
"confirmSubmit": "Set password",
"ok": "Password updated. You can now sign in."
},
"dashboard": {
"title": "Account",
"balance": "Allocation",
"total": "Total",
"spent": "Spent",
"reserved": "Reserved",
"remaining": "Remaining",
"manageKeys": "Manage API keys",
"redeemTitle": "Redeem a top-up code",
"redeemPlaceholder": "helexa-topup-…",
"redeem": "Redeem",
"redeemed": "Code redeemed.",
"logout": "Sign out"
},
"keys": {
"title": "API keys",
"create": "Create key",
"label": "Label",
"limitKind": "Limit",
"percent": "% of allocation",
"hardcap": "Hard cap (tokens)",
"value": "Value",
"none": "No keys yet.",
"createdTitle": "Your new API key",
"createdWarn": "Copy it now — you won't see it again.",
"copy": "Copy",
"copied": "Copied",
"archive": "Archive",
"save": "Save",
"status": "Status",
"usage": "Used"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -0,0 +1,69 @@
{
"login": {
"title": "Sign in",
"email": "Email",
"password": "Password",
"submit": "Sign in",
"noAccount": "No account? Sign up"
},
"register": {
"title": "Create your account",
"email": "Email",
"password": "Password",
"submit": "Sign up",
"haveAccount": "Already have an account? Sign in",
"checkEmail": "Almost there — check your email to verify your account."
},
"verify": {
"verifying": "Verifying…",
"ok": "Email verified. You can now sign in.",
"failed": "This verification link is invalid or has expired.",
"toLogin": "Go to sign in"
},
"reset": {
"requestTitle": "Reset your password",
"email": "Email",
"requestSubmit": "Send reset link",
"requestDone": "If that email has an account, a reset link is on its way.",
"confirmTitle": "Choose a new password",
"newPassword": "New password",
"confirmSubmit": "Set password",
"ok": "Password updated. You can now sign in."
},
"dashboard": {
"title": "Account",
"balance": "Allocation",
"total": "Total",
"spent": "Spent",
"reserved": "Reserved",
"remaining": "Remaining",
"manageKeys": "Manage API keys",
"redeemTitle": "Redeem a top-up code",
"redeemPlaceholder": "helexa-topup-…",
"redeem": "Redeem",
"redeemed": "Code redeemed.",
"logout": "Sign out"
},
"keys": {
"title": "API keys",
"create": "Create key",
"label": "Label",
"limitKind": "Limit",
"percent": "% of allocation",
"hardcap": "Hard cap (tokens)",
"value": "Value",
"none": "No keys yet.",
"createdTitle": "Your new API key",
"createdWarn": "Copy it now — you won't see it again.",
"copy": "Copy",
"copied": "Copied",
"archive": "Archive",
"save": "Save",
"status": "Status",
"usage": "Used"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -0,0 +1,69 @@
{
"login": {
"title": "Sign in",
"email": "Email",
"password": "Password",
"submit": "Sign in",
"noAccount": "No account? Sign up"
},
"register": {
"title": "Create your account",
"email": "Email",
"password": "Password",
"submit": "Sign up",
"haveAccount": "Already have an account? Sign in",
"checkEmail": "Almost there — check your email to verify your account."
},
"verify": {
"verifying": "Verifying…",
"ok": "Email verified. You can now sign in.",
"failed": "This verification link is invalid or has expired.",
"toLogin": "Go to sign in"
},
"reset": {
"requestTitle": "Reset your password",
"email": "Email",
"requestSubmit": "Send reset link",
"requestDone": "If that email has an account, a reset link is on its way.",
"confirmTitle": "Choose a new password",
"newPassword": "New password",
"confirmSubmit": "Set password",
"ok": "Password updated. You can now sign in."
},
"dashboard": {
"title": "Account",
"balance": "Allocation",
"total": "Total",
"spent": "Spent",
"reserved": "Reserved",
"remaining": "Remaining",
"manageKeys": "Manage API keys",
"redeemTitle": "Redeem a top-up code",
"redeemPlaceholder": "helexa-topup-…",
"redeem": "Redeem",
"redeemed": "Code redeemed.",
"logout": "Sign out"
},
"keys": {
"title": "API keys",
"create": "Create key",
"label": "Label",
"limitKind": "Limit",
"percent": "% of allocation",
"hardcap": "Hard cap (tokens)",
"value": "Value",
"none": "No keys yet.",
"createdTitle": "Your new API key",
"createdWarn": "Copy it now — you won't see it again.",
"copy": "Copy",
"copied": "Copied",
"archive": "Archive",
"save": "Save",
"status": "Status",
"usage": "Used"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -0,0 +1,69 @@
{
"login": {
"title": "Sign in",
"email": "Email",
"password": "Password",
"submit": "Sign in",
"noAccount": "No account? Sign up"
},
"register": {
"title": "Create your account",
"email": "Email",
"password": "Password",
"submit": "Sign up",
"haveAccount": "Already have an account? Sign in",
"checkEmail": "Almost there — check your email to verify your account."
},
"verify": {
"verifying": "Verifying…",
"ok": "Email verified. You can now sign in.",
"failed": "This verification link is invalid or has expired.",
"toLogin": "Go to sign in"
},
"reset": {
"requestTitle": "Reset your password",
"email": "Email",
"requestSubmit": "Send reset link",
"requestDone": "If that email has an account, a reset link is on its way.",
"confirmTitle": "Choose a new password",
"newPassword": "New password",
"confirmSubmit": "Set password",
"ok": "Password updated. You can now sign in."
},
"dashboard": {
"title": "Account",
"balance": "Allocation",
"total": "Total",
"spent": "Spent",
"reserved": "Reserved",
"remaining": "Remaining",
"manageKeys": "Manage API keys",
"redeemTitle": "Redeem a top-up code",
"redeemPlaceholder": "helexa-topup-…",
"redeem": "Redeem",
"redeemed": "Code redeemed.",
"logout": "Sign out"
},
"keys": {
"title": "API keys",
"create": "Create key",
"label": "Label",
"limitKind": "Limit",
"percent": "% of allocation",
"hardcap": "Hard cap (tokens)",
"value": "Value",
"none": "No keys yet.",
"createdTitle": "Your new API key",
"createdWarn": "Copy it now — you won't see it again.",
"copy": "Copy",
"copied": "Copied",
"archive": "Archive",
"save": "Save",
"status": "Status",
"usage": "Used"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -0,0 +1,69 @@
{
"login": {
"title": "Sign in",
"email": "Email",
"password": "Password",
"submit": "Sign in",
"noAccount": "No account? Sign up"
},
"register": {
"title": "Create your account",
"email": "Email",
"password": "Password",
"submit": "Sign up",
"haveAccount": "Already have an account? Sign in",
"checkEmail": "Almost there — check your email to verify your account."
},
"verify": {
"verifying": "Verifying…",
"ok": "Email verified. You can now sign in.",
"failed": "This verification link is invalid or has expired.",
"toLogin": "Go to sign in"
},
"reset": {
"requestTitle": "Reset your password",
"email": "Email",
"requestSubmit": "Send reset link",
"requestDone": "If that email has an account, a reset link is on its way.",
"confirmTitle": "Choose a new password",
"newPassword": "New password",
"confirmSubmit": "Set password",
"ok": "Password updated. You can now sign in."
},
"dashboard": {
"title": "Account",
"balance": "Allocation",
"total": "Total",
"spent": "Spent",
"reserved": "Reserved",
"remaining": "Remaining",
"manageKeys": "Manage API keys",
"redeemTitle": "Redeem a top-up code",
"redeemPlaceholder": "helexa-topup-…",
"redeem": "Redeem",
"redeemed": "Code redeemed.",
"logout": "Sign out"
},
"keys": {
"title": "API keys",
"create": "Create key",
"label": "Label",
"limitKind": "Limit",
"percent": "% of allocation",
"hardcap": "Hard cap (tokens)",
"value": "Value",
"none": "No keys yet.",
"createdTitle": "Your new API key",
"createdWarn": "Copy it now — you won't see it again.",
"copy": "Copy",
"copied": "Copied",
"archive": "Archive",
"save": "Save",
"status": "Status",
"usage": "Used"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -0,0 +1,69 @@
{
"login": {
"title": "Sign in",
"email": "Email",
"password": "Password",
"submit": "Sign in",
"noAccount": "No account? Sign up"
},
"register": {
"title": "Create your account",
"email": "Email",
"password": "Password",
"submit": "Sign up",
"haveAccount": "Already have an account? Sign in",
"checkEmail": "Almost there — check your email to verify your account."
},
"verify": {
"verifying": "Verifying…",
"ok": "Email verified. You can now sign in.",
"failed": "This verification link is invalid or has expired.",
"toLogin": "Go to sign in"
},
"reset": {
"requestTitle": "Reset your password",
"email": "Email",
"requestSubmit": "Send reset link",
"requestDone": "If that email has an account, a reset link is on its way.",
"confirmTitle": "Choose a new password",
"newPassword": "New password",
"confirmSubmit": "Set password",
"ok": "Password updated. You can now sign in."
},
"dashboard": {
"title": "Account",
"balance": "Allocation",
"total": "Total",
"spent": "Spent",
"reserved": "Reserved",
"remaining": "Remaining",
"manageKeys": "Manage API keys",
"redeemTitle": "Redeem a top-up code",
"redeemPlaceholder": "helexa-topup-…",
"redeem": "Redeem",
"redeemed": "Code redeemed.",
"logout": "Sign out"
},
"keys": {
"title": "API keys",
"create": "Create key",
"label": "Label",
"limitKind": "Limit",
"percent": "% of allocation",
"hardcap": "Hard cap (tokens)",
"value": "Value",
"none": "No keys yet.",
"createdTitle": "Your new API key",
"createdWarn": "Copy it now — you won't see it again.",
"copy": "Copy",
"copied": "Copied",
"archive": "Archive",
"save": "Save",
"status": "Status",
"usage": "Used"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -0,0 +1,69 @@
{
"login": {
"title": "Sign in",
"email": "Email",
"password": "Password",
"submit": "Sign in",
"noAccount": "No account? Sign up"
},
"register": {
"title": "Create your account",
"email": "Email",
"password": "Password",
"submit": "Sign up",
"haveAccount": "Already have an account? Sign in",
"checkEmail": "Almost there — check your email to verify your account."
},
"verify": {
"verifying": "Verifying…",
"ok": "Email verified. You can now sign in.",
"failed": "This verification link is invalid or has expired.",
"toLogin": "Go to sign in"
},
"reset": {
"requestTitle": "Reset your password",
"email": "Email",
"requestSubmit": "Send reset link",
"requestDone": "If that email has an account, a reset link is on its way.",
"confirmTitle": "Choose a new password",
"newPassword": "New password",
"confirmSubmit": "Set password",
"ok": "Password updated. You can now sign in."
},
"dashboard": {
"title": "Account",
"balance": "Allocation",
"total": "Total",
"spent": "Spent",
"reserved": "Reserved",
"remaining": "Remaining",
"manageKeys": "Manage API keys",
"redeemTitle": "Redeem a top-up code",
"redeemPlaceholder": "helexa-topup-…",
"redeem": "Redeem",
"redeemed": "Code redeemed.",
"logout": "Sign out"
},
"keys": {
"title": "API keys",
"create": "Create key",
"label": "Label",
"limitKind": "Limit",
"percent": "% of allocation",
"hardcap": "Hard cap (tokens)",
"value": "Value",
"none": "No keys yet.",
"createdTitle": "Your new API key",
"createdWarn": "Copy it now — you won't see it again.",
"copy": "Copy",
"copied": "Copied",
"archive": "Archive",
"save": "Save",
"status": "Status",
"usage": "Used"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -0,0 +1,69 @@
{
"login": {
"title": "Sign in",
"email": "Email",
"password": "Password",
"submit": "Sign in",
"noAccount": "No account? Sign up"
},
"register": {
"title": "Create your account",
"email": "Email",
"password": "Password",
"submit": "Sign up",
"haveAccount": "Already have an account? Sign in",
"checkEmail": "Almost there — check your email to verify your account."
},
"verify": {
"verifying": "Verifying…",
"ok": "Email verified. You can now sign in.",
"failed": "This verification link is invalid or has expired.",
"toLogin": "Go to sign in"
},
"reset": {
"requestTitle": "Reset your password",
"email": "Email",
"requestSubmit": "Send reset link",
"requestDone": "If that email has an account, a reset link is on its way.",
"confirmTitle": "Choose a new password",
"newPassword": "New password",
"confirmSubmit": "Set password",
"ok": "Password updated. You can now sign in."
},
"dashboard": {
"title": "Account",
"balance": "Allocation",
"total": "Total",
"spent": "Spent",
"reserved": "Reserved",
"remaining": "Remaining",
"manageKeys": "Manage API keys",
"redeemTitle": "Redeem a top-up code",
"redeemPlaceholder": "helexa-topup-…",
"redeem": "Redeem",
"redeemed": "Code redeemed.",
"logout": "Sign out"
},
"keys": {
"title": "API keys",
"create": "Create key",
"label": "Label",
"limitKind": "Limit",
"percent": "% of allocation",
"hardcap": "Hard cap (tokens)",
"value": "Value",
"none": "No keys yet.",
"createdTitle": "Your new API key",
"createdWarn": "Copy it now — you won't see it again.",
"copy": "Copy",
"copied": "Copied",
"archive": "Archive",
"save": "Save",
"status": "Status",
"usage": "Used"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -0,0 +1,69 @@
{
"login": {
"title": "Sign in",
"email": "Email",
"password": "Password",
"submit": "Sign in",
"noAccount": "No account? Sign up"
},
"register": {
"title": "Create your account",
"email": "Email",
"password": "Password",
"submit": "Sign up",
"haveAccount": "Already have an account? Sign in",
"checkEmail": "Almost there — check your email to verify your account."
},
"verify": {
"verifying": "Verifying…",
"ok": "Email verified. You can now sign in.",
"failed": "This verification link is invalid or has expired.",
"toLogin": "Go to sign in"
},
"reset": {
"requestTitle": "Reset your password",
"email": "Email",
"requestSubmit": "Send reset link",
"requestDone": "If that email has an account, a reset link is on its way.",
"confirmTitle": "Choose a new password",
"newPassword": "New password",
"confirmSubmit": "Set password",
"ok": "Password updated. You can now sign in."
},
"dashboard": {
"title": "Account",
"balance": "Allocation",
"total": "Total",
"spent": "Spent",
"reserved": "Reserved",
"remaining": "Remaining",
"manageKeys": "Manage API keys",
"redeemTitle": "Redeem a top-up code",
"redeemPlaceholder": "helexa-topup-…",
"redeem": "Redeem",
"redeemed": "Code redeemed.",
"logout": "Sign out"
},
"keys": {
"title": "API keys",
"create": "Create key",
"label": "Label",
"limitKind": "Limit",
"percent": "% of allocation",
"hardcap": "Hard cap (tokens)",
"value": "Value",
"none": "No keys yet.",
"createdTitle": "Your new API key",
"createdWarn": "Copy it now — you won't see it again.",
"copy": "Copy",
"copied": "Copied",
"archive": "Archive",
"save": "Save",
"status": "Status",
"usage": "Used"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -0,0 +1,69 @@
{
"login": {
"title": "Sign in",
"email": "Email",
"password": "Password",
"submit": "Sign in",
"noAccount": "No account? Sign up"
},
"register": {
"title": "Create your account",
"email": "Email",
"password": "Password",
"submit": "Sign up",
"haveAccount": "Already have an account? Sign in",
"checkEmail": "Almost there — check your email to verify your account."
},
"verify": {
"verifying": "Verifying…",
"ok": "Email verified. You can now sign in.",
"failed": "This verification link is invalid or has expired.",
"toLogin": "Go to sign in"
},
"reset": {
"requestTitle": "Reset your password",
"email": "Email",
"requestSubmit": "Send reset link",
"requestDone": "If that email has an account, a reset link is on its way.",
"confirmTitle": "Choose a new password",
"newPassword": "New password",
"confirmSubmit": "Set password",
"ok": "Password updated. You can now sign in."
},
"dashboard": {
"title": "Account",
"balance": "Allocation",
"total": "Total",
"spent": "Spent",
"reserved": "Reserved",
"remaining": "Remaining",
"manageKeys": "Manage API keys",
"redeemTitle": "Redeem a top-up code",
"redeemPlaceholder": "helexa-topup-…",
"redeem": "Redeem",
"redeemed": "Code redeemed.",
"logout": "Sign out"
},
"keys": {
"title": "API keys",
"create": "Create key",
"label": "Label",
"limitKind": "Limit",
"percent": "% of allocation",
"hardcap": "Hard cap (tokens)",
"value": "Value",
"none": "No keys yet.",
"createdTitle": "Your new API key",
"createdWarn": "Copy it now — you won't see it again.",
"copy": "Copy",
"copied": "Copied",
"archive": "Archive",
"save": "Save",
"status": "Status",
"usage": "Used"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -0,0 +1,69 @@
{
"login": {
"title": "Sign in",
"email": "Email",
"password": "Password",
"submit": "Sign in",
"noAccount": "No account? Sign up"
},
"register": {
"title": "Create your account",
"email": "Email",
"password": "Password",
"submit": "Sign up",
"haveAccount": "Already have an account? Sign in",
"checkEmail": "Almost there — check your email to verify your account."
},
"verify": {
"verifying": "Verifying…",
"ok": "Email verified. You can now sign in.",
"failed": "This verification link is invalid or has expired.",
"toLogin": "Go to sign in"
},
"reset": {
"requestTitle": "Reset your password",
"email": "Email",
"requestSubmit": "Send reset link",
"requestDone": "If that email has an account, a reset link is on its way.",
"confirmTitle": "Choose a new password",
"newPassword": "New password",
"confirmSubmit": "Set password",
"ok": "Password updated. You can now sign in."
},
"dashboard": {
"title": "Account",
"balance": "Allocation",
"total": "Total",
"spent": "Spent",
"reserved": "Reserved",
"remaining": "Remaining",
"manageKeys": "Manage API keys",
"redeemTitle": "Redeem a top-up code",
"redeemPlaceholder": "helexa-topup-…",
"redeem": "Redeem",
"redeemed": "Code redeemed.",
"logout": "Sign out"
},
"keys": {
"title": "API keys",
"create": "Create key",
"label": "Label",
"limitKind": "Limit",
"percent": "% of allocation",
"hardcap": "Hard cap (tokens)",
"value": "Value",
"none": "No keys yet.",
"createdTitle": "Your new API key",
"createdWarn": "Copy it now — you won't see it again.",
"copy": "Copy",
"copied": "Copied",
"archive": "Archive",
"save": "Save",
"status": "Status",
"usage": "Used"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -0,0 +1,69 @@
{
"login": {
"title": "Sign in",
"email": "Email",
"password": "Password",
"submit": "Sign in",
"noAccount": "No account? Sign up"
},
"register": {
"title": "Create your account",
"email": "Email",
"password": "Password",
"submit": "Sign up",
"haveAccount": "Already have an account? Sign in",
"checkEmail": "Almost there — check your email to verify your account."
},
"verify": {
"verifying": "Verifying…",
"ok": "Email verified. You can now sign in.",
"failed": "This verification link is invalid or has expired.",
"toLogin": "Go to sign in"
},
"reset": {
"requestTitle": "Reset your password",
"email": "Email",
"requestSubmit": "Send reset link",
"requestDone": "If that email has an account, a reset link is on its way.",
"confirmTitle": "Choose a new password",
"newPassword": "New password",
"confirmSubmit": "Set password",
"ok": "Password updated. You can now sign in."
},
"dashboard": {
"title": "Account",
"balance": "Allocation",
"total": "Total",
"spent": "Spent",
"reserved": "Reserved",
"remaining": "Remaining",
"manageKeys": "Manage API keys",
"redeemTitle": "Redeem a top-up code",
"redeemPlaceholder": "helexa-topup-…",
"redeem": "Redeem",
"redeemed": "Code redeemed.",
"logout": "Sign out"
},
"keys": {
"title": "API keys",
"create": "Create key",
"label": "Label",
"limitKind": "Limit",
"percent": "% of allocation",
"hardcap": "Hard cap (tokens)",
"value": "Value",
"none": "No keys yet.",
"createdTitle": "Your new API key",
"createdWarn": "Copy it now — you won't see it again.",
"copy": "Copy",
"copied": "Copied",
"archive": "Archive",
"save": "Save",
"status": "Status",
"usage": "Used"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -0,0 +1,69 @@
{
"login": {
"title": "Sign in",
"email": "Email",
"password": "Password",
"submit": "Sign in",
"noAccount": "No account? Sign up"
},
"register": {
"title": "Create your account",
"email": "Email",
"password": "Password",
"submit": "Sign up",
"haveAccount": "Already have an account? Sign in",
"checkEmail": "Almost there — check your email to verify your account."
},
"verify": {
"verifying": "Verifying…",
"ok": "Email verified. You can now sign in.",
"failed": "This verification link is invalid or has expired.",
"toLogin": "Go to sign in"
},
"reset": {
"requestTitle": "Reset your password",
"email": "Email",
"requestSubmit": "Send reset link",
"requestDone": "If that email has an account, a reset link is on its way.",
"confirmTitle": "Choose a new password",
"newPassword": "New password",
"confirmSubmit": "Set password",
"ok": "Password updated. You can now sign in."
},
"dashboard": {
"title": "Account",
"balance": "Allocation",
"total": "Total",
"spent": "Spent",
"reserved": "Reserved",
"remaining": "Remaining",
"manageKeys": "Manage API keys",
"redeemTitle": "Redeem a top-up code",
"redeemPlaceholder": "helexa-topup-…",
"redeem": "Redeem",
"redeemed": "Code redeemed.",
"logout": "Sign out"
},
"keys": {
"title": "API keys",
"create": "Create key",
"label": "Label",
"limitKind": "Limit",
"percent": "% of allocation",
"hardcap": "Hard cap (tokens)",
"value": "Value",
"none": "No keys yet.",
"createdTitle": "Your new API key",
"createdWarn": "Copy it now — you won't see it again.",
"copy": "Copy",
"copied": "Copied",
"archive": "Archive",
"save": "Save",
"status": "Status",
"usage": "Used"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -0,0 +1,69 @@
{
"login": {
"title": "Sign in",
"email": "Email",
"password": "Password",
"submit": "Sign in",
"noAccount": "No account? Sign up"
},
"register": {
"title": "Create your account",
"email": "Email",
"password": "Password",
"submit": "Sign up",
"haveAccount": "Already have an account? Sign in",
"checkEmail": "Almost there — check your email to verify your account."
},
"verify": {
"verifying": "Verifying…",
"ok": "Email verified. You can now sign in.",
"failed": "This verification link is invalid or has expired.",
"toLogin": "Go to sign in"
},
"reset": {
"requestTitle": "Reset your password",
"email": "Email",
"requestSubmit": "Send reset link",
"requestDone": "If that email has an account, a reset link is on its way.",
"confirmTitle": "Choose a new password",
"newPassword": "New password",
"confirmSubmit": "Set password",
"ok": "Password updated. You can now sign in."
},
"dashboard": {
"title": "Account",
"balance": "Allocation",
"total": "Total",
"spent": "Spent",
"reserved": "Reserved",
"remaining": "Remaining",
"manageKeys": "Manage API keys",
"redeemTitle": "Redeem a top-up code",
"redeemPlaceholder": "helexa-topup-…",
"redeem": "Redeem",
"redeemed": "Code redeemed.",
"logout": "Sign out"
},
"keys": {
"title": "API keys",
"create": "Create key",
"label": "Label",
"limitKind": "Limit",
"percent": "% of allocation",
"hardcap": "Hard cap (tokens)",
"value": "Value",
"none": "No keys yet.",
"createdTitle": "Your new API key",
"createdWarn": "Copy it now — you won't see it again.",
"copy": "Copy",
"copied": "Copied",
"archive": "Archive",
"save": "Save",
"status": "Status",
"usage": "Used"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -0,0 +1,159 @@
import { useCallback, useEffect, useState } from "react";
import { Alert, Badge, Button, Container, Form, Modal, Table } from "react-bootstrap";
import { useTranslation } from "react-i18next";
import { useAuth } from "../../auth/context";
import { accountApi } from "../../api/account";
import { ApiError, type ApiKeySummary, type CreatedKey } from "../../api/types";
type LimitKind = "percent" | "hardcap";
export default function ApiKeys() {
const { t } = useTranslation("account");
const { token, logout } = useAuth();
const [keys, setKeys] = useState<ApiKeySummary[]>([]);
const [error, setError] = useState<string | null>(null);
// Create-key form state.
const [label, setLabel] = useState("");
const [limitKind, setLimitKind] = useState<LimitKind>("percent");
const [limitValue, setLimitValue] = useState(100);
const [created, setCreated] = useState<CreatedKey | null>(null);
const [copied, setCopied] = useState(false);
const load = useCallback(async () => {
if (!token) return;
try {
setKeys(await accountApi().listKeys(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 create(e: React.FormEvent) {
e.preventDefault();
if (!token) return;
setError(null);
try {
const key = await accountApi().createKey(token, label, limitKind, limitValue);
setCreated(key);
setLabel("");
await load();
} catch (err) {
setError(err instanceof ApiError ? err.message : t("error.generic"));
}
}
async function archive(id: string) {
if (!token) return;
await accountApi().archiveKey(token, id);
await load();
}
return (
<Container className="py-5 flex-grow-1" style={{ maxWidth: 860 }}>
<h1 className="h3 mb-4">{t("keys.title")}</h1>
{error && <Alert variant="warning">{error}</Alert>}
<Form onSubmit={create} className="surface-elevated p-3 rounded-3 mb-4">
<div className="row g-2 align-items-end">
<div className="col">
<Form.Label className="small">{t("keys.label")}</Form.Label>
<Form.Control value={label} onChange={(e) => setLabel(e.target.value)} />
</div>
<div className="col">
<Form.Label className="small">{t("keys.limitKind")}</Form.Label>
<Form.Select
value={limitKind}
onChange={(e) => setLimitKind(e.target.value as LimitKind)}
>
<option value="percent">{t("keys.percent")}</option>
<option value="hardcap">{t("keys.hardcap")}</option>
</Form.Select>
</div>
<div className="col">
<Form.Label className="small">{t("keys.value")}</Form.Label>
<Form.Control
type="number"
min={0}
value={limitValue}
onChange={(e) => setLimitValue(Number(e.target.value))}
/>
</div>
<div className="col-auto">
<Button type="submit">{t("keys.create")}</Button>
</div>
</div>
</Form>
{keys.length === 0 ? (
<p className="text-muted">{t("keys.none")}</p>
) : (
<Table responsive hover>
<thead>
<tr>
<th>{t("keys.label")}</th>
<th>Prefix</th>
<th>{t("keys.limitKind")}</th>
<th>{t("keys.usage")}</th>
<th>{t("keys.status")}</th>
<th />
</tr>
</thead>
<tbody>
{keys.map((k) => (
<tr key={k.id}>
<td>{k.label || "—"}</td>
<td>
<code>{k.prefix}</code>
</td>
<td>
{k.limit_kind === "percent" ? `${k.limit_value}%` : k.limit_value.toLocaleString()}
</td>
<td>{k.spent.toLocaleString()}</td>
<td>
<Badge bg={k.status === "active" ? "success" : "secondary"}>{k.status}</Badge>
</td>
<td className="text-end">
{k.status === "active" && (
<Button size="sm" variant="outline-danger" onClick={() => void archive(k.id)}>
{t("keys.archive")}
</Button>
)}
</td>
</tr>
))}
</tbody>
</Table>
)}
{/* The raw key is shown exactly once. */}
<Modal show={!!created} onHide={() => setCreated(null)} centered>
<Modal.Header closeButton>
<Modal.Title className="h6">{t("keys.createdTitle")}</Modal.Title>
</Modal.Header>
<Modal.Body>
<Alert variant="warning" className="py-2">{t("keys.createdWarn")}</Alert>
<div className="d-flex gap-2">
<Form.Control readOnly value={created?.key ?? ""} />
<Button
variant="outline-secondary"
onClick={() => {
if (created) void navigator.clipboard.writeText(created.key);
setCopied(true);
}}
>
{copied ? t("keys.copied") : t("keys.copy")}
</Button>
</div>
</Modal.Body>
</Modal>
</Container>
);
}

View File

@@ -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<AccountBalance | null>(null);
const [code, setCode] = useState("");
const [msg, setMsg] = useState<string | null>(null);
const [error, setError] = useState<string | null>(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 (
<Container className="py-5 flex-grow-1" style={{ maxWidth: 720 }}>
<div className="d-flex justify-content-between align-items-center mb-4">
<h1 className="h3 mb-0">{t("dashboard.title")}</h1>
<Button variant="outline-secondary" size="sm" onClick={logout}>
{t("dashboard.logout")}
</Button>
</div>
<Card className="surface-elevated mb-4">
<Card.Body>
<Card.Title className="h6 text-uppercase text-muted">
{t("dashboard.balance")}
</Card.Title>
{balance && (
<>
<ProgressBar now={pct} className="my-3" />
<div className="d-flex justify-content-between small">
<span>{t("dashboard.total")}: {balance.allocation_total.toLocaleString()}</span>
<span>{t("dashboard.spent")}: {balance.allocation_spent.toLocaleString()}</span>
<span>{t("dashboard.reserved")}: {balance.allocation_reserved.toLocaleString()}</span>
<span>{t("dashboard.remaining")}: {remaining.toLocaleString()}</span>
</div>
</>
)}
<Link to="/account/keys" className="btn btn-primary btn-sm mt-3">
{t("dashboard.manageKeys")}
</Link>
</Card.Body>
</Card>
<Card className="surface-elevated">
<Card.Body>
<Card.Title className="h6">{t("dashboard.redeemTitle")}</Card.Title>
{msg && <Alert variant="success" className="py-2">{msg}</Alert>}
{error && <Alert variant="warning" className="py-2">{error}</Alert>}
<Form onSubmit={redeem} className="d-flex gap-2">
<Form.Control
value={code}
placeholder={t("dashboard.redeemPlaceholder")}
onChange={(e) => setCode(e.target.value)}
/>
<Button type="submit" disabled={!code.trim()}>
{t("dashboard.redeem")}
</Button>
</Form>
</Card.Body>
</Card>
</Container>
);
}

View File

@@ -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<string | null>(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 (
<Container className="py-5 flex-grow-1" style={{ maxWidth: 420 }}>
<h1 className="h3 mb-4">{t("login.title")}</h1>
{error && <Alert variant="warning">{error}</Alert>}
<Form onSubmit={submit}>
<Form.Group className="mb-3">
<Form.Label>{t("login.email")}</Form.Label>
<Form.Control
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>{t("login.password")}</Form.Label>
<Form.Control
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</Form.Group>
<Button type="submit" disabled={busy} className="w-100">
{t("login.submit")}
</Button>
</Form>
<p className="mt-3 small">
<Link to="/register">{t("login.noAccount")}</Link>
</p>
</Container>
);
}

View File

@@ -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<string | null>(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 (
<Container className="py-5 flex-grow-1" style={{ maxWidth: 420 }}>
<h1 className="h3 mb-4">{t("register.title")}</h1>
{done ? (
<Alert variant="success">{t("register.checkEmail")}</Alert>
) : (
<>
{error && <Alert variant="warning">{error}</Alert>}
<Form onSubmit={submit}>
<Form.Group className="mb-3">
<Form.Label>{t("register.email")}</Form.Label>
<Form.Control
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>{t("register.password")}</Form.Label>
<Form.Control
type="password"
minLength={8}
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</Form.Group>
<Button type="submit" disabled={busy} className="w-100">
{t("register.submit")}
</Button>
</Form>
<p className="mt-3 small">
<Link to="/login">{t("register.haveAccount")}</Link>
</p>
</>
)}
</Container>
);
}

View File

@@ -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 (
<Container className="py-5 flex-grow-1" style={{ maxWidth: 420 }}>
<h1 className="h3 mb-4">{t("reset.requestTitle")}</h1>
{done ? (
<Alert variant="info">{t("reset.requestDone")}</Alert>
) : (
<Form onSubmit={submit}>
<Form.Group className="mb-3">
<Form.Label>{t("reset.email")}</Form.Label>
<Form.Control
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</Form.Group>
<Button type="submit" disabled={busy} className="w-100">
{t("reset.requestSubmit")}
</Button>
</Form>
)}
</Container>
);
}

View File

@@ -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<string | null>(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 (
<Container className="py-5 flex-grow-1" style={{ maxWidth: 420 }}>
<h1 className="h3 mb-4">{t("reset.confirmTitle")}</h1>
{done ? (
<Alert variant="success">
{t("reset.ok")} <Link to="/login">{t("verify.toLogin")}</Link>
</Alert>
) : (
<>
{error && <Alert variant="warning">{error}</Alert>}
<Form onSubmit={submit}>
<Form.Group className="mb-3">
<Form.Label>{t("reset.newPassword")}</Form.Label>
<Form.Control
type="password"
minLength={8}
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</Form.Group>
<Button type="submit" disabled={busy} className="w-100">
{t("reset.confirmSubmit")}
</Button>
</Form>
</>
)}
</Container>
);
}

View File

@@ -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 (
<Container className="py-5 flex-grow-1" style={{ maxWidth: 480 }}>
{state === "verifying" && (
<p>
<Spinner size="sm" className="me-2" />
{t("verify.verifying")}
</p>
)}
{state === "ok" && (
<Alert variant="success">
{t("verify.ok")} <Link to="/login">{t("verify.toLogin")}</Link>
</Alert>
)}
{state === "failed" && (
<Alert variant="warning">
{t("verify.failed")} <Link to="/login">{t("verify.toLogin")}</Link>
</Alert>
)}
</Container>
);
}

View File

@@ -0,0 +1,109 @@
# Prebuilt-binary spec for helexa-upstream.
#
# Wraps a pre-built `helexa-upstream` binary produced by an upstream CI
# job and packages it for rpm.lair.cafe. The %build phase is a no-op.
# helexa-upstream is a pure-Rust, non-CUDA daemon: the mesh account +
# budget authority. It serves an inbound HTTP API on tcp/8090 (/authz/v1
# for cortex, /web/v1 for the frontend) — hence the firewalld service —
# and connects out to PostgreSQL (the system of record), running schema
# migrations on startup.
#
# Required defines at rpmbuild time:
# upstream_version e.g. "0.1.16"
# upstream_prerelease e.g. "0.1.20260518140530.gitabcdef0"
# ^^^^^^^^^^^^^^^^^^ ^^^^^^^^
# commit time (sec) commit sha
# (used as Release; the timestamp prefix
# keeps same-day builds strictly ordered.)
%global _build_id_links none
%global debug_package %{nil}
%global __strip /usr/bin/true
%{!?upstream_version: %global upstream_version 0.0.0}
%if 0%{?upstream_prerelease:1}
%global upstream_release %{upstream_prerelease}
%else
%global upstream_release 1
%endif
Name: helexa-upstream
Version: %{upstream_version}
Release: %{upstream_release}%{?dist}
Summary: Mesh account + budget authority for helexa (prebuilt)
License: GPL-3.0-or-later
URL: https://git.lair.cafe/helexa/helexa
Source0: helexa-upstream
Source1: helexa-upstream.service
Source2: helexa-upstream-sysusers.conf
Source3: helexa-upstream.example.toml
Source4: LICENSE
Source5: helexa-upstream-firewalld.xml
Requires: firewalld-filesystem
ExclusiveArch: x86_64
Requires(pre): shadow-utils
Requires: systemd
Provides: user(helexa-upstream)
%description
helexa-upstream is the mesh-level authority: it issues user accounts and
API keys, holds the allocation ledger (free grant + redeemable top-up
codes), enforces per-key budgets via a transactional reserve/settle
contract, and reconciles served usage for operator compensation. cortex
validates locally-unrecognised keys against its /authz/v1 surface
(fail-closed); the helexa.ai frontend drives account self-service via
/web/v1. PostgreSQL is the system of record; the schema is migrated on
startup.
%prep
cp %{SOURCE0} ./helexa-upstream
cp %{SOURCE1} .
cp %{SOURCE2} .
cp %{SOURCE3} .
cp %{SOURCE4} .
cp %{SOURCE5} .
%build
# Already built in the upstream CI build job.
%install
install -Dm755 helexa-upstream %{buildroot}%{_bindir}/helexa-upstream
install -Dm644 helexa-upstream.service %{buildroot}%{_unitdir}/helexa-upstream.service
install -Dm644 helexa-upstream-sysusers.conf %{buildroot}%{_sysusersdir}/helexa-upstream.conf
install -Dm644 helexa-upstream-firewalld.xml %{buildroot}%{_prefix}/lib/firewalld/services/helexa-upstream.xml
install -dm755 %{buildroot}%{_sysconfdir}/helexa-upstream
install -Dm644 helexa-upstream.example.toml %{buildroot}%{_sysconfdir}/helexa-upstream/helexa-upstream.toml
%pre
getent group helexa-upstream >/dev/null || groupadd -r helexa-upstream
getent passwd helexa-upstream >/dev/null || \
useradd -r -g helexa-upstream -d /var/lib/helexa-upstream -s /sbin/nologin \
-c "helexa-upstream authority" helexa-upstream
%post
%systemd_post helexa-upstream.service
%preun
%systemd_preun helexa-upstream.service
%postun
%systemd_postun_with_restart helexa-upstream.service
%files
%license LICENSE
%{_bindir}/helexa-upstream
%{_unitdir}/helexa-upstream.service
%{_sysusersdir}/helexa-upstream.conf
%{_prefix}/lib/firewalld/services/helexa-upstream.xml
%dir %{_sysconfdir}/helexa-upstream
%config(noreplace) %{_sysconfdir}/helexa-upstream/helexa-upstream.toml
%changelog
* Mon Jun 23 2026 Gitea Actions <actions@git.lair.cafe> - %{upstream_version}-%{upstream_release}
- Prerelease build from upstream CI binary.