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) |
| 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 |
+6 -1
View File
@@ -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
+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
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
+25 -2
View File
@@ -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/<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)
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"
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" />
<!-- Twitter -->
@@ -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."
/>
<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="apple-touch-icon" href="/logo.jpg" />
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
<link rel="apple-touch-icon" href="/logo.svg" />
</head>
<body>
<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 { 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<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 [model, setModel] = useState("");
const [convs, setConvs] = useState<Conversation[]>([]);
@@ -63,6 +70,9 @@ export function App() {
if (RESET_TOKEN) {
return <ResetPage token={RESET_TOKEN} />;
}
if (REVIEW_ID) {
return <ReviewPage ticketId={REVIEW_ID} />;
}
if (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 { 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 { 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";
type Tab = "accounts" | "features" | "mail";
type Tab = "accounts" | "access" | "features" | "mail";
function applyTheme(theme: string) {
const root = document.documentElement;
@@ -16,6 +17,7 @@ function applyTheme(theme: string) {
const TABS: { id: Tab; label: string; icon: typeof Users }[] = [
{ id: "accounts", label: "Accounts", icon: Users },
{ id: "access", label: "Access", icon: Inbox },
{ id: "features", label: "Features", icon: SlidersHorizontal },
{ 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">
<ArrowLeft size={18} />
</a>
<Logo size={32} className="rounded-xl" />
<FlowerMark size={32} />
<h1 className="flex items-center gap-2 text-lg font-bold tracking-tight">
<LayoutDashboard size={18} className="text-accent-600 dark:text-accent-400" />
Admin Center
@@ -116,6 +118,12 @@ export function AdminCenter() {
</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" && (
<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">
+83 -4
View File
@@ -1,8 +1,8 @@
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 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). */
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 [editTarget, setEditTarget] = useState<User | null>(null);
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
const [username, setUsername] = useState("");
@@ -197,8 +206,11 @@ export function AdminPanel({ open, onClose, bare }: { open: boolean; onClose: ()
setBusy(true);
setError("");
try {
const res = await api.adminList();
const res = await api.adminList({ q: debouncedQ || undefined, sort, filter, page, per: PER_PAGE });
setUsers(res.users);
setTotal(res.total);
setPages(res.pages);
if (res.page !== page) setPage(res.page);
} catch (err) {
setError(err instanceof Error ? err.message : "Load failed");
} finally {
@@ -215,6 +227,20 @@ export function AdminPanel({ open, onClose, bare }: { open: boolean; onClose: ()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [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) {
e.preventDefault();
setError("");
@@ -299,6 +325,38 @@ export function AdminPanel({ open, onClose, bare }: { open: boolean; onClose: ()
</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 ? (
<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} />
<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="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>
{!u.isAdmin && (
<div className="flex shrink-0 gap-1">
@@ -321,6 +379,27 @@ export function AdminPanel({ open, onClose, bare }: { open: boolean; onClose: ()
))}
</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>
<ConfirmDialog
+7 -5
View File
@@ -1,7 +1,7 @@
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 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 { cn } from "../lib/cn.ts";
import { useFeatures } from "../lib/features.ts";
@@ -10,7 +10,9 @@ function MessageBubble({ msg, authorAvatar, authorName }: { msg: ChatMessage; au
const isUser = msg.role === "user";
return (
<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="mb-1 flex items-center gap-1.5 text-xs opacity-60">
{isUser ? <UserIcon size={12} /> : <Bot size={12} />}
@@ -123,7 +125,7 @@ export function Chat({
</span>
)}
<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"}
</span>
{conv?.topic && (
@@ -151,7 +153,7 @@ export function Chat({
<div className="mx-auto w-full max-w-3xl space-y-5">
{messages.length === 0 && !streaming && (
<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>
<p className="mt-1 text-sm opacity-60">Pick a model, then type below. Markdown is supported.</p>
</div>
@@ -161,7 +163,7 @@ export function Chat({
))}
{streaming !== "" && (
<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="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">
+19 -3
View File
@@ -1,9 +1,10 @@
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 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 { AccessRequestModal } from "./ReviewPage.tsx";
const PERKS = [
{ 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 [registerOpen, setRegisterOpen] = useState(false);
const [registrationOn, setRegistrationOn] = useState(false);
const [requestOpen, setRequestOpen] = useState(false);
useEffect(() => {
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="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">
<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>
<p className="mt-1 text-sm opacity-60">Sign in to KisAssistant to continue</p>
</div>
@@ -113,6 +115,20 @@ export function Login({ onLogin }: { onLogin: (user: UserType) => void }) {
onClose={() => setRegisterOpen(false)}
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 && (
<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?
+2 -2
View File
@@ -1,7 +1,7 @@
import { useEffect, useState } from "react";
import { KeyRound, MailWarning, Sparkles } from "lucide-react";
import { api } from "../lib/api.ts";
import { Button, CopyButton, Logo, Spinner } from "./ui.tsx";
import { Button, CopyButton, FlowerMark, Spinner } from "./ui.tsx";
type State =
| { kind: "loading" }
@@ -34,7 +34,7 @@ export function ResetPage({ token }: { token: string }) {
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="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 === "invalid" && (
<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 { api } from "../lib/api.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";
export function SharePage({ publicId }: { publicId: string }) {
@@ -19,7 +19,7 @@ export function SharePage({ publicId }: { publicId: string }) {
<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">
<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">
<p className="truncate font-semibold">{chat?.title ?? "Shared chat"}</p>
{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?.messages.map((m, i) => (
<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="mb-1 flex items-center gap-1.5 text-xs opacity-60">
{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">
<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} />
<span className="min-w-0 flex-1">
<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 fileRef = useRef<HTMLInputElement>(null);
// Section search: each section lists keywords; a section shows when the
// query is empty or matches its title/keywords.
function show(...keywords: string[]): boolean {
// Two-column settings: categories on the left, content on the right.
// Searching filters the category list and jumps to the first match.
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();
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(() => {
if (user) {
@@ -442,9 +451,31 @@ export function SettingsModal({
)}
</div>
{show("profile", "avatar", "picture", "photo", "name", "display", "email", "account") && (
<SettingSection icon={UserIcon} title="Profile">
<div className="flex items-center gap-3">
<div className="flex flex-col gap-4 sm:flex-row">
<nav aria-label="Settings categories" className="flex shrink-0 gap-1.5 overflow-x-auto sm:w-44 sm:flex-col">
{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} />
<div className="min-w-0 flex-1">
<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.">
<Input value={email} onChange={(e) => setEmail(e.target.value)} placeholder="[email protected]" inputMode="email" />
</Field>
</SettingSection>
)}
</SettingsPane>
)}
{show("appearance", "theme", "dark", "light", "color", "colour", "accent", "palette") && (
<SettingSection icon={Palette} title="Appearance">
{activeCat === "appearance" && (
<SettingsPane>
<Field label="Theme">
<Picker
ariaLabel="Theme"
@@ -520,18 +551,19 @@ export function SettingsModal({
/>
</Field>
<AccentSection />
</SettingSection>
)}
</SettingsPane>
)}
{show("features", "thinking", "attachments", "search", "deep", "upload", "ocr", "capabilities", "enable", "disable") && (
<SettingSection icon={Sparkles} title="Features" hint="Turn composer capabilities on or off. Applied instantly, saved on this device.">
{activeCat === "features" && (
<SettingsPane>
<p className="-mb-2 text-xs opacity-60">Turn composer capabilities on or off. Applied instantly, saved on this device.</p>
<FeaturesSection />
</SettingSection>
)}
</SettingsPane>
)}
</div>
</div>
{search.trim() && !show("profile", "avatar", "picture", "photo", "name", "display", "email", "account")
&& !show("appearance", "theme", "dark", "light", "color", "colour", "accent", "palette")
&& !show("features", "thinking", "attachments", "search", "deep", "upload", "ocr", "capabilities", "enable", "disable") && (
{search.trim() && visibleCats.length === 0 && (
<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({
icon: Icon,
title,
hint,
children,
}: {
icon: typeof Settings2;
title: string;
hint?: string;
children: React.ReactNode;
}) {
function SettingsPane({ children }: { children: React.ReactNode }) {
// Single unified surface for every settings section: flat matte card, no
// blur, no gradient — identical gloss on profile, appearance and features.
return (
<section className="rounded-3xl border border-stone-200/70 p-4 dark:border-zinc-800">
<h3 className="mb-1 flex items-center gap-2 text-sm font-semibold">
<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 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">
{children}
</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";
export function Logo({ size = 36, className }: { size?: number; className?: string }) {
/**
* The KisAssistant flower mark. Petals follow the runtime accent color,
* 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 (
<img
src={LOGO_URL}
alt="KisAssistant logo"
<svg
width={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 }}
/>
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). */
export function LoadingScreen() {
const petals = [0, 60, 120, 180, 240, 300];
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">
<svg width="72" height="72" viewBox="0 0 100 100" className="h-16 w-16 sm:h-20 sm:w-20" aria-hidden="true">
{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>
<FlowerMark className="h-16 w-16 sm:h-20 sm:w-20" />
<p className="font-serif text-2xl font-bold tracking-tight">KisAssistant</p>
<p className="loading-dots text-sm opacity-60">Preparing your chats</p>
</div>
+15
View File
@@ -109,6 +109,15 @@ body {
0%, 100% { transform: scale(0.85); }
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 {
content: "";
animation: dots 1.4s steps(4) infinite;
@@ -120,6 +129,12 @@ body {
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) */
input:-webkit-autofill,
input:-webkit-autofill:hover,
+61 -2
View File
@@ -1,5 +1,26 @@
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";
export function getToken(): string | null {
@@ -58,7 +79,18 @@ export const api = {
updateMe: (patch: { displayName?: string; avatarUrl?: string; theme?: string; email?: string }) =>
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 }) =>
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" }),
@@ -90,7 +122,34 @@ export const api = {
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 (
name: string,
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,
} from "./db.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 { OllamaDriver } from "./drivers/ollama.ts";
import { DriverRegistry } from "./drivers/registry.ts";
import { createHash, randomBytes } from "node:crypto";
import { statSync } from "node:fs";
import { mailEnabled, sendRecoveryEmail } from "./mail.ts";
import { mailEnabled, sendAccessMessageEmail, sendAccessReceivedEmail, sendAccessStatusEmail, sendRecoveryEmail } from "./mail.ts";
import {
CDN_NAMESPACES,
cdnFile,
@@ -71,6 +82,7 @@ const registry = new DriverRegistry();
// Ollama handles admin model pulls/deletes (host is loopback/LAN, no auth).
const ollamaDriver = registry.get("ollama") as OllamaDriver;
await tools.init();
initAccessTables();
function json(data: unknown, status = 200): Response {
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 ---
if (path.startsWith("/api/")) {
const user = requireAuth(req);
@@ -430,7 +502,35 @@ const server = Bun.serve({
// --- admin: user management (No-KYC accounts, keys issued by admin) ---
if (path === "/api/admin/users" && req.method === "GET") {
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") {
@@ -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) ---
if (path === "/api/conversations" && req.method === "GET") {
return json({ conversations: listConversations(user.id) });
+130
View File
@@ -37,6 +37,136 @@ async function getTransporter(): Promise<Transporter | null> {
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. */
export async function sendRecoveryEmail(to: string, username: string, resetUrl: string): Promise<boolean> {
const t = await getTransporter();