diff --git a/README.md b/README.md index 66c81f9..e2caaf1 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,9 @@ from the **Accounts** panel (each gets a one-time access key to hand over). | GET | `/api/conversations/search?q=` | Bearer | search titles, topics + old prompts (with snippet) | | POST | `/api/admin/ollama/pull` | admin | pull a model from the Ollama library (NDJSON progress) | | DELETE | `/api/admin/ollama/models/:name` | admin | remove a local Ollama model | +| POST | `/api/access/request` | public | reserve username+email with a motivation → ticket (`/review/:id`) | +| GET/POST | `/api/access/ticket/:id` (+`/message`) | ticket bearer | follow + reply on an access request | +| GET/PATCH/POST | `/api/admin/access…` | admin | wishlist triage: list, accept/refuse/review, reply | | GET/PATCH/DELETE | `/api/conversations/:id` | owner | incl. messages on GET | | POST/GET/DELETE | `/api/conversations/:id/share` | owner | public link `/share/:publicId` | | GET | `/api/share/:publicId` | **public** | read-only shared chat JSON | diff --git a/anoying_promt b/anoying_promt index 3c750f3..6b0a342 100644 --- a/anoying_promt +++ b/anoying_promt @@ -30,7 +30,7 @@ Can refusing with a reason / accepting with reason / reviewing : --- UX bugs / improvements: class="flex w-full items-center gap-2.5 rounded-xl p-1.5 text-left transition hover:bg-stone-100 dark:hover:bg-zinc-800" -the actual on:hover makes the two buttons together, seprared it goodly +the actual on:hover makes the settings button very next to the border hover clor Use the new flower svgs built-in for the ai logo. Only one declaration of the logo, not copy-paste everywhere needing it. One constant and imported anywhere Make the loading screen smoother @@ -39,3 +39,8 @@ Make the loading screen smoother --- SETTINGS REWAMPING: Make a paginations system for users into the Admin Center. Filtering, by account timetamp, alphabetily filtered, unverified/etc... + + +---(NEW) BUGS WITH SETTINGS: +glossian blur its diffent at each settings section opened: need to unify it +padding for buttons into admin center at pagination is so tigh. fix it diff --git a/docs/FRONTEND.md b/docs/FRONTEND.md index 9b8eca2..4d231b6 100644 --- a/docs/FRONTEND.md +++ b/docs/FRONTEND.md @@ -43,8 +43,9 @@ 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. Renders bare - (no modal shell) when embedded in the Admin Center page. + regenerate (revokes sessions), edit, delete with confirm, plus server-side + search / sort (newest, oldest, A–Z, Z–A) / email filter / pagination. + 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 diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index d72c636..0af7294 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -55,8 +55,9 @@ unshare. See `AGENTS.md`. ## 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). +four tabs: **Accounts** (same manager as before, incl. the Ollama model +catalog), **Access** (access-request wishlist triage, see below), +**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) @@ -67,6 +68,28 @@ catalog), **Features** (server-side switches), **Mail** (SMTP status). - Flags live in the `settings` SQLite table, edited via `GET/PATCH /api/admin/settings` (admin Bearer only). +## Access-request wishlist (registration approval queue) + +When public registration is off, visitors can still reserve a username via +**Request access** on the login page: username + email (required) + a +motivation message (10+ chars). This creates a ticket (`access_requests` + +`access_messages` tables) with a personal page at `/review/` (unguessable +UUID, same pattern as share links). + +- Requester: follows the decision, argues their case and replies on the + `/review` page. Closed tickets (accepted/refused) are read-only. +- Admin: **Access** tab in the Admin Center — filter by status, open a ticket, + reply (emailed to the requester), and set `pending → reviewing → accepted / + refused` with a reason. **Accepting creates the account** and returns the + access key once (also emailed). Terminal tickets can't be reopened. +- Email (needs `SMTP_*`): received-confirmation, reviewing/accepted/refused + notices, and every admin reply — all HTML, all stating email is + notification-only and replies belong on the `/review` page. +- Routes: `POST /api/access/request` (public, rate-limited), + `GET /api/access/ticket/:id` + `POST .../message` (public, ticket bearer), + `GET /api/admin/access`, `GET/PATCH /api/admin/access/:id`, + `POST /api/admin/access/:id/message` (admin). + ## Key recovery via email (optional) Set the `SMTP_*` vars (and the real public `APP_URL`) to enable it; otherwise diff --git a/frontend/index.html b/frontend/index.html index 0a93baf..66ff87c 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -22,7 +22,7 @@ property="og:description" content="A private, self-hosted AI chat assistant: local Ollama models + cloud LLMs, server-side chats, public shares, No-KYC accounts." /> - + @@ -32,10 +32,10 @@ name="twitter:description" content="A private, self-hosted AI chat assistant: local Ollama models + cloud LLMs, server-side chats and public shares." /> - + - - + +
diff --git a/frontend/public/logo.jpg b/frontend/public/logo.jpg deleted file mode 100644 index 3297738..0000000 Binary files a/frontend/public/logo.jpg and /dev/null differ diff --git a/frontend/public/logo.svg b/frontend/public/logo.svg new file mode 100644 index 0000000..fcba0ff --- /dev/null +++ b/frontend/public/logo.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 65423e9..9bfd9c8 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -9,6 +9,7 @@ import { ConvEditDialog, FilePreviewModal, SettingsModal, ShareModal } from "./c import { AdminCenter } from "./components/AdminCenter.tsx"; import { SharePage } from "./components/SharePage.tsx"; import { ResetPage } from "./components/ResetPage.tsx"; +import { ReviewPage } from "./components/ReviewPage.tsx"; import { ConfirmDialog, LoadingScreen } from "./components/ui.tsx"; function shareIdFromPath(): string | null { @@ -19,6 +20,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 REVIEW_ID = typeof window !== "undefined" ? reviewIdFromPath() : null; const IS_ADMIN_PAGE = typeof window !== "undefined" && /^\/admin\/?$/.test(window.location.pathname); function resetTokenFromPath(): string | null { @@ -26,6 +28,11 @@ function resetTokenFromPath(): string | null { return m ? m[1]! : null; } +function reviewIdFromPath(): string | null { + const m = window.location.pathname.match(/^\/review\/([0-9a-f-]{36})\/?$/i); + return m ? m[1]! : null; +} + function applyTheme(theme: string) { const root = document.documentElement; const dark = @@ -36,7 +43,7 @@ function applyTheme(theme: string) { export function App() { const [user, setUser] = useState(null); - const [checking, setChecking] = useState(!SHARE_ID && !RESET_TOKEN && !IS_ADMIN_PAGE && !!getToken()); + const [checking, setChecking] = useState(!SHARE_ID && !RESET_TOKEN && !REVIEW_ID && !IS_ADMIN_PAGE && !!getToken()); const [models, setModels] = useState([]); const [model, setModel] = useState(""); const [convs, setConvs] = useState([]); @@ -63,6 +70,9 @@ export function App() { if (RESET_TOKEN) { return ; } + if (REVIEW_ID) { + return ; + } if (SHARE_ID) { return ; } diff --git a/frontend/src/components/AccessRequests.tsx b/frontend/src/components/AccessRequests.tsx new file mode 100644 index 0000000..1bf884d --- /dev/null +++ b/frontend/src/components/AccessRequests.tsx @@ -0,0 +1,242 @@ +import { useEffect, useState } from "react"; +import { CheckCircle2, Clock, Inbox, Search, SendHorizontal, Undo2, XCircle } from "lucide-react"; +import { api, type AccessMessage, type AccessRequest, type AccessStatus } from "../lib/api.ts"; +import { Button, CopyButton, Field, Input, Spinner } from "./ui.tsx"; +import { cn } from "../lib/cn.ts"; + +const STATUS_STYLE: Record = { + pending: { label: "Pending", cls: "bg-amber-500/10 text-amber-700 dark:text-amber-400" }, + reviewing: { label: "Reviewing", cls: "bg-sky-500/10 text-sky-700 dark:text-sky-400" }, + accepted: { label: "Accepted", cls: "bg-accent-500/10 text-accent-700 dark:text-accent-400" }, + refused: { label: "Refused", cls: "bg-red-500/10 text-red-600 dark:text-red-400" }, +}; + +type Filter = "all" | AccessStatus; +const FILTERS: Filter[] = ["all", "pending", "reviewing", "accepted", "refused"]; + +/** Admin triage for the access-request wishlist: accept / refuse / review. */ +export function AccessRequestsPanel() { + const [requests, setRequests] = useState<(AccessRequest & { message_count: number })[]>([]); + const [filter, setFilter] = useState("pending"); + const [selectedId, setSelectedId] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + + async function refresh(select?: string) { + try { + const res = await api.adminAccessList(); + setRequests(res.requests); + if (select) setSelectedId(select); + } catch (err) { + setError(err instanceof Error ? err.message : "Load failed"); + } finally { + setLoading(false); + } + } + + useEffect(() => { + refresh(); + }, []); + + const visible = requests.filter((r) => filter === "all" || r.status === filter); + const pendingCount = requests.filter((r) => r.status === "pending").length; + + return ( +
+
+ {FILTERS.map((f) => { + const n = f === "all" ? requests.length : requests.filter((r) => r.status === f).length; + return ( + + ); + })} + +
+ + {error &&

{error}

} + {loading &&

Loading…

} + + {!loading && visible.length === 0 && ( +

+ + {pendingCount > 0 ? "Nothing with this status." : "No access requests. The wishlist is empty."} +

+ )} + +
    + {visible.map((r) => ( +
  • + + {selectedId === r.id && refresh(r.id)} />} +
  • + ))} +
+
+ ); +} + +function TicketDetail({ request, onChanged }: { request: AccessRequest; onChanged: () => void }) { + const [messages, setMessages] = useState([]); + const [reason, setReason] = useState(request.reason); + const [draft, setDraft] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const [freshKey, setFreshKey] = useState(null); + + useEffect(() => { + setReason(request.reason); + setFreshKey(null); + api.adminAccessGet(request.id).then((res) => setMessages(res.messages)).catch(() => {}); + }, [request.id, request.reason]); + + const closed = request.status === "accepted" || request.status === "refused"; + + async function setStatus(status: AccessStatus) { + setBusy(true); + setError(""); + try { + const res = await api.adminAccessPatch(request.id, { status, reason: reason.trim() }); + if (res.key) setFreshKey(res.key); + onChanged(); + } catch (err) { + setError(err instanceof Error ? err.message : "Update failed"); + } finally { + setBusy(false); + } + } + + async function reply(e: React.FormEvent) { + e.preventDefault(); + const body = draft.trim(); + if (!body || busy) return; + setBusy(true); + setError(""); + try { + const res = await api.adminAccessReply(request.id, body); + setMessages((prev) => [...prev, res.message]); + setDraft(""); + onChanged(); + } catch (err) { + setError(err instanceof Error ? err.message : "Send failed"); + } finally { + setBusy(false); + } + } + + return ( +
+ {freshKey && ( +
+

Account created — key shown once, copy it now:

+
+ {freshKey} + +
+
+ )} + +
+ {messages.map((m) => ( +
+
+

+ {m.author === "admin" ? "You (admin)" : `@${request.username}`} · {new Date(m.created_at).toLocaleString()} +

+

{m.body}

+
+
+ ))} +
+ +
+