Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
66450303a1 | ||
|
|
af9b8c17d0 | ||
|
|
245d26677b |
@@ -66,6 +66,8 @@ 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 |
|
||||
| GET | `/api/admin/mail` | admin | SMTP credentials viewer (password blurred by default in UI) |
|
||||
| POST | `/api/admin/mail/verify`, `/api/admin/mail/test` | admin | verify SMTP connectivity / send test mail `{to}` |
|
||||
| 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 |
|
||||
|
||||
+2
-1
@@ -51,7 +51,8 @@ read-only `SharePage` (no auth), everything else renders the authenticated app.
|
||||
- `AdminCenter.tsx` — `/admin` route (admin gate + locked screen otherwise):
|
||||
tabbed hub (Accounts / Features / Mail) reusing `AdminPanel`, server
|
||||
feature `Switch`es (`registrationEnabled`, `ocrEnabled` via
|
||||
`/api/admin/settings`), SMTP status card. Follows the platform theme.
|
||||
`/api/admin/settings`), SMTP viewer (password blurred by default, eye toggle
|
||||
to reveal) + connectivity tester (verify + send test mail). Follows the platform theme.
|
||||
- `SharePage.tsx` — public read-only view, fetches `/api/share/:id` unauthenticated
|
||||
(`{ title, topic, model, authorName, authorAvatarUrl, sharedAt, messages }`);
|
||||
user messages render the author's avatar via `Avatar` (URL or initials).
|
||||
|
||||
+3
-1
@@ -57,7 +57,9 @@ unshare. See `AGENTS.md`.
|
||||
The sidebar **Admin Center** button (admin only) opens the `/admin` page with
|
||||
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).
|
||||
**Features** (server-side switches), **Mail** (SMTP credentials viewer +
|
||||
connectivity tester: `GET /api/admin/mail`, `POST /api/admin/mail/verify`,
|
||||
`POST /api/admin/mail/test`, admin Bearer only).
|
||||
|
||||
- `registration_enabled` (default off): when on, the login page shows a
|
||||
working **Register** button — anyone can create an account (key shown once)
|
||||
|
||||
+10
-7
@@ -11,6 +11,7 @@ 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";
|
||||
import { useT } from "./lib/i18n.ts";
|
||||
|
||||
function shareIdFromPath(): string | null {
|
||||
const m = window.location.pathname.match(/^\/share\/([A-Za-z0-9_-]{6,64})\/?$/);
|
||||
@@ -66,6 +67,7 @@ export function App() {
|
||||
const [filePreview, setFilePreview] = useState<FilePreview | null>(null);
|
||||
const [previewBusy, setPreviewBusy] = useState(false);
|
||||
const [ocrAvailable, setOcrAvailable] = useState(false);
|
||||
const { t } = useT();
|
||||
|
||||
// Public routes: no auth needed. IDs are constant for the page lifetime.
|
||||
if (IS_ADMIN_PAGE) {
|
||||
@@ -188,7 +190,8 @@ export function App() {
|
||||
const userMsg: ChatMessage = { role: "user", content: text };
|
||||
// Attachments travel as reviewed text blocks appended to the message.
|
||||
const blocks = attachments.map((a) =>
|
||||
`[attached ${a.kind === "ocr" ? "image transcription" : "text file"}: ${a.name}]\n\`\`\`text\n${a.text}\n\`\`\``,
|
||||
t("attach.tpl", { kind: t(a.kind === "ocr" ? "attach.kindOcr" : "attach.kindText"), name: a.name }) +
|
||||
`\n\`\`\`text\n${a.text}\n\`\`\``,
|
||||
);
|
||||
if (blocks.length > 0) userMsg.content = [text, ...blocks].filter(Boolean).join("\n\n");
|
||||
const history = [...messages, userMsg];
|
||||
@@ -211,7 +214,7 @@ export function App() {
|
||||
setStreaming("");
|
||||
await refreshConvs();
|
||||
} catch {
|
||||
setMessages([...history, { role: "assistant", content: "**Error:** the model did not respond. Try again." }]);
|
||||
setMessages([...history, { role: "assistant", content: t("attach.chatError") }]);
|
||||
setStreaming("");
|
||||
} finally {
|
||||
setSending(false);
|
||||
@@ -223,11 +226,11 @@ export function App() {
|
||||
if (!user) return;
|
||||
setAttachError(null);
|
||||
if (kind === "ocr" && file.size > 5 * 1024 * 1024) {
|
||||
setAttachError("Image too large (max 5MB).");
|
||||
setAttachError(t("attach.tooBigImg"));
|
||||
return;
|
||||
}
|
||||
if (kind === "text" && file.size > 500 * 1024) {
|
||||
setAttachError("Text file too large (max 500KB).");
|
||||
setAttachError(t("attach.tooBigText"));
|
||||
return;
|
||||
}
|
||||
setFilePreview({ name: file.name, kind, text: "", truncated: false });
|
||||
@@ -250,7 +253,7 @@ export function App() {
|
||||
}
|
||||
} catch (e) {
|
||||
setFilePreview(null);
|
||||
setAttachError(e instanceof Error ? e.message : "Upload failed");
|
||||
setAttachError(e instanceof Error ? e.message : t("attach.uploadFailed"));
|
||||
} finally {
|
||||
setPreviewBusy(false);
|
||||
}
|
||||
@@ -342,8 +345,8 @@ export function App() {
|
||||
<ConfirmDialog
|
||||
open={deleteConv !== null}
|
||||
onClose={() => setDeleteConv(null)}
|
||||
title="Delete conversation?"
|
||||
message={`"${deleteConv?.title}" and all its messages will be permanently removed.`}
|
||||
title={t("app.deleteConvTitle")}
|
||||
message={t("app.deleteConvMsg", { title: deleteConv?.title ?? "" })}
|
||||
onConfirm={() => deleteConv && removeConv(deleteConv)}
|
||||
/>
|
||||
<SettingsModal
|
||||
|
||||
@@ -5,6 +5,7 @@ import { AdminPanel } from "./AdminPanel.tsx";
|
||||
import { AccessRequestsPanel } from "./AccessRequests.tsx";
|
||||
import { Button, CopyButton, FlowerMark, Input, LoadingScreen, Spinner, Switch } from "./ui.tsx";
|
||||
import { cn } from "../lib/cn.ts";
|
||||
import { useT } from "../lib/i18n.ts";
|
||||
|
||||
type Tab = "accounts" | "access" | "features" | "mail";
|
||||
|
||||
@@ -15,20 +16,21 @@ function applyTheme(theme: string) {
|
||||
root.style.colorScheme = dark ? "dark" : "light";
|
||||
}
|
||||
|
||||
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 },
|
||||
];
|
||||
|
||||
export function AdminCenter() {
|
||||
const { t } = useT();
|
||||
const [allowed, setAllowed] = useState<boolean | null>(null);
|
||||
const [tab, setTab] = useState<Tab>("accounts");
|
||||
const [settings, setSettings] = useState<{ registrationEnabled: boolean; accessRequestEnabled: boolean; ocrEnabled: boolean } | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const TABS: { id: Tab; label: string; icon: typeof Users }[] = [
|
||||
{ id: "accounts", label: t("center.tabAccounts"), icon: Users },
|
||||
{ id: "access", label: t("center.tabAccess"), icon: Inbox },
|
||||
{ id: "features", label: t("center.tabFeatures"), icon: SlidersHorizontal },
|
||||
{ id: "mail", label: t("center.tabMail"), icon: Mail },
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
api.verify()
|
||||
.then((r) => {
|
||||
@@ -38,7 +40,7 @@ export function AdminCenter() {
|
||||
}
|
||||
setAllowed(true);
|
||||
applyTheme(r.user.theme);
|
||||
api.adminGetSettings().then((s) => setSettings(s.settings)).catch((e) => setError(e instanceof Error ? e.message : "Load failed"));
|
||||
api.adminGetSettings().then((s) => setSettings(s.settings)).catch((e) => setError(e instanceof Error ? e.message : t("admin.loadFailed")));
|
||||
})
|
||||
.catch(() => setAllowed(false));
|
||||
}, []);
|
||||
@@ -50,7 +52,7 @@ export function AdminCenter() {
|
||||
const res = await api.adminPatchSettings(patch);
|
||||
setSettings(res.settings);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Save failed");
|
||||
setError(e instanceof Error ? e.message : t("common.saveFailed"));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -65,9 +67,9 @@ export function AdminCenter() {
|
||||
<div className="grid min-h-full place-items-center p-6">
|
||||
<div className="w-full max-w-sm rounded-[2rem] border border-stone-200 bg-white p-8 text-center shadow-xl dark:border-zinc-800 dark:bg-zinc-900">
|
||||
<ShieldAlert size={28} className="mx-auto mb-3 text-red-500" />
|
||||
<h1 className="text-lg font-bold">Admins only</h1>
|
||||
<p className="mt-1 text-sm opacity-60">Log in with the admin account to open the Admin Center.</p>
|
||||
<a href="/" className="mt-4 inline-block rounded-full bg-accent-600 px-5 py-2.5 text-sm font-medium text-white dark:bg-accent-500 dark:text-zinc-950">Back to login</a>
|
||||
<h1 className="text-lg font-bold">{t("center.locked")}</h1>
|
||||
<p className="mt-1 text-sm opacity-60">{t("center.lockedSub")}</p>
|
||||
<a href="/" className="mt-4 inline-block rounded-full bg-accent-600 px-5 py-2.5 text-sm font-medium text-white dark:bg-accent-500 dark:text-zinc-950">{t("common.backToLogin")}</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -77,13 +79,13 @@ export function AdminCenter() {
|
||||
<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-4xl items-center gap-3 px-4 py-3">
|
||||
<a href="/" aria-label="Back to chat" className="rounded-full p-2 transition hover:bg-stone-200/60 dark:hover:bg-zinc-800">
|
||||
<a href="/" aria-label={t("center.back")} className="rounded-full p-2 transition hover:bg-stone-200/60 dark:hover:bg-zinc-800">
|
||||
<ArrowLeft size={18} />
|
||||
</a>
|
||||
<FlowerMark size={32} dynamic={false} />
|
||||
<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
|
||||
{t("center.title")}
|
||||
</h1>
|
||||
</div>
|
||||
<div className="mx-auto w-full max-w-4xl px-4 pb-3">
|
||||
@@ -126,25 +128,25 @@ export function AdminCenter() {
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center gap-4 rounded-3xl border border-stone-200/70 bg-white p-5 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium">Public registration</p>
|
||||
<p className="text-sm opacity-60">Show the Register button on the login page. Off = admin-created accounts only. Turning it on retires the wishlist below.</p>
|
||||
<p className="text-sm font-medium">{t("center.regTitle")}</p>
|
||||
<p className="text-sm opacity-60">{t("center.regDesc")}</p>
|
||||
</div>
|
||||
<Switch label="Public registration" checked={settings?.registrationEnabled ?? false} onChange={(v) => toggle({ registrationEnabled: v })} />
|
||||
<Switch label={t("center.regTitle")} checked={settings?.registrationEnabled ?? false} onChange={(v) => toggle({ registrationEnabled: v })} />
|
||||
</div>
|
||||
<div className={cn(
|
||||
"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",
|
||||
settings?.registrationEnabled && "opacity-60",
|
||||
)}>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium">Access requests (wishlist)</p>
|
||||
<p className="text-sm font-medium">{t("center.accTitle")}</p>
|
||||
<p className="text-sm opacity-60">
|
||||
{settings?.registrationEnabled
|
||||
? "Unavailable while public registration is on — it replaces the wishlist."
|
||||
: "Show Request access on the login page. Visitors reserve a username and plead their case; you triage them in the Access tab."}
|
||||
? t("center.accDescOn")
|
||||
: t("center.accDescOff")}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
label="Access requests"
|
||||
label={t("center.accTitle")}
|
||||
checked={settings?.accessRequestEnabled ?? false}
|
||||
disabled={settings?.registrationEnabled ?? false}
|
||||
onChange={(v) => toggle({ accessRequestEnabled: v })}
|
||||
@@ -152,13 +154,13 @@ export function AdminCenter() {
|
||||
</div>
|
||||
<div className="flex items-center gap-4 rounded-3xl border border-stone-200/70 bg-white p-5 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium">OCR tool</p>
|
||||
<p className="text-sm opacity-60">Server-side image transcription for attachments. Requires the tesseract binary.</p>
|
||||
<p className="text-sm font-medium">{t("center.ocrTitle")}</p>
|
||||
<p className="text-sm opacity-60">{t("center.ocrDesc")}</p>
|
||||
</div>
|
||||
<Switch label="OCR tool" checked={settings?.ocrEnabled ?? false} onChange={(v) => toggle({ ocrEnabled: v })} />
|
||||
<Switch label={t("center.ocrTitle")} checked={settings?.ocrEnabled ?? false} onChange={(v) => toggle({ ocrEnabled: v })} />
|
||||
</div>
|
||||
{saving && <p className="flex items-center gap-2 text-sm opacity-60"><Spinner size={14} /> Saving…</p>}
|
||||
{!settings && !error && <p className="flex items-center gap-2 text-sm opacity-60"><Spinner size={14} /> Loading…</p>}
|
||||
{saving && <p className="flex items-center gap-2 text-sm opacity-60"><Spinner size={14} /> {t("common.saving")}</p>}
|
||||
{!settings && !error && <p className="flex items-center gap-2 text-sm opacity-60"><Spinner size={14} /> {t("common.loading")}</p>}
|
||||
</section>
|
||||
)}
|
||||
|
||||
@@ -181,6 +183,7 @@ type SmtpInfo = {
|
||||
};
|
||||
|
||||
function MailPanel() {
|
||||
const { t } = useT();
|
||||
const [smtp, setSmtp] = useState<SmtpInfo | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [showPass, setShowPass] = useState(false);
|
||||
@@ -190,7 +193,7 @@ function MailPanel() {
|
||||
const [result, setResult] = useState<{ ok: boolean; text: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.adminMailStatus().then((r) => setSmtp(r.smtp)).catch((e) => setError(e instanceof Error ? e.message : "Load failed"));
|
||||
api.adminMailStatus().then((r) => setSmtp(r.smtp)).catch((e) => setError(e instanceof Error ? e.message : t("admin.loadFailed")));
|
||||
}, []);
|
||||
|
||||
async function verify() {
|
||||
@@ -198,9 +201,9 @@ function MailPanel() {
|
||||
setResult(null);
|
||||
try {
|
||||
await api.adminMailVerify();
|
||||
setResult({ ok: true, text: "Connection OK — SMTP host reachable, auth accepted." });
|
||||
setResult({ ok: true, text: t("center.connOk") });
|
||||
} catch (e) {
|
||||
setResult({ ok: false, text: e instanceof Error ? e.message : "Verification failed" });
|
||||
setResult({ ok: false, text: e instanceof Error ? e.message : t("center.verifyFailed") });
|
||||
} finally {
|
||||
setVerifying(false);
|
||||
}
|
||||
@@ -211,9 +214,9 @@ function MailPanel() {
|
||||
setResult(null);
|
||||
try {
|
||||
const r = await api.adminMailTest(to.trim());
|
||||
setResult({ ok: true, text: `Test mail sent to ${to.trim()}${r.messageId ? ` (${r.messageId})` : ""}.` });
|
||||
setResult({ ok: true, text: t("center.testSent", { to: to.trim(), tail: r.messageId ? ` (${r.messageId})` : "" }) });
|
||||
} catch (e) {
|
||||
setResult({ ok: false, text: e instanceof Error ? e.message : "Send failed" });
|
||||
setResult({ ok: false, text: e instanceof Error ? e.message : t("center.mailSendFailed") });
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
@@ -236,40 +239,40 @@ function MailPanel() {
|
||||
<Mail size={17} />
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-sm font-medium">Key recovery emails</p>
|
||||
<p className="text-sm font-medium">{t("center.mailTitle")}</p>
|
||||
<p className="text-sm opacity-60">
|
||||
{smtp == null ? "Checking…" : smtp.enabled ? `Enabled — sent from ${smtp.from}` : "Disabled — set SMTP_HOST on the server to enable"}
|
||||
{smtp == null ? t("center.mailChecking") : smtp.enabled ? t("center.mailFrom", { from: smtp.from }) : t("center.mailOff")}
|
||||
</p>
|
||||
</div>
|
||||
<span className={cn(
|
||||
"ml-auto rounded-full px-3 py-1 text-xs font-medium",
|
||||
smtp?.enabled ? "bg-emerald-600/10 text-emerald-700 dark:text-emerald-400" : "bg-stone-200/70 dark:bg-zinc-800",
|
||||
)}>
|
||||
{smtp?.enabled ? "On" : "Off"}
|
||||
{smtp?.enabled ? t("center.on") : t("center.off")}
|
||||
</span>
|
||||
</div>
|
||||
{error && <p className="mt-3 text-sm text-red-600 dark:text-red-400" role="alert">{error}</p>}
|
||||
{!smtp && !error && <p className="mt-3 flex items-center gap-2 text-sm opacity-60"><Spinner size={14} /> Loading…</p>}
|
||||
{!smtp && !error && <p className="mt-3 flex items-center gap-2 text-sm opacity-60"><Spinner size={14} /> {t("common.loading")}</p>}
|
||||
{smtp && (
|
||||
<div className="mt-3 divide-y divide-stone-200/70 rounded-2xl border border-stone-200/70 px-4 dark:divide-zinc-800 dark:border-zinc-800">
|
||||
<Row label="Host">{smtp.host || <span className="opacity-50">(not set)</span>}</Row>
|
||||
<Row label="Port">{smtp.port} · {smtp.secure ? "implicit TLS (465)" : "STARTTLS (587)"}</Row>
|
||||
<Row label="User">{smtp.user || <span className="opacity-50">(no auth)</span>}</Row>
|
||||
<Row label="Password">
|
||||
<Row label={t("center.smtpHost")}>{smtp.host || <span className="opacity-50">{t("center.notSet")}</span>}</Row>
|
||||
<Row label={t("center.smtpPort")}>{smtp.port} · {smtp.secure ? t("center.tlsImplicit") : t("center.tlsStarttls")}</Row>
|
||||
<Row label={t("center.smtpUser")}>{smtp.user || <span className="opacity-50">{t("center.noAuth")}</span>}</Row>
|
||||
<Row label={t("center.smtpPass")}>
|
||||
<span className="flex items-center gap-2">
|
||||
{!smtp.hasPass ? (
|
||||
<span className="opacity-50">(not set)</span>
|
||||
<span className="opacity-50">{t("center.notSet")}</span>
|
||||
) : showPass ? (
|
||||
<code className="truncate font-mono">{smtp.pass}</code>
|
||||
) : (
|
||||
<code className="select-none blur-sm" aria-label="hidden password">••••••••••</code>
|
||||
<code className="select-none blur-sm" aria-label={t("center.hiddenPass")}>••••••••••</code>
|
||||
)}
|
||||
{smtp.hasPass && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPass((v) => !v)}
|
||||
aria-label={showPass ? "Hide SMTP password" : "Show SMTP password"}
|
||||
aria-label={showPass ? t("center.hidePass") : t("center.showPass")}
|
||||
className="rounded-full p-1.5 transition hover:bg-stone-200/60 dark:hover:bg-zinc-800"
|
||||
>
|
||||
{showPass ? <EyeOff size={15} /> : <Eye size={15} />}
|
||||
@@ -279,37 +282,36 @@ function MailPanel() {
|
||||
)}
|
||||
</span>
|
||||
</Row>
|
||||
<Row label="From"><span className="truncate">{smtp.from}</span></Row>
|
||||
<Row label="App URL"><span className="truncate">{smtp.appUrl}</span></Row>
|
||||
<Row label={t("center.smtpFrom")}><span className="truncate">{smtp.from}</span></Row>
|
||||
<Row label={t("center.smtpAppUrl")}><span className="truncate">{smtp.appUrl}</span></Row>
|
||||
</div>
|
||||
)}
|
||||
<p className="mt-3 text-sm opacity-60">
|
||||
Credentials live in the server environment (see <code>docs/OPERATIONS.md</code>), read-only here.
|
||||
Users set their recovery address in profile settings or at account creation.
|
||||
{t("center.credsNote")}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="rounded-3xl border border-stone-200/70 bg-white p-5 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
|
||||
<p className="text-sm font-medium">Connectivity test</p>
|
||||
<p className="mt-0.5 text-sm opacity-60">Verify the connection, then send a real test mail to an address you control.</p>
|
||||
<p className="text-sm font-medium">{t("center.mailTestTitle")}</p>
|
||||
<p className="mt-0.5 text-sm opacity-60">{t("center.mailTestDesc")}</p>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<Button variant="secondary" size="sm" onClick={verify} disabled={verifying || !smtp?.enabled}>
|
||||
{verifying ? <Spinner size={14} /> : null}
|
||||
{verifying ? "Verifying…" : "Verify connection"}
|
||||
{verifying ? t("center.verifying") : t("center.verifyConn")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-col gap-2 sm:flex-row">
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="[email protected]"
|
||||
aria-label="Test recipient email"
|
||||
aria-label={t("center.testToAria")}
|
||||
value={to}
|
||||
onChange={(e) => setTo(e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button size="sm" onClick={sendTest} disabled={sending || !smtp?.enabled || !/^[^\s@]{1,64}@[^\s@]{1,253}\.[^\s@]{2,}$/.test(to.trim())}>
|
||||
{sending ? <Spinner size={14} /> : <Send size={14} />}
|
||||
{sending ? "Sending…" : "Send test mail"}
|
||||
{sending ? t("center.sendingMail") : t("center.sendTest")}
|
||||
</Button>
|
||||
</div>
|
||||
{result && (
|
||||
|
||||
@@ -2,25 +2,27 @@ import { useEffect, useState, type FC, type ReactNode } from "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 { useT, type StringKey } from "../lib/i18n.ts";
|
||||
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[] }[] = [
|
||||
{ name: "llama3.2", desc: "Meta's compact everyday model", sizes: ["1b", "3b"] },
|
||||
{ name: "llama3.1", desc: "Meta's general-purpose workhorse", sizes: ["8b", "70b"] },
|
||||
{ name: "qwen2.5", desc: "Alibaba's multilingual all-rounder", sizes: ["0.5b", "1.5b", "7b", "14b", "32b"] },
|
||||
{ name: "gemma2", desc: "Google's efficient open model", sizes: ["2b", "9b", "27b"] },
|
||||
{ name: "mistral", desc: "Mistral 7B — fast and capable", sizes: ["7b"] },
|
||||
{ name: "phi3", desc: "Microsoft's small research model", sizes: ["3.8b", "14b"] },
|
||||
{ name: "deepseek-r1", desc: "Reasoning model (chain-of-thought)", sizes: ["1.5b", "7b", "8b", "14b", "32b"] },
|
||||
{ name: "codellama", desc: "Code completion and chat", sizes: ["7b", "13b", "34b"] },
|
||||
{ name: "llava", desc: "Vision model (image understanding)", sizes: ["7b", "13b", "34b"] },
|
||||
{ name: "nomic-embed-text", desc: "Text embeddings", sizes: [] },
|
||||
/** Curated Ollama library catalog (admin downloads only). Descriptions resolve via i18n (`ollama.cat.*`). */
|
||||
const OLLAMA_CATALOG: { name: string; catKey: StringKey; sizes: string[] }[] = [
|
||||
{ name: "llama3.2", catKey: "ollama.cat.llama3_2", sizes: ["1b", "3b"] },
|
||||
{ name: "llama3.1", catKey: "ollama.cat.llama3_1", sizes: ["8b", "70b"] },
|
||||
{ name: "qwen2.5", catKey: "ollama.cat.qwen2_5", sizes: ["0.5b", "1.5b", "7b", "14b", "32b"] },
|
||||
{ name: "gemma2", catKey: "ollama.cat.gemma2", sizes: ["2b", "9b", "27b"] },
|
||||
{ name: "mistral", catKey: "ollama.cat.mistral", sizes: ["7b"] },
|
||||
{ name: "phi3", catKey: "ollama.cat.phi3", sizes: ["3.8b", "14b"] },
|
||||
{ name: "deepseek-r1", catKey: "ollama.cat.deepseek_r1", sizes: ["1.5b", "7b", "8b", "14b", "32b"] },
|
||||
{ name: "codellama", catKey: "ollama.cat.codellama", sizes: ["7b", "13b", "34b"] },
|
||||
{ name: "llava", catKey: "ollama.cat.llava", sizes: ["7b", "13b", "34b"] },
|
||||
{ name: "nomic-embed-text", catKey: "ollama.cat.nomic_embed_text", sizes: [] },
|
||||
];
|
||||
|
||||
/* ---------- Ollama model catalog (admin) ---------- */
|
||||
|
||||
export function OllamaModelsModal({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
const { t } = useT();
|
||||
const [installed, setInstalled] = useState<DriverModel[]>([]);
|
||||
const [pullName, setPullName] = useState("");
|
||||
const [pullStatus, setPullStatus] = useState("");
|
||||
@@ -52,20 +54,20 @@ export function OllamaModelsModal({ open, onClose }: { open: boolean; onClose: (
|
||||
if (pulling || !fullName.trim()) return;
|
||||
setPulling(true);
|
||||
setError("");
|
||||
setPullStatus("Contacting Ollama…");
|
||||
setPullStatus(t("ollama.contacting"));
|
||||
setPullPct(null);
|
||||
try {
|
||||
await api.ollamaPull(fullName.trim(), (p) => {
|
||||
if (p.error) { setError(p.error); return; }
|
||||
setPullStatus(p.status || "Pulling…");
|
||||
setPullStatus(p.status || t("ollama.pullingSt"));
|
||||
if (p.total && p.completed) setPullPct(Math.min(100, Math.round((p.completed / p.total) * 100)));
|
||||
else setPullPct(null);
|
||||
});
|
||||
setPullStatus("Done");
|
||||
setPullStatus(t("ollama.doneSt"));
|
||||
setPullPct(100);
|
||||
await refreshInstalled();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Pull failed");
|
||||
setError(err instanceof Error ? err.message : t("ollama.pullFailed"));
|
||||
} finally {
|
||||
setPulling(false);
|
||||
}
|
||||
@@ -76,15 +78,15 @@ export function OllamaModelsModal({ open, onClose }: { open: boolean; onClose: (
|
||||
await api.ollamaDelete(name);
|
||||
await refreshInstalled();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Delete failed");
|
||||
setError(err instanceof Error ? err.message : t("admin.deleteFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal open={open} onClose={pulling ? () => {} : onClose} title="Ollama models" icon={Bot}>
|
||||
<Modal open={open} onClose={pulling ? () => {} : onClose} title={t("ollama.models")} icon={Bot}>
|
||||
<p className="mb-4 text-sm opacity-70">
|
||||
Download models from the Ollama library to your Ollama host, or remove local ones. Admin only.
|
||||
{t("ollama.desc")}
|
||||
</p>
|
||||
{error && <p className="mb-3 rounded-2xl bg-red-50 px-4 py-2.5 text-sm text-red-600 dark:bg-red-950/40 dark:text-red-400" role="alert">{error}</p>}
|
||||
|
||||
@@ -92,7 +94,7 @@ export function OllamaModelsModal({ open, onClose }: { open: boolean; onClose: (
|
||||
<div className="mb-4 rounded-2xl border border-accent-500/40 bg-accent-50 p-4 dark:bg-accent-950/30">
|
||||
<p className="mb-2 flex items-center gap-2 text-sm font-medium">
|
||||
<Download size={15} className="animate-bounce" />
|
||||
Pulling {pullName}
|
||||
{t("ollama.pulling", { name: pullName })}
|
||||
</p>
|
||||
<p className="mb-2 truncate text-xs opacity-70">{pullStatus}</p>
|
||||
{pullPct !== null && (
|
||||
@@ -105,7 +107,7 @@ export function OllamaModelsModal({ open, onClose }: { open: boolean; onClose: (
|
||||
|
||||
<div className="mb-4 flex flex-wrap items-end gap-2 rounded-2xl border border-stone-200 p-3 dark:border-zinc-700">
|
||||
<div className="min-w-52 flex-1">
|
||||
<Field label="Custom model tag" hint="e.g. llama3.2:3b, or a full library name">
|
||||
<Field label={t("ollama.customTag")} hint={t("ollama.customHint")}>
|
||||
<Input
|
||||
value={pullName}
|
||||
onChange={(e) => setPullName(e.target.value)}
|
||||
@@ -116,18 +118,18 @@ export function OllamaModelsModal({ open, onClose }: { open: boolean; onClose: (
|
||||
</Field>
|
||||
</div>
|
||||
<Button size="sm" onClick={() => pull(pullName)} disabled={pulling || !pullName.trim()}>
|
||||
{pulling ? <Spinner size={15} /> : <Download size={15} />} Pull
|
||||
{pulling ? <Spinner size={15} /> : <Download size={15} />} {t("ollama.pull")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="mb-2 flex items-center gap-1.5 text-sm font-medium"><HardDrive size={14} /> Installed ({installed.length})</p>
|
||||
<p className="mb-2 flex items-center gap-1.5 text-sm font-medium"><HardDrive size={14} /> {t("ollama.installed", { n: installed.length })}</p>
|
||||
<ul className="mb-5 space-y-1.5">
|
||||
{installed.length === 0 && <li className="text-sm opacity-50">No local Ollama models yet.</li>}
|
||||
{installed.length === 0 && <li className="text-sm opacity-50">{t("ollama.noneYet")}</li>}
|
||||
{installed.map((m) => (
|
||||
<li key={m.id} className="flex items-center gap-3 rounded-2xl border border-stone-200/70 px-3.5 py-2 dark:border-zinc-800">
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium">{m.label || m.id}</span>
|
||||
<button
|
||||
title={`Delete ${m.label || m.id}`}
|
||||
title={t("ollama.delTip", { name: m.label || m.id })}
|
||||
onClick={() => setDeleteModel(m.label || m.id)}
|
||||
className="rounded-full p-2 text-red-600 transition hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-950/40"
|
||||
>
|
||||
@@ -137,7 +139,7 @@ export function OllamaModelsModal({ open, onClose }: { open: boolean; onClose: (
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<p className="mb-2 flex items-center gap-1.5 text-sm font-medium"><Download size={14} /> Catalog</p>
|
||||
<p className="mb-2 flex items-center gap-1.5 text-sm font-medium"><Download size={14} /> {t("ollama.catalog")}</p>
|
||||
<ul className="space-y-1.5">
|
||||
{OLLAMA_CATALOG.map((m) => {
|
||||
const size = sizeOverrides[m.name] ?? (m.sizes.length > 0 ? m.sizes[0]! : "");
|
||||
@@ -146,12 +148,12 @@ export function OllamaModelsModal({ open, onClose }: { open: boolean; onClose: (
|
||||
<li key={m.name} className="flex flex-wrap items-center gap-2 rounded-2xl border border-stone-200/70 px-3.5 py-2 dark:border-zinc-800">
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium">{m.name}</span>
|
||||
<span className="block text-xs opacity-50">{m.desc}</span>
|
||||
<span className="block text-xs opacity-50">{t(m.catKey)}</span>
|
||||
</span>
|
||||
{m.sizes.length > 0 && (
|
||||
<span className="w-24 shrink-0">
|
||||
<Picker
|
||||
ariaLabel={`${m.name} size`}
|
||||
ariaLabel={t("ollama.sizeAria", { name: m.name })}
|
||||
value={size}
|
||||
onChange={(v) => setSizeOverrides((prev) => ({ ...prev, [m.name]: v }))}
|
||||
align="right"
|
||||
@@ -160,7 +162,7 @@ export function OllamaModelsModal({ open, onClose }: { open: boolean; onClose: (
|
||||
</span>
|
||||
)}
|
||||
<Button size="sm" variant="secondary" onClick={() => { setPullName(tag); pull(tag); }} disabled={pulling}>
|
||||
<Download size={14} /> Pull
|
||||
<Download size={14} /> {t("ollama.pull")}
|
||||
</Button>
|
||||
</li>
|
||||
);
|
||||
@@ -171,8 +173,8 @@ export function OllamaModelsModal({ open, onClose }: { open: boolean; onClose: (
|
||||
<ConfirmDialog
|
||||
open={deleteModel !== null}
|
||||
onClose={() => setDeleteModel(null)}
|
||||
title={`Delete ${deleteModel}?`}
|
||||
message="The model is removed from the Ollama host. Pull it again anytime."
|
||||
title={t("ollama.delTitle", { name: deleteModel ?? "" })}
|
||||
message={t("ollama.delMsg")}
|
||||
onConfirm={() => deleteModel && removeModel(deleteModel)}
|
||||
/>
|
||||
</>
|
||||
@@ -180,6 +182,7 @@ export function OllamaModelsModal({ open, onClose }: { open: boolean; onClose: (
|
||||
}
|
||||
|
||||
export function AdminPanel({ open, onClose, bare }: { open: boolean; onClose: () => void; bare?: boolean }) {
|
||||
const { t } = useT();
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
@@ -213,7 +216,7 @@ export function AdminPanel({ open, onClose, bare }: { open: boolean; onClose: ()
|
||||
setPages(res.pages);
|
||||
if (res.page !== page) setPage(res.page);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Load failed");
|
||||
setError(err instanceof Error ? err.message : t("admin.loadFailed"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -254,7 +257,7 @@ export function AdminPanel({ open, onClose, bare }: { open: boolean; onClose: ()
|
||||
setShowCreate(false);
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Create failed");
|
||||
setError(err instanceof Error ? err.message : t("admin.createFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -264,7 +267,7 @@ export function AdminPanel({ open, onClose, bare }: { open: boolean; onClose: ()
|
||||
const res = await api.adminRegenerate(u.id);
|
||||
setFreshKey({ username: u.username, key: res.key });
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Regenerate failed");
|
||||
setError(err instanceof Error ? err.message : t("admin.regenFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -273,7 +276,7 @@ export function AdminPanel({ open, onClose, bare }: { open: boolean; onClose: ()
|
||||
await api.adminDelete(u.id);
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Delete failed");
|
||||
setError(err instanceof Error ? err.message : t("admin.deleteFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -281,19 +284,19 @@ export function AdminPanel({ open, onClose, bare }: { open: boolean; onClose: ()
|
||||
// of a modal — no logic duplicated.
|
||||
const Shell: FC<{ children: ReactNode }> = bare
|
||||
? ({ children }) => <div className="min-w-0 flex-1 space-y-1">{children}</div>
|
||||
: ({ children }) => <Modal open={open} onClose={onClose} title="Accounts" icon={Users} wide>{children}</Modal>;
|
||||
: ({ children }) => <Modal open={open} onClose={onClose} title={t("admin.title")} icon={Users} wide>{children}</Modal>;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Shell>
|
||||
<p className="mb-4 text-sm opacity-70">
|
||||
No-KYC accounts: you create a username, hand the access key to its owner once. Keys are stored hashed — regenerate to rotate.
|
||||
{t("admin.intro")}
|
||||
</p>
|
||||
{error && <p className="mb-3 rounded-2xl bg-red-50 px-4 py-2.5 text-sm text-red-600 dark:bg-red-950/40 dark:text-red-400">{error}</p>}
|
||||
|
||||
{freshKey && (
|
||||
<div className="mb-4 rounded-2xl border border-accent-500/40 bg-accent-50 p-4 dark:bg-accent-950/30">
|
||||
<p className="mb-2 flex items-center gap-2 text-sm font-medium"><KeyRound size={15} /> Key for @{freshKey.username} — shown once, copy it now:</p>
|
||||
<p className="mb-2 flex items-center gap-2 text-sm font-medium"><KeyRound size={15} /> {t("admin.keyOnce", { user: freshKey.username })}</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.key}</code>
|
||||
<CopyButton text={freshKey.key} />
|
||||
@@ -304,62 +307,62 @@ export function AdminPanel({ open, onClose, bare }: { open: boolean; onClose: ()
|
||||
{!showCreate ? (
|
||||
<div className="mb-5 flex flex-wrap gap-2">
|
||||
<Button variant="secondary" size="sm" onClick={() => setShowCreate(true)}>
|
||||
<UserPlus size={15} /> New account
|
||||
<UserPlus size={15} /> {t("admin.newAccount")}
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" onClick={() => setModelsOpen(true)}>
|
||||
<Bot size={15} /> Ollama models
|
||||
<Bot size={15} /> {t("ollama.models")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={create} className="mb-4 flex flex-wrap items-end gap-2 rounded-2xl border border-stone-200 p-3 dark:border-zinc-700">
|
||||
<div className="min-w-40 flex-1">
|
||||
<Field label="Username"><Input value={username} onChange={(e) => setUsername(e.target.value)} placeholder="alice" required /></Field>
|
||||
<Field label={t("common.username")}><Input value={username} onChange={(e) => setUsername(e.target.value)} placeholder="alice" required /></Field>
|
||||
</div>
|
||||
<div className="min-w-40 flex-1">
|
||||
<Field label="Display name"><Input value={displayName} onChange={(e) => setDisplayName(e.target.value)} placeholder="Alice" /></Field>
|
||||
<Field label={t("common.displayName")}><Input value={displayName} onChange={(e) => setDisplayName(e.target.value)} placeholder="Alice" /></Field>
|
||||
</div>
|
||||
<div className="min-w-40 flex-1">
|
||||
<Field label="Email (recovery)"><Input value={email} onChange={(e) => setEmail(e.target.value)} placeholder="[email protected]" inputMode="email" /></Field>
|
||||
<Field label={t("admin.email")}><Input value={email} onChange={(e) => setEmail(e.target.value)} placeholder="[email protected]" inputMode="email" /></Field>
|
||||
</div>
|
||||
<Button size="sm" type="submit">Create</Button>
|
||||
<Button size="sm" variant="ghost" type="button" onClick={() => setShowCreate(false)}>Cancel</Button>
|
||||
<Button size="sm" type="submit">{t("common.create")}</Button>
|
||||
<Button size="sm" variant="ghost" type="button" onClick={() => setShowCreate(false)}>{t("common.cancel")}</Button>
|
||||
</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" />
|
||||
<Input value={q} onChange={(e) => setQ(e.target.value)} placeholder={t("admin.searchPh")} aria-label={t("admin.searchAria")} className="pl-10" />
|
||||
</div>
|
||||
<Picker
|
||||
ariaLabel="Sort accounts"
|
||||
ariaLabel={t("admin.sortAria")}
|
||||
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" },
|
||||
{ value: "newest", label: t("admin.sortNewest") },
|
||||
{ value: "oldest", label: t("admin.sortOldest") },
|
||||
{ value: "az", label: t("admin.sortAz") },
|
||||
{ value: "za", label: t("admin.sortZa") },
|
||||
]}
|
||||
/>
|
||||
<Picker
|
||||
ariaLabel="Filter accounts"
|
||||
ariaLabel={t("admin.filterAria")}
|
||||
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" },
|
||||
{ value: "all", label: t("admin.filterAll") },
|
||||
{ value: "with-email", label: t("admin.filterWith") },
|
||||
{ value: "no-email", label: t("admin.filterNo") },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{busy ? (
|
||||
<p className="flex items-center gap-2 text-sm opacity-60"><Spinner size={15} /> Loading…</p>
|
||||
<p className="flex items-center gap-2 text-sm opacity-60"><Spinner size={15} /> {t("common.loading")}</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{users.map((u) => (
|
||||
@@ -367,13 +370,13 @@ 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="truncate text-xs opacity-50">{u.email || "no recovery email"} · {new Date(u.createdAt).toLocaleDateString()}</p>
|
||||
<p className="truncate text-xs opacity-50">{u.email || t("admin.noEmail")} · {new Date(u.createdAt).toLocaleDateString()}</p>
|
||||
</div>
|
||||
{!u.isAdmin && (
|
||||
<div className="flex shrink-0 gap-1">
|
||||
<button title="Edit profile" onClick={() => setEditTarget(u)} className="rounded-full p-2 transition hover:bg-stone-100 dark:hover:bg-zinc-800"><Pencil size={15} /></button>
|
||||
<button title="Regenerate key (revokes old sessions)" onClick={() => regenerate(u)} className="rounded-full p-2 transition hover:bg-stone-100 dark:hover:bg-zinc-800"><RefreshCw size={15} /></button>
|
||||
<button title="Delete account" onClick={() => setDeleteTarget(u)} className="rounded-full p-2 text-red-600 transition hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-950/40"><Trash2 size={15} /></button>
|
||||
<button title={t("admin.editProfile")} onClick={() => setEditTarget(u)} className="rounded-full p-2 transition hover:bg-stone-100 dark:hover:bg-zinc-800"><Pencil size={15} /></button>
|
||||
<button title={t("admin.regenKey")} onClick={() => regenerate(u)} className="rounded-full p-2 transition hover:bg-stone-100 dark:hover:bg-zinc-800"><RefreshCw size={15} /></button>
|
||||
<button title={t("admin.deleteAccount")} onClick={() => setDeleteTarget(u)} className="rounded-full p-2 text-red-600 transition hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-950/40"><Trash2 size={15} /></button>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
@@ -381,18 +384,18 @@ 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>
|
||||
<span className="opacity-60">{total === 1 ? t("admin.countOne", { total, page, pages }) : t("admin.countMany", { total, page, pages })}</span>
|
||||
{pages > 1 && (
|
||||
<div className="flex gap-2.5">
|
||||
<IconButton
|
||||
title="Previous page"
|
||||
title={t("admin.prevPage")}
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
title="Next page"
|
||||
title={t("admin.nextPage")}
|
||||
disabled={page >= pages}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
>
|
||||
@@ -406,8 +409,8 @@ export function AdminPanel({ open, onClose, bare }: { open: boolean; onClose: ()
|
||||
<ConfirmDialog
|
||||
open={deleteTarget !== null}
|
||||
onClose={() => setDeleteTarget(null)}
|
||||
title={`Delete @${deleteTarget?.username}?`}
|
||||
message="This permanently removes the account, all its conversations and its shares."
|
||||
title={t("admin.deleteTitle", { user: deleteTarget?.username ?? "" })}
|
||||
message={t("admin.deleteMsg")}
|
||||
onConfirm={() => deleteTarget && remove(deleteTarget)}
|
||||
/>
|
||||
|
||||
@@ -418,6 +421,7 @@ export function AdminPanel({ open, onClose, bare }: { open: boolean; onClose: ()
|
||||
}
|
||||
|
||||
function EditUserDialog({ user, onClose, onSaved }: { user: User | null; onClose: () => void; onSaved: () => void }) {
|
||||
const { t } = useT();
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [avatarUrl, setAvatarUrl] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
@@ -442,33 +446,33 @@ function EditUserDialog({ user, onClose, onSaved }: { user: User | null; onClose
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Save failed");
|
||||
setError(err instanceof Error ? err.message : t("common.saveFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal open={user !== null} onClose={onClose} title={`Edit @${user?.username}`} icon={Pencil}>
|
||||
<Modal open={user !== null} onClose={onClose} title={t("admin.editUserTitle", { user: user?.username ?? "" })} icon={Pencil}>
|
||||
<form onSubmit={save} className="space-y-4">
|
||||
<Field label="Display name"><Input value={displayName} onChange={(e) => setDisplayName(e.target.value)} maxLength={60} required /></Field>
|
||||
<Field label="Avatar URL"><Input value={avatarUrl} onChange={(e) => setAvatarUrl(e.target.value)} placeholder="https://…" /></Field>
|
||||
<Field label="Email (recovery)"><Input value={email} onChange={(e) => setEmail(e.target.value)} placeholder="[email protected]" inputMode="email" /></Field>
|
||||
<Field label="Theme">
|
||||
<Field label={t("common.displayName")}><Input value={displayName} onChange={(e) => setDisplayName(e.target.value)} maxLength={60} required /></Field>
|
||||
<Field label={t("admin.avatarUrl")}><Input value={avatarUrl} onChange={(e) => setAvatarUrl(e.target.value)} placeholder="https://…" /></Field>
|
||||
<Field label={t("admin.email")}><Input value={email} onChange={(e) => setEmail(e.target.value)} placeholder="[email protected]" inputMode="email" /></Field>
|
||||
<Field label={t("common.theme")}>
|
||||
<Picker
|
||||
ariaLabel="Theme"
|
||||
ariaLabel={t("common.theme")}
|
||||
value={theme}
|
||||
onChange={setTheme}
|
||||
align="left"
|
||||
options={[
|
||||
{ value: "auto", label: "Auto" },
|
||||
{ value: "light", label: "Light" },
|
||||
{ value: "dark", label: "Dark" },
|
||||
{ value: "auto", label: t("common.themeAuto") },
|
||||
{ value: "light", label: t("common.light") },
|
||||
{ value: "dark", label: t("common.dark") },
|
||||
]}
|
||||
/>
|
||||
</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">Save</Button>
|
||||
<Button variant="secondary" size="sm" type="button" onClick={onClose}>{t("common.cancel")}</Button>
|
||||
<Button size="sm" type="submit">{t("common.save")}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
@@ -5,8 +5,10 @@ import { AssistantAvatar, Avatar, FlowerMark, IconButton, Picker, type PickerGro
|
||||
import { Markdown } from "./Markdown.tsx";
|
||||
import { cn } from "../lib/cn.ts";
|
||||
import { useFeatures } from "../lib/features.ts";
|
||||
import { useT } from "../lib/i18n.ts";
|
||||
|
||||
function MessageBubble({ msg, authorAvatar, authorName }: { msg: ChatMessage; authorAvatar?: string; authorName: string }) {
|
||||
const { t } = useT();
|
||||
const isUser = msg.role === "user";
|
||||
return (
|
||||
<div className={cn("flex gap-3", isUser && "flex-row-reverse")}>
|
||||
@@ -16,7 +18,7 @@ function MessageBubble({ msg, authorAvatar, authorName }: { msg: ChatMessage; au
|
||||
<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} />}
|
||||
{isUser ? "You" : "KisAssistant"}
|
||||
{isUser ? t("chat.you") : "KisAssistant"}
|
||||
</div>
|
||||
{isUser ? (
|
||||
<div className="whitespace-pre-wrap rounded-3xl rounded-tr-lg bg-accent-600 px-4 py-2.5 text-[15px] text-white dark:bg-accent-500 dark:text-zinc-950">
|
||||
@@ -73,6 +75,7 @@ export function Chat({
|
||||
}) {
|
||||
const [draft, setDraft] = useState("");
|
||||
const [attachOpen, setAttachOpen] = useState(false);
|
||||
const { t } = useT();
|
||||
const features = useFeatures();
|
||||
const boxRef = useRef<HTMLDivElement>(null);
|
||||
const taRef = useRef<HTMLTextAreaElement>(null);
|
||||
@@ -114,35 +117,35 @@ export function Chat({
|
||||
return (
|
||||
<div className="flex h-full min-w-0 flex-1 flex-col">
|
||||
<header className="flex items-center gap-2 border-b border-stone-200/70 px-4 py-2.5 backdrop-blur dark:border-zinc-800">
|
||||
<IconButton title="Open chats" onClick={onOpenNav} className="md:hidden">
|
||||
<IconButton title={t("chat.openNav")} onClick={onOpenNav} className="md:hidden">
|
||||
<Menu size={18} />
|
||||
</IconButton>
|
||||
{sidebarCollapsed && (
|
||||
<span className="hidden md:inline">
|
||||
<IconButton title="Expand sidebar" onClick={onExpandSidebar}>
|
||||
<IconButton title={t("chat.expand")} onClick={onExpandSidebar}>
|
||||
<PanelLeftOpen size={18} />
|
||||
</IconButton>
|
||||
</span>
|
||||
)}
|
||||
<span className="hidden items-center gap-2 font-semibold sm:flex">
|
||||
<FlowerMark size={24} dynamic={false} />
|
||||
{conv?.title || "New chat"}
|
||||
{conv?.title || t("sidebar.newChat")}
|
||||
</span>
|
||||
{conv?.topic && (
|
||||
<span className="hidden rounded-full bg-stone-200/70 px-2.5 py-0.5 text-xs opacity-80 md:inline dark:bg-zinc-800">{conv.topic}</span>
|
||||
)}
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<Picker
|
||||
ariaLabel="Model"
|
||||
ariaLabel={t("chat.modelAria")}
|
||||
icon={Cpu}
|
||||
value={model}
|
||||
onChange={onModelChange}
|
||||
groups={modelGroups}
|
||||
placeholder={models.length === 0 ? "No models" : "Select model…"}
|
||||
placeholder={models.length === 0 ? t("chat.noModels") : t("chat.selectModel")}
|
||||
disabled={models.length === 0}
|
||||
/>
|
||||
{conv && (
|
||||
<IconButton title="Share publicly" onClick={onShare}>
|
||||
<IconButton title={t("sidebar.share")} onClick={onShare}>
|
||||
<Share2 size={17} />
|
||||
</IconButton>
|
||||
)}
|
||||
@@ -154,8 +157,8 @@ export function Chat({
|
||||
{messages.length === 0 && !streaming && (
|
||||
<div className="py-16 text-center">
|
||||
<FlowerMark size={56} dynamic={false} 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>
|
||||
<h2 className="text-2xl font-bold tracking-tight">{t("chat.greet", { name: user.displayName.split(" ")[0]! })}</h2>
|
||||
<p className="mt-1 text-sm opacity-60">{t("chat.greetSub")}</p>
|
||||
</div>
|
||||
)}
|
||||
{messages.filter((m) => m.role !== "system").map((m, i) => (
|
||||
@@ -173,7 +176,7 @@ export function Chat({
|
||||
</div>
|
||||
)}
|
||||
{sending && streaming === "" && (
|
||||
<div className="flex items-center gap-2 opacity-60"><Spinner size={15} /><span className="text-sm">Thinking…</span></div>
|
||||
<div className="flex items-center gap-2 opacity-60"><Spinner size={15} /><span className="text-sm">{t("chat.thinking")}</span></div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -189,7 +192,7 @@ export function Chat({
|
||||
<span key={a.id} className="inline-flex max-w-full items-center gap-1.5 rounded-full border border-accent-600/30 bg-accent-600/10 py-1 pl-3 pr-1.5 text-xs font-medium dark:border-accent-500/30 dark:bg-accent-500/10">
|
||||
{a.kind === "ocr" ? <ScanText size={13} /> : <FileText size={13} />}
|
||||
<span className="max-w-40 truncate">{a.name}</span>
|
||||
<button onClick={() => onRemoveAttachment(a.id)} aria-label={`Remove ${a.name}`} className="rounded-full p-1 transition hover:bg-black/10 dark:hover:bg-white/10">
|
||||
<button onClick={() => onRemoveAttachment(a.id)} aria-label={t("chat.removeAttach", { name: a.name })} className="rounded-full p-1 transition hover:bg-black/10 dark:hover:bg-white/10">
|
||||
<X size={13} />
|
||||
</button>
|
||||
</span>
|
||||
@@ -211,8 +214,8 @@ export function Chat({
|
||||
<ScanText size={16} />
|
||||
</span>
|
||||
<span>
|
||||
<span className="block text-sm font-medium">Transcribe image {ocrAvailable ? "" : "(unavailable)"}</span>
|
||||
<span className="block text-xs opacity-60">JPG, PNG, WEBP — OCR on the server, you review the text first</span>
|
||||
<span className="block text-sm font-medium">{t("chat.ocrOpt")} {ocrAvailable ? "" : t("chat.unavailable")}</span>
|
||||
<span className="block text-xs opacity-60">{t("chat.ocrHint")}</span>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
@@ -223,12 +226,12 @@ export function Chat({
|
||||
<FileText size={16} />
|
||||
</span>
|
||||
<span>
|
||||
<span className="block text-sm font-medium">Attach text file</span>
|
||||
<span className="block text-xs opacity-60">TXT or Markdown, max 500KB</span>
|
||||
<span className="block text-sm font-medium">{t("chat.textOpt")}</span>
|
||||
<span className="block text-xs opacity-60">{t("chat.textHint")}</span>
|
||||
</span>
|
||||
</button>
|
||||
<p className="px-3 pb-1.5 pt-2 text-xs opacity-60">
|
||||
Uploads go through platform tools only. Images are sent to the model as reviewed text — never as raw images.
|
||||
{t("chat.attachNote")}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
@@ -239,8 +242,8 @@ export function Chat({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAttachOpen((o) => !o)}
|
||||
aria-label="Attach a file"
|
||||
title="Attach a file"
|
||||
aria-label={t("chat.attachTip")}
|
||||
title={t("chat.attachTip")}
|
||||
className="grid h-10 w-10 shrink-0 -translate-y-[2px] place-items-center rounded-full transition hover:bg-stone-100 active:scale-95 dark:hover:bg-zinc-800"
|
||||
>
|
||||
<Paperclip size={17} className="opacity-70" />
|
||||
@@ -259,14 +262,14 @@ export function Chat({
|
||||
submit();
|
||||
}
|
||||
}}
|
||||
placeholder="Message KisAssistant…"
|
||||
placeholder={t("chat.placeholder")}
|
||||
className="max-h-50 flex-1 resize-none bg-transparent py-2.5 text-[15px] outline-none placeholder:text-stone-400 dark:placeholder:text-zinc-500"
|
||||
style={{ maxHeight: 200 }}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={(!draft.trim() && attachments.length === 0) || sending}
|
||||
aria-label="Send"
|
||||
aria-label={t("chat.send")}
|
||||
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"
|
||||
>
|
||||
<SendHorizontal size={17} />
|
||||
@@ -275,7 +278,7 @@ export function Chat({
|
||||
<p className="py-2 text-center text-xs opacity-50">
|
||||
<a href="/wiki" target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 underline-offset-2 hover:underline">
|
||||
<BookOpen size={12} />
|
||||
Wiki & docs
|
||||
{t("chat.wiki")}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -196,7 +196,7 @@ export function Sidebar({
|
||||
) : (
|
||||
<>
|
||||
{convs.length === 0 && (
|
||||
<p className="px-3 py-6 text-center text-sm opacity-50">No conversations yet.<br />Start a new chat above.</p>
|
||||
<p className="px-3 py-6 text-center text-sm opacity-50">{t("sidebar.emptyA")}<br />{t("sidebar.emptyB")}</p>
|
||||
)}
|
||||
{convs.map((c) => (
|
||||
<div
|
||||
@@ -243,7 +243,7 @@ export function Sidebar({
|
||||
)}
|
||||
</span>
|
||||
<button
|
||||
aria-label="Conversation options"
|
||||
aria-label={t("sidebar.convOptions")}
|
||||
onClick={(e) => openMenu(e, c)}
|
||||
className="rounded-full p-1 opacity-0 transition group-hover:opacity-60 hover:!opacity-100 hover:bg-stone-300/50 dark:hover:bg-zinc-700"
|
||||
>
|
||||
@@ -261,32 +261,32 @@ export function Sidebar({
|
||||
y={menu.y}
|
||||
onClose={() => setMenu(null)}
|
||||
items={[
|
||||
{ icon: Pencil, label: "Rename", onClick: () => onRename(menu.conv) },
|
||||
{ icon: Tag, label: "Set topic", onClick: () => onTopic(menu.conv) },
|
||||
{ icon: Share2, label: "Share publicly", onClick: () => onShare(menu.conv) },
|
||||
{ icon: Trash2, label: "Delete", danger: true, onClick: () => onDelete(menu.conv) },
|
||||
{ icon: Pencil, label: t("sidebar.rename"), onClick: () => onRename(menu.conv) },
|
||||
{ icon: Tag, label: t("sidebar.topic"), onClick: () => onTopic(menu.conv) },
|
||||
{ icon: Share2, label: t("sidebar.share"), onClick: () => onShare(menu.conv) },
|
||||
{ icon: Trash2, label: t("sidebar.delete"), danger: true, onClick: () => onDelete(menu.conv) },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
|
||||
<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="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">
|
||||
<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={t("sidebar.settingsTip")}>
|
||||
<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>
|
||||
<span className="block truncate text-xs opacity-60">@{user.username}{user.isAdmin ? " · admin" : ""}</span>
|
||||
<span className="block truncate text-xs opacity-60">@{user.username}{user.isAdmin ? ` · ${t("sidebar.admin")}` : ""}</span>
|
||||
</span>
|
||||
<Settings size={16} className="opacity-50" />
|
||||
</button>
|
||||
<div className="flex gap-1.5">
|
||||
{user.isAdmin && (
|
||||
<Button variant="secondary" size="sm" className="flex-1" onClick={onOpenAdmin} title="Open the Admin Center">
|
||||
<Button variant="secondary" size="sm" className="flex-1" onClick={onOpenAdmin} title={t("sidebar.adminCenterTip")}>
|
||||
<LayoutDashboard size={15} />
|
||||
Admin Center
|
||||
{t("sidebar.adminCenter")}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="ghost" size="sm" className={user.isAdmin ? "" : "flex-1"} onClick={onLogout}>
|
||||
Log out
|
||||
{t("sidebar.logout")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +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";
|
||||
import { LANGS, setLang, useT, type LangId } from "../lib/i18n.ts";
|
||||
|
||||
/* ---------- Button ---------- */
|
||||
|
||||
@@ -234,6 +234,21 @@ export function Picker({
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- Language picker (framework-styled dropdown over LANGS) ---------- */
|
||||
|
||||
export function LangPicker({ align = "right" }: { align?: "left" | "right" }) {
|
||||
const { t, lang } = useT();
|
||||
return (
|
||||
<Picker
|
||||
ariaLabel={t("settings.language")}
|
||||
value={lang}
|
||||
onChange={(v) => setLang(v as LangId)}
|
||||
align={align}
|
||||
options={LANGS.map((l) => ({ value: l.id, label: l.label }))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- Modal / Dialog ---------- */
|
||||
|
||||
export function Modal({
|
||||
|
||||
+504
-6
@@ -273,7 +273,7 @@ const en = {
|
||||
"center.lockedSub": "Log in with the admin account to open the Admin Center.",
|
||||
"center.regTitle": "Public registration",
|
||||
"center.regDesc":
|
||||
"Show the Register button on the login page. Off = admin-created accounts only.",
|
||||
"Show the Register button on the login page. Off = admin-created accounts only. Turning it on retires the wishlist below.",
|
||||
"center.accTitle": "Access requests (wishlist)",
|
||||
"center.accDescOn":
|
||||
"Unavailable while public registration is on — it replaces the wishlist.",
|
||||
@@ -318,6 +318,144 @@ const en = {
|
||||
"app.deleteConvTitle": "Delete conversation?",
|
||||
"app.deleteConvMsg":
|
||||
"“{title}” and all its messages will be permanently removed.",
|
||||
|
||||
"settings.language": "Language",
|
||||
"settings.languageHint":
|
||||
"Interface language. Applied instantly, saved on this device.",
|
||||
"settings.security": "Security",
|
||||
|
||||
"sec.accessKey": "Access key",
|
||||
"sec.accessKeyHint":
|
||||
"Rotate your access key. Old sessions are revoked immediately — you'll log back in with the new key.",
|
||||
"sec.rotate": "Rotate my access key",
|
||||
"sec.rotateConfirm": "Click again to confirm rotation",
|
||||
"sec.rotationFailed": "Rotation failed",
|
||||
"sec.totp": "Two-factor (TOTP)",
|
||||
"sec.totpChecking": "Checking…",
|
||||
"sec.totpAddHint":
|
||||
"Add a 6-digit code from your authenticator app on top of your access key.",
|
||||
"sec.totpEnable": "Enable two-factor",
|
||||
"sec.totpSecretHint":
|
||||
"Add this secret to your authenticator app (or open the otpauth link), then enter a code to confirm:",
|
||||
"sec.totpConfirm": "Confirm",
|
||||
"sec.totpDisableLabel": "Disable with a current code",
|
||||
"sec.totpDisable": "Disable 2FA",
|
||||
"sec.setupFailed": "Setup failed",
|
||||
"sec.passkeys": "Passkeys",
|
||||
"sec.passkeysHint":
|
||||
"WebAuthn / platform authenticators — needs a server RP setup.",
|
||||
"sec.danger": "Danger zone",
|
||||
"sec.dangerHint":
|
||||
"Permanently delete your account with all chats, shares and sessions. Cannot be undone.",
|
||||
"sec.deleteMe": "Delete my account",
|
||||
"sec.deleteFailed": "Delete failed",
|
||||
"sec.newKeyTitle": "New access key",
|
||||
"sec.newKeyHint":
|
||||
"Copy it now — it won't be shown again. Your old sessions are revoked.",
|
||||
"sec.newKeyDone": "Done — log me out",
|
||||
"sec.deleteTitle": "Delete your account?",
|
||||
"sec.deleteMsg":
|
||||
"This permanently removes your account with all conversations, shares and sessions. This cannot be undone.",
|
||||
"sec.deleteForever": "Delete forever",
|
||||
"sec.avatarTooBig": "File too large (max 5MB).",
|
||||
"sec.avatarType": "Only jpg, png or webp images are accepted.",
|
||||
|
||||
"center.tabAccess": "Access",
|
||||
|
||||
"center.smtpHost": "Host",
|
||||
"center.smtpPort": "Port",
|
||||
"center.smtpUser": "User",
|
||||
"center.smtpPass": "Password",
|
||||
"center.smtpFrom": "From",
|
||||
"center.smtpAppUrl": "App URL",
|
||||
"center.notSet": "(not set)",
|
||||
"center.noAuth": "(no auth)",
|
||||
"center.tlsImplicit": "implicit TLS (465)",
|
||||
"center.tlsStarttls": "STARTTLS (587)",
|
||||
"center.showPass": "Show SMTP password",
|
||||
"center.hidePass": "Hide SMTP password",
|
||||
"center.hiddenPass": "hidden password",
|
||||
"center.mailTestTitle": "Connectivity test",
|
||||
"center.mailTestDesc":
|
||||
"Verify the connection, then send a real test mail to an address you control.",
|
||||
"center.verifyConn": "Verify connection",
|
||||
"center.verifying": "Verifying…",
|
||||
"center.sendTest": "Send test mail",
|
||||
"center.sendingMail": "Sending…",
|
||||
"center.testToAria": "Test recipient email",
|
||||
"center.connOk": "Connection OK — SMTP host reachable, auth accepted.",
|
||||
"center.verifyFailed": "Verification failed",
|
||||
"center.testSent": "Test mail sent to {to}{tail}.",
|
||||
"center.mailSendFailed": "Send failed",
|
||||
"center.credsNote":
|
||||
"Credentials live in the server environment (see docs/OPERATIONS.md), read-only here. Users set their recovery address in profile settings or at account creation.",
|
||||
|
||||
"access.status.pending": "Pending",
|
||||
"access.status.reviewing": "Reviewing",
|
||||
"access.status.accepted": "Accepted",
|
||||
"access.status.refused": "Refused",
|
||||
"access.filter.all": "all",
|
||||
"access.filter.pending": "pending",
|
||||
"access.filter.reviewing": "reviewing",
|
||||
"access.filter.accepted": "accepted",
|
||||
"access.filter.refused": "refused",
|
||||
"access.refresh": "Refresh",
|
||||
"access.refreshAria": "Refresh requests",
|
||||
"access.empty": "No access requests. The wishlist is empty.",
|
||||
"access.emptyFiltered": "Nothing with this status.",
|
||||
"access.msgOne": "1 message",
|
||||
"access.msgMany": "{n} messages",
|
||||
"access.accountCreated": "Account created — key shown once, copy it now:",
|
||||
"access.replyPh": "Reply (emailed to the requester)…",
|
||||
"access.replyAria": "Reply to requester",
|
||||
"access.reasonLabel": "Reason (emailed with the decision)",
|
||||
"access.reasonPh": "Optional reason…",
|
||||
"access.btnReviewing": "Reviewing",
|
||||
"access.btnAccept": "Accept",
|
||||
"access.btnRefuse": "Refuse",
|
||||
"access.closedNote": "Ticket closed ({status}).",
|
||||
"access.reasonWord": "Reason",
|
||||
"access.youAdmin": "You (admin)",
|
||||
"access.sendFailed": "Send failed",
|
||||
"access.updateFailed": "Update failed",
|
||||
|
||||
"review.title": "Access request",
|
||||
"review.loadingTicket": "Loading ticket…",
|
||||
"review.notFound": "Ticket not found",
|
||||
"review.notFoundSub":
|
||||
"This link is unknown. Check the URL from your email, or file a new request from the login page.",
|
||||
"review.adminNote": "Admin note:",
|
||||
"review.acceptedHint":
|
||||
"Your account was created — check your email for the access key, then log in.",
|
||||
"review.closedHint":
|
||||
"This ticket is closed. Replies by email are not read — if needed, file a new request.",
|
||||
"review.replyPh": "Argue your case…",
|
||||
"review.replyAria": "Reply to your ticket",
|
||||
"review.mailHint":
|
||||
"Email notifications are one-way — always reply here, not by email.",
|
||||
"review.status.pending": "Pending review",
|
||||
"review.status.reviewing": "Under review",
|
||||
"review.status.accepted": "Accepted",
|
||||
"review.status.refused": "Refused",
|
||||
"review.admin": "Admin",
|
||||
"review.you": "You",
|
||||
"review.requestTitle": "Request access",
|
||||
"review.requestIntro":
|
||||
"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.",
|
||||
"review.emailHint":
|
||||
"Required — status updates and ticket replies land here.",
|
||||
"review.whyLabel": "Why should you get access?",
|
||||
"review.whyHint": "10+ characters. This starts your ticket thread.",
|
||||
"review.whyPh": "I want to…",
|
||||
"review.sendRequest": "Send request",
|
||||
"review.received": "Request received",
|
||||
"review.reserved":
|
||||
"@{user} is reserved for you while an admin reviews it. A confirmation email with your personal ticket link is on its way.",
|
||||
"review.copyLink": "Copy link",
|
||||
"review.openTicket": "Open my ticket",
|
||||
|
||||
"admin.editUserTitle": "Edit @{user}",
|
||||
"admin.avatarUrl": "Avatar URL",
|
||||
} as const;
|
||||
|
||||
export type StringKey = keyof typeof en;
|
||||
@@ -575,7 +713,7 @@ const es: Dict = {
|
||||
"center.lockedSub": "Inicia sesión como admin para abrir el Centro de admin.",
|
||||
"center.regTitle": "Registro público",
|
||||
"center.regDesc":
|
||||
"Muestra el botón Registrarse en el login. Off = solo cuentas del admin.",
|
||||
"Muestra el botón Registrarse en el login. Off = solo cuentas del admin. Activarlo jubila la lista de espera.",
|
||||
"center.accTitle": "Solicitudes de acceso (lista)",
|
||||
"center.accDescOn": "No disponible con registro público — lo reemplaza.",
|
||||
"center.accDescOff":
|
||||
@@ -618,6 +756,144 @@ const es: Dict = {
|
||||
|
||||
"app.deleteConvTitle": "¿Eliminar conversación?",
|
||||
"app.deleteConvMsg": "«{title}» y sus mensajes se eliminarán.",
|
||||
|
||||
"settings.language": "Idioma",
|
||||
"settings.languageHint":
|
||||
"Idioma de la interfaz. Al instante, en este dispositivo.",
|
||||
"settings.security": "Seguridad",
|
||||
|
||||
"sec.accessKey": "Clave de acceso",
|
||||
"sec.accessKeyHint":
|
||||
"Rota tu clave de acceso. Las sesiones antiguas se revocan al instante — volverás con la nueva.",
|
||||
"sec.rotate": "Rotar mi clave",
|
||||
"sec.rotateConfirm": "Pulsa de nuevo para confirmar",
|
||||
"sec.rotationFailed": "No se pudo rotar",
|
||||
"sec.totp": "Doble factor (TOTP)",
|
||||
"sec.totpChecking": "Verificando…",
|
||||
"sec.totpAddHint":
|
||||
"Añade un código de 6 dígitos de tu app además de tu clave.",
|
||||
"sec.totpEnable": "Activar doble factor",
|
||||
"sec.totpSecretHint":
|
||||
"Añade este secreto a tu app (o abre el enlace otpauth) y escribe un código para confirmar:",
|
||||
"sec.totpConfirm": "Confirmar",
|
||||
"sec.totpDisableLabel": "Desactivar con un código actual",
|
||||
"sec.totpDisable": "Desactivar 2FA",
|
||||
"sec.setupFailed": "No se pudo configurar",
|
||||
"sec.passkeys": "Passkeys",
|
||||
"sec.passkeysHint":
|
||||
"WebAuthn / autenticadores del sistema — requiere RP en el servidor.",
|
||||
"sec.danger": "Zona de peligro",
|
||||
"sec.dangerHint":
|
||||
"Elimina tu cuenta con chats, shares y sesiones. Sin vuelta atrás.",
|
||||
"sec.deleteMe": "Eliminar mi cuenta",
|
||||
"sec.deleteFailed": "No se pudo eliminar",
|
||||
"sec.newKeyTitle": "Nueva clave de acceso",
|
||||
"sec.newKeyHint":
|
||||
"Cópiala ahora — no se mostrará de nuevo. Tus sesiones se revocan.",
|
||||
"sec.newKeyDone": "Listo — cerrar sesión",
|
||||
"sec.deleteTitle": "¿Eliminar tu cuenta?",
|
||||
"sec.deleteMsg":
|
||||
"Esto elimina tu cuenta con conversaciones, shares y sesiones. Sin vuelta atrás.",
|
||||
"sec.deleteForever": "Eliminar para siempre",
|
||||
"sec.avatarTooBig": "Archivo muy grande (máx. 5MB).",
|
||||
"sec.avatarType": "Solo se aceptan imágenes jpg, png o webp.",
|
||||
|
||||
"center.tabAccess": "Acceso",
|
||||
|
||||
"center.smtpHost": "Servidor",
|
||||
"center.smtpPort": "Puerto",
|
||||
"center.smtpUser": "Usuario",
|
||||
"center.smtpPass": "Contraseña",
|
||||
"center.smtpFrom": "Remitente",
|
||||
"center.smtpAppUrl": "URL de la app",
|
||||
"center.notSet": "(sin definir)",
|
||||
"center.noAuth": "(sin auth)",
|
||||
"center.tlsImplicit": "TLS implícito (465)",
|
||||
"center.tlsStarttls": "STARTTLS (587)",
|
||||
"center.showPass": "Mostrar contraseña SMTP",
|
||||
"center.hidePass": "Ocultar contraseña SMTP",
|
||||
"center.hiddenPass": "contraseña oculta",
|
||||
"center.mailTestTitle": "Prueba de conexión",
|
||||
"center.mailTestDesc":
|
||||
"Verifica la conexión y envía un email real a una dirección tuya.",
|
||||
"center.verifyConn": "Verificar conexión",
|
||||
"center.verifying": "Verificando…",
|
||||
"center.sendTest": "Enviar email de prueba",
|
||||
"center.sendingMail": "Enviando…",
|
||||
"center.testToAria": "Email de prueba",
|
||||
"center.connOk": "Conexión OK — SMTP accesible, auth aceptada.",
|
||||
"center.verifyFailed": "Verificación fallida",
|
||||
"center.testSent": "Email enviado a {to}{tail}.",
|
||||
"center.mailSendFailed": "No se pudo enviar",
|
||||
"center.credsNote":
|
||||
"Las credenciales viven en el servidor (ver docs/OPERATIONS.md), solo lectura aquí. El email se define en el perfil o al crear la cuenta.",
|
||||
|
||||
"access.status.pending": "Pendiente",
|
||||
"access.status.reviewing": "En revisión",
|
||||
"access.status.accepted": "Aceptada",
|
||||
"access.status.refused": "Rechazada",
|
||||
"access.filter.all": "todas",
|
||||
"access.filter.pending": "pendientes",
|
||||
"access.filter.reviewing": "en revisión",
|
||||
"access.filter.accepted": "aceptadas",
|
||||
"access.filter.refused": "rechazadas",
|
||||
"access.refresh": "Actualizar",
|
||||
"access.refreshAria": "Actualizar solicitudes",
|
||||
"access.empty": "Sin solicitudes. La lista está vacía.",
|
||||
"access.emptyFiltered": "Nada con este estado.",
|
||||
"access.msgOne": "1 mensaje",
|
||||
"access.msgMany": "{n} mensajes",
|
||||
"access.accountCreated": "Cuenta creada — clave única, cópiala ahora:",
|
||||
"access.replyPh": "Responder (llega por email)…",
|
||||
"access.replyAria": "Responder al solicitante",
|
||||
"access.reasonLabel": "Motivo (se envía con la decisión)",
|
||||
"access.reasonPh": "Motivo opcional…",
|
||||
"access.btnReviewing": "Revisar",
|
||||
"access.btnAccept": "Aceptar",
|
||||
"access.btnRefuse": "Rechazar",
|
||||
"access.closedNote": "Ticket cerrado ({status}).",
|
||||
"access.reasonWord": "Motivo",
|
||||
"access.youAdmin": "Tú (admin)",
|
||||
"access.sendFailed": "No se pudo enviar",
|
||||
"access.updateFailed": "No se pudo actualizar",
|
||||
|
||||
"review.title": "Solicitud de acceso",
|
||||
"review.loadingTicket": "Cargando ticket…",
|
||||
"review.notFound": "Ticket no encontrado",
|
||||
"review.notFoundSub":
|
||||
"Este enlace no existe. Revisa la URL de tu email o pide acceso desde el login.",
|
||||
"review.adminNote": "Nota del admin:",
|
||||
"review.acceptedHint":
|
||||
"Tu cuenta fue creada — revisa tu email con la clave e inicia sesión.",
|
||||
"review.closedHint":
|
||||
"Ticket cerrado. Los emails no se leen — si hace falta, pide acceso de nuevo.",
|
||||
"review.replyPh": "Defiende tu caso…",
|
||||
"review.replyAria": "Responder a tu ticket",
|
||||
"review.mailHint":
|
||||
"Los emails son unidireccionales — responde aquí, no por email.",
|
||||
"review.status.pending": "Pendiente de revisión",
|
||||
"review.status.reviewing": "En revisión",
|
||||
"review.status.accepted": "Aceptada",
|
||||
"review.status.refused": "Rechazada",
|
||||
"review.admin": "Admin",
|
||||
"review.you": "Tú",
|
||||
"review.requestTitle": "Solicitar acceso",
|
||||
"review.requestIntro":
|
||||
"Reserva un usuario y cuéntanos por qué quieres entrar. Un admin revisa cada solicitud — sigues el hilo en tu ticket y por email.",
|
||||
"review.emailHint":
|
||||
"Obligatorio — avisos y respuestas llegan aquí.",
|
||||
"review.whyLabel": "¿Por qué darte acceso?",
|
||||
"review.whyHint": "10+ caracteres. Esto abre tu ticket.",
|
||||
"review.whyPh": "Quiero…",
|
||||
"review.sendRequest": "Enviar solicitud",
|
||||
"review.received": "Solicitud recibida",
|
||||
"review.reserved":
|
||||
"@{user} queda reservado mientras un admin lo revisa. Un email con tu ticket va en camino.",
|
||||
"review.copyLink": "Copiar enlace",
|
||||
"review.openTicket": "Abrir mi ticket",
|
||||
|
||||
"admin.editUserTitle": "Editar @{user}",
|
||||
"admin.avatarUrl": "URL del avatar",
|
||||
};
|
||||
|
||||
const fr: Dict = {
|
||||
@@ -873,7 +1149,7 @@ const fr: Dict = {
|
||||
"center.lockedSub": "Connectez-vous en admin pour ouvrir le Centre admin.",
|
||||
"center.regTitle": "Inscription publique",
|
||||
"center.regDesc":
|
||||
"Affiche le bouton S'inscrire au login. Off = comptes admin uniquement.",
|
||||
"Affiche le bouton S'inscrire au login. Off = comptes admin uniquement. L'activer retire la liste d'attente ci-dessous.",
|
||||
"center.accTitle": "Demandes d'accès (liste)",
|
||||
"center.accDescOn": "Indispo avec l'inscription publique — elle la remplace.",
|
||||
"center.accDescOff":
|
||||
@@ -915,6 +1191,144 @@ const fr: Dict = {
|
||||
|
||||
"app.deleteConvTitle": "Supprimer la conversation ?",
|
||||
"app.deleteConvMsg": "« {title} » et ses messages seront supprimés.",
|
||||
|
||||
"settings.language": "Langue",
|
||||
"settings.languageHint":
|
||||
"Langue de l'interface. Aussitôt, sur cet appareil.",
|
||||
"settings.security": "Sécurité",
|
||||
|
||||
"sec.accessKey": "Clé d'accès",
|
||||
"sec.accessKeyHint":
|
||||
"Faites tourner votre clé. Les anciennes sessions sont révoquées aussitôt — reconnectez-vous avec la neuve.",
|
||||
"sec.rotate": "Faire tourner ma clé",
|
||||
"sec.rotateConfirm": "Recliquez pour confirmer",
|
||||
"sec.rotationFailed": "Rotation impossible",
|
||||
"sec.totp": "Double facteur (TOTP)",
|
||||
"sec.totpChecking": "Vérification…",
|
||||
"sec.totpAddHint":
|
||||
"Ajoutez un code à 6 chiffres de votre app en plus de votre clé.",
|
||||
"sec.totpEnable": "Activer le double facteur",
|
||||
"sec.totpSecretHint":
|
||||
"Ajoutez ce secret à votre app (ou ouvrez le lien otpauth), puis entrez un code :",
|
||||
"sec.totpConfirm": "Confirmer",
|
||||
"sec.totpDisableLabel": "Désactiver avec un code actuel",
|
||||
"sec.totpDisable": "Désactiver 2FA",
|
||||
"sec.setupFailed": "Configuration impossible",
|
||||
"sec.passkeys": "Passkeys",
|
||||
"sec.passkeysHint":
|
||||
"WebAuthn / authentificateurs système — requiert un RP serveur.",
|
||||
"sec.danger": "Zone dangereuse",
|
||||
"sec.dangerHint":
|
||||
"Supprime votre compte avec chats, partages et sessions. Irréversible.",
|
||||
"sec.deleteMe": "Supprimer mon compte",
|
||||
"sec.deleteFailed": "Suppression impossible",
|
||||
"sec.newKeyTitle": "Nouvelle clé d'accès",
|
||||
"sec.newKeyHint":
|
||||
"Copiez-la vite — plus affichée ensuite. Vos sessions sont révoquées.",
|
||||
"sec.newKeyDone": "OK — me déconnecter",
|
||||
"sec.deleteTitle": "Supprimer votre compte ?",
|
||||
"sec.deleteMsg":
|
||||
"Supprime définitivement votre compte avec conversations, partages et sessions. Irréversible.",
|
||||
"sec.deleteForever": "Supprimer pour toujours",
|
||||
"sec.avatarTooBig": "Fichier trop lourd (5 Mo max).",
|
||||
"sec.avatarType": "Seules les images jpg, png ou webp sont acceptées.",
|
||||
|
||||
"center.tabAccess": "Accès",
|
||||
|
||||
"center.smtpHost": "Serveur",
|
||||
"center.smtpPort": "Port",
|
||||
"center.smtpUser": "Utilisateur",
|
||||
"center.smtpPass": "Mot de passe",
|
||||
"center.smtpFrom": "Expéditeur",
|
||||
"center.smtpAppUrl": "URL de l'app",
|
||||
"center.notSet": "(non défini)",
|
||||
"center.noAuth": "(sans auth)",
|
||||
"center.tlsImplicit": "TLS implicite (465)",
|
||||
"center.tlsStarttls": "STARTTLS (587)",
|
||||
"center.showPass": "Afficher le mot de passe SMTP",
|
||||
"center.hidePass": "Masquer le mot de passe SMTP",
|
||||
"center.hiddenPass": "mot de passe masqué",
|
||||
"center.mailTestTitle": "Test de connexion",
|
||||
"center.mailTestDesc":
|
||||
"Vérifiez la connexion, puis envoyez un vrai mail de test à une adresse à vous.",
|
||||
"center.verifyConn": "Vérifier la connexion",
|
||||
"center.verifying": "Vérification…",
|
||||
"center.sendTest": "Envoyer un mail de test",
|
||||
"center.sendingMail": "Envoi…",
|
||||
"center.testToAria": "Email de test",
|
||||
"center.connOk": "Connexion OK — hôte SMTP joignable, auth acceptée.",
|
||||
"center.verifyFailed": "Vérification échouée",
|
||||
"center.testSent": "Mail de test envoyé à {to}{tail}.",
|
||||
"center.mailSendFailed": "Envoi impossible",
|
||||
"center.credsNote":
|
||||
"Les identifiants vivent sur le serveur (voir docs/OPERATIONS.md), lecture seule ici. L'email se règle dans le profil ou à la création.",
|
||||
|
||||
"access.status.pending": "En attente",
|
||||
"access.status.reviewing": "En examen",
|
||||
"access.status.accepted": "Acceptée",
|
||||
"access.status.refused": "Refusée",
|
||||
"access.filter.all": "tous",
|
||||
"access.filter.pending": "en attente",
|
||||
"access.filter.reviewing": "en examen",
|
||||
"access.filter.accepted": "acceptés",
|
||||
"access.filter.refused": "refusés",
|
||||
"access.refresh": "Actualiser",
|
||||
"access.refreshAria": "Actualiser les demandes",
|
||||
"access.empty": "Aucune demande. La liste est vide.",
|
||||
"access.emptyFiltered": "Rien avec ce statut.",
|
||||
"access.msgOne": "1 message",
|
||||
"access.msgMany": "{n} messages",
|
||||
"access.accountCreated": "Compte créé — clé unique, copiez-la :",
|
||||
"access.replyPh": "Répondre (envoyé par email)…",
|
||||
"access.replyAria": "Répondre au demandeur",
|
||||
"access.reasonLabel": "Motif (envoyé avec la décision)",
|
||||
"access.reasonPh": "Motif optionnel…",
|
||||
"access.btnReviewing": "Examiner",
|
||||
"access.btnAccept": "Accepter",
|
||||
"access.btnRefuse": "Refuser",
|
||||
"access.closedNote": "Ticket fermé ({status}).",
|
||||
"access.reasonWord": "Motif",
|
||||
"access.youAdmin": "Vous (admin)",
|
||||
"access.sendFailed": "Envoi impossible",
|
||||
"access.updateFailed": "Mise à jour impossible",
|
||||
|
||||
"review.title": "Demande d'accès",
|
||||
"review.loadingTicket": "Chargement du ticket…",
|
||||
"review.notFound": "Ticket introuvable",
|
||||
"review.notFoundSub":
|
||||
"Ce lien est inconnu. Vérifiez l'URL de votre email, ou refaites une demande depuis le login.",
|
||||
"review.adminNote": "Note admin :",
|
||||
"review.acceptedHint":
|
||||
"Votre compte est créé — vérifiez votre email avec la clé, puis connectez-vous.",
|
||||
"review.closedHint":
|
||||
"Ticket fermé. Les emails ne sont pas lus — si besoin, refaites une demande.",
|
||||
"review.replyPh": "Plaidez votre cas…",
|
||||
"review.replyAria": "Répondre à votre ticket",
|
||||
"review.mailHint":
|
||||
"Les emails sont à sens unique — répondez ici, pas par email.",
|
||||
"review.status.pending": "En attente d'examen",
|
||||
"review.status.reviewing": "En cours d'examen",
|
||||
"review.status.accepted": "Acceptée",
|
||||
"review.status.refused": "Refusée",
|
||||
"review.admin": "Admin",
|
||||
"review.you": "Vous",
|
||||
"review.requestTitle": "Demander l'accès",
|
||||
"review.requestIntro":
|
||||
"Réservez un pseudo et dites pourquoi vous voulez entrer. Un admin examine chaque demande — suivez le fil sur votre ticket et par email.",
|
||||
"review.emailHint":
|
||||
"Requis — avis et réponses arrivent ici.",
|
||||
"review.whyLabel": "Pourquoi vous donner l'accès ?",
|
||||
"review.whyHint": "10+ caractères. Ceci ouvre votre ticket.",
|
||||
"review.whyPh": "Je veux…",
|
||||
"review.sendRequest": "Envoyer la demande",
|
||||
"review.received": "Demande reçue",
|
||||
"review.reserved":
|
||||
"@{user} est réservé pendant l'examen admin. Un email avec votre ticket est en route.",
|
||||
"review.copyLink": "Copier le lien",
|
||||
"review.openTicket": "Ouvrir mon ticket",
|
||||
|
||||
"admin.editUserTitle": "Modifier @{user}",
|
||||
"admin.avatarUrl": "URL de l'avatar",
|
||||
};
|
||||
|
||||
const ru: Dict = {
|
||||
@@ -1161,7 +1575,7 @@ const ru: Dict = {
|
||||
"center.locked": "Только админы",
|
||||
"center.lockedSub": "Войдите как админ, чтобы открыть центр.",
|
||||
"center.regTitle": "Открытая регистрация",
|
||||
"center.regDesc": "Кнопка регистрации на входе. Выкл = только админ создаёт.",
|
||||
"center.regDesc": "Кнопка регистрации на входе. Выкл = только админ создаёт. Включение закрывает лист ожидания ниже.",
|
||||
"center.accTitle": "Заявки (лист ожидания)",
|
||||
"center.accDescOn": "Недоступно при открытой регистрации — она заменяет.",
|
||||
"center.accDescOff":
|
||||
@@ -1244,6 +1658,34 @@ const ru: Dict = {
|
||||
|
||||
"center.tabAccess": "Доступ",
|
||||
|
||||
"center.smtpHost": "Хост",
|
||||
"center.smtpPort": "Порт",
|
||||
"center.smtpUser": "Пользователь",
|
||||
"center.smtpPass": "Пароль",
|
||||
"center.smtpFrom": "Отправитель",
|
||||
"center.smtpAppUrl": "URL приложения",
|
||||
"center.notSet": "(не задано)",
|
||||
"center.noAuth": "(без auth)",
|
||||
"center.tlsImplicit": "неявный TLS (465)",
|
||||
"center.tlsStarttls": "STARTTLS (587)",
|
||||
"center.showPass": "Показать пароль SMTP",
|
||||
"center.hidePass": "Скрыть пароль SMTP",
|
||||
"center.hiddenPass": "пароль скрыт",
|
||||
"center.mailTestTitle": "Проверка связи",
|
||||
"center.mailTestDesc":
|
||||
"Проверьте соединение и отправьте тестовое письмо на свой адрес.",
|
||||
"center.verifyConn": "Проверить соединение",
|
||||
"center.verifying": "Проверяем…",
|
||||
"center.sendTest": "Отправить тест",
|
||||
"center.sendingMail": "Отправляем…",
|
||||
"center.testToAria": "Адрес для теста",
|
||||
"center.connOk": "Связь OK — SMTP доступен, auth принят.",
|
||||
"center.verifyFailed": "Проверка не удалась",
|
||||
"center.testSent": "Тест отправлен на {to}{tail}.",
|
||||
"center.mailSendFailed": "Не отправилось",
|
||||
"center.credsNote":
|
||||
"Доступы живут в окружении сервера (см. docs/OPERATIONS.md), здесь только чтение. Email задаётся в профиле или при создании.",
|
||||
|
||||
"access.status.pending": "Ожидает",
|
||||
"access.status.reviewing": "На проверке",
|
||||
"access.status.accepted": "Принята",
|
||||
@@ -1558,7 +2000,7 @@ const it: Dict = {
|
||||
"center.locked": "Solo admin",
|
||||
"center.lockedSub": "Accedi come admin per aprire il Centro.",
|
||||
"center.regTitle": "Registrazione pubblica",
|
||||
"center.regDesc": "Mostra Registrati al login. Off = solo account admin.",
|
||||
"center.regDesc": "Mostra Registrati al login. Off = solo account admin. Attivarlo ritira la wishlist qui sotto.",
|
||||
"center.accTitle": "Richieste di accesso (lista)",
|
||||
"center.accDescOn":
|
||||
"Non disponibile con registrazione pubblica — la sostituisce.",
|
||||
@@ -1644,6 +2086,34 @@ const it: Dict = {
|
||||
|
||||
"center.tabAccess": "Accesso",
|
||||
|
||||
"center.smtpHost": "Host",
|
||||
"center.smtpPort": "Porta",
|
||||
"center.smtpUser": "Utente",
|
||||
"center.smtpPass": "Password",
|
||||
"center.smtpFrom": "Mittente",
|
||||
"center.smtpAppUrl": "URL dell'app",
|
||||
"center.notSet": "(non impostato)",
|
||||
"center.noAuth": "(senza auth)",
|
||||
"center.tlsImplicit": "TLS implicito (465)",
|
||||
"center.tlsStarttls": "STARTTLS (587)",
|
||||
"center.showPass": "Mostra password SMTP",
|
||||
"center.hidePass": "Nascondi password SMTP",
|
||||
"center.hiddenPass": "password nascosta",
|
||||
"center.mailTestTitle": "Test di connessione",
|
||||
"center.mailTestDesc":
|
||||
"Verifica la connessione e invia una vera mail di test a un tuo indirizzo.",
|
||||
"center.verifyConn": "Verifica connessione",
|
||||
"center.verifying": "Verifica…",
|
||||
"center.sendTest": "Invia mail di test",
|
||||
"center.sendingMail": "Invio…",
|
||||
"center.testToAria": "Email di test",
|
||||
"center.connOk": "Connessione OK — host SMTP raggiungibile, auth accettata.",
|
||||
"center.verifyFailed": "Verifica fallita",
|
||||
"center.testSent": "Mail di test inviata a {to}{tail}.",
|
||||
"center.mailSendFailed": "Invio fallito",
|
||||
"center.credsNote":
|
||||
"Le credenziali vivono sul server (vedi docs/OPERATIONS.md), qui sola lettura. L'email si imposta nel profilo o alla creazione.",
|
||||
|
||||
"access.status.pending": "In attesa",
|
||||
"access.status.reviewing": "In revisione",
|
||||
"access.status.accepted": "Accettata",
|
||||
@@ -1953,7 +2423,7 @@ const pt: Dict = {
|
||||
"center.locked": "Só admins",
|
||||
"center.lockedSub": "Entra como admin para abrir o Centro.",
|
||||
"center.regTitle": "Registo público",
|
||||
"center.regDesc": "Mostra Registar no login. Off = só contas do admin.",
|
||||
"center.regDesc": "Mostra Registar no login. Off = só contas do admin. Ligar aposenta a lista abaixo.",
|
||||
"center.accTitle": "Pedidos de acesso (lista)",
|
||||
"center.accDescOn": "Indisponível com registo público — substitui.",
|
||||
"center.accDescOff": "Mostra Pedir acesso no login. Triagem na aba Acesso.",
|
||||
@@ -2035,6 +2505,34 @@ const pt: Dict = {
|
||||
|
||||
"center.tabAccess": "Acesso",
|
||||
|
||||
"center.smtpHost": "Servidor",
|
||||
"center.smtpPort": "Porta",
|
||||
"center.smtpUser": "Utilizador",
|
||||
"center.smtpPass": "Palavra-passe",
|
||||
"center.smtpFrom": "Remetente",
|
||||
"center.smtpAppUrl": "URL da app",
|
||||
"center.notSet": "(não definido)",
|
||||
"center.noAuth": "(sem auth)",
|
||||
"center.tlsImplicit": "TLS implícito (465)",
|
||||
"center.tlsStarttls": "STARTTLS (587)",
|
||||
"center.showPass": "Mostrar palavra-passe SMTP",
|
||||
"center.hidePass": "Ocultar palavra-passe SMTP",
|
||||
"center.hiddenPass": "palavra-passe oculta",
|
||||
"center.mailTestTitle": "Teste de ligação",
|
||||
"center.mailTestDesc":
|
||||
"Verifica a ligação e envia um mail real para um endereço teu.",
|
||||
"center.verifyConn": "Verificar ligação",
|
||||
"center.verifying": "A verificar…",
|
||||
"center.sendTest": "Enviar mail de teste",
|
||||
"center.sendingMail": "A enviar…",
|
||||
"center.testToAria": "Email de teste",
|
||||
"center.connOk": "Ligação OK — SMTP acessível, auth aceite.",
|
||||
"center.verifyFailed": "Verificação falhou",
|
||||
"center.testSent": "Mail de teste enviado para {to}{tail}.",
|
||||
"center.mailSendFailed": "Falha ao enviar",
|
||||
"center.credsNote":
|
||||
"Credenciais vivem no servidor (ver docs/OPERATIONS.md), só leitura aqui. Email define-se no perfil ou criação.",
|
||||
|
||||
"access.status.pending": "Pendente",
|
||||
"access.status.reviewing": "Em revisão",
|
||||
"access.status.accepted": "Aceite",
|
||||
|
||||
Reference in New Issue
Block a user