This commit is contained in:
k
2026-09-21 16:48:59 +02:00
parent 70c21698e8
commit 245d26677b
9 changed files with 586 additions and 60 deletions
+2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+14 -12
View File
@@ -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("");
+24 -21
View File
@@ -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>
+11 -11
View File
@@ -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>
+16 -1
View File
@@ -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
View File
@@ -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",