From c05aa7a2117f54be55e31dc1e1f89858a861640d Mon Sep 17 00:00:00 2001 From: Anais Saraiva Date: Mon, 21 Sep 2026 13:36:25 +0200 Subject: [PATCH] update --- .env.example | 7 +- README.md | 11 +- anoying_promt | 18 ++ docs/ARCHITECTURE.md | 7 +- docs/FRONTEND.md | 7 +- docs/OPERATIONS.md | 18 +- frontend/src/App.tsx | 12 +- frontend/src/components/AdminCenter.tsx | 168 +++++++++++++++++++ frontend/src/components/AdminPanel.tsx | 209 ++++++++++++++++++++++-- frontend/src/components/Login.tsx | 28 +++- frontend/src/components/Sidebar.tsx | 8 +- frontend/src/lib/api.ts | 38 +++++ src/config.ts | 6 + src/drivers/apiBase.ts | 2 +- src/drivers/apis.ts | 17 ++ src/drivers/ollama.ts | 2 +- src/drivers/registry.ts | 9 +- src/index.ts | 1 + 18 files changed, 532 insertions(+), 36 deletions(-) create mode 100644 frontend/src/components/AdminCenter.tsx diff --git a/.env.example b/.env.example index 7dbb9f0..101929a 100644 --- a/.env.example +++ b/.env.example @@ -43,12 +43,17 @@ RATE_LIMIT_MAX=5 RATE_LIMIT_WINDOW_MIN=10 # --- drivers --- -# Priority (global): ollama -> mistral -> puppeteer-openai(stub) -> deepseek -> anthropic -> openai +# Priority (global): ollama -> mistral -> glm -> puppeteer-openai(stub) -> deepseek -> anthropic -> openai OLLAMA_HOST=http://10.66.66.4:11434 OLLAMA_ENABLED=true # Stubs below: leave empty to keep disabled. Implemented last per spec. MISTRAL_API_KEY= MISTRAL_ENABLED=false +# GLM (Zhipu AI, OpenAI-compatible). Key from https://z.ai ; mainland China +# users point GLM_BASE_URL at https://open.bigmodel.cn/api/paas/v4 instead. +GLM_API_KEY= +GLM_ENABLED=false +# GLM_BASE_URL=https://api.z.ai/api/paas/v4 DEEPSEEK_API_KEY= DEEPSEEK_ENABLED=false ANTHROPIC_API_KEY= diff --git a/README.md b/README.md index 6ee8300..2a1bb14 100644 --- a/README.md +++ b/README.md @@ -9,8 +9,8 @@ accounts, server-side chats and public shares. No cookies — Bearer keys in Driver priority: ``` -1. ollama → 2. mistral API → 3. puppeteer-openai (stub, not implemented) -→ 4. deepseek → 5. anthropic → 6. openai +1. ollama → 2. mistral API → 3. glm API → 4. puppeteer-openai (stub, not implemented) +→ 5. deepseek → 6. anthropic → 7. openai ``` Docs: [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) (backend, drivers, DB) · @@ -51,12 +51,14 @@ from the **Accounts** panel (each gets a one-time access key to hand over). | Method | Route | Auth | Notes | |---|---|---|---| | POST | `/api/auth/login` | rate-limited (5/10min/IP) | `{username, key}` → `{token, expiresAt, user}` | +| POST | `/api/auth/register` | public, rate-limited | self-registration (403 when disabled by admin) → `{user, key}` | | GET | `/api/auth/verify` | Bearer | → `{ok, user}` | | POST | `/api/auth/logout` | Bearer | revokes token | | GET/PATCH | `/api/me` | Bearer | profile: displayName, avatarUrl, theme | | GET/POST | `/api/admin/users` | admin | create returns `{user, key}` (key shown once) | | PATCH/DELETE | `/api/admin/users/:id` | admin | edit profile / delete + cascade | | POST | `/api/admin/users/:id/regenerate` | admin | new key, old sessions revoked | +| GET/PATCH | `/api/admin/settings` | admin | feature flags: registrationEnabled, ocrEnabled | | GET/POST | `/api/conversations` | Bearer | server-persisted chats (title, topic, model) | | GET/PATCH/DELETE | `/api/conversations/:id` | owner | incl. messages on GET | | POST/GET/DELETE | `/api/conversations/:id/share` | owner | public link `/share/:publicId` | @@ -70,8 +72,9 @@ reply into that conversation (ownership enforced) and auto-titles new chats. ## Database (SQLite, Bun native) `$DATA_DIR/kisassistant.db` (WAL mode). Tables: `users` (id, username, -display_name, avatar_url, theme, key_hash), `sessions` (hashed Bearer tokens), -`conversations` (id, user, title, topic, model), `messages`, `shares` +display_name, avatar_url, theme, email, key_hash), `sessions` (hashed Bearer tokens), +`conversations` (id, user, title, topic, model), `messages`, `shares`, +`resets` (recovery tokens), `settings` (feature flags) (conv ↔ public id). No migration system — schema is created idempotently at boot. ## Drivers (backend layout) diff --git a/anoying_promt b/anoying_promt index a492c49..935df77 100644 --- a/anoying_promt +++ b/anoying_promt @@ -9,3 +9,21 @@ or something original but in the actual theme and Adapative to phone make a better settings overview,with larger modal in screen . with categories, section for searching --- UPLOAD URL FOR PFP MUST BE A READ ONLY IN USER . NOW CAUSE OF THE OWN CDN. USER SHOULDNT SEE THAT VALUE /EDIT IT ANYMORe. + +--- UPDATE ABOUT REGISTRATION + +Another feature for rehistration.: +A WishList. Like you reserv username, mail (needed) +in AdminCenter, a specific category for wish access : accepting/refusing/review a request. +Can refusing with a reason / accepting with reason / reviewing : + - make a /review page + the user want to being granted access can arguing about why him + - admin can see messages, responding, like a minimal ticket syste, + and so accepting reusing. + - the user is who want to earn access receive emails about the current status of the situation + Refusing -with reasons etc + Accepting - with/without reasons etc + mail or reviewing + each new message in the conversations makes a new mail forwarded by (like real ticket) + precise that theses mail arent the official way and need to be responded into the official site (chat.kisakay.com/review) + makes beautiful simple easy - by design HTML for each type of mail diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b3cf4f7..0ed2df7 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -46,6 +46,7 @@ Created idempotently at boot in `getDb()` (no migration framework; keep it so). | `users` | `id, username UNIQUE, display_name, avatar_url, theme, email, key_hash, created_at`. The `admin` row is bootstrapped (its `key_hash` is unused — admin auth goes through `APP_PASSWORD`). | | `sessions` | `token_hash PK, user_id, created_at, expires_at`. Bearer tokens, 256-bit, hashed with sha256. Expired rows are purged lazily on access. | | `resets` | `token_hash PK, user_id, created_at, expires_at`. One-time key-recovery tokens (single use, consumed on POST). | +| `settings` | `key PK, value, updated_at`. Admin-controlled feature flags (`registration_enabled` default off, `tools_ocr_enabled` default on). | | `conversations` | `id, user_id, title, topic, model, created_at, updated_at`. | | `messages` | `id AUTOINCREMENT, conv_id → conversations ON DELETE CASCADE, role, content, created_at`. | | `shares` | `conv_id PK → conversations ON DELETE CASCADE, public_id UNIQUE, created_at`. | @@ -58,6 +59,10 @@ conversations → sessions → user) inside a transaction. - `POST /api/auth/login { username, key }`, sliding-window rate limit per IP (`RATE_LIMIT_MAX` / `RATE_LIMIT_WINDOW_MIN`, default 5 per 10 min) + 400 ms delay on failure. Success clears the IP counter and issues a fresh token. +- `POST /api/auth/register { username, displayName?, email? }` — public + self-registration, guarded by the `registration_enabled` setting (403 when + off), same rate limit and validation as admin creation. Returns `{user, key}` + with the key shown once. - `admin` is authenticated against `APP_PASSWORD` (timing-safe); users against their `ka_…` access key (timing-safe against the stored hash). - Keys are generated with `crypto.randomBytes` (`ka_` + 24 bytes base64url) and @@ -93,7 +98,7 @@ only ever sees reviewed text blocks, never raw files. ## Drivers`src/drivers/`: `LLMDriver` interface (`listModels`, `chat`, `chatStream`), `DriverRegistry` with global priority -`ollama → mistral → puppeteer-openai (stub) → deepseek → anthropic → openai`. +`ollama → mistral → glm → puppeteer-openai (stub) → deepseek → anthropic → openai`. Models are addressed as `"driver:model"` (split on the first `:`). - `OllamaDriver`: model discovery via `GET {OLLAMA_HOST}/api/tags`, chat via diff --git a/docs/FRONTEND.md b/docs/FRONTEND.md index eac8863..9b8eca2 100644 --- a/docs/FRONTEND.md +++ b/docs/FRONTEND.md @@ -43,7 +43,12 @@ read-only `SharePage` (no auth), everything else renders the authenticated app. copy, unshare), `SettingsModal` (display name, avatar upload box with drag & drop, avatar URL, theme). - `AdminPanel.tsx` — No-KYC account management: create (key shown once), - regenerate (revokes sessions), edit, delete with confirm. + regenerate (revokes sessions), edit, delete with confirm. Renders bare + (no modal shell) when embedded in the Admin Center page. +- `AdminCenter.tsx` — `/admin` route (admin gate + locked screen otherwise): + tabbed hub (Accounts / Features / Mail) reusing `AdminPanel`, server + feature `Switch`es (`registrationEnabled`, `ocrEnabled` via + `/api/admin/settings`), SMTP status card. Follows the platform theme. - `SharePage.tsx` — public read-only view, fetches `/api/share/:id` unauthenticated. - `Markdown.tsx` — `react-markdown` + GFM; code blocks get a language label and a copy button. diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index f02a6d1..b24d6c4 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -24,7 +24,8 @@ Copy `.env.example` to `.env`. Full list: | `RESET_TTL_MIN` | `60` | reset-link lifetime (single use) | | `RATE_LIMIT_MAX` / `RATE_LIMIT_WINDOW_MIN` | `5` / `10` | login rate limit per IP | | `OLLAMA_HOST` / `OLLAMA_ENABLED` | `http://10.66.66.4:11434` / `true` | local-model driver | -| `MISTRAL_*`, `DEEPSEEK_*`, `ANTHROPIC_*`, `OPENAI_*` | disabled | API drivers: set `*_ENABLED=true` **and** `*_API_KEY` | +| `MISTRAL_*`, `GLM_*`, `DEEPSEEK_*`, `ANTHROPIC_*`, `OPENAI_*` | disabled | API drivers: set `*_ENABLED=true` **and** `*_API_KEY` | +| `GLM_BASE_URL` | `https://api.z.ai/api/paas/v4` | Zhipu platform override (mainland China: `https://open.bigmodel.cn/api/paas/v4`) | ## Local development @@ -51,6 +52,21 @@ unshare. See `AGENTS.md`. 5. Rotate a compromised key with the regenerate button (old sessions die); delete removes the account with all its chats and shares. +## Admin Center (`/admin`) & self-registration + +The sidebar **Admin Center** button (admin only) opens the `/admin` page with +three tabs: **Accounts** (same manager as before, incl. the Ollama model +catalog), **Features** (server-side switches), **Mail** (SMTP status). + +- `registration_enabled` (default off): when on, the login page shows a + working **Register** button — anyone can create an account (key shown once) + via `POST /api/auth/register`. When off, the button is greyed with the + tooltip *"Registration currently disabled on this platform"* and the + endpoint answers 403. Flipped live, no restart. +- `tools_ocr_enabled` (default on): admin kill-switch for the OCR tool. +- Flags live in the `settings` SQLite table, edited via + `GET/PATCH /api/admin/settings` (admin Bearer only). + ## Key recovery via email (optional) Set the `SMTP_*` vars (and the real public `APP_URL`) to enable it; otherwise diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 11d040a..49110c9 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -6,7 +6,7 @@ import { Login } from "./components/Login.tsx"; import { Sidebar } from "./components/Sidebar.tsx"; import { Chat } from "./components/Chat.tsx"; import { ConvEditDialog, FilePreviewModal, SettingsModal, ShareModal } from "./components/dialogs.tsx"; -import { AdminPanel } from "./components/AdminPanel.tsx"; +import { AdminCenter } from "./components/AdminCenter.tsx"; import { SharePage } from "./components/SharePage.tsx"; import { ResetPage } from "./components/ResetPage.tsx"; import { ConfirmDialog } from "./components/ui.tsx"; @@ -19,6 +19,7 @@ function shareIdFromPath(): string | null { // Module-level: the path never changes without a full reload, so hook order stays stable. const SHARE_ID = typeof window !== "undefined" ? shareIdFromPath() : null; const RESET_TOKEN = typeof window !== "undefined" ? resetTokenFromPath() : null; +const IS_ADMIN_PAGE = typeof window !== "undefined" && /^\/admin\/?$/.test(window.location.pathname); function resetTokenFromPath(): string | null { const m = window.location.pathname.match(/^\/reset\/([A-Za-z0-9_-]{6,80})\/?$/); @@ -35,7 +36,7 @@ function applyTheme(theme: string) { export function App() { const [user, setUser] = useState(null); - const [checking, setChecking] = useState(!SHARE_ID && !RESET_TOKEN && !!getToken()); + const [checking, setChecking] = useState(!SHARE_ID && !RESET_TOKEN && !IS_ADMIN_PAGE && !!getToken()); const [models, setModels] = useState([]); const [model, setModel] = useState(""); const [convs, setConvs] = useState([]); @@ -49,7 +50,6 @@ export function App() { const [shareConv, setShareConv] = useState(null); const [deleteConv, setDeleteConv] = useState(null); const [settingsOpen, setSettingsOpen] = useState(false); - const [adminOpen, setAdminOpen] = useState(false); const [attachments, setAttachments] = useState([]); const [attachError, setAttachError] = useState(null); const [filePreview, setFilePreview] = useState(null); @@ -57,6 +57,9 @@ export function App() { const [ocrAvailable, setOcrAvailable] = useState(false); // Public routes: no auth needed. IDs are constant for the page lifetime. + if (IS_ADMIN_PAGE) { + return ; + } if (RESET_TOKEN) { return ; } @@ -277,7 +280,7 @@ export function App() { onShare={setShareConv} onDelete={setDeleteConv} onOpenSettings={() => { setSettingsOpen(true); setMobileNav(false); }} - onOpenAdmin={() => { setAdminOpen(true); setMobileNav(false); }} + onOpenAdmin={() => { window.location.href = "/admin"; }} onLogout={logout} mobileOpen={mobileNav} onCloseMobile={() => setMobileNav(false)} @@ -332,7 +335,6 @@ export function App() { applyTheme(u.theme); }} /> - setAdminOpen(false)} /> ); } diff --git a/frontend/src/components/AdminCenter.tsx b/frontend/src/components/AdminCenter.tsx new file mode 100644 index 0000000..6c8a8cb --- /dev/null +++ b/frontend/src/components/AdminCenter.tsx @@ -0,0 +1,168 @@ +import { useEffect, useState } from "react"; +import { ArrowLeft, LayoutDashboard, Mail, ShieldAlert, SlidersHorizontal, Users } from "lucide-react"; +import { api } from "../lib/api.ts"; +import { AdminPanel } from "./AdminPanel.tsx"; +import { Logo, Spinner, Switch } from "./ui.tsx"; +import { cn } from "../lib/cn.ts"; + +type Tab = "accounts" | "features" | "mail"; + +function applyTheme(theme: string) { + const root = document.documentElement; + const dark = theme === "dark" || (theme !== "light" && window.matchMedia("(prefers-color-scheme: dark)").matches); + root.classList.toggle("dark", dark); + root.style.colorScheme = dark ? "dark" : "light"; +} + +const TABS: { id: Tab; label: string; icon: typeof Users }[] = [ + { id: "accounts", label: "Accounts", icon: Users }, + { id: "features", label: "Features", icon: SlidersHorizontal }, + { id: "mail", label: "Mail", icon: Mail }, +]; + +export function AdminCenter() { + const [allowed, setAllowed] = useState(null); + const [tab, setTab] = useState("accounts"); + const [settings, setSettings] = useState<{ registrationEnabled: boolean; ocrEnabled: boolean } | null>(null); + const [mail, setMail] = useState<{ recovery: boolean; from?: string } | null>(null); + const [error, setError] = useState(""); + const [saving, setSaving] = useState(false); + + useEffect(() => { + api.verify() + .then((r) => { + if (!r.user.isAdmin) { + setAllowed(false); + return; + } + setAllowed(true); + applyTheme(r.user.theme); + api.adminGetSettings().then((s) => setSettings(s.settings)).catch((e) => setError(e instanceof Error ? e.message : "Load failed")); + api.methods().then((m) => setMail(m)).catch(() => {}); + }) + .catch(() => setAllowed(false)); + }, []); + + async function toggle(patch: { registrationEnabled?: boolean; ocrEnabled?: boolean }) { + setSaving(true); + setError(""); + try { + const res = await api.adminPatchSettings(patch); + setSettings(res.settings); + } catch (e) { + setError(e instanceof Error ? e.message : "Save failed"); + } finally { + setSaving(false); + } + } + + if (allowed === null) { + return
Checking admin access…
; + } + + if (!allowed) { + return ( +
+
+ +

Admins only

+

Log in with the admin account to open the Admin Center.

+ Back to login +
+
+ ); + } + + return ( +
+
+
+ + + + +

+ + Admin Center +

+
+
+
+ {TABS.map((t) => ( + + ))} +
+
+
+ +
+ {error &&

{error}

} + + {tab === "accounts" && ( +
+ {}} /> +
+ )} + + {tab === "features" && ( +
+
+
+

Public registration

+

Show the Register button on the login page. Off = admin-created accounts only.

+
+ toggle({ registrationEnabled: v })} /> +
+
+
+

OCR tool

+

Server-side image transcription for attachments. Requires the tesseract binary.

+
+ toggle({ ocrEnabled: v })} /> +
+ {saving &&

Saving…

} + {!settings && !error &&

Loading…

} +
+ )} + + {tab === "mail" && ( +
+
+ + + +
+

Key recovery emails

+

+ {mail == null ? "Checking…" : mail.recovery ? `Enabled — sent from ${mail.from ?? "the configured sender"}` : "Disabled — set SMTP_HOST on the server to enable"} +

+
+ + {mail?.recovery ? "On" : "Off"} + +
+

+ SMTP credentials live in the server environment (see docs/OPERATIONS.md), never in this UI. + Users set their recovery address in profile settings or at account creation. +

+
+ )} +
+
+ ); +} diff --git a/frontend/src/components/AdminPanel.tsx b/frontend/src/components/AdminPanel.tsx index 465cc95..165e26a 100644 --- a/frontend/src/components/AdminPanel.tsx +++ b/frontend/src/components/AdminPanel.tsx @@ -1,10 +1,184 @@ -import { useEffect, useState } from "react"; -import { KeyRound, Pencil, RefreshCw, Trash2, UserPlus, Users } from "lucide-react"; +import { useEffect, useState, type FC, type ReactNode } from "react"; +import { Bot, Download, HardDrive, KeyRound, Pencil, RefreshCw, Trash2, UserPlus, Users } from "lucide-react"; import { api } from "../lib/api.ts"; -import type { User } from "../lib/types.ts"; +import type { DriverModel, User } from "../lib/types.ts"; import { Avatar, Button, ConfirmDialog, CopyButton, Field, Input, Modal, Picker, Spinner } from "./ui.tsx"; -export function AdminPanel({ open, onClose }: { open: boolean; onClose: () => void }) { +/** Curated Ollama library catalog (admin downloads only). */ +const OLLAMA_CATALOG: { name: string; desc: string; sizes: string[] }[] = [ + { name: "llama3.2", desc: "Meta's compact everyday model", sizes: ["1b", "3b"] }, + { name: "llama3.1", desc: "Meta's general-purpose workhorse", sizes: ["8b", "70b"] }, + { name: "qwen2.5", desc: "Alibaba's multilingual all-rounder", sizes: ["0.5b", "1.5b", "7b", "14b", "32b"] }, + { name: "gemma2", desc: "Google's efficient open model", sizes: ["2b", "9b", "27b"] }, + { name: "mistral", desc: "Mistral 7B — fast and capable", sizes: ["7b"] }, + { name: "phi3", desc: "Microsoft's small research model", sizes: ["3.8b", "14b"] }, + { name: "deepseek-r1", desc: "Reasoning model (chain-of-thought)", sizes: ["1.5b", "7b", "8b", "14b", "32b"] }, + { name: "codellama", desc: "Code completion and chat", sizes: ["7b", "13b", "34b"] }, + { name: "llava", desc: "Vision model (image understanding)", sizes: ["7b", "13b", "34b"] }, + { name: "nomic-embed-text", desc: "Text embeddings", sizes: [] }, +]; + +/* ---------- Ollama model catalog (admin) ---------- */ + +export function OllamaModelsModal({ open, onClose }: { open: boolean; onClose: () => void }) { + const [installed, setInstalled] = useState([]); + const [pullName, setPullName] = useState(""); + const [pullStatus, setPullStatus] = useState(""); + const [pullPct, setPullPct] = useState(null); + const [pulling, setPulling] = useState(false); + const [error, setError] = useState(""); + const [deleteModel, setDeleteModel] = useState(null); + const [sizeOverrides, setSizeOverrides] = useState>({}); + + async function refreshInstalled() { + try { + const res = await api.models(); + setInstalled(res.models.filter((m) => m.driver === "ollama")); + } catch { /* ignore */ } + } + + useEffect(() => { + if (open) { + setError(""); + setPullStatus(""); + setPullPct(null); + setPulling(false); + refreshInstalled(); + } + }, [open]); + + async function pull(fullName: string) { + if (pulling || !fullName.trim()) return; + setPulling(true); + setError(""); + setPullStatus("Contacting Ollama…"); + setPullPct(null); + try { + await api.ollamaPull(fullName.trim(), (p) => { + if (p.error) { setError(p.error); return; } + setPullStatus(p.status || "Pulling…"); + if (p.total && p.completed) setPullPct(Math.min(100, Math.round((p.completed / p.total) * 100))); + else setPullPct(null); + }); + setPullStatus("Done"); + setPullPct(100); + await refreshInstalled(); + } catch (err) { + setError(err instanceof Error ? err.message : "Pull failed"); + } finally { + setPulling(false); + } + } + + async function removeModel(name: string) { + try { + await api.ollamaDelete(name); + await refreshInstalled(); + } catch (err) { + setError(err instanceof Error ? err.message : "Delete failed"); + } + } + + return ( + <> + {} : onClose} title="Ollama models" icon={Bot}> +

+ Download models from the Ollama library to your Ollama host, or remove local ones. Admin only. +

+ {error &&

{error}

} + + {pulling && ( +
+

+ + Pulling {pullName} +

+

{pullStatus}

+ {pullPct !== null && ( +
+
+
+ )} +
+ )} + +
+
+ + setPullName(e.target.value)} + placeholder="llama3.2:3b" + disabled={pulling} + onKeyDown={(e) => e.key === "Enter" && pull(pullName)} + /> + +
+ +
+ +

Installed ({installed.length})

+
    + {installed.length === 0 &&
  • No local Ollama models yet.
  • } + {installed.map((m) => ( +
  • + {m.label || m.id} + +
  • + ))} +
+ +

Catalog

+
    + {OLLAMA_CATALOG.map((m) => { + const size = sizeOverrides[m.name] ?? (m.sizes.length > 0 ? m.sizes[0]! : ""); + const tag = size ? `${m.name}:${size}` : m.name; + return ( +
  • + + {m.name} + {m.desc} + + {m.sizes.length > 0 && ( + + setSizeOverrides((prev) => ({ ...prev, [m.name]: v }))} + align="right" + options={m.sizes.map((s) => ({ value: s, label: s }))} + /> + + )} + +
  • + ); + })} +
