mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-10-20 06:28:19 +02:00
feat: bulk edit profiles
This commit is contained in:
1 parent
47664e6212
commit
3f5b573ead
25 files changed
+1389
-23
No files matched your search
@@ -0,0 +1,414 @@
|
||||
"use client";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DnsServerField } from "@/components/dns-server-field";
|
||||
import { ProfileStatusChip } from "@/components/profile-statuses-dialog";
|
||||
import { AnimatedSwitch } from "@/components/ui/animated-switch";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { translateBackendError } from "@/lib/backend-errors";
|
||||
import { DNS_BLOCKLIST_LEVELS } from "@/lib/dns-blocklist-levels";
|
||||
import { startPagesFromText } from "@/lib/start-pages";
|
||||
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ProfileStatus } from "@/types";
|
||||
|
||||
type Field =
|
||||
| "status"
|
||||
| "tagsAdd"
|
||||
| "tagsRemove"
|
||||
| "note"
|
||||
| "dnsBlocklist"
|
||||
| "dnsServer"
|
||||
| "startPages"
|
||||
| "clearOnClose"
|
||||
| "webrtc"
|
||||
| "blockImages"
|
||||
| "blockWebgl"
|
||||
| "randomize"
|
||||
| "restoreSession"
|
||||
| "newDevice";
|
||||
|
||||
const NONE = "__none__";
|
||||
|
||||
function tagsFrom(text: string): string[] {
|
||||
return text
|
||||
.split(",")
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function Row({
|
||||
field,
|
||||
label,
|
||||
enabled,
|
||||
onToggle,
|
||||
children,
|
||||
}: {
|
||||
field: Field;
|
||||
label: string;
|
||||
enabled: boolean;
|
||||
onToggle: (field: Field, on: boolean) => void;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
const id = `bulk-edit-${field}`;
|
||||
return (
|
||||
<div
|
||||
className="flex items-start gap-3 py-1.5"
|
||||
data-slot="bulk-edit-row"
|
||||
data-field={field}
|
||||
>
|
||||
<Checkbox
|
||||
id={id}
|
||||
checked={enabled}
|
||||
onCheckedChange={(checked) => onToggle(field, checked === true)}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||
<label
|
||||
htmlFor={id}
|
||||
className={cn(
|
||||
"text-sm",
|
||||
enabled ? "text-foreground" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
{enabled && children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OnOff({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: boolean;
|
||||
onChange: (value: boolean) => void;
|
||||
}) {
|
||||
return <AnimatedSwitch checked={value} onCheckedChange={onChange} />;
|
||||
}
|
||||
|
||||
export function BulkEditDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
profileIds,
|
||||
statuses,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
profileIds: string[];
|
||||
statuses: ProfileStatus[];
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [enabled, setEnabled] = React.useState<Set<Field>>(new Set());
|
||||
const [statusId, setStatusId] = React.useState<string>(NONE);
|
||||
const [tagsAdd, setTagsAdd] = React.useState("");
|
||||
const [tagsRemove, setTagsRemove] = React.useState("");
|
||||
const [note, setNote] = React.useState("");
|
||||
const [dnsBlocklist, setDnsBlocklist] = React.useState<string>(NONE);
|
||||
const [dnsServer, setDnsServer] = React.useState("");
|
||||
const [dnsServerValid, setDnsServerValid] = React.useState(true);
|
||||
const [startPages, setStartPages] = React.useState("");
|
||||
const [clearOnClose, setClearOnClose] = React.useState(false);
|
||||
const [webrtc, setWebrtc] = React.useState("auto");
|
||||
const [blockImages, setBlockImages] = React.useState(false);
|
||||
const [blockWebgl, setBlockWebgl] = React.useState(false);
|
||||
const [randomize, setRandomize] = React.useState(false);
|
||||
const [restoreSession, setRestoreSession] = React.useState(true);
|
||||
const [isSaving, setIsSaving] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
setEnabled(new Set());
|
||||
setStatusId(NONE);
|
||||
setTagsAdd("");
|
||||
setTagsRemove("");
|
||||
setNote("");
|
||||
setDnsBlocklist(NONE);
|
||||
setDnsServer("");
|
||||
setStartPages("");
|
||||
setClearOnClose(false);
|
||||
setWebrtc("auto");
|
||||
setBlockImages(false);
|
||||
setBlockWebgl(false);
|
||||
setRandomize(false);
|
||||
setRestoreSession(true);
|
||||
}, [isOpen]);
|
||||
|
||||
const toggle = (field: Field, on: boolean) => {
|
||||
setEnabled((current) => {
|
||||
const next = new Set(current);
|
||||
if (on) next.add(field);
|
||||
else next.delete(field);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
const has = (field: Field) => enabled.has(field);
|
||||
|
||||
const buildPatch = () => {
|
||||
const patch: Record<string, unknown> = {};
|
||||
if (has("status")) patch.status_id = statusId === NONE ? null : statusId;
|
||||
if (has("tagsAdd")) patch.tags_add = tagsFrom(tagsAdd);
|
||||
if (has("tagsRemove")) patch.tags_remove = tagsFrom(tagsRemove);
|
||||
if (has("note")) patch.note = note.trim() || null;
|
||||
if (has("dnsBlocklist"))
|
||||
patch.dns_blocklist = dnsBlocklist === NONE ? null : dnsBlocklist;
|
||||
if (has("dnsServer")) patch.dns_server = dnsServer.trim() || null;
|
||||
if (has("startPages")) patch.start_urls = startPagesFromText(startPages);
|
||||
if (has("clearOnClose")) patch.clear_on_close = clearOnClose;
|
||||
if (has("webrtc")) patch.webrtc_mode = webrtc;
|
||||
if (has("blockImages")) patch.block_images = blockImages;
|
||||
if (has("blockWebgl")) patch.block_webgl = blockWebgl;
|
||||
if (has("randomize")) patch.randomize_fingerprint_on_launch = randomize;
|
||||
if (has("restoreSession")) patch.restore_session = restoreSession;
|
||||
if (has("newDevice")) patch.new_device = true;
|
||||
return patch;
|
||||
};
|
||||
|
||||
const apply = async () => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const changed = await invoke<number>("bulk_update_profiles", {
|
||||
profileIds,
|
||||
patch: buildPatch(),
|
||||
});
|
||||
showSuccessToast(t("bulkEdit.applied", { count: changed }));
|
||||
onClose();
|
||||
} catch (error) {
|
||||
showErrorToast(translateBackendError(t, error));
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const blocked = has("dnsServer") && !dnsServerValid;
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent
|
||||
className="flex max-h-[85vh] max-w-lg flex-col"
|
||||
data-slot="bulk-edit-dialog"
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("bulkEdit.title")}</DialogTitle>
|
||||
<DialogDescription>{t("bulkEdit.description")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="-mx-1 min-h-0 flex-1 divide-y divide-border/50 overflow-y-auto px-1">
|
||||
<Row
|
||||
field="status"
|
||||
label={t("profileStatuses.column")}
|
||||
enabled={has("status")}
|
||||
onToggle={toggle}
|
||||
>
|
||||
<Select value={statusId} onValueChange={setStatusId}>
|
||||
<SelectTrigger variant="soft" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NONE}>
|
||||
{t("profileStatuses.none")}
|
||||
</SelectItem>
|
||||
{statuses.map((status) => (
|
||||
<SelectItem key={status.id} value={status.id}>
|
||||
<ProfileStatusChip status={status} />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Row>
|
||||
<Row
|
||||
field="tagsAdd"
|
||||
label={t("bulkEdit.tagsAdd")}
|
||||
enabled={has("tagsAdd")}
|
||||
onToggle={toggle}
|
||||
>
|
||||
<Input
|
||||
variant="soft"
|
||||
value={tagsAdd}
|
||||
onChange={(event) => setTagsAdd(event.target.value)}
|
||||
placeholder={t("bulkEdit.tagsPlaceholder")}
|
||||
/>
|
||||
</Row>
|
||||
<Row
|
||||
field="tagsRemove"
|
||||
label={t("bulkEdit.tagsRemove")}
|
||||
enabled={has("tagsRemove")}
|
||||
onToggle={toggle}
|
||||
>
|
||||
<Input
|
||||
variant="soft"
|
||||
value={tagsRemove}
|
||||
onChange={(event) => setTagsRemove(event.target.value)}
|
||||
placeholder={t("bulkEdit.tagsPlaceholder")}
|
||||
/>
|
||||
</Row>
|
||||
<Row
|
||||
field="note"
|
||||
label={t("profileTable.noteHeader")}
|
||||
enabled={has("note")}
|
||||
onToggle={toggle}
|
||||
>
|
||||
<Textarea
|
||||
variant="soft"
|
||||
value={note}
|
||||
onChange={(event) => setNote(event.target.value)}
|
||||
placeholder={t("bulkEdit.notePlaceholder")}
|
||||
rows={2}
|
||||
/>
|
||||
</Row>
|
||||
<Row
|
||||
field="dnsBlocklist"
|
||||
label={t("dnsBlocklist.title")}
|
||||
enabled={has("dnsBlocklist")}
|
||||
onToggle={toggle}
|
||||
>
|
||||
<Select value={dnsBlocklist} onValueChange={setDnsBlocklist}>
|
||||
<SelectTrigger variant="soft" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NONE}>{t("dnsBlocklist.none")}</SelectItem>
|
||||
{DNS_BLOCKLIST_LEVELS.map((level) => (
|
||||
<SelectItem key={level.value} value={level.value}>
|
||||
{t(level.labelKey)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Row>
|
||||
<Row
|
||||
field="dnsServer"
|
||||
label={t("dnsServer.label")}
|
||||
enabled={has("dnsServer")}
|
||||
onToggle={toggle}
|
||||
>
|
||||
<DnsServerField
|
||||
id="bulk-edit-dns-server-value"
|
||||
value={dnsServer}
|
||||
onChange={setDnsServer}
|
||||
onValidityChange={setDnsServerValid}
|
||||
/>
|
||||
</Row>
|
||||
<Row
|
||||
field="startPages"
|
||||
label={t("profileInfo.sections.startPages")}
|
||||
enabled={has("startPages")}
|
||||
onToggle={toggle}
|
||||
>
|
||||
<Textarea
|
||||
variant="soft"
|
||||
value={startPages}
|
||||
onChange={(event) => setStartPages(event.target.value)}
|
||||
placeholder={t("startPages.placeholder")}
|
||||
rows={3}
|
||||
spellCheck={false}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
</Row>
|
||||
<Row
|
||||
field="clearOnClose"
|
||||
label={t("clearOnClose.label")}
|
||||
enabled={has("clearOnClose")}
|
||||
onToggle={toggle}
|
||||
>
|
||||
<OnOff value={clearOnClose} onChange={setClearOnClose} />
|
||||
</Row>
|
||||
<Row
|
||||
field="webrtc"
|
||||
label={t("fingerprint.webrtcModeLabel")}
|
||||
enabled={has("webrtc")}
|
||||
onToggle={toggle}
|
||||
>
|
||||
<Select value={webrtc} onValueChange={setWebrtc}>
|
||||
<SelectTrigger variant="soft" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(["auto", "tcp_only", "block"] as const).map((mode) => (
|
||||
<SelectItem key={mode} value={mode}>
|
||||
{t(`fingerprint.webrtcMode.${mode}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Row>
|
||||
<Row
|
||||
field="blockImages"
|
||||
label={t("fingerprint.blockImages")}
|
||||
enabled={has("blockImages")}
|
||||
onToggle={toggle}
|
||||
>
|
||||
<OnOff value={blockImages} onChange={setBlockImages} />
|
||||
</Row>
|
||||
<Row
|
||||
field="blockWebgl"
|
||||
label={t("fingerprint.blockWebGL")}
|
||||
enabled={has("blockWebgl")}
|
||||
onToggle={toggle}
|
||||
>
|
||||
<OnOff value={blockWebgl} onChange={setBlockWebgl} />
|
||||
</Row>
|
||||
<Row
|
||||
field="randomize"
|
||||
label={t("fingerprint.generateRandomOnLaunch")}
|
||||
enabled={has("randomize")}
|
||||
onToggle={toggle}
|
||||
>
|
||||
<OnOff value={randomize} onChange={setRandomize} />
|
||||
</Row>
|
||||
<Row
|
||||
field="restoreSession"
|
||||
label={t("fingerprint.restoreSession")}
|
||||
enabled={has("restoreSession")}
|
||||
onToggle={toggle}
|
||||
>
|
||||
<OnOff value={restoreSession} onChange={setRestoreSession} />
|
||||
</Row>
|
||||
<Row
|
||||
field="newDevice"
|
||||
label={t("bulkEdit.newDevice")}
|
||||
enabled={has("newDevice")}
|
||||
onToggle={toggle}
|
||||
>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("bulkEdit.newDeviceDescription")}
|
||||
</p>
|
||||
</Row>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={onClose} disabled={isSaving}>
|
||||
{t("common.buttons.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
data-slot="bulk-edit-apply"
|
||||
onClick={() => void apply()}
|
||||
disabled={isSaving || enabled.size === 0 || blocked}
|
||||
>
|
||||
{t("bulkEdit.apply", { count: profileIds.length })}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -33,12 +33,14 @@ import {
|
||||
LuPlay,
|
||||
LuPuzzle,
|
||||
LuShuffle,
|
||||
LuSlidersHorizontal,
|
||||
LuSquare,
|
||||
LuTrash2,
|
||||
LuTriangleAlert,
|
||||
LuUserSearch,
|
||||
LuUsers,
|
||||
} from "react-icons/lu";
|
||||
import { BulkEditDialog } from "@/components/bulk-edit-dialog";
|
||||
import { CookieBotEnrolDialog } from "@/components/cookie-bot-enrol-dialog";
|
||||
import { CookieBotRunsDialog } from "@/components/cookie-bot-runs-dialog";
|
||||
import {
|
||||
@@ -1816,6 +1818,7 @@ export function ProfilesDataTable({
|
||||
const [bypassRulesProfile, setBypassRulesProfile] =
|
||||
React.useState<BrowserProfile | null>(null);
|
||||
const [statusesDialogOpen, setStatusesDialogOpen] = React.useState(false);
|
||||
const [bulkEditOpen, setBulkEditOpen] = React.useState(false);
|
||||
const [dnsBlocklistProfile, setDnsBlocklistProfile] =
|
||||
React.useState<BrowserProfile | null>(null);
|
||||
const [launchHookProfile, setLaunchHookProfile] =
|
||||
@@ -4187,6 +4190,12 @@ export function ProfilesDataTable({
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
<DataTableActionBarAction
|
||||
tooltip={t("bulkEdit.action")}
|
||||
onClick={() => setBulkEditOpen(true)}
|
||||
>
|
||||
<LuSlidersHorizontal />
|
||||
</DataTableActionBarAction>
|
||||
{onBulkGroupAssignment && (
|
||||
<DataTableActionBarAction
|
||||
tooltip={t("profiles.actionBar.assignToGroup")}
|
||||
@@ -4272,6 +4281,12 @@ export function ProfilesDataTable({
|
||||
profileId={bypassRulesProfile?.id ?? null}
|
||||
initialRules={bypassRulesProfile?.proxy_bypass_rules ?? []}
|
||||
/>
|
||||
<BulkEditDialog
|
||||
isOpen={bulkEditOpen}
|
||||
onClose={() => setBulkEditOpen(false)}
|
||||
profileIds={selectedProfiles}
|
||||
statuses={profileStatuses}
|
||||
/>
|
||||
<ProfileStatusesDialog
|
||||
isOpen={statusesDialogOpen}
|
||||
onClose={() => setStatusesDialogOpen(false)}
|
||||
|
||||
@@ -1775,6 +1775,21 @@
|
||||
"deleteTitle": "2FA-Schlüssel für „{{label}}“ löschen?",
|
||||
"deleteDescription": "Bewahren Sie zuerst eine Kopie des Schlüssels an anderer Stelle auf, sonst können Sie den Zugang zum Konto verlieren."
|
||||
},
|
||||
"bulkEdit": {
|
||||
"action": "Auswahl bearbeiten",
|
||||
"title": "Profile bearbeiten",
|
||||
"description": "Haken Sie die Einstellungen an, die sich ändern sollen. Alles andere bleibt in jedem Profil, wie es ist.",
|
||||
"tagsAdd": "Tags hinzufügen",
|
||||
"tagsRemove": "Tags entfernen",
|
||||
"tagsPlaceholder": "arbeit, shop",
|
||||
"notePlaceholder": "Leer lassen, um die Notiz zu entfernen",
|
||||
"newDevice": "Neues Gerät beim nächsten Start",
|
||||
"newDeviceDescription": "Jedes Profil erhält beim nächsten Start ein neues Gerät, sodass Websites es nicht mit dem alten verbinden können.",
|
||||
"apply_one": "Auf {{count}} Profil anwenden",
|
||||
"apply_other": "Auf {{count}} Profile anwenden",
|
||||
"applied_one": "{{count}} Profil geändert",
|
||||
"applied_other": "{{count}} Profile geändert"
|
||||
},
|
||||
"vpns": {
|
||||
"form": {
|
||||
"titleEdit": "VPN bearbeiten",
|
||||
@@ -2514,7 +2529,9 @@
|
||||
"totpKeyInvalid": "Das ist kein 2FA-Schlüssel. Fügen Sie den geheimen Schlüssel oder den otpauth://-Link ein, den die Website zeigt.",
|
||||
"totpLabelTooLong": "Eine Bezeichnung darf höchstens 60 Zeichen haben.",
|
||||
"totpLimitReached": "Ein Profil kann höchstens 50 2FA-Schlüssel speichern.",
|
||||
"totpEntryNotFound": "Dieser 2FA-Schlüssel existiert nicht mehr."
|
||||
"totpEntryNotFound": "Dieser 2FA-Schlüssel existiert nicht mehr.",
|
||||
"dnsBlocklistInvalid": "Diese Stufe der DNS-Sperrliste existiert nicht.",
|
||||
"webrtcModeInvalid": "WebRTC muss Automatisch, Nur Proxy-Ausgang oder Blockiert sein."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Profile",
|
||||
|
||||
@@ -1775,6 +1775,21 @@
|
||||
"deleteTitle": "Delete the 2FA key for \"{{label}}\"?",
|
||||
"deleteDescription": "Keep a copy of the key somewhere else first, or you can lose access to the account."
|
||||
},
|
||||
"bulkEdit": {
|
||||
"action": "Edit selected",
|
||||
"title": "Edit profiles",
|
||||
"description": "Tick the settings to change. Everything else stays as it is on each profile.",
|
||||
"tagsAdd": "Add tags",
|
||||
"tagsRemove": "Remove tags",
|
||||
"tagsPlaceholder": "work, shop",
|
||||
"notePlaceholder": "Leave empty to remove the note",
|
||||
"newDevice": "New device at next launch",
|
||||
"newDeviceDescription": "Each profile gets a new device the next time it starts, so sites cannot link it to the old one.",
|
||||
"apply_one": "Apply to {{count}} profile",
|
||||
"apply_other": "Apply to {{count}} profiles",
|
||||
"applied_one": "Changed {{count}} profile",
|
||||
"applied_other": "Changed {{count}} profiles"
|
||||
},
|
||||
"vpns": {
|
||||
"form": {
|
||||
"titleEdit": "Edit VPN",
|
||||
@@ -2514,7 +2529,9 @@
|
||||
"totpKeyInvalid": "This is not a 2FA key. Paste the secret key or the otpauth:// link that the site shows.",
|
||||
"totpLabelTooLong": "A label can have at most 60 characters.",
|
||||
"totpLimitReached": "A profile can keep at most 50 2FA keys.",
|
||||
"totpEntryNotFound": "This 2FA key no longer exists."
|
||||
"totpEntryNotFound": "This 2FA key no longer exists.",
|
||||
"dnsBlocklistInvalid": "This DNS blocklist level does not exist.",
|
||||
"webrtcModeInvalid": "WebRTC must be Automatic, Proxy exit only or Blocked."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Profiles",
|
||||
|
||||
@@ -1782,6 +1782,23 @@
|
||||
"deleteTitle": "¿Eliminar la clave 2FA de «{{label}}»?",
|
||||
"deleteDescription": "Guarda antes una copia de la clave en otro lugar, o puedes perder el acceso a la cuenta."
|
||||
},
|
||||
"bulkEdit": {
|
||||
"action": "Editar selección",
|
||||
"title": "Editar perfiles",
|
||||
"description": "Marca los ajustes que quieres cambiar. Todo lo demás se queda igual en cada perfil.",
|
||||
"tagsAdd": "Añadir etiquetas",
|
||||
"tagsRemove": "Quitar etiquetas",
|
||||
"tagsPlaceholder": "trabajo, tienda",
|
||||
"notePlaceholder": "Déjalo vacío para quitar la nota",
|
||||
"newDevice": "Dispositivo nuevo en el próximo inicio",
|
||||
"newDeviceDescription": "Cada perfil recibe un dispositivo nuevo la próxima vez que se inicia, para que los sitios no puedan relacionarlo con el anterior.",
|
||||
"apply_one": "Aplicar a {{count}} perfil",
|
||||
"apply_many": "Aplicar a {{count}} perfiles",
|
||||
"apply_other": "Aplicar a {{count}} perfiles",
|
||||
"applied_one": "{{count}} perfil cambiado",
|
||||
"applied_many": "{{count}} perfiles cambiados",
|
||||
"applied_other": "{{count}} perfiles cambiados"
|
||||
},
|
||||
"vpns": {
|
||||
"form": {
|
||||
"titleEdit": "Editar VPN",
|
||||
@@ -2525,7 +2542,9 @@
|
||||
"totpKeyInvalid": "Esto no es una clave 2FA. Pega la clave secreta o el enlace otpauth:// que muestra el sitio.",
|
||||
"totpLabelTooLong": "Una etiqueta puede tener como máximo 60 caracteres.",
|
||||
"totpLimitReached": "Un perfil puede guardar como máximo 50 claves 2FA.",
|
||||
"totpEntryNotFound": "Esta clave 2FA ya no existe."
|
||||
"totpEntryNotFound": "Esta clave 2FA ya no existe.",
|
||||
"dnsBlocklistInvalid": "Este nivel de lista de bloqueo DNS no existe.",
|
||||
"webrtcModeInvalid": "WebRTC debe ser Automático, Solo salida del proxy o Bloqueado."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Perfiles",
|
||||
|
||||
@@ -1782,6 +1782,23 @@
|
||||
"deleteTitle": "Supprimer la clé 2FA de « {{label}} » ?",
|
||||
"deleteDescription": "Gardez d'abord une copie de la clé ailleurs, sinon vous pouvez perdre l'accès au compte."
|
||||
},
|
||||
"bulkEdit": {
|
||||
"action": "Modifier la sélection",
|
||||
"title": "Modifier les profils",
|
||||
"description": "Cochez les réglages à changer. Tout le reste ne change pas sur chaque profil.",
|
||||
"tagsAdd": "Ajouter des tags",
|
||||
"tagsRemove": "Retirer des tags",
|
||||
"tagsPlaceholder": "travail, boutique",
|
||||
"notePlaceholder": "Laissez vide pour retirer la note",
|
||||
"newDevice": "Nouvel appareil au prochain lancement",
|
||||
"newDeviceDescription": "Chaque profil reçoit un nouvel appareil au prochain démarrage, pour que les sites ne puissent pas le relier à l'ancien.",
|
||||
"apply_one": "Appliquer à {{count}} profil",
|
||||
"apply_many": "Appliquer à {{count}} profils",
|
||||
"apply_other": "Appliquer à {{count}} profils",
|
||||
"applied_one": "{{count}} profil modifié",
|
||||
"applied_many": "{{count}} profils modifiés",
|
||||
"applied_other": "{{count}} profils modifiés"
|
||||
},
|
||||
"vpns": {
|
||||
"form": {
|
||||
"titleEdit": "Modifier le VPN",
|
||||
@@ -2525,7 +2542,9 @@
|
||||
"totpKeyInvalid": "Ce n'est pas une clé 2FA. Collez la clé secrète ou le lien otpauth:// affiché par le site.",
|
||||
"totpLabelTooLong": "Un libellé ne peut pas dépasser 60 caractères.",
|
||||
"totpLimitReached": "Un profil peut garder au plus 50 clés 2FA.",
|
||||
"totpEntryNotFound": "Cette clé 2FA n'existe plus."
|
||||
"totpEntryNotFound": "Cette clé 2FA n'existe plus.",
|
||||
"dnsBlocklistInvalid": "Ce niveau de liste de blocage DNS n'existe pas.",
|
||||
"webrtcModeInvalid": "WebRTC doit être Automatique, Sortie du proxy uniquement ou Bloqué."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Profils",
|
||||
|
||||
@@ -1775,6 +1775,21 @@
|
||||
"deleteTitle": "「{{label}}」の 2FA キーを削除しますか?",
|
||||
"deleteDescription": "先にキーのコピーを別の場所に保管してください。保管しないとアカウントにアクセスできなくなる場合があります。"
|
||||
},
|
||||
"bulkEdit": {
|
||||
"action": "選択項目を編集",
|
||||
"title": "プロファイルを編集",
|
||||
"description": "変更する設定にチェックを入れてください。それ以外は各プロファイルでそのままです。",
|
||||
"tagsAdd": "タグを追加",
|
||||
"tagsRemove": "タグを削除",
|
||||
"tagsPlaceholder": "仕事, ショップ",
|
||||
"notePlaceholder": "空欄にするとメモを削除します",
|
||||
"newDevice": "次回起動時に新しいデバイス",
|
||||
"newDeviceDescription": "各プロファイルは次回起動時に新しいデバイスになり、サイトは以前のデバイスと結び付けられなくなります。",
|
||||
"apply_one": "{{count}} 件のプロファイルに適用",
|
||||
"apply_other": "{{count}} 件のプロファイルに適用",
|
||||
"applied_one": "{{count}} 件のプロファイルを変更しました",
|
||||
"applied_other": "{{count}} 件のプロファイルを変更しました"
|
||||
},
|
||||
"vpns": {
|
||||
"form": {
|
||||
"titleEdit": "VPN を編集",
|
||||
@@ -2514,7 +2529,9 @@
|
||||
"totpKeyInvalid": "2FA キーではありません。サイトに表示されるシークレットキーまたは otpauth:// リンクを貼り付けてください。",
|
||||
"totpLabelTooLong": "ラベルは 60 文字以内にしてください。",
|
||||
"totpLimitReached": "1 つのプロファイルに保存できる 2FA キーは 50 個までです。",
|
||||
"totpEntryNotFound": "この 2FA キーはもう存在しません。"
|
||||
"totpEntryNotFound": "この 2FA キーはもう存在しません。",
|
||||
"dnsBlocklistInvalid": "この DNS ブロックリストのレベルは存在しません。",
|
||||
"webrtcModeInvalid": "WebRTC は「自動」「プロキシ出口のみ」「ブロック」のいずれかにしてください。"
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "プロファイル",
|
||||
|
||||
@@ -1775,6 +1775,21 @@
|
||||
"deleteTitle": "\"{{label}}\"의 2FA 키를 삭제할까요?",
|
||||
"deleteDescription": "먼저 키 사본을 다른 곳에 보관하세요. 그렇지 않으면 계정에 접근하지 못할 수 있습니다."
|
||||
},
|
||||
"bulkEdit": {
|
||||
"action": "선택 항목 편집",
|
||||
"title": "프로필 편집",
|
||||
"description": "변경할 설정을 체크하세요. 나머지는 각 프로필에서 그대로 유지됩니다.",
|
||||
"tagsAdd": "태그 추가",
|
||||
"tagsRemove": "태그 제거",
|
||||
"tagsPlaceholder": "업무, 쇼핑",
|
||||
"notePlaceholder": "비워 두면 메모를 제거합니다",
|
||||
"newDevice": "다음 실행 시 새 기기",
|
||||
"newDeviceDescription": "각 프로필은 다음 시작 시 새 기기를 받으므로 사이트가 이전 기기와 연결할 수 없습니다.",
|
||||
"apply_one": "프로필 {{count}}개에 적용",
|
||||
"apply_other": "프로필 {{count}}개에 적용",
|
||||
"applied_one": "프로필 {{count}}개를 변경했습니다",
|
||||
"applied_other": "프로필 {{count}}개를 변경했습니다"
|
||||
},
|
||||
"vpns": {
|
||||
"form": {
|
||||
"titleEdit": "VPN 편집",
|
||||
@@ -2514,7 +2529,9 @@
|
||||
"totpKeyInvalid": "2FA 키가 아닙니다. 사이트에 표시된 비밀 키나 otpauth:// 링크를 붙여 넣으세요.",
|
||||
"totpLabelTooLong": "라벨은 최대 60자까지 가능합니다.",
|
||||
"totpLimitReached": "프로필 하나에 2FA 키는 최대 50개까지 저장할 수 있습니다.",
|
||||
"totpEntryNotFound": "이 2FA 키는 더 이상 존재하지 않습니다."
|
||||
"totpEntryNotFound": "이 2FA 키는 더 이상 존재하지 않습니다.",
|
||||
"dnsBlocklistInvalid": "이 DNS 차단 목록 수준은 존재하지 않습니다.",
|
||||
"webrtcModeInvalid": "WebRTC는 자동, 프록시 출구만, 차단 중 하나여야 합니다."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "프로필",
|
||||
|
||||
@@ -1782,6 +1782,23 @@
|
||||
"deleteTitle": "Excluir a chave 2FA de \"{{label}}\"?",
|
||||
"deleteDescription": "Guarde antes uma cópia da chave em outro lugar, ou você pode perder o acesso à conta."
|
||||
},
|
||||
"bulkEdit": {
|
||||
"action": "Editar seleção",
|
||||
"title": "Editar perfis",
|
||||
"description": "Marque as configurações que quer mudar. Todo o resto fica como está em cada perfil.",
|
||||
"tagsAdd": "Adicionar tags",
|
||||
"tagsRemove": "Remover tags",
|
||||
"tagsPlaceholder": "trabalho, loja",
|
||||
"notePlaceholder": "Deixe vazio para remover a nota",
|
||||
"newDevice": "Novo dispositivo na próxima inicialização",
|
||||
"newDeviceDescription": "Cada perfil recebe um novo dispositivo na próxima inicialização, para que os sites não o liguem ao anterior.",
|
||||
"apply_one": "Aplicar a {{count}} perfil",
|
||||
"apply_many": "Aplicar a {{count}} perfis",
|
||||
"apply_other": "Aplicar a {{count}} perfis",
|
||||
"applied_one": "{{count}} perfil alterado",
|
||||
"applied_many": "{{count}} perfis alterados",
|
||||
"applied_other": "{{count}} perfis alterados"
|
||||
},
|
||||
"vpns": {
|
||||
"form": {
|
||||
"titleEdit": "Editar VPN",
|
||||
@@ -2525,7 +2542,9 @@
|
||||
"totpKeyInvalid": "Isto não é uma chave 2FA. Cole a chave secreta ou o link otpauth:// que o site mostra.",
|
||||
"totpLabelTooLong": "Um rótulo pode ter no máximo 60 caracteres.",
|
||||
"totpLimitReached": "Um perfil pode guardar no máximo 50 chaves 2FA.",
|
||||
"totpEntryNotFound": "Esta chave 2FA não existe mais."
|
||||
"totpEntryNotFound": "Esta chave 2FA não existe mais.",
|
||||
"dnsBlocklistInvalid": "Este nível de lista de bloqueio DNS não existe.",
|
||||
"webrtcModeInvalid": "O WebRTC precisa ser Automático, Só saída do proxy ou Bloqueado."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Perfis",
|
||||
|
||||
@@ -1789,6 +1789,25 @@
|
||||
"deleteTitle": "Удалить ключ 2FA для «{{label}}»?",
|
||||
"deleteDescription": "Сначала сохраните копию ключа в другом месте, иначе вы можете потерять доступ к аккаунту."
|
||||
},
|
||||
"bulkEdit": {
|
||||
"action": "Изменить выбранные",
|
||||
"title": "Изменить профили",
|
||||
"description": "Отметьте настройки, которые нужно изменить. Всё остальное в каждом профиле останется как есть.",
|
||||
"tagsAdd": "Добавить теги",
|
||||
"tagsRemove": "Удалить теги",
|
||||
"tagsPlaceholder": "работа, магазин",
|
||||
"notePlaceholder": "Оставьте пустым, чтобы удалить заметку",
|
||||
"newDevice": "Новое устройство при следующем запуске",
|
||||
"newDeviceDescription": "Каждый профиль получит новое устройство при следующем запуске, и сайты не смогут связать его со старым.",
|
||||
"apply_one": "Применить к {{count}} профилю",
|
||||
"apply_few": "Применить к {{count}} профилям",
|
||||
"apply_many": "Применить к {{count}} профилям",
|
||||
"apply_other": "Применить к {{count}} профилям",
|
||||
"applied_one": "Изменён {{count}} профиль",
|
||||
"applied_few": "Изменено {{count}} профиля",
|
||||
"applied_many": "Изменено {{count}} профилей",
|
||||
"applied_other": "Изменено {{count}} профилей"
|
||||
},
|
||||
"vpns": {
|
||||
"form": {
|
||||
"titleEdit": "Изменить VPN",
|
||||
@@ -2536,7 +2555,9 @@
|
||||
"totpKeyInvalid": "Это не ключ 2FA. Вставьте секретный ключ или ссылку otpauth://, которую показывает сайт.",
|
||||
"totpLabelTooLong": "Метка может содержать не более 60 символов.",
|
||||
"totpLimitReached": "Профиль может хранить не более 50 ключей 2FA.",
|
||||
"totpEntryNotFound": "Этого ключа 2FA больше нет."
|
||||
"totpEntryNotFound": "Этого ключа 2FA больше нет.",
|
||||
"dnsBlocklistInvalid": "Такого уровня списка блокировки DNS нет.",
|
||||
"webrtcModeInvalid": "WebRTC должен быть «Автоматически», «Только выход прокси» или «Заблокирован»."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Профили",
|
||||
|
||||
@@ -1775,6 +1775,21 @@
|
||||
"deleteTitle": "\"{{label}}\" için 2FA anahtarı silinsin mi?",
|
||||
"deleteDescription": "Önce anahtarın bir kopyasını başka bir yerde saklayın, yoksa hesaba erişimi kaybedebilirsiniz."
|
||||
},
|
||||
"bulkEdit": {
|
||||
"action": "Seçilenleri düzenle",
|
||||
"title": "Profilleri düzenle",
|
||||
"description": "Değiştirilecek ayarları işaretleyin. Diğer her şey her profilde olduğu gibi kalır.",
|
||||
"tagsAdd": "Etiket ekle",
|
||||
"tagsRemove": "Etiket kaldır",
|
||||
"tagsPlaceholder": "iş, mağaza",
|
||||
"notePlaceholder": "Notu kaldırmak için boş bırakın",
|
||||
"newDevice": "Sonraki başlatmada yeni cihaz",
|
||||
"newDeviceDescription": "Her profil bir sonraki başlatmada yeni bir cihaz alır; böylece siteler onu eskisiyle ilişkilendiremez.",
|
||||
"apply_one": "{{count}} profile uygula",
|
||||
"apply_other": "{{count}} profile uygula",
|
||||
"applied_one": "{{count}} profil değiştirildi",
|
||||
"applied_other": "{{count}} profil değiştirildi"
|
||||
},
|
||||
"vpns": {
|
||||
"form": {
|
||||
"titleEdit": "VPN'i Düzenle",
|
||||
@@ -2514,7 +2529,9 @@
|
||||
"totpKeyInvalid": "Bu bir 2FA anahtarı değil. Sitenin gösterdiği gizli anahtarı veya otpauth:// bağlantısını yapıştırın.",
|
||||
"totpLabelTooLong": "Etiket en fazla 60 karakter olabilir.",
|
||||
"totpLimitReached": "Bir profil en fazla 50 2FA anahtarı saklayabilir.",
|
||||
"totpEntryNotFound": "Bu 2FA anahtarı artık yok."
|
||||
"totpEntryNotFound": "Bu 2FA anahtarı artık yok.",
|
||||
"dnsBlocklistInvalid": "Bu DNS engel listesi düzeyi yok.",
|
||||
"webrtcModeInvalid": "WebRTC Otomatik, Yalnızca proxy çıkışı veya Engelli olmalıdır."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Profiller",
|
||||
|
||||
@@ -1775,6 +1775,21 @@
|
||||
"deleteTitle": "Xóa khóa 2FA của \"{{label}}\"?",
|
||||
"deleteDescription": "Hãy lưu một bản sao khóa ở nơi khác trước, nếu không bạn có thể mất quyền truy cập tài khoản."
|
||||
},
|
||||
"bulkEdit": {
|
||||
"action": "Sửa các mục đã chọn",
|
||||
"title": "Sửa profile",
|
||||
"description": "Đánh dấu các cài đặt cần thay đổi. Mọi thứ khác giữ nguyên trên từng profile.",
|
||||
"tagsAdd": "Thêm thẻ",
|
||||
"tagsRemove": "Gỡ thẻ",
|
||||
"tagsPlaceholder": "công việc, cửa hàng",
|
||||
"notePlaceholder": "Để trống để xóa ghi chú",
|
||||
"newDevice": "Thiết bị mới ở lần khởi chạy tiếp theo",
|
||||
"newDeviceDescription": "Mỗi profile nhận thiết bị mới ở lần khởi động tiếp theo, nên các trang web không thể liên kết nó với thiết bị cũ.",
|
||||
"apply_one": "Áp dụng cho {{count}} profile",
|
||||
"apply_other": "Áp dụng cho {{count}} profile",
|
||||
"applied_one": "Đã thay đổi {{count}} profile",
|
||||
"applied_other": "Đã thay đổi {{count}} profile"
|
||||
},
|
||||
"vpns": {
|
||||
"form": {
|
||||
"titleEdit": "Chỉnh sửa VPN",
|
||||
@@ -2514,7 +2529,9 @@
|
||||
"totpKeyInvalid": "Đây không phải khóa 2FA. Hãy dán khóa bí mật hoặc liên kết otpauth:// mà trang web hiển thị.",
|
||||
"totpLabelTooLong": "Nhãn tối đa 60 ký tự.",
|
||||
"totpLimitReached": "Mỗi profile lưu tối đa 50 khóa 2FA.",
|
||||
"totpEntryNotFound": "Khóa 2FA này không còn tồn tại."
|
||||
"totpEntryNotFound": "Khóa 2FA này không còn tồn tại.",
|
||||
"dnsBlocklistInvalid": "Mức danh sách chặn DNS này không tồn tại.",
|
||||
"webrtcModeInvalid": "WebRTC phải là Tự động, Chỉ lối ra proxy hoặc Bị chặn."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Profile",
|
||||
|
||||
@@ -1775,6 +1775,21 @@
|
||||
"deleteTitle": "删除“{{label}}”的 2FA 密钥?",
|
||||
"deleteDescription": "请先在其他地方保存密钥副本,否则可能无法再访问该账号。"
|
||||
},
|
||||
"bulkEdit": {
|
||||
"action": "编辑所选项",
|
||||
"title": "编辑配置文件",
|
||||
"description": "勾选要更改的设置。其他设置在每个配置文件中保持不变。",
|
||||
"tagsAdd": "添加标签",
|
||||
"tagsRemove": "移除标签",
|
||||
"tagsPlaceholder": "工作, 商店",
|
||||
"notePlaceholder": "留空即可移除备注",
|
||||
"newDevice": "下次启动时使用新设备",
|
||||
"newDeviceDescription": "每个配置文件在下次启动时获得新设备,网站无法将其与旧设备关联。",
|
||||
"apply_one": "应用到 {{count}} 个配置文件",
|
||||
"apply_other": "应用到 {{count}} 个配置文件",
|
||||
"applied_one": "已更改 {{count}} 个配置文件",
|
||||
"applied_other": "已更改 {{count}} 个配置文件"
|
||||
},
|
||||
"vpns": {
|
||||
"form": {
|
||||
"titleEdit": "编辑 VPN",
|
||||
@@ -2514,7 +2529,9 @@
|
||||
"totpKeyInvalid": "这不是 2FA 密钥。请粘贴网站显示的密钥或 otpauth:// 链接。",
|
||||
"totpLabelTooLong": "标签最多 60 个字符。",
|
||||
"totpLimitReached": "一个配置文件最多保存 50 个 2FA 密钥。",
|
||||
"totpEntryNotFound": "此 2FA 密钥已不存在。"
|
||||
"totpEntryNotFound": "此 2FA 密钥已不存在。",
|
||||
"dnsBlocklistInvalid": "该 DNS 拦截列表级别不存在。",
|
||||
"webrtcModeInvalid": "WebRTC 必须是“自动”“仅代理出口”或“已阻止”。"
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "配置文件",
|
||||
|
||||
@@ -41,6 +41,8 @@ export type BackendErrorCode =
|
||||
| "TOTP_LABEL_TOO_LONG"
|
||||
| "TOTP_LIMIT_REACHED"
|
||||
| "TOTP_ENTRY_NOT_FOUND"
|
||||
| "DNS_BLOCKLIST_INVALID"
|
||||
| "WEBRTC_MODE_INVALID"
|
||||
| "COOKIE_DB_LOCKED"
|
||||
| "COOKIE_DB_UNAVAILABLE"
|
||||
| "COOKIE_IMPORT_BROWSER_RUNNING"
|
||||
@@ -413,6 +415,10 @@ export function translateBackendError(t: TFunction, err: unknown): string {
|
||||
return t("backendErrors.totpLimitReached");
|
||||
case "TOTP_ENTRY_NOT_FOUND":
|
||||
return t("backendErrors.totpEntryNotFound");
|
||||
case "DNS_BLOCKLIST_INVALID":
|
||||
return t("backendErrors.dnsBlocklistInvalid");
|
||||
case "WEBRTC_MODE_INVALID":
|
||||
return t("backendErrors.webrtcModeInvalid");
|
||||
case "COOKIE_DB_LOCKED":
|
||||
return t("backendErrors.cookieDbLocked");
|
||||
case "COOKIE_DB_UNAVAILABLE":
|
||||
|
||||
Reference in new issue
Block a user