,
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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("");
|
||||
|
||||
@@ -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