.
This commit is contained in:
@@ -14,6 +14,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { Conversation, User } from "../lib/types.ts";
|
import type { Conversation, User } from "../lib/types.ts";
|
||||||
import { api } from "../lib/api.ts";
|
import { api } from "../lib/api.ts";
|
||||||
|
import { useT } from "../lib/i18n.ts";
|
||||||
import { Avatar, Button, ContextMenu, FlowerMark, IconButton } from "./ui.tsx";
|
import { Avatar, Button, ContextMenu, FlowerMark, IconButton } from "./ui.tsx";
|
||||||
import { cn } from "../lib/cn.ts";
|
import { cn } from "../lib/cn.ts";
|
||||||
|
|
||||||
@@ -58,6 +59,7 @@ export function Sidebar({
|
|||||||
mobileOpen: boolean;
|
mobileOpen: boolean;
|
||||||
onCloseMobile: () => void;
|
onCloseMobile: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useT();
|
||||||
const [menu, setMenu] = useState<MenuState | null>(null);
|
const [menu, setMenu] = useState<MenuState | null>(null);
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [results, setResults] = useState<(Conversation & { snippet: string | null; snippetRole: string | null })[] | null>(null);
|
const [results, setResults] = useState<(Conversation & { snippet: string | null; snippetRole: string | null })[] | null>(null);
|
||||||
@@ -132,12 +134,12 @@ export function Sidebar({
|
|||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Button variant="secondary" size="sm" className="flex-1" onClick={onNew}>
|
<Button variant="secondary" size="sm" className="flex-1" onClick={onNew}>
|
||||||
<MessageSquarePlus size={16} />
|
<MessageSquarePlus size={16} />
|
||||||
New chat
|
{t("sidebar.newChat")}
|
||||||
</Button>
|
</Button>
|
||||||
<IconButton title="Collapse sidebar" onClick={onToggle} className="hidden md:inline-flex">
|
<IconButton title={t("sidebar.collapse")} onClick={onToggle} className="hidden md:inline-flex">
|
||||||
<PanelLeftClose size={18} />
|
<PanelLeftClose size={18} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<IconButton title="Close chats" onClick={onCloseMobile} className="md:hidden">
|
<IconButton title={t("sidebar.closeDrawer")} onClick={onCloseMobile} className="md:hidden">
|
||||||
<X size={18} />
|
<X size={18} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</div>
|
</div>
|
||||||
@@ -146,13 +148,13 @@ export function Sidebar({
|
|||||||
<input
|
<input
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
placeholder="Search chats…"
|
placeholder={t("sidebar.searchPh")}
|
||||||
aria-label="Search conversations"
|
aria-label={t("sidebar.searchAria")}
|
||||||
className="w-full rounded-2xl border border-stone-200/70 bg-stone-100 py-2 pl-9 pr-8 text-sm outline-none transition placeholder:text-stone-400 focus:border-accent-500/60 focus:bg-white dark:border-zinc-800 dark:bg-zinc-800/60 dark:placeholder:text-zinc-500 dark:focus:bg-zinc-900"
|
className="w-full rounded-2xl border border-stone-200/70 bg-stone-100 py-2 pl-9 pr-8 text-sm outline-none transition placeholder:text-stone-400 focus:border-accent-500/60 focus:bg-white dark:border-zinc-800 dark:bg-zinc-800/60 dark:placeholder:text-zinc-500 dark:focus:bg-zinc-900"
|
||||||
/>
|
/>
|
||||||
{query && (
|
{query && (
|
||||||
<button
|
<button
|
||||||
aria-label="Clear search"
|
aria-label={t("sidebar.clearSearch")}
|
||||||
onClick={() => setQuery("")}
|
onClick={() => setQuery("")}
|
||||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 rounded-full p-0.5 opacity-60 transition hover:opacity-100"
|
className="absolute right-2.5 top-1/2 -translate-y-1/2 rounded-full p-0.5 opacity-60 transition hover:opacity-100"
|
||||||
>
|
>
|
||||||
@@ -164,9 +166,9 @@ export function Sidebar({
|
|||||||
<div className="flex-1 space-y-1 overflow-y-auto py-1">
|
<div className="flex-1 space-y-1 overflow-y-auto py-1">
|
||||||
{results !== null ? (
|
{results !== null ? (
|
||||||
<>
|
<>
|
||||||
{searching && <p className="px-3 py-4 text-center text-sm opacity-50">Searching…</p>}
|
{searching && <p className="px-3 py-4 text-center text-sm opacity-50">{t("sidebar.searching")}</p>}
|
||||||
{!searching && results.length === 0 && (
|
{!searching && results.length === 0 && (
|
||||||
<p className="px-3 py-6 text-center text-sm opacity-50">No match for “{query.trim()}”.</p>
|
<p className="px-3 py-6 text-center text-sm opacity-50">{t("sidebar.noMatch", { q: query.trim() })}</p>
|
||||||
)}
|
)}
|
||||||
{results.map((c) => (
|
{results.map((c) => (
|
||||||
<div
|
<div
|
||||||
@@ -185,7 +187,7 @@ export function Sidebar({
|
|||||||
<span className="block truncate">{c.title}</span>
|
<span className="block truncate">{c.title}</span>
|
||||||
{c.snippet && (
|
{c.snippet && (
|
||||||
<span className="mt-0.5 block truncate text-xs opacity-60">
|
<span className="mt-0.5 block truncate text-xs opacity-60">
|
||||||
{c.snippetRole === "user" ? "you: " : ""}{c.snippet}
|
{c.snippetRole === "user" ? t("sidebar.youPrefix") : ""}{c.snippet}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -805,25 +805,25 @@ function SecuritySection({ onKeyRotated }: { onKeyRotated?: () => void }) {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<div>
|
<div>
|
||||||
<p className="mb-1 flex items-center gap-1.5 text-sm font-medium"><KeyRound size={14} className="opacity-60" /> Access key</p>
|
<p className="mb-1 flex items-center gap-1.5 text-sm font-medium"><KeyRound size={14} className="opacity-60" /> {t("sec.accessKey")}</p>
|
||||||
<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>
|
<p className="mb-2 text-xs opacity-60">{t("sec.accessKeyHint")}</p>
|
||||||
<Button size="sm" variant="secondary" type="button" disabled={busy} onClick={rotate}>
|
<Button size="sm" variant="secondary" disabled={busy} onClick={rotate}>
|
||||||
{armingRotate ? "Click again to confirm rotation" : "Rotate my access key"}
|
{armingRotate ? t("sec.rotateConfirm") : t("sec.rotate")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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>
|
<p className="mb-1 flex items-center gap-1.5 text-sm font-medium"><ShieldCheck size={14} className="opacity-60" /> {t("sec.totp")}</p>
|
||||||
{totpOn === null && <p className="flex items-center gap-2 text-sm opacity-60"><Spinner size={14} /> Checking…</p>}
|
{totpOn === null && <p className="flex items-center gap-2 text-sm opacity-60"><Spinner size={14} /> {t("sec.totpChecking")}</p>}
|
||||||
{totpOn === false && !secret && (
|
{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>
|
<p className="mb-2 text-xs opacity-60">{t("sec.totpAddHint")}</p>
|
||||||
<Button size="sm" variant="secondary" type="button" disabled={busy} onClick={startTotp}>Enable two-factor</Button>
|
<Button size="sm" variant="secondary" disabled={busy} onClick={startTotp}>{t("sec.totpEnable")}</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{secret && (
|
{secret && (
|
||||||
<div className="space-y-2 rounded-2xl border border-stone-200 p-3 dark:border-zinc-700">
|
<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>
|
<p className="text-xs opacity-70">{t("sec.totpSecretHint")}</p>
|
||||||
<div className="flex items-center gap-2">
|
<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>
|
<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} />
|
<CopyButton text={secret} />
|
||||||
@@ -834,30 +834,30 @@ function SecuritySection({ onKeyRotated }: { onKeyRotated?: () => void }) {
|
|||||||
value={code}
|
value={code}
|
||||||
onChange={(e) => setCode(e.target.value.replace(/\D/g, "").slice(0, 6))}
|
onChange={(e) => setCode(e.target.value.replace(/\D/g, "").slice(0, 6))}
|
||||||
placeholder="000000"
|
placeholder="000000"
|
||||||
aria-label="Authenticator code"
|
aria-label={t("login.totpAria")}
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
className="text-center tracking-[0.4em]"
|
className="text-center tracking-[0.4em]"
|
||||||
/>
|
/>
|
||||||
<Button size="sm" type="button" disabled={busy || code.length !== 6} onClick={confirmTotp}>Confirm</Button>
|
<Button size="sm" disabled={busy || code.length !== 6} onClick={confirmTotp}>{t("sec.totpConfirm")}</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{totpOn === true && (
|
{totpOn === true && (
|
||||||
<div className="flex flex-wrap items-end gap-2">
|
<div className="flex flex-wrap items-end gap-2">
|
||||||
<div className="min-w-36 flex-1">
|
<div className="min-w-36 flex-1">
|
||||||
<Field label="Disable with a current code">
|
<Field label={t("sec.totpDisableLabel")}>
|
||||||
<Input
|
<Input
|
||||||
value={code}
|
value={code}
|
||||||
onChange={(e) => setCode(e.target.value.replace(/\D/g, "").slice(0, 6))}
|
onChange={(e) => setCode(e.target.value.replace(/\D/g, "").slice(0, 6))}
|
||||||
placeholder="000000"
|
placeholder="000000"
|
||||||
aria-label="Current authenticator code"
|
aria-label={t("login.totpAria")}
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
className="text-center tracking-[0.4em]"
|
className="text-center tracking-[0.4em]"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
<Button size="sm" variant="secondary" type="button" disabled={busy || code.length !== 6} onClick={disableTotp} className="!text-red-600 dark:!text-red-400">
|
<Button size="sm" variant="secondary" disabled={busy || code.length !== 6} onClick={disableTotp} className="!text-red-600 dark:!text-red-400">
|
||||||
Disable 2FA
|
{t("sec.totpDisable")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -866,31 +866,31 @@ function SecuritySection({ onKeyRotated }: { onKeyRotated?: () => void }) {
|
|||||||
<div className="flex items-center gap-3 rounded-2xl px-1 py-1 opacity-50">
|
<div className="flex items-center gap-3 rounded-2xl px-1 py-1 opacity-50">
|
||||||
<Fingerprint size={16} />
|
<Fingerprint size={16} />
|
||||||
<span className="min-w-0 flex-1">
|
<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-sm font-medium">{t("sec.passkeys")} <span className="ml-1 text-xs opacity-60">{t("feat.soon")}</span></span>
|
||||||
<span className="block text-xs opacity-60">WebAuthn / platform authenticators — needs a server RP setup.</span>
|
<span className="block text-xs opacity-60">{t("sec.passkeysHint")}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-2xl border border-red-500/30 p-3">
|
<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">
|
<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
|
<Trash2 size={14} /> {t("sec.danger")}
|
||||||
</p>
|
</p>
|
||||||
<p className="mb-2 text-xs opacity-60">Permanently delete your account with all chats, shares and sessions. Cannot be undone.</p>
|
<p className="mb-2 text-xs opacity-60">{t("sec.dangerHint")}</p>
|
||||||
<Button size="sm" variant="secondary" type="button" disabled={busy} onClick={() => setConfirmDelete(true)} className="!text-red-600 dark:!text-red-400">
|
<Button size="sm" variant="secondary" disabled={busy} onClick={() => setConfirmDelete(true)} className="!text-red-600 dark:!text-red-400">
|
||||||
Delete my account
|
{t("sec.deleteMe")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
|
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
|
||||||
|
|
||||||
<Modal open={rotatedKey !== null} onClose={() => {}} title="New access key" icon={KeyRound}>
|
<Modal open={rotatedKey !== null} onClose={() => {}} title={t("sec.newKeyTitle")} icon={KeyRound}>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<p className="text-sm opacity-80">Copy it now — it won't be shown again. Your old sessions are revoked.</p>
|
<p className="text-sm opacity-80">{t("sec.newKeyHint")}</p>
|
||||||
<div className="flex items-center gap-2">
|
<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 font-mono text-sm dark:bg-zinc-800">{rotatedKey}</code>
|
<code className="min-w-0 flex-1 truncate rounded-2xl bg-stone-100 px-3 py-2.5 font-mono text-sm dark:bg-zinc-800">{rotatedKey}</code>
|
||||||
{rotatedKey && <CopyButton text={rotatedKey} />}
|
{rotatedKey && <CopyButton text={rotatedKey} />}
|
||||||
</div>
|
</div>
|
||||||
<Button size="sm" className="w-full" onClick={() => onKeyRotated?.()}>Done — log me out</Button>
|
<Button size="sm" className="w-full" onClick={() => onKeyRotated?.()}>{t("sec.newKeyDone")}</Button>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
@@ -898,9 +898,9 @@ function SecuritySection({ onKeyRotated }: { onKeyRotated?: () => void }) {
|
|||||||
open={confirmDelete}
|
open={confirmDelete}
|
||||||
onClose={() => setConfirmDelete(false)}
|
onClose={() => setConfirmDelete(false)}
|
||||||
onConfirm={removeAccount}
|
onConfirm={removeAccount}
|
||||||
title="Delete your account?"
|
title={t("sec.deleteTitle")}
|
||||||
message="This permanently removes your account with all conversations, shares and sessions. This cannot be undone."
|
message={t("sec.deleteMsg")}
|
||||||
confirmLabel="Delete forever"
|
confirmLabel={t("sec.deleteForever")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user