+ + + setDeleteModel(null)} + title={`Delete ${deleteModel}?`} + message="The model is removed from the Ollama host. Pull it again anytime." + onConfirm={() => deleteModel && removeModel(deleteModel)} + /> + + ); +} + +export function AdminPanel({ open, onClose, bare }: { open: boolean; onClose: () => void; bare?: boolean }) { const [users, setUsers] = useState([]); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); @@ -12,6 +186,7 @@ export function AdminPanel({ open, onClose }: { open: boolean; onClose: () => vo const [freshKey, setFreshKey] = useState<{ username: string; key: string } | null>(null); const [deleteTarget, setDeleteTarget] = useState(null); const [editTarget, setEditTarget] = useState(null); + const [modelsOpen, setModelsOpen] = useState(false); // create form const [username, setUsername] = useState(""); @@ -32,13 +207,13 @@ export function AdminPanel({ open, onClose }: { open: boolean; onClose: () => vo } useEffect(() => { - if (open) { + if (open || bare) { refresh(); setShowCreate(false); setFreshKey(null); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [open ]); + }, [open, bare ]); async function create(e: React.FormEvent) { e.preventDefault(); @@ -75,9 +250,15 @@ export function AdminPanel({ open, onClose }: { open: boolean; onClose: () => vo } } + // Bare mode embeds the same manager in the /admin Center page instead + // of a modal — no logic duplicated. + const Shell: FC<{ children: ReactNode }> = bare + ? ({ children }) =>
{children}
+ : ({ children }) => {children}; + return ( <> - +

No-KYC accounts: you create a username, hand the access key to its owner once. Keys are stored hashed — regenerate to rotate.

@@ -94,9 +275,14 @@ export function AdminPanel({ open, onClose }: { open: boolean; onClose: () => vo )} {!showCreate ? ( - +
+ + +
) : (
@@ -135,7 +321,7 @@ export function AdminPanel({ open, onClose }: { open: boolean; onClose: () => vo ))} )} - + vo /> setEditTarget(null)} onSaved={refresh} /> + setModelsOpen(false)} /> ); } diff --git a/frontend/src/components/Login.tsx b/frontend/src/components/Login.tsx index 62bd060..febd251 100644 --- a/frontend/src/components/Login.tsx +++ b/frontend/src/components/Login.tsx @@ -1,9 +1,9 @@ import { useEffect, useState } from "react"; -import { ArrowRight, KeyRound, ShieldCheck, User, Zap } from "lucide-react"; +import { ArrowRight, KeyRound, ShieldCheck, User, UserPlus, Zap } from "lucide-react"; import { api, setToken } from "../lib/api.ts"; import type { User as UserType } from "../lib/types.ts"; import { Button, Input, Logo, Spinner } from "./ui.tsx"; -import { RecoverDialog } from "./dialogs.tsx"; +import { RecoverDialog, RegisterDialog } from "./dialogs.tsx"; const PERKS = [ { icon: ShieldCheck, text: "Private by design — no cookies, no tracking" }, @@ -18,11 +18,14 @@ export function Login({ onLogin }: { onLogin: (user: UserType) => void }) { const [recoverOpen, setRecoverOpen] = useState(false); const [recoveryOn, setRecoveryOn] = useState(false); const [recoveryFrom, setRecoveryFrom] = useState(undefined); + const [registerOpen, setRegisterOpen] = useState(false); + const [registrationOn, setRegistrationOn] = useState(false); useEffect(() => { api.methods().then((m) => { setRecoveryOn(m.recovery); setRecoveryFrom(m.from); + setRegistrationOn(m.registration); }).catch(() => {}); }, []); @@ -89,6 +92,27 @@ export function Login({ onLogin }: { onLogin: (user: UserType) => void }) { {busy ? : (<>Unlock )} +
+ + New here? + +
+ + setRegisterOpen(false)} + onDone={(u, k) => { setUsername(u); setKey(k); }} + /> {recoveryOn && (
{user.isAdmin && ( - )}