Compare commits

..

3 Commits

Author SHA1 Message Date
508b326bf7 feat(F5): authenticated chat + key usage integration
All checks were successful
CI / Format (push) Successful in 32s
CI / CUDA type-check (push) Successful in 1m37s
CI / Clippy (push) Successful in 3m22s
CI / Test (push) Successful in 7m4s
CI / Build cortex SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
Signing in upgrades the chat workspace from anonymous to account-scoped.

- Auth context exposes accountId (resolved on login and on reload for an
  existing token) so the chat can scope its Dexie owner and the dashboard
  can query.
- Chat: when authed, owner switches to the account id (anon history was
  already re-owned via claimAnonymousData on login), the anon message cap
  is lifted (budget is enforced upstream by the account allocation), the
  full default model (VITE_DEFAULT_MODEL) is used, and the user's API key
  is sent as the bearer.
- The bearer is the raw key the user stored locally via "use for chat on
  this device" in the key-creation modal (client-side only — consistent
  with no server-side secrets). Signed in without a stored key → a banner
  prompts creating/enabling one (sending is disabled until then).
- Error mapping: insufficient_quota → top-up link (/account) when authed,
  sign-up (/register) when anon; rate_limit_exceeded → a wait-and-retry
  hint; both distinct from generic errors.
- New chat (topUp/rateLimited/needsKey/manageKeysLink) and account
  (keys.useForChat/usedForChat) i18n keys across all languages.

Validated: lint, typecheck, build, i18n:check 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:55:48 +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
94 changed files with 3578 additions and 473 deletions

View File

@@ -105,5 +105,3 @@ enabled = false
# upstream). Override via CORTEX_UPSTREAM__BEARER in prod.
# bearer = "replace-with-operator-client-secret"
# timeout_secs = 5
# How often to flush served-usage counters to upstream for reconciliation (#58).
# served_usage_report_interval_secs = 60

View File

@@ -48,18 +48,11 @@ pub struct UpstreamClientConfig {
/// Per-call timeout (seconds) to upstream.
#[serde(default = "default_upstream_timeout")]
pub timeout_secs: u64,
/// How often (seconds) to flush served-usage counters to upstream for
/// reconciliation (#58).
#[serde(default = "default_served_usage_interval")]
pub served_usage_report_interval_secs: u64,
}
fn default_upstream_timeout() -> u64 {
5
}
fn default_served_usage_interval() -> u64 {
60
}
/// `[entitlements]` — the local/static [`crate::entitlements::EntitlementProvider`]
/// source of truth (#50). Accounts, keys, and hard caps live here; the

View File

@@ -322,11 +322,7 @@ async fn anthropic_messages(
)
.await
{
Ok(guard) => Some(crate::metering::usage_sink(
principal,
guard,
std::sync::Arc::clone(&fleet.served_usage),
)),
Ok(guard) => Some(crate::metering::usage_sink(principal, guard)),
Err(env) => return crate::error::envelope_response(env),
}
}
@@ -806,11 +802,7 @@ async fn proxy_with_metrics(
)
.await
{
Ok(guard) => Some(crate::metering::usage_sink(
principal,
guard,
std::sync::Arc::clone(&fleet.served_usage),
)),
Ok(guard) => Some(crate::metering::usage_sink(principal, guard)),
Err(env) => return crate::error::envelope_response(env),
}
}

View File

@@ -11,7 +11,6 @@ pub mod metrics;
pub mod poller;
pub mod proxy;
pub mod router;
pub mod served_usage;
pub mod state;
use anyhow::Result;
@@ -58,28 +57,6 @@ pub async fn run(config: GatewayConfig) -> Result<()> {
evictor::eviction_loop(evictor_fleet).await;
});
// Served-usage reporter (#58): when this operator is part of the mesh,
// periodically flush absolute per-principal served-token counters to
// upstream for reconciliation.
if config.upstream.enabled {
let su_fleet = Arc::clone(&fleet);
let url = config.upstream.url.clone();
let bearer = config.upstream.bearer.clone();
let interval =
std::time::Duration::from_secs(config.upstream.served_usage_report_interval_secs);
tokio::spawn(async move {
loop {
tokio::time::sleep(interval).await;
let rows = su_fleet.served_usage.snapshot();
if let Err(e) =
served_usage::report(&su_fleet.http_client, &url, &bearer, &rows).await
{
tracing::warn!(error = %e, "served-usage report failed (will retry)");
}
}
});
}
let app = build_app(Arc::clone(&fleet));
let listen_addr = config.gateway.listen.parse::<std::net::SocketAddr>()?;

View File

