mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-10-19 14:07:19 +02:00
feat: 2FA codes per profile
This commit is contained in:
1 parent
4044c845c7
commit
47664e6212
24 files changed
+1381
-33
No files matched your search
@@ -62,6 +62,11 @@ export const commandCoverage = {
|
||||
"profile_statuses::update_profile_status",
|
||||
"profile_statuses::delete_profile_status",
|
||||
"profile_statuses::set_profiles_status",
|
||||
"totp::generate_totp_code",
|
||||
"profile_totp::list_profile_totp",
|
||||
"profile_totp::add_profile_totp",
|
||||
"profile_totp::delete_profile_totp",
|
||||
"profile_totp::get_profile_totp_codes",
|
||||
"rename_profile",
|
||||
"detect_existing_profiles",
|
||||
"import_browser_profiles",
|
||||
|
||||
@@ -1354,6 +1354,97 @@ test("cookie import/copy/export, profile encryption, and traffic-stat read/clear
|
||||
});
|
||||
});
|
||||
|
||||
test("2FA keys stay sealed on this device, hand out codes, and go with their profile", async () => {
|
||||
await withApp("entities-two-factor", async (app) => {
|
||||
const secret = "JBSWY3DPEHPK3PXP";
|
||||
const quick = await app.invoke("generate_totp_code", {
|
||||
key: ` ${secret.toLowerCase()} `,
|
||||
});
|
||||
assert.match(quick.code, /^\d{6}$/);
|
||||
assert.equal(quick.valid_until % 30, 0);
|
||||
assert.match(
|
||||
await app.invokeError("generate_totp_code", { key: "not a key" }),
|
||||
/TOTP_KEY_INVALID/,
|
||||
);
|
||||
|
||||
const profile = await createProfile(app, "Two Factor Profile");
|
||||
assert.match(
|
||||
await app.invokeError("add_profile_totp", {
|
||||
profileId: profile.id,
|
||||
label: "",
|
||||
key: "otpauth://hotp/x?secret=JBSWY3DPEHPK3PXP&counter=1",
|
||||
}),
|
||||
/TOTP_KEY_INVALID/,
|
||||
);
|
||||
const entry = await app.invoke("add_profile_totp", {
|
||||
profileId: profile.id,
|
||||
label: "",
|
||||
key: `otpauth://totp/Shop:alice@example.com?secret=${secret}&issuer=Shop&digits=8`,
|
||||
});
|
||||
assert.equal(entry.label, "Shop", "the issuer names an unlabelled key");
|
||||
assert.equal(entry.account, "alice@example.com");
|
||||
assert.equal(entry.digits, 8);
|
||||
const listed = await app.invoke("list_profile_totp", {
|
||||
profileId: profile.id,
|
||||
});
|
||||
assert.equal(listed.length, 1);
|
||||
assert.ok(
|
||||
!JSON.stringify(listed).includes(secret),
|
||||
"the window never receives the key",
|
||||
);
|
||||
const codes = await app.invoke("get_profile_totp_codes", {
|
||||
profileId: profile.id,
|
||||
});
|
||||
assert.equal(codes[0].id, entry.id);
|
||||
assert.match(codes[0].code, /^\d{8}$/);
|
||||
|
||||
const sealed = path.join(app.dataRoot, "data", "totp", `${profile.id}.dat`);
|
||||
const onDisk = await readFile(sealed);
|
||||
assert.ok(
|
||||
!onDisk.toString("latin1").includes(secret) &&
|
||||
!onDisk.toString("latin1").includes("alice"),
|
||||
"the key file is sealed",
|
||||
);
|
||||
const metadata = await readFile(
|
||||
path.join(app.dataRoot, "data", "profiles", profile.id, "metadata.json"),
|
||||
"utf8",
|
||||
);
|
||||
assert.ok(
|
||||
!metadata.includes(secret),
|
||||
"profile metadata, which syncs, has no key",
|
||||
);
|
||||
|
||||
await app.invoke("delete_profile_totp", {
|
||||
profileId: profile.id,
|
||||
entryId: entry.id,
|
||||
});
|
||||
assert.deepEqual(
|
||||
await app.invoke("list_profile_totp", { profileId: profile.id }),
|
||||
[],
|
||||
);
|
||||
assert.match(
|
||||
await app.invokeError("delete_profile_totp", {
|
||||
profileId: profile.id,
|
||||
entryId: entry.id,
|
||||
}),
|
||||
/TOTP_ENTRY_NOT_FOUND/,
|
||||
);
|
||||
|
||||
await app.invoke("add_profile_totp", {
|
||||
profileId: profile.id,
|
||||
label: "Mail",
|
||||
key: secret,
|
||||
});
|
||||
assert.ok(existsSync(sealed));
|
||||
await app.invoke("delete_profile", { profileId: profile.id });
|
||||
await app.invoke("purge_trashed_profile", { profileId: profile.id });
|
||||
assert.ok(
|
||||
!existsSync(sealed),
|
||||
"a purged profile takes its 2FA keys with it",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("deleted profiles land in the trash and come back intact on restore", async () => {
|
||||
await withApp("entities-trash", async (app) => {
|
||||
assert.deepEqual(await app.invoke("list_trashed_profiles"), []);
|
||||
|
||||
@@ -4275,6 +4275,57 @@ test("the start pages section saves one page per line and reports a bad page", a
|
||||
});
|
||||
});
|
||||
|
||||
test("2FA codes come from a pasted key and from a profile's saved keys", async () => {
|
||||
await withApp("ui-two-factor", async (app) => {
|
||||
const profile = await createUiProfile(app, "Profile Two Factor");
|
||||
await app.waitForText(profile.name);
|
||||
|
||||
await app.pressShortcut({
|
||||
key: "o",
|
||||
shift: true,
|
||||
...(process.platform === "darwin" ? { meta: true } : { ctrl: true }),
|
||||
});
|
||||
await app.waitForText(en.totp.toolDescription);
|
||||
await app.fillSelector("#totp-tool-key", "JBSWY3DPEHPK3PXP");
|
||||
await app.waitFor(
|
||||
() =>
|
||||
app.execute(
|
||||
`return /^\\d{3} \\d{3}$/.test(document.querySelector('[data-slot="totp-tool-dialog"] [data-slot="totp-code"] span')?.textContent ?? "");`,
|
||||
),
|
||||
{ description: "a code from the pasted key" },
|
||||
);
|
||||
await app.capture("totp-tool");
|
||||
await dismissSurface(app);
|
||||
|
||||
await app.clickSelector(
|
||||
`[data-slot="profile-inspect-trigger"][data-profile-id="${profile.id}"]`,
|
||||
);
|
||||
await app.clickSelector(
|
||||
'[data-slot="profile-info-section"][data-section="twoFactor"]',
|
||||
);
|
||||
await app.waitForText(en.totp.empty);
|
||||
await app.fillSelector(
|
||||
'[data-slot="profile-totp-key"]',
|
||||
"otpauth://totp/Mail:bob?secret=JBSWY3DPEHPK3PXP&issuer=Mail",
|
||||
);
|
||||
await app.clickText(en.totp.add, { roles: ["button"] });
|
||||
await app.waitFor(
|
||||
() =>
|
||||
app.execute(
|
||||
`return document.querySelectorAll('[data-slot="profile-totp-entry"] [data-slot="totp-code"]').length === 1;`,
|
||||
),
|
||||
{ description: "the saved key shows a live code" },
|
||||
);
|
||||
await app.capture("profile-totp-section");
|
||||
assert.equal(
|
||||
(await app.invoke("list_profile_totp", { profileId: profile.id }))[0]
|
||||
.label,
|
||||
"Mail",
|
||||
);
|
||||
await app.clickSelector('[data-slot="profile-info-close"]');
|
||||
});
|
||||
});
|
||||
|
||||
test("the profile network section saves, refuses and clears a DNS server", async () => {
|
||||
await withApp("ui-dns-server", async (app) => {
|
||||
const profile = await createUiProfile(app, "Profile DNS Server");
|
||||
|
||||
@@ -114,6 +114,7 @@ mod profile_import;
|
||||
mod profile_importer;
|
||||
mod profile_statuses;
|
||||
mod profile_templates;
|
||||
mod profile_totp;
|
||||
mod proxy_distribution;
|
||||
mod proxy_manager;
|
||||
mod proxy_rotation;
|
||||
@@ -152,6 +153,7 @@ mod mcp_remote;
|
||||
mod mcp_server;
|
||||
mod tag_manager;
|
||||
mod team_lock;
|
||||
mod totp;
|
||||
mod vault;
|
||||
mod version_updater;
|
||||
pub mod vpn;
|
||||
@@ -3340,6 +3342,11 @@ pub fn run_with_builder(
|
||||
profile_statuses::update_profile_status,
|
||||
profile_statuses::delete_profile_status,
|
||||
profile_statuses::set_profiles_status,
|
||||
totp::generate_totp_code,
|
||||
profile_totp::list_profile_totp,
|
||||
profile_totp::add_profile_totp,
|
||||
profile_totp::delete_profile_totp,
|
||||
profile_totp::get_profile_totp_codes,
|
||||
proxy_rotation::set_proxy_rotation_url,
|
||||
proxy_rotation::rotate_proxy_ip,
|
||||
proxy_rotation::rotate_proxy_ips,
|
||||
|
||||
@@ -929,6 +929,8 @@ impl ProfileManager {
|
||||
crate::launch_gate_prefs::forget_profile(profile_id);
|
||||
// Per-domain traffic history is zero-overwritten on removal.
|
||||
crate::traffic_stats::delete_traffic_stats(profile_id);
|
||||
// Device-only 2FA keys for the profile's accounts.
|
||||
crate::profile_totp::forget_profile(profile_id);
|
||||
}
|
||||
|
||||
/// Bookkeeping after one or more profiles left the live list.
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
//! The 2FA keys of the accounts a profile signs in to.
|
||||
//!
|
||||
//! Device-only on purpose: one file per profile, sealed with this
|
||||
//! installation's vault key. The keys are never synced, never part of a
|
||||
//! profile export, and never reachable over REST or MCP. Only the codes leave
|
||||
//! this module, and only to the app's own window.
|
||||
|
||||
use crate::totp::{TotpCode, TotpKey};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const HEADER: &[u8; 5] = b"DBTFA";
|
||||
const MAX_ENTRIES: usize = 50;
|
||||
const MAX_LABEL_CHARS: usize = 60;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct StoredEntry {
|
||||
id: String,
|
||||
label: String,
|
||||
key: TotpKey,
|
||||
}
|
||||
|
||||
/// What the window may know about an entry: everything but the secret.
|
||||
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
||||
pub struct TotpEntry {
|
||||
pub id: String,
|
||||
pub label: String,
|
||||
pub issuer: Option<String>,
|
||||
pub account: Option<String>,
|
||||
pub digits: u32,
|
||||
pub period: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct TotpEntryCode {
|
||||
pub id: String,
|
||||
#[serde(flatten)]
|
||||
pub code: TotpCode,
|
||||
}
|
||||
|
||||
fn magic() -> [u8; 6] {
|
||||
let mut magic = [0u8; 6];
|
||||
magic[..5].copy_from_slice(HEADER);
|
||||
magic[5] = 2;
|
||||
magic
|
||||
}
|
||||
|
||||
fn file_for(profile_id: &str) -> Result<std::path::PathBuf, String> {
|
||||
// A UUID, so a crafted id can never name a path outside the folder.
|
||||
let id =
|
||||
uuid::Uuid::parse_str(profile_id).map_err(|_| crate::backend_error("INVALID_PROFILE_ID"))?;
|
||||
Ok(
|
||||
crate::app_dirs::data_subdir()
|
||||
.join("totp")
|
||||
.join(format!("{id}.dat")),
|
||||
)
|
||||
}
|
||||
|
||||
fn read(profile_id: &str) -> Result<Vec<StoredEntry>, String> {
|
||||
match crate::vault::open(&file_for(profile_id)?, &magic())? {
|
||||
None => Ok(Vec::new()),
|
||||
Some(json) => serde_json::from_str(&json).map_err(|e| format!("2FA keys are unreadable: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn write(profile_id: &str, entries: &[StoredEntry]) -> Result<(), String> {
|
||||
let file = file_for(profile_id)?;
|
||||
if entries.is_empty() {
|
||||
return match std::fs::remove_file(&file) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(e) => Err(e.to_string()),
|
||||
};
|
||||
}
|
||||
let json = serde_json::to_string(entries).map_err(|e| e.to_string())?;
|
||||
crate::vault::seal(&file, &magic(), &json)?;
|
||||
crate::app_dirs::restrict_to_owner(&file);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn view(entry: &StoredEntry) -> TotpEntry {
|
||||
TotpEntry {
|
||||
id: entry.id.clone(),
|
||||
label: entry.label.clone(),
|
||||
issuer: entry.key.issuer.clone(),
|
||||
account: entry.key.account.clone(),
|
||||
digits: entry.key.digits,
|
||||
period: entry.key.period,
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_profile(profile_id: &str) -> Result<(), String> {
|
||||
let exists = crate::profile::ProfileManager::instance()
|
||||
.list_profiles()
|
||||
.map_err(|e| crate::wrap_backend_error(e, "Failed to list profiles"))?
|
||||
.iter()
|
||||
.any(|profile| profile.id.to_string() == profile_id);
|
||||
if exists {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(crate::backend_error("PROFILE_NOT_FOUND"))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn list(profile_id: &str) -> Result<Vec<TotpEntry>, String> {
|
||||
Ok(read(profile_id)?.iter().map(view).collect())
|
||||
}
|
||||
|
||||
pub fn add(profile_id: &str, label: &str, key: &str) -> Result<TotpEntry, String> {
|
||||
ensure_profile(profile_id)?;
|
||||
let key = TotpKey::parse(key).map_err(|_| crate::backend_error("TOTP_KEY_INVALID"))?;
|
||||
let label = label.trim();
|
||||
let label = if label.is_empty() {
|
||||
key
|
||||
.issuer
|
||||
.clone()
|
||||
.or_else(|| key.account.clone())
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
label.to_string()
|
||||
};
|
||||
if label.is_empty() {
|
||||
return Err(crate::backend_error("NAME_CANNOT_BE_EMPTY"));
|
||||
}
|
||||
if label.chars().count() > MAX_LABEL_CHARS {
|
||||
return Err(crate::backend_error("TOTP_LABEL_TOO_LONG"));
|
||||
}
|
||||
let mut entries = read(profile_id)?;
|
||||
if entries.len() >= MAX_ENTRIES {
|
||||
return Err(crate::backend_error("TOTP_LIMIT_REACHED"));
|
||||
}
|
||||
let entry = StoredEntry {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
label,
|
||||
key,
|
||||
};
|
||||
entries.push(entry.clone());
|
||||
write(profile_id, &entries)?;
|
||||
log::info!("2FA key added profile={profile_id} keys={}", entries.len());
|
||||
Ok(view(&entry))
|
||||
}
|
||||
|
||||
pub fn remove(profile_id: &str, entry_id: &str) -> Result<(), String> {
|
||||
let mut entries = read(profile_id)?;
|
||||
let before = entries.len();
|
||||
entries.retain(|entry| entry.id != entry_id);
|
||||
if entries.len() == before {
|
||||
return Err(crate::backend_error("TOTP_ENTRY_NOT_FOUND"));
|
||||
}
|
||||
write(profile_id, &entries)?;
|
||||
log::info!(
|
||||
"2FA key removed profile={profile_id} keys={}",
|
||||
entries.len()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn codes(profile_id: &str) -> Result<Vec<TotpEntryCode>, String> {
|
||||
Ok(
|
||||
read(profile_id)?
|
||||
.iter()
|
||||
.map(|entry| TotpEntryCode {
|
||||
id: entry.id.clone(),
|
||||
code: entry.key.code_now(),
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// With the profile itself: its accounts' keys must not outlive it.
|
||||
pub fn forget_profile(profile_id: &str) {
|
||||
if let Ok(file) = file_for(profile_id) {
|
||||
match std::fs::remove_file(&file) {
|
||||
Ok(()) => log::info!("2FA keys erased profile={profile_id}"),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(e) => log::warn!("2FA keys not erased profile={profile_id} err=\"{e}\""),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn list_profile_totp(profile_id: String) -> Result<Vec<TotpEntry>, String> {
|
||||
list(&profile_id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn add_profile_totp(
|
||||
profile_id: String,
|
||||
label: String,
|
||||
key: String,
|
||||
) -> Result<TotpEntry, String> {
|
||||
add(&profile_id, &label, &key)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn delete_profile_totp(profile_id: String, entry_id: String) -> Result<(), String> {
|
||||
remove(&profile_id, &entry_id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_profile_totp_codes(profile_id: String) -> Result<Vec<TotpEntryCode>, String> {
|
||||
codes(&profile_id)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn keys_are_sealed_on_disk_and_only_codes_come_back() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let _data = crate::app_dirs::set_test_data_dir(dir.path().to_path_buf());
|
||||
let profile = uuid::Uuid::new_v4().to_string();
|
||||
let key = "otpauth://totp/Shop:alice?secret=JBSWY3DPEHPK3PXP&issuer=Shop";
|
||||
let entry = StoredEntry {
|
||||
id: "e1".to_string(),
|
||||
label: "Shop".to_string(),
|
||||
key: TotpKey::parse(key).unwrap(),
|
||||
};
|
||||
write(&profile, std::slice::from_ref(&entry)).unwrap();
|
||||
|
||||
let raw = std::fs::read(file_for(&profile).unwrap()).unwrap();
|
||||
let text = String::from_utf8_lossy(&raw);
|
||||
assert!(
|
||||
!text.contains("JBSWY3DPEHPK3PXP"),
|
||||
"the secret must not be readable on disk"
|
||||
);
|
||||
assert!(!text.contains("alice"));
|
||||
|
||||
let listed = list(&profile).unwrap();
|
||||
assert_eq!(listed[0].label, "Shop");
|
||||
assert_eq!(listed[0].account.as_deref(), Some("alice"));
|
||||
assert!(!serde_json::to_string(&listed)
|
||||
.unwrap()
|
||||
.contains("JBSWY3DPEHPK3PXP"));
|
||||
let code = codes(&profile).unwrap();
|
||||
assert_eq!(code[0].code.code.len(), 6);
|
||||
|
||||
remove(&profile, "e1").unwrap();
|
||||
assert!(
|
||||
!file_for(&profile).unwrap().exists(),
|
||||
"the last key takes the file with it"
|
||||
);
|
||||
assert!(remove(&profile, "e1")
|
||||
.unwrap_err()
|
||||
.contains("TOTP_ENTRY_NOT_FOUND"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_profile_id_must_be_a_uuid() {
|
||||
assert!(file_for("../../etc/passwd")
|
||||
.unwrap_err()
|
||||
.contains("INVALID_PROFILE_ID"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
//! Time-based one-time passwords (RFC 6238), for the 2FA codes of the
|
||||
//! accounts a profile signs in to.
|
||||
//!
|
||||
//! A key is read from an `otpauth://totp/...` link (what a QR code holds) or
|
||||
//! from the bare Base32 secret most sites show under "can't scan the code".
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "UPPERCASE")]
|
||||
pub enum TotpAlgorithm {
|
||||
#[default]
|
||||
Sha1,
|
||||
Sha256,
|
||||
Sha512,
|
||||
}
|
||||
|
||||
/// A parsed key: everything needed to compute a code.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct TotpKey {
|
||||
/// The shared secret, Base32 (RFC 4648) without padding.
|
||||
pub secret: String,
|
||||
#[serde(default)]
|
||||
pub algorithm: TotpAlgorithm,
|
||||
pub digits: u32,
|
||||
pub period: u64,
|
||||
#[serde(default)]
|
||||
pub issuer: Option<String>,
|
||||
#[serde(default)]
|
||||
pub account: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct TotpCode {
|
||||
pub code: String,
|
||||
/// Unix seconds at which `code` stops being valid.
|
||||
pub valid_until: u64,
|
||||
pub period: u64,
|
||||
}
|
||||
|
||||
/// The input is neither a usable `otpauth://totp` link nor a Base32 secret.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct InvalidTotpKey;
|
||||
|
||||
const MAX_SECRET_BYTES: usize = 128;
|
||||
|
||||
fn decode_base32(input: &str) -> Option<Vec<u8>> {
|
||||
let mut bits: u64 = 0;
|
||||
let mut bit_count = 0u32;
|
||||
let mut out = Vec::with_capacity(input.len() * 5 / 8);
|
||||
for c in input.chars() {
|
||||
if c == '=' || c == ' ' || c == '-' {
|
||||
continue;
|
||||
}
|
||||
let value = match c.to_ascii_uppercase() {
|
||||
c @ 'A'..='Z' => c as u64 - 'A' as u64,
|
||||
c @ '2'..='7' => c as u64 - '2' as u64 + 26,
|
||||
_ => return None,
|
||||
};
|
||||
bits = (bits << 5) | value;
|
||||
bit_count += 5;
|
||||
if bit_count >= 8 {
|
||||
bit_count -= 8;
|
||||
out.push((bits >> bit_count) as u8);
|
||||
bits &= (1 << bit_count) - 1;
|
||||
}
|
||||
}
|
||||
(!out.is_empty() && out.len() <= MAX_SECRET_BYTES).then_some(out)
|
||||
}
|
||||
|
||||
fn normalized_secret(raw: &str) -> Option<String> {
|
||||
decode_base32(raw)?;
|
||||
Some(
|
||||
raw
|
||||
.chars()
|
||||
.filter(|c| !matches!(c, '=' | ' ' | '-'))
|
||||
.map(|c| c.to_ascii_uppercase())
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
impl TotpKey {
|
||||
pub fn parse(input: &str) -> Result<Self, InvalidTotpKey> {
|
||||
let input = input.trim();
|
||||
if input.len() > 2048 {
|
||||
return Err(InvalidTotpKey);
|
||||
}
|
||||
if !input
|
||||
.get(..10)
|
||||
.is_some_and(|scheme| scheme.eq_ignore_ascii_case("otpauth://"))
|
||||
{
|
||||
return Ok(Self {
|
||||
secret: normalized_secret(input).ok_or(InvalidTotpKey)?,
|
||||
algorithm: TotpAlgorithm::Sha1,
|
||||
digits: 6,
|
||||
period: 30,
|
||||
issuer: None,
|
||||
account: None,
|
||||
});
|
||||
}
|
||||
|
||||
let url = url::Url::parse(input).map_err(|_| InvalidTotpKey)?;
|
||||
// HOTP counts presses instead of time and needs a counter kept in step
|
||||
// with the server; it is not what sites use for 2FA codes.
|
||||
if !url
|
||||
.host_str()
|
||||
.is_some_and(|kind| kind.eq_ignore_ascii_case("totp"))
|
||||
{
|
||||
return Err(InvalidTotpKey);
|
||||
}
|
||||
let label = urlencoding::decode(url.path().trim_start_matches('/'))
|
||||
.map(|l| l.into_owned())
|
||||
.unwrap_or_default();
|
||||
let (label_issuer, account) = match label.split_once(':') {
|
||||
Some((issuer, account)) => (Some(issuer.trim().to_string()), account.trim().to_string()),
|
||||
None => (None, label.trim().to_string()),
|
||||
};
|
||||
|
||||
let mut key = Self {
|
||||
secret: String::new(),
|
||||
algorithm: TotpAlgorithm::Sha1,
|
||||
digits: 6,
|
||||
period: 30,
|
||||
issuer: label_issuer.filter(|i| !i.is_empty()),
|
||||
account: Some(account).filter(|a| !a.is_empty()),
|
||||
};
|
||||
for (name, value) in url.query_pairs() {
|
||||
match name.to_ascii_lowercase().as_str() {
|
||||
"secret" => key.secret = normalized_secret(&value).ok_or(InvalidTotpKey)?,
|
||||
"issuer" if !value.trim().is_empty() => key.issuer = Some(value.trim().to_string()),
|
||||
"algorithm" => {
|
||||
key.algorithm = match value.to_ascii_uppercase().as_str() {
|
||||
"SHA1" => TotpAlgorithm::Sha1,
|
||||
"SHA256" => TotpAlgorithm::Sha256,
|
||||
"SHA512" => TotpAlgorithm::Sha512,
|
||||
_ => return Err(InvalidTotpKey),
|
||||
}
|
||||
}
|
||||
"digits" => {
|
||||
key.digits = value.parse().map_err(|_| InvalidTotpKey)?;
|
||||
}
|
||||
"period" => {
|
||||
key.period = value.parse().map_err(|_| InvalidTotpKey)?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if key.secret.is_empty() || !(6..=8).contains(&key.digits) || !(1..=300).contains(&key.period) {
|
||||
return Err(InvalidTotpKey);
|
||||
}
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
/// The code for the time step containing `unix_secs`.
|
||||
pub fn code_at(&self, unix_secs: u64) -> String {
|
||||
let secret = decode_base32(&self.secret).unwrap_or_default();
|
||||
let algorithm = match self.algorithm {
|
||||
TotpAlgorithm::Sha1 => ring::hmac::HMAC_SHA1_FOR_LEGACY_USE_ONLY,
|
||||
TotpAlgorithm::Sha256 => ring::hmac::HMAC_SHA256,
|
||||
TotpAlgorithm::Sha512 => ring::hmac::HMAC_SHA512,
|
||||
};
|
||||
let counter = unix_secs / self.period.max(1);
|
||||
let tag = ring::hmac::sign(
|
||||
&ring::hmac::Key::new(algorithm, &secret),
|
||||
&counter.to_be_bytes(),
|
||||
);
|
||||
let hash = tag.as_ref();
|
||||
let offset = (hash[hash.len() - 1] & 0x0F) as usize;
|
||||
let truncated = u32::from_be_bytes([
|
||||
hash[offset] & 0x7F,
|
||||
hash[offset + 1],
|
||||
hash[offset + 2],
|
||||
hash[offset + 3],
|
||||
]);
|
||||
let code = truncated % 10u32.pow(self.digits);
|
||||
format!("{code:0width$}", width = self.digits as usize)
|
||||
}
|
||||
|
||||
pub fn code_now(&self) -> TotpCode {
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
let period = self.period.max(1);
|
||||
TotpCode {
|
||||
code: self.code_at(now),
|
||||
valid_until: (now / period + 1) * period,
|
||||
period,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The code for a key the person pastes, without keeping the key anywhere.
|
||||
#[tauri::command]
|
||||
pub fn generate_totp_code(key: String) -> Result<TotpCode, String> {
|
||||
TotpKey::parse(&key)
|
||||
.map(|key| key.code_now())
|
||||
.map_err(|_| crate::backend_error("TOTP_KEY_INVALID"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn base32(bytes: &[u8]) -> String {
|
||||
const ALPHABET: &[u8; 32] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
||||
let mut out = String::new();
|
||||
let mut bits: u64 = 0;
|
||||
let mut count = 0;
|
||||
for byte in bytes {
|
||||
bits = (bits << 8) | u64::from(*byte);
|
||||
count += 8;
|
||||
while count >= 5 {
|
||||
count -= 5;
|
||||
out.push(ALPHABET[((bits >> count) & 31) as usize] as char);
|
||||
}
|
||||
}
|
||||
if count > 0 {
|
||||
out.push(ALPHABET[((bits << (5 - count)) & 31) as usize] as char);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn rfc_key(secret: &[u8], algorithm: TotpAlgorithm) -> TotpKey {
|
||||
TotpKey {
|
||||
secret: base32(secret),
|
||||
algorithm,
|
||||
digits: 8,
|
||||
period: 30,
|
||||
issuer: None,
|
||||
account: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// RFC 6238 appendix B.
|
||||
#[test]
|
||||
fn codes_match_the_rfc_6238_vectors() {
|
||||
let sha1 = rfc_key(b"12345678901234567890", TotpAlgorithm::Sha1);
|
||||
let sha256 = rfc_key(b"12345678901234567890123456789012", TotpAlgorithm::Sha256);
|
||||
let sha512 = rfc_key(
|
||||
b"1234567890123456789012345678901234567890123456789012345678901234",
|
||||
TotpAlgorithm::Sha512,
|
||||
);
|
||||
for (time, a, b, c) in [
|
||||
(59, "94287082", "46119246", "90693936"),
|
||||
(1111111109, "07081804", "68084774", "25091201"),
|
||||
(1111111111, "14050471", "67062674", "99943326"),
|
||||
(1234567890, "89005924", "91819424", "93441116"),
|
||||
(2000000000, "69279037", "90698825", "38618901"),
|
||||
(20000000000, "65353130", "77737706", "47863826"),
|
||||
] {
|
||||
assert_eq!(sha1.code_at(time), a, "SHA1 at {time}");
|
||||
assert_eq!(sha256.code_at(time), b, "SHA256 at {time}");
|
||||
assert_eq!(sha512.code_at(time), c, "SHA512 at {time}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bare_secret_is_read_the_way_sites_print_it() {
|
||||
let key = TotpKey::parse(" jbsw y3dp ehpk 3pxp ").unwrap();
|
||||
assert_eq!(key.secret, "JBSWY3DPEHPK3PXP");
|
||||
assert_eq!((key.digits, key.period), (6, 30));
|
||||
assert_eq!(key.algorithm, TotpAlgorithm::Sha1);
|
||||
assert_eq!(
|
||||
TotpKey::parse("JBSWY3DP-EHPK3PXP===").unwrap().secret,
|
||||
"JBSWY3DPEHPK3PXP"
|
||||
);
|
||||
assert_eq!(key.code_at(59).len(), 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_otpauth_link_keeps_its_issuer_account_and_parameters() {
|
||||
let key = TotpKey::parse(
|
||||
"otpauth://totp/Example%20Co:alice@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Example&algorithm=SHA256&digits=8&period=60",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(key.issuer.as_deref(), Some("Example"));
|
||||
assert_eq!(key.account.as_deref(), Some("alice@example.com"));
|
||||
assert_eq!(key.algorithm, TotpAlgorithm::Sha256);
|
||||
assert_eq!((key.digits, key.period), (8, 60));
|
||||
|
||||
let no_issuer = TotpKey::parse("otpauth://totp/bob?secret=JBSWY3DPEHPK3PXP").unwrap();
|
||||
assert_eq!(no_issuer.issuer, None);
|
||||
assert_eq!(no_issuer.account.as_deref(), Some("bob"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unusable_keys_are_refused() {
|
||||
for input in [
|
||||
"",
|
||||
" ",
|
||||
"not base32!",
|
||||
"otpauth://hotp/x?secret=JBSWY3DPEHPK3PXP&counter=1",
|
||||
"otpauth://totp/x",
|
||||
"otpauth://totp/x?secret=JBSWY3DPEHPK3PXP&digits=4",
|
||||
"otpauth://totp/x?secret=JBSWY3DPEHPK3PXP&period=0",
|
||||
"otpauth://totp/x?secret=JBSWY3DPEHPK3PXP&algorithm=MD5",
|
||||
"otpauth://totp/x?secret=0189",
|
||||
] {
|
||||
assert_eq!(TotpKey::parse(input), Err(InvalidTotpKey), "{input:?}");
|
||||
}
|
||||
assert!(
|
||||
TotpKey::parse(&"A".repeat(300)).is_err(),
|
||||
"longer than any real key"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_code_is_valid_until_the_end_of_its_step() {
|
||||
let code = TotpKey::parse("JBSWY3DPEHPK3PXP").unwrap().code_now();
|
||||
assert_eq!(code.valid_until % 30, 0);
|
||||
assert_eq!(code.code.len(), 6);
|
||||
assert!(generate_totp_code("nope!".to_string()).is_err());
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,7 @@ import { SynchronizerPanel } from "@/components/synchronizer-panel";
|
||||
import { TableFilterControls } from "@/components/table-controls";
|
||||
import { ThankYouDialog } from "@/components/thank-you-dialog";
|
||||
import { TipsDialog } from "@/components/tips-dialog";
|
||||
import { TotpToolDialog } from "@/components/totp-code";
|
||||
import { TrashPage } from "@/components/trash-page";
|
||||
import { WayfernConfigDialog } from "@/components/wayfern-config-dialog";
|
||||
import { WayfernTermsDialog } from "@/components/wayfern-terms-dialog";
|
||||
@@ -449,6 +450,7 @@ export default function Home() {
|
||||
"api" | "remote"
|
||||
>("api");
|
||||
const [cookieBotDialogOpen, setCookieBotDialogOpen] = useState(false);
|
||||
const [totpToolOpen, setTotpToolOpen] = useState(false);
|
||||
const [cookieBotInitialTab, setCookieBotInitialTab] =
|
||||
useState<CookieBotTab>("overview");
|
||||
const [agentDialogOpen, setAgentDialogOpen] = useState(false);
|
||||
@@ -777,6 +779,9 @@ export default function Home() {
|
||||
case "goTrash":
|
||||
handleRailNavigate("trash");
|
||||
break;
|
||||
case "openTotp":
|
||||
setTotpToolOpen(true);
|
||||
break;
|
||||
}
|
||||
},
|
||||
[
|
||||
@@ -2513,6 +2518,11 @@ export default function Home() {
|
||||
/>
|
||||
)}
|
||||
|
||||
<TotpToolDialog
|
||||
isOpen={totpToolOpen}
|
||||
onClose={() => setTotpToolOpen(false)}
|
||||
/>
|
||||
|
||||
{cookieBotDialogOpen && (
|
||||
<CookieBotPage
|
||||
isOpen={cookieBotDialogOpen}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
LuCookie,
|
||||
LuInfo,
|
||||
LuKeyboard,
|
||||
LuKeyRound,
|
||||
LuLightbulb,
|
||||
LuMessageSquareHeart,
|
||||
LuPlay,
|
||||
@@ -69,6 +70,7 @@ const ICONS: Record<ShortcutId, React.ComponentType<{ className?: string }>> = {
|
||||
openShortcuts: LuKeyboard,
|
||||
openTips: LuLightbulb,
|
||||
sendFeedback: LuMessageSquareHeart,
|
||||
openTotp: LuKeyRound,
|
||||
importProfile: FaDownload,
|
||||
goProfiles: LuUser,
|
||||
goProxies: FiWifi,
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
LuGroup,
|
||||
LuHouse,
|
||||
LuKey,
|
||||
LuKeyRound,
|
||||
LuLink,
|
||||
LuLock,
|
||||
LuLockOpen,
|
||||
@@ -47,6 +48,7 @@ import { ProfileLaunchActivity } from "@/components/profile-launch-activity";
|
||||
import { ProfileMetadataCard } from "@/components/profile-metadata-card";
|
||||
import { ExportProfileSection } from "@/components/profile-transfer";
|
||||
import { SharedFingerprintConfigForm } from "@/components/shared-fingerprint-config-form";
|
||||
import { ProfileTotpSection } from "@/components/totp-code";
|
||||
import { AnimatedSwitch } from "@/components/ui/animated-switch";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -971,6 +973,7 @@ type ProfileSection =
|
||||
| "automation"
|
||||
| "transfer"
|
||||
| "security"
|
||||
| "twoFactor"
|
||||
| "delete";
|
||||
|
||||
function ProfileInfoLayout({
|
||||
@@ -1135,6 +1138,11 @@ function ProfileInfoLayout({
|
||||
icon: <LuKey className="size-3.5" />,
|
||||
label: t("profileInfo.sections.security"),
|
||||
},
|
||||
{
|
||||
id: "twoFactor",
|
||||
icon: <LuKeyRound className="size-3.5" />,
|
||||
label: t("profileInfo.sections.twoFactor"),
|
||||
},
|
||||
];
|
||||
const section = sidebarItems.some(
|
||||
(item) => item.id === requestedSection && !item.hidden,
|
||||
@@ -1487,6 +1495,16 @@ function ProfileInfoLayout({
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
|
||||
{section === "twoFactor" && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||
<LuKeyRound className="size-4" />
|
||||
{t("profileInfo.sections.twoFactor")}
|
||||
</div>
|
||||
<ProfileTotpSection profileId={profile.id} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
"use client";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuTrash2 } from "react-icons/lu";
|
||||
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CopyToClipboard } from "@/components/ui/copy-to-clipboard";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { translateBackendError } from "@/lib/backend-errors";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** `TotpCode` in `src-tauri/src/totp.rs`. */
|
||||
interface TotpCode {
|
||||
code: string;
|
||||
valid_until: number;
|
||||
period: number;
|
||||
}
|
||||
|
||||
/** `TotpEntry` in `src-tauri/src/profile_totp.rs`: everything but the key. */
|
||||
interface TotpEntry {
|
||||
id: string;
|
||||
label: string;
|
||||
issuer: string | null;
|
||||
account: string | null;
|
||||
digits: number;
|
||||
period: number;
|
||||
}
|
||||
|
||||
type TotpEntryCode = TotpCode & { id: string };
|
||||
|
||||
/** Seconds since the epoch, ticking once a second. */
|
||||
function useNow(): number {
|
||||
const [now, setNow] = React.useState(() => Math.floor(Date.now() / 1000));
|
||||
React.useEffect(() => {
|
||||
const timer = window.setInterval(() => {
|
||||
setNow(Math.floor(Date.now() / 1000));
|
||||
}, 1000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, []);
|
||||
return now;
|
||||
}
|
||||
|
||||
/** Groups of three, the way authenticator apps show a code. */
|
||||
function spaced(code: string): string {
|
||||
return code.length > 6
|
||||
? `${code.slice(0, 4)} ${code.slice(4)}`
|
||||
: `${code.slice(0, 3)} ${code.slice(3)}`;
|
||||
}
|
||||
|
||||
function CodeView({ code, now }: { code: TotpCode; now: number }) {
|
||||
const { t } = useTranslation();
|
||||
const remaining = Math.max(0, code.valid_until - now);
|
||||
return (
|
||||
<div className="flex items-center gap-3" data-slot="totp-code">
|
||||
<span className="font-mono text-2xl tracking-wider tabular-nums">
|
||||
{spaced(code.code)}
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<div className="h-1 overflow-hidden rounded-full bg-foreground/8">
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-full transition-[width] duration-1000 ease-linear",
|
||||
remaining <= 5 ? "bg-warning" : "bg-primary",
|
||||
)}
|
||||
style={{ width: `${(remaining / code.period) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground tabular-nums">
|
||||
{t("totp.expiresIn", { seconds: remaining })}
|
||||
</span>
|
||||
</div>
|
||||
<CopyToClipboard
|
||||
text={code.code}
|
||||
size="sm"
|
||||
variant="soft"
|
||||
successMessage={t("totp.copied")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn a pasted key into a code without keeping it: the key lives in this
|
||||
* dialog's state and nowhere else.
|
||||
*/
|
||||
export function TotpToolDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const now = useNow();
|
||||
const [key, setKey] = React.useState("");
|
||||
const [code, setCode] = React.useState<TotpCode | null>(null);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setKey("");
|
||||
setCode(null);
|
||||
setError(null);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const expired = code !== null && now >= code.valid_until;
|
||||
React.useEffect(() => {
|
||||
if (!key.trim()) {
|
||||
setCode(null);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
if (code && !expired) return;
|
||||
let current = true;
|
||||
invoke<TotpCode>("generate_totp_code", { key })
|
||||
.then((next) => {
|
||||
if (!current) return;
|
||||
setCode(next);
|
||||
setError(null);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (!current) return;
|
||||
setCode(null);
|
||||
setError(translateBackendError(t, err));
|
||||
});
|
||||
return () => {
|
||||
current = false;
|
||||
};
|
||||
}, [key, expired, code, t]);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="max-w-md" data-slot="totp-tool-dialog">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("totp.toolTitle")}</DialogTitle>
|
||||
<DialogDescription>{t("totp.toolDescription")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="totp-tool-key">{t("totp.keyLabel")}</Label>
|
||||
<Input
|
||||
id="totp-tool-key"
|
||||
type="password"
|
||||
value={key}
|
||||
onChange={(event) => {
|
||||
setKey(event.target.value);
|
||||
setCode(null);
|
||||
}}
|
||||
placeholder={t("totp.keyPlaceholder")}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
{error && <p className="text-xs text-destructive-text">{error}</p>}
|
||||
{code && <CodeView code={code} now={now} />}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/** The profile's saved 2FA keys, shown as live codes. */
|
||||
export function ProfileTotpSection({
|
||||
profileId,
|
||||
isDisabled,
|
||||
}: {
|
||||
profileId: string;
|
||||
isDisabled?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const now = useNow();
|
||||
const [entries, setEntries] = React.useState<TotpEntry[]>([]);
|
||||
const [codes, setCodes] = React.useState<Map<string, TotpEntryCode>>(
|
||||
new Map(),
|
||||
);
|
||||
const [label, setLabel] = React.useState("");
|
||||
const [key, setKey] = React.useState("");
|
||||
const [isAdding, setIsAdding] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [deleting, setDeleting] = React.useState<TotpEntry | null>(null);
|
||||
|
||||
const load = React.useCallback(async () => {
|
||||
try {
|
||||
const [nextEntries, nextCodes] = await Promise.all([
|
||||
invoke<TotpEntry[]>("list_profile_totp", { profileId }),
|
||||
invoke<TotpEntryCode[]>("get_profile_totp_codes", { profileId }),
|
||||
]);
|
||||
setEntries(nextEntries);
|
||||
setCodes(new Map(nextCodes.map((code) => [code.id, code])));
|
||||
} catch (err) {
|
||||
setError(translateBackendError(t, err));
|
||||
}
|
||||
}, [profileId, t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const anyExpired = [...codes.values()].some(
|
||||
(code) => now >= code.valid_until,
|
||||
);
|
||||
React.useEffect(() => {
|
||||
if (!anyExpired) return;
|
||||
void invoke<TotpEntryCode[]>("get_profile_totp_codes", { profileId })
|
||||
.then((next) => setCodes(new Map(next.map((code) => [code.id, code]))))
|
||||
.catch(() => {});
|
||||
}, [anyExpired, profileId]);
|
||||
|
||||
const add = async () => {
|
||||
setIsAdding(true);
|
||||
setError(null);
|
||||
try {
|
||||
await invoke("add_profile_totp", { profileId, label, key });
|
||||
setLabel("");
|
||||
setKey("");
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(translateBackendError(t, err));
|
||||
} finally {
|
||||
setIsAdding(false);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!deleting) return;
|
||||
try {
|
||||
await invoke("delete_profile_totp", {
|
||||
profileId,
|
||||
entryId: deleting.id,
|
||||
});
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(translateBackendError(t, err));
|
||||
} finally {
|
||||
setDeleting(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3" data-slot="profile-totp-section">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("totp.sectionDescription")}
|
||||
</p>
|
||||
{entries.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t("totp.empty")}</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{entries.map((entry) => {
|
||||
const code = codes.get(entry.id);
|
||||
return (
|
||||
<li
|
||||
key={entry.id}
|
||||
className="flex flex-col gap-1 rounded-lg bg-foreground/4 p-3"
|
||||
data-slot="profile-totp-entry"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium">
|
||||
{entry.label}
|
||||
{entry.account && entry.account !== entry.label && (
|
||||
<span className="ml-2 text-xs font-normal text-muted-foreground">
|
||||
{entry.account}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="subtle"
|
||||
size="icon"
|
||||
className="size-7"
|
||||
aria-label={t("totp.delete")}
|
||||
onClick={() => setDeleting(entry)}
|
||||
>
|
||||
<LuTrash2 className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
{code && <CodeView code={code} now={now} />}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
<form
|
||||
className="flex flex-col gap-2"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void add();
|
||||
}}
|
||||
>
|
||||
<div className="grid gap-2 sm:grid-cols-[1fr_2fr]">
|
||||
<Input
|
||||
variant="soft"
|
||||
value={label}
|
||||
onChange={(event) => setLabel(event.target.value)}
|
||||
placeholder={t("totp.labelPlaceholder")}
|
||||
aria-label={t("totp.labelPlaceholder")}
|
||||
disabled={isDisabled || isAdding}
|
||||
/>
|
||||
<Input
|
||||
variant="soft"
|
||||
type="password"
|
||||
data-slot="profile-totp-key"
|
||||
value={key}
|
||||
onChange={(event) => setKey(event.target.value)}
|
||||
placeholder={t("totp.keyPlaceholder")}
|
||||
aria-label={t("totp.keyLabel")}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
disabled={isDisabled || isAdding}
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-xs text-destructive-text">{error}</p>}
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
className="h-7 self-start text-xs"
|
||||
disabled={isDisabled || isAdding || !key.trim()}
|
||||
>
|
||||
{t("totp.add")}
|
||||
</Button>
|
||||
</form>
|
||||
<DeleteConfirmationDialog
|
||||
isOpen={deleting !== null}
|
||||
onClose={() => setDeleting(null)}
|
||||
onConfirm={confirmDelete}
|
||||
title={t("totp.deleteTitle", { label: deleting?.label ?? "" })}
|
||||
description={t("totp.deleteDescription")}
|
||||
confirmButtonText={t("common.buttons.delete")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1512,7 +1512,8 @@
|
||||
"activity": "Aktivität",
|
||||
"launchHook": "Start-Hook",
|
||||
"transfer": "Übertragung",
|
||||
"startPages": "Startseiten"
|
||||
"startPages": "Startseiten",
|
||||
"twoFactor": "2FA-Codes"
|
||||
},
|
||||
"sectionDesc": {
|
||||
"fingerprint": "Legen Sie fest, wie dieses Profil für Fingerprinting-Skripte erscheint.",
|
||||
@@ -1759,6 +1760,21 @@
|
||||
"description": "Seiten, die jeder Start aus Donut öffnet, eine pro Zeile, statt der letzten Sitzung. Ein Lauf mit eigener URL öffnet nur diese URL.",
|
||||
"placeholder": "https://mail.example.com\nhttps://shop.example.com"
|
||||
},
|
||||
"totp": {
|
||||
"toolTitle": "2FA-Code",
|
||||
"toolDescription": "Fügen Sie einen 2FA-Schlüssel ein, um den aktuellen Code zu sehen. Donut speichert den Schlüssel nicht.",
|
||||
"keyLabel": "2FA-Schlüssel",
|
||||
"keyPlaceholder": "Geheimer Schlüssel oder otpauth://-Link",
|
||||
"expiresIn": "Wechselt in {{seconds}} s",
|
||||
"copied": "Code kopiert",
|
||||
"sectionDescription": "Codes für die Konten, bei denen sich dieses Profil anmeldet. Die Schlüssel bleiben verschlüsselt auf diesem Gerät. Sie werden nie synchronisiert, exportiert oder an die API gegeben.",
|
||||
"empty": "Noch keine 2FA-Schlüssel.",
|
||||
"labelPlaceholder": "Konto (optional)",
|
||||
"add": "2FA-Schlüssel hinzufügen",
|
||||
"delete": "2FA-Schlüssel löschen",
|
||||
"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."
|
||||
},
|
||||
"vpns": {
|
||||
"form": {
|
||||
"titleEdit": "VPN bearbeiten",
|
||||
@@ -2494,7 +2510,11 @@
|
||||
"proxyRotationTimeout": "Der Anbieter hat innerhalb von 30 Sekunden nicht auf den IP-Wechsel geantwortet.",
|
||||
"proxyRotationUnreachable": "Donut konnte den Link zum IP-Wechsel des Anbieters nicht erreichen.",
|
||||
"startUrlInvalid": "„{{url}}“ ist keine Webseite. Startseiten müssen http://- oder https://-Adressen sein.",
|
||||
"startUrlsTooMany": "Ein Profil kann höchstens 20 Startseiten haben."
|
||||
"startUrlsTooMany": "Ein Profil kann höchstens 20 Startseiten haben.",
|
||||
"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."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Profile",
|
||||
@@ -2635,7 +2655,8 @@
|
||||
"goTrash": "Zum Papierkorb wechseln",
|
||||
"goAgent": "Zu „Agent“ wechseln",
|
||||
"openTips": "Tipps öffnen",
|
||||
"sendFeedback": "Feedback senden"
|
||||
"sendFeedback": "Feedback senden",
|
||||
"openTotp": "2FA-Code aus einem Schlüssel"
|
||||
},
|
||||
"closeConfirm": {
|
||||
"title": "Donut Browser schließen?",
|
||||
|
||||
@@ -1512,7 +1512,8 @@
|
||||
"activity": "Activity",
|
||||
"launchHook": "Launch hook",
|
||||
"transfer": "Transfer",
|
||||
"startPages": "Start pages"
|
||||
"startPages": "Start pages",
|
||||
"twoFactor": "2FA codes"
|
||||
},
|
||||
"sectionDesc": {
|
||||
"fingerprint": "Configure how this profile appears to fingerprinting scripts.",
|
||||
@@ -1759,6 +1760,21 @@
|
||||
"description": "Pages that every launch from Donut opens, one per line, instead of the last session. A run with its own URL opens only that URL.",
|
||||
"placeholder": "https://mail.example.com\nhttps://shop.example.com"
|
||||
},
|
||||
"totp": {
|
||||
"toolTitle": "2FA code",
|
||||
"toolDescription": "Paste a 2FA key to see its current code. Donut does not keep the key.",
|
||||
"keyLabel": "2FA key",
|
||||
"keyPlaceholder": "Secret key or otpauth:// link",
|
||||
"expiresIn": "Changes in {{seconds}} s",
|
||||
"copied": "Code copied",
|
||||
"sectionDescription": "Codes for the accounts this profile signs in to. The keys stay on this device, encrypted. They are never synced, exported or given to the API.",
|
||||
"empty": "No 2FA keys yet.",
|
||||
"labelPlaceholder": "Account (optional)",
|
||||
"add": "Add 2FA key",
|
||||
"delete": "Delete 2FA key",
|
||||
"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."
|
||||
},
|
||||
"vpns": {
|
||||
"form": {
|
||||
"titleEdit": "Edit VPN",
|
||||
@@ -2494,7 +2510,11 @@
|
||||
"proxyRotationTimeout": "The provider did not answer the IP change within 30 seconds.",
|
||||
"proxyRotationUnreachable": "Donut could not reach the provider's change IP link.",
|
||||
"startUrlInvalid": "\"{{url}}\" is not a web page. Start pages must be http:// or https:// addresses.",
|
||||
"startUrlsTooMany": "A profile can have at most 20 start pages."
|
||||
"startUrlsTooMany": "A profile can have at most 20 start pages.",
|
||||
"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."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Profiles",
|
||||
@@ -2635,7 +2655,8 @@
|
||||
"goTrash": "Go to Trash",
|
||||
"goAgent": "Go to Agent",
|
||||
"openTips": "Open tips",
|
||||
"sendFeedback": "Send feedback"
|
||||
"sendFeedback": "Send feedback",
|
||||
"openTotp": "2FA code from a key"
|
||||
},
|
||||
"closeConfirm": {
|
||||
"title": "Close Donut Browser?",
|
||||
|
||||
@@ -1519,7 +1519,8 @@
|
||||
"activity": "Actividad",
|
||||
"launchHook": "Hook de inicio",
|
||||
"transfer": "Transferir",
|
||||
"startPages": "Páginas de inicio"
|
||||
"startPages": "Páginas de inicio",
|
||||
"twoFactor": "Códigos 2FA"
|
||||
},
|
||||
"sectionDesc": {
|
||||
"fingerprint": "Configura cómo aparece este perfil para los scripts de fingerprinting.",
|
||||
@@ -1766,6 +1767,21 @@
|
||||
"description": "Páginas que abre cada inicio desde Donut, una por línea, en lugar de la última sesión. Una ejecución con su propia URL abre solo esa URL.",
|
||||
"placeholder": "https://mail.example.com\nhttps://shop.example.com"
|
||||
},
|
||||
"totp": {
|
||||
"toolTitle": "Código 2FA",
|
||||
"toolDescription": "Pega una clave 2FA para ver su código actual. Donut no guarda la clave.",
|
||||
"keyLabel": "Clave 2FA",
|
||||
"keyPlaceholder": "Clave secreta o enlace otpauth://",
|
||||
"expiresIn": "Cambia en {{seconds}} s",
|
||||
"copied": "Código copiado",
|
||||
"sectionDescription": "Códigos de las cuentas en las que inicia sesión este perfil. Las claves se quedan cifradas en este dispositivo. Nunca se sincronizan, exportan ni se dan a la API.",
|
||||
"empty": "Aún no hay claves 2FA.",
|
||||
"labelPlaceholder": "Cuenta (opcional)",
|
||||
"add": "Añadir clave 2FA",
|
||||
"delete": "Eliminar clave 2FA",
|
||||
"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."
|
||||
},
|
||||
"vpns": {
|
||||
"form": {
|
||||
"titleEdit": "Editar VPN",
|
||||
@@ -2505,7 +2521,11 @@
|
||||
"proxyRotationTimeout": "El proveedor no respondió al cambio de IP en 30 segundos.",
|
||||
"proxyRotationUnreachable": "Donut no pudo acceder al enlace para cambiar IP del proveedor.",
|
||||
"startUrlInvalid": "«{{url}}» no es una página web. Las páginas de inicio deben ser direcciones http:// o https://.",
|
||||
"startUrlsTooMany": "Un perfil puede tener como máximo 20 páginas de inicio."
|
||||
"startUrlsTooMany": "Un perfil puede tener como máximo 20 páginas de inicio.",
|
||||
"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."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Perfiles",
|
||||
@@ -2646,7 +2666,8 @@
|
||||
"goTrash": "Ir a la Papelera",
|
||||
"goAgent": "Ir a Agente",
|
||||
"openTips": "Abrir consejos",
|
||||
"sendFeedback": "Enviar comentarios"
|
||||
"sendFeedback": "Enviar comentarios",
|
||||
"openTotp": "Código 2FA desde una clave"
|
||||
},
|
||||
"closeConfirm": {
|
||||
"title": "¿Cerrar Donut Browser?",
|
||||
|
||||
@@ -1519,7 +1519,8 @@
|
||||
"activity": "Activité",
|
||||
"launchHook": "Hook de lancement",
|
||||
"transfer": "Transfert",
|
||||
"startPages": "Pages de démarrage"
|
||||
"startPages": "Pages de démarrage",
|
||||
"twoFactor": "Codes 2FA"
|
||||
},
|
||||
"sectionDesc": {
|
||||
"fingerprint": "Configurez l’apparence de ce profil pour les scripts de fingerprinting.",
|
||||
@@ -1766,6 +1767,21 @@
|
||||
"description": "Pages que chaque lancement depuis Donut ouvre, une par ligne, à la place de la dernière session. Une exécution avec sa propre URL n'ouvre que cette URL.",
|
||||
"placeholder": "https://mail.example.com\nhttps://shop.example.com"
|
||||
},
|
||||
"totp": {
|
||||
"toolTitle": "Code 2FA",
|
||||
"toolDescription": "Collez une clé 2FA pour voir son code actuel. Donut ne garde pas la clé.",
|
||||
"keyLabel": "Clé 2FA",
|
||||
"keyPlaceholder": "Clé secrète ou lien otpauth://",
|
||||
"expiresIn": "Change dans {{seconds}} s",
|
||||
"copied": "Code copié",
|
||||
"sectionDescription": "Codes des comptes auxquels ce profil se connecte. Les clés restent chiffrées sur cet appareil. Elles ne sont jamais synchronisées, exportées ni données à l'API.",
|
||||
"empty": "Aucune clé 2FA pour l'instant.",
|
||||
"labelPlaceholder": "Compte (facultatif)",
|
||||
"add": "Ajouter une clé 2FA",
|
||||
"delete": "Supprimer la clé 2FA",
|
||||
"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."
|
||||
},
|
||||
"vpns": {
|
||||
"form": {
|
||||
"titleEdit": "Modifier le VPN",
|
||||
@@ -2505,7 +2521,11 @@
|
||||
"proxyRotationTimeout": "Le fournisseur n'a pas répondu au changement d'IP en 30 secondes.",
|
||||
"proxyRotationUnreachable": "Donut n'a pas pu joindre le lien de changement d'IP du fournisseur.",
|
||||
"startUrlInvalid": "« {{url}} » n'est pas une page web. Les pages de démarrage doivent être des adresses http:// ou https://.",
|
||||
"startUrlsTooMany": "Un profil peut avoir au plus 20 pages de démarrage."
|
||||
"startUrlsTooMany": "Un profil peut avoir au plus 20 pages de démarrage.",
|
||||
"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."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Profils",
|
||||
@@ -2646,7 +2666,8 @@
|
||||
"goTrash": "Aller à la Corbeille",
|
||||
"goAgent": "Aller à Agent",
|
||||
"openTips": "Ouvrir les astuces",
|
||||
"sendFeedback": "Envoyer un avis"
|
||||
"sendFeedback": "Envoyer un avis",
|
||||
"openTotp": "Code 2FA à partir d'une clé"
|
||||
},
|
||||
"closeConfirm": {
|
||||
"title": "Fermer Donut Browser ?",
|
||||
|
||||
@@ -1512,7 +1512,8 @@
|
||||
"activity": "アクティビティ",
|
||||
"launchHook": "起動フック",
|
||||
"transfer": "転送",
|
||||
"startPages": "スタートページ"
|
||||
"startPages": "スタートページ",
|
||||
"twoFactor": "2FA コード"
|
||||
},
|
||||
"sectionDesc": {
|
||||
"fingerprint": "フィンガープリント対策スクリプトに対するこのプロファイルの表示を設定します。",
|
||||
@@ -1759,6 +1760,21 @@
|
||||
"description": "Donut から起動するたびに前回のセッションの代わりに開くページです。1 行に 1 つ入力します。独自の URL を指定した実行では、その URL だけが開きます。",
|
||||
"placeholder": "https://mail.example.com\nhttps://shop.example.com"
|
||||
},
|
||||
"totp": {
|
||||
"toolTitle": "2FA コード",
|
||||
"toolDescription": "2FA キーを貼り付けると現在のコードを表示します。Donut はキーを保存しません。",
|
||||
"keyLabel": "2FA キー",
|
||||
"keyPlaceholder": "シークレットキーまたは otpauth:// リンク",
|
||||
"expiresIn": "あと {{seconds}} 秒で更新",
|
||||
"copied": "コードをコピーしました",
|
||||
"sectionDescription": "このプロファイルがログインするアカウントのコードです。キーはこのデバイスに暗号化して保存され、同期、エクスポート、API への提供は一切されません。",
|
||||
"empty": "2FA キーはまだありません。",
|
||||
"labelPlaceholder": "アカウント (任意)",
|
||||
"add": "2FA キーを追加",
|
||||
"delete": "2FA キーを削除",
|
||||
"deleteTitle": "「{{label}}」の 2FA キーを削除しますか?",
|
||||
"deleteDescription": "先にキーのコピーを別の場所に保管してください。保管しないとアカウントにアクセスできなくなる場合があります。"
|
||||
},
|
||||
"vpns": {
|
||||
"form": {
|
||||
"titleEdit": "VPN を編集",
|
||||
@@ -2494,7 +2510,11 @@
|
||||
"proxyRotationTimeout": "プロバイダーが 30 秒以内に IP 変更に応答しませんでした。",
|
||||
"proxyRotationUnreachable": "プロバイダーの IP 変更リンクに接続できませんでした。",
|
||||
"startUrlInvalid": "「{{url}}」はウェブページではありません。スタートページは http:// または https:// のアドレスにしてください。",
|
||||
"startUrlsTooMany": "1 つのプロファイルに設定できるスタートページは 20 件までです。"
|
||||
"startUrlsTooMany": "1 つのプロファイルに設定できるスタートページは 20 件までです。",
|
||||
"totpKeyInvalid": "2FA キーではありません。サイトに表示されるシークレットキーまたは otpauth:// リンクを貼り付けてください。",
|
||||
"totpLabelTooLong": "ラベルは 60 文字以内にしてください。",
|
||||
"totpLimitReached": "1 つのプロファイルに保存できる 2FA キーは 50 個までです。",
|
||||
"totpEntryNotFound": "この 2FA キーはもう存在しません。"
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "プロファイル",
|
||||
@@ -2635,7 +2655,8 @@
|
||||
"goTrash": "ゴミ箱へ移動",
|
||||
"goAgent": "エージェントへ移動",
|
||||
"openTips": "ヒントを開く",
|
||||
"sendFeedback": "フィードバックを送信"
|
||||
"sendFeedback": "フィードバックを送信",
|
||||
"openTotp": "キーから 2FA コードを表示"
|
||||
},
|
||||
"closeConfirm": {
|
||||
"title": "Donut Browser を閉じますか?",
|
||||
|
||||
@@ -1512,7 +1512,8 @@
|
||||
"activity": "활동",
|
||||
"launchHook": "실행 후크",
|
||||
"transfer": "전송",
|
||||
"startPages": "시작 페이지"
|
||||
"startPages": "시작 페이지",
|
||||
"twoFactor": "2FA 코드"
|
||||
},
|
||||
"sectionDesc": {
|
||||
"fingerprint": "이 프로필이 핑거프린팅 스크립트에 어떻게 표시되는지 구성합니다.",
|
||||
@@ -1759,6 +1760,21 @@
|
||||
"description": "Donut에서 실행할 때마다 마지막 세션 대신 여는 페이지입니다. 한 줄에 하나씩 입력하세요. 자체 URL이 있는 실행은 그 URL만 엽니다.",
|
||||
"placeholder": "https://mail.example.com\nhttps://shop.example.com"
|
||||
},
|
||||
"totp": {
|
||||
"toolTitle": "2FA 코드",
|
||||
"toolDescription": "2FA 키를 붙여 넣으면 현재 코드를 보여 줍니다. Donut은 키를 저장하지 않습니다.",
|
||||
"keyLabel": "2FA 키",
|
||||
"keyPlaceholder": "비밀 키 또는 otpauth:// 링크",
|
||||
"expiresIn": "{{seconds}}초 후 변경",
|
||||
"copied": "코드를 복사했습니다",
|
||||
"sectionDescription": "이 프로필이 로그인하는 계정의 코드입니다. 키는 이 기기에 암호화되어 남으며, 동기화, 내보내기, API 제공은 절대 되지 않습니다.",
|
||||
"empty": "아직 2FA 키가 없습니다.",
|
||||
"labelPlaceholder": "계정 (선택 사항)",
|
||||
"add": "2FA 키 추가",
|
||||
"delete": "2FA 키 삭제",
|
||||
"deleteTitle": "\"{{label}}\"의 2FA 키를 삭제할까요?",
|
||||
"deleteDescription": "먼저 키 사본을 다른 곳에 보관하세요. 그렇지 않으면 계정에 접근하지 못할 수 있습니다."
|
||||
},
|
||||
"vpns": {
|
||||
"form": {
|
||||
"titleEdit": "VPN 편집",
|
||||
@@ -2494,7 +2510,11 @@
|
||||
"proxyRotationTimeout": "공급자가 30초 안에 IP 변경에 응답하지 않았습니다.",
|
||||
"proxyRotationUnreachable": "공급자의 IP 변경 링크에 연결할 수 없습니다.",
|
||||
"startUrlInvalid": "\"{{url}}\"은(는) 웹 페이지가 아닙니다. 시작 페이지는 http:// 또는 https:// 주소여야 합니다.",
|
||||
"startUrlsTooMany": "프로필 하나에 시작 페이지는 최대 20개까지 가능합니다."
|
||||
"startUrlsTooMany": "프로필 하나에 시작 페이지는 최대 20개까지 가능합니다.",
|
||||
"totpKeyInvalid": "2FA 키가 아닙니다. 사이트에 표시된 비밀 키나 otpauth:// 링크를 붙여 넣으세요.",
|
||||
"totpLabelTooLong": "라벨은 최대 60자까지 가능합니다.",
|
||||
"totpLimitReached": "프로필 하나에 2FA 키는 최대 50개까지 저장할 수 있습니다.",
|
||||
"totpEntryNotFound": "이 2FA 키는 더 이상 존재하지 않습니다."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "프로필",
|
||||
@@ -2635,7 +2655,8 @@
|
||||
"goTrash": "휴지통으로 이동",
|
||||
"goAgent": "에이전트로 이동",
|
||||
"openTips": "팁 열기",
|
||||
"sendFeedback": "피드백 보내기"
|
||||
"sendFeedback": "피드백 보내기",
|
||||
"openTotp": "키로 2FA 코드 보기"
|
||||
},
|
||||
"closeConfirm": {
|
||||
"title": "Donut Browser를 닫으시겠습니까?",
|
||||
|
||||
@@ -1519,7 +1519,8 @@
|
||||
"activity": "Atividade",
|
||||
"launchHook": "Hook de inicialização",
|
||||
"transfer": "Transferência",
|
||||
"startPages": "Páginas iniciais"
|
||||
"startPages": "Páginas iniciais",
|
||||
"twoFactor": "Códigos 2FA"
|
||||
},
|
||||
"sectionDesc": {
|
||||
"fingerprint": "Configure como este perfil aparece para scripts de fingerprinting.",
|
||||
@@ -1766,6 +1767,21 @@
|
||||
"description": "Páginas que cada inicialização pelo Donut abre, uma por linha, em vez da última sessão. Uma execução com URL própria abre só essa URL.",
|
||||
"placeholder": "https://mail.example.com\nhttps://shop.example.com"
|
||||
},
|
||||
"totp": {
|
||||
"toolTitle": "Código 2FA",
|
||||
"toolDescription": "Cole uma chave 2FA para ver o código atual. O Donut não guarda a chave.",
|
||||
"keyLabel": "Chave 2FA",
|
||||
"keyPlaceholder": "Chave secreta ou link otpauth://",
|
||||
"expiresIn": "Muda em {{seconds}} s",
|
||||
"copied": "Código copiado",
|
||||
"sectionDescription": "Códigos das contas em que este perfil entra. As chaves ficam criptografadas neste dispositivo. Nunca são sincronizadas, exportadas ou dadas à API.",
|
||||
"empty": "Ainda não há chaves 2FA.",
|
||||
"labelPlaceholder": "Conta (opcional)",
|
||||
"add": "Adicionar chave 2FA",
|
||||
"delete": "Excluir chave 2FA",
|
||||
"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."
|
||||
},
|
||||
"vpns": {
|
||||
"form": {
|
||||
"titleEdit": "Editar VPN",
|
||||
@@ -2505,7 +2521,11 @@
|
||||
"proxyRotationTimeout": "O provedor não respondeu à troca de IP em 30 segundos.",
|
||||
"proxyRotationUnreachable": "O Donut não conseguiu acessar o link de troca de IP do provedor.",
|
||||
"startUrlInvalid": "\"{{url}}\" não é uma página da web. As páginas iniciais precisam ser endereços http:// ou https://.",
|
||||
"startUrlsTooMany": "Um perfil pode ter no máximo 20 páginas iniciais."
|
||||
"startUrlsTooMany": "Um perfil pode ter no máximo 20 páginas iniciais.",
|
||||
"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."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Perfis",
|
||||
@@ -2646,7 +2666,8 @@
|
||||
"goTrash": "Ir para a Lixeira",
|
||||
"goAgent": "Ir para Agente",
|
||||
"openTips": "Abrir dicas",
|
||||
"sendFeedback": "Enviar feedback"
|
||||
"sendFeedback": "Enviar feedback",
|
||||
"openTotp": "Código 2FA a partir de uma chave"
|
||||
},
|
||||
"closeConfirm": {
|
||||
"title": "Fechar Donut Browser?",
|
||||
|
||||
@@ -1526,7 +1526,8 @@
|
||||
"activity": "Активность",
|
||||
"launchHook": "Хук запуска",
|
||||
"transfer": "Перенос",
|
||||
"startPages": "Стартовые страницы"
|
||||
"startPages": "Стартовые страницы",
|
||||
"twoFactor": "Коды 2FA"
|
||||
},
|
||||
"sectionDesc": {
|
||||
"fingerprint": "Настройте, как этот профиль выглядит для скриптов отпечатков.",
|
||||
@@ -1773,6 +1774,21 @@
|
||||
"description": "Страницы, которые открывает каждый запуск из Donut вместо прошлой сессии, по одной на строку. Запуск со своим URL открывает только этот URL.",
|
||||
"placeholder": "https://mail.example.com\nhttps://shop.example.com"
|
||||
},
|
||||
"totp": {
|
||||
"toolTitle": "Код 2FA",
|
||||
"toolDescription": "Вставьте ключ 2FA, чтобы увидеть текущий код. Donut не сохраняет ключ.",
|
||||
"keyLabel": "Ключ 2FA",
|
||||
"keyPlaceholder": "Секретный ключ или ссылка otpauth://",
|
||||
"expiresIn": "Сменится через {{seconds}} с",
|
||||
"copied": "Код скопирован",
|
||||
"sectionDescription": "Коды аккаунтов, в которые входит этот профиль. Ключи хранятся на этом устройстве в зашифрованном виде. Они никогда не синхронизируются, не экспортируются и не передаются в API.",
|
||||
"empty": "Ключей 2FA пока нет.",
|
||||
"labelPlaceholder": "Аккаунт (необязательно)",
|
||||
"add": "Добавить ключ 2FA",
|
||||
"delete": "Удалить ключ 2FA",
|
||||
"deleteTitle": "Удалить ключ 2FA для «{{label}}»?",
|
||||
"deleteDescription": "Сначала сохраните копию ключа в другом месте, иначе вы можете потерять доступ к аккаунту."
|
||||
},
|
||||
"vpns": {
|
||||
"form": {
|
||||
"titleEdit": "Изменить VPN",
|
||||
@@ -2516,7 +2532,11 @@
|
||||
"proxyRotationTimeout": "Провайдер не ответил на смену IP за 30 секунд.",
|
||||
"proxyRotationUnreachable": "Donut не удалось открыть ссылку провайдера для смены IP.",
|
||||
"startUrlInvalid": "«{{url}}» — не веб-страница. Стартовые страницы должны быть адресами http:// или https://.",
|
||||
"startUrlsTooMany": "У профиля может быть не более 20 стартовых страниц."
|
||||
"startUrlsTooMany": "У профиля может быть не более 20 стартовых страниц.",
|
||||
"totpKeyInvalid": "Это не ключ 2FA. Вставьте секретный ключ или ссылку otpauth://, которую показывает сайт.",
|
||||
"totpLabelTooLong": "Метка может содержать не более 60 символов.",
|
||||
"totpLimitReached": "Профиль может хранить не более 50 ключей 2FA.",
|
||||
"totpEntryNotFound": "Этого ключа 2FA больше нет."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Профили",
|
||||
@@ -2657,7 +2677,8 @@
|
||||
"goTrash": "Перейти в Корзину",
|
||||
"goAgent": "Перейти к агенту",
|
||||
"openTips": "Открыть подсказки",
|
||||
"sendFeedback": "Отправить отзыв"
|
||||
"sendFeedback": "Отправить отзыв",
|
||||
"openTotp": "Код 2FA по ключу"
|
||||
},
|
||||
"closeConfirm": {
|
||||
"title": "Закрыть Donut Browser?",
|
||||
|
||||
@@ -1512,7 +1512,8 @@
|
||||
"activity": "Etkinlik",
|
||||
"launchHook": "Başlatma kancası",
|
||||
"transfer": "Aktarım",
|
||||
"startPages": "Başlangıç sayfaları"
|
||||
"startPages": "Başlangıç sayfaları",
|
||||
"twoFactor": "2FA kodları"
|
||||
},
|
||||
"sectionDesc": {
|
||||
"fingerprint": "Bu profilin parmak izi tarama betiklerine nasıl görüneceğini yapılandırın.",
|
||||
@@ -1759,6 +1760,21 @@
|
||||
"description": "Donut'tan her başlatmada son oturum yerine açılan sayfalar, satır başına bir tane. Kendi URL'si olan bir çalıştırma yalnızca o URL'yi açar.",
|
||||
"placeholder": "https://mail.example.com\nhttps://shop.example.com"
|
||||
},
|
||||
"totp": {
|
||||
"toolTitle": "2FA kodu",
|
||||
"toolDescription": "Geçerli kodu görmek için bir 2FA anahtarı yapıştırın. Donut anahtarı saklamaz.",
|
||||
"keyLabel": "2FA anahtarı",
|
||||
"keyPlaceholder": "Gizli anahtar veya otpauth:// bağlantısı",
|
||||
"expiresIn": "{{seconds}} sn sonra değişir",
|
||||
"copied": "Kod kopyalandı",
|
||||
"sectionDescription": "Bu profilin oturum açtığı hesapların kodları. Anahtarlar bu cihazda şifreli kalır. Asla eşitlenmez, dışa aktarılmaz veya API'ye verilmez.",
|
||||
"empty": "Henüz 2FA anahtarı yok.",
|
||||
"labelPlaceholder": "Hesap (isteğe bağlı)",
|
||||
"add": "2FA anahtarı ekle",
|
||||
"delete": "2FA anahtarını sil",
|
||||
"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."
|
||||
},
|
||||
"vpns": {
|
||||
"form": {
|
||||
"titleEdit": "VPN'i Düzenle",
|
||||
@@ -2494,7 +2510,11 @@
|
||||
"proxyRotationTimeout": "Sağlayıcı IP değişikliğine 30 saniye içinde yanıt vermedi.",
|
||||
"proxyRotationUnreachable": "Donut, sağlayıcının IP değiştirme bağlantısına ulaşamadı.",
|
||||
"startUrlInvalid": "\"{{url}}\" bir web sayfası değil. Başlangıç sayfaları http:// veya https:// adresleri olmalıdır.",
|
||||
"startUrlsTooMany": "Bir profilde en fazla 20 başlangıç sayfası olabilir."
|
||||
"startUrlsTooMany": "Bir profilde en fazla 20 başlangıç sayfası olabilir.",
|
||||
"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."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Profiller",
|
||||
@@ -2635,7 +2655,8 @@
|
||||
"goTrash": "Çöp Kutusuna git",
|
||||
"goAgent": "Ajan'a git",
|
||||
"openTips": "İpuçlarını aç",
|
||||
"sendFeedback": "Geri bildirim gönder"
|
||||
"sendFeedback": "Geri bildirim gönder",
|
||||
"openTotp": "Anahtardan 2FA kodu"
|
||||
},
|
||||
"closeConfirm": {
|
||||
"title": "Donut Browser kapatılsın mı?",
|
||||
|
||||
@@ -1512,7 +1512,8 @@
|
||||
"activity": "Hoạt động",
|
||||
"launchHook": "Hook khởi chạy",
|
||||
"transfer": "Chuyển",
|
||||
"startPages": "Trang khởi động"
|
||||
"startPages": "Trang khởi động",
|
||||
"twoFactor": "Mã 2FA"
|
||||
},
|
||||
"sectionDesc": {
|
||||
"fingerprint": "Cấu hình cách profile này hiển thị với các script vân tay.",
|
||||
@@ -1759,6 +1760,21 @@
|
||||
"description": "Các trang mà mỗi lần khởi chạy từ Donut sẽ mở thay cho phiên trước, mỗi dòng một trang. Lần chạy có URL riêng chỉ mở URL đó.",
|
||||
"placeholder": "https://mail.example.com\nhttps://shop.example.com"
|
||||
},
|
||||
"totp": {
|
||||
"toolTitle": "Mã 2FA",
|
||||
"toolDescription": "Dán khóa 2FA để xem mã hiện tại. Donut không lưu khóa.",
|
||||
"keyLabel": "Khóa 2FA",
|
||||
"keyPlaceholder": "Khóa bí mật hoặc liên kết otpauth://",
|
||||
"expiresIn": "Đổi sau {{seconds}} giây",
|
||||
"copied": "Đã sao chép mã",
|
||||
"sectionDescription": "Mã của các tài khoản mà profile này đăng nhập. Khóa được mã hóa và chỉ lưu trên thiết bị này. Khóa không bao giờ được đồng bộ, xuất ra hay cung cấp cho API.",
|
||||
"empty": "Chưa có khóa 2FA nào.",
|
||||
"labelPlaceholder": "Tài khoản (không bắt buộc)",
|
||||
"add": "Thêm khóa 2FA",
|
||||
"delete": "Xóa khóa 2FA",
|
||||
"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."
|
||||
},
|
||||
"vpns": {
|
||||
"form": {
|
||||
"titleEdit": "Chỉnh sửa VPN",
|
||||
@@ -2494,7 +2510,11 @@
|
||||
"proxyRotationTimeout": "Nhà cung cấp không phản hồi yêu cầu đổi IP trong 30 giây.",
|
||||
"proxyRotationUnreachable": "Donut không thể truy cập liên kết đổi IP của nhà cung cấp.",
|
||||
"startUrlInvalid": "\"{{url}}\" không phải là trang web. Trang khởi động phải là địa chỉ http:// hoặc https://.",
|
||||
"startUrlsTooMany": "Mỗi profile có tối đa 20 trang khởi động."
|
||||
"startUrlsTooMany": "Mỗi profile có tối đa 20 trang khởi động.",
|
||||
"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."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Profile",
|
||||
@@ -2635,7 +2655,8 @@
|
||||
"goTrash": "Đi tới Thùng rác",
|
||||
"goAgent": "Đến Tác nhân",
|
||||
"openTips": "Mở mẹo",
|
||||
"sendFeedback": "Gửi phản hồi"
|
||||
"sendFeedback": "Gửi phản hồi",
|
||||
"openTotp": "Mã 2FA từ khóa"
|
||||
},
|
||||
"closeConfirm": {
|
||||
"title": "Đóng Donut Browser?",
|
||||
|
||||
@@ -1512,7 +1512,8 @@
|
||||
"activity": "活动",
|
||||
"launchHook": "启动钩子",
|
||||
"transfer": "迁移",
|
||||
"startPages": "启动页"
|
||||
"startPages": "启动页",
|
||||
"twoFactor": "2FA 验证码"
|
||||
},
|
||||
"sectionDesc": {
|
||||
"fingerprint": "配置此配置文件如何对指纹脚本显示。",
|
||||
@@ -1759,6 +1760,21 @@
|
||||
"description": "每次从 Donut 启动时打开的页面,每行一个,代替上次会话。带有自身 URL 的运行只打开该 URL。",
|
||||
"placeholder": "https://mail.example.com\nhttps://shop.example.com"
|
||||
},
|
||||
"totp": {
|
||||
"toolTitle": "2FA 验证码",
|
||||
"toolDescription": "粘贴 2FA 密钥即可查看当前验证码。Donut 不保存该密钥。",
|
||||
"keyLabel": "2FA 密钥",
|
||||
"keyPlaceholder": "密钥或 otpauth:// 链接",
|
||||
"expiresIn": "{{seconds}} 秒后更新",
|
||||
"copied": "验证码已复制",
|
||||
"sectionDescription": "此配置文件所登录账号的验证码。密钥加密保存在本设备上,绝不会同步、导出或提供给 API。",
|
||||
"empty": "还没有 2FA 密钥。",
|
||||
"labelPlaceholder": "账号(可选)",
|
||||
"add": "添加 2FA 密钥",
|
||||
"delete": "删除 2FA 密钥",
|
||||
"deleteTitle": "删除“{{label}}”的 2FA 密钥?",
|
||||
"deleteDescription": "请先在其他地方保存密钥副本,否则可能无法再访问该账号。"
|
||||
},
|
||||
"vpns": {
|
||||
"form": {
|
||||
"titleEdit": "编辑 VPN",
|
||||
@@ -2494,7 +2510,11 @@
|
||||
"proxyRotationTimeout": "服务商在 30 秒内未响应更换 IP。",
|
||||
"proxyRotationUnreachable": "Donut 无法访问服务商的更换 IP 链接。",
|
||||
"startUrlInvalid": "“{{url}}”不是网页。启动页必须是 http:// 或 https:// 地址。",
|
||||
"startUrlsTooMany": "一个配置文件最多可以有 20 个启动页。"
|
||||
"startUrlsTooMany": "一个配置文件最多可以有 20 个启动页。",
|
||||
"totpKeyInvalid": "这不是 2FA 密钥。请粘贴网站显示的密钥或 otpauth:// 链接。",
|
||||
"totpLabelTooLong": "标签最多 60 个字符。",
|
||||
"totpLimitReached": "一个配置文件最多保存 50 个 2FA 密钥。",
|
||||
"totpEntryNotFound": "此 2FA 密钥已不存在。"
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "配置文件",
|
||||
@@ -2635,7 +2655,8 @@
|
||||
"goTrash": "前往回收站",
|
||||
"goAgent": "前往智能体",
|
||||
"openTips": "打开小贴士",
|
||||
"sendFeedback": "发送反馈"
|
||||
"sendFeedback": "发送反馈",
|
||||
"openTotp": "由密钥生成 2FA 验证码"
|
||||
},
|
||||
"closeConfirm": {
|
||||
"title": "关闭 Donut Browser?",
|
||||
|
||||
@@ -37,6 +37,10 @@ export type BackendErrorCode =
|
||||
| "PROXY_ROTATION_FAILED"
|
||||
| "START_URL_INVALID"
|
||||
| "START_URLS_TOO_MANY"
|
||||
| "TOTP_KEY_INVALID"
|
||||
| "TOTP_LABEL_TOO_LONG"
|
||||
| "TOTP_LIMIT_REACHED"
|
||||
| "TOTP_ENTRY_NOT_FOUND"
|
||||
| "COOKIE_DB_LOCKED"
|
||||
| "COOKIE_DB_UNAVAILABLE"
|
||||
| "COOKIE_IMPORT_BROWSER_RUNNING"
|
||||
@@ -401,6 +405,14 @@ export function translateBackendError(t: TFunction, err: unknown): string {
|
||||
});
|
||||
case "START_URLS_TOO_MANY":
|
||||
return t("backendErrors.startUrlsTooMany");
|
||||
case "TOTP_KEY_INVALID":
|
||||
return t("backendErrors.totpKeyInvalid");
|
||||
case "TOTP_LABEL_TOO_LONG":
|
||||
return t("backendErrors.totpLabelTooLong");
|
||||
case "TOTP_LIMIT_REACHED":
|
||||
return t("backendErrors.totpLimitReached");
|
||||
case "TOTP_ENTRY_NOT_FOUND":
|
||||
return t("backendErrors.totpEntryNotFound");
|
||||
case "COOKIE_DB_LOCKED":
|
||||
return t("backendErrors.cookieDbLocked");
|
||||
case "COOKIE_DB_UNAVAILABLE":
|
||||
|
||||
@@ -33,6 +33,7 @@ export type ShortcutId =
|
||||
| "openShortcuts"
|
||||
| "openTips"
|
||||
| "sendFeedback"
|
||||
| "openTotp"
|
||||
| "importProfile"
|
||||
| "goProfiles"
|
||||
| "goProxies"
|
||||
@@ -79,6 +80,14 @@ export const SHORTCUTS: ShortcutDef[] = [
|
||||
mod: true,
|
||||
shift: true,
|
||||
},
|
||||
{
|
||||
id: "openTotp",
|
||||
labelKey: "shortcuts.openTotp",
|
||||
group: "actions",
|
||||
key: "o",
|
||||
mod: true,
|
||||
shift: true,
|
||||
},
|
||||
{
|
||||
id: "importProfile",
|
||||
labelKey: "shortcuts.importProfile",
|
||||
|
||||
Reference in new issue
Block a user