This commit is contained in:
k
2026-09-21 15:57:10 +02:00
parent 2dfcb944d1
commit 133a08b7ed
8 changed files with 2167 additions and 5 deletions
+1 -1
View File
@@ -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"
+62
View File
@@ -23,6 +23,10 @@ export function Login({ onLogin }: { onLogin: (user: UserType) => void }) {
const [registrationOn, setRegistrationOn] = useState(false);
const [requestOpen, setRequestOpen] = useState(false);
const [requestOn, setRequestOn] = useState(false);
// 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) => {
@@ -40,6 +44,11 @@ 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) {
@@ -49,6 +58,22 @@ export function Login({ onLogin }: { onLogin: (user: UserType) => void }) {
}
}
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);
}
}
return (
<div className="grid min-h-full place-items-center overflow-y-auto 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">
@@ -59,6 +84,42 @@ export function Login({ onLogin }: { onLogin: (user: UserType) => void }) {
<p className="mt-1 text-sm opacity-60">Sign in to KisAssistant to continue</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" />
@@ -96,6 +157,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?
+166 -1
View File
@@ -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, 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,159 @@ 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() {
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>
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
</div>
);
}
+5 -2
View File
@@ -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(
+20 -1
View File
@@ -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