@@ -117,21 +117,9 @@ impl Drop for ReservationGuard {
/// Build the completion sink for an authenticated request: record spend and
/// settle the reservation with the observed total. Dropping it unused (no
/// usage observed) releases the reservation via the guard.
pub fn usage_sink(
principal: Principal,
guard: ReservationGuard,
served_usage: std::sync::Arc<crate::served_usage::ServedUsage>,
) -> UsageSink {
pub fn usage_sink(principal: Principal, guard: ReservationGuard) -> UsageSink {
Box::new(move |prompt, completion| {
record_spend(&principal, prompt, completion);
// Per-principal served-usage tally for #58 reconciliation. Recorded
// for every metered (authenticated) request; the flush task reports
// it to upstream when the operator is part of the mesh.
served_usage.add(
&principal.account_id,
&principal.key_id,
prompt + completion,
);
guard.settle(prompt + completion);
})
}

View File

@@ -1,105 +0,0 @@
//! Served-usage ledger (#58): cortex meters, per principal and per UTC day,
//! the tokens it has served on behalf of mesh accounts, and periodically
//! reports **absolute** cumulative counters to helexa-upstream for
//! reconciliation (operators are compensated for served tokens).
//!
//! Counters are cumulative-since-process-start for the current period;
//! upstream upserts them monotonically (GREATEST), so re-sending the same
//! value is idempotent and a flush that races another is harmless. (A
//! process restart resets the in-memory counter; the monotonic upsert keeps
//! upstream from regressing — at most it under-counts the restarted window,
//! acceptable for beta. One cortex per operator token is assumed.)
use serde::Serialize;
use std::collections::HashMap;
use std::sync::Mutex;
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct ServedRow {
pub account_id: String,
pub key_id: String,
pub period: String, // YYYY-MM-DD (UTC)
pub served_tokens: u64,
}
#[derive(Default)]
pub struct ServedUsage {
inner: Mutex<HashMap<(String, String, String), u64>>,
}
impl ServedUsage {
pub fn new() -> Self {
Self::default()
}
/// Add served tokens for a principal in today's (UTC) period.
pub fn add(&self, account_id: &str, key_id: &str, tokens: u64) {
if tokens == 0 {
return;
}
let period = chrono::Utc::now().format("%Y-%m-%d").to_string();
let mut m = self.inner.lock().expect("served-usage lock");
*m.entry((account_id.to_string(), key_id.to_string(), period))
.or_insert(0) += tokens;
}
/// Absolute cumulative counters, for a flush to upstream.
pub fn snapshot(&self) -> Vec<ServedRow> {
let m = self.inner.lock().expect("served-usage lock");
m.iter()
.map(|((account_id, key_id, period), &served_tokens)| ServedRow {
account_id: account_id.clone(),
key_id: key_id.clone(),
period: period.clone(),
served_tokens,
})
.collect()
}
}
/// POST the absolute counters to upstream's `/authz/v1/served-usage`.
pub async fn report(
client: &reqwest::Client,
base_url: &str,
bearer: &str,
rows: &[ServedRow],
) -> Result<(), reqwest::Error> {
if rows.is_empty() {
return Ok(());
}
let url = format!("{}/authz/v1/served-usage", base_url.trim_end_matches('/'));
client
.post(url)
.bearer_auth(bearer)
.json(&serde_json::json!({ "rows": rows }))
.send()
.await?
.error_for_status()?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn accumulates_per_principal_and_period() {
let su = ServedUsage::new();
su.add("acct", "key", 10);
su.add("acct", "key", 5);
su.add("acct", "other", 7);
su.add("acct", "key", 0); // no-op
let mut rows = su.snapshot();
rows.sort_by(|a, b| a.key_id.cmp(&b.key_id));
assert_eq!(rows.len(), 2);
let key_row = rows.iter().find(|r| r.key_id == "key").unwrap();
assert_eq!(key_row.served_tokens, 15);
assert_eq!(
rows.iter()
.find(|r| r.key_id == "other")
.unwrap()
.served_tokens,
7
);
}
}

View File

@@ -22,9 +22,6 @@ pub struct CortexState {
/// Whether to reject unauthenticated requests (#49). Read by the auth
/// middleware once it lands.
pub require_auth: bool,
/// Per-principal served-token tally (#58), reported to upstream for
/// operator reconciliation by the flush task when upstream is enabled.
pub served_usage: Arc<crate::served_usage::ServedUsage>,
}
impl CortexState {
@@ -76,7 +73,6 @@ impl CortexState {
.expect("failed to build HTTP client"),
entitlements,
require_auth: config.entitlements.require_auth,
served_usage: Arc::new(crate::served_usage::ServedUsage::new()),
}
}
}

View File

@@ -60,7 +60,6 @@ fn chain(local: LocalEntitlementProvider, url: &str) -> ChainedEntitlementProvid
url: url.to_string(),
bearer: "client-secret".into(),
timeout_secs: 5,
served_usage_report_interval_secs: 60,
});
ChainedEntitlementProvider::new(local, upstream)
}

View File

