Compare commits
3
Commits
2dfcb944d1
...
1352f5374e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1352f5374e | ||
|
|
d3780edc9c | ||
|
|
133a08b7ed |
@@ -66,6 +66,9 @@ from the **Accounts** panel (each gets a one-time access key to hand over).
|
||||
| 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 |
|
||||
| POST / DELETE | `/api/me/key/rotate`, `/api/me` | Bearer | rotate own key (sessions revoked) / delete own account |
|
||||
| GET/POST/DELETE | `/api/me/totp`, `/api/me/totp/setup`, `/api/me/totp/verify` | Bearer | TOTP two-factor status/setup/verify/disable |
|
||||
| POST | `/api/auth/totp` | challenge | second login step `{totpToken, code}` → session |
|
||||
| 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 |
|
||||
|
||||
@@ -344,6 +344,8 @@ export function App() {
|
||||
setUser(u);
|
||||
applyTheme(u.theme);
|
||||
}}
|
||||
// Key rotated or account deleted: sessions are dead server-side.
|
||||
onKeyRotated={() => { setSettingsOpen(false); logout(); }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -234,7 +234,7 @@ export function Chat({
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<form onSubmit={submit} className="mx-auto flex w-full max-w-3xl items-end gap-2 rounded-[1.75rem] border border-stone-200 bg-white p-2 pl-2 shadow-lg dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<form onSubmit={submit} className={`mx-auto flex w-full max-w-3xl items-end gap-2 rounded-[1.75rem] border border-stone-200 bg-white p-2 shadow-lg dark:border-zinc-700 dark:bg-zinc-900 ${features.attachments ? "pl-2" : "pl-5"}`}>
|
||||
{features.attachments && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { ArrowRight, KeyRound, MessageSquareText, ShieldCheck, User, UserPlus, Zap } from "lucide-react";
|
||||
import { api, setToken } from "../lib/api.ts";
|
||||
import { useT } from "../lib/i18n.ts";
|
||||
import type { User as UserType } from "../lib/types.ts";
|
||||
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" },
|
||||
{ icon: Zap, text: "Your own models, streamed in real time" },
|
||||
];
|
||||
|
||||
export function Login({ onLogin }: { onLogin: (user: UserType) => void }) {
|
||||
const { t } = useT();
|
||||
const [username, setUsername] = useState("admin");
|
||||
const [key, setKey] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
@@ -24,6 +21,15 @@ export function Login({ onLogin }: { onLogin: (user: UserType) => void }) {
|
||||
const [requestOpen, setRequestOpen] = useState(false);
|
||||
const [requestOn, setRequestOn] = useState(false);
|
||||
|
||||
const PERKS = [
|
||||
{ icon: ShieldCheck, text: t("login.perk1") },
|
||||
{ icon: Zap, text: t("login.perk2") },
|
||||
];
|
||||
// TOTP second step (set when the server answers totpRequired).
|
||||
const [totpToken, setTotpToken] = useState<string | null>(null);
|
||||
const [totpUsername, setTotpUsername] = useState("");
|
||||
const [code, setCode] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
api.methods().then((m) => {
|
||||
setRecoveryOn(m.recovery);
|
||||
@@ -40,10 +46,31 @@ export function Login({ onLogin }: { onLogin: (user: UserType) => void }) {
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await api.login(username.trim().toLowerCase(), key);
|
||||
if ("totpRequired" in res) {
|
||||
setTotpToken(res.totpToken);
|
||||
setTotpUsername(res.username);
|
||||
return;
|
||||
}
|
||||
setToken(res.token);
|
||||
onLogin(res.user);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Login failed");
|
||||
setError(err instanceof Error ? err.message : t("login.failed"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitTotp(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (busy || !totpToken) return;
|
||||
setError("");
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await api.totpLogin(totpToken, code);
|
||||
setToken(res.token);
|
||||
onLogin(res.user);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Invalid code");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -55,10 +82,46 @@ export function Login({ onLogin }: { onLogin: (user: UserType) => void }) {
|
||||
<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">
|
||||
<FlowerMark size={68} dynamic={false} 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>
|
||||
<h1 className="text-2xl font-bold tracking-tight">{t("login.welcome")}</h1>
|
||||
<p className="mt-1 text-sm opacity-60">{t("login.subtitle")}</p>
|
||||
</div>
|
||||
|
||||
{totpToken ? (
|
||||
<form onSubmit={submitTotp} className="mt-6 space-y-3">
|
||||
<p className="text-center text-sm opacity-70">
|
||||
Two-factor for <strong>@{totpUsername}</strong> — enter the 6-digit code from your authenticator app.
|
||||
</p>
|
||||
<div className="relative">
|
||||
<KeyRound size={17} className="pointer-events-none absolute left-5 top-1/2 -translate-y-1/2 text-stone-400 dark:text-zinc-500" />
|
||||
<Input
|
||||
variant="soft"
|
||||
className="pl-12 text-center tracking-[0.5em]"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.replace(/\D/g, "").slice(0, 6))}
|
||||
autoComplete="one-time-code"
|
||||
placeholder="000000"
|
||||
aria-label="Authenticator code"
|
||||
inputMode="numeric"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="rounded-full bg-red-500/10 px-5 py-2.5 text-center text-sm text-red-600 dark:text-red-400" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<Button type="submit" variant="gradient" size="lg" className="w-full" disabled={busy || code.length !== 6}>
|
||||
{busy ? <Spinner /> : (<>Verify <ArrowRight size={17} /></>)}
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setTotpToken(null); setCode(""); setError(""); }}
|
||||
className="w-full text-center text-sm opacity-60 transition hover:opacity-100 hover:underline"
|
||||
>
|
||||
Back to login
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<form onSubmit={submit} className="mt-6 space-y-3">
|
||||
<div className="relative">
|
||||
<User size={17} className="pointer-events-none absolute left-5 top-1/2 -translate-y-1/2 text-stone-400 dark:text-zinc-500" />
|
||||
@@ -68,8 +131,8 @@ export function Login({ onLogin }: { onLogin: (user: UserType) => void }) {
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
autoComplete="username"
|
||||
placeholder="Username"
|
||||
aria-label="Username"
|
||||
placeholder={t("login.usernamePh")}
|
||||
aria-label={t("login.usernamePh")}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
@@ -82,8 +145,8 @@ export function Login({ onLogin }: { onLogin: (user: UserType) => void }) {
|
||||
value={key}
|
||||
onChange={(e) => setKey(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
placeholder="Access key"
|
||||
aria-label="Access key"
|
||||
placeholder={t("login.keyPh")}
|
||||
aria-label={t("login.keyPh")}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
@@ -96,6 +159,7 @@ export function Login({ onLogin }: { onLogin: (user: UserType) => void }) {
|
||||
{busy ? <Spinner /> : (<>Unlock <ArrowRight size={17} /></>)}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
<div className="mt-4 flex items-center gap-3 text-xs opacity-40">
|
||||
<span className="h-px flex-1 bg-current" />
|
||||
New here?
|
||||
@@ -127,13 +191,13 @@ export function Login({ onLogin }: { onLogin: (user: UserType) => void }) {
|
||||
onClick={() => setRequestOpen(true)}
|
||||
>
|
||||
<MessageSquareText size={16} />
|
||||
Request access
|
||||
{t("login.requestAccess")}
|
||||
</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?
|
||||
{t("login.forgot")}
|
||||
</button>
|
||||
)}
|
||||
<RecoverDialog open={recoverOpen} onClose={() => setRecoverOpen(false)} from={recoveryFrom} />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Globe, FileText, ImagePlus, Link2, Mail, Palette, Pencil, ScanText, Search, Settings2, Sparkles, Tag, Unplug, User as UserIcon, UserPlus, X } from "lucide-react";
|
||||
import { Fingerprint, Globe, FileText, ImagePlus, KeyRound, Link2, Mail, Palette, Pencil, ScanText, Search, Settings2, ShieldCheck, Sparkles, Tag, Trash2, Unplug, User as UserIcon, UserPlus, X } from "lucide-react";
|
||||
import { api } from "../lib/api.ts";
|
||||
import { cn } from "../lib/cn.ts";
|
||||
import { ACCENT_PRESETS, applyAccent, currentAccent, previewAccent, type AccentState } from "../lib/accent.ts";
|
||||
@@ -340,10 +340,12 @@ export function SettingsModal({
|
||||
user,
|
||||
onClose,
|
||||
onSaved,
|
||||
onKeyRotated,
|
||||
}: {
|
||||
user: User | null;
|
||||
onClose: () => void;
|
||||
onSaved: (u: User) => void;
|
||||
onKeyRotated?: () => void;
|
||||
}) {
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [avatarUrl, setAvatarUrl] = useState("");
|
||||
@@ -363,6 +365,7 @@ export function SettingsModal({
|
||||
{ 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"] },
|
||||
{ id: "security", title: "Security", icon: ShieldCheck, keywords: ["security", "key", "rotate", "password", "totp", "2fa", "two", "factor", "authenticator", "passkey", "webauthn"] },
|
||||
] as const;
|
||||
type CatId = (typeof CATS)[number]["id"];
|
||||
const [cat, setCat] = useState<CatId>("profile");
|
||||
@@ -560,6 +563,12 @@ export function SettingsModal({
|
||||
<FeaturesSection />
|
||||
</SettingsPane>
|
||||
)}
|
||||
|
||||
{activeCat === "security" && (
|
||||
<SettingsPane>
|
||||
<SecuritySection onKeyRotated={onKeyRotated} />
|
||||
</SettingsPane>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -684,3 +693,199 @@ function FeaturesSection() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- Security (settings): key rotation, TOTP, passkeys ---------- */
|
||||
|
||||
function SecuritySection({ onKeyRotated }: { onKeyRotated?: () => void }) {
|
||||
const [totpOn, setTotpOn] = useState<boolean | null>(null);
|
||||
const [secret, setSecret] = useState<string | null>(null);
|
||||
const [otpauthUrl, setOtpauthUrl] = useState("");
|
||||
const [code, setCode] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [rotatedKey, setRotatedKey] = useState<string | null>(null);
|
||||
const [armingRotate, setArmingRotate] = useState(false);
|
||||
const [armingDelete, setArmingDelete] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
api.totpStatus().then((r) => setTotpOn(r.enabled)).catch(() => setTotpOn(false));
|
||||
}, []);
|
||||
|
||||
async function rotate() {
|
||||
if (!armingRotate) {
|
||||
setArmingRotate(true);
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const res = await api.rotateKey();
|
||||
setRotatedKey(res.key);
|
||||
setArmingRotate(false);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Rotation failed");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function startTotp() {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const res = await api.totpSetup();
|
||||
setSecret(res.secret);
|
||||
setOtpauthUrl(res.otpauthUrl);
|
||||
setCode("");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Setup failed");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmTotp() {
|
||||
if (!secret || code.replace(/\D/g, "").length !== 6) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await api.totpVerify(secret, code.replace(/\D/g, ""));
|
||||
setTotpOn(true);
|
||||
setSecret(null);
|
||||
setCode("");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Invalid code");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function disableTotp() {
|
||||
if (code.replace(/\D/g, "").length !== 6) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await api.totpDisable(code.replace(/\D/g, ""));
|
||||
setTotpOn(false);
|
||||
setCode("");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Invalid code");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function removeAccount() {
|
||||
if (!armingDelete) {
|
||||
setArmingDelete(true);
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await api.deleteMe();
|
||||
onKeyRotated?.();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Delete failed");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<p className="mb-1 flex items-center gap-1.5 text-sm font-medium"><KeyRound size={14} className="opacity-60" /> Access key</p>
|
||||
{rotatedKey ? (
|
||||
<div className="rounded-2xl border border-accent-500/40 bg-accent-50 p-3 dark:bg-accent-950/30">
|
||||
<p className="mb-2 text-sm font-medium">New key — copy it now, it won't be shown again:</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">{rotatedKey}</code>
|
||||
<CopyButton text={rotatedKey} />
|
||||
</div>
|
||||
<p className="mt-2 text-xs opacity-70">Your old sessions are revoked. Log back in with the new key.</p>
|
||||
<Button size="sm" className="mt-2 w-full" onClick={() => onKeyRotated?.()}>Done — log me out</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="mb-2 text-xs opacity-60">Rotate your access key. Old sessions are revoked immediately — you'll log back in with the new key.</p>
|
||||
<Button size="sm" variant="secondary" disabled={busy} onClick={rotate}>
|
||||
{armingRotate ? "Click again to confirm rotation" : "Rotate my access key"}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="mb-1 flex items-center gap-1.5 text-sm font-medium"><ShieldCheck size={14} className="opacity-60" /> Two-factor (TOTP)</p>
|
||||
{totpOn === null && <p className="flex items-center gap-2 text-sm opacity-60"><Spinner size={14} /> Checking…</p>}
|
||||
{totpOn === false && !secret && (
|
||||
<>
|
||||
<p className="mb-2 text-xs opacity-60">Add a 6-digit code from your authenticator app on top of your access key.</p>
|
||||
<Button size="sm" variant="secondary" disabled={busy} onClick={startTotp}>Enable two-factor</Button>
|
||||
</>
|
||||
)}
|
||||
{secret && (
|
||||
<div className="space-y-2 rounded-2xl border border-stone-200 p-3 dark:border-zinc-700">
|
||||
<p className="text-xs opacity-70">Add this secret to your authenticator app (or open the otpauth link), then enter a code to confirm:</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="min-w-0 flex-1 break-all rounded-xl bg-stone-100 px-3 py-2 font-mono text-sm dark:bg-zinc-800">{secret}</code>
|
||||
<CopyButton text={secret} />
|
||||
</div>
|
||||
<a href={otpauthUrl} className="block truncate text-xs text-accent-700 underline-offset-2 hover:underline dark:text-accent-400">{otpauthUrl}</a>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.replace(/\D/g, "").slice(0, 6))}
|
||||
placeholder="000000"
|
||||
aria-label="Authenticator code"
|
||||
inputMode="numeric"
|
||||
className="text-center tracking-[0.4em]"
|
||||
/>
|
||||
<Button size="sm" disabled={busy || code.length !== 6} onClick={confirmTotp}>Confirm</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{totpOn === true && (
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<div className="min-w-36 flex-1">
|
||||
<Field label="Disable with a current code">
|
||||
<Input
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.replace(/\D/g, "").slice(0, 6))}
|
||||
placeholder="000000"
|
||||
aria-label="Current authenticator code"
|
||||
inputMode="numeric"
|
||||
className="text-center tracking-[0.4em]"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<Button size="sm" variant="secondary" disabled={busy || code.length !== 6} onClick={disableTotp} className="!text-red-600 dark:!text-red-400">
|
||||
Disable 2FA
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 rounded-2xl px-1 py-1 opacity-50">
|
||||
<Fingerprint size={16} />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-sm font-medium">Passkeys <span className="ml-1 text-xs opacity-60">(soon)</span></span>
|
||||
<span className="block text-xs opacity-60">WebAuthn / platform authenticators — needs a server RP setup.</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-red-500/30 p-3">
|
||||
<p className="mb-1 flex items-center gap-1.5 text-sm font-medium text-red-600 dark:text-red-400">
|
||||
<Trash2 size={14} /> Danger zone
|
||||
</p>
|
||||
<p className="mb-2 text-xs opacity-60">Permanently delete your account with all chats, shares and sessions. Cannot be undone.</p>
|
||||
<Button size="sm" variant="secondary" disabled={busy} onClick={removeAccount} className="!text-red-600 dark:!text-red-400">
|
||||
{armingDelete ? "Click again to permanently delete" : "Delete my account"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from
|
||||
import { createPortal } from "react-dom";
|
||||
import { Check, ChevronDown, Copy, Loader2, X, type LucideIcon } from "lucide-react";
|
||||
import { cn } from "../lib/cn.ts";
|
||||
import { useT } from "../lib/i18n.ts";
|
||||
|
||||
/* ---------- Button ---------- */
|
||||
|
||||
@@ -82,7 +83,7 @@ export function Picker({
|
||||
onChange,
|
||||
options,
|
||||
groups,
|
||||
placeholder = "Select…",
|
||||
placeholder,
|
||||
ariaLabel,
|
||||
disabled,
|
||||
icon: Icon,
|
||||
@@ -100,12 +101,14 @@ export function Picker({
|
||||
className?: string;
|
||||
align?: "left" | "right";
|
||||
}) {
|
||||
const { t } = useT();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [pos, setPos] = useState({ top: 0, left: 0, width: 208 });
|
||||
const btnRef = useRef<HTMLButtonElement>(null);
|
||||
const popRef = useRef<HTMLDivElement>(null);
|
||||
const all: PickerGroup[] = groups ?? [{ group: "", options: options ?? [] }];
|
||||
const selected = all.flatMap((g) => g.options).find((o) => o.value === value);
|
||||
const shownPlaceholder = placeholder ?? t("common.select");
|
||||
|
||||
// Position the popover in viewport coords (rendered in a portal so no
|
||||
// ancestor overflow — e.g. Modal's overflow-y-auto — can clip it).
|
||||
@@ -176,7 +179,7 @@ export function Picker({
|
||||
)}
|
||||
>
|
||||
{Icon && <Icon size={15} className="shrink-0 text-accent-600 dark:text-accent-400" />}
|
||||
<span className="truncate">{selected?.label ?? placeholder}</span>
|
||||
<span className="truncate">{selected?.label ?? shownPlaceholder}</span>
|
||||
<ChevronDown size={15} className={cn("shrink-0 opacity-50 transition-transform", open && "rotate-180")} />
|
||||
</button>
|
||||
{open && createPortal(
|
||||
@@ -265,6 +268,7 @@ export function Modal({
|
||||
dragStart.current = null;
|
||||
}
|
||||
}, [open ]);
|
||||
const { t } = useT();
|
||||
|
||||
if (!open) return null;
|
||||
const Icon = icon;
|
||||
@@ -305,7 +309,7 @@ export function Modal({
|
||||
</span>
|
||||
)}
|
||||
<h2 className="text-lg font-semibold">{title}</h2>
|
||||
<button onClick={onClose} aria-label="Close" className="ml-auto rounded-full p-1.5 hover:bg-stone-200/60 dark:hover:bg-zinc-800">
|
||||
<button onClick={onClose} aria-label={t("common.close")} className="ml-auto rounded-full p-1.5 hover:bg-stone-200/60 dark:hover:bg-zinc-800">
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
@@ -322,7 +326,7 @@ export function ConfirmDialog({
|
||||
onConfirm,
|
||||
title,
|
||||
message,
|
||||
confirmLabel = "Delete",
|
||||
confirmLabel,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
@@ -331,12 +335,13 @@ export function ConfirmDialog({
|
||||
message: string;
|
||||
confirmLabel?: string;
|
||||
}) {
|
||||
const { t } = useT();
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} title={title}>
|
||||
<p className="text-sm opacity-80">{message}</p>
|
||||
<div className="mt-5 flex justify-end gap-2">
|
||||
<Button variant="secondary" size="sm" onClick={onClose}>Cancel</Button>
|
||||
<Button variant="danger" size="sm" onClick={() => { onConfirm(); onClose(); }}>{confirmLabel}</Button>
|
||||
<Button variant="secondary" size="sm" onClick={onClose}>{t("common.cancel")}</Button>
|
||||
<Button variant="danger" size="sm" onClick={() => { onConfirm(); onClose(); }}>{confirmLabel ?? t("common.delete")}</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
@@ -556,7 +561,8 @@ export function Toggle({
|
||||
);
|
||||
}
|
||||
|
||||
export function CopyButton({ text, label = "Copy" }: { text: string; label?: string }) {
|
||||
export function CopyButton({ text, label }: { text: string; label?: string }) {
|
||||
const { t } = useT();
|
||||
const [done, setDone] = useState(false);
|
||||
return (
|
||||
<button
|
||||
@@ -569,7 +575,7 @@ export function CopyButton({ text, label = "Copy" }: { text: string; label?: str
|
||||
className="inline-flex items-center gap-1.5 rounded-full border border-stone-200 bg-white px-3 py-1.5 text-xs font-medium transition hover:bg-stone-50 dark:border-zinc-700 dark:bg-zinc-900 dark:hover:bg-zinc-800"
|
||||
>
|
||||
{done ? <Check size={13} className="text-accent-500" /> : <Copy size={13} />}
|
||||
{done ? "Copied" : label}
|
||||
{done ? t("common.copied") : (label ?? t("common.copy"))}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -578,12 +584,13 @@ 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 { t } = useT();
|
||||
return (
|
||||
<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">
|
||||
<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>
|
||||
<p className="loading-dots text-sm opacity-60">{t("loading.tagline")}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
+20
-1
@@ -60,10 +60,15 @@ async function req<T>(path: string, opts: RequestInit = {}, auth = true): Promis
|
||||
|
||||
export const api = {
|
||||
login: (username: string, key: string) =>
|
||||
req<{ token: string; expiresAt: number; user: User }>("/api/auth/login", {
|
||||
req<{ token: string; expiresAt: number; user: User } | { totpRequired: true; totpToken: string; username: string }>("/api/auth/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ username, key }),
|
||||
}, false),
|
||||
totpLogin: (totpToken: string, code: string) =>
|
||||
req<{ token: string; expiresAt: number; user: User }>("/api/auth/totp", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ totpToken, code }),
|
||||
}, false),
|
||||
verify: () => req<{ ok: boolean; user: User }>("/api/auth/verify"),
|
||||
logout: () => req<{ ok: boolean }>("/api/auth/logout", { method: "POST" }).catch(() => ({ ok: true as const })),
|
||||
|
||||
@@ -79,6 +84,20 @@ export const api = {
|
||||
updateMe: (patch: { displayName?: string; avatarUrl?: string; theme?: string; email?: string }) =>
|
||||
req<{ user: User }>("/api/me", { method: "PATCH", body: JSON.stringify(patch) }),
|
||||
|
||||
/** Rotate your own access key (old sessions revoked, key shown once). */
|
||||
rotateKey: () => req<{ key: string }>("/api/me/key/rotate", { method: "POST" }),
|
||||
|
||||
/** Permanently delete your own account (chats, shares, sessions). */
|
||||
deleteMe: () => req<{ ok: boolean }>("/api/me", { method: "DELETE" }),
|
||||
|
||||
/** TOTP two-factor self-service. */
|
||||
totpStatus: () => req<{ enabled: boolean }>("/api/me/totp"),
|
||||
totpSetup: () => req<{ secret: string; otpauthUrl: string }>("/api/me/totp/setup", { method: "POST" }),
|
||||
totpVerify: (secret: string, code: string) =>
|
||||
req<{ enabled: boolean }>("/api/me/totp/verify", { method: "POST", body: JSON.stringify({ secret, code }) }),
|
||||
totpDisable: (code: string) =>
|
||||
req<{ enabled: boolean }>("/api/me/totp", { method: "DELETE", body: JSON.stringify({ code }) }),
|
||||
|
||||
adminList: (p: { q?: string; sort?: string; filter?: string; page?: number; per?: number } = {}) => {
|
||||
const qs = new URLSearchParams();
|
||||
if (p.q) qs.set("q", p.q);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -43,6 +43,16 @@ import {
|
||||
updateUser,
|
||||
} from "./db.ts";
|
||||
import type { ChatMessage } from "./drivers/types.ts";
|
||||
import {
|
||||
consumeTotpChallenge,
|
||||
createTotpChallenge,
|
||||
getTotpSecret,
|
||||
initTotpTables,
|
||||
newTotpSecret,
|
||||
setTotpSecret,
|
||||
totpAuthUrl,
|
||||
verifyTotp,
|
||||
} from "./totp.ts";
|
||||
import {
|
||||
addAccessMessage,
|
||||
createAccessRequest,
|
||||
@@ -83,6 +93,7 @@ const registry = new DriverRegistry();
|
||||
const ollamaDriver = registry.get("ollama") as OllamaDriver;
|
||||
await tools.init();
|
||||
initAccessTables();
|
||||
initTotpTables();
|
||||
|
||||
function json(data: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(data), {
|
||||
@@ -297,10 +308,35 @@ const server = Bun.serve({
|
||||
return json({ error: "invalid credentials" }, 401);
|
||||
}
|
||||
clearAttempts(ip);
|
||||
// Second factor: correct key but TOTP enabled -> short-lived challenge,
|
||||
// the session is only issued after POST /api/auth/totp verifies a code.
|
||||
if (getTotpSecret(user.id)) {
|
||||
const totpToken = createTotpChallenge(user.id);
|
||||
return json({ totpRequired: true, totpToken, username: user.username });
|
||||
}
|
||||
const { token, expiresAt } = issueToken(user);
|
||||
return json({ token, expiresAt, user });
|
||||
}
|
||||
|
||||
// --- auth: TOTP second step (rate limited, challenge is single-use) ---
|
||||
if (path === "/api/auth/totp" && 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);
|
||||
const userId =
|
||||
ok && typeof body.totpToken === "string" ? consumeTotpChallenge(body.totpToken) : null;
|
||||
const code = ok && typeof body.code === "string" ? body.code : "";
|
||||
const target = userId ? getUserById(userId) : null;
|
||||
if (!target || !verifyTotp(getTotpSecret(target.id), code)) {
|
||||
recordAttempt(ip);
|
||||
await Bun.sleep(400);
|
||||
return json({ error: "invalid code" }, 401);
|
||||
}
|
||||
clearAttempts(ip);
|
||||
const { token, expiresAt } = issueToken(toPublicUser(target));
|
||||
return json({ token, expiresAt, user: toPublicUser(target) });
|
||||
}
|
||||
|
||||
if (path === "/api/auth/verify" && req.method === "GET") {
|
||||
const user = requireAuth(req);
|
||||
return user ? json({ ok: true, user }) : json({ error: "unauthorized" }, 401);
|
||||
@@ -506,6 +542,48 @@ const server = Bun.serve({
|
||||
return json({ user: toPublicUser(updated) });
|
||||
}
|
||||
|
||||
// --- self-service security: rotate access key (old sessions revoked) ---
|
||||
if (path === "/api/me/key/rotate" && req.method === "POST") {
|
||||
const key = newAccessKey();
|
||||
updateUser(user.id, { keyHash: hashSecret(key) });
|
||||
deleteUserSessions(user.id);
|
||||
return json({ key });
|
||||
}
|
||||
|
||||
// --- self-service: delete your own account (chats, shares, sessions gone) ---
|
||||
if (path === "/api/me" && req.method === "DELETE") {
|
||||
if (user.username === "admin") return json({ error: "the admin account cannot be deleted" }, 403);
|
||||
deleteUser(user.id);
|
||||
getDb().query("DELETE FROM totp_challenges WHERE user_id = ?").run(user.id);
|
||||
return json({ ok: true });
|
||||
}
|
||||
|
||||
// --- self-service security: TOTP two-factor ---
|
||||
if (path === "/api/me/totp" && req.method === "GET") {
|
||||
return json({ enabled: getTotpSecret(user.id).length > 0 });
|
||||
}
|
||||
if (path === "/api/me/totp/setup" && req.method === "POST") {
|
||||
if (getTotpSecret(user.id)) return json({ error: "TOTP already enabled — disable it first" }, 409);
|
||||
const secret = newTotpSecret();
|
||||
return json({ secret, otpauthUrl: totpAuthUrl(secret, user.username) });
|
||||
}
|
||||
if (path === "/api/me/totp/verify" && req.method === "POST") {
|
||||
const { ok, body } = await readJson(req);
|
||||
const secret = ok && typeof body.secret === "string" ? body.secret : "";
|
||||
const code = ok && typeof body.code === "string" ? body.code : "";
|
||||
if (!/^[A-Z2-7]{16,64}$/.test(secret.trim().toUpperCase())) return json({ error: "invalid secret" }, 400);
|
||||
if (!verifyTotp(secret, code)) return json({ error: "invalid code" }, 401);
|
||||
setTotpSecret(user.id, secret.trim().toUpperCase());
|
||||
return json({ enabled: true });
|
||||
}
|
||||
if (path === "/api/me/totp" && req.method === "DELETE") {
|
||||
const { ok, body } = await readJson(req);
|
||||
const code = ok && typeof body.code === "string" ? body.code : "";
|
||||
if (!verifyTotp(getTotpSecret(user.id), code)) return json({ error: "invalid code" }, 401);
|
||||
setTotpSecret(user.id, "");
|
||||
return json({ enabled: false });
|
||||
}
|
||||
|
||||
// --- 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);
|
||||
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
import { createHash, createHmac, randomBytes } from "node:crypto";
|
||||
import { getDb } from "./db.ts";
|
||||
|
||||
/**
|
||||
* TOTP two-factor (RFC 6238, SHA-1, 30s step, 6 digits, ±1 window).
|
||||
* The per-user secret lives in `users.totp_secret` (empty = disabled);
|
||||
* login challenges live in `totp_challenges`. Tables/columns are created
|
||||
* idempotently here so db.ts stays untouched.
|
||||
*/
|
||||
|
||||
const STEP_SEC = 30;
|
||||
const DIGITS = 6;
|
||||
const WINDOW = 1;
|
||||
const CHALLENGE_TTL_MS = 5 * 60_000;
|
||||
|
||||
export function initTotpTables(): void {
|
||||
const d = getDb();
|
||||
try {
|
||||
d.query("ALTER TABLE users ADD COLUMN totp_secret TEXT NOT NULL DEFAULT ''").run();
|
||||
} catch {
|
||||
// column already exists — fine
|
||||
}
|
||||
d.query(`CREATE TABLE IF NOT EXISTS totp_challenges (
|
||||
token_hash TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL
|
||||
)`).run();
|
||||
}
|
||||
|
||||
const B32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
||||
|
||||
export function newTotpSecret(bytes = 20): string {
|
||||
const buf = randomBytes(bytes);
|
||||
let out = "";
|
||||
let bits = 0;
|
||||
let value = 0;
|
||||
for (const b of buf) {
|
||||
value = (value << 8) | b;
|
||||
bits += 8;
|
||||
while (bits >= 5) {
|
||||
out += B32[(value >>> (bits - 5)) & 31];
|
||||
bits -= 5;
|
||||
}
|
||||
}
|
||||
if (bits > 0) out += B32[(value << (5 - bits)) & 31];
|
||||
return out;
|
||||
}
|
||||
|
||||
export function totpAuthUrl(secret: string, username: string, issuer = "KisAssistant"): string {
|
||||
return `otpauth://totp/${encodeURIComponent(issuer)}:${encodeURIComponent(username)}?secret=${secret}&issuer=${encodeURIComponent(issuer)}&algorithm=SHA1&digits=6&period=30`;
|
||||
}
|
||||
|
||||
function base32Decode(s: string): Buffer {
|
||||
const clean = s.trim().replace(/=+$/, "").toUpperCase();
|
||||
let bits = 0;
|
||||
let value = 0;
|
||||
const out: number[] = [];
|
||||
for (const ch of clean) {
|
||||
const idx = B32.indexOf(ch);
|
||||
if (idx < 0) throw new Error("invalid base32");
|
||||
value = (value << 5) | idx;
|
||||
bits += 5;
|
||||
if (bits >= 8) {
|
||||
out.push((value >>> (bits - 8)) & 255);
|
||||
bits -= 8;
|
||||
}
|
||||
}
|
||||
return Buffer.from(out);
|
||||
}
|
||||
|
||||
function hotp(key: Buffer, counter: bigint): string {
|
||||
const msg = Buffer.alloc(8);
|
||||
msg.writeBigUInt64BE(counter);
|
||||
const h = createHmac("sha1", key).update(msg).digest();
|
||||
const offset = h[h.length - 1]! & 0x0f;
|
||||
const code =
|
||||
((h[offset]! & 0x7f) << 24) | ((h[offset + 1]! << 16) | ((h[offset + 2]! << 8) | h[offset + 3]!));
|
||||
return String(code % 10 ** DIGITS).padStart(DIGITS, "0");
|
||||
}
|
||||
|
||||
/** Timing-safe comparison against the current ±1 window. */
|
||||
export function verifyTotp(secret: string, code: string): boolean {
|
||||
const c = code.trim();
|
||||
if (!/^\d{6}$/.test(c)) return false;
|
||||
let key: Buffer;
|
||||
try {
|
||||
key = base32Decode(secret);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (key.length < 10) return false;
|
||||
const now = Math.floor(Date.now() / 1000 / STEP_SEC);
|
||||
let ok = false;
|
||||
for (let w = -WINDOW; w <= WINDOW; w++) {
|
||||
const expected = hotp(key, BigInt(now + w));
|
||||
const a = Buffer.from(expected);
|
||||
const b = Buffer.from(c);
|
||||
if (a.length === b.length && createHash("sha256").update(a).digest().equals(createHash("sha256").update(b).digest())) {
|
||||
ok = true;
|
||||
}
|
||||
}
|
||||
// Constant-time-ish: always hash-compare all window slots (loop above).
|
||||
return ok;
|
||||
}
|
||||
|
||||
export function getTotpSecret(userId: string): string {
|
||||
const row = getDb().query("SELECT totp_secret AS s FROM users WHERE id = ?").get(userId) as { s: string } | null;
|
||||
return row?.s ?? "";
|
||||
}
|
||||
|
||||
export function setTotpSecret(userId: string, secret: string): void {
|
||||
getDb().query("UPDATE users SET totp_secret = ? WHERE id = ?").run(secret, userId);
|
||||
}
|
||||
|
||||
/** Short-lived login challenge after a correct key; returns the raw token. */
|
||||
export function createTotpChallenge(userId: string): string {
|
||||
const token = "tc_" + randomBytes(24).toString("base64url");
|
||||
const hash = createHash("sha256").update(token).digest("hex");
|
||||
const now = Date.now();
|
||||
getDb().query("INSERT INTO totp_challenges (token_hash, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)").run(
|
||||
hash, userId, now, now + CHALLENGE_TTL_MS,
|
||||
);
|
||||
// Opportunistic cleanup.
|
||||
getDb().query("DELETE FROM totp_challenges WHERE expires_at < ?").run(now);
|
||||
return token;
|
||||
}
|
||||
|
||||
export function consumeTotpChallenge(token: string): string | null {
|
||||
const hash = createHash("sha256").update(token).digest("hex");
|
||||
const row = getDb().query("SELECT user_id, expires_at FROM totp_challenges WHERE token_hash = ?").get(hash) as {
|
||||
user_id: string;
|
||||
expires_at: number;
|
||||
} | null;
|
||||
if (!row) return null;
|
||||
getDb().query("DELETE FROM totp_challenges WHERE token_hash = ?").run(hash);
|
||||
if (row.expires_at < Date.now()) return null;
|
||||
return row.user_id;
|
||||
}
|
||||
Reference in New Issue
Block a user