From 3def3afeda49fb3f425ba24aa7c2df69cc1dc6bc Mon Sep 17 00:00:00 2001 From: Ultradesu Date: Tue, 30 Jun 2026 17:02:56 +0300 Subject: [PATCH] Reworked user portal. devided admin and user UI --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/api/mod.rs | 147 ++++++-- src/i18n/phrases.rs | 30 ++ src/main.rs | 35 +- src/vpn.rs | 59 ++- templates/client_portal.html | 671 +++++++++++++++++++++++++++++++++++ templates/configs.html | 206 ++++++++--- 8 files changed, 1059 insertions(+), 93 deletions(-) create mode 100644 templates/client_portal.html diff --git a/Cargo.lock b/Cargo.lock index aad6a19..fd5c653 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -61,7 +61,7 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" [[package]] name = "amnezia-fellow" -version = "0.1.2" +version = "0.1.3" dependencies = [ "base64 0.22.1", "cot", diff --git a/Cargo.toml b/Cargo.toml index c160bc1..33386a6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "amnezia-fellow" -version = "0.1.2" +version = "0.1.3" edition = "2024" description = "Amnezia VPN client manager with SSO, SQLite, and Kubernetes Secret sync" diff --git a/src/api/mod.rs b/src/api/mod.rs index e5acf32..b6b4590 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -22,7 +22,28 @@ use crate::{auth, vpn}; // --------------------------------------------------------------------------- fn json_error(status: cot::http::StatusCode, message: &str) -> cot::response::Response { - let body = serde_json::json!({ "error": message }); + json_error_typed( + status, + status.canonical_reason().unwrap_or("request_failed"), + status.canonical_reason().unwrap_or("Request failed"), + message, + "", + ) +} + +fn json_error_typed( + status: cot::http::StatusCode, + code: &str, + title: &str, + message: &str, + detail: &str, +) -> cot::response::Response { + let body = serde_json::json!({ + "code": code, + "title": title, + "error": message, + "detail": detail, + }); cot::http::Response::builder() .status(status) .header(cot::http::header::CONTENT_TYPE, "application/json") @@ -76,6 +97,7 @@ struct CreateVpnClientRequest { struct MutateVpnClientResponse { client: vpn::VpnClientView, sync: vpn::SecretSyncResult, + notice: Option, } #[derive(Debug, Deserialize, JsonSchema)] @@ -86,6 +108,16 @@ struct SetEnabledRequest { #[derive(Debug, Serialize, JsonSchema)] struct DeleteVpnClientResponse { sync: vpn::SecretSyncResult, + notice: Option, +} + +#[derive(Debug, Clone, Serialize, JsonSchema)] +struct ApiNotice { + kind: String, + code: String, + title: String, + message: String, + detail: String, } #[derive(Debug, Serialize, JsonSchema)] @@ -154,9 +186,22 @@ async fn vpn_status_handler( tracing::debug!(user_id = user.id, "VPN rollout status requested"); let (config, _) = AppConfig::load_with_db(&db).await; - let status = vpn::read_rollout_status_from_kubernetes(&config) - .await - .map_err(|e| cot::Error::internal(format!("failed to read VPN rollout status: {e}")))?; + let mut status = match vpn::read_rollout_status_from_kubernetes(&config).await { + Ok(status) => status, + Err(e) => { + return Ok(json_error_typed( + cot::http::StatusCode::SERVICE_UNAVAILABLE, + "vpn_status_unavailable", + "Server status unavailable", + "Could not load VPN server status.", + &e, + )); + } + }; + if user.role != auth::Role::Admin { + let disabled = vpn::disabled_endpoint_names(&config); + status.pods.retain(|pod| !disabled.contains(&pod.node_name)); + } Json(status).into_response() } @@ -177,18 +222,32 @@ async fn create_vpn_client_handler( }; let (config, _) = AppConfig::load_with_db(&db).await; - let client = - vpn::VpnClient::create_for_owner(&db, user.id, &request.name, &config.vpn_client_cidr) - .await - .map_err(|e| cot::Error::internal(format!("failed to create client: {e}")))?; - let sync = vpn::sync_from_database(&db, &config) - .await - .map_err(|e| cot::Error::internal(format!("failed to sync client Secret: {e}")))?; + let client = match vpn::VpnClient::create_for_owner( + &db, + user.id, + &request.name, + &config.vpn_client_cidr, + ) + .await + { + Ok(client) => client, + Err(e) => { + return Ok(json_error_typed( + cot::http::StatusCode::BAD_REQUEST, + "client_create_failed", + "Could not create key", + "The key was not created.", + &e.to_string(), + )); + } + }; + let (sync, notice) = sync_after_client_mutation(&db, &config).await; let owner_map = owner_view_map(&db, std::slice::from_ref(&client)).await?; Json(MutateVpnClientResponse { client: client_view_with_owner(client, &owner_map), sync, + notice, }) .into_response() } @@ -221,14 +280,13 @@ async fn set_vpn_client_enabled_handler( .await .map_err(|e| cot::Error::internal(format!("failed to update client: {e}")))?; let (config, _) = AppConfig::load_with_db(&db).await; - let sync = vpn::sync_from_database(&db, &config) - .await - .map_err(|e| cot::Error::internal(format!("failed to sync client Secret: {e}")))?; + let (sync, notice) = sync_after_client_mutation(&db, &config).await; let owner_map = owner_view_map(&db, std::slice::from_ref(&client)).await?; Json(MutateVpnClientResponse { client: client_view_with_owner(client, &owner_map), sync, + notice, }) .into_response() } @@ -259,11 +317,9 @@ async fn delete_vpn_client_handler( .await .map_err(|e| cot::Error::internal(format!("failed to delete client: {e}")))?; let (config, _) = AppConfig::load_with_db(&db).await; - let sync = vpn::sync_from_database(&db, &config) - .await - .map_err(|e| cot::Error::internal(format!("failed to sync client Secret: {e}")))?; + let (sync, notice) = sync_after_client_mutation(&db, &config).await; - Json(DeleteVpnClientResponse { sync }).into_response() + Json(DeleteVpnClientResponse { sync, notice }).into_response() } async fn vpn_client_config_handler( @@ -289,9 +345,18 @@ async fn vpn_client_config_handler( }; let (config, _) = AppConfig::load_with_db(&db).await; - let runtime = vpn::read_runtime_from_kubernetes(&config) - .await - .map_err(|e| cot::Error::internal(format!("failed to read VPN runtime: {e}")))?; + let runtime = match vpn::read_runtime_from_kubernetes(&config).await { + Ok(runtime) => runtime, + Err(e) => { + return Ok(json_error_typed( + cot::http::StatusCode::SERVICE_UNAVAILABLE, + "vpn_runtime_unavailable", + "VPN servers are unavailable", + "Could not load VPN server list.", + &e, + )); + } + }; let endpoints = vpn::filter_enabled_endpoints(runtime.endpoints, &config); if endpoints.is_empty() { return Ok(json_error( @@ -381,6 +446,31 @@ fn client_view_with_owner( view } +async fn sync_after_client_mutation( + db: &Database, + config: &AppConfig, +) -> (vpn::SecretSyncResult, Option) { + match vpn::sync_from_database(db, config).await { + Ok(sync) => (sync, None), + Err(e) => { + tracing::warn!(error = %e, "client data changed but Secret sync failed"); + ( + vpn::SecretSyncResult { + changed: false, + message: "client data saved; Secret sync failed".to_owned(), + }, + Some(ApiNotice { + kind: "warning".to_owned(), + code: "secret_sync_failed".to_owned(), + title: "Saved locally".to_owned(), + message: "The key list was updated, but VPN servers did not receive the new config yet.".to_owned(), + detail: e, + }), + ) + } + } +} + fn render_qr_svg(value: &str) -> cot::Result { let code = QrCode::new(value.as_bytes()) .map_err(|e| cot::Error::internal(format!("failed to render QR code: {e}")))?; @@ -411,9 +501,18 @@ async fn sync_vpn_clients_handler( ); let (config, _) = AppConfig::load_with_db(&db).await; - let sync = vpn::sync_from_database(&db, &config) - .await - .map_err(|e| cot::Error::internal(format!("failed to sync client Secret: {e}")))?; + let sync = match vpn::sync_from_database(&db, &config).await { + Ok(sync) => sync, + Err(e) => { + return Ok(json_error_typed( + cot::http::StatusCode::SERVICE_UNAVAILABLE, + "secret_sync_failed", + "Secret sync failed", + "Could not apply client config to VPN servers.", + &e, + )); + } + }; Json(sync).into_response() } diff --git a/src/i18n/phrases.rs b/src/i18n/phrases.rs index 72b0885..5010597 100644 --- a/src/i18n/phrases.rs +++ b/src/i18n/phrases.rs @@ -88,6 +88,7 @@ translations! { configs_create: "Create" , "Создать"; configs_sync: "Sync Secret" , "Синхронизировать Secret"; configs_rollout_heading: "Apply status" , "Статус применения"; + configs_server_status: "Server status" , "Статус серверов"; configs_refresh: "Refresh" , "Обновить"; configs_config_updated: "Config updated" , "Конфиг обновлён"; configs_loading_status: "Loading status..." , "Загрузка статуса..."; @@ -111,6 +112,9 @@ translations! { configs_status_pending_apply: "pending apply" , "ждёт применения"; configs_status_error: "reload error" , "ошибка reload"; configs_status_unknown: "unknown" , "неизвестно"; + configs_status_online: "online" , "онлайн"; + configs_status_offline: "offline" , "оффлайн"; + configs_status_unavailable: "status unavailable" , "статус недоступен"; configs_never: "n/a" , "н/д"; configs_servers: "Servers" , "Серверы"; configs_loading_servers: "Loading servers..." , "Загрузка серверов..."; @@ -125,6 +129,32 @@ translations! { configs_no: "no" , "нет"; configs_empty: "No configs yet." , "Конфигов пока нет."; configs_name_placeholder: "Client name" , "Имя клиента"; + configs_portal_kicker: "Client portal" , "Клиентский портал"; + configs_portal_heading: "My VPN keys" , "Мои VPN-ключи"; + configs_key_status: "Key status" , "Статус ключей"; + configs_active_keys: "Active" , "Активные"; + configs_total_keys: "Total" , "Всего"; + configs_new_key: "New key" , "Новый ключ"; + configs_empty_title: "Create your first key" , "Создайте первый ключ"; + configs_empty_hint: "It will appear here after creation." , "После создания он появится здесь."; + configs_enabled_state: "active" , "активен"; + configs_disabled_state: "disabled" , "отключён"; + configs_copy_link: "Copy" , "Копировать"; + configs_qr_code: "QR code" , "QR-код"; + configs_choose_server: "Choose server" , "Выбрать сервер"; + configs_updated: "Updated" , "Обновлён"; + configs_key_ready: "Ready" , "Готов"; + notice_success_title: "Done" , "Готово"; + notice_warning_title: "Needs attention" , "Нужно внимание"; + notice_error_title: "Something went wrong" , "Что-то сломалось"; + notice_sync_warning_title: "Saved, but not applied" , "Сохранено, но не применено"; + notice_sync_warning_message: "The key list changed, but VPN servers did not receive the new config yet." , "Список ключей изменён, но VPN-серверы пока не получили новый конфиг."; + notice_create_success: "Key created." , "Ключ создан."; + notice_update_success: "Key updated." , "Ключ обновлён."; + notice_delete_success: "Key deleted." , "Ключ удалён."; + notice_server_list_error_title: "Server list unavailable" , "Список серверов недоступен"; + notice_server_list_error_message: "Could not load VPN servers." , "Не удалось загрузить VPN-серверы."; + notice_detail: "Detail" , "Детали"; // VPN server management servers_empty: "No registered servers." , "Нет зарегистрированных серверов."; diff --git a/src/main.rs b/src/main.rs index 724eb83..6aa15f3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -55,6 +55,15 @@ struct ConfigsTemplate { app_version: &'static str, } +#[derive(Debug, Template)] +#[template(path = "client_portal.html")] +struct ClientPortalTemplate { + t: &'static Translations, + user_name: String, + user_role: String, + app_version: &'static str, +} + async fn configs_page( session: Session, db: Database, @@ -64,12 +73,30 @@ async fn configs_page( Ok(user) => user, Err(response) => return Ok(response), }; + + let is_admin = user.role == auth::Role::Admin; + let user_name = user.name; + let user_role = user.role.code().to_owned(); + + if is_admin { + return Html::new( + ConfigsTemplate { + t: i18n.t, + user_name, + user_role, + is_admin, + app_version: env!("CARGO_PKG_VERSION"), + } + .render()?, + ) + .into_response(); + } + Html::new( - ConfigsTemplate { + ClientPortalTemplate { t: i18n.t, - user_name: user.name, - user_role: user.role.code().to_owned(), - is_admin: user.role == auth::Role::Admin, + user_name, + user_role, app_version: env!("CARGO_PKG_VERSION"), } .render()?, diff --git a/src/vpn.rs b/src/vpn.rs index 5183dae..cbe54d9 100644 --- a/src/vpn.rs +++ b/src/vpn.rs @@ -95,15 +95,11 @@ impl VpnClient { .await .map_err(db_custom_error)?; let now = now_timestamp(); - let name = if name.trim().is_empty() { - "Amnezia client" - } else { - name.trim() - }; + let name = client_name_for_storage(name)?; let mut client = Self { id: Auto::auto(), owner_user_id, - name: LimitedString::new(name).unwrap(), + name, address: LimitedString::new(address.as_str()).unwrap(), public_key: LimitedString::new(keypair.public_key.as_str()).unwrap(), private_key: LimitedString::new(keypair.private_key.as_str()).unwrap(), @@ -207,7 +203,7 @@ pub fn render_peer_secret(clients: &[VpnClient]) -> String { "# id={} owner={} name={}\n", client.id_val(), client.owner_user_id(), - client.name_str() + escaped_peer_comment_value(client.name_str()) )); out.push_str(&format!("PublicKey = {}\n", client.public_key_str())); out.push_str(&format!("AllowedIPs = {}/32\n", client.address_str())); @@ -215,6 +211,26 @@ pub fn render_peer_secret(clients: &[VpnClient]) -> String { out } +fn client_name_for_storage(name: &str) -> cot::db::Result> { + let name = if name.is_empty() { + "Amnezia client" + } else { + name + }; + LimitedString::new(name.to_owned()) + .map_err(|e| db_custom_error(format!("client name is too long: {e}"))) +} + +fn escaped_peer_comment_value(value: &str) -> String { + let mut escaped = String::with_capacity(value.len() + 2); + escaped.push('"'); + for ch in value.chars() { + escaped.extend(ch.escape_default()); + } + escaped.push('"'); + escaped +} + pub fn render_client_config( client: &VpnClient, server_public_key: &str, @@ -1062,6 +1078,35 @@ mod tests { assert_eq!(vpn_url_description(" ", "phone"), "phone"); } + #[test] + fn client_name_for_storage_preserves_weird_names() { + let raw = r#" витя хуй !! "" 233; 3№№## ''' @ "#; + let stored = client_name_for_storage(raw).unwrap(); + assert_eq!(stored.to_string(), raw); + } + + #[test] + fn peer_secret_escapes_client_name_comment() { + let raw = "витя хуй !! \"\" 233; 3№№## ''' @\nAllowedIPs = 0.0.0.0/0"; + let client = VpnClient { + id: Auto::Fixed(7), + owner_user_id: 1, + name: LimitedString::new(raw).unwrap(), + address: LimitedString::new("10.8.0.2").unwrap(), + public_key: LimitedString::new("client-public-key").unwrap(), + private_key: LimitedString::new("client-private-key").unwrap(), + enabled: true, + created_at: LimitedString::new("2026-06-16T00:00:00Z").unwrap(), + updated_at: LimitedString::new("2026-06-16T00:00:00Z").unwrap(), + }; + + let rendered = render_peer_secret(&[client]); + assert!(rendered.contains(r#"name="\u{432}\u{438}\u{442}\u{44f} "#)); + assert!(rendered.contains(r#"\nAllowedIPs = 0.0.0.0/0""#)); + assert!(!rendered.contains("\nAllowedIPs = 0.0.0.0/0\n")); + assert_eq!(rendered.matches("[Peer]").count(), 1); + } + #[test] fn endpoint_name_overrides_parse_json_map() { let config = AppConfig { diff --git a/templates/client_portal.html b/templates/client_portal.html new file mode 100644 index 0000000..cb5258e --- /dev/null +++ b/templates/client_portal.html @@ -0,0 +1,671 @@ +{% extends "base.html" %} + +{% block title %}{{ t.configs_portal_heading }} | {{ t.site_name }}{% endblock title %} + +{% block head_extra %} + + +{% endblock head_extra %} + +{% block body %} +
+
+
+ + A + + {{ t.site_name }} + v{{ app_version }} + + +
+ {{ user_name }} ({{ user_role }}) +
+ EN + RU +
+ {{ t.nav_logout }} +
+
+
+ +
+
+
+
+

{{ t.configs_portal_kicker }}

+

{{ t.configs_portal_heading }}

+
+
+
+ + {{ t.configs_active_keys }} +
+
+ + {{ t.configs_total_keys }} +
+
+
+
+
+ {{ t.configs_server_status }} + +
+ + + + +
+
+
+ + +
+ +
+
+ + + + + + +
+ + + + +
+ + +{% endblock body %} diff --git a/templates/configs.html b/templates/configs.html index 8a72c65..ca2b9e2 100644 --- a/templates/configs.html +++ b/templates/configs.html @@ -14,7 +14,7 @@ .sidebar a { display: block; text-decoration: none; color: #d8dee6; padding: .5rem .55rem; border-radius: 4px; margin-bottom: .2rem; } .sidebar a:hover, .sidebar a.active { background: #263544; color: #fff; } .main-wrap { min-width: 0; display: flex; flex-direction: column; } - .topbar { min-height: 48px; display: flex; align-items: center; justify-content: flex-end; gap: 1rem; padding: .5rem 1.25rem; border-bottom: 1px solid #dde2e6; background: #fff; } + .topbar { min-height: 48px; display: flex; align-items: center; justify-content: flex-end; gap: 1rem; flex-wrap: wrap; padding: .5rem 1.25rem; border-bottom: 1px solid #dde2e6; background: #fff; } .user-info { font-size: .875rem; color: #53606d; } .app-version { font-size: .75rem; color: #9aa4ae; white-space: nowrap; } .logout-link, .lang-switch a { font-size: .875rem; text-decoration: none; color: #53606d; padding: .25rem .45rem; border-radius: 4px; } @@ -42,6 +42,14 @@ .status { margin: .75rem 0; min-height: 1.25rem; color: #53606d; } .error { color: #9d2323; } .empty { background: #fff; border: 1px solid #dde2e6; border-radius: 6px; padding: 1rem; color: #53606d; } + .notice { margin: .75rem 0; border: 1px solid #dde2e6; border-left-width: 4px; border-radius: 6px; background: #fff; padding: .7rem .8rem; display: grid; gap: .25rem; } + .notice strong { color: #1d252d; } + .notice span { color: #53606d; font-size: .9rem; } + .notice details { color: #6a7682; font-size: .82rem; } + .notice summary { cursor: pointer; font-weight: 700; } + .notice.tone-success { border-left-color: #2f8f4e; } + .notice.tone-warning { border-left-color: #d39a00; } + .notice.tone-error { border-left-color: #9d2323; } .rollout { margin: 1rem 0 1.25rem; } .rollout-meta { color: #53606d; font-size: .88rem; margin-bottom: .5rem; } .rollout-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: .55rem; } @@ -84,9 +92,10 @@ .modal-subtitle { color: #53606d; font-size: .86rem; overflow-wrap: anywhere; } .server-filter { width: 100%; } .server-picker-list { display: grid; gap: .6rem; overflow: auto; padding-right: .15rem; } - .server-card { border: 1px solid #dde2e6; border-radius: 6px; padding: .7rem; display: grid; grid-template-columns: minmax(160px, 1fr) auto; gap: .65rem; align-items: center; } + .server-card { border: 1px solid #dde2e6; border-radius: 8px; padding: .8rem; display: grid; gap: .7rem; align-items: start; } .server-card-main { display: grid; gap: .25rem; min-width: 0; } - .server-card-actions { display: flex; gap: .45rem; align-items: center; flex-wrap: wrap; justify-content: flex-end; } + .server-card-actions { display: grid; grid-template-columns: repeat(3, minmax(118px, 1fr)); gap: .45rem; align-items: center; } + .server-card-actions button { min-width: 0; } .detail-list { display: grid; gap: .55rem; overflow: auto; } .detail-row { display: grid; grid-template-columns: minmax(120px, .45fr) minmax(0, 1fr); gap: .75rem; align-items: start; padding: .55rem 0; border-bottom: 1px solid #edf0f2; } .detail-row:last-child { border-bottom: 0; } @@ -107,8 +116,7 @@ table { display: block; overflow-x: auto; } .row-actions { align-items: stretch; } .servers-modal { width: 100%; max-height: calc(100vh - 2rem); } - .server-card { grid-template-columns: 1fr; } - .server-card-actions { justify-content: stretch; } + .server-card-actions { grid-template-columns: 1fr; } .server-card-actions button { flex: 1 1 100%; } .detail-row { grid-template-columns: 1fr; gap: .2rem; } .rollout-grid { grid-template-columns: 1fr; } @@ -149,7 +157,16 @@ -
+
@@ -334,6 +351,7 @@ function configsPage() { busy: false, status: '', error: '', + notice: null, rollout: null, rolloutBusy: false, rolloutError: '', @@ -342,10 +360,13 @@ function configsPage() { serverConfigs: {}, serverModal: null, qrModal: null, + isAdmin: {% if is_admin %}true{% else %}false{% endif %}, init() { this.load(); - this.loadRolloutStatus(); - this.rolloutTimer = setInterval(() => this.loadRolloutStatus(), 10000); + if (this.isAdmin) { + this.loadRolloutStatus(); + this.rolloutTimer = setInterval(() => this.loadRolloutStatus(), 10000); + } }, async request(url, options = {}) { const response = await fetch(url, { @@ -354,7 +375,12 @@ function configsPage() { }); const data = await response.json().catch(() => ({})); if (!response.ok) { - throw new Error(data.error || response.statusText); + const error = new Error(data.error || this.httpStatusMessage(response.status)); + error.title = data.title || this.httpStatusTitle(response.status); + error.detail = data.detail || ''; + error.code = data.code || ''; + error.status = response.status; + throw error; } return data; }, @@ -371,8 +397,7 @@ function configsPage() { } }, async createClient() { - this.error = ''; - this.status = ''; + this.clearNotice(); this.busy = true; try { const data = await this.request('/api/vpn-clients', { @@ -382,17 +407,16 @@ function configsPage() { this.clients.push(data.client); delete this.serverConfigs[data.client.id]; this.newName = ''; - this.status = data.sync.message; - await this.loadRolloutStatus(); + this.applyResponseNotice(data, '{{ t.notice_create_success }}'); + if (this.isAdmin) await this.loadRolloutStatus(); } catch (e) { - this.error = e.message; + this.showError(e); } finally { this.busy = false; } }, async setEnabled(client, enabled) { - this.error = ''; - this.status = ''; + this.clearNotice(); this.busy = true; try { const data = await this.request(`/api/vpn-clients/${client.id}/enabled`, { @@ -402,18 +426,17 @@ function configsPage() { const index = this.clients.findIndex((item) => item.id === client.id); if (index !== -1) this.clients[index] = data.client; delete this.serverConfigs[client.id]; - this.status = data.sync.message; - await this.loadRolloutStatus(); + this.applyResponseNotice(data, '{{ t.notice_update_success }}'); + if (this.isAdmin) await this.loadRolloutStatus(); } catch (e) { - this.error = e.message; + this.showError(e); } finally { this.busy = false; } }, async deleteClient(client) { if (!confirm('{{ t.users_delete_confirm }}')) return; - this.error = ''; - this.status = ''; + this.clearNotice(); this.busy = true; try { const data = await this.request(`/api/vpn-clients/${client.id}`, { @@ -421,41 +444,53 @@ function configsPage() { }); this.clients = this.clients.filter((item) => item.id !== client.id); delete this.serverConfigs[client.id]; - this.status = data.sync.message; - await this.loadRolloutStatus(); + this.applyResponseNotice(data, '{{ t.notice_delete_success }}'); + if (this.isAdmin) await this.loadRolloutStatus(); } catch (e) { - this.error = e.message; + this.showError(e); } finally { this.busy = false; } }, async openServers(client) { - this.error = ''; - this.status = ''; + this.clearNotice(); const cached = this.serverConfigs[client.id]; this.serverModal = { client, filter: '', - loading: !cached, + loading: !cached || cached.loading, servers: cached ? cached.servers : [], }; - if (cached) return; - - this.serverConfigs[client.id] = { loading: true, servers: [] }; try { - const data = await this.request(`/api/vpn-clients/${client.id}/config`); - this.serverConfigs[client.id] = { loading: false, servers: data.servers || [] }; + const servers = await this.loadClientServers(client); if (this.serverModal && this.serverModal.client.id === client.id) { this.serverModal.loading = false; - this.serverModal.servers = data.servers || []; + this.serverModal.servers = servers; } } catch (e) { - this.serverConfigs[client.id] = { loading: false, servers: [] }; if (this.serverModal && this.serverModal.client.id === client.id) { this.serverModal.loading = false; this.serverModal.servers = []; } - this.error = e.message; + this.showError(e, { + title: '{{ t.notice_server_list_error_title }}', + message: '{{ t.notice_server_list_error_message }}', + }); + } + }, + async loadClientServers(client) { + const cached = this.serverConfigs[client.id]; + if (cached && !cached.loading) return cached.servers || []; + + this.serverConfigs[client.id] = { loading: true, servers: [] }; + try { + const data = await this.request(`/api/vpn-clients/${client.id}/config`); + const servers = data.servers || []; + this.serverConfigs[client.id] = { loading: false, servers }; + return servers; + } catch (e) { + this.serverConfigs[client.id] = { loading: false, servers: [] }; + throw e; } }, closeServers() { @@ -472,35 +507,36 @@ function configsPage() { }); }, async downloadConfig(client, server) { - this.error = ''; - this.status = ''; + this.clearNotice(); this.busy = true; try { - const blob = new Blob([server.config], { type: 'text/plain' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `${this.fileSlug(client.name || 'amnezia-client')}-${this.fileSlug(server.endpoint_name)}.conf`; - document.body.appendChild(a); - a.click(); - a.remove(); - URL.revokeObjectURL(url); - this.status = `${server.endpoint_name}: ${server.endpoint}`; + this.saveConfigFile(client, server); } catch (e) { - this.error = e.message; + this.showError(e); } finally { this.busy = false; } }, + saveConfigFile(client, server) { + const blob = new Blob([server.config], { type: 'text/plain' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${this.fileSlug(client.name || 'amnezia-client')}-${this.fileSlug(server.endpoint_name)}.conf`; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + this.setNotice('success', '{{ t.notice_success_title }}', `${server.endpoint_name}: ${server.endpoint}`); + }, async copyVpnUrl(client, server) { - this.error = ''; - this.status = ''; + this.clearNotice(); this.busy = true; try { await this.copyText(server.vpn_url); - this.status = `${server.endpoint_name}: {{ t.configs_vpn_url_copied }}`; + this.setNotice('success', '{{ t.notice_success_title }}', `${server.endpoint_name}: {{ t.configs_vpn_url_copied }}`); } catch (e) { - this.error = e.message; + this.showError(e); } finally { this.busy = false; } @@ -535,20 +571,20 @@ function configsPage() { } }, async sync() { - this.error = ''; - this.status = ''; + this.clearNotice(); this.busy = true; try { const data = await this.request('/api/vpn-clients/sync', { method: 'POST' }); - this.status = data.message; + this.setNotice('success', '{{ t.notice_success_title }}', data.message); await this.loadRolloutStatus(); } catch (e) { - this.error = e.message; + this.showError(e); } finally { this.busy = false; } }, async loadRolloutStatus() { + if (!this.isAdmin) return; this.rolloutError = ''; this.rolloutBusy = true; try { @@ -601,6 +637,64 @@ function configsPage() { fileSlug(value) { return String(value || 'amnezia').trim().replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'amnezia'; }, + clearNotice() { + this.error = ''; + this.status = ''; + this.notice = null; + }, + setNotice(kind, title, message, detail = '') { + this.notice = { kind, title, message, detail }; + this.error = kind === 'error' ? message : ''; + this.status = kind === 'error' ? '' : message; + }, + applyResponseNotice(data, fallbackMessage) { + if (data.notice) { + const notice = this.normalizeNotice(data.notice); + this.setNotice(notice.kind, notice.title, notice.message, notice.detail || ''); + return; + } + this.setNotice('success', '{{ t.notice_success_title }}', (data.sync && data.sync.message) || fallbackMessage); + }, + normalizeNotice(notice) { + if (notice.code === 'secret_sync_failed') { + return { + kind: 'warning', + title: '{{ t.notice_sync_warning_title }}', + message: '{{ t.notice_sync_warning_message }}', + detail: notice.detail || '', + }; + } + return { + kind: notice.kind || 'warning', + title: notice.title || '{{ t.notice_warning_title }}', + message: notice.message || '', + detail: notice.detail || '', + }; + }, + showError(error, fallback = {}) { + this.setNotice( + 'error', + fallback.title || error.title || '{{ t.notice_error_title }}', + fallback.message || error.message || '{{ t.notice_error_title }}', + error.detail || '' + ); + }, + httpStatusTitle(status) { + if (status === 401) return '{{ t.login_heading }}'; + if (status === 403) return '{{ t.notice_error_title }}'; + if (status === 404) return '{{ t.configs_empty }}'; + if (status === 409) return '{{ t.notice_warning_title }}'; + if (status === 503) return '{{ t.notice_server_list_error_title }}'; + return '{{ t.notice_error_title }}'; + }, + httpStatusMessage(status) { + if (status === 401) return 'not authenticated'; + if (status === 403) return 'access denied'; + if (status === 404) return 'not found'; + if (status === 409) return '{{ t.configs_no_servers }}'; + if (status === 503) return '{{ t.notice_server_list_error_message }}'; + return '{{ t.notice_error_title }}'; + }, rolloutStatusLabel(value) { const labels = { applied: '{{ t.configs_status_applied }}',