@@ -22,7 +22,7 @@ use axum::http::{StatusCode, header};
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
use axum::routing::post;
use axum::{Extension, Json, Router};
use axum::{Json, Router};
use cortex_core::error_envelope::OpenAiError;
use serde::{Deserialize, Serialize};
use subtle::ConstantTimeEq;
@@ -41,7 +41,6 @@ pub fn router(state: &AppState) -> Router<AppState> {
.route("/authz/v1/settle", post(settle))
.route("/authz/v1/release", post(release))
.route("/authz/v1/snapshot", post(snapshot))
.route("/authz/v1/served-usage", post(served_usage))
.layer(axum::middleware::from_fn_with_state(
state.clone(),
client_auth,
@@ -275,61 +274,6 @@ async fn snapshot(State(state): State<AppState>, Json(req): Json<SnapshotReq>) -
}
}
// ── served-usage report (#58) ───────────────────────────────────────
#[derive(Deserialize)]
struct ServedUsageReport {
rows: Vec<ServedUsageRow>,
}
#[derive(Deserialize)]
struct ServedUsageRow {
account_id: String,
key_id: String,
period: String, // YYYY-MM-DD
served_tokens: i64,
}
/// `POST /authz/v1/served-usage` — a cortex reports the absolute served-token
/// counters it has accrued for the current period. Upsert is monotonic
/// (`GREATEST`) so re-sends and races are idempotent and never regress.
/// `operator_id` comes from the validated client bearer (request extension).
async fn served_usage(
State(state): State<AppState>,
Extension(operator): Extension<OperatorId>,
Json(req): Json<ServedUsageReport>,
) -> Response {
for row in &req.rows {
let (Ok(account_id), Ok(key_id)) = (
Uuid::parse_str(&row.account_id),
Uuid::parse_str(&row.key_id),
) else {
continue; // skip malformed ids rather than fail the whole batch
};
let Ok(period) = chrono::NaiveDate::parse_from_str(&row.period, "%Y-%m-%d") else {
continue;
};
let res = sqlx::query(
"INSERT INTO served_usage (operator_id, account_id, key_id, period, served_tokens) \
VALUES ($1, $2, $3, $4, $5) \
ON CONFLICT (operator_id, account_id, key_id, period) \
DO UPDATE SET served_tokens = GREATEST(served_usage.served_tokens, EXCLUDED.served_tokens)",
)
.bind(&operator.0)
.bind(account_id)
.bind(key_id)
.bind(period)
.bind(row.served_tokens.max(0))
.execute(&state.pool)
.await;
if let Err(e) = res {
tracing::error!(error = %e, "served-usage upsert failed");
return envelope_response(OpenAiError::service_unavailable("authority error", Some(5)));
}
}
StatusCode::NO_CONTENT.into_response()
}
fn bad_request(msg: &str) -> Response {
envelope_response(OpenAiError::new(
400,

View File

@@ -20,7 +20,6 @@ pub mod email;
pub mod error;
pub mod handlers;
pub mod ledger;
pub mod reconcile;
pub mod state;
pub mod topup;
pub mod web;

View File

@@ -36,12 +36,6 @@ enum Commands {
#[arg(long)]
denomination: Option<String>,
},
/// Roll up not-yet-reconciled served usage per operator/period (#58),
/// stamp it reconciled, and print the totals. Payout is out of scope.
Reconcile {
#[arg(short, long, default_value = "helexa-upstream.toml")]
config: String,
},
}
#[tokio::main]
@@ -81,18 +75,6 @@ async fn main() -> Result<()> {
println!("{code}");
}
}
Commands::Reconcile { config } => {
let cfg = UpstreamConfig::load(&config)
.map_err(|e| anyhow::anyhow!("failed to load config from '{config}': {e}"))?;
let pool =
helexa_upstream::db::connect_and_migrate(&cfg.db.url, cfg.db.max_connections)
.await?;
let rollup = helexa_upstream::reconcile::reconcile(&pool).await?;
for r in &rollup {
println!("{}\t{}\t{}", r.operator_id, r.period, r.total_served_tokens);
}
tracing::info!(operators_periods = rollup.len(), "reconciliation complete");
}
}
Ok(())

View File

@@ -1,43 +0,0 @@
//! Reconciliation rollup (#58): aggregate the served-usage ledger per
//! operator and period for operator compensation, stamping rows
//! `reconciled_at` so each window is settled once. The payout mechanism
//! itself is out of scope — this produces the authoritative per-operator
//! totals a settlement process consumes.
use sqlx::Row;
use sqlx::postgres::PgPool;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RollupRow {
pub operator_id: String,
pub period: chrono::NaiveDate,
pub total_served_tokens: i64,
}
/// Roll up all not-yet-reconciled served-usage into per-(operator, period)
/// totals, then stamp those rows `reconciled_at`. Returns the rollup.
/// Idempotent: a second run finds nothing unreconciled and returns empty.
pub async fn reconcile(pool: &PgPool) -> Result<Vec<RollupRow>, sqlx::Error> {
let mut tx = pool.begin().await?;
let rows = sqlx::query(
// SUM(bigint) is numeric in Postgres — cast back to bigint for i64.
"SELECT operator_id, period, SUM(served_tokens)::bigint AS total \
FROM served_usage WHERE reconciled_at IS NULL \
GROUP BY operator_id, period ORDER BY operator_id, period",
)
.fetch_all(&mut *tx)
.await?;
let rollup: Vec<RollupRow> = rows
.iter()
.map(|r| RollupRow {
operator_id: r.get("operator_id"),
period: r.get("period"),
total_served_tokens: r.get::<i64, _>("total"),
})
.collect();
sqlx::query("UPDATE served_usage SET reconciled_at = now() WHERE reconciled_at IS NULL")
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(rollup)
}

View File

@@ -1,116 +0,0 @@
//! Integration test for the served-usage report (#58): the idempotent,
//! monotonic upsert and the reconcile rollup. Gated on
//! UPSTREAM_TEST_DATABASE_URL (skips cleanly when unset).
use helexa_upstream::config::{ClientToken, UpstreamConfig};
use helexa_upstream::db::connect_and_migrate;
use helexa_upstream::email::EmailSender;
use helexa_upstream::reconcile::reconcile;
use helexa_upstream::state::AppState;
use serde_json::{Value, json};
use sqlx::Row;
use sqlx::postgres::PgPool;
use uuid::Uuid;
const CLIENT_TOKEN: &str = "su-test-token";
const OPERATOR: &str = "op-su-test";
async fn spawn_or_skip(test: &str) -> Option<(String, PgPool)> {
let Ok(url) = std::env::var("UPSTREAM_TEST_DATABASE_URL") else {
eprintln!("skipping {test}: UPSTREAM_TEST_DATABASE_URL not set");
return None;
};
let pool = connect_and_migrate(&url, 16).await.expect("migrate");
let mut config = UpstreamConfig {
server: Default::default(),
db: helexa_upstream::config::DbSettings {
url,
max_connections: 16,
},
grant: Default::default(),
abuse: Default::default(),
client_auth: Default::default(),
authz: Default::default(),
auth: Default::default(),
email: Default::default(),
};
config.client_auth.tokens.push(ClientToken {
token: CLIENT_TOKEN.into(),
operator_id: OPERATOR.into(),
});
let email = EmailSender::from_config(&config.email).unwrap();
let state = AppState::new(pool.clone(), config, email);
let app = helexa_upstream::build_app(state);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
Some((format!("http://{addr}"), pool))
}
async fn report(base: &str, rows: Value) -> u16 {
reqwest::Client::new()
.post(format!("{base}/authz/v1/served-usage"))
.bearer_auth(CLIENT_TOKEN)
.json(&json!({ "rows": rows }))
.send()
.await
.unwrap()
.status()
.as_u16()
}
async fn stored(pool: &PgPool, account: Uuid, key: Uuid) -> i64 {
sqlx::query(
"SELECT served_tokens FROM served_usage WHERE operator_id = $1 AND account_id = $2 AND key_id = $3",
)
.bind(OPERATOR)
.bind(account)
.bind(key)
.fetch_one(pool)
.await
.unwrap()
.get("served_tokens")
}
#[tokio::test]
async fn served_usage_upsert_is_monotonic_and_reconciles() {
let Some((base, pool)) = spawn_or_skip("served_usage_upsert_is_monotonic_and_reconciles").await
else {
return;
};
let account = Uuid::new_v4();
let key = Uuid::new_v4();
let period = "2026-06-23";
let row = |n: i64| json!([{"account_id": account, "key_id": key, "period": period, "served_tokens": n}]);
// First report.
assert_eq!(report(&base, row(100)).await, 204);
assert_eq!(stored(&pool, account, key).await, 100);
// Re-send a higher absolute value → advances.
assert_eq!(report(&base, row(250)).await, 204);
assert_eq!(stored(&pool, account, key).await, 250);
// A lower value (e.g. a restarted cortex) must NOT regress (GREATEST).
assert_eq!(report(&base, row(50)).await, 204);
assert_eq!(stored(&pool, account, key).await, 250);
// Re-sending the same value is idempotent.
assert_eq!(report(&base, row(250)).await, 204);
assert_eq!(stored(&pool, account, key).await, 250);
// Reconcile rolls it up and stamps reconciled_at; a second run is empty.
let rollup = reconcile(&pool).await.unwrap();
let mine = rollup
.iter()
.find(|r| r.operator_id == OPERATOR)
.expect("operator in rollup");
assert!(mine.total_served_tokens >= 250);
let again = reconcile(&pool).await.unwrap();
assert!(
again.iter().all(|r| r.operator_id != OPERATOR),
"already reconciled"
);
}

View File

@@ -23,7 +23,7 @@ const ROOT = path.resolve(
const RESOURCES_DIR = path.join(ROOT, "src", "i18n", "resources");
// Namespaces to validate.
const NAMESPACES = ["common", "mission", "chat"];
const NAMESPACES = ["common", "mission", "chat", "account"];
// Languages to validate should track SUPPORTED_LANGUAGES in src/i18n/languages.ts.
// NOTE: This list is intentionally narrower than SUPPORTED_LANGUAGES and does not

View File

@@ -1,26 +1,58 @@
import { BrowserRouter, Routes, Route } from "react-router-dom";
import ThemeProvider from "./layout/ThemeProvider";
import AuthProvider from "./auth/AuthProvider";
import RequireAuth from "./auth/RequireAuth";
import Header from "./components/Header";
import Footer from "./components/Footer";
import Mission from "./pages/Mission";
import Chat from "./pages/Chat";
import Login from "./pages/auth/Login";
import Register from "./pages/auth/Register";
import VerifyEmail from "./pages/auth/VerifyEmail";
import RequestReset from "./pages/auth/RequestReset";
import ResetPassword from "./pages/auth/ResetPassword";
import Dashboard from "./pages/account/Dashboard";
import ApiKeys from "./pages/account/ApiKeys";
import "./App.css";
// Composition root: theme + router + layout shell. `/` is the chat
// workspace (F3, anonymous for now); `/mission` (F2) is the EU-sovereignty
// narrative; the auth/account routes (F4) land next.
// Composition root: theme router → auth → layout shell. `/` is the chat
// workspace (F3); `/mission` the EU-sovereignty narrative (F2); the auth +
// account routes (F4) follow, with /account guarded.
export default function App() {
return (
<ThemeProvider>
<BrowserRouter>
<div className="d-flex flex-column min-vh-100">
<Header />
<Routes>
<Route path="/" element={<Chat />} />
<Route path="/mission" element={<Mission />} />
</Routes>
<Footer />
</div>
<AuthProvider>
<div className="d-flex flex-column min-vh-100">
<Header />
<Routes>
<Route path="/" element={<Chat />} />
<Route path="/mission" element={<Mission />} />
<Route path="/login" element={<Login />} />
<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>
</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,76 @@
import { useEffect, 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),
);
const [accountId, setAccountId] = useState<string | null>(null);
// Resolve the account id for an existing session (page reload) so the chat
// workspace can scope its IndexedDB owner without a fresh login.
useEffect(() => {
if (!token || accountId) return;
accountApi()
.account(token)
.then((a) => setAccountId(a.account_id))
.catch(() => {
/* token may be stale; chat falls back to anon until re-login */
});
}, [token, accountId]);
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);
setAccountId(acct.account_id);
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);
setAccountId(null);
}
return (
<AuthContext.Provider
value={{
token,
email,
accountId,
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,26 @@
import { createContext, useContext } from "react";
export interface AuthContextValue {
token: string | null;
email: string | null;
/** The signed-in account id (for the Dexie owner + usage queries). */
accountId: 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,
accountId: 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 { AUTONYM_MAP, type LanguageCode, isRtlLanguage } from "../i18n/languages";
import { getLanguageOptionsByUsage } from "../i18n/translation-priority";
import { useAuth } from "../auth/context";
/**
* Top navigation: brand, primary routes (chat at `/`, `/mission`), an
* auth-aware cluster (stubbed until F4 wires sessions), the theme toggle,
* and the language selector.
* auth-aware cluster (Account/Sign out when signed in, else Sign in/up),
* the theme toggle, and the language selector.
*
* The language picker is ordered by **estimated usage**
* (getLanguageOptionsByUsage), not alphabetically — a deliberate choice that
@@ -21,6 +22,7 @@ import { getLanguageOptionsByUsage } from "../i18n/translation-priority";
const Header: React.FC = () => {
const { theme, toggleTheme } = useTheme();
const { t, i18n } = useTranslation("common");
const { status, logout } = useAuth();
const currentLanguage: LanguageCode = (i18n.language.split("-")[0] ||
"en") as LanguageCode;
@@ -75,13 +77,31 @@ const Header: React.FC = () => {
</Nav>
<div className="d-flex align-items-center gap-2">
{/* Auth cluster — plain links until F4 wires session state. */}
<NavLink to="/login" className="nav-link">
{t("nav.login")}
</NavLink>
<NavLink to="/register" className="nav-link">
{t("nav.register")}
</NavLink>
{/* Auth-aware cluster. */}
{status === "authed" ? (
<>
<NavLink to="/account" className="nav-link">
{t("nav.account")}
</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
size="sm"

View File

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

View File

@@ -0,0 +1,71 @@
{
"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",
"useForChat": "Use for chat on this device",
"usedForChat": "Enabled for chat ✓"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -13,5 +13,9 @@
"emptyState": "Start a conversation. Your history stays in this browser.",
"anonBanner": "You have reached the anonymous limit. Sign up for a free allocation.",
"signUp": "Sign up",
"stop": "Stop"
"stop": "Stop",
"topUp": "Top up",
"rateLimited": "Rate limited — wait a moment and retry.",
"needsKey": "Create an API key and enable it for chat to send as yourself.",
"manageKeysLink": "Manage keys"
}

View File

@@ -0,0 +1,71 @@
{
"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",
"useForChat": "Use for chat on this device",
"usedForChat": "Enabled for chat ✓"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -13,5 +13,9 @@
"emptyState": "Start a conversation. Your history stays in this browser.",
"anonBanner": "You have reached the anonymous limit. Sign up for a free allocation.",
"signUp": "Sign up",
"stop": "Stop"
"stop": "Stop",
"topUp": "Top up",
"rateLimited": "Rate limited — wait a moment and retry.",
"needsKey": "Create an API key and enable it for chat to send as yourself.",
"manageKeysLink": "Manage keys"
}

View File

@@ -0,0 +1,71 @@
{
"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",
"useForChat": "Use for chat on this device",
"usedForChat": "Enabled for chat ✓"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -13,5 +13,9 @@
"emptyState": "Start a conversation. Your history stays in this browser.",
"anonBanner": "You have reached the anonymous limit. Sign up for a free allocation.",
"signUp": "Sign up",
"stop": "Stop"
"stop": "Stop",
"topUp": "Top up",
"rateLimited": "Rate limited — wait a moment and retry.",
"needsKey": "Create an API key and enable it for chat to send as yourself.",
"manageKeysLink": "Manage keys"
}

View File

@@ -0,0 +1,71 @@
{
"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",
"useForChat": "Use for chat on this device",
"usedForChat": "Enabled for chat ✓"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -13,5 +13,9 @@
"emptyState": "Start a conversation. Your history stays in this browser.",
"anonBanner": "You have reached the anonymous limit. Sign up for a free allocation.",
"signUp": "Sign up",
"stop": "Stop"
"stop": "Stop",
"topUp": "Top up",
"rateLimited": "Rate limited — wait a moment and retry.",
"needsKey": "Create an API key and enable it for chat to send as yourself.",
"manageKeysLink": "Manage keys"
}

View File

@@ -0,0 +1,71 @@
{
"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",
"useForChat": "Use for chat on this device",
"usedForChat": "Enabled for chat ✓"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -13,5 +13,9 @@
"emptyState": "Start a conversation. Your history stays in this browser.",
"anonBanner": "You have reached the anonymous limit. Sign up for a free allocation.",
"signUp": "Sign up",
"stop": "Stop"
"stop": "Stop",
"topUp": "Top up",
"rateLimited": "Rate limited — wait a moment and retry.",
"needsKey": "Create an API key and enable it for chat to send as yourself.",
"manageKeysLink": "Manage keys"
}

View File

@@ -0,0 +1,71 @@
{
"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",
"useForChat": "Use for chat on this device",
"usedForChat": "Enabled for chat ✓"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -13,5 +13,9 @@
"emptyState": "Start a conversation. Your history stays in this browser.",
"anonBanner": "You have reached the anonymous limit. Sign up for a free allocation.",
"signUp": "Sign up",
"stop": "Stop"
"stop": "Stop",
"topUp": "Top up",
"rateLimited": "Rate limited — wait a moment and retry.",
"needsKey": "Create an API key and enable it for chat to send as yourself.",
"manageKeysLink": "Manage keys"
}

View File

@@ -0,0 +1,71 @@
{
"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",
"useForChat": "Use for chat on this device",
"usedForChat": "Enabled for chat ✓"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -13,5 +13,9 @@
"emptyState": "Start a conversation. Your history stays in this browser.",
"anonBanner": "You have reached the anonymous limit. Sign up for a free allocation.",
"signUp": "Sign up",
"stop": "Stop"
"stop": "Stop",
"topUp": "Top up",
"rateLimited": "Rate limited — wait a moment and retry.",
"needsKey": "Create an API key and enable it for chat to send as yourself.",
"manageKeysLink": "Manage keys"
}

View File

@@ -0,0 +1,71 @@
{
"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",
"useForChat": "Use for chat on this device",
"usedForChat": "Enabled for chat ✓"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -13,5 +13,9 @@
"emptyState": "Start a conversation. Your history stays in this browser.",
"anonBanner": "You have reached the anonymous limit. Sign up for a free allocation.",
"signUp": "Sign up",
"stop": "Stop"
"stop": "Stop",
"topUp": "Top up",
"rateLimited": "Rate limited — wait a moment and retry.",
"needsKey": "Create an API key and enable it for chat to send as yourself.",
"manageKeysLink": "Manage keys"
}

View File

@@ -0,0 +1,71 @@
{
"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",
"useForChat": "Use for chat on this device",
"usedForChat": "Enabled for chat ✓"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -13,5 +13,9 @@
"emptyState": "Start a conversation. Your history stays in this browser.",
"anonBanner": "You have reached the anonymous limit. Sign up for a free allocation.",
"signUp": "Sign up",
"stop": "Stop"
"stop": "Stop",
"topUp": "Top up",
"rateLimited": "Rate limited — wait a moment and retry.",
"needsKey": "Create an API key and enable it for chat to send as yourself.",
"manageKeysLink": "Manage keys"
}

View File

@@ -0,0 +1,71 @@
{
"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",
"useForChat": "Use for chat on this device",
"usedForChat": "Enabled for chat ✓"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -13,5 +13,9 @@
"emptyState": "Start a conversation. Your history stays in this browser.",
"anonBanner": "You have reached the anonymous limit. Sign up for a free allocation.",
"signUp": "Sign up",
"stop": "Stop"
"stop": "Stop",
"topUp": "Top up",
"rateLimited": "Rate limited — wait a moment and retry.",
"needsKey": "Create an API key and enable it for chat to send as yourself.",
"manageKeysLink": "Manage keys"
}

View File

@@ -0,0 +1,71 @@
{
"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",
"useForChat": "Use for chat on this device",
"usedForChat": "Enabled for chat ✓"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -13,5 +13,9 @@
"emptyState": "Start a conversation. Your history stays in this browser.",
"anonBanner": "You have reached the anonymous limit. Sign up for a free allocation.",
"signUp": "Sign up",
"stop": "Stop"
"stop": "Stop",
"topUp": "Top up",
"rateLimited": "Rate limited — wait a moment and retry.",
"needsKey": "Create an API key and enable it for chat to send as yourself.",
"manageKeysLink": "Manage keys"
}

View File

@@ -0,0 +1,71 @@
{
"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",
"useForChat": "Use for chat on this device",
"usedForChat": "Enabled for chat ✓"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -13,5 +13,9 @@
"emptyState": "Start a conversation. Your history stays in this browser.",
"anonBanner": "You have reached the anonymous limit. Sign up for a free allocation.",
"signUp": "Sign up",
"stop": "Stop"
"stop": "Stop",
"topUp": "Top up",
"rateLimited": "Rate limited — wait a moment and retry.",
"needsKey": "Create an API key and enable it for chat to send as yourself.",
"manageKeysLink": "Manage keys"
}

View File

@@ -0,0 +1,71 @@
{
"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",
"useForChat": "Use for chat on this device",
"usedForChat": "Enabled for chat ✓"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -13,5 +13,9 @@
"emptyState": "Start a conversation. Your history stays in this browser.",
"anonBanner": "You have reached the anonymous limit. Sign up for a free allocation.",
"signUp": "Sign up",
"stop": "Stop"
"stop": "Stop",
"topUp": "Top up",
"rateLimited": "Rate limited — wait a moment and retry.",
"needsKey": "Create an API key and enable it for chat to send as yourself.",
"manageKeysLink": "Manage keys"
}

View File

@@ -0,0 +1,71 @@
{
"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",
"useForChat": "Use for chat on this device",
"usedForChat": "Enabled for chat ✓"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -13,5 +13,9 @@
"emptyState": "Start a conversation. Your history stays in this browser.",
"anonBanner": "You have reached the anonymous limit. Sign up for a free allocation.",
"signUp": "Sign up",
"stop": "Stop"
"stop": "Stop",
"topUp": "Top up",
"rateLimited": "Rate limited — wait a moment and retry.",
"needsKey": "Create an API key and enable it for chat to send as yourself.",
"manageKeysLink": "Manage keys"
}

View File

@@ -0,0 +1,71 @@
{
"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",
"useForChat": "Use for chat on this device",
"usedForChat": "Enabled for chat ✓"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -13,5 +13,9 @@
"emptyState": "Start a conversation. Your history stays in this browser.",
"anonBanner": "You have reached the anonymous limit. Sign up for a free allocation.",
"signUp": "Sign up",
"stop": "Stop"
"stop": "Stop",
"topUp": "Top up",
"rateLimited": "Rate limited — wait a moment and retry.",
"needsKey": "Create an API key and enable it for chat to send as yourself.",
"manageKeysLink": "Manage keys"
}

View File

@@ -0,0 +1,71 @@
{
"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",
"useForChat": "Use for chat on this device",
"usedForChat": "Enabled for chat ✓"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -13,5 +13,9 @@
"emptyState": "Start a conversation. Your history stays in this browser.",
"anonBanner": "You have reached the anonymous limit. Sign up for a free allocation.",
"signUp": "Sign up",
"stop": "Stop"
"stop": "Stop",
"topUp": "Top up",
"rateLimited": "Rate limited — wait a moment and retry.",
"needsKey": "Create an API key and enable it for chat to send as yourself.",
"manageKeysLink": "Manage keys"
}

View File

@@ -0,0 +1,71 @@
{
"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",
"useForChat": "Use for chat on this device",
"usedForChat": "Enabled for chat ✓"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -13,5 +13,9 @@
"emptyState": "Start a conversation. Your history stays in this browser.",
"anonBanner": "You have reached the anonymous limit. Sign up for a free allocation.",
"signUp": "Sign up",
"stop": "Stop"
"stop": "Stop",
"topUp": "Top up",
"rateLimited": "Rate limited — wait a moment and retry.",
"needsKey": "Create an API key and enable it for chat to send as yourself.",
"manageKeysLink": "Manage keys"
}

View File

@@ -0,0 +1,71 @@
{
"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",
"useForChat": "Use for chat on this device",
"usedForChat": "Enabled for chat ✓"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -13,5 +13,9 @@
"emptyState": "Start a conversation. Your history stays in this browser.",
"anonBanner": "You have reached the anonymous limit. Sign up for a free allocation.",
"signUp": "Sign up",
"stop": "Stop"
"stop": "Stop",
"topUp": "Top up",
"rateLimited": "Rate limited — wait a moment and retry.",
"needsKey": "Create an API key and enable it for chat to send as yourself.",
"manageKeysLink": "Manage keys"
}

View File

@@ -0,0 +1,71 @@
{
"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",
"useForChat": "Use for chat on this device",
"usedForChat": "Enabled for chat ✓"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -13,5 +13,9 @@
"emptyState": "Start a conversation. Your history stays in this browser.",
"anonBanner": "You have reached the anonymous limit. Sign up for a free allocation.",
"signUp": "Sign up",
"stop": "Stop"
"stop": "Stop",
"topUp": "Top up",
"rateLimited": "Rate limited — wait a moment and retry.",
"needsKey": "Create an API key and enable it for chat to send as yourself.",
"manageKeysLink": "Manage keys"
}

View File

@@ -0,0 +1,71 @@
{
"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",
"useForChat": "Use for chat on this device",
"usedForChat": "Enabled for chat ✓"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -13,5 +13,9 @@
"emptyState": "Start a conversation. Your history stays in this browser.",
"anonBanner": "You have reached the anonymous limit. Sign up for a free allocation.",
"signUp": "Sign up",
"stop": "Stop"
"stop": "Stop",
"topUp": "Top up",
"rateLimited": "Rate limited — wait a moment and retry.",
"needsKey": "Create an API key and enable it for chat to send as yourself.",
"manageKeysLink": "Manage keys"
}

View File

@@ -0,0 +1,71 @@
{
"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",
"useForChat": "Use for chat on this device",
"usedForChat": "Enabled for chat ✓"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -13,5 +13,9 @@
"emptyState": "Start a conversation. Your history stays in this browser.",
"anonBanner": "You have reached the anonymous limit. Sign up for a free allocation.",
"signUp": "Sign up",
"stop": "Stop"
"stop": "Stop",
"topUp": "Top up",
"rateLimited": "Rate limited — wait a moment and retry.",
"needsKey": "Create an API key and enable it for chat to send as yourself.",
"manageKeysLink": "Manage keys"
}

View File

@@ -0,0 +1,71 @@
{
"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",
"useForChat": "Use for chat on this device",
"usedForChat": "Enabled for chat ✓"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -13,5 +13,9 @@
"emptyState": "Start a conversation. Your history stays in this browser.",
"anonBanner": "You have reached the anonymous limit. Sign up for a free allocation.",
"signUp": "Sign up",
"stop": "Stop"
"stop": "Stop",
"topUp": "Top up",
"rateLimited": "Rate limited — wait a moment and retry.",
"needsKey": "Create an API key and enable it for chat to send as yourself.",
"manageKeysLink": "Manage keys"
}

View File

@@ -0,0 +1,71 @@
{
"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",
"useForChat": "Use for chat on this device",
"usedForChat": "Enabled for chat ✓"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -13,5 +13,9 @@
"emptyState": "Start a conversation. Your history stays in this browser.",
"anonBanner": "You have reached the anonymous limit. Sign up for a free allocation.",
"signUp": "Sign up",
"stop": "Stop"
"stop": "Stop",
"topUp": "Top up",
"rateLimited": "Rate limited — wait a moment and retry.",
"needsKey": "Create an API key and enable it for chat to send as yourself.",
"manageKeysLink": "Manage keys"
}

View File

@@ -0,0 +1,71 @@
{
"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",
"useForChat": "Use for chat on this device",
"usedForChat": "Enabled for chat ✓"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -13,5 +13,9 @@
"emptyState": "Start a conversation. Your history stays in this browser.",
"anonBanner": "You have reached the anonymous limit. Sign up for a free allocation.",
"signUp": "Sign up",
"stop": "Stop"
"stop": "Stop",
"topUp": "Top up",
"rateLimited": "Rate limited — wait a moment and retry.",
"needsKey": "Create an API key and enable it for chat to send as yourself.",
"manageKeysLink": "Manage keys"
}

View File

@@ -0,0 +1,71 @@
{
"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",
"useForChat": "Use for chat on this device",
"usedForChat": "Enabled for chat ✓"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -13,5 +13,9 @@
"emptyState": "Start a conversation. Your history stays in this browser.",
"anonBanner": "You have reached the anonymous limit. Sign up for a free allocation.",
"signUp": "Sign up",
"stop": "Stop"
"stop": "Stop",
"topUp": "Top up",
"rateLimited": "Rate limited — wait a moment and retry.",
"needsKey": "Create an API key and enable it for chat to send as yourself.",
"manageKeysLink": "Manage keys"
}

View File

@@ -0,0 +1,71 @@
{
"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",
"useForChat": "Use for chat on this device",
"usedForChat": "Enabled for chat ✓"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -13,5 +13,9 @@
"emptyState": "Start a conversation. Your history stays in this browser.",
"anonBanner": "You have reached the anonymous limit. Sign up for a free allocation.",
"signUp": "Sign up",
"stop": "Stop"
"stop": "Stop",
"topUp": "Top up",
"rateLimited": "Rate limited — wait a moment and retry.",
"needsKey": "Create an API key and enable it for chat to send as yourself.",
"manageKeysLink": "Manage keys"
}

View File

@@ -0,0 +1,71 @@
{
"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",
"useForChat": "Use for chat on this device",
"usedForChat": "Enabled for chat ✓"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -13,5 +13,9 @@
"emptyState": "Start a conversation. Your history stays in this browser.",
"anonBanner": "You have reached the anonymous limit. Sign up for a free allocation.",
"signUp": "Sign up",
"stop": "Stop"
"stop": "Stop",
"topUp": "Top up",
"rateLimited": "Rate limited — wait a moment and retry.",
"needsKey": "Create an API key and enable it for chat to send as yourself.",
"manageKeysLink": "Manage keys"
}

View File

@@ -0,0 +1,71 @@
{
"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",
"useForChat": "Use for chat on this device",
"usedForChat": "Enabled for chat ✓"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -13,5 +13,9 @@
"emptyState": "Start a conversation. Your history stays in this browser.",
"anonBanner": "You have reached the anonymous limit. Sign up for a free allocation.",
"signUp": "Sign up",
"stop": "Stop"
"stop": "Stop",
"topUp": "Top up",
"rateLimited": "Rate limited — wait a moment and retry.",
"needsKey": "Create an API key and enable it for chat to send as yourself.",
"manageKeysLink": "Manage keys"
}

View File

@@ -0,0 +1,71 @@
{
"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",
"useForChat": "Use for chat on this device",
"usedForChat": "Enabled for chat ✓"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -13,5 +13,9 @@
"emptyState": "Start a conversation. Your history stays in this browser.",
"anonBanner": "You have reached the anonymous limit. Sign up for a free allocation.",
"signUp": "Sign up",
"stop": "Stop"
"stop": "Stop",
"topUp": "Top up",
"rateLimited": "Rate limited — wait a moment and retry.",
"needsKey": "Create an API key and enable it for chat to send as yourself.",
"manageKeysLink": "Manage keys"
}

View File

@@ -0,0 +1,71 @@
{
"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",
"useForChat": "Use for chat on this device",
"usedForChat": "Enabled for chat ✓"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -13,5 +13,9 @@
"emptyState": "Start a conversation. Your history stays in this browser.",
"anonBanner": "You have reached the anonymous limit. Sign up for a free allocation.",
"signUp": "Sign up",
"stop": "Stop"
"stop": "Stop",
"topUp": "Top up",
"rateLimited": "Rate limited — wait a moment and retry.",
"needsKey": "Create an API key and enable it for chat to send as yourself.",
"manageKeysLink": "Manage keys"
}

View File

@@ -0,0 +1,71 @@
{
"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",
"useForChat": "Use for chat on this device",
"usedForChat": "Enabled for chat ✓"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -13,5 +13,9 @@
"emptyState": "Start a conversation. Your history stays in this browser.",
"anonBanner": "You have reached the anonymous limit. Sign up for a free allocation.",
"signUp": "Sign up",
"stop": "Stop"
"stop": "Stop",
"topUp": "Top up",
"rateLimited": "Rate limited — wait a moment and retry.",
"needsKey": "Create an API key and enable it for chat to send as yourself.",
"manageKeysLink": "Manage keys"
}

View File

@@ -0,0 +1,71 @@
{
"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",
"useForChat": "Use for chat on this device",
"usedForChat": "Enabled for chat ✓"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -13,5 +13,9 @@
"emptyState": "Start a conversation. Your history stays in this browser.",
"anonBanner": "You have reached the anonymous limit. Sign up for a free allocation.",
"signUp": "Sign up",
"stop": "Stop"
"stop": "Stop",
"topUp": "Top up",
"rateLimited": "Rate limited — wait a moment and retry.",
"needsKey": "Create an API key and enable it for chat to send as yourself.",
"manageKeysLink": "Manage keys"
}

View File

@@ -11,38 +11,64 @@ import {
} from "../data/repositories";
import { getFingerprint } from "../lib/fingerprint";
import { useChat } from "../lib/useChat";
import { useAuth } from "../auth/context";
const ANON_OWNER = "anon";
const ANON_MODEL = import.meta.env.VITE_ANON_MODEL || "helexa/small";
const AUTH_MODEL = import.meta.env.VITE_DEFAULT_MODEL || "helexa/balanced";
const ANON_MESSAGE_CAP = 20;
const ANON_COUNT_KEY = "anonMessageCount";
/**
* The chat workspace landing (`/`). Anonymous + fingerprinted: history and
* project organisation live entirely in IndexedDB; inference streams from
* the mesh router with no bearer (the constrained anonymous model), capped
* client-side with a sign-up nudge. Authenticated mode (bearer + full
* models, owner = account) lands in F5.
* The chat workspace landing (`/`). Anonymous visitors are fingerprinted and
* capped, streaming from the constrained public model with no bearer. Signed
* in (F5), the workspace switches its IndexedDB owner to the account, lifts
* the cap, uses the full default model, and sends the user's API key (stored
* locally, never server-side) as the bearer. History always stays in the
* browser.
*/
export default function Chat() {
const { t } = useTranslation("chat");
const owner = ANON_OWNER;
const { status, accountId } = useAuth();
const authed = status === "authed" && !!accountId;
const owner = authed ? accountId! : "anon";
const model = authed ? AUTH_MODEL : ANON_MODEL;
// Namespace anonymous data to the fingerprint (best-effort) at mount.
useEffect(() => {
void getFingerprint();
}, []);
// The user's API key for authenticated chat — stored client-side only,
// captured from the create-key modal ("use for chat on this device").
const chatApiKey = useLiveQuery(
async () => {
const m = await db.meta.get("chatApiKey");
return typeof m?.value === "string" ? m.value : undefined;
},
[],
undefined,
);
const projects = useLiveQuery(() => listProjects(owner), [owner], []);
const conversations = useLiveQuery(() => listConversations(owner), [owner], []);
const [activeId, setActiveId] = useState<string | null>(null);
// Reset the active conversation when the owner changes (login/logout).
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setActiveId(null);
}, [owner]);
const anonCount =
useLiveQuery(async () => {
const m = await db.meta.get(ANON_COUNT_KEY);
return typeof m?.value === "number" ? m.value : 0;
}, [], 0) ?? 0;
const capped = anonCount >= ANON_MESSAGE_CAP;
// The cap only applies to anonymous visitors; signed-in users are gated by
// their account allocation (enforced upstream), not a client counter.
const capped = !authed && anonCount >= ANON_MESSAGE_CAP;
// Signed in but no local key enabled for chat → can't send as yourself yet.
const needsKey = authed && !chatApiKey;
const messages = useLiveQuery(
async () => {
@@ -54,7 +80,10 @@ export default function Chat() {
[],
);
const { streaming, error, send, stop } = useChat(activeId, { model: ANON_MODEL });
const { streaming, error, send, stop } = useChat(activeId, {
model,
apiKey: authed ? chatApiKey : undefined,
});
const [draft, setDraft] = useState("");
const threadRef = useRef<HTMLDivElement>(null);
@@ -63,20 +92,22 @@ export default function Chat() {
}, [messages]);
async function newChat(projectId: string | null = null) {
const id = await createConversation(owner, ANON_MODEL, projectId);
const id = await createConversation(owner, model, projectId);
setActiveId(id);
}
async function onSend() {
const text = draft.trim();
if (!text || streaming || capped) return;
if (!text || streaming || capped || needsKey) return;
let convId = activeId;
if (!convId) {
convId = await createConversation(owner, ANON_MODEL);
convId = await createConversation(owner, model);
setActiveId(convId);
}
setDraft("");
await db.meta.put({ key: ANON_COUNT_KEY, value: anonCount + 1 });
if (!authed) {
await db.meta.put({ key: ANON_COUNT_KEY, value: anonCount + 1 });
}
await send(text);
}
@@ -162,9 +193,16 @@ export default function Chat() {
{error && (
<Alert variant="warning" className="m-2 py-2">
{error.message}{" "}
{(error.code === "insufficient_quota" ||
error.code === "rate_limit_exceeded" ||
capped) && <a href="/register">{t("signUp")}</a>}
{error.code === "insufficient_quota" ? (
// Hard balance exhausted → top up (authed) or sign up (anon).
<a href={authed ? "/account" : "/register"}>
{authed ? t("topUp") : t("signUp")}
</a>
) : error.code === "rate_limit_exceeded" ? (
<span className="text-muted">{t("rateLimited")}</span>
) : (
!authed && <a href="/register">{t("signUp")}</a>
)}
</Alert>
)}
@@ -174,6 +212,12 @@ export default function Chat() {
</Alert>
)}
{needsKey && !error && (
<Alert variant="info" className="m-2 py-2">
{t("needsKey")} <a href="/account/keys">{t("manageKeysLink")}</a>
</Alert>
)}
<Form
className="d-flex gap-2 p-2 border-top"
onSubmit={(e) => {
@@ -185,8 +229,10 @@ export default function Chat() {
as="textarea"
rows={1}
value={draft}
disabled={capped}
placeholder={capped ? t("anonBanner") : t("inputPlaceholder")}
disabled={capped || needsKey}
placeholder={
capped ? t("anonBanner") : needsKey ? t("needsKey") : t("inputPlaceholder")
}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
@@ -200,7 +246,7 @@ export default function Chat() {
{t("stop")}
</Button>
) : (
<Button type="submit" variant="primary" disabled={capped || !draft.trim()}>
<Button type="submit" variant="primary" disabled={capped || needsKey || !draft.trim()}>
{t("send")}
</Button>
)}

View File

@@ -0,0 +1,175 @@
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 { db } from "../../data/db";
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 [usedForChat, setUsedForChat] = 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>
{/* Store the raw key locally (this browser only) so the chat can
use it as your bearer — consistent with no server-side secrets. */}
<Button
variant="link"
className="px-0 mt-2"
onClick={async () => {
if (!created) return;
await db.meta.put({ key: "chatApiKey", value: created.key });
await db.meta.put({ key: "chatApiKeyId", value: created.id });
setUsedForChat(true);
}}
>
{usedForChat ? t("keys.usedForChat") : t("keys.useForChat")}
</Button>
</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>
);
}