feat: bulk edit profiles

This commit is contained in:
zhom committed 2026-10-12 01:50:42 +04:00
1 parent 47664e6212
commit 3f5b573ead
25 files changed
+1389 -23

No files matched your search

+414
View File
@@ -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>
);
}
+15
View File
@@ -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)}
+18 -1
View File
@@ -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",
+18 -1
View File
@@ -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",
+20 -1
View File
@@ -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",
+20 -1
View File
@@ -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",
+18 -1
View File
@@ -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": "プロファイル",
+18 -1
View File
@@ -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": "프로필",
+20 -1
View File
@@ -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",
+22 -1
View File
@@ -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": "Профили",
+18 -1
View File
@@ -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",
+18 -1
View File
@@ -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",
+18 -1
View File
@@ -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": "配置文件",
+6
View File
@@ -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":