This commit is contained in:
k
2026-09-21 15:02:41 +02:00
parent c18397880a
commit 1de54b3caf
24 changed files with 1255 additions and 95 deletions
+3
View File
@@ -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) | | 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) | | 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 | | 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 | | GET/PATCH/DELETE | `/api/conversations/:id` | owner | incl. messages on GET |
| POST/GET/DELETE | `/api/conversations/:id/share` | owner | public link `/share/:publicId` | | POST/GET/DELETE | `/api/conversations/:id/share` | owner | public link `/share/:publicId` |
| GET | `/api/share/:publicId` | **public** | read-only shared chat JSON | | GET | `/api/share/:publicId` | **public** | read-only shared chat JSON |
+6 -1
View File
@@ -30,7 +30,7 @@ Can refusing with a reason / accepting with reason / reviewing :
--- UX bugs / improvements: --- 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" 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 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 Make the loading screen smoother
@@ -39,3 +39,8 @@ Make the loading screen smoother
--- SETTINGS REWAMPING: --- SETTINGS REWAMPING:
Make a paginations system for users into the Admin Center. Make a paginations system for users into the Admin Center.
Filtering, by account timetamp, alphabetily filtered, unverified/etc... 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
+3 -2
View File
@@ -43,8 +43,9 @@ read-only `SharePage` (no auth), everything else renders the authenticated app.
copy, unshare), `SettingsModal` (display name, avatar upload box with copy, unshare), `SettingsModal` (display name, avatar upload box with
drag & drop, avatar URL, theme). drag & drop, avatar URL, theme).
- `AdminPanel.tsx` — No-KYC account management: create (key shown once), - `AdminPanel.tsx` — No-KYC account management: create (key shown once),
regenerate (revokes sessions), edit, delete with confirm. Renders bare regenerate (revokes sessions), edit, delete with confirm, plus server-side
(no modal shell) when embedded in the Admin Center page. 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): - `AdminCenter.tsx` — `/admin` route (admin gate + locked screen otherwise):
tabbed hub (Accounts / Features / Mail) reusing `AdminPanel`, server tabbed hub (Accounts / Features / Mail) reusing `AdminPanel`, server
feature `Switch`es (`registrationEnabled`, `ocrEnabled` via feature `Switch`es (`registrationEnabled`, `ocrEnabled` via
+25 -2
View File
@@ -55,8 +55,9 @@ unshare. See `AGENTS.md`.
## Admin Center (`/admin`) & self-registration ## Admin Center (`/admin`) & self-registration
The sidebar **Admin Center** button (admin only) opens the `/admin` page with The sidebar **Admin Center** button (admin only) opens the `/admin` page with
three tabs: **Accounts** (same manager as before, incl. the Ollama model four tabs: **Accounts** (same manager as before, incl. the Ollama model
catalog), **Features** (server-side switches), **Mail** (SMTP status). 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 - `registration_enabled` (default off): when on, the login page shows a
working **Register** button — anyone can create an account (key shown once) 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 - Flags live in the `settings` SQLite table, edited via
`GET/PATCH /api/admin/settings` (admin Bearer only). `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/<id>` (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) ## Key recovery via email (optional)
Set the `SMTP_*` vars (and the real public `APP_URL`) to enable it; otherwise Set the `SMTP_*` vars (and the real public `APP_URL`) to enable it; otherwise
+4 -4
View File
@@ -22,7 +22,7 @@
property="og:description" property="og:description"
content="A private, self-hosted AI chat assistant: local Ollama models + cloud LLMs, server-side chats, public shares, No-KYC accounts." content="A private, self-hosted AI chat assistant: local Ollama models + cloud LLMs, server-side chats, public shares, No-KYC accounts."
/> />
<meta property="og:image" content="/logo.jpg" /> <meta property="og:image" content="/logo.svg" />
<meta property="og:locale" content="en_US" /> <meta property="og:locale" content="en_US" />
<!-- Twitter --> <!-- Twitter -->
@@ -32,10 +32,10 @@
name="twitter:description" name="twitter:description"
content="A private, self-hosted AI chat assistant: local Ollama models + cloud LLMs, server-side chats and public shares." content="A private, self-hosted AI chat assistant: local Ollama models + cloud LLMs, server-side chats and public shares."
/> />
<meta name="twitter:image" content="/logo.jpg" /> <meta name="twitter:image" content="/logo.svg" />
<link rel="icon" type="image/jpeg" href="/logo.jpg" /> <link rel="icon" type="image/svg+xml" href="/logo.svg" />
<link rel="apple-touch-icon" href="/logo.jpg" /> <link rel="apple-touch-icon" href="/logo.svg" />
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 561 KiB

+11
View File
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<g>
<ellipse cx="50" cy="25" rx="13" ry="21" fill="#10b981"/>
<ellipse cx="50" cy="25" rx="13" ry="21" fill="#10b981" transform="rotate(60 50 50)"/>
<ellipse cx="50" cy="25" rx="13" ry="21" fill="#10b981" transform="rotate(120 50 50)"/>
<ellipse cx="50" cy="25" rx="13" ry="21" fill="#10b981" transform="rotate(180 50 50)"/>
<ellipse cx="50" cy="25" rx="13" ry="21" fill="#10b981" transform="rotate(240 50 50)"/>
<ellipse cx="50" cy="25" rx="13" ry="21" fill="#10b981" transform="rotate(300 50 50)"/>
<circle cx="50" cy="50" r="10" fill="#fbbf24"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 656 B

+11 -1
View File
@@ -9,6 +9,7 @@ import { ConvEditDialog, FilePreviewModal, SettingsModal, ShareModal } from "./c
import { AdminCenter } from "./components/AdminCenter.tsx"; import { AdminCenter } from "./components/AdminCenter.tsx";
import { SharePage } from "./components/SharePage.tsx"; import { SharePage } from "./components/SharePage.tsx";
import { ResetPage } from "./components/ResetPage.tsx"; import { ResetPage } from "./components/ResetPage.tsx";
import { ReviewPage } from "./components/ReviewPage.tsx";
import { ConfirmDialog, LoadingScreen } from "./components/ui.tsx"; import { ConfirmDialog, LoadingScreen } from "./components/ui.tsx";
function shareIdFromPath(): string | null { 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. // Module-level: the path never changes without a full reload, so hook order stays stable.
const SHARE_ID = typeof window !== "undefined" ? shareIdFromPath() : null; const SHARE_ID = typeof window !== "undefined" ? shareIdFromPath() : null;
const RESET_TOKEN = typeof window !== "undefined" ? resetTokenFromPath() : 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); const IS_ADMIN_PAGE = typeof window !== "undefined" && /^\/admin\/?$/.test(window.location.pathname);
function resetTokenFromPath(): string | null { function resetTokenFromPath(): string | null {
@@ -26,6 +28,11 @@ function resetTokenFromPath(): string | null {
return m ? m[1]! : 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) { function applyTheme(theme: string) {
const root = document.documentElement; const root = document.documentElement;
const dark = const dark =
@@ -36,7 +43,7 @@ function applyTheme(theme: string) {
export function App() { export function App() {
const [user, setUser] = useState<User | null>(null); const [user, setUser] = useState<User | null>(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<DriverModel[]>([]); const [models, setModels] = useState<DriverModel[]>([]);
const [model, setModel] = useState(""); const [model, setModel] = useState("");
const [convs, setConvs] = useState<Conversation[]>([]); const [convs, setConvs] = useState<Conversation[]>([]);
@@ -63,6 +70,9 @@ export function App() {
if (RESET_TOKEN) { if (RESET_TOKEN) {
return <ResetPage token={RESET_TOKEN} />; return <ResetPage token={RESET_TOKEN} />;
} }
if (REVIEW_ID) {
return <ReviewPage ticketId={REVIEW_ID} />;
}
if (SHARE_ID) { if (SHARE_ID) {
return <SharePage publicId={SHARE_ID} />; return <SharePage publicId={SHARE_ID} />;
} }
+242
View File
@@ -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<AccessStatus, { label: string; cls: string }> = {
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<Filter>("pending");
const [selectedId, setSelectedId] = useState<string | null>(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 (
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-1.5">
{FILTERS.map((f) => {
const n = f === "all" ? requests.length : requests.filter((r) => r.status === f).length;
return (
<button
key={f}
onClick={() => setFilter(f)}
className={cn(
"rounded-full px-3.5 py-1.5 text-xs font-medium capitalize transition",
filter === f
? "bg-stone-900 text-white dark:bg-zinc-100 dark:text-zinc-900"
: "bg-stone-200/70 hover:bg-stone-300/60 dark:bg-zinc-800 dark:hover:bg-zinc-700",
)}
>
{f} · {n}
</button>
);
})}
<button
onClick={() => refresh()}
className="ml-auto rounded-full p-2 opacity-60 transition hover:bg-stone-200/60 hover:opacity-100 dark:hover:bg-zinc-800"
title="Refresh"
aria-label="Refresh requests"
>
<Undo2 size={15} />
</button>
</div>
{error && <p className="rounded-2xl bg-red-500/10 px-4 py-2.5 text-sm text-red-600 dark:text-red-400" role="alert">{error}</p>}
{loading && <p className="flex items-center gap-2 text-sm opacity-60"><Spinner size={14} /> Loading…</p>}
{!loading && visible.length === 0 && (
<p className="flex items-center justify-center gap-2 rounded-3xl border border-dashed border-stone-300 px-4 py-8 text-sm opacity-50 dark:border-zinc-700">
<Inbox size={16} />
{pendingCount > 0 ? "Nothing with this status." : "No access requests. The wishlist is empty."}
</p>
)}
<ul className="space-y-2">
{visible.map((r) => (
<li key={r.id}>
<button
onClick={() => setSelectedId((cur) => (cur === r.id ? null : r.id))}
className={cn(
"flex w-full items-center gap-3 rounded-3xl border p-4 text-left transition",
selectedId === r.id
? "border-accent-500/50 bg-accent-500/5"
: "border-stone-200/70 bg-white hover:border-stone-300 dark:border-zinc-800 dark:bg-zinc-900 dark:hover:border-zinc-700",
)}
>
<span className="grid h-10 w-10 shrink-0 place-items-center rounded-full bg-stone-200/70 text-sm font-semibold dark:bg-zinc-800">
{(r.username[0] || "?").toUpperCase()}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">@{r.username} <span className="font-normal opacity-50">{r.email}</span></span>
<span className="block truncate text-xs opacity-60">{r.message} · {r.message_count} message{r.message_count === 1 ? "" : "s"}</span>
</span>
<span className={cn("shrink-0 rounded-full px-2.5 py-1 text-xs font-medium", STATUS_STYLE[r.status].cls)}>
{STATUS_STYLE[r.status].label}
</span>
</button>
{selectedId === r.id && <TicketDetail request={r} onChanged={() => refresh(r.id)} />}
</li>
))}
</ul>
</div>
);
}
function TicketDetail({ request, onChanged }: { request: AccessRequest; onChanged: () => void }) {
const [messages, setMessages] = useState<AccessMessage[]>([]);
const [reason, setReason] = useState(request.reason);
const [draft, setDraft] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [freshKey, setFreshKey] = useState<string | null>(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 (
<div className="mt-2 space-y-3 rounded-3xl border border-stone-200/70 bg-stone-50 p-4 dark:border-zinc-800 dark:bg-zinc-900/60">
{freshKey && (
<div className="rounded-2xl border border-accent-500/40 bg-accent-50 p-3 dark:bg-accent-950/30">
<p className="mb-2 flex items-center gap-2 text-sm font-medium"><CheckCircle2 size={15} /> Account created — key shown once, copy it now:</p>
<div className="flex items-center gap-2">
<code className="min-w-0 flex-1 truncate rounded-xl bg-white px-3 py-2 text-sm dark:bg-zinc-900">{freshKey}</code>
<CopyButton text={freshKey} />
</div>
</div>
)}
<div className="max-h-64 space-y-2 overflow-y-auto">
{messages.map((m) => (
<div key={m.id} className={cn("flex", m.author === "admin" ? "justify-end" : "justify-start")}>
<div className={cn(
"max-w-[85%] rounded-2xl px-3 py-2 text-sm",
m.author === "admin"
? "rounded-tr-lg bg-accent-600 text-white dark:bg-accent-500 dark:text-zinc-950"
: "rounded-tl-lg bg-white dark:bg-zinc-800",
)}>
<p className="mb-0.5 text-[11px] font-medium opacity-70">
{m.author === "admin" ? "You (admin)" : `@${request.username}`} · {new Date(m.created_at).toLocaleString()}
</p>
<p className="whitespace-pre-wrap">{m.body}</p>
</div>
</div>
))}
</div>
<form onSubmit={reply} className="flex items-end gap-2">
<textarea
rows={2}
value={draft}
onChange={(e) => setDraft(e.target.value)}
placeholder="Reply (emailed to the requester)…"
aria-label="Reply to requester"
className="max-h-32 flex-1 resize-none rounded-2xl border border-stone-200 bg-white px-3.5 py-2.5 text-sm outline-none transition placeholder:text-stone-400 focus:border-accent-500/60 dark:border-zinc-700 dark:bg-zinc-900 dark:placeholder:text-zinc-500"
/>
<button
type="submit"
disabled={!draft.trim() || busy}
aria-label="Send reply"
className="grid h-10 w-10 shrink-0 place-items-center rounded-full bg-accent-600 text-white shadow transition hover:bg-accent-500 active:scale-95 disabled:opacity-40 dark:bg-accent-500 dark:text-zinc-950 dark:hover:bg-accent-400"
>
{busy ? <Spinner size={15} /> : <SendHorizontal size={15} />}
</button>
</form>
{!closed ? (
<div className="flex flex-wrap items-end gap-2 rounded-2xl border border-stone-200 bg-white p-3 dark:border-zinc-700 dark:bg-zinc-900">
<div className="min-w-48 flex-1">
<Field label="Reason (emailed with the decision)">
<Input value={reason} onChange={(e) => setReason(e.target.value)} placeholder="Optional reason…" maxLength={500} />
</Field>
</div>
<div className="flex gap-2">
{request.status === "pending" && (
<Button size="sm" variant="secondary" disabled={busy} onClick={() => setStatus("reviewing")}>
<Search size={14} /> Reviewing
</Button>
)}
<Button size="sm" disabled={busy} onClick={() => setStatus("accepted")}>
<CheckCircle2 size={14} /> Accept
</Button>
<Button size="sm" variant="secondary" disabled={busy} onClick={() => setStatus("refused")} className="!text-red-600 dark:!text-red-400">
<XCircle size={14} /> Refuse
</Button>
</div>
</div>
) : (
<p className="flex items-center gap-2 text-xs opacity-60">
<Clock size={13} /> Ticket closed ({STATUS_STYLE[request.status].label.toLowerCase()}).
{request.reason && <> Reason: <em>{request.reason}</em></>}
</p>
)}
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
</div>
);
}
+12 -4
View File
@@ -1,11 +1,12 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { ArrowLeft, LayoutDashboard, Mail, ShieldAlert, SlidersHorizontal, Users } from "lucide-react"; import { ArrowLeft, Inbox, LayoutDashboard, Mail, ShieldAlert, SlidersHorizontal, Users } from "lucide-react";
import { api } from "../lib/api.ts"; import { api } from "../lib/api.ts";
import { AdminPanel } from "./AdminPanel.tsx"; import { AdminPanel } from "./AdminPanel.tsx";
import { LoadingScreen, Logo, Spinner, Switch } from "./ui.tsx"; import { AccessRequestsPanel } from "./AccessRequests.tsx";
import { FlowerMark, LoadingScreen, Spinner, Switch } from "./ui.tsx";
import { cn } from "../lib/cn.ts"; import { cn } from "../lib/cn.ts";
type Tab = "accounts" | "features" | "mail"; type Tab = "accounts" | "access" | "features" | "mail";
function applyTheme(theme: string) { function applyTheme(theme: string) {
const root = document.documentElement; const root = document.documentElement;
@@ -16,6 +17,7 @@ function applyTheme(theme: string) {
const TABS: { id: Tab; label: string; icon: typeof Users }[] = [ const TABS: { id: Tab; label: string; icon: typeof Users }[] = [
{ id: "accounts", label: "Accounts", icon: Users }, { id: "accounts", label: "Accounts", icon: Users },
{ id: "access", label: "Access", icon: Inbox },
{ id: "features", label: "Features", icon: SlidersHorizontal }, { id: "features", label: "Features", icon: SlidersHorizontal },
{ id: "mail", label: "Mail", icon: Mail }, { id: "mail", label: "Mail", icon: Mail },
]; ];
@@ -80,7 +82,7 @@ export function AdminCenter() {
<a href="/" aria-label="Back to chat" className="rounded-full p-2 transition hover:bg-stone-200/60 dark:hover:bg-zinc-800"> <a href="/" aria-label="Back to chat" className="rounded-full p-2 transition hover:bg-stone-200/60 dark:hover:bg-zinc-800">
<ArrowLeft size={18} /> <ArrowLeft size={18} />
</a> </a>
<Logo size={32} className="rounded-xl" /> <FlowerMark size={32} />
<h1 className="flex items-center gap-2 text-lg font-bold tracking-tight"> <h1 className="flex items-center gap-2 text-lg font-bold tracking-tight">
<LayoutDashboard size={18} className="text-accent-600 dark:text-accent-400" /> <LayoutDashboard size={18} className="text-accent-600 dark:text-accent-400" />
Admin Center Admin Center
@@ -116,6 +118,12 @@ export function AdminCenter() {
</section> </section>
)} )}
{tab === "access" && (
<section className="rounded-3xl border border-stone-200/70 bg-white p-5 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<AccessRequestsPanel />
</section>
)}
{tab === "features" && ( {tab === "features" && (
<section className="space-y-3"> <section className="space-y-3">
<div className="flex items-center gap-4 rounded-3xl border border-stone-200/70 bg-white p-5 shadow-sm dark:border-zinc-800 dark:bg-zinc-900"> <div className="flex items-center gap-4 rounded-3xl border border-stone-200/70 bg-white p-5 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
+83 -4
View File
@@ -1,8 +1,8 @@
import { useEffect, useState, type FC, type ReactNode } from "react"; import { useEffect, useState, type FC, type ReactNode } from "react";
import { Bot, Download, HardDrive, KeyRound, Pencil, RefreshCw, Trash2, UserPlus, Users } from "lucide-react"; import { ArrowUpDown, Bot, ChevronLeft, ChevronRight, Download, Filter, HardDrive, KeyRound, Pencil, RefreshCw, Search, Trash2, UserPlus, Users } from "lucide-react";
import { api } from "../lib/api.ts"; import { api } from "../lib/api.ts";
import type { DriverModel, 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"; import { Avatar, Button, ConfirmDialog, CopyButton, Field, IconButton, Input, Modal, Picker, Spinner } from "./ui.tsx";
/** Curated Ollama library catalog (admin downloads only). */ /** Curated Ollama library catalog (admin downloads only). */
const OLLAMA_CATALOG: { name: string; desc: string; sizes: string[] }[] = [ const OLLAMA_CATALOG: { name: string; desc: string; sizes: string[] }[] = [
@@ -187,6 +187,15 @@ export function AdminPanel({ open, onClose, bare }: { open: boolean; onClose: ()
const [deleteTarget, setDeleteTarget] = useState<User | null>(null); const [deleteTarget, setDeleteTarget] = useState<User | null>(null);
const [editTarget, setEditTarget] = useState<User | null>(null); const [editTarget, setEditTarget] = useState<User | null>(null);
const [modelsOpen, setModelsOpen] = useState(false); const [modelsOpen, setModelsOpen] = useState(false);
// Listing: search + sort + filter + pagination (server-side).
const [q, setQ] = useState("");
const [debouncedQ, setDebouncedQ] = useState("");
const [sort, setSort] = useState("newest");
const [filter, setFilter] = useState("all");
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [pages, setPages] = useState(1);
const PER_PAGE = 8;
// create form // create form
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
@@ -197,8 +206,11 @@ export function AdminPanel({ open, onClose, bare }: { open: boolean; onClose: ()
setBusy(true); setBusy(true);
setError(""); setError("");
try { try {
const res = await api.adminList(); const res = await api.adminList({ q: debouncedQ || undefined, sort, filter, page, per: PER_PAGE });
setUsers(res.users); setUsers(res.users);
setTotal(res.total);
setPages(res.pages);
if (res.page !== page) setPage(res.page);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Load failed"); setError(err instanceof Error ? err.message : "Load failed");
} finally { } finally {
@@ -215,6 +227,20 @@ export function AdminPanel({ open, onClose, bare }: { open: boolean; onClose: ()
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, bare ]); }, [open, bare ]);
// Debounced search (resets to first page); sort/filter reload too.
useEffect(() => {
const t = setTimeout(() => {
setDebouncedQ(q);
setPage(1);
}, 300);
return () => clearTimeout(t);
}, [q ]);
useEffect(() => {
if (open || bare) refresh();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedQ, sort, filter, page ]);
async function create(e: React.FormEvent) { async function create(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
setError(""); setError("");
@@ -299,6 +325,38 @@ export function AdminPanel({ open, onClose, bare }: { open: boolean; onClose: ()
</form> </form>
)} )}
<div className="mb-3 flex flex-wrap items-center gap-2">
<div className="relative min-w-40 flex-1">
<Search size={15} className="pointer-events-none absolute left-3.5 top-1/2 -translate-y-1/2 opacity-40" />
<Input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search accounts…" aria-label="Search accounts" className="pl-10" />
</div>
<Picker
ariaLabel="Sort accounts"
icon={ArrowUpDown}
value={sort}
onChange={(v) => { setSort(v); setPage(1); }}
align="right"
options={[
{ value: "newest", label: "Newest first" },
{ value: "oldest", label: "Oldest first" },
{ value: "az", label: "A → Z" },
{ value: "za", label: "Z → A" },
]}
/>
<Picker
ariaLabel="Filter accounts"
icon={Filter}
value={filter}
onChange={(v) => { setFilter(v); setPage(1); }}
align="right"
options={[
{ value: "all", label: "All accounts" },
{ value: "with-email", label: "With recovery email" },
{ value: "no-email", label: "No recovery email" },
]}
/>
</div>
{busy ? ( {busy ? (
<p className="flex items-center gap-2 text-sm opacity-60"><Spinner size={15} /> Loading…</p> <p className="flex items-center gap-2 text-sm opacity-60"><Spinner size={15} /> Loading…</p>
) : ( ) : (
@@ -308,7 +366,7 @@ export function AdminPanel({ open, onClose, bare }: { open: boolean; onClose: ()
<Avatar name={u.displayName} url={u.avatarUrl} size={36} /> <Avatar name={u.displayName} url={u.avatarUrl} size={36} />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{u.displayName} <span className="font-normal opacity-50">@{u.username}</span></p> <p className="truncate text-sm font-medium">{u.displayName} <span className="font-normal opacity-50">@{u.username}</span></p>
<p className="text-xs opacity-50">theme: {u.theme} · since {new Date(u.createdAt).toLocaleDateString()}</p> <p className="truncate text-xs opacity-50">{u.email || "no recovery email"} · {new Date(u.createdAt).toLocaleDateString()}</p>
</div> </div>
{!u.isAdmin && ( {!u.isAdmin && (
<div className="flex shrink-0 gap-1"> <div className="flex shrink-0 gap-1">
@@ -321,6 +379,27 @@ export function AdminPanel({ open, onClose, bare }: { open: boolean; onClose: ()
))} ))}
</ul> </ul>
)} )}
<div className="mt-3 flex items-center justify-between gap-2 text-sm">
<span className="opacity-60">{total} account{total === 1 ? "" : "s"} · page {page} of {pages}</span>
{pages > 1 && (
<div className="flex gap-2.5">
<IconButton
title="Previous page"
disabled={page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
<ChevronLeft size={16} />
</IconButton>
<IconButton
title="Next page"
disabled={page >= pages}
onClick={() => setPage((p) => p + 1)}
>
<ChevronRight size={16} />
</IconButton>
</div>
)}
</div>
</Shell> </Shell>
<ConfirmDialog <ConfirmDialog
+7 -5
View File
@@ -1,7 +1,7 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { BookOpen, Bot, Cpu, FileText, Menu, PanelLeftOpen, Paperclip, ScanText, SendHorizontal, Share2, User as UserIcon, X } from "lucide-react"; import { BookOpen, Bot, Cpu, FileText, Menu, PanelLeftOpen, Paperclip, ScanText, SendHorizontal, Share2, User as UserIcon, X } from "lucide-react";
import type { Attachment, ChatMessage, Conversation, DriverModel, User } from "../lib/types.ts"; import type { Attachment, ChatMessage, Conversation, DriverModel, User } from "../lib/types.ts";
import { Avatar, IconButton, LOGO_URL, Logo, Picker, type PickerGroup, Spinner } from "./ui.tsx"; import { AssistantAvatar, Avatar, FlowerMark, IconButton, Picker, type PickerGroup, Spinner } from "./ui.tsx";
import { Markdown } from "./Markdown.tsx"; import { Markdown } from "./Markdown.tsx";
import { cn } from "../lib/cn.ts"; import { cn } from "../lib/cn.ts";
import { useFeatures } from "../lib/features.ts"; import { useFeatures } from "../lib/features.ts";
@@ -10,7 +10,9 @@ function MessageBubble({ msg, authorAvatar, authorName }: { msg: ChatMessage; au
const isUser = msg.role === "user"; const isUser = msg.role === "user";
return ( return (
<div className={cn("flex gap-3", isUser && "flex-row-reverse")}> <div className={cn("flex gap-3", isUser && "flex-row-reverse")}>
<Avatar name={authorName} url={isUser ? authorAvatar : LOGO_URL} size={32} /> {isUser
? <Avatar name={authorName} url={authorAvatar} size={32} />
: <AssistantAvatar size={32} />}
<div className={cn("min-w-0 max-w-[85%]", isUser && "flex flex-col items-end")}> <div className={cn("min-w-0 max-w-[85%]", isUser && "flex flex-col items-end")}>
<div className="mb-1 flex items-center gap-1.5 text-xs opacity-60"> <div className="mb-1 flex items-center gap-1.5 text-xs opacity-60">
{isUser ? <UserIcon size={12} /> : <Bot size={12} />} {isUser ? <UserIcon size={12} /> : <Bot size={12} />}
@@ -123,7 +125,7 @@ export function Chat({
</span> </span>
)} )}
<span className="hidden items-center gap-2 font-semibold sm:flex"> <span className="hidden items-center gap-2 font-semibold sm:flex">
<Logo size={24} className="rounded-lg" /> <FlowerMark size={24} dynamic={false} />
{conv?.title || "New chat"} {conv?.title || "New chat"}
</span> </span>
{conv?.topic && ( {conv?.topic && (
@@ -151,7 +153,7 @@ export function Chat({
<div className="mx-auto w-full max-w-3xl space-y-5"> <div className="mx-auto w-full max-w-3xl space-y-5">
{messages.length === 0 && !streaming && ( {messages.length === 0 && !streaming && (
<div className="py-16 text-center"> <div className="py-16 text-center">
<Logo size={56} className="mx-auto mb-4 rounded-[1.3rem] shadow-lg shadow-red-900/10" /> <FlowerMark size={56} className="mx-auto mb-4" />
<h2 className="text-2xl font-bold tracking-tight">How can I help, {user.displayName.split(" ")[0]}?</h2> <h2 className="text-2xl font-bold tracking-tight">How can I help, {user.displayName.split(" ")[0]}?</h2>
<p className="mt-1 text-sm opacity-60">Pick a model, then type below. Markdown is supported.</p> <p className="mt-1 text-sm opacity-60">Pick a model, then type below. Markdown is supported.</p>
</div> </div>
@@ -161,7 +163,7 @@ export function Chat({
))} ))}
{streaming !== "" && ( {streaming !== "" && (
<div className="flex gap-3"> <div className="flex gap-3">
<Avatar name="KisAssistant" url={LOGO_URL} size={32} /> <AssistantAvatar size={32} />
<div className="min-w-0 max-w-[85%]"> <div className="min-w-0 max-w-[85%]">
<div className="mb-1 text-xs opacity-60">KisAssistant</div> <div className="mb-1 text-xs opacity-60">KisAssistant</div>
<div className="rounded-3xl rounded-tl-lg border border-stone-200/70 bg-white px-5 py-3.5 shadow-sm dark:border-zinc-800 dark:bg-zinc-900"> <div className="rounded-3xl rounded-tl-lg border border-stone-200/70 bg-white px-5 py-3.5 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
+19 -3
View File
@@ -1,9 +1,10 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { ArrowRight, KeyRound, ShieldCheck, User, UserPlus, Zap } from "lucide-react"; import { ArrowRight, KeyRound, MessageSquareText, ShieldCheck, User, UserPlus, Zap } from "lucide-react";
import { api, setToken } from "../lib/api.ts"; import { api, setToken } from "../lib/api.ts";
import type { User as UserType } from "../lib/types.ts"; import type { User as UserType } from "../lib/types.ts";
import { Button, Input, Logo, Spinner } from "./ui.tsx"; import { Button, FlowerMark, Input, Spinner } from "./ui.tsx";
import { RecoverDialog, RegisterDialog } from "./dialogs.tsx"; import { RecoverDialog, RegisterDialog } from "./dialogs.tsx";
import { AccessRequestModal } from "./ReviewPage.tsx";
const PERKS = [ const PERKS = [
{ icon: ShieldCheck, text: "Private by design — no cookies, no tracking" }, { icon: ShieldCheck, text: "Private by design — no cookies, no tracking" },
@@ -20,6 +21,7 @@ export function Login({ onLogin }: { onLogin: (user: UserType) => void }) {
const [recoveryFrom, setRecoveryFrom] = useState<string | undefined>(undefined); const [recoveryFrom, setRecoveryFrom] = useState<string | undefined>(undefined);
const [registerOpen, setRegisterOpen] = useState(false); const [registerOpen, setRegisterOpen] = useState(false);
const [registrationOn, setRegistrationOn] = useState(false); const [registrationOn, setRegistrationOn] = useState(false);
const [requestOpen, setRequestOpen] = useState(false);
useEffect(() => { useEffect(() => {
api.methods().then((m) => { api.methods().then((m) => {
@@ -50,7 +52,7 @@ export function Login({ onLogin }: { onLogin: (user: UserType) => void }) {
<div className="w-full max-w-sm"> <div className="w-full max-w-sm">
<div className="rounded-[2rem] border border-white/60 bg-white/80 p-8 shadow-2xl shadow-accent-900/5 backdrop-blur-xl dark:border-zinc-700/60 dark:bg-zinc-900/80"> <div className="rounded-[2rem] border border-white/60 bg-white/80 p-8 shadow-2xl shadow-accent-900/5 backdrop-blur-xl dark:border-zinc-700/60 dark:bg-zinc-900/80">
<div className="mb-2 flex flex-col items-center text-center"> <div className="mb-2 flex flex-col items-center text-center">
<Logo size={68} className="mb-4 rounded-[1.5rem] shadow-lg shadow-red-900/20" /> <FlowerMark size={68} className="mb-4 drop-shadow-lg" />
<h1 className="text-2xl font-bold tracking-tight">Welcome back</h1> <h1 className="text-2xl font-bold tracking-tight">Welcome back</h1>
<p className="mt-1 text-sm opacity-60">Sign in to KisAssistant to continue</p> <p className="mt-1 text-sm opacity-60">Sign in to KisAssistant to continue</p>
</div> </div>
@@ -113,6 +115,20 @@ export function Login({ onLogin }: { onLogin: (user: UserType) => void }) {
onClose={() => setRegisterOpen(false)} onClose={() => setRegisterOpen(false)}
onDone={(u, k) => { setUsername(u); setKey(k); }} onDone={(u, k) => { setUsername(u); setKey(k); }}
/> />
{/* Wishlist path: when open registration is off, visitors can still
reserve a username and plead their case; an admin triages it. */}
{!registrationOn && (
<Button
variant="secondary"
size="lg"
className="mt-3 w-full"
onClick={() => setRequestOpen(true)}
>
<MessageSquareText size={16} />
Request access
</Button>
)}
<AccessRequestModal open={requestOpen} onClose={() => setRequestOpen(false)} />
{recoveryOn && ( {recoveryOn && (
<button onClick={() => setRecoverOpen(true)} className="mt-3 w-full text-center text-sm opacity-60 transition hover:opacity-100 hover:underline"> <button onClick={() => setRecoverOpen(true)} className="mt-3 w-full text-center text-sm opacity-60 transition hover:opacity-100 hover:underline">
Forgot your access key? Forgot your access key?
+2 -2
View File
@@ -1,7 +1,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { KeyRound, MailWarning, Sparkles } from "lucide-react"; import { KeyRound, MailWarning, Sparkles } from "lucide-react";
import { api } from "../lib/api.ts"; import { api } from "../lib/api.ts";
import { Button, CopyButton, Logo, Spinner } from "./ui.tsx"; import { Button, CopyButton, FlowerMark, Spinner } from "./ui.tsx";
type State = type State =
| { kind: "loading" } | { kind: "loading" }
@@ -34,7 +34,7 @@ export function ResetPage({ token }: { token: string }) {
return ( return (
<div className="grid min-h-full place-items-center bg-gradient-to-br from-stone-100 via-stone-50 to-accent-100/60 p-6 dark:from-zinc-950 dark:via-zinc-950 dark:to-accent-950/40"> <div className="grid min-h-full place-items-center bg-gradient-to-br from-stone-100 via-stone-50 to-accent-100/60 p-6 dark:from-zinc-950 dark:via-zinc-950 dark:to-accent-950/40">
<div className="w-full max-w-sm rounded-[2rem] border border-white/60 bg-white/80 p-8 text-center shadow-2xl backdrop-blur-xl dark:border-zinc-700/60 dark:bg-zinc-900/80"> <div className="w-full max-w-sm rounded-[2rem] border border-white/60 bg-white/80 p-8 text-center shadow-2xl backdrop-blur-xl dark:border-zinc-700/60 dark:bg-zinc-900/80">
<Logo size={56} className="mx-auto mb-4 rounded-[1.2rem] shadow-lg" /> <FlowerMark size={56} className="mx-auto mb-4" />
{state.kind === "loading" && <p className="flex items-center justify-center gap-2 text-sm opacity-60"><Spinner size={15} /> Checking link…</p>} {state.kind === "loading" && <p className="flex items-center justify-center gap-2 text-sm opacity-60"><Spinner size={15} /> Checking link…</p>}
{state.kind === "invalid" && ( {state.kind === "invalid" && (
<div className="space-y-3"> <div className="space-y-3">
+246
View File
@@ -0,0 +1,246 @@
import { useEffect, useState } from "react";
import { CheckCircle2, Clock, MailWarning, MessageSquareText, Search, SendHorizontal, XCircle } from "lucide-react";
import { api, type AccessMessage, type AccessRequest } from "../lib/api.ts";
import { Button, CopyButton, Field, FlowerMark, Input, Modal, Spinner } from "./ui.tsx";
import { cn } from "../lib/cn.ts";
const STATUS_STYLE: Record<AccessRequest["status"], { label: string; cls: string; icon: typeof Clock }> = {
pending: { label: "Pending review", cls: "bg-amber-500/10 text-amber-700 dark:text-amber-400", icon: Clock },
reviewing: { label: "Under review", cls: "bg-sky-500/10 text-sky-700 dark:text-sky-400", icon: Search },
accepted: { label: "Accepted", cls: "bg-accent-500/10 text-accent-700 dark:text-accent-400", icon: CheckCircle2 },
refused: { label: "Refused", cls: "bg-red-500/10 text-red-600 dark:text-red-400", icon: XCircle },
};
function StatusBadge({ status }: { status: AccessRequest["status"] }) {
const s = STATUS_STYLE[status];
return (
<span className={cn("inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-medium", s.cls)}>
<s.icon size={13} />
{s.label}
</span>
);
}
/** Public ticket page: /review/:id — argue your case, follow the decision. */
export function ReviewPage({ ticketId }: { ticketId: string }) {
const [request, setRequest] = useState<AccessRequest | null>(null);
const [messages, setMessages] = useState<AccessMessage[]>([]);
const [draft, setDraft] = useState("");
const [loading, setLoading] = useState(true);
const [invalid, setInvalid] = useState(false);
const [sending, setSending] = useState(false);
const [error, setError] = useState("");
async function load() {
try {
const res = await api.accessTicket(ticketId);
setRequest(res.request);
setMessages(res.messages);
} catch {
setInvalid(true);
} finally {
setLoading(false);
}
}
useEffect(() => {
load();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ticketId]);
async function send(e: React.FormEvent) {
e.preventDefault();
const body = draft.trim();
if (!body || sending) return;
setSending(true);
setError("");
try {
const res = await api.accessReply(ticketId, body);
setMessages((prev) => [...prev, res.message]);
setDraft("");
} catch (err) {
setError(err instanceof Error ? err.message : "Send failed");
} finally {
setSending(false);
}
}
const closed = request?.status === "accepted" || request?.status === "refused";
return (
<div className="grid min-h-full place-items-center bg-gradient-to-br from-stone-100 via-stone-50 to-accent-100/60 p-4 dark:from-zinc-950 dark:via-zinc-950 dark:to-accent-950/40 sm:p-6">
<div className="w-full max-w-xl rounded-[2rem] border border-white/60 bg-white/80 p-6 shadow-2xl backdrop-blur-xl dark:border-zinc-700/60 dark:bg-zinc-900/80 sm:p-8">
<FlowerMark size={48} className="mx-auto mb-3" />
<h1 className="text-center font-serif text-2xl font-bold tracking-tight">Access request</h1>
{loading && <p className="mt-4 flex items-center justify-center gap-2 text-sm opacity-60"><Spinner size={15} /> Loading ticket…</p>}
{invalid && (
<div className="mt-4 space-y-3 text-center">
<p className="flex items-center justify-center gap-2 font-semibold"><MailWarning size={18} className="text-red-500" /> Ticket not found</p>
<p className="text-sm opacity-70">This link is unknown. Check the URL from your email, or file a new request from the login page.</p>
<Button size="sm" onClick={() => (window.location.href = "/")}>Back to login</Button>
</div>
)}
{request && (
<div className="mt-4 space-y-4">
<div className="flex flex-wrap items-center justify-center gap-2">
<StatusBadge status={request.status} />
<span className="text-sm opacity-70">@{request.username}</span>
</div>
{request.reason && (
<p className="rounded-2xl bg-stone-100 px-4 py-2.5 text-center text-sm dark:bg-zinc-800">
<span className="font-medium">Admin note: </span>{request.reason}
</p>
)}
{request.status === "accepted" && (
<p className="rounded-2xl bg-accent-500/10 px-4 py-2.5 text-center text-sm">
Your account was created — check your email for the access key, then log in.
</p>
)}
<div className="max-h-80 space-y-2.5 overflow-y-auto rounded-2xl border border-stone-200/70 p-3 dark:border-zinc-800">
{messages.map((m) => (
<div key={m.id} className={cn("flex", m.author === "admin" ? "justify-start" : "justify-end")}>
<div className={cn(
"max-w-[85%] rounded-2xl px-3.5 py-2 text-sm",
m.author === "admin"
? "rounded-tl-lg bg-stone-200/70 dark:bg-zinc-800"
: "rounded-tr-lg bg-accent-600 text-white dark:bg-accent-500 dark:text-zinc-950",
)}>
<p className="mb-0.5 text-[11px] font-medium opacity-70">{m.author === "admin" ? "Admin" : "You"}</p>
<p className="whitespace-pre-wrap">{m.body}</p>
</div>
</div>
))}
</div>
{closed ? (
<p className="text-center text-xs opacity-60">
This ticket is closed. Replies by email are not read — if needed, file a new request.
</p>
) : (
<form onSubmit={send} className="space-y-2">
<div className="flex items-end gap-2">
<textarea
rows={2}
value={draft}
onChange={(e) => setDraft(e.target.value)}
placeholder="Argue your case…"
aria-label="Reply to your ticket"
className="max-h-40 flex-1 resize-none rounded-2xl border border-stone-200 bg-white px-3.5 py-2.5 text-sm outline-none transition placeholder:text-stone-400 focus:border-accent-500/60 dark:border-zinc-700 dark:bg-zinc-900 dark:placeholder:text-zinc-500"
/>
<button
type="submit"
disabled={!draft.trim() || sending}
aria-label="Send reply"
className="grid h-10 w-10 shrink-0 place-items-center rounded-full bg-accent-600 text-white shadow transition hover:bg-accent-500 active:scale-95 disabled:opacity-40 dark:bg-accent-500 dark:text-zinc-950 dark:hover:bg-accent-400"
>
{sending ? <Spinner size={16} /> : <SendHorizontal size={16} />}
</button>
</div>
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
<p className="text-center text-xs opacity-60">
Email notifications are one-way — always reply here, not by email.
</p>
</form>
)}
</div>
)}
</div>
</div>
);
}
/** Wishlist form: reserve a username + email with a motivation message. */
export function AccessRequestModal({ open, onClose }: { open: boolean; onClose: () => void }) {
const [username, setUsername] = useState("");
const [email, setEmail] = useState("");
const [message, setMessage] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [done, setDone] = useState<{ reviewUrl: string; username: string } | null>(null);
useEffect(() => {
if (open) {
setUsername("");
setEmail("");
setMessage("");
setError("");
setDone(null);
}
}, [open ]);
async function submit(e: React.FormEvent) {
e.preventDefault();
if (busy) return;
setBusy(true);
setError("");
try {
const res = await api.accessRequest({
username: username.trim().toLowerCase(),
email: email.trim(),
message: message.trim(),
});
setDone({ reviewUrl: res.reviewUrl, username: res.request.username });
} catch (err) {
setError(err instanceof Error ? err.message : "Request failed");
} finally {
setBusy(false);
}
}
return (
<Modal open={open} onClose={onClose} title="Request access" icon={MessageSquareText}>
{done ? (
<div className="space-y-4 text-center">
<p className="flex items-center justify-center gap-2 font-semibold text-accent-700 dark:text-accent-400">
<CheckCircle2 size={18} /> Request received
</p>
<p className="text-sm opacity-70">
@{done.username} is reserved for you while an admin reviews it.
A confirmation email with your personal ticket link is on its way.
</p>
<div className="flex items-center gap-2">
<code className="min-w-0 flex-1 truncate rounded-2xl bg-stone-100 px-3 py-2.5 text-left text-xs dark:bg-zinc-800">{done.reviewUrl}</code>
<CopyButton text={done.reviewUrl} label="Copy link" />
</div>
<Button size="sm" className="w-full" onClick={() => (window.location.href = done.reviewUrl.replace(window.location.origin, ""))}>
Open my ticket
</Button>
</div>
) : (
<form onSubmit={submit} className="space-y-4">
<p className="text-sm opacity-70">
Reserve a username and tell us why you want in. An admin reviews every request —
you follow the discussion on your personal ticket page and by email.
</p>
<Field label="Username">
<Input value={username} onChange={(e) => setUsername(e.target.value)} placeholder="alice" required minLength={2} maxLength={32} />
</Field>
<Field label="Email" hint="Required — status updates and ticket replies land here.">
<Input value={email} onChange={(e) => setEmail(e.target.value)} placeholder="[email protected]" inputMode="email" required />
</Field>
<Field label="Why should you get access?" hint="10+ characters. This starts your ticket thread.">
<textarea
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="I want to…"
required
minLength={10}
maxLength={2000}
rows={4}
className="max-h-60 w-full resize-y rounded-2xl border border-stone-200 bg-white px-3.5 py-2.5 text-sm outline-none transition placeholder:text-stone-400 focus:border-accent-500/60 dark:border-zinc-700 dark:bg-zinc-900 dark:placeholder:text-zinc-500"
/>
</Field>
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
<div className="flex justify-end gap-2">
<Button variant="secondary" size="sm" type="button" onClick={onClose}>Cancel</Button>
<Button size="sm" type="submit" disabled={busy}>{busy ? <Spinner size={15} /> : "Send request"}</Button>
</div>
</form>
)}
</Modal>
);
}
+5 -3
View File
@@ -2,7 +2,7 @@ import { useEffect, useState } from "react";
import { Bot, Globe, User as UserIcon } from "lucide-react"; import { Bot, Globe, User as UserIcon } from "lucide-react";
import { api } from "../lib/api.ts"; import { api } from "../lib/api.ts";
import type { SharedChat } from "../lib/types.ts"; import type { SharedChat } from "../lib/types.ts";
import { Avatar, LOGO_URL, Logo } from "./ui.tsx"; import { AssistantAvatar, Avatar, FlowerMark } from "./ui.tsx";
import { Markdown } from "./Markdown.tsx"; import { Markdown } from "./Markdown.tsx";
export function SharePage({ publicId }: { publicId: string }) { export function SharePage({ publicId }: { publicId: string }) {
@@ -19,7 +19,7 @@ export function SharePage({ publicId }: { publicId: string }) {
<div className="min-h-full"> <div className="min-h-full">
<header className="border-b border-stone-200/70 bg-white/70 backdrop-blur dark:border-zinc-800 dark:bg-zinc-900/70"> <header className="border-b border-stone-200/70 bg-white/70 backdrop-blur dark:border-zinc-800 dark:bg-zinc-900/70">
<div className="mx-auto flex w-full max-w-3xl items-center gap-2.5 px-4 py-3"> <div className="mx-auto flex w-full max-w-3xl items-center gap-2.5 px-4 py-3">
<Logo size={36} className="rounded-2xl" /> <FlowerMark size={36} dynamic={false} />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="truncate font-semibold">{chat?.title ?? "Shared chat"}</p> <p className="truncate font-semibold">{chat?.title ?? "Shared chat"}</p>
{chat && ( {chat && (
@@ -39,7 +39,9 @@ export function SharePage({ publicId }: { publicId: string }) {
{!chat && !error && <p className="py-16 text-center text-sm opacity-60">Loading shared chat…</p>} {!chat && !error && <p className="py-16 text-center text-sm opacity-60">Loading shared chat…</p>}
{chat?.messages.map((m, i) => ( {chat?.messages.map((m, i) => (
<div key={i} className="flex gap-3"> <div key={i} className="flex gap-3">
<Avatar name={m.role === "user" ? chat.authorName : "KisAssistant"} url={m.role === "user" ? undefined : LOGO_URL} size={32} /> {m.role === "user"
? <Avatar name={chat.authorName} size={32} />
: <AssistantAvatar size={32} />}
<div className="min-w-0 max-w-[90%] flex-1"> <div className="min-w-0 max-w-[90%] flex-1">
<div className="mb-1 flex items-center gap-1.5 text-xs opacity-60"> <div className="mb-1 flex items-center gap-1.5 text-xs opacity-60">
{m.role === "user" ? <UserIcon size={12} /> : <Bot size={12} />} {m.role === "user" ? <UserIcon size={12} /> : <Bot size={12} />}
+1 -1
View File
@@ -271,7 +271,7 @@ export function Sidebar({
)} )}
<div className="space-y-2 rounded-2xl border border-stone-200/70 bg-white p-2.5 dark:border-zinc-800 dark:bg-zinc-900"> <div className="space-y-2 rounded-2xl border border-stone-200/70 bg-white p-2.5 dark:border-zinc-800 dark:bg-zinc-900">
<button onClick={onOpenSettings} className="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"> <button onClick={onOpenSettings} className="m-0.5 flex w-[calc(100%-4px)] items-center gap-2.5 rounded-xl p-1.5 text-left transition hover:bg-accent-600/10" title="Profile & settings">
<Avatar name={user.displayName} url={user.avatarUrl} size={34} /> <Avatar name={user.displayName} url={user.avatarUrl} size={34} />
<span className="min-w-0 flex-1"> <span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">{user.displayName}</span> <span className="block truncate text-sm font-medium">{user.displayName}</span>
+59 -40
View File
@@ -357,13 +357,22 @@ export function SettingsModal({
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const fileRef = useRef<HTMLInputElement>(null); const fileRef = useRef<HTMLInputElement>(null);
// Section search: each section lists keywords; a section shows when the // Two-column settings: categories on the left, content on the right.
// query is empty or matches its title/keywords. // Searching filters the category list and jumps to the first match.
function show(...keywords: string[]): boolean { const CATS = [
{ id: "profile", title: "Profile", icon: UserIcon, keywords: ["profile", "avatar", "picture", "photo", "name", "display", "email", "account"] },
{ id: "appearance", title: "Appearance", icon: Palette, keywords: ["appearance", "theme", "dark", "light", "color", "colour", "accent", "palette"] },
{ id: "features", title: "Features", icon: Sparkles, keywords: ["features", "thinking", "attachments", "search", "deep", "upload", "ocr", "capabilities", "enable", "disable"] },
] as const;
type CatId = (typeof CATS)[number]["id"];
const [cat, setCat] = useState<CatId>("profile");
const visibleCats = CATS.filter((c) => {
const q = search.trim().toLowerCase(); const q = search.trim().toLowerCase();
if (!q) return true; if (!q) return true;
return keywords.some((k) => k.toLowerCase().includes(q)); return c.title.toLowerCase().includes(q) || c.keywords.some((k) => k.includes(q));
} });
const activeCat = visibleCats.some((c) => c.id === cat) ? cat : (visibleCats[0]?.id ?? "profile");
useEffect(() => { useEffect(() => {
if (user) { if (user) {
@@ -442,9 +451,31 @@ export function SettingsModal({
)} )}
</div> </div>
{show("profile", "avatar", "picture", "photo", "name", "display", "email", "account") && ( <div className="flex flex-col gap-4 sm:flex-row">
<SettingSection icon={UserIcon} title="Profile"> <nav aria-label="Settings categories" className="flex shrink-0 gap-1.5 overflow-x-auto sm:w-44 sm:flex-col">
<div className="flex items-center gap-3"> {visibleCats.map((c) => (
<button
key={c.id}
type="button"
onClick={() => setCat(c.id)}
aria-current={activeCat === c.id}
className={cn(
"flex shrink-0 items-center gap-2.5 rounded-2xl px-3.5 py-2.5 text-sm transition",
activeCat === c.id
? "bg-accent-600/10 font-medium text-accent-900 dark:bg-accent-500/10 dark:text-accent-100"
: "hover:bg-stone-200/50 dark:hover:bg-zinc-800/70",
)}
>
<c.icon size={16} className="opacity-70" />
{c.title}
</button>
))}
</nav>
<div className="min-w-0 flex-1 space-y-4">
{activeCat === "profile" && (
<SettingsPane>
<div className="flex items-center gap-3">
<Avatar name={displayName || "?"} url={avatarUrl || undefined} size={52} /> <Avatar name={displayName || "?"} url={avatarUrl || undefined} size={52} />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{displayName || "…"}</p> <p className="truncate text-sm font-medium">{displayName || "…"}</p>
@@ -501,11 +532,11 @@ export function SettingsModal({
<Field label="Recovery email" hint="Optional. Used only to send you a fresh access key if you lose it."> <Field label="Recovery email" hint="Optional. Used only to send you a fresh access key if you lose it.">
<Input value={email} onChange={(e) => setEmail(e.target.value)} placeholder="[email protected]" inputMode="email" /> <Input value={email} onChange={(e) => setEmail(e.target.value)} placeholder="[email protected]" inputMode="email" />
</Field> </Field>
</SettingSection> </SettingsPane>
)} )}
{show("appearance", "theme", "dark", "light", "color", "colour", "accent", "palette") && ( {activeCat === "appearance" && (
<SettingSection icon={Palette} title="Appearance"> <SettingsPane>
<Field label="Theme"> <Field label="Theme">
<Picker <Picker
ariaLabel="Theme" ariaLabel="Theme"
@@ -520,18 +551,19 @@ export function SettingsModal({
/> />
</Field> </Field>
<AccentSection /> <AccentSection />
</SettingSection> </SettingsPane>
)} )}
{show("features", "thinking", "attachments", "search", "deep", "upload", "ocr", "capabilities", "enable", "disable") && ( {activeCat === "features" && (
<SettingSection icon={Sparkles} title="Features" hint="Turn composer capabilities on or off. Applied instantly, saved on this device."> <SettingsPane>
<p className="-mb-2 text-xs opacity-60">Turn composer capabilities on or off. Applied instantly, saved on this device.</p>
<FeaturesSection /> <FeaturesSection />
</SettingSection> </SettingsPane>
)} )}
</div>
</div>
{search.trim() && !show("profile", "avatar", "picture", "photo", "name", "display", "email", "account") {search.trim() && visibleCats.length === 0 && (
&& !show("appearance", "theme", "dark", "light", "color", "colour", "accent", "palette")
&& !show("features", "thinking", "attachments", "search", "deep", "upload", "ocr", "capabilities", "enable", "disable") && (
<p className="px-2 py-4 text-center text-sm opacity-50">No settings match “{search.trim()}”.</p> <p className="px-2 py-4 text-center text-sm opacity-50">No settings match “{search.trim()}”.</p>
)} )}
@@ -545,27 +577,14 @@ export function SettingsModal({
); );
} }
/* ---------- Settings sections ---------- */ /* ---------- Settings pane (unified glass card for every category) ---------- */
function SettingSection({ function SettingsPane({ children }: { children: React.ReactNode }) {
icon: Icon, // Single unified surface for every settings section: flat matte card, no
title, // blur, no gradient — identical gloss on profile, appearance and features.
hint,
children,
}: {
icon: typeof Settings2;
title: string;
hint?: string;
children: React.ReactNode;
}) {
return ( return (
<section className="rounded-3xl border border-stone-200/70 p-4 dark:border-zinc-800"> <section className="space-y-4 rounded-3xl border border-stone-200/70 bg-stone-50 p-4 dark:border-zinc-800 dark:bg-zinc-800/60">
<h3 className="mb-1 flex items-center gap-2 text-sm font-semibold"> {children}
<Icon size={15} className="opacity-60" />
{title}
</h3>
{hint && <p className="mb-3 text-xs opacity-60">{hint}</p>}
<div className="mt-3 space-y-4">{children}</div>
</section> </section>
); );
} }
+43 -19
View File
@@ -455,20 +455,52 @@ export function Avatar({ name, url, size = 36 }: { name: string; url?: string; s
); );
} }
/* ---------- Brand logo ---------- */ /* ---------- Brand logo (single declaration — import anywhere) ---------- */
export const LOGO_URL = "/logo.jpg"; /**
* The KisAssistant flower mark. Petals follow the runtime accent color,
export function Logo({ size = 36, className }: { size?: number; className?: string }) { * heart stays amber — same artwork as the loading screen and favicon.
* `dynamic` controls the bloom animation: pass false for a static,
* full-bloom mark (e.g. thread bars and headers).
*/
export function FlowerMark({ size, className, dynamic = true }: { size?: number; className?: string; dynamic?: boolean }) {
const petals = [0, 60, 120, 180, 240, 300];
return ( return (
<img <svg
src={LOGO_URL}
alt="KisAssistant logo"
width={size} width={size}
height={size} height={size}
className={cn("shrink-0 object-cover", className)} viewBox="0 0 100 100"
className={cn("shrink-0", className)}
role="img"
aria-label="KisAssistant"
>
{petals.map((r, i) => (
<g key={r} transform={`rotate(${r} 50 50)`}>
<ellipse
cx="50"
cy="25"
rx="13"
ry="21"
className={dynamic ? "flower-petal fill-accent-500" : "fill-accent-500"}
style={dynamic ? { animationDelay: `${i * 0.22}s` } : undefined}
/>
</g>
))}
<circle cx="50" cy="50" r="10" className={dynamic ? "flower-core fill-amber-400" : "fill-amber-400"} />
</svg>
);
}
/** Assistant identity: flower mark on a soft circle (message avatars, headers). */
export function AssistantAvatar({ size = 32 }: { size?: number }) {
return (
<span
className="grid shrink-0 place-items-center rounded-full bg-accent-600/10"
style={{ width: size, height: size }} style={{ width: size, height: size }}
/> aria-label="KisAssistant"
>
<FlowerMark size={Math.round(size * 0.78)} />
</span>
); );
} }
@@ -542,18 +574,10 @@ export function CopyButton({ text, label = "Copy" }: { text: string; label?: str
/** Animated blooming flower shown while the app boots (themed, mobile-safe). */ /** Animated blooming flower shown while the app boots (themed, mobile-safe). */
export function LoadingScreen() { export function LoadingScreen() {
const petals = [0, 60, 120, 180, 240, 300];
return ( return (
<div className="grid min-h-full place-items-center" role="status" aria-label="Loading KisAssistant"> <div className="loading-enter grid min-h-full place-items-center" role="status" aria-label="Loading KisAssistant">
<div className="flex flex-col items-center gap-4 px-6 text-center"> <div className="flex flex-col items-center gap-4 px-6 text-center">
<svg width="72" height="72" viewBox="0 0 100 100" className="h-16 w-16 sm:h-20 sm:w-20" aria-hidden="true"> <FlowerMark className="h-16 w-16 sm:h-20 sm:w-20" />
{petals.map((r, i) => (
<g key={r} transform={`rotate(${r} 50 50)`}>
<ellipse cx="50" cy="25" rx="13" ry="21" className="flower-petal fill-accent-500" style={{ animationDelay: `${i * 0.22}s` }} />
</g>
))}
<circle cx="50" cy="50" r="10" className="flower-core fill-accent-700 dark:fill-accent-400" />
</svg>
<p className="font-serif text-2xl font-bold tracking-tight">KisAssistant</p> <p className="font-serif text-2xl font-bold tracking-tight">KisAssistant</p>
<p className="loading-dots text-sm opacity-60">Preparing your chats</p> <p className="loading-dots text-sm opacity-60">Preparing your chats</p>
</div> </div>
+15
View File
@@ -109,6 +109,15 @@ body {
0%, 100% { transform: scale(0.85); } 0%, 100% { transform: scale(0.85); }
50% { transform: scale(1.15); } 50% { transform: scale(1.15); }
} }
/* Smooth entrance for full-screen loaders. */
@keyframes loading-in {
from { opacity: 0; transform: scale(0.94); }
to { opacity: 1; transform: scale(1); }
}
.loading-enter {
animation: loading-in 0.45s ease-out both;
}
.loading-dots::after { .loading-dots::after {
content: ""; content: "";
animation: dots 1.4s steps(4) infinite; animation: dots 1.4s steps(4) infinite;
@@ -120,6 +129,12 @@ body {
75% { content: "..."; } 75% { content: "..."; }
} }
@media (prefers-reduced-motion: reduce) {
.flower-petal, .flower-core, .loading-dots::after, .loading-enter, .typing-cursor::after {
animation: none;
}
}
/* Kill Chrome's yellow autofill background (login / password fields) */ /* Kill Chrome's yellow autofill background (login / password fields) */
input:-webkit-autofill, input:-webkit-autofill,
input:-webkit-autofill:hover, input:-webkit-autofill:hover,
+61 -2
View File
@@ -1,5 +1,26 @@
import type { ChatMessage, Conversation, DriverModel, SharedChat, User } from "./types.ts"; import type { ChatMessage, Conversation, DriverModel, SharedChat, User } from "./types.ts";
export type AccessStatus = "pending" | "reviewing" | "accepted" | "refused";
export interface AccessRequest {
id: string;
username: string;
email: string;
message: string;
status: AccessStatus;
reason: string;
created_at: number;
updated_at: number;
}
export interface AccessMessage {
id: number;
request_id: string;
author: "user" | "admin";
body: string;
created_at: number;
}
const TOKEN_KEY = "kisassistant_token"; const TOKEN_KEY = "kisassistant_token";
export function getToken(): string | null { export function getToken(): string | null {
@@ -58,7 +79,18 @@ export const api = {
updateMe: (patch: { displayName?: string; avatarUrl?: string; theme?: string; email?: string }) => updateMe: (patch: { displayName?: string; avatarUrl?: string; theme?: string; email?: string }) =>
req<{ user: User }>("/api/me", { method: "PATCH", body: JSON.stringify(patch) }), req<{ user: User }>("/api/me", { method: "PATCH", body: JSON.stringify(patch) }),
adminList: () => req<{ users: User[] }>("/api/admin/users"), adminList: (p: { q?: string; sort?: string; filter?: string; page?: number; per?: number } = {}) => {
const qs = new URLSearchParams();
if (p.q) qs.set("q", p.q);
if (p.sort) qs.set("sort", p.sort);
if (p.filter) qs.set("filter", p.filter);
if (p.page) qs.set("page", String(p.page));
if (p.per) qs.set("per", String(p.per));
const s = qs.toString();
return req<{ users: User[]; total: number; page: number; perPage: number; pages: number }>(
`/api/admin/users${s ? `?${s}` : ""}`,
);
},
adminCreate: (u: { username: string; displayName?: string; avatarUrl?: string; theme?: string; email?: string }) => adminCreate: (u: { username: string; displayName?: string; avatarUrl?: string; theme?: string; email?: string }) =>
req<{ user: User; key: string }>("/api/admin/users", { method: "POST", body: JSON.stringify(u) }), req<{ user: User; key: string }>("/api/admin/users", { method: "POST", body: JSON.stringify(u) }),
adminDelete: (id: string) => req<{ ok: boolean }>(`/api/admin/users/${id}`, { method: "DELETE" }), adminDelete: (id: string) => req<{ ok: boolean }>(`/api/admin/users/${id}`, { method: "DELETE" }),
@@ -90,7 +122,34 @@ export const api = {
models: () => req<{ models: DriverModel[] }>("/api/models"), models: () => req<{ models: DriverModel[] }>("/api/models"),
/** Admin: stream an Ollama /api/pull as NDJSON progress events. */ /** Access-request wishlist (public, no auth). */
accessRequest: (a: { username: string; email: string; message: string }) =>
req<{ request: AccessRequest; reviewUrl: string }>("/api/access/request", {
method: "POST",
body: JSON.stringify(a),
}, false),
accessTicket: (id: string) =>
req<{ request: AccessRequest; messages: AccessMessage[] }>(`/api/access/ticket/${id}`, {}, false),
accessReply: (id: string, body: string) =>
req<{ message: AccessMessage }>(`/api/access/ticket/${id}/message`, {
method: "POST",
body: JSON.stringify({ body }),
}, false),
/** Admin: access-request triage. */
adminAccessList: () => req<{ requests: (AccessRequest & { message_count: number })[] }>("/api/admin/access"),
adminAccessGet: (id: string) =>
req<{ request: AccessRequest; messages: AccessMessage[] }>(`/api/admin/access/${id}`),
adminAccessPatch: (id: string, patch: { status: AccessStatus; reason?: string }) =>
req<{ request: AccessRequest; key?: string }>(`/api/admin/access/${id}`, {
method: "PATCH",
body: JSON.stringify(patch),
}),
adminAccessReply: (id: string, body: string) =>
req<{ message: AccessMessage }>(`/api/admin/access/${id}/message`, {
method: "POST",
body: JSON.stringify({ body }),
}),
ollamaPull: async ( ollamaPull: async (
name: string, name: string,
onProgress: (p: { status?: string; digest?: string; total?: number; completed?: number; error?: string }) => void, onProgress: (p: { status?: string; digest?: string; total?: number; completed?: number; error?: string }) => void,
+114
View File
@@ -0,0 +1,114 @@
import { randomUUID } from "node:crypto";
import { getDb } from "./db.ts";
/**
* Access-request wishlist (registration approval queue) + minimal ticket
* system. Tables are created idempotently here so db.ts stays untouched.
*
* Flow: visitor reserves username+email with a motivation message ->
* ticket page at /review/<request-id> (UUID, unguessable — same pattern as
* public share links) -> admin triages in the AdminCenter (reviewing /
* accepted / refused, each with a reason) -> both sides exchange messages;
* every admin event emails the requester (notification-only).
*/
export type AccessStatus = "pending" | "reviewing" | "accepted" | "refused";
export interface AccessRequest {
id: string;
username: string;
email: string;
message: string;
status: AccessStatus;
reason: string;
created_at: number;
updated_at: number;
}
export interface AccessMessage {
id: number;
request_id: string;
author: "user" | "admin";
body: string;
created_at: number;
}
export function initAccessTables(): void {
const d = getDb();
d.query(`CREATE TABLE IF NOT EXISTS access_requests (
id TEXT PRIMARY KEY,
username TEXT NOT NULL,
email TEXT NOT NULL,
message TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'pending',
reason TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)`).run();
d.query(`CREATE TABLE IF NOT EXISTS access_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
request_id TEXT NOT NULL REFERENCES access_requests(id) ON DELETE CASCADE,
author TEXT NOT NULL,
body TEXT NOT NULL,
created_at INTEGER NOT NULL
)`).run();
d.query(`CREATE INDEX IF NOT EXISTS idx_access_requests_status ON access_requests(status, updated_at DESC)`).run();
d.query(`CREATE INDEX IF NOT EXISTS idx_access_messages_req ON access_messages(request_id, id)`).run();
}
export function createAccessRequest(opts: {
username: string;
email: string;
message: string;
}): AccessRequest {
const now = Date.now();
const row: AccessRequest = {
id: randomUUID(),
username: opts.username,
email: opts.email,
message: opts.message,
status: "pending",
reason: "",
created_at: now,
updated_at: now,
};
getDb().query(
"INSERT INTO access_requests (id, username, email, message, status, reason, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
).run(row.id, row.username, row.email, row.message, row.status, row.reason, row.created_at, row.updated_at);
return row;
}
export function getAccessRequest(id: string): AccessRequest | null {
return getDb().query("SELECT * FROM access_requests WHERE id = ?").get(id) as AccessRequest | null;
}
/** Open (non-terminal) request already reserving this username or email. */
export function findOpenAccessRequest(username: string, email: string): AccessRequest | null {
return getDb().query(
"SELECT * FROM access_requests WHERE status IN ('pending','reviewing') AND (username = ? OR email = ?) ORDER BY created_at DESC LIMIT 1",
).get(username, email) as AccessRequest | null;
}
export function listAccessRequests(): (AccessRequest & { message_count: number })[] {
return getDb().query(
`SELECT r.*, (SELECT COUNT(*) FROM access_messages m WHERE m.request_id = r.id) AS message_count
FROM access_requests r ORDER BY r.updated_at DESC`,
).all() as (AccessRequest & { message_count: number })[];
}
export function setAccessStatus(id: string, status: AccessStatus, reason: string): AccessRequest | null {
getDb().query("UPDATE access_requests SET status = ?, reason = ?, updated_at = ? WHERE id = ?").run(status, reason, Date.now(), id);
return getAccessRequest(id);
}
export function listAccessMessages(requestId: string): AccessMessage[] {
return getDb().query("SELECT * FROM access_messages WHERE request_id = ? ORDER BY id ASC").all(requestId) as AccessMessage[];
}
export function addAccessMessage(requestId: string, author: "user" | "admin", body: string): AccessMessage {
const now = Date.now();
getDb().query("INSERT INTO access_messages (request_id, author, body, created_at) VALUES (?, ?, ?, ?)").run(requestId, author, body, now);
getDb().query("UPDATE access_requests SET updated_at = ? WHERE id = ?").run(now, requestId);
const id = (getDb().query("SELECT last_insert_rowid() AS id").get() as { id: number }).id;
return { id, request_id: requestId, author, body, created_at: now };
}
+153 -2
View File
@@ -43,12 +43,23 @@ import {
updateUser, updateUser,
} from "./db.ts"; } from "./db.ts";
import type { ChatMessage } from "./drivers/types.ts"; import type { ChatMessage } from "./drivers/types.ts";
import {
addAccessMessage,
createAccessRequest,
findOpenAccessRequest,
getAccessRequest,
initAccessTables,
listAccessMessages,
listAccessRequests,
setAccessStatus,
type AccessStatus,
} from "./access.ts";
import type { ConversationRow } from "./db.ts"; import type { ConversationRow } from "./db.ts";
import type { OllamaDriver } from "./drivers/ollama.ts"; import type { OllamaDriver } from "./drivers/ollama.ts";
import { DriverRegistry } from "./drivers/registry.ts"; import { DriverRegistry } from "./drivers/registry.ts";
import { createHash, randomBytes } from "node:crypto"; import { createHash, randomBytes } from "node:crypto";
import { statSync } from "node:fs"; import { statSync } from "node:fs";
import { mailEnabled, sendRecoveryEmail } from "./mail.ts"; import { mailEnabled, sendAccessMessageEmail, sendAccessReceivedEmail, sendAccessStatusEmail, sendRecoveryEmail } from "./mail.ts";
import { import {
CDN_NAMESPACES, CDN_NAMESPACES,
cdnFile, cdnFile,
@@ -71,6 +82,7 @@ const registry = new DriverRegistry();
// Ollama handles admin model pulls/deletes (host is loopback/LAN, no auth). // Ollama handles admin model pulls/deletes (host is loopback/LAN, no auth).
const ollamaDriver = registry.get("ollama") as OllamaDriver; const ollamaDriver = registry.get("ollama") as OllamaDriver;
await tools.init(); await tools.init();
initAccessTables();
function json(data: unknown, status = 200): Response { function json(data: unknown, status = 200): Response {
return new Response(JSON.stringify(data), { return new Response(JSON.stringify(data), {
@@ -390,6 +402,66 @@ const server = Bun.serve({
} }
} }
// --- access-request wishlist (public ticket system, email-notified) ---
// Reserve a username + email with a motivation; triaged in the AdminCenter.
if (path === "/api/access/request" && req.method === "POST") {
const ip = clientIp(req, server);
if (isRateLimited(ip)) return json({ error: "too many attempts, try again later" }, 429);
const { ok, body } = await readJson(req);
if (!ok) {
recordAttempt(ip);
return json({ error: "invalid JSON" }, 400);
}
const username = validUsername(body.username);
const email = validEmail(body.email);
const message = cleanStr(body.message, 2000);
if (!username || !email || !message || message.length < 10) {
recordAttempt(ip);
return json({ error: "username (2-32 chars), valid email and a motivation (10+ chars) are required" }, 400);
}
if (getUserByUsername(username)) {
recordAttempt(ip);
return json({ error: "username taken" }, 409);
}
if (findOpenAccessRequest(username, email)) {
recordAttempt(ip);
return json({ error: "a request for this username or email is already open" }, 409);
}
const created = createAccessRequest({ username, email, message });
addAccessMessage(created.id, "user", message);
clearAttempts(ip);
// Ticket page: /review/<uuid> (unguessable, same pattern as share links).
const reviewUrl = `${config.appUrl}/review/${created.id}`;
sendAccessReceivedEmail(email, username, reviewUrl).catch(() => {});
return json({ request: created, reviewUrl }, 201);
}
const ticketMatch = path.match(/^\/api\/access\/ticket\/([0-9a-f-]{36})(\/message)?$/);
if (ticketMatch) {
const r = getAccessRequest(ticketMatch[1]!);
if (!r) return json({ error: "invalid ticket link" }, 404);
if (req.method === "GET" && !ticketMatch[2]) {
return json({ request: r, messages: listAccessMessages(r.id) });
}
if (req.method === "POST" && ticketMatch[2] === "/message") {
const ip = clientIp(req, server);
if (isRateLimited(ip)) return json({ error: "too many attempts, try again later" }, 429);
if (r.status === "accepted" || r.status === "refused") {
return json({ error: "ticket closed" }, 403);
}
const { ok, body } = await readJson(req);
const text = ok ? cleanStr(body.body, 2000) : null;
if (!text) {
recordAttempt(ip);
return json({ error: "message: 1-2000 chars" }, 400);
}
const msg = addAccessMessage(r.id, "user", text);
clearAttempts(ip);
return json({ message: msg }, 201);
}
return json({ error: "not found" }, 404);
}
// --- everything below requires auth --- // --- everything below requires auth ---
if (path.startsWith("/api/")) { if (path.startsWith("/api/")) {
const user = requireAuth(req); const user = requireAuth(req);
@@ -430,7 +502,35 @@ const server = Bun.serve({
// --- admin: user management (No-KYC accounts, keys issued by admin) --- // --- admin: user management (No-KYC accounts, keys issued by admin) ---
if (path === "/api/admin/users" && req.method === "GET") { if (path === "/api/admin/users" && req.method === "GET") {
if (!admin) return json({ error: "forbidden" }, 403); if (!admin) return json({ error: "forbidden" }, 403);
return json({ users: listUsers().map(toPublicUser) }); // Listing with search / sort / filter / pagination for the Admin Center.
const q = (url.searchParams.get("q") || "").trim().toLowerCase();
const sort = url.searchParams.get("sort") || "newest";
const filter = url.searchParams.get("filter") || "all"; // all | with-email | no-email
const per = Math.min(Math.max(Number(url.searchParams.get("per")) || 10, 1), 50);
const page = Math.max(Number(url.searchParams.get("page")) || 1, 1);
let users = listUsers();
if (q) {
users = users.filter(
(u) =>
u.username.includes(q) ||
u.display_name.toLowerCase().includes(q) ||
(u.email || "").toLowerCase().includes(q),
);
}
if (filter === "with-email") users = users.filter((u) => u.email);
else if (filter === "no-email") users = users.filter((u) => !u.email);
const sorts: Record<string, (a: (typeof users)[number], b: (typeof users)[number]) => number> = {
newest: (a, b) => b.created_at - a.created_at,
oldest: (a, b) => a.created_at - b.created_at,
az: (a, b) => a.username.localeCompare(b.username),
za: (a, b) => b.username.localeCompare(a.username),
};
users.sort(sorts[sort] ?? sorts.newest);
const total = users.length;
const pages = Math.max(1, Math.ceil(total / per));
const safePage = Math.min(page, pages);
const slice = users.slice((safePage - 1) * per, safePage * per);
return json({ users: slice.map(toPublicUser), total, page: safePage, perPage: per, pages });
} }
if (path === "/api/admin/users" && req.method === "POST") { if (path === "/api/admin/users" && req.method === "POST") {
@@ -620,6 +720,57 @@ const server = Bun.serve({
} }
} }
// --- admin: access-request wishlist triage ---
if (path === "/api/admin/access" && req.method === "GET") {
if (!admin) return json({ error: "forbidden" }, 403);
return json({ requests: listAccessRequests() });
}
const accessMatch = path.match(/^\/api\/admin\/access\/([0-9a-f-]{36})(\/message)?$/);
if (accessMatch) {
if (!admin) return json({ error: "forbidden" }, 403);
const target = getAccessRequest(accessMatch[1]!);
if (!target) return json({ error: "request not found" }, 404);
const reviewUrl = `${config.appUrl}/review/${target.id}`;
if (req.method === "GET" && !accessMatch[2]) {
return json({ request: target, messages: listAccessMessages(target.id) });
}
if (req.method === "POST" && accessMatch[2] === "/message") {
const { ok, body } = await readJson(req);
const text = ok ? cleanStr(body.body, 2000) : null;
if (!text) return json({ error: "message: 1-2000 chars" }, 400);
const msg = addAccessMessage(target.id, "admin", text);
// Every admin reply is forwarded to the requester (notification-only).
sendAccessMessageEmail(target.email, target.username, true, text, reviewUrl).catch(() => {});
return json({ message: msg });
}
if (req.method === "PATCH" && !accessMatch[2]) {
const { ok, body } = await readJson(req);
if (!ok) return json({ error: "invalid JSON" }, 400);
const status = body.status as AccessStatus | undefined;
if (status !== "pending" && status !== "reviewing" && status !== "accepted" && status !== "refused") {
return json({ error: "status must be pending|reviewing|accepted|refused" }, 400);
}
const reason = body.reason === undefined ? "" : cleanStr(body.reason, 500);
if (reason === null) return json({ error: "reason: max 500 chars" }, 400);
if ((target.status === "accepted" || target.status === "refused") && status !== target.status) {
return json({ error: "ticket closed" }, 409);
}
let key: string | undefined;
if (status === "accepted" && target.status !== "accepted") {
if (getUserByUsername(target.username)) return json({ error: "username taken" }, 409);
key = newAccessKey();
createUser({ username: target.username, displayName: target.username, avatarUrl: "", theme: "auto", email: target.email, keyHash: hashSecret(key) });
}
const updated = setAccessStatus(target.id, status, reason);
if (status !== target.status && status !== "pending") {
sendAccessStatusEmail(target.email, target.username, status, reason, reviewUrl, key).catch(() => {});
}
return json({ request: updated, ...(key ? { key } : {}) });
}
return json({ error: "not found" }, 404);
}
// --- conversations (server-persisted, per account) --- // --- conversations (server-persisted, per account) ---
if (path === "/api/conversations" && req.method === "GET") { if (path === "/api/conversations" && req.method === "GET") {
return json({ conversations: listConversations(user.id) }); return json({ conversations: listConversations(user.id) });
+130
View File
@@ -37,6 +37,136 @@ async function getTransporter(): Promise<Transporter | null> {
return transporter; return transporter;
} }
function escHtml(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
/**
* Shared branded layout for ticket emails. Every mail states the same rule:
* email is notification-only — real replies happen on the /review page.
*/
function ticketLayout(title: string, bodyHtml: string, reviewUrl: string): { html: string; text: string } {
const text = bodyHtml
.replace(/<br\s*\/?>/gi, "\n")
.replace(/<\/p>/gi, "\n\n")
.replace(/<[^>]+>/g, "");
const html =
`<!doctype html><html><body style="margin:0;padding:0;background:#f5f5f4;font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;">` +
`<div style="max-width:560px;margin:0 auto;padding:24px 16px;">` +
`<div style="background:#ffffff;border-radius:16px;overflow:hidden;border:1px solid #e7e5e4;">` +
`<div style="background:#059669;padding:20px 24px;color:#ffffff;">` +
`<div style="font-size:20px;font-weight:700;">KisAssistant</div>` +
`<div style="font-size:13px;opacity:.85;">${escHtml(title)}</div>` +
`</div>` +
`<div style="padding:24px;color:#1c1917;font-size:14px;line-height:1.6;">${bodyHtml}</div>` +
`<div style="padding:16px 24px;background:#fafaf9;border-top:1px solid #e7e5e4;font-size:12px;color:#78716c;">` +
`This email is notification-only — please do not reply to it.<br>` +
`Respond on the official site: <a href="${escHtml(reviewUrl)}" style="color:#059669;">${escHtml(reviewUrl)}</a>` +
`</div></div></div></body></html>`;
return { html, text: `${text}\n\n--\nThis email is notification-only, do not reply. Respond at: ${reviewUrl}\n` };
}
async function sendTicketMail(to: string, subject: string, title: string, bodyHtml: string, reviewUrl: string): Promise<boolean> {
const t = await getTransporter();
if (!t) {
console.error("[mail] SMTP_HOST not set — ticket email skipped");
return false;
}
try {
const { html, text } = ticketLayout(title, bodyHtml, reviewUrl);
await t.sendMail({ from: config.smtpFrom, to, subject, text, html });
console.log(`[mail] ticket email "${subject}" sent`);
return true;
} catch (e) {
console.error("[mail] send failed:", (e as Error).message);
return false;
}
}
/** Confirm a wishlist request was received, with the /review ticket link. */
export async function sendAccessReceivedEmail(to: string, username: string, reviewUrl: string): Promise<boolean> {
return sendTicketMail(
to,
"KisAssistant — access request received",
"Access request received",
`<p>Hello <strong>${escHtml(username)}</strong>,</p>` +
`<p>We received your request for a KisAssistant account. An admin will review it shortly.</p>` +
`<p>Follow the discussion and add arguments here:<br>` +
`<a href="${escHtml(reviewUrl)}" style="color:#059669;">${escHtml(reviewUrl)}</a></p>` +
`<p>Keep this link — it is your personal ticket page.</p>`,
reviewUrl,
);
}
/** Notify status changes. `key` is set only on acceptance (shown once). */
export async function sendAccessStatusEmail(
to: string,
username: string,
status: "reviewing" | "accepted" | "refused",
reason: string,
reviewUrl: string,
key?: string,
): Promise<boolean> {
if (status === "accepted") {
return sendTicketMail(
to,
"KisAssistant — access granted",
"Access granted",
`<p>Hello <strong>${escHtml(username)}</strong>,</p>` +
`<p>Good news — your KisAssistant account was created${reason ? ` (${escHtml(reason)})` : ""}.</p>` +
`<p>Your access key (shown <strong>once</strong> — save it now):</p>` +
`<p style="background:#f5f5f4;border-radius:8px;padding:12px 16px;font-family:monospace;font-size:15px;">${escHtml(key ?? "")}</p>` +
`<p>Log in with your username and this key. You can still discuss on your ticket page:<br>` +
`<a href="${escHtml(reviewUrl)}" style="color:#059669;">${escHtml(reviewUrl)}</a></p>`,
reviewUrl,
);
}
if (status === "refused") {
return sendTicketMail(
to,
"KisAssistant — access request refused",
"Access refused",
`<p>Hello <strong>${escHtml(username)}</strong>,</p>` +
`<p>Your request for a KisAssistant account was refused${reason ? ` for this reason: <em>${escHtml(reason)}</em>` : ""}.</p>` +
`<p>You may still reply on your ticket page if you think this is a mistake:<br>` +
`<a href="${escHtml(reviewUrl)}" style="color:#059669;">${escHtml(reviewUrl)}</a></p>`,
reviewUrl,
);
}
return sendTicketMail(
to,
"KisAssistant — access request under review",
"Under review",
`<p>Hello <strong>${escHtml(username)}</strong>,</p>` +
`<p>An admin is now reviewing your request${reason ? `: <em>${escHtml(reason)}</em>` : ""}.</p>` +
`<p>You can add arguments on your ticket page:<br>` +
`<a href="${escHtml(reviewUrl)}" style="color:#059669;">${escHtml(reviewUrl)}</a></p>`,
reviewUrl,
);
}
/** Forward each new ticket message to the requester (notification-only). */
export async function sendAccessMessageEmail(
to: string,
username: string,
fromAdmin: boolean,
body: string,
reviewUrl: string,
): Promise<boolean> {
const excerpt = body.length > 500 ? `${body.slice(0, 500)}…` : body;
return sendTicketMail(
to,
`KisAssistant — new message on your access request`,
"New ticket message",
`<p>Hello <strong>${escHtml(username)}</strong>,</p>` +
`<p><strong>${fromAdmin ? "An admin" : "You"}</strong> wrote on your access-request ticket:</p>` +
`<p style="background:#f5f5f4;border-left:4px solid #059669;border-radius:0 8px 8px 0;padding:12px 16px;white-space:pre-wrap;">${escHtml(excerpt)}</p>` +
`<p>Reply on your ticket page:<br>` +
`<a href="${escHtml(reviewUrl)}" style="color:#059669;">${escHtml(reviewUrl)}</a></p>`,
reviewUrl,
);
}
/** Send a key-recovery email with a one-time reset link. Fire-and-forget safe. */ /** Send a key-recovery email with a one-time reset link. Fire-and-forget safe. */
export async function sendRecoveryEmail(to: string, username: string, resetUrl: string): Promise<boolean> { export async function sendRecoveryEmail(to: string, username: string, resetUrl: string): Promise<boolean> {
const t = await getTransporter(); const t = await getTransporter();