diff --git a/crates/tranquil-api/src/admin/status.rs b/crates/tranquil-api/src/admin/status.rs index 9b6c439..581ec84 100644 --- a/crates/tranquil-api/src/admin/status.rs +++ b/crates/tranquil-api/src/admin/status.rs @@ -302,9 +302,13 @@ pub async fn update_subject_status( Some("com.atproto.repo.strongRef") => { let uri_str = input.subject.get("uri").and_then(Value::as_str); if let Some(uri_str) = uri_str { - let cid_str = input.subject.get("cid").and_then(Value::as_str).ok_or_else(|| { - ApiError::InvalidRequest("Record subject must include a CID".into()) - })?; + let cid_str = input + .subject + .get("cid") + .and_then(Value::as_str) + .ok_or_else(|| { + ApiError::InvalidRequest("Record subject must include a CID".into()) + })?; let cid: CidLink = cid_str .parse() .map_err(|_| ApiError::InvalidRequest("Invalid CID format".into()))?; diff --git a/crates/tranquil-api/src/delegation.rs b/crates/tranquil-api/src/delegation.rs index 2e52499..5c640ee 100644 --- a/crates/tranquil-api/src/delegation.rs +++ b/crates/tranquil-api/src/delegation.rs @@ -12,8 +12,8 @@ use tranquil_pds::api::{ }; use tranquil_pds::auth::{Active, Auth}; use tranquil_pds::delegation::{ - DelegationActionType, SCOPE_PRESETS, ValidatedDelegationScope, verify_can_add_controllers, - verify_can_control_accounts, + DelegationActionType, SCOPE_PRESETS, ValidatedDelegationScope, grant_can_delete_account, + verify_can_add_controllers, verify_can_control_accounts, }; use tranquil_pds::rate_limit::{AccountCreationLimit, RateLimited}; use tranquil_pds::state::AppState; @@ -447,6 +447,96 @@ pub async fn create_delegated_account( Ok(Json(CreateDelegatedAccountOutput { did, handle })) } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DeleteControlledAccountInput { + pub did: Did, +} + +pub async fn delete_controlled_account( + State(state): State, + auth: Auth, + Json(input): Json, +) -> Result, ApiError> { + let grant = state + .repos + .delegation + .get_delegation(&input.did, &auth.did) + .await + .map_err(|e| { + error!("Failed to look up delegation for delete: {:?}", e); + ApiError::InternalError(Some("Failed to look up delegation".into())) + })? + .ok_or(ApiError::DelegationNotFound)?; + + if !grant_can_delete_account(grant.granted_scopes.as_str()) { + return Err(ApiError::InvalidDelegation( + "Owner access is required to delete a controlled account".into(), + )); + } + + let (user_id, handle) = state + .repos + .user + .get_id_and_handle_by_did(&input.did) + .await + .map_err(|e| { + error!("Failed to look up controlled account: {:?}", e); + ApiError::InternalError(Some("Failed to look up account".into())) + })? + .ok_or(ApiError::AccountNotFound) + .map(|row| (row.id, row.handle))?; + + state + .repos + .user + .admin_delete_account_complete(user_id, &input.did) + .await + .map_err(|e| { + error!("Failed to delete controlled account: {:?}", e); + ApiError::InternalError(Some("Failed to delete account".into())) + })?; + + if let Err(e) = tranquil_pds::repo_ops::sequence_account_event( + &state, + &input.did, + tranquil_db_traits::AccountStatus::Deleted, + ) + .await + { + warn!( + "Failed to sequence controlled account deletion event for {}: {}", + input.did, e + ); + } + let _ = state + .cache + .delete(&tranquil_pds::cache_keys::handle_key(&handle)) + .await; + let _ = state + .repos + .delegation + .log_delegation_action( + &input.did, + &auth.did, + Some(&auth.did), + DelegationActionType::AccountAction, + Some(json!({ "deleted": true })), + None, + None, + ) + .await; + + info!( + did = %input.did, + handle = %handle, + controller = %auth.did, + "Controlled account deleted" + ); + + Ok(Json(SuccessResponse { success: true })) +} + #[derive(Debug, Deserialize)] pub struct ResolveControllerParams { pub identifier: String, diff --git a/crates/tranquil-api/src/lib.rs b/crates/tranquil-api/src/lib.rs index 2f3aeaa..a03ab2d 100644 --- a/crates/tranquil-api/src/lib.rs +++ b/crates/tranquil-api/src/lib.rs @@ -442,6 +442,10 @@ pub fn api_routes() -> axum::Router { "/_delegation.createDelegatedAccount", post(delegation::create_delegated_account), ) + .route( + "/_delegation.deleteControlledAccount", + post(delegation::delete_controlled_account), + ) .route( "/_delegation.resolveController", get(delegation::resolve_controller), diff --git a/crates/tranquil-api/src/repo/record/read.rs b/crates/tranquil-api/src/repo/record/read.rs index c273cf9..07e639f 100644 --- a/crates/tranquil-api/src/repo/record/read.rs +++ b/crates/tranquil-api/src/repo/record/read.rs @@ -86,12 +86,7 @@ pub async fn get_record( return ApiError::RecordNotFound.into_response(); } }; - match state - .repos - .repo - .get_record_by_cid(&record_cid_link) - .await - { + match state.repos.repo.get_record_by_cid(&record_cid_link).await { Ok(Some(record)) if record.takedown_ref.is_none() => {} Ok(_) => return ApiError::RecordNotFound.into_response(), Err(e) => { diff --git a/crates/tranquil-api/src/server/account_status.rs b/crates/tranquil-api/src/server/account_status.rs index 72a51c9..c3e11e6 100644 --- a/crates/tranquil-api/src/server/account_status.rs +++ b/crates/tranquil-api/src/server/account_status.rs @@ -591,7 +591,7 @@ pub async fn deactivate_account( pub async fn request_account_delete( State(state): State, auth: Auth, -) -> Result, ApiError> { +) -> Result, ApiError> { let _rate_limit = check_user_rate_limit::(&state, auth.did.as_str()).await?; let session_mfa = require_legacy_session_mfa(&state, &auth).await?; @@ -612,20 +612,43 @@ pub async fn request_account_delete( .create_deletion_request(&confirmation_token, session_mfa.did(), expires_at) .await .log_db_err("creating deletion token")?; + let can_deliver = state + .repos + .user + .get_comms_prefs(user_id) + .await + .ok() + .flatten() + .is_some_and(|prefs| tranquil_pds::comms::has_deliverable_recipient(&prefs)); let hostname = &tranquil_config::get().server.hostname; - if let Err(e) = tranquil_pds::comms::comms_repo::enqueue_account_deletion( - state.repos.user.as_ref(), - state.repos.infra.as_ref(), - user_id, - &confirmation_token, - hostname, - ) - .await - { - warn!("Failed to enqueue account deletion notification: {:?}", e); + let mut delivered = false; + if can_deliver { + match tranquil_pds::comms::comms_repo::enqueue_account_deletion( + state.repos.user.as_ref(), + state.repos.infra.as_ref(), + user_id, + &confirmation_token, + hostname, + ) + .await + { + Ok(_) => delivered = true, + Err(e) => { + warn!("Failed to enqueue account deletion notification: {:?}", e); + } + } } info!("Account deletion requested for user {}", session_mfa.did()); - Ok(Json(EmptyResponse {})) + Ok(Json(RequestAccountDeleteOutput { + token: (!delivered).then_some(confirmation_token), + })) +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RequestAccountDeleteOutput { + #[serde(skip_serializing_if = "Option::is_none")] + pub token: Option, } #[derive(Deserialize)] @@ -643,9 +666,6 @@ pub async fn delete_account( let did = &input.did; let password = &input.password; let token = input.token.trim(); - if password.is_empty() { - return Err(ApiError::InvalidRequest("password is required".into())); - } const OLD_PASSWORD_MAX_LENGTH: usize = 512; if password.len() > OLD_PASSWORD_MAX_LENGTH { return Err(ApiError::InvalidRequest("Invalid password length".into())); @@ -664,18 +684,28 @@ pub async fn delete_account( })? .ok_or(ApiError::InvalidRequest("account not found".into()))?; let (user_id, password_hash, handle) = (user.id, user.password_hash, user.handle); - if crate::common::verify_credential( - state.repos.session.as_ref(), - user_id, - password, - password_hash.as_ref(), - ) - .await - .is_none() - { - return Err(ApiError::AuthenticationFailed(Some( - "Invalid password".into(), - ))); + match password_hash.as_ref() { + Some(hash) => { + if crate::common::verify_credential( + state.repos.session.as_ref(), + user_id, + password, + Some(hash), + ) + .await + .is_none() + { + return Err(ApiError::AuthenticationFailed(Some( + "Invalid password".into(), + ))); + } + } + None if !password.is_empty() => { + return Err(ApiError::AuthenticationFailed(Some( + "Invalid password".into(), + ))); + } + None => {} } let deletion_request = state .repos diff --git a/crates/tranquil-api/src/server/email.rs b/crates/tranquil-api/src/server/email.rs index ed2a8cd..eed0e59 100644 --- a/crates/tranquil-api/src/server/email.rs +++ b/crates/tranquil-api/src/server/email.rs @@ -71,10 +71,10 @@ pub async fn request_email_update( .log_db_err("getting email info")? .ok_or(ApiError::AccountNotFound)?; - let Some(_current_email) = user.email else { - return Err(ApiError::InvalidRequest( - "account does not have an email address".into(), - )); + let Some(_current_email) = user.email.filter(|email| !email.trim().is_empty()) else { + return Ok(Json(TokenRequiredResponse { + token_required: false, + })); }; let token_required = user.email_verified; diff --git a/crates/tranquil-api/src/server/mod.rs b/crates/tranquil-api/src/server/mod.rs index e578efb..2f7675b 100644 --- a/crates/tranquil-api/src/server/mod.rs +++ b/crates/tranquil-api/src/server/mod.rs @@ -49,9 +49,10 @@ pub use reauth::{ pub use service_auth::get_service_auth; pub use session::{ account_not_verified_message, auto_resend_verification, confirm_signup, create_session, - delete_session, get_legacy_login_preference, get_session, list_sessions, refresh_session, - resend_verification, revoke_all_sessions, revoke_session, update_legacy_login_preference, - update_locale, verification_blocks_login, + delete_session, get_legacy_login_preference, get_session, has_verifiable_contact, + list_sessions, refresh_session, resend_verification, revoke_all_sessions, revoke_session, + update_legacy_login_preference, update_locale, verification_blocks_login, + verification_blocks_unverified_account, }; pub use signing_key::reserve_signing_key; pub use totp::{ diff --git a/crates/tranquil-api/src/server/session.rs b/crates/tranquil-api/src/server/session.rs index b491e1c..ef9e8ca 100644 --- a/crates/tranquil-api/src/server/session.rs +++ b/crates/tranquil-api/src/server/session.rs @@ -23,13 +23,25 @@ use tranquil_pds::state::AppState; use tranquil_pds::types::{AccountState, AtIdentifier, Did, Handle, PlainPassword}; use tranquil_types::TokenId; -pub fn verification_blocks_login(channel_verification: &ChannelVerificationStatus) -> bool { - !tranquil_config::get() - .server - .disable_account_verification_gate +pub fn has_verifiable_contact(email: Option<&str>) -> bool { + email.is_some_and(|value| !value.trim().is_empty()) +} + +pub fn verification_blocks_unverified_account( + channel_verification: &ChannelVerificationStatus, + has_verifiable_contact: bool, +) -> bool { + has_verifiable_contact + && !tranquil_config::get() + .server + .disable_account_verification_gate && !channel_verification.has_any_verified() } +pub fn verification_blocks_login(channel_verification: &ChannelVerificationStatus) -> bool { + verification_blocks_unverified_account(channel_verification, true) +} + fn account_not_verified_message_for(hostname: &str) -> String { format!("Please verify your account at https://{hostname} before logging in.") } @@ -40,7 +52,11 @@ pub fn account_not_verified_message() -> String { #[cfg(test)] mod account_not_verified_message_tests { - use super::account_not_verified_message_for; + use super::{ + account_not_verified_message_for, has_verifiable_contact, + verification_blocks_unverified_account, + }; + use tranquil_db_traits::ChannelVerificationStatus; #[test] fn includes_the_pds_url() { @@ -49,6 +65,23 @@ mod account_not_verified_message_tests { "Please verify your account at https://pds.example.com before logging in." ); } + + #[test] + fn unverified_account_without_contact_can_log_in() { + let status = ChannelVerificationStatus::default(); + assert!(!has_verifiable_contact(None)); + assert!(!has_verifiable_contact(Some(""))); + assert!(!verification_blocks_unverified_account(&status, false)); + } + + #[test] + fn verified_account_is_never_blocked() { + let status = ChannelVerificationStatus { + email: true, + ..ChannelVerificationStatus::default() + }; + assert!(!verification_blocks_unverified_account(&status, true)); + } } #[derive(Deserialize)] @@ -176,7 +209,11 @@ pub async fn create_session( .is_delegated_account(&row.did) .await .unwrap_or(false); - if verification_blocks_login(&row.channel_verification) && !is_delegated { + if verification_blocks_unverified_account( + &row.channel_verification, + has_verifiable_contact(row.email.as_deref()), + ) && !is_delegated + { warn!("Login attempt for unverified account: {}", row.did); let resend_info = auto_resend_verification(&state, &row.did).await; let handle = resend_info diff --git a/crates/tranquil-oauth-server/src/endpoints/authorize/login.rs b/crates/tranquil-oauth-server/src/endpoints/authorize/login.rs index 9d1a286..30b6eb4 100644 --- a/crates/tranquil-oauth-server/src/endpoints/authorize/login.rs +++ b/crates/tranquil-oauth-server/src/endpoints/authorize/login.rs @@ -484,7 +484,10 @@ pub async fn authorize_post( if !password_valid { return show_login_error("Invalid identifier or password.", json_response); } - if tranquil_api::server::verification_blocks_login(&user.channel_verification) { + if tranquil_api::server::verification_blocks_unverified_account( + &user.channel_verification, + tranquil_api::server::has_verifiable_contact(user.email.as_deref()), + ) { let resend_info = tranquil_api::server::auto_resend_verification(&state, &user.did).await; let handle = resend_info .as_ref() @@ -846,7 +849,18 @@ pub async fn authorize_select( ); } }; - if tranquil_api::server::verification_blocks_login(&user.channel_verification) { + let has_contact = state + .repos + .user + .get_email_info_by_did(&did) + .await + .ok() + .flatten() + .is_some_and(|info| tranquil_api::server::has_verifiable_contact(info.email.as_deref())); + if tranquil_api::server::verification_blocks_unverified_account( + &user.channel_verification, + has_contact, + ) { let resend_info = tranquil_api::server::auto_resend_verification(&state, &did).await; return ( StatusCode::FORBIDDEN, diff --git a/crates/tranquil-oauth-server/src/endpoints/authorize/mod.rs b/crates/tranquil-oauth-server/src/endpoints/authorize/mod.rs index b36a2dd..622307b 100644 --- a/crates/tranquil-oauth-server/src/endpoints/authorize/mod.rs +++ b/crates/tranquil-oauth-server/src/endpoints/authorize/mod.rs @@ -218,7 +218,9 @@ fn sign_redirect_parameters( let payload = serde_json::to_vec(&(redirect_uri, code, state, response_mode)) .expect("redirect parameters are serializable"); let mut mac = Hmac::::new_from_slice( - tranquil_pds::config::AuthConfig::get().dpop_secret().as_bytes(), + tranquil_pds::config::AuthConfig::get() + .dpop_secret() + .as_bytes(), ) .expect("HMAC accepts keys of any size"); mac.update(&payload); diff --git a/crates/tranquil-oauth-server/src/endpoints/authorize/passkey.rs b/crates/tranquil-oauth-server/src/endpoints/authorize/passkey.rs index ff6ca83..ae9f476 100644 --- a/crates/tranquil-oauth-server/src/endpoints/authorize/passkey.rs +++ b/crates/tranquil-oauth-server/src/endpoints/authorize/passkey.rs @@ -286,7 +286,10 @@ async fn passkey_start_named( .into_response(); } - if tranquil_api::server::verification_blocks_login(&user.channel_verification) { + if tranquil_api::server::verification_blocks_unverified_account( + &user.channel_verification, + tranquil_api::server::has_verifiable_contact(user.email.as_deref()), + ) { let resend_info = tranquil_api::server::auto_resend_verification(&state, &user.did).await; return ( StatusCode::FORBIDDEN, diff --git a/crates/tranquil-oauth-server/src/endpoints/authorize/registration.rs b/crates/tranquil-oauth-server/src/endpoints/authorize/registration.rs index 6ecd3fd..ddf1258 100644 --- a/crates/tranquil-oauth-server/src/endpoints/authorize/registration.rs +++ b/crates/tranquil-oauth-server/src/endpoints/authorize/registration.rs @@ -171,9 +171,10 @@ pub async fn register_complete( } let login_blocked = match state.repos.user.get_session_info_by_did(&did).await { - Ok(Some(info)) => { - tranquil_api::server::verification_blocks_login(&info.channel_verification) - } + Ok(Some(info)) => tranquil_api::server::verification_blocks_unverified_account( + &info.channel_verification, + tranquil_api::server::has_verifiable_contact(info.email.as_deref()), + ), Ok(None) => { return ( StatusCode::FORBIDDEN, diff --git a/crates/tranquil-oauth-server/src/sso_endpoints.rs b/crates/tranquil-oauth-server/src/sso_endpoints.rs index c61b789..48faa97 100644 --- a/crates/tranquil-oauth-server/src/sso_endpoints.rs +++ b/crates/tranquil-oauth-server/src/sso_endpoints.rs @@ -409,9 +409,10 @@ async fn handle_sso_login( .get_session_info_by_did(&identity.did) .await { - Ok(Some(info)) => { - tranquil_api::server::verification_blocks_login(&info.channel_verification) - } + Ok(Some(info)) => tranquil_api::server::verification_blocks_unverified_account( + &info.channel_verification, + tranquil_api::server::has_verifiable_contact(info.email.as_deref()), + ), Ok(None) => { tracing::error!("User not found for SSO login: {}", identity.did); return redirect_to_error("Account not found"); diff --git a/crates/tranquil-pds/src/comms/mod.rs b/crates/tranquil-pds/src/comms/mod.rs index 2342773..aa64039 100644 --- a/crates/tranquil-pds/src/comms/mod.rs +++ b/crates/tranquil-pds/src/comms/mod.rs @@ -7,4 +7,6 @@ pub use tranquil_comms::{ validate_locale, }; -pub use service::{CommsService, repo as comms_repo, resolve_delivery_channel}; +pub use service::{ + CommsService, has_deliverable_recipient, repo as comms_repo, resolve_delivery_channel, +}; diff --git a/crates/tranquil-pds/src/comms/service.rs b/crates/tranquil-pds/src/comms/service.rs index 0f0303c..0294f03 100644 --- a/crates/tranquil-pds/src/comms/service.rs +++ b/crates/tranquil-pds/src/comms/service.rs @@ -201,6 +201,13 @@ pub fn resolve_delivery_channel( resolve_recipient(prefs, channel).channel } +pub fn has_deliverable_recipient(prefs: &UserCommsPrefs) -> bool { + !resolve_recipient(prefs, prefs.preferred_channel) + .recipient + .trim() + .is_empty() +} + fn resolve_recipient( prefs: &UserCommsPrefs, channel: tranquil_db_traits::CommsChannel, diff --git a/crates/tranquil-pds/src/delegation/mod.rs b/crates/tranquil-pds/src/delegation/mod.rs index e793f8d..c59f5a0 100644 --- a/crates/tranquil-pds/src/delegation/mod.rs +++ b/crates/tranquil-pds/src/delegation/mod.rs @@ -6,7 +6,8 @@ pub use roles::{ }; pub use scopes::{ EDITOR_FULL_SCOPES, GrantCoverage, InvalidDelegationScopeError, OWNER_FULL_SCOPES, - SCOPE_PRESETS, ScopePreset, ValidatedDelegationScope, grant_coverage, intersect_scopes, + SCOPE_PRESETS, ScopePreset, ValidatedDelegationScope, grant_can_delete_account, grant_coverage, + intersect_scopes, }; pub use tranquil_db_traits::DelegationActionType; diff --git a/crates/tranquil-pds/src/delegation/scopes.rs b/crates/tranquil-pds/src/delegation/scopes.rs index 8503011..8fe5265 100644 --- a/crates/tranquil-pds/src/delegation/scopes.rs +++ b/crates/tranquil-pds/src/delegation/scopes.rs @@ -119,6 +119,14 @@ pub fn grant_coverage(granted: &str, scope: &str) -> GrantCoverage { scope_coverage(&granted_parsed, scope, has_owner_access) } +pub fn grant_can_delete_account(granted: &str) -> bool { + matches!(grant_coverage(granted, "identity:*"), GrantCoverage::Full) + && matches!( + grant_coverage(granted, "account:*?action=manage"), + GrantCoverage::Full + ) +} + fn parse_grant(granted: &str) -> Vec { granted.split_whitespace().map(parse_scope).collect() } @@ -157,6 +165,15 @@ mod tests { assert!(result.contains("blob:*/*")); } + #[test] + fn test_owner_can_delete_controlled_account() { + assert!(grant_can_delete_account(OWNER_FULL_SCOPES)); + assert!(!grant_can_delete_account(EDITOR_FULL_SCOPES)); + assert!(!grant_can_delete_account( + "atproto repo:* blob:*/* account:*?action=manage" + )); + } + #[test] fn test_intersect_owner_grant_covers_transition_scopes() { let result = intersect_scopes( diff --git a/crates/tranquil-store/src/metastore/mod.rs b/crates/tranquil-store/src/metastore/mod.rs index 84bba6f..da0a852 100644 --- a/crates/tranquil-store/src/metastore/mod.rs +++ b/crates/tranquil-store/src/metastore/mod.rs @@ -848,14 +848,14 @@ mod tests { #[test] fn deleting_account_removes_all_user_oauth_state() { use super::oauth_schema::{ - AccountDeviceValue, AuthorizedClientValue, DeviceTrustValue, OAuthRequestValue, - OAuthDeviceValue, OAuthTokenValue, ScopePrefsValue, TokenIndexValue, + AccountDeviceValue, AuthorizedClientValue, DeviceTrustValue, OAuthDeviceValue, + OAuthRequestValue, OAuthTokenValue, ScopePrefsValue, TokenIndexValue, TwoFactorChallengeValue, UsedRefreshValue, oauth_2fa_by_request_key, oauth_2fa_challenge_key, oauth_account_device_key, oauth_auth_by_code_key, - oauth_auth_client_key, oauth_auth_request_key, oauth_device_key, oauth_device_trust_key, - oauth_scope_prefs_key, oauth_token_by_family_key, oauth_token_by_id_key, - oauth_token_by_prev_refresh_key, oauth_token_by_refresh_key, oauth_token_key, - oauth_used_refresh_key, + oauth_auth_client_key, oauth_auth_request_key, oauth_device_key, + oauth_device_trust_key, oauth_scope_prefs_key, oauth_token_by_family_key, + oauth_token_by_id_key, oauth_token_by_prev_refresh_key, oauth_token_by_refresh_key, + oauth_token_key, oauth_used_refresh_key, }; let (_dir, ms) = open_fresh(); diff --git a/crates/tranquil-store/src/metastore/oauth_ops.rs b/crates/tranquil-store/src/metastore/oauth_ops.rs index 71ec739..4f449bd 100644 --- a/crates/tranquil-store/src/metastore/oauth_ops.rs +++ b/crates/tranquil-store/src/metastore/oauth_ops.rs @@ -38,10 +38,7 @@ fn stage_delete_token_indexes( token: &OAuthTokenValue, user_hash: UserHash, ) { - batch.remove( - auth, - oauth_token_key(user_hash, token.family_id).as_slice(), - ); + batch.remove(auth, oauth_token_key(user_hash, token.family_id).as_slice()); batch.remove(auth, oauth_token_by_id_key(&token.token_id).as_slice()); batch.remove( auth, @@ -50,10 +47,7 @@ fn stage_delete_token_indexes( if let Some(prev) = &token.previous_refresh_token { batch.remove(auth, oauth_token_by_prev_refresh_key(prev).as_slice()); } - batch.remove( - auth, - oauth_token_by_family_key(token.family_id).as_slice(), - ); + batch.remove(auth, oauth_token_by_family_key(token.family_id).as_slice()); } fn collect_tokens_for_user( @@ -82,11 +76,7 @@ pub(super) fn stage_delete_user_oauth_data( tokens.iter().for_each(|token| { stage_delete_token_indexes(auth, batch, token, user_hash); }); - delete_all_by_prefix( - auth, - batch, - oauth_token_user_prefix(user_hash).as_slice(), - )?; + delete_all_by_prefix(auth, batch, oauth_token_user_prefix(user_hash).as_slice())?; let used_prefix = oauth_used_refresh_prefix(); auth.prefix(used_prefix.as_slice()).try_for_each(|guard| { diff --git a/frontend/src/components/dashboard/ControllersContent.svelte b/frontend/src/components/dashboard/ControllersContent.svelte index 1546e67..f82504b 100644 --- a/frontend/src/components/dashboard/ControllersContent.svelte +++ b/frontend/src/components/dashboard/ControllersContent.svelte @@ -91,6 +91,7 @@ let newDelegatedEmail = $state('') let newDelegatedScopes = $state('') let creatingDelegated = $state(false) + let deletingDid = $state(null) let selectedDelegatedPreset = $derived(scopePresets.find(p => p.scopes === newDelegatedScopes)) onMount(async () => { @@ -225,6 +226,22 @@ creatingDelegated = false } + async function deleteControlledAccount(account: ControlledAccount) { + const label = account.handle ?? account.did + if (!confirm($_('delegation.deleteAccountConfirm', { values: { handle: label } }))) { + return + } + deletingDid = account.did + const result = await api.deleteControlledAccount(session.accessJwt, account.did) + deletingDid = null + if (result.ok) { + toast.success($_('delegation.accountDeleted')) + await loadControlledAccounts() + } else { + toast.error(result.error.message || $_('delegation.deleteFailed')) + } + } + function getScopeLabel(scopes: ScopeSet): string { const preset = scopePresets.find(p => p.scopes === scopes) if (preset) return preset.label @@ -519,6 +536,14 @@ {$_('delegation.actAs')} + {/each} diff --git a/frontend/src/components/dashboard/SettingsContent.svelte b/frontend/src/components/dashboard/SettingsContent.svelte index 931512e..a4793c9 100644 --- a/frontend/src/components/dashboard/SettingsContent.svelte +++ b/frontend/src/components/dashboard/SettingsContent.svelte @@ -73,12 +73,18 @@ emailLoading = true try { const result = await api.requestEmailUpdate(session.accessJwt, newEmail.trim()) - emailTokenRequired = result.tokenRequired - if (emailTokenRequired) { + if (result.tokenRequired) { + emailTokenRequired = true toast.success($_('settings.messages.emailCodeSentToCurrent')) startEmailPolling() } else { - emailTokenRequired = true + await api.updateEmail(session.accessJwt, newEmail.trim()) + await refreshSession() + toast.success($_('settings.messages.emailAdded')) + newEmail = '' + emailToken = '' + emailTokenRequired = false + emailUpdateAuthorized = false } } catch (e) { toast.error(e instanceof ApiError ? e.message : $_('settings.messages.emailUpdateFailed')) @@ -193,13 +199,21 @@ let deletePassword = $state('') let deleteToken = $state('') let deleteTokenSent = $state(false) + let deleteTokenIssuedInApp = $state(false) async function handleRequestDelete() { deleteLoading = true try { - await api.requestAccountDelete(session.accessJwt) + const result = await api.requestAccountDelete(session.accessJwt) deleteTokenSent = true - toast.success($_('settings.messages.deletionConfirmationSent')) + if (result.token) { + deleteToken = result.token + deleteTokenIssuedInApp = true + toast.success($_('settings.messages.deletionCodeReady')) + } else { + deleteTokenIssuedInApp = false + toast.success($_('settings.messages.deletionConfirmationSent')) + } } catch (e) { toast.error(e instanceof ApiError ? e.message : $_('settings.messages.deletionRequestFailed')) } finally { @@ -209,7 +223,7 @@ async function handleConfirmDelete(e: Event) { e.preventDefault() - if (!deletePassword || !deleteToken) return + if (!deleteToken || (!deleteTokenIssuedInApp && !deletePassword)) return if (!confirm($_('settings.messages.deleteConfirmation'))) { return } @@ -242,9 +256,11 @@
-

{$_('settings.changeEmail')}

+

{getSessionEmail(session) ? $_('settings.changeEmail') : $_('settings.addEmail')}

{#if getSessionEmail(session)}

{$_('settings.currentEmail', { values: { email: getSessionEmail(session) } })}

+ {:else} +

{$_('settings.noEmail')}

{/if} {#if emailTokenRequired}
@@ -305,7 +321,7 @@ {/if}
{/if} @@ -384,6 +400,9 @@

{$_('settings.deleteWarning')}

{#if deleteTokenSent}
+ {#if deleteTokenIssuedInApp} +

{$_('settings.deletionCodeHint')}

+ {/if}
- -
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index ac642d6..bc6ea74 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -62,6 +62,7 @@ import type { ReauthStatus, RecommendedDidCredentials, RecordResponse, + RequestAccountDeleteResponse, RegenerateBackupCodesResponse, RepoDescription, ResendMigrationVerificationResponse, @@ -644,8 +645,10 @@ export const api = { }); }, - async requestAccountDelete(token: AccessToken): Promise { - await xrpc("com.atproto.server.requestAccountDelete", { + requestAccountDelete( + token: AccessToken, + ): Promise { + return xrpc("com.atproto.server.requestAccountDelete", { method: "POST", token, }); @@ -1533,6 +1536,17 @@ export const api = { }); }, + deleteControlledAccount( + token: AccessToken, + did: Did, + ): Promise> { + return xrpcResult("_delegation.deleteControlledAccount", { + method: "POST", + token, + body: { did }, + }); + }, + async getDelegationAuditLog( token: AccessToken, limit: number, diff --git a/frontend/src/lib/types/api.ts b/frontend/src/lib/types/api.ts index 13a3041..3920cf6 100644 --- a/frontend/src/lib/types/api.ts +++ b/frontend/src/lib/types/api.ts @@ -483,6 +483,10 @@ export interface EmailUpdateResponse { tokenRequired: boolean; } +export interface RequestAccountDeleteResponse { + token?: string; +} + export interface LegacyLoginPreference { allowLegacyLogin: boolean; hasMfa: boolean; diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index 4e097ba..1487b8e 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -226,11 +226,14 @@ "settings": { "language": "Language", "changeEmail": "Change Email", + "addEmail": "Add Email", + "noEmail": "This account has no email address.", "currentEmail": "Current: {email}", "newEmail": "New Email", "newEmailPlaceholder": "new@example.com", "emailInUseWarning": "This email is already used by another account. You can still use it, but account recovery may require your handle.", "changeEmailButton": "Change Email", + "addEmailButton": "Add Email", "requesting": "Requesting...", "verificationCode": "Verification Code", "verificationCodePlaceholder": "Enter verification code", @@ -263,6 +266,7 @@ "requestDeletion": "Request Account Deletion", "confirmationCode": "Confirmation Code", "confirmationCodePlaceholder": "Enter confirmation code", + "deletionCodeHint": "This account has no email, so the confirmation code is shown here. Enter your password if you set one.", "yourPassword": "Your Password", "yourPasswordPlaceholder": "Enter your password", "permanentlyDelete": "Permanently Delete Account", @@ -270,9 +274,11 @@ "messages": { "emailCodeSentToCurrent": "Verification code sent to your current email address", "emailUpdated": "Email updated successfully", + "emailAdded": "Email added. Check it to verify the account.", "emailUpdateFailed": "Failed to update email", "handleUpdated": "Handle updated successfully", "handleUpdateFailed": "Failed to update handle", + "deletionCodeReady": "Confirmation code is shown below. This account has no email.", "deletionConfirmationSent": "Deletion confirmation sent to your email", "deletionRequestFailed": "Failed to request account deletion", "deleteConfirmation": "Are you absolutely sure you want to delete your account? This cannot be undone.", @@ -907,6 +913,10 @@ "controlledAccountsLocalOnly": "Only accounts on this PDS are shown. Another PDS may have granted you controller access separately", "noControlledAccounts": "You do not have access to any delegated accounts.", "actAs": "Act As", + "deleteAccount": "Delete", + "deleteAccountConfirm": "Permanently delete {handle}? This cannot be undone.", + "accountDeleted": "Delegated account deleted", + "deleteFailed": "Failed to delete delegated account", "cannotControlAccounts": "You cannot control other accounts because this account has controllers. An account can either have controllers or control other accounts, but not both.", "createDelegatedAccount": "Create Delegated Account", "handle": "Handle", diff --git a/frontend/src/locales/fi.json b/frontend/src/locales/fi.json index 9573fed..e2a1e06 100644 --- a/frontend/src/locales/fi.json +++ b/frontend/src/locales/fi.json @@ -226,11 +226,14 @@ "settings": { "language": "Kieli", "changeEmail": "Vaihda sähköposti", + "addEmail": "Lisää sähköposti", + "noEmail": "Tällä tilillä ei ole sähköpostiosoitetta.", "currentEmail": "Nykyinen: {email}", "newEmail": "Uusi sähköposti", "newEmailPlaceholder": "uusi@esimerkki.fi", "emailInUseWarning": "Tämä sähköposti on jo toisen tilin käytössä. Voit silti käyttää sitä, mutta tilin palauttaminen voi vaatia käsittelynimeäsi.", "changeEmailButton": "Vaihda sähköposti", + "addEmailButton": "Lisää sähköposti", "requesting": "Pyydetään...", "verificationCode": "Vahvistuskoodi", "verificationCodePlaceholder": "Syötä vahvistuskoodi", @@ -263,6 +266,7 @@ "requestDeletion": "Pyydä tilin poistoa", "confirmationCode": "Vahvistuskoodi", "confirmationCodePlaceholder": "Syötä vahvistuskoodi", + "deletionCodeHint": "Tällä tilillä ei ole sähköpostia, joten vahvistuskoodi näytetään tässä. Syötä salasana, jos olet asettanut sen.", "yourPassword": "Salasanasi", "yourPasswordPlaceholder": "Syötä salasanasi", "permanentlyDelete": "Poista tili pysyvästi", @@ -270,9 +274,11 @@ "messages": { "emailCodeSentToCurrent": "Vahvistuskoodi lähetetty nykyiseen sähköpostiosoitteeseesi", "emailUpdated": "Sähköposti päivitetty", + "emailAdded": "Sähköposti lisätty. Tarkista se tilin vahvistamiseksi.", "emailUpdateFailed": "Sähköpostin päivitys epäonnistui", "handleUpdated": "Käyttäjänimi päivitetty", "handleUpdateFailed": "Käyttäjänimen päivitys epäonnistui", + "deletionCodeReady": "Vahvistuskoodi näkyy alla. Tällä tilillä ei ole sähköpostia.", "deletionConfirmationSent": "Poistovahvistus lähetetty sähköpostiisi", "deletionRequestFailed": "Tilin poistopyyntö epäonnistui", "deleteConfirmation": "Oletko täysin varma, että haluat poistaa tilisi? Tätä ei voi perua.", @@ -908,6 +914,10 @@ "controlledAccountsLocalOnly": "Vain tämän PDS:n tilit näytetään. Toinen PDS on voinut myöntää sinulle ohjausoikeuden erikseen", "noControlledAccounts": "Sinulla ei ole pääsyä delegoituihin tileihin.", "actAs": "Toimi käyttäjänä", + "deleteAccount": "Poista", + "deleteAccountConfirm": "Poistetaanko {handle} pysyvästi? Tätä ei voi perua.", + "accountDeleted": "Delegoitu tili poistettu", + "deleteFailed": "Delegoidun tilin poisto epäonnistui", "cannotControlAccounts": "Et voi hallinnoida muita tilejä, koska tällä tilillä on hallinnoijia. Tili voi joko olla hallinnoija tai hallinnoidaan, mutta ei molempia.", "createDelegatedAccount": "Luo delegoitu tili", "handle": "Käyttäjänimi", diff --git a/frontend/src/locales/fr.json b/frontend/src/locales/fr.json index 96da790..8551c07 100644 --- a/frontend/src/locales/fr.json +++ b/frontend/src/locales/fr.json @@ -226,11 +226,14 @@ "settings": { "language": "Langue", "changeEmail": "Changer l'e-mail", + "addEmail": "Ajouter un e-mail", + "noEmail": "Ce compte n'a pas d'adresse e-mail.", "currentEmail": "Actuel : {email}", "newEmail": "Nouvel e-mail", "newEmailPlaceholder": "nouveau@exemple.com", "emailInUseWarning": "Cet e-mail est déjà utilisé par un autre compte. Vous pouvez quand même l'utiliser, mais la récupération de compte peut nécessiter votre identifiant.", "changeEmailButton": "Changer l'e-mail", + "addEmailButton": "Ajouter un e-mail", "requesting": "Demande en cours...", "verificationCode": "Code de vérification", "verificationCodePlaceholder": "Entrez le code de vérification", @@ -263,6 +266,7 @@ "requestDeletion": "Demander la suppression du compte", "confirmationCode": "Code de confirmation", "confirmationCodePlaceholder": "Entrez le code de confirmation", + "deletionCodeHint": "Ce compte n'a pas d'e-mail, le code de confirmation est donc affiché ici. Entrez votre mot de passe si vous en avez défini un.", "yourPassword": "Votre mot de passe", "yourPasswordPlaceholder": "Entrez votre mot de passe", "permanentlyDelete": "Supprimer définitivement le compte", @@ -270,9 +274,11 @@ "messages": { "emailCodeSentToCurrent": "Code de vérification envoyé à votre adresse e-mail actuelle", "emailUpdated": "E-mail mis à jour avec succès", + "emailAdded": "E-mail ajouté. Vérifiez-le pour confirmer le compte.", "emailUpdateFailed": "Échec de la mise à jour de l'e-mail", "handleUpdated": "Identifiant mis à jour avec succès", "handleUpdateFailed": "Échec de la mise à jour de l'identifiant", + "deletionCodeReady": "Le code de confirmation est affiché ci-dessous. Ce compte n'a pas d'e-mail.", "deletionConfirmationSent": "Confirmation de suppression envoyée à votre e-mail", "deletionRequestFailed": "Échec de la demande de suppression du compte", "deleteConfirmation": "Êtes-vous absolument sûr de vouloir supprimer votre compte ? Cette action est irréversible.", @@ -908,6 +914,10 @@ "controlledAccountsLocalOnly": "Seuls les comptes sur ce PDS sont affichés. Un autre PDS peut vous avoir accordé l'accès contrôleur séparément", "noControlledAccounts": "Vous n'avez accès à aucun compte délégué.", "actAs": "Agir comme", + "deleteAccount": "Supprimer", + "deleteAccountConfirm": "Supprimer définitivement {handle} ? Cette action est irréversible.", + "accountDeleted": "Compte délégué supprimé", + "deleteFailed": "Échec de la suppression du compte délégué", "cannotControlAccounts": "Vous ne pouvez pas contrôler d'autres comptes car ce compte a des contrôleurs. Un compte peut soit avoir des contrôleurs, soit contrôler d'autres comptes, mais pas les deux.", "createDelegatedAccount": "Créer un compte délégué", "handle": "Identifiant", diff --git a/frontend/src/locales/ja.json b/frontend/src/locales/ja.json index 4aea152..45e2417 100644 --- a/frontend/src/locales/ja.json +++ b/frontend/src/locales/ja.json @@ -226,11 +226,14 @@ "settings": { "language": "言語", "changeEmail": "メール変更", + "addEmail": "メールを追加", + "noEmail": "このアカウントにはメールアドレスがありません。", "currentEmail": "現在: {email}", "newEmail": "新しいメール", "newEmailPlaceholder": "new@example.com", "emailInUseWarning": "このメールアドレスは既に別のアカウントで使用されています。引き続き使用できますが、アカウント回復にはハンドルが必要になる場合があります。", "changeEmailButton": "メールを変更", + "addEmailButton": "メールを追加", "requesting": "リクエスト中...", "verificationCode": "確認コード", "verificationCodePlaceholder": "認証コードを入力", @@ -263,6 +266,7 @@ "requestDeletion": "アカウント削除をリクエスト", "confirmationCode": "確認コード", "confirmationCodePlaceholder": "確認コードを入力", + "deletionCodeHint": "このアカウントにはメールがないため、確認コードをここに表示します。パスワードを設定している場合は入力してください。", "yourPassword": "パスワード", "yourPasswordPlaceholder": "パスワードを入力", "permanentlyDelete": "アカウントを完全に削除", @@ -270,9 +274,11 @@ "messages": { "emailCodeSentToCurrent": "現在のメールアドレスに確認コードを送信しました", "emailUpdated": "メールを更新しました", + "emailAdded": "メールを追加しました。アカウントを確認するためにメールを確認してください。", "emailUpdateFailed": "メールの更新に失敗しました", "handleUpdated": "ハンドルを更新しました", "handleUpdateFailed": "ハンドルの更新に失敗しました", + "deletionCodeReady": "確認コードを下に表示しています。このアカウントにはメールがありません。", "deletionConfirmationSent": "削除確認をメールに送信しました", "deletionRequestFailed": "アカウント削除リクエストに失敗しました", "deleteConfirmation": "本当にアカウントを削除しますか?この操作は取り消せません。", @@ -891,6 +897,10 @@ "scopeViewer": "閲覧者", "scopeCustom": "カスタム", "actAs": "として行動", + "deleteAccount": "削除", + "deleteAccountConfirm": "{handle} を完全に削除しますか?この操作は元に戻せません。", + "accountDeleted": "委任アカウントを削除しました", + "deleteFailed": "委任アカウントの削除に失敗しました", "auditLog": "監査ログ", "actor": "アクター", "details": "詳細", diff --git a/frontend/src/locales/ko.json b/frontend/src/locales/ko.json index 60b0453..dfb50ee 100644 --- a/frontend/src/locales/ko.json +++ b/frontend/src/locales/ko.json @@ -226,10 +226,13 @@ "settings": { "language": "언어", "changeEmail": "이메일 변경", + "addEmail": "이메일 추가", + "noEmail": "이 계정에는 이메일 주소가 없습니다.", "currentEmail": "현재: {email}", "newEmail": "새 이메일", "newEmailPlaceholder": "new@example.com", "changeEmailButton": "이메일 변경", + "addEmailButton": "이메일 추가", "emailInUseWarning": "이 이메일은 이미 다른 계정과 연결되어 있습니다. 계속 사용하실 수 있지만, 계정 복구 시 이메일 대신 핸들을 사용해야 할 수 있습니다.", "requesting": "요청 중...", "verificationCode": "인증 코드", @@ -263,6 +266,7 @@ "requestDeletion": "계정 삭제 요청", "confirmationCode": "확인 코드", "confirmationCodePlaceholder": "확인 코드 입력", + "deletionCodeHint": "이 계정에는 이메일이 없어 확인 코드를 여기에 표시합니다. 비밀번호를 설정했다면 입력하세요.", "yourPassword": "비밀번호", "yourPasswordPlaceholder": "비밀번호 입력", "permanentlyDelete": "계정 영구 삭제", @@ -270,9 +274,11 @@ "messages": { "emailCodeSentToCurrent": "현재 이메일 주소로 인증 코드를 보냈습니다", "emailUpdated": "이메일이 업데이트되었습니다", + "emailAdded": "이메일을 추가했습니다. 계정을 확인하려면 메일을 확인하세요.", "emailUpdateFailed": "이메일 업데이트에 실패했습니다", "handleUpdated": "핸들이 업데이트되었습니다", "handleUpdateFailed": "핸들 업데이트에 실패했습니다", + "deletionCodeReady": "확인 코드가 아래에 표시됩니다. 이 계정에는 이메일이 없습니다.", "deletionConfirmationSent": "이메일로 삭제 확인을 보냈습니다", "deletionRequestFailed": "계정 삭제 요청에 실패했습니다", "deleteConfirmation": "정말로 계정을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.", @@ -891,6 +897,10 @@ "scopeViewer": "뷰어", "scopeCustom": "사용자 정의", "actAs": "로 활동", + "deleteAccount": "삭제", + "deleteAccountConfirm": "{handle}을(를) 영구 삭제할까요? 이 작업은 되돌릴 수 없습니다.", + "accountDeleted": "위임 계정을 삭제했습니다", + "deleteFailed": "위임 계정 삭제에 실패했습니다", "auditLog": "감사 로그", "actor": "액터", "details": "세부정보", diff --git a/frontend/src/locales/sv.json b/frontend/src/locales/sv.json index 9d26245..ee2eae2 100644 --- a/frontend/src/locales/sv.json +++ b/frontend/src/locales/sv.json @@ -226,10 +226,13 @@ "settings": { "language": "Språk", "changeEmail": "Ändra e-post", + "addEmail": "Lägg till e-post", + "noEmail": "Det här kontot har ingen e-postadress.", "currentEmail": "Nuvarande: {email}", "newEmail": "Ny e-post", "newEmailPlaceholder": "ny@exempel.se", "changeEmailButton": "Ändra e-post", + "addEmailButton": "Lägg till e-post", "emailInUseWarning": "Denna e-post används redan av ett annat konto. Du kan fortfarande använda den, men kontoåterställning kan kräva ditt användarnamn.", "requesting": "Begär...", "verificationCode": "Verifieringskod", @@ -263,6 +266,7 @@ "requestDeletion": "Begär kontoradering", "confirmationCode": "Bekräftelsekod", "confirmationCodePlaceholder": "Ange bekräftelsekod", + "deletionCodeHint": "Det här kontot har ingen e-post, så bekräftelsekoden visas här. Ange ditt lösenord om du har ett.", "yourPassword": "Ditt lösenord", "yourPasswordPlaceholder": "Ange ditt lösenord", "permanentlyDelete": "Radera konto permanent", @@ -270,9 +274,11 @@ "messages": { "emailCodeSentToCurrent": "Verifieringskod skickad till din nuvarande e-postadress", "emailUpdated": "E-post uppdaterad", + "emailAdded": "E-post tillagd. Kontrollera den för att verifiera kontot.", "emailUpdateFailed": "Kunde inte uppdatera e-post", "handleUpdated": "Användarnamn uppdaterat", "handleUpdateFailed": "Kunde inte uppdatera användarnamn", + "deletionCodeReady": "Bekräftelsekoden visas nedan. Det här kontot har ingen e-post.", "deletionConfirmationSent": "Bekräftelse för radering skickad till din e-post", "deletionRequestFailed": "Kunde inte begära kontoradering", "deleteConfirmation": "Är du helt säker på att du vill radera ditt konto? Detta kan inte ångras.", @@ -891,6 +897,10 @@ "scopeViewer": "Läsare", "scopeCustom": "Anpassad", "actAs": "Agera som", + "deleteAccount": "Ta bort", + "deleteAccountConfirm": "Ta bort {handle} permanent? Detta går inte att ångra.", + "accountDeleted": "Delegerat konto borttaget", + "deleteFailed": "Kunde inte ta bort det delegerade kontot", "auditLog": "Granskningslogg", "actor": "Aktör", "details": "Detaljer", diff --git a/frontend/src/locales/zh.json b/frontend/src/locales/zh.json index 1432b52..5e09088 100644 --- a/frontend/src/locales/zh.json +++ b/frontend/src/locales/zh.json @@ -226,10 +226,13 @@ "settings": { "language": "语言", "changeEmail": "更改邮箱", + "addEmail": "添加邮箱", + "noEmail": "此账户没有邮箱地址。", "currentEmail": "当前:{email}", "newEmail": "新邮箱", "newEmailPlaceholder": "new@example.com", "changeEmailButton": "更改邮箱", + "addEmailButton": "添加邮箱", "emailInUseWarning": "此邮箱已被其他账户使用。您仍可使用,但账户恢复可能需要使用用户名。", "requesting": "请求中...", "verificationCode": "验证码", @@ -263,6 +266,7 @@ "requestDeletion": "请求删除账户", "confirmationCode": "确认码", "confirmationCodePlaceholder": "输入确认码", + "deletionCodeHint": "此账户没有邮箱,因此确认码显示在此处。如果设置了密码,请输入密码。", "yourPassword": "您的密码", "yourPasswordPlaceholder": "输入您的密码", "permanentlyDelete": "永久删除账户", @@ -270,9 +274,11 @@ "messages": { "emailCodeSentToCurrent": "验证码已发送到您当前的邮箱地址", "emailUpdated": "邮箱更新成功", + "emailAdded": "邮箱已添加。请查收邮件以验证账户。", "emailUpdateFailed": "邮箱更新失败", "handleUpdated": "用户名更新成功", "handleUpdateFailed": "用户名更新失败", + "deletionCodeReady": "确认码显示在下方。此账户没有邮箱。", "deletionConfirmationSent": "删除确认码已发送到您的邮箱", "deletionRequestFailed": "账户删除请求失败", "deleteConfirmation": "您确定要删除账户吗?此操作无法撤销。", @@ -891,6 +897,10 @@ "scopeViewer": "查看者", "scopeCustom": "自定义", "actAs": "代理操作", + "deleteAccount": "删除", + "deleteAccountConfirm": "永久删除 {handle}?此操作无法撤销。", + "accountDeleted": "已删除委托账户", + "deleteFailed": "删除委托账户失败", "auditLog": "审计日志", "actor": "执行者", "accountCreated": "已创建委托账户:{handle}",