Reworked user portal. devided admin and user UI
Build and Publish / Build and Publish Docker Image (push) Successful in 3m7s

This commit is contained in:
Ultradesu
2026-06-30 17:02:56 +03:00
parent c3db347da6
commit 3def3afeda
8 changed files with 1059 additions and 93 deletions
+123 -24
View File
@@ -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<ApiNotice>,
}
#[derive(Debug, Deserialize, JsonSchema)]
@@ -86,6 +108,16 @@ struct SetEnabledRequest {
#[derive(Debug, Serialize, JsonSchema)]
struct DeleteVpnClientResponse {
sync: vpn::SecretSyncResult,
notice: Option<ApiNotice>,
}
#[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<ApiNotice>) {
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<String> {
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()
}
+30
View File
@@ -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." , "Нет зарегистрированных серверов.";
+31 -4
View File
@@ -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()?,
+52 -7
View File
@@ -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<LimitedString<255>> {
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 {