mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
Merge pull request #432 from RWDai/fix/cli-install-copy
Add admin API key CLI install sessions
This commit is contained in:
@@ -158,9 +158,21 @@ pub(super) fn classify_admin_observability_family_route(
|
|||||||
"admin:api_keys",
|
"admin:api_keys",
|
||||||
false,
|
false,
|
||||||
))
|
))
|
||||||
|
} else if method == http::Method::POST
|
||||||
|
&& normalized_path_no_trailing.starts_with("/api/admin/api-keys/")
|
||||||
|
&& normalized_path_no_trailing.ends_with("/install-sessions")
|
||||||
|
&& normalized_path_no_trailing.matches('/').count() == 5
|
||||||
|
{
|
||||||
|
Some(classified(
|
||||||
|
"admin_proxy",
|
||||||
|
"api_keys_manage",
|
||||||
|
"create_api_key_install_session",
|
||||||
|
"admin:api_keys",
|
||||||
|
false,
|
||||||
|
))
|
||||||
} else if method == http::Method::GET
|
} else if method == http::Method::GET
|
||||||
&& normalized_path.starts_with("/api/admin/api-keys/")
|
&& normalized_path_no_trailing.starts_with("/api/admin/api-keys/")
|
||||||
&& normalized_path.matches('/').count() == 4
|
&& normalized_path_no_trailing.matches('/').count() == 4
|
||||||
{
|
{
|
||||||
Some(classified(
|
Some(classified(
|
||||||
"admin_proxy",
|
"admin_proxy",
|
||||||
@@ -170,8 +182,8 @@ pub(super) fn classify_admin_observability_family_route(
|
|||||||
false,
|
false,
|
||||||
))
|
))
|
||||||
} else if method == http::Method::PUT
|
} else if method == http::Method::PUT
|
||||||
&& normalized_path.starts_with("/api/admin/api-keys/")
|
&& normalized_path_no_trailing.starts_with("/api/admin/api-keys/")
|
||||||
&& normalized_path.matches('/').count() == 4
|
&& normalized_path_no_trailing.matches('/').count() == 4
|
||||||
{
|
{
|
||||||
Some(classified(
|
Some(classified(
|
||||||
"admin_proxy",
|
"admin_proxy",
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
use http::Uri;
|
use http::Uri;
|
||||||
|
|
||||||
|
use crate::control::GatewayPublicRequestContext;
|
||||||
|
use crate::handlers::shared::local_proxy_route_requires_buffered_body;
|
||||||
|
|
||||||
use super::{classify_control_route, headers};
|
use super::{classify_control_route, headers};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -38,6 +41,50 @@ fn classifies_admin_api_keys_create_as_admin_proxy_route() {
|
|||||||
assert!(!decision.is_execution_runtime_candidate());
|
assert!(!decision.is_execution_runtime_candidate());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn classifies_admin_api_key_install_session_create_as_admin_proxy_route() {
|
||||||
|
let headers = headers(&[]);
|
||||||
|
for path in [
|
||||||
|
"/api/admin/api-keys/key-123/install-sessions",
|
||||||
|
"/api/admin/api-keys/key-123/install-sessions/",
|
||||||
|
] {
|
||||||
|
let uri: Uri = path.parse().expect("uri should parse");
|
||||||
|
let decision = classify_control_route(&http::Method::POST, &uri, &headers)
|
||||||
|
.expect("route should classify");
|
||||||
|
|
||||||
|
assert_eq!(decision.route_class.as_deref(), Some("admin_proxy"));
|
||||||
|
assert_eq!(decision.route_family.as_deref(), Some("api_keys_manage"));
|
||||||
|
assert_eq!(
|
||||||
|
decision.route_kind.as_deref(),
|
||||||
|
Some("create_api_key_install_session")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
decision.auth_endpoint_signature.as_deref(),
|
||||||
|
Some("admin:api_keys")
|
||||||
|
);
|
||||||
|
assert!(!decision.is_execution_runtime_candidate());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn admin_api_key_install_session_create_buffers_request_body() {
|
||||||
|
let headers = headers(&[]);
|
||||||
|
let uri: Uri = "/api/admin/api-keys/key-123/install-sessions"
|
||||||
|
.parse()
|
||||||
|
.expect("uri should parse");
|
||||||
|
let decision =
|
||||||
|
classify_control_route(&http::Method::POST, &uri, &headers).expect("route should classify");
|
||||||
|
let context = GatewayPublicRequestContext::from_request_parts(
|
||||||
|
"trace-admin-install-session",
|
||||||
|
&http::Method::POST,
|
||||||
|
&uri,
|
||||||
|
&headers,
|
||||||
|
Some(decision),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(local_proxy_route_requires_buffered_body(&context));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn classifies_admin_api_keys_detail_as_admin_proxy_route() {
|
fn classifies_admin_api_keys_detail_as_admin_proxy_route() {
|
||||||
let headers = headers(&[]);
|
let headers = headers(&[]);
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
use super::shared::{
|
||||||
|
admin_api_key_install_session_id_from_path, build_admin_api_keys_bad_request_response,
|
||||||
|
build_admin_api_keys_data_unavailable_response, build_admin_api_keys_not_found_response,
|
||||||
|
};
|
||||||
|
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
|
||||||
|
use crate::handlers::admin::shared::attach_admin_audit_response;
|
||||||
|
use crate::handlers::public::{
|
||||||
|
build_api_key_install_session_response, CreateApiKeyInstallSessionRequest,
|
||||||
|
};
|
||||||
|
use crate::GatewayError;
|
||||||
|
use axum::{
|
||||||
|
body::Body,
|
||||||
|
http,
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub(super) async fn build_admin_create_api_key_install_session_response(
|
||||||
|
state: &AdminAppState<'_>,
|
||||||
|
request_context: &AdminRequestContext<'_>,
|
||||||
|
request_headers: &http::HeaderMap,
|
||||||
|
request_body: Option<&axum::body::Bytes>,
|
||||||
|
) -> Result<Response<Body>, GatewayError> {
|
||||||
|
if !state.has_auth_api_key_data_reader() {
|
||||||
|
return Ok(build_admin_api_keys_data_unavailable_response());
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(api_key_id) = admin_api_key_install_session_id_from_path(request_context.path())
|
||||||
|
else {
|
||||||
|
return Ok(build_admin_api_keys_data_unavailable_response());
|
||||||
|
};
|
||||||
|
let Some(request_body) = request_body else {
|
||||||
|
return Ok(build_admin_api_keys_bad_request_response(
|
||||||
|
"请求数据验证失败",
|
||||||
|
));
|
||||||
|
};
|
||||||
|
let payload = match serde_json::from_slice::<CreateApiKeyInstallSessionRequest>(request_body) {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(_) => {
|
||||||
|
return Ok(build_admin_api_keys_bad_request_response(
|
||||||
|
"请求数据验证失败",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(record) = state
|
||||||
|
.find_auth_api_key_export_standalone_record_by_id(&api_key_id)
|
||||||
|
.await?
|
||||||
|
else {
|
||||||
|
return Ok(build_admin_api_keys_not_found_response());
|
||||||
|
};
|
||||||
|
let Some(ciphertext) = record
|
||||||
|
.key_encrypted
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
else {
|
||||||
|
return Ok(build_admin_api_keys_bad_request_response(
|
||||||
|
"该密钥没有存储完整密钥信息",
|
||||||
|
));
|
||||||
|
};
|
||||||
|
let Some(api_key) = state.decrypt_catalog_secret_with_fallbacks(ciphertext) else {
|
||||||
|
return Ok((
|
||||||
|
http::StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
axum::Json(serde_json::json!({ "detail": "解密密钥失败" })),
|
||||||
|
)
|
||||||
|
.into_response());
|
||||||
|
};
|
||||||
|
|
||||||
|
let response = build_api_key_install_session_response(
|
||||||
|
state.app(),
|
||||||
|
request_context.public(),
|
||||||
|
request_headers,
|
||||||
|
record.api_key_id.clone(),
|
||||||
|
record.name.unwrap_or_else(|| "API Key".to_string()),
|
||||||
|
api_key,
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
Ok(attach_admin_audit_response(
|
||||||
|
response,
|
||||||
|
"admin_standalone_api_key_install_session_created",
|
||||||
|
"create_standalone_api_key_install_session",
|
||||||
|
"api_key",
|
||||||
|
&api_key_id,
|
||||||
|
))
|
||||||
|
}
|
||||||
@@ -18,11 +18,13 @@ use axum::{
|
|||||||
};
|
};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
|
mod install_routes;
|
||||||
mod mutation_routes;
|
mod mutation_routes;
|
||||||
mod read_routes;
|
mod read_routes;
|
||||||
mod routes;
|
mod routes;
|
||||||
mod shared;
|
mod shared;
|
||||||
|
|
||||||
|
use self::install_routes::build_admin_create_api_key_install_session_response;
|
||||||
use self::mutation_routes::{
|
use self::mutation_routes::{
|
||||||
build_admin_create_api_key_response, build_admin_delete_api_key_response,
|
build_admin_create_api_key_response, build_admin_delete_api_key_response,
|
||||||
build_admin_toggle_api_key_response, build_admin_update_api_key_response,
|
build_admin_toggle_api_key_response, build_admin_update_api_key_response,
|
||||||
@@ -39,8 +41,14 @@ use self::shared::{
|
|||||||
pub(crate) async fn maybe_build_local_admin_api_keys_response(
|
pub(crate) async fn maybe_build_local_admin_api_keys_response(
|
||||||
state: &AdminAppState<'_>,
|
state: &AdminAppState<'_>,
|
||||||
request_context: &AdminRequestContext<'_>,
|
request_context: &AdminRequestContext<'_>,
|
||||||
|
request_headers: &http::HeaderMap,
|
||||||
request_body: Option<&axum::body::Bytes>,
|
request_body: Option<&axum::body::Bytes>,
|
||||||
) -> Result<Option<Response<Body>>, GatewayError> {
|
) -> Result<Option<Response<Body>>, GatewayError> {
|
||||||
routes::maybe_build_local_admin_api_keys_routes_response(state, request_context, request_body)
|
routes::maybe_build_local_admin_api_keys_routes_response(
|
||||||
.await
|
state,
|
||||||
|
request_context,
|
||||||
|
request_headers,
|
||||||
|
request_body,
|
||||||
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use super::install_routes::build_admin_create_api_key_install_session_response;
|
||||||
use super::mutation_routes::{
|
use super::mutation_routes::{
|
||||||
build_admin_create_api_key_response, build_admin_delete_api_key_response,
|
build_admin_create_api_key_response, build_admin_delete_api_key_response,
|
||||||
build_admin_toggle_api_key_response, build_admin_update_api_key_response,
|
build_admin_toggle_api_key_response, build_admin_update_api_key_response,
|
||||||
@@ -11,6 +12,7 @@ use axum::{body::Body, http, response::Response};
|
|||||||
pub(super) async fn maybe_build_local_admin_api_keys_routes_response(
|
pub(super) async fn maybe_build_local_admin_api_keys_routes_response(
|
||||||
state: &AdminAppState<'_>,
|
state: &AdminAppState<'_>,
|
||||||
request_context: &AdminRequestContext<'_>,
|
request_context: &AdminRequestContext<'_>,
|
||||||
|
request_headers: &http::HeaderMap,
|
||||||
request_body: Option<&axum::body::Bytes>,
|
request_body: Option<&axum::body::Bytes>,
|
||||||
) -> Result<Option<Response<Body>>, GatewayError> {
|
) -> Result<Option<Response<Body>>, GatewayError> {
|
||||||
let Some(decision) = request_context.decision() else {
|
let Some(decision) = request_context.decision() else {
|
||||||
@@ -22,8 +24,10 @@ pub(super) async fn maybe_build_local_admin_api_keys_routes_response(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let path = request_context.path();
|
let path = request_context.path();
|
||||||
|
let path_no_trailing = path.trim_end_matches('/');
|
||||||
let is_api_keys_route = matches!(path, "/api/admin/api-keys" | "/api/admin/api-keys/")
|
let is_api_keys_route = matches!(path, "/api/admin/api-keys" | "/api/admin/api-keys/")
|
||||||
|| (path.starts_with("/api/admin/api-keys/") && path.matches('/').count() == 4);
|
|| (path_no_trailing.starts_with("/api/admin/api-keys/")
|
||||||
|
&& matches!(path_no_trailing.matches('/').count(), 4 | 5));
|
||||||
|
|
||||||
if !is_api_keys_route {
|
if !is_api_keys_route {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
@@ -54,6 +58,21 @@ pub(super) async fn maybe_build_local_admin_api_keys_routes_response(
|
|||||||
build_admin_create_api_key_response(state, request_context, request_body).await?,
|
build_admin_create_api_key_response(state, request_context, request_body).await?,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
Some("create_api_key_install_session")
|
||||||
|
if request_context.method() == http::Method::POST
|
||||||
|
&& path_no_trailing.starts_with("/api/admin/api-keys/")
|
||||||
|
&& path_no_trailing.ends_with("/install-sessions") =>
|
||||||
|
{
|
||||||
|
Ok(Some(
|
||||||
|
build_admin_create_api_key_install_session_response(
|
||||||
|
state,
|
||||||
|
request_context,
|
||||||
|
request_headers,
|
||||||
|
request_body,
|
||||||
|
)
|
||||||
|
.await?,
|
||||||
|
))
|
||||||
|
}
|
||||||
Some("update_api_key")
|
Some("update_api_key")
|
||||||
if request_context.method() == http::Method::PUT
|
if request_context.method() == http::Method::PUT
|
||||||
&& path.starts_with("/api/admin/api-keys/") =>
|
&& path.starts_with("/api/admin/api-keys/") =>
|
||||||
|
|||||||
@@ -91,6 +91,17 @@ pub(super) fn admin_api_keys_id_from_path(request_path: &str) -> Option<String>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(super) fn admin_api_key_install_session_id_from_path(request_path: &str) -> Option<String> {
|
||||||
|
let raw = request_path
|
||||||
|
.strip_prefix("/api/admin/api-keys/")?
|
||||||
|
.trim()
|
||||||
|
.trim_matches('/');
|
||||||
|
let mut segments = raw.split('/').map(str::trim);
|
||||||
|
let api_key_id = segments.next()?.to_string();
|
||||||
|
let suffix = segments.next()?;
|
||||||
|
(suffix == "install-sessions" && segments.next().is_none()).then_some(api_key_id)
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) fn admin_api_keys_operator_id(
|
pub(super) fn admin_api_keys_operator_id(
|
||||||
request_context: &AdminRequestContext<'_>,
|
request_context: &AdminRequestContext<'_>,
|
||||||
) -> Option<String> {
|
) -> Option<String> {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ pub(crate) async fn maybe_build_local_admin_auth_response(
|
|||||||
if let Some(response) = api_keys::maybe_build_local_admin_api_keys_response(
|
if let Some(response) = api_keys::maybe_build_local_admin_api_keys_response(
|
||||||
&request.state(),
|
&request.state(),
|
||||||
&request.request_context(),
|
&request.request_context(),
|
||||||
|
request.request_headers(),
|
||||||
request.request_body(),
|
request.request_body(),
|
||||||
)
|
)
|
||||||
.await?
|
.await?
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use super::{AdminAppState, AdminRequestContext};
|
use super::{AdminAppState, AdminRequestContext};
|
||||||
use crate::{AppState, GatewayError};
|
use crate::{AppState, GatewayError};
|
||||||
use axum::body::{Body, Bytes};
|
use axum::body::{Body, Bytes};
|
||||||
use axum::http::Response;
|
use axum::http::{HeaderMap, Response};
|
||||||
|
|
||||||
pub(crate) enum AdminCancelVideoTaskError {
|
pub(crate) enum AdminCancelVideoTaskError {
|
||||||
NotFound,
|
NotFound,
|
||||||
@@ -14,6 +14,7 @@ pub(crate) enum AdminCancelVideoTaskError {
|
|||||||
pub(crate) struct AdminRouteRequest<'a> {
|
pub(crate) struct AdminRouteRequest<'a> {
|
||||||
state: AdminAppState<'a>,
|
state: AdminAppState<'a>,
|
||||||
request_context: AdminRequestContext<'a>,
|
request_context: AdminRequestContext<'a>,
|
||||||
|
request_headers: &'a HeaderMap,
|
||||||
request_body: Option<&'a Bytes>,
|
request_body: Option<&'a Bytes>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,11 +22,13 @@ impl<'a> AdminRouteRequest<'a> {
|
|||||||
pub(crate) fn new(
|
pub(crate) fn new(
|
||||||
state: &'a AppState,
|
state: &'a AppState,
|
||||||
request_context: &'a crate::control::GatewayPublicRequestContext,
|
request_context: &'a crate::control::GatewayPublicRequestContext,
|
||||||
|
request_headers: &'a HeaderMap,
|
||||||
request_body: Option<&'a Bytes>,
|
request_body: Option<&'a Bytes>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
state: AdminAppState::new(state),
|
state: AdminAppState::new(state),
|
||||||
request_context: AdminRequestContext::new(request_context),
|
request_context: AdminRequestContext::new(request_context),
|
||||||
|
request_headers,
|
||||||
request_body,
|
request_body,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -38,6 +41,10 @@ impl<'a> AdminRouteRequest<'a> {
|
|||||||
self.request_context
|
self.request_context
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn request_headers(self) -> &'a HeaderMap {
|
||||||
|
self.request_headers
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn request_body(self) -> Option<&'a Bytes> {
|
pub(crate) fn request_body(self) -> Option<&'a Bytes> {
|
||||||
self.request_body
|
self.request_body
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ pub(super) async fn maybe_build_local_internal_proxy_response(
|
|||||||
pub(super) async fn maybe_build_local_admin_proxy_response(
|
pub(super) async fn maybe_build_local_admin_proxy_response(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
request_context: &GatewayPublicRequestContext,
|
request_context: &GatewayPublicRequestContext,
|
||||||
|
request_headers: &http::HeaderMap,
|
||||||
request_body: Option<&Bytes>,
|
request_body: Option<&Bytes>,
|
||||||
) -> Result<Option<Response<Body>>, GatewayError> {
|
) -> Result<Option<Response<Body>>, GatewayError> {
|
||||||
let Some(decision) = request_context.control_decision.as_ref() else {
|
let Some(decision) = request_context.control_decision.as_ref() else {
|
||||||
@@ -49,6 +50,7 @@ pub(super) async fn maybe_build_local_admin_proxy_response(
|
|||||||
admin_api::maybe_build_local_admin_response(admin_api::AdminRouteRequest::new(
|
admin_api::maybe_build_local_admin_response(admin_api::AdminRouteRequest::new(
|
||||||
state,
|
state,
|
||||||
request_context,
|
request_context,
|
||||||
|
request_headers,
|
||||||
request_body,
|
request_body,
|
||||||
))
|
))
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -874,9 +874,13 @@ pub(crate) async fn proxy_request(
|
|||||||
request_permit.take(),
|
request_permit.take(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
if let Some(response) =
|
if let Some(response) = maybe_build_local_admin_proxy_response(
|
||||||
maybe_build_local_admin_proxy_response(&state, &request_context, local_proxy_body.as_ref())
|
&state,
|
||||||
.await?
|
&request_context,
|
||||||
|
&parts.headers,
|
||||||
|
local_proxy_body.as_ref(),
|
||||||
|
)
|
||||||
|
.await?
|
||||||
{
|
{
|
||||||
let execution_path =
|
let execution_path =
|
||||||
resolve_local_proxy_execution_path(&response, EXECUTION_PATH_PUBLIC_PROXY_PASSTHROUGH);
|
resolve_local_proxy_execution_path(&response, EXECUTION_PATH_PUBLIC_PROXY_PASSTHROUGH);
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ pub(crate) use self::system_modules_helpers::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
pub(crate) use self::support::{
|
pub(crate) use self::support::{
|
||||||
build_unhandled_public_support_response, matches_model_mapping_for_models,
|
build_api_key_install_session_response, build_unhandled_public_support_response,
|
||||||
maybe_build_local_admin_announcements_response, maybe_build_local_public_support_response,
|
matches_model_mapping_for_models, maybe_build_local_admin_announcements_response,
|
||||||
|
maybe_build_local_public_support_response, CreateApiKeyInstallSessionRequest,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -63,6 +63,9 @@ use self::support_auth::{
|
|||||||
build_auth_settings_payload, extract_client_device_id, maybe_build_local_auth_response,
|
build_auth_settings_payload, extract_client_device_id, maybe_build_local_auth_response,
|
||||||
};
|
};
|
||||||
use self::support_dashboard::maybe_build_local_dashboard_response;
|
use self::support_dashboard::maybe_build_local_dashboard_response;
|
||||||
|
pub(crate) use self::support_install::{
|
||||||
|
build_api_key_install_session_response, CreateApiKeyInstallSessionRequest,
|
||||||
|
};
|
||||||
use self::support_install::{
|
use self::support_install::{
|
||||||
handle_users_me_api_key_install_session_create, maybe_build_local_install_response,
|
handle_users_me_api_key_install_session_create, maybe_build_local_install_response,
|
||||||
users_me_api_key_install_sessions_path_matches,
|
users_me_api_key_install_sessions_path_matches,
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ const INSTALL_SESSION_KEY_PREFIX: &str = "install:session:";
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
enum InstallTargetCli {
|
pub(crate) enum InstallTargetCli {
|
||||||
ClaudeCode,
|
ClaudeCode,
|
||||||
CodexCli,
|
CodexCli,
|
||||||
GeminiCli,
|
GeminiCli,
|
||||||
@@ -25,7 +25,7 @@ enum InstallTargetCli {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
enum InstallTargetSystem {
|
pub(crate) enum InstallTargetSystem {
|
||||||
Macos,
|
Macos,
|
||||||
Linux,
|
Linux,
|
||||||
Windows,
|
Windows,
|
||||||
@@ -33,9 +33,9 @@ enum InstallTargetSystem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct UsersMeCreateInstallSessionRequest {
|
pub(crate) struct CreateApiKeyInstallSessionRequest {
|
||||||
target_cli: InstallTargetCli,
|
pub(crate) target_cli: InstallTargetCli,
|
||||||
target_system: InstallTargetSystem,
|
pub(crate) target_system: InstallTargetSystem,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
@@ -240,20 +240,60 @@ PY
|
|||||||
;;
|
;;
|
||||||
codex_cli)
|
codex_cli)
|
||||||
mkdir -p "$HOME/.codex"
|
mkdir -p "$HOME/.codex"
|
||||||
cat > "$HOME/.codex/auth.json" <<EOF
|
python3 - "$HOME/.codex/config.toml" "$AETHER_BASE_URL" "$AETHER_API_KEY" <<'PY'
|
||||||
{{"OPENAI_API_KEY":"$AETHER_API_KEY"}}
|
import pathlib, re, sys
|
||||||
EOF
|
|
||||||
cat > "$HOME/.codex/config.toml" <<EOF
|
|
||||||
# Managed by Aether
|
|
||||||
model_provider = "aether"
|
|
||||||
|
|
||||||
[model_providers.aether]
|
path = pathlib.Path(sys.argv[1])
|
||||||
name = "Aether"
|
base_url = sys.argv[2].rstrip('/') + '/v1'
|
||||||
base_url = "$AETHER_BASE_URL/v1"
|
api_key = sys.argv[3]
|
||||||
env_key = "OPENAI_API_KEY"
|
text = path.read_text() if path.exists() else ''
|
||||||
wire_api = "chat"
|
lines = text.splitlines()
|
||||||
EOF
|
|
||||||
chmod 600 "$HOME/.codex/auth.json" "$HOME/.codex/config.toml" 2>/dev/null || true
|
def quote_toml(value: str) -> str:
|
||||||
|
return '"' + value.replace('\\', '\\\\').replace('"', '\\"') + '"'
|
||||||
|
|
||||||
|
result = []
|
||||||
|
in_aether = False
|
||||||
|
top_model_provider_set = False
|
||||||
|
seen_section = False
|
||||||
|
for line in lines:
|
||||||
|
stripped = line.strip()
|
||||||
|
if re.match(r'^\[.*\]$', stripped):
|
||||||
|
seen_section = True
|
||||||
|
in_aether = stripped == '[model_providers.aether]'
|
||||||
|
if in_aether:
|
||||||
|
continue
|
||||||
|
if in_aether:
|
||||||
|
continue
|
||||||
|
if not seen_section and re.match(r'^model_provider\s*=', stripped):
|
||||||
|
if not top_model_provider_set:
|
||||||
|
result.append('model_provider = "aether"')
|
||||||
|
top_model_provider_set = True
|
||||||
|
continue
|
||||||
|
result.append(line)
|
||||||
|
|
||||||
|
if not top_model_provider_set:
|
||||||
|
insert_at = next((idx for idx, line in enumerate(result) if line.strip().startswith('[')), len(result))
|
||||||
|
while insert_at > 0 and result[insert_at - 1].strip() == '':
|
||||||
|
insert_at -= 1
|
||||||
|
result[insert_at:insert_at] = ['model_provider = "aether"', '']
|
||||||
|
|
||||||
|
while result and result[-1].strip() == '':
|
||||||
|
result.pop()
|
||||||
|
if result:
|
||||||
|
result.append('')
|
||||||
|
result.extend([
|
||||||
|
'# Managed by Aether',
|
||||||
|
'[model_providers.aether]',
|
||||||
|
'name = "Aether"',
|
||||||
|
f'base_url = {{quote_toml(base_url)}}',
|
||||||
|
'wire_api = "responses"',
|
||||||
|
'requires_openai_auth = false',
|
||||||
|
f'experimental_bearer_token = {{quote_toml(api_key)}}',
|
||||||
|
])
|
||||||
|
path.write_text('\n'.join(result) + '\n')
|
||||||
|
PY
|
||||||
|
chmod 600 "$HOME/.codex/config.toml" 2>/dev/null || true
|
||||||
;;
|
;;
|
||||||
gemini_cli)
|
gemini_cli)
|
||||||
mkdir -p "$HOME/.gemini"
|
mkdir -p "$HOME/.gemini"
|
||||||
@@ -329,8 +369,51 @@ if ($TargetCli -eq 'claude_code') {{
|
|||||||
$Data | ConvertTo-Json -Depth 8 | Set-Content $Path -Encoding UTF8
|
$Data | ConvertTo-Json -Depth 8 | Set-Content $Path -Encoding UTF8
|
||||||
}} elseif ($TargetCli -eq 'codex_cli') {{
|
}} elseif ($TargetCli -eq 'codex_cli') {{
|
||||||
$Dir = Join-Path $HomeDir '.codex'; New-Item -ItemType Directory -Force -Path $Dir | Out-Null
|
$Dir = Join-Path $HomeDir '.codex'; New-Item -ItemType Directory -Force -Path $Dir | Out-Null
|
||||||
Set-Content (Join-Path $Dir 'auth.json') -Value (@{{ OPENAI_API_KEY = $AetherApiKey }} | ConvertTo-Json) -Encoding UTF8
|
$Path = Join-Path $Dir 'config.toml'
|
||||||
Set-Content (Join-Path $Dir 'config.toml') -Value "# Managed by Aether`nmodel_provider = \"aether\"`n`n[model_providers.aether]`nname = \"Aether\"`nbase_url = \"$AetherBaseUrl/v1\"`nenv_key = \"OPENAI_API_KEY\"`nwire_api = \"chat\"`n" -Encoding UTF8
|
$Text = if (Test-Path $Path) {{ Get-Content $Path -Raw }} else {{ '' }}
|
||||||
|
$Lines = if ($Text.Length -gt 0) {{ $Text -split "`r?`n" }} else {{ @() }}
|
||||||
|
$Result = New-Object System.Collections.Generic.List[string]
|
||||||
|
$InAether = $false
|
||||||
|
$TopModelProviderSet = $false
|
||||||
|
$SeenSection = $false
|
||||||
|
foreach ($Line in $Lines) {{
|
||||||
|
$Stripped = $Line.Trim()
|
||||||
|
if ($Stripped -match '^\[.*\]$') {{
|
||||||
|
$SeenSection = $true
|
||||||
|
$InAether = $Stripped -eq '[model_providers.aether]'
|
||||||
|
if ($InAether) {{ continue }}
|
||||||
|
}}
|
||||||
|
if ($InAether) {{ continue }}
|
||||||
|
if (-not $SeenSection -and $Stripped -match '^model_provider\s*=') {{
|
||||||
|
if (-not $TopModelProviderSet) {{
|
||||||
|
$Result.Add('model_provider = "aether"')
|
||||||
|
$TopModelProviderSet = $true
|
||||||
|
}}
|
||||||
|
continue
|
||||||
|
}}
|
||||||
|
$Result.Add($Line)
|
||||||
|
}}
|
||||||
|
if (-not $TopModelProviderSet) {{
|
||||||
|
$InsertAt = $Result.Count
|
||||||
|
for ($Index = 0; $Index -lt $Result.Count; $Index++) {{
|
||||||
|
if ($Result[$Index].Trim().StartsWith('[')) {{ $InsertAt = $Index; break }}
|
||||||
|
}}
|
||||||
|
while ($InsertAt -gt 0 -and $Result[$InsertAt - 1].Trim() -eq '') {{ $InsertAt-- }}
|
||||||
|
$Result.Insert($InsertAt, '')
|
||||||
|
$Result.Insert($InsertAt, 'model_provider = "aether"')
|
||||||
|
}}
|
||||||
|
while ($Result.Count -gt 0 -and $Result[$Result.Count - 1].Trim() -eq '') {{ $Result.RemoveAt($Result.Count - 1) }}
|
||||||
|
if ($Result.Count -gt 0) {{ $Result.Add('') }}
|
||||||
|
$EscapedBaseUrl = ($AetherBaseUrl.TrimEnd('/') + '/v1').Replace('\', '\\').Replace('"', '\"')
|
||||||
|
$EscapedApiKey = $AetherApiKey.Replace('\', '\\').Replace('"', '\"')
|
||||||
|
$Result.Add('# Managed by Aether')
|
||||||
|
$Result.Add('[model_providers.aether]')
|
||||||
|
$Result.Add('name = "Aether"')
|
||||||
|
$Result.Add("base_url = `"$EscapedBaseUrl`"")
|
||||||
|
$Result.Add('wire_api = "responses"')
|
||||||
|
$Result.Add('requires_openai_auth = false')
|
||||||
|
$Result.Add("experimental_bearer_token = `"$EscapedApiKey`"")
|
||||||
|
Set-Content -Path $Path -Value (($Result -join "`n") + "`n") -Encoding UTF8
|
||||||
}} elseif ($TargetCli -eq 'gemini_cli') {{
|
}} elseif ($TargetCli -eq 'gemini_cli') {{
|
||||||
$Dir = Join-Path $HomeDir '.gemini'; New-Item -ItemType Directory -Force -Path $Dir | Out-Null
|
$Dir = Join-Path $HomeDir '.gemini'; New-Item -ItemType Directory -Force -Path $Dir | Out-Null
|
||||||
Set-Content (Join-Path $Dir '.env') -Value "GEMINI_API_KEY=$AetherApiKey`nGOOGLE_API_KEY=$AetherApiKey`nGOOGLE_GEMINI_BASE_URL=$AetherBaseUrl`nAETHER_BASE_URL=$AetherBaseUrl`n" -Encoding UTF8
|
Set-Content (Join-Path $Dir '.env') -Value "GEMINI_API_KEY=$AetherApiKey`nGOOGLE_API_KEY=$AetherApiKey`nGOOGLE_GEMINI_BASE_URL=$AetherBaseUrl`nAETHER_BASE_URL=$AetherBaseUrl`n" -Encoding UTF8
|
||||||
@@ -375,7 +458,7 @@ pub(super) async fn handle_users_me_api_key_install_session_create(
|
|||||||
let Some(request_body) = request_body else {
|
let Some(request_body) = request_body else {
|
||||||
return build_auth_error_response(http::StatusCode::BAD_REQUEST, "请求数据验证失败", false);
|
return build_auth_error_response(http::StatusCode::BAD_REQUEST, "请求数据验证失败", false);
|
||||||
};
|
};
|
||||||
let payload = match serde_json::from_slice::<UsersMeCreateInstallSessionRequest>(request_body) {
|
let payload = match serde_json::from_slice::<CreateApiKeyInstallSessionRequest>(request_body) {
|
||||||
Ok(value) => value,
|
Ok(value) => value,
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
return build_auth_error_response(
|
return build_auth_error_response(
|
||||||
@@ -426,11 +509,32 @@ pub(super) async fn handle_users_me_api_key_install_session_create(
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
build_api_key_install_session_response(
|
||||||
|
state,
|
||||||
|
request_context,
|
||||||
|
headers,
|
||||||
|
record.api_key_id.clone(),
|
||||||
|
record.name.unwrap_or_else(|| "API Key".to_string()),
|
||||||
|
api_key,
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn build_api_key_install_session_response(
|
||||||
|
state: &AppState,
|
||||||
|
request_context: &GatewayPublicRequestContext,
|
||||||
|
headers: &http::HeaderMap,
|
||||||
|
api_key_id: String,
|
||||||
|
api_key_name: String,
|
||||||
|
api_key: String,
|
||||||
|
payload: CreateApiKeyInstallSessionRequest,
|
||||||
|
) -> Response<Body> {
|
||||||
let code = generate_install_code();
|
let code = generate_install_code();
|
||||||
let expires_at_unix_secs = unix_secs_now().saturating_add(INSTALL_SESSION_TTL_SECS);
|
let expires_at_unix_secs = unix_secs_now().saturating_add(INSTALL_SESSION_TTL_SECS);
|
||||||
let session = StoredInstallSession {
|
let session = StoredInstallSession {
|
||||||
api_key_id: record.api_key_id.clone(),
|
api_key_id,
|
||||||
api_key_name: record.name.unwrap_or_else(|| "API Key".to_string()),
|
api_key_name,
|
||||||
api_key,
|
api_key,
|
||||||
base_url: base_url_from_request(headers, request_context),
|
base_url: base_url_from_request(headers, request_context),
|
||||||
target_cli: payload.target_cli,
|
target_cli: payload.target_cli,
|
||||||
@@ -559,3 +663,49 @@ pub(super) async fn maybe_build_local_install_response(
|
|||||||
);
|
);
|
||||||
Some(response)
|
Some(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn test_session(target_cli: InstallTargetCli) -> StoredInstallSession {
|
||||||
|
StoredInstallSession {
|
||||||
|
api_key_id: "key-1".to_string(),
|
||||||
|
api_key_name: "Key 1".to_string(),
|
||||||
|
api_key: "sk-test".to_string(),
|
||||||
|
base_url: "http://localhost:8084".to_string(),
|
||||||
|
target_cli,
|
||||||
|
target_system: InstallTargetSystem::Linux,
|
||||||
|
expires_at_unix_secs: u64::MAX,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn codex_unix_script_preserves_config_and_uses_responses_bearer_token() {
|
||||||
|
let script = build_unix_script(&test_session(InstallTargetCli::CodexCli));
|
||||||
|
|
||||||
|
assert!(script.contains("path.read_text() if path.exists() else ''"));
|
||||||
|
assert!(script.contains("stripped == '[model_providers.aether]'"));
|
||||||
|
assert!(script.contains("model_provider = \"aether\""));
|
||||||
|
assert!(script.contains("wire_api = \"responses\""));
|
||||||
|
assert!(script.contains("requires_openai_auth = false"));
|
||||||
|
assert!(script.contains("experimental_bearer_token ="));
|
||||||
|
assert!(!script.contains("wire_api = \"chat\""));
|
||||||
|
assert!(!script.contains("cat > \"$HOME/.codex/config.toml\""));
|
||||||
|
assert!(!script.contains("auth.json"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn codex_powershell_script_preserves_config_and_uses_responses_bearer_token() {
|
||||||
|
let script = build_powershell_script(&test_session(InstallTargetCli::CodexCli));
|
||||||
|
|
||||||
|
assert!(script.contains("Get-Content $Path -Raw"));
|
||||||
|
assert!(script.contains("$Stripped -eq '[model_providers.aether]'"));
|
||||||
|
assert!(script.contains("model_provider = \"aether\""));
|
||||||
|
assert!(script.contains("wire_api = \"responses\""));
|
||||||
|
assert!(script.contains("requires_openai_auth = false"));
|
||||||
|
assert!(script.contains("experimental_bearer_token ="));
|
||||||
|
assert!(!script.contains("wire_api = \"chat\""));
|
||||||
|
assert!(!script.contains("auth.json"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -307,7 +307,11 @@ pub(crate) fn admin_proxy_local_requires_buffered_body(
|
|||||||
| (Some("payments_manage"), http::Method::POST, Some("credit_order"))
|
| (Some("payments_manage"), http::Method::POST, Some("credit_order"))
|
||||||
| (Some("payments_manage"), http::Method::POST, Some("create_redeem_code_batch"))
|
| (Some("payments_manage"), http::Method::POST, Some("create_redeem_code_batch"))
|
||||||
| (Some("payments_manage"), http::Method::POST, Some("delete_redeem_code_batch"))
|
| (Some("payments_manage"), http::Method::POST, Some("delete_redeem_code_batch"))
|
||||||
| (Some("api_keys_manage"), http::Method::POST, Some("create_api_key"))
|
| (
|
||||||
|
Some("api_keys_manage"),
|
||||||
|
http::Method::POST,
|
||||||
|
Some("create_api_key" | "create_api_key_install_session"),
|
||||||
|
)
|
||||||
| (Some("api_keys_manage"), http::Method::PUT, Some("update_api_key"))
|
| (Some("api_keys_manage"), http::Method::PUT, Some("update_api_key"))
|
||||||
| (Some("api_keys_manage"), http::Method::PATCH, Some("toggle_api_key"))
|
| (Some("api_keys_manage"), http::Method::PATCH, Some("toggle_api_key"))
|
||||||
| (Some("adaptive_manage"), http::Method::PATCH, Some("toggle_mode"))
|
| (Some("adaptive_manage"), http::Method::PATCH, Some("toggle_mode"))
|
||||||
|
|||||||
@@ -88,6 +88,8 @@ fn frontend_path_bypasses_static(path: &str) -> bool {
|
|||||||
|| path.starts_with("/upload/")
|
|| path.starts_with("/upload/")
|
||||||
|| path.starts_with("/_gateway/")
|
|| path.starts_with("/_gateway/")
|
||||||
|| path.starts_with("/.well-known/")
|
|| path.starts_with("/.well-known/")
|
||||||
|
|| path.starts_with("/install/")
|
||||||
|
|| path.starts_with("/i/")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn frontend_path_targets_static_asset(path: &str) -> bool {
|
fn frontend_path_targets_static_asset(path: &str) -> bool {
|
||||||
|
|||||||
@@ -228,8 +228,13 @@ async fn gateway_handles_admin_api_keys_list_locally_with_trusted_admin_principa
|
|||||||
.await
|
.await
|
||||||
.expect("request should succeed");
|
.expect("request should succeed");
|
||||||
|
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
let status = response.status();
|
||||||
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
let body = response
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.expect("response body should be readable");
|
||||||
|
assert_eq!(status, StatusCode::OK, "unexpected response body: {body}");
|
||||||
|
let payload: serde_json::Value = serde_json::from_str(&body).expect("json body should parse");
|
||||||
assert_eq!(payload["total"], json!(1));
|
assert_eq!(payload["total"], json!(1));
|
||||||
assert_eq!(payload["limit"], json!(10));
|
assert_eq!(payload["limit"], json!(10));
|
||||||
assert_eq!(payload["skip"], json!(0));
|
assert_eq!(payload["skip"], json!(0));
|
||||||
@@ -300,8 +305,13 @@ async fn gateway_handles_admin_api_keys_detail_locally_with_trusted_admin_princi
|
|||||||
.await
|
.await
|
||||||
.expect("request should succeed");
|
.expect("request should succeed");
|
||||||
|
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
let status = response.status();
|
||||||
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
let body = response
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.expect("response body should be readable");
|
||||||
|
assert_eq!(status, StatusCode::OK, "unexpected response body: {body}");
|
||||||
|
let payload: serde_json::Value = serde_json::from_str(&body).expect("json body should parse");
|
||||||
assert_eq!(payload["id"], json!("key-1"));
|
assert_eq!(payload["id"], json!("key-1"));
|
||||||
assert_eq!(payload["user_id"], json!("user-1"));
|
assert_eq!(payload["user_id"], json!("user-1"));
|
||||||
assert_eq!(payload["wallet"]["id"], json!("wallet-key-1"));
|
assert_eq!(payload["wallet"]["id"], json!("wallet-key-1"));
|
||||||
@@ -412,6 +422,78 @@ async fn gateway_handles_admin_api_keys_full_key_locally_with_trusted_admin_prin
|
|||||||
upstream_handle.abort();
|
upstream_handle.abort();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn gateway_handles_admin_api_key_install_session_locally_with_trusted_admin_principal() {
|
||||||
|
let (upstream_url, upstream_hits, upstream_handle) =
|
||||||
|
start_api_keys_upstream("/api/admin/api-keys/key-1/install-sessions").await;
|
||||||
|
let auth_repository = Arc::new(
|
||||||
|
InMemoryAuthApiKeySnapshotRepository::seed(vec![(
|
||||||
|
None,
|
||||||
|
sample_standalone_api_key_snapshot("key-1", "user-1", true),
|
||||||
|
)])
|
||||||
|
.with_export_records([sample_standalone_export_record(
|
||||||
|
"key-1",
|
||||||
|
"user-1",
|
||||||
|
"sk-key-1-plaintext",
|
||||||
|
true,
|
||||||
|
)]),
|
||||||
|
);
|
||||||
|
let gateway = build_router_with_state(
|
||||||
|
AppState::new()
|
||||||
|
.expect("gateway should build")
|
||||||
|
.with_data_state_for_tests(
|
||||||
|
GatewayDataState::with_auth_api_key_repository_for_tests(auth_repository)
|
||||||
|
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||||
|
|
||||||
|
let response = admin_request(reqwest::Client::new().post(format!(
|
||||||
|
"{gateway_url}/api/admin/api-keys/key-1/install-sessions/"
|
||||||
|
)))
|
||||||
|
.header("x-forwarded-host", "aether.example")
|
||||||
|
.header("x-forwarded-proto", "https")
|
||||||
|
.json(&json!({
|
||||||
|
"target_cli": "codex_cli",
|
||||||
|
"target_system": "linux",
|
||||||
|
}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("request should succeed");
|
||||||
|
|
||||||
|
let status = response.status();
|
||||||
|
let body = response
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.expect("response body should be readable");
|
||||||
|
assert_eq!(status, StatusCode::OK, "unexpected response body: {body}");
|
||||||
|
let payload: serde_json::Value = serde_json::from_str(&body).expect("json body should parse");
|
||||||
|
let install_code = payload["install_code"]
|
||||||
|
.as_str()
|
||||||
|
.expect("install code should be returned");
|
||||||
|
assert_eq!(install_code.len(), 24);
|
||||||
|
assert_eq!(payload["expires_in_seconds"], json!(15 * 60));
|
||||||
|
assert_eq!(payload["target_cli"], json!("codex_cli"));
|
||||||
|
assert_eq!(payload["target_system"], json!("linux"));
|
||||||
|
assert!(payload["expires_at_unix_secs"].is_number());
|
||||||
|
assert_eq!(
|
||||||
|
payload["unix_command"],
|
||||||
|
json!(format!(
|
||||||
|
"curl -fsSL https://aether.example/install/{install_code} | sh"
|
||||||
|
))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
payload["powershell_command"],
|
||||||
|
json!(format!(
|
||||||
|
"irm https://aether.example/install/{install_code}.ps1 | iex"
|
||||||
|
))
|
||||||
|
);
|
||||||
|
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||||
|
|
||||||
|
gateway_handle.abort();
|
||||||
|
upstream_handle.abort();
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn gateway_handles_admin_api_keys_create_locally_with_trusted_admin_principal() {
|
async fn gateway_handles_admin_api_keys_create_locally_with_trusted_admin_principal() {
|
||||||
let (upstream_url, upstream_hits, upstream_handle) =
|
let (upstream_url, upstream_hits, upstream_handle) =
|
||||||
|
|||||||
@@ -189,6 +189,34 @@ async fn gateway_serves_frontend_routes_and_assets_without_shadowing_public_api(
|
|||||||
assert_eq!(payload["site_name"], "Aether");
|
assert_eq!(payload["site_name"], "Aether");
|
||||||
assert_eq!(payload["site_subtitle"], "AI Gateway");
|
assert_eq!(payload["site_subtitle"], "AI Gateway");
|
||||||
|
|
||||||
|
let response = client
|
||||||
|
.get(format!("{gateway_url}/install/4b143b471afa4d9ebc652d95"))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("install route request should succeed");
|
||||||
|
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||||
|
let body = response
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.expect("install error body should be readable");
|
||||||
|
assert!(!body.contains("Aether Frontend"));
|
||||||
|
assert!(body.contains("install code"));
|
||||||
|
|
||||||
|
let response = client
|
||||||
|
.get(format!(
|
||||||
|
"{gateway_url}/install/4b143b471afa4d9ebc652d95.ps1"
|
||||||
|
))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("powershell install route request should succeed");
|
||||||
|
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||||
|
let body = response
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.expect("powershell install error body should be readable");
|
||||||
|
assert!(!body.contains("Aether Frontend"));
|
||||||
|
assert!(body.contains("install code"));
|
||||||
|
|
||||||
gateway_handle.abort();
|
gateway_handle.abort();
|
||||||
let _ = fs::remove_dir_all(&static_dir);
|
let _ = fs::remove_dir_all(&static_dir);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import apiClient from './client'
|
import apiClient from './client'
|
||||||
import { cachedRequest, buildCacheKey } from '@/utils/cache'
|
import { cachedRequest, buildCacheKey } from '@/utils/cache'
|
||||||
import type { BillingSummary } from './auth'
|
import type { BillingSummary } from './auth'
|
||||||
|
import type { ApiKeyInstallSession, InstallSessionTargetSystem, InstallTargetCli } from './me'
|
||||||
|
|
||||||
// LDAP 配置导出结构
|
// LDAP 配置导出结构
|
||||||
export interface LDAPConfigExport {
|
export interface LDAPConfigExport {
|
||||||
@@ -678,6 +679,18 @@ export const adminApi = {
|
|||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 创建独立余额 Key 的 CLI 安装会话
|
||||||
|
async createApiKeyInstallSession(
|
||||||
|
keyId: string,
|
||||||
|
data: { target_cli: InstallTargetCli; target_system: InstallSessionTargetSystem }
|
||||||
|
): Promise<ApiKeyInstallSession> {
|
||||||
|
const response = await apiClient.post<ApiKeyInstallSession>(
|
||||||
|
`/api/admin/api-keys/${keyId}/install-sessions`,
|
||||||
|
data
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
// 系统配置相关
|
// 系统配置相关
|
||||||
// 获取所有系统配置
|
// 获取所有系统配置
|
||||||
async getAllSystemConfigs(): Promise<Array<{ key: string; value: unknown; description?: string }>> {
|
async getAllSystemConfigs(): Promise<Array<{ key: string; value: unknown; description?: string }>> {
|
||||||
|
|||||||
@@ -173,6 +173,7 @@ export interface ApiKey {
|
|||||||
|
|
||||||
export type InstallTargetCli = 'claude_code' | 'codex_cli' | 'gemini_cli'
|
export type InstallTargetCli = 'claude_code' | 'codex_cli' | 'gemini_cli'
|
||||||
export type InstallTargetSystem = 'macos' | 'linux' | 'windows' | 'auto'
|
export type InstallTargetSystem = 'macos' | 'linux' | 'windows' | 'auto'
|
||||||
|
export type InstallSessionTargetSystem = Exclude<InstallTargetSystem, 'auto'>
|
||||||
|
|
||||||
export interface ApiKeyInstallSession {
|
export interface ApiKeyInstallSession {
|
||||||
install_code: string
|
install_code: string
|
||||||
@@ -287,7 +288,7 @@ export const meApi = {
|
|||||||
|
|
||||||
async createApiKeyInstallSession(
|
async createApiKeyInstallSession(
|
||||||
keyId: string,
|
keyId: string,
|
||||||
data: { target_cli: InstallTargetCli; target_system: InstallTargetSystem }
|
data: { target_cli: InstallTargetCli; target_system: InstallSessionTargetSystem }
|
||||||
): Promise<ApiKeyInstallSession> {
|
): Promise<ApiKeyInstallSession> {
|
||||||
const response = await apiClient.post<ApiKeyInstallSession>(
|
const response = await apiClient.post<ApiKeyInstallSession>(
|
||||||
`/api/users/me/api-keys/${keyId}/install-sessions`,
|
`/api/users/me/api-keys/${keyId}/install-sessions`,
|
||||||
|
|||||||
@@ -317,6 +317,15 @@
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell class="py-4">
|
<TableCell class="py-4">
|
||||||
<div class="flex justify-center gap-1">
|
<div class="flex justify-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="h-7 w-7"
|
||||||
|
title="一键安装并配置 CLI"
|
||||||
|
@click="openInstallDialog(apiKey)"
|
||||||
|
>
|
||||||
|
<Terminal class="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -533,6 +542,15 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-2 gap-2 pt-0.5">
|
<div class="grid grid-cols-2 gap-2 pt-0.5">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
class="h-8 text-xs"
|
||||||
|
@click="openInstallDialog(apiKey)"
|
||||||
|
>
|
||||||
|
<Terminal class="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
安装
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -652,6 +670,123 @@
|
|||||||
</template>
|
</template>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
<!-- 一键安装并配置 CLI 对话框 -->
|
||||||
|
<Dialog
|
||||||
|
v-model="showInstallDialog"
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<div class="border-b border-border px-6 py-4">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10 flex-shrink-0">
|
||||||
|
<Terminal class="h-5 w-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<h3 class="text-lg font-semibold text-foreground leading-tight">
|
||||||
|
一键安装并配置 CLI
|
||||||
|
</h3>
|
||||||
|
<p class="text-xs text-muted-foreground truncate">
|
||||||
|
当前密钥:{{ selectedInstallApiKey?.name || selectedInstallApiKey?.key_display || '未选择' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="space-y-5">
|
||||||
|
<div class="rounded-lg border border-border/60 bg-muted/30 p-3 text-xs text-muted-foreground">
|
||||||
|
选择要配置的 CLI 和目标系统,Aether 会生成 15 分钟内有效的一次性 install code。页面命令不会包含原始 API Key。
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label class="text-sm font-semibold">目标 CLI</Label>
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||||
|
<Button
|
||||||
|
v-for="option in installCliOptions"
|
||||||
|
:key="option.value"
|
||||||
|
:variant="installCli === option.value ? 'default' : 'outline'"
|
||||||
|
class="justify-start h-auto py-3"
|
||||||
|
@click="selectInstallCli(option.value)"
|
||||||
|
>
|
||||||
|
{{ option.label }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label class="text-sm font-semibold">目标系统</Label>
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||||
|
<Button
|
||||||
|
v-for="option in installSystemOptions"
|
||||||
|
:key="option.value"
|
||||||
|
:variant="installSystem === option.value ? 'default' : 'outline'"
|
||||||
|
class="justify-start h-auto py-3"
|
||||||
|
@click="selectInstallSystem(option.value)"
|
||||||
|
>
|
||||||
|
{{ option.label }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<Label class="text-sm font-semibold">复制到目标机器执行</Label>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
class="gap-1.5"
|
||||||
|
:disabled="installLoading || !installCommand"
|
||||||
|
:title="installCopied ? '已复制' : '一键复制安装命令'"
|
||||||
|
@click="copyInstallCommand"
|
||||||
|
>
|
||||||
|
<CheckCircle
|
||||||
|
v-if="installCopied"
|
||||||
|
class="h-3.5 w-3.5 text-emerald-600 dark:text-emerald-400"
|
||||||
|
/>
|
||||||
|
<Copy
|
||||||
|
v-else
|
||||||
|
class="h-3.5 w-3.5"
|
||||||
|
/>
|
||||||
|
{{ installCopied ? '已复制' : '一键复制' }}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
:disabled="installLoading || !selectedInstallApiKey"
|
||||||
|
@click="refreshInstallCommand"
|
||||||
|
>
|
||||||
|
{{ installLoading ? '生成中...' : '重新生成' }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg border border-border/60 bg-background overflow-hidden">
|
||||||
|
<pre class="max-h-32 overflow-x-auto whitespace-pre-wrap break-all p-3 text-xs font-mono">{{ installCommand || '正在生成短命令...' }}</pre>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
{{ installCommandHint }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
class="h-10 px-5"
|
||||||
|
@click="showInstallDialog = false"
|
||||||
|
>
|
||||||
|
关闭
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
class="h-10 px-5 shadow-lg shadow-primary/20"
|
||||||
|
:disabled="!installCommand || installLoading"
|
||||||
|
@click="copyInstallCommand"
|
||||||
|
>
|
||||||
|
{{ installCopied ? '已复制' : '复制命令' }}
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
<WalletOpsDrawer
|
<WalletOpsDrawer
|
||||||
:open="showWalletActionDrawer"
|
:open="showWalletActionDrawer"
|
||||||
:wallet="walletActionTarget?.wallet || null"
|
:wallet="walletActionTarget?.wallet || null"
|
||||||
@@ -667,11 +802,12 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||||
import { useToast } from '@/composables/useToast'
|
import { useToast } from '@/composables/useToast'
|
||||||
import { useConfirm } from '@/composables/useConfirm'
|
import { useConfirm } from '@/composables/useConfirm'
|
||||||
import { useClipboard } from '@/composables/useClipboard'
|
import { useClipboard } from '@/composables/useClipboard'
|
||||||
import { adminApi, type AdminApiKey, type CreateStandaloneApiKeyRequest } from '@/api/admin'
|
import { adminApi, type AdminApiKey, type CreateStandaloneApiKeyRequest } from '@/api/admin'
|
||||||
|
import type { ApiKeyInstallSession, InstallSessionTargetSystem, InstallTargetCli } from '@/api/me'
|
||||||
import type { AdminWallet } from '@/api/admin-wallets'
|
import type { AdminWallet } from '@/api/admin-wallets'
|
||||||
import { walletStatusBadge, walletStatusLabel } from '@/utils/walletDisplay'
|
import { walletStatusBadge, walletStatusLabel } from '@/utils/walletDisplay'
|
||||||
import WalletOpsDrawer from '@/features/wallet/components/WalletOpsDrawer.vue'
|
import WalletOpsDrawer from '@/features/wallet/components/WalletOpsDrawer.vue'
|
||||||
@@ -710,7 +846,8 @@ import {
|
|||||||
Copy,
|
Copy,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
SquarePen,
|
SquarePen,
|
||||||
Search
|
Search,
|
||||||
|
Terminal
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
|
|
||||||
import { StandaloneKeyFormDialog, type StandaloneKeyFormData } from '@/features/api-keys'
|
import { StandaloneKeyFormDialog, type StandaloneKeyFormData } from '@/features/api-keys'
|
||||||
@@ -729,8 +866,16 @@ const total = ref(0)
|
|||||||
const currentPage = ref(1)
|
const currentPage = ref(1)
|
||||||
const limit = ref(100)
|
const limit = ref(100)
|
||||||
const showNewKeyDialog = ref(false)
|
const showNewKeyDialog = ref(false)
|
||||||
|
const showInstallDialog = ref(false)
|
||||||
const newKeyValue = ref('')
|
const newKeyValue = ref('')
|
||||||
const keyInput = ref<HTMLInputElement>()
|
const keyInput = ref<HTMLInputElement>()
|
||||||
|
const selectedInstallApiKey = ref<AdminApiKey | null>(null)
|
||||||
|
const installCli = ref<InstallTargetCli>('claude_code')
|
||||||
|
const installSystem = ref<InstallSessionTargetSystem>('linux')
|
||||||
|
const installSession = ref<ApiKeyInstallSession | null>(null)
|
||||||
|
const installLoading = ref(false)
|
||||||
|
const installCopied = ref(false)
|
||||||
|
let installCopiedResetTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
// 统一的表单对话框状态
|
// 统一的表单对话框状态
|
||||||
const showKeyFormDialog = ref(false)
|
const showKeyFormDialog = ref(false)
|
||||||
@@ -750,6 +895,18 @@ const statusFilters = [
|
|||||||
{ value: 'inactive' as const, label: '禁用' }
|
{ value: 'inactive' as const, label: '禁用' }
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const installCliOptions: Array<{ value: InstallTargetCli; label: string }> = [
|
||||||
|
{ value: 'claude_code', label: 'Claude Code' },
|
||||||
|
{ value: 'codex_cli', label: 'Codex CLI' },
|
||||||
|
{ value: 'gemini_cli', label: 'Gemini CLI' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const installSystemOptions: Array<{ value: InstallSessionTargetSystem; label: string }> = [
|
||||||
|
{ value: 'macos', label: 'macOS' },
|
||||||
|
{ value: 'linux', label: 'Linux' },
|
||||||
|
{ value: 'windows', label: 'Windows' }
|
||||||
|
]
|
||||||
|
|
||||||
const balanceFilters = [
|
const balanceFilters = [
|
||||||
{ value: 'all' as const, label: '全部类型' },
|
{ value: 'all' as const, label: '全部类型' },
|
||||||
{ value: 'limited' as const, label: '限额' },
|
{ value: 'limited' as const, label: '限额' },
|
||||||
@@ -760,6 +917,20 @@ const hasActiveFilters = computed(() => {
|
|||||||
return searchQuery.value !== '' || filterStatus.value !== 'all' || filterBalance.value !== 'all'
|
return searchQuery.value !== '' || filterStatus.value !== 'all' || filterBalance.value !== 'all'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const installCommand = computed(() => {
|
||||||
|
if (!installSession.value) return ''
|
||||||
|
return installSystem.value === 'windows'
|
||||||
|
? installSession.value.powershell_command
|
||||||
|
: installSession.value.unix_command
|
||||||
|
})
|
||||||
|
|
||||||
|
const installCommandHint = computed(() => {
|
||||||
|
if (installSystem.value === 'windows') {
|
||||||
|
return 'Windows 请在 PowerShell 中执行。install code 使用后立即失效,如需再次执行请重新生成。'
|
||||||
|
}
|
||||||
|
return 'macOS / Linux 请在 sh 兼容终端中执行。install code 使用后立即失效,如需再次执行请重新生成。'
|
||||||
|
})
|
||||||
|
|
||||||
function clearFilters() {
|
function clearFilters() {
|
||||||
searchQuery.value = ''
|
searchQuery.value = ''
|
||||||
filterStatus.value = 'all'
|
filterStatus.value = 'all'
|
||||||
@@ -808,9 +979,32 @@ const showWalletActionDrawer = ref(false)
|
|||||||
const walletActionTarget = ref<{ apiKey: AdminApiKey; wallet: AdminWallet } | null>(null)
|
const walletActionTarget = ref<{ apiKey: AdminApiKey; wallet: AdminWallet } | null>(null)
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
installSystem.value = detectCurrentSystem()
|
||||||
await refreshApiKeys()
|
await refreshApiKeys()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
resetInstallCopiedState()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(showInstallDialog, (isOpen) => {
|
||||||
|
if (!isOpen) {
|
||||||
|
resetInstallCopiedState()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function clearInstallCopiedResetTimer() {
|
||||||
|
if (installCopiedResetTimer) {
|
||||||
|
clearTimeout(installCopiedResetTimer)
|
||||||
|
installCopiedResetTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetInstallCopiedState() {
|
||||||
|
clearInstallCopiedResetTimer()
|
||||||
|
installCopied.value = false
|
||||||
|
}
|
||||||
|
|
||||||
function buildAdminWalletFromApiKey(apiKey: AdminApiKey): AdminWallet | null {
|
function buildAdminWalletFromApiKey(apiKey: AdminApiKey): AdminWallet | null {
|
||||||
if (!apiKey.wallet?.id) {
|
if (!apiKey.wallet?.id) {
|
||||||
return null
|
return null
|
||||||
@@ -867,6 +1061,64 @@ function handlePageChange(page: number) {
|
|||||||
refreshApiKeys()
|
refreshApiKeys()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function detectCurrentSystem(): InstallSessionTargetSystem {
|
||||||
|
const platform = window.navigator.platform.toLowerCase()
|
||||||
|
const userAgent = window.navigator.userAgent.toLowerCase()
|
||||||
|
if (platform.includes('mac')) return 'macos'
|
||||||
|
if (platform.includes('win') || userAgent.includes('windows')) return 'windows'
|
||||||
|
return 'linux'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openInstallDialog(apiKey: AdminApiKey) {
|
||||||
|
selectedInstallApiKey.value = apiKey
|
||||||
|
installSession.value = null
|
||||||
|
resetInstallCopiedState()
|
||||||
|
showInstallDialog.value = true
|
||||||
|
await refreshInstallCommand()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectInstallCli(value: InstallTargetCli) {
|
||||||
|
installCli.value = value
|
||||||
|
await refreshInstallCommand()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectInstallSystem(value: InstallSessionTargetSystem) {
|
||||||
|
installSystem.value = value
|
||||||
|
await refreshInstallCommand()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshInstallCommand() {
|
||||||
|
if (!selectedInstallApiKey.value) return
|
||||||
|
installLoading.value = true
|
||||||
|
installSession.value = null
|
||||||
|
resetInstallCopiedState()
|
||||||
|
try {
|
||||||
|
installSession.value = await adminApi.createApiKeyInstallSession(selectedInstallApiKey.value.id, {
|
||||||
|
target_cli: installCli.value,
|
||||||
|
target_system: installSystem.value,
|
||||||
|
})
|
||||||
|
} catch (err: unknown) {
|
||||||
|
log.error('生成 CLI 安装命令失败:', err)
|
||||||
|
error(parseApiError(err, '生成 CLI 安装命令失败'))
|
||||||
|
} finally {
|
||||||
|
installLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyInstallCommand() {
|
||||||
|
if (!installCommand.value) return
|
||||||
|
const copied = await copyToClipboard(installCommand.value, false)
|
||||||
|
if (!copied) return
|
||||||
|
|
||||||
|
installCopied.value = true
|
||||||
|
success('安装命令已复制到剪贴板')
|
||||||
|
clearInstallCopiedResetTimer()
|
||||||
|
installCopiedResetTimer = setTimeout(() => {
|
||||||
|
installCopied.value = false
|
||||||
|
installCopiedResetTimer = null
|
||||||
|
}, 2000)
|
||||||
|
}
|
||||||
|
|
||||||
async function toggleApiKey(apiKey: AdminApiKey) {
|
async function toggleApiKey(apiKey: AdminApiKey) {
|
||||||
try {
|
try {
|
||||||
const response = await adminApi.toggleApiKey(apiKey.id)
|
const response = await adminApi.toggleApiKey(apiKey.id)
|
||||||
@@ -1063,7 +1315,12 @@ async function copyKeyPrefix(apiKey: AdminApiKey) {
|
|||||||
try {
|
try {
|
||||||
// 调用后端 API 获取完整密钥
|
// 调用后端 API 获取完整密钥
|
||||||
const response = await adminApi.getFullApiKey(apiKey.id)
|
const response = await adminApi.getFullApiKey(apiKey.id)
|
||||||
await copyToClipboard(response.key)
|
const copied = await copyToClipboard(response.key, false)
|
||||||
|
if (copied) {
|
||||||
|
success('完整密钥已复制到剪贴板')
|
||||||
|
} else {
|
||||||
|
error('复制失败,请手动复制')
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.error('复制密钥失败:', err)
|
log.error('复制密钥失败:', err)
|
||||||
error('复制失败,请重试')
|
error('复制失败,请重试')
|
||||||
|
|||||||
@@ -589,14 +589,34 @@
|
|||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<div class="flex items-center justify-between gap-2">
|
<div class="flex items-center justify-between gap-2">
|
||||||
<Label class="text-sm font-semibold">复制到目标机器执行</Label>
|
<Label class="text-sm font-semibold">复制到目标机器执行</Label>
|
||||||
<Button
|
<div class="flex items-center gap-2">
|
||||||
variant="ghost"
|
<Button
|
||||||
size="sm"
|
variant="outline"
|
||||||
:disabled="installLoading || !selectedInstallApiKey"
|
size="sm"
|
||||||
@click="refreshInstallCommand"
|
class="gap-1.5"
|
||||||
>
|
:disabled="installLoading || !installCommand"
|
||||||
{{ installLoading ? '生成中...' : '重新生成' }}
|
:title="installCopied ? '已复制' : '一键复制安装命令'"
|
||||||
</Button>
|
@click="copyInstallCommand"
|
||||||
|
>
|
||||||
|
<CheckCircle
|
||||||
|
v-if="installCopied"
|
||||||
|
class="h-3.5 w-3.5 text-emerald-600 dark:text-emerald-400"
|
||||||
|
/>
|
||||||
|
<Copy
|
||||||
|
v-else
|
||||||
|
class="h-3.5 w-3.5"
|
||||||
|
/>
|
||||||
|
{{ installCopied ? '已复制' : '一键复制' }}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
:disabled="installLoading || !selectedInstallApiKey"
|
||||||
|
@click="refreshInstallCommand"
|
||||||
|
>
|
||||||
|
{{ installLoading ? '生成中...' : '重新生成' }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="rounded-lg border border-border/60 bg-background overflow-hidden">
|
<div class="rounded-lg border border-border/60 bg-background overflow-hidden">
|
||||||
<pre class="max-h-32 overflow-x-auto whitespace-pre-wrap break-all p-3 text-xs font-mono">{{ installCommand || '正在生成短命令...' }}</pre>
|
<pre class="max-h-32 overflow-x-auto whitespace-pre-wrap break-all p-3 text-xs font-mono">{{ installCommand || '正在生成短命令...' }}</pre>
|
||||||
@@ -618,9 +638,9 @@
|
|||||||
<Button
|
<Button
|
||||||
class="h-10 px-5 shadow-lg shadow-primary/20"
|
class="h-10 px-5 shadow-lg shadow-primary/20"
|
||||||
:disabled="!installCommand || installLoading"
|
:disabled="!installCommand || installLoading"
|
||||||
@click="copyTextToClipboard(installCommand)"
|
@click="copyInstallCommand"
|
||||||
>
|
>
|
||||||
复制命令
|
{{ installCopied ? '已复制' : '复制命令' }}
|
||||||
</Button>
|
</Button>
|
||||||
</template>
|
</template>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
@@ -640,8 +660,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, computed, watch } from 'vue'
|
import { ref, onMounted, onBeforeUnmount, computed, watch } from 'vue'
|
||||||
import { meApi, type ApiKey, type InstallTargetCli, type InstallTargetSystem, type ApiKeyInstallSession } from '@/api/me'
|
import { meApi, type ApiKey, type InstallSessionTargetSystem, type InstallTargetCli, type ApiKeyInstallSession } from '@/api/me'
|
||||||
import Card from '@/components/ui/card.vue'
|
import Card from '@/components/ui/card.vue'
|
||||||
import Button from '@/components/ui/button.vue'
|
import Button from '@/components/ui/button.vue'
|
||||||
import Input from '@/components/ui/input.vue'
|
import Input from '@/components/ui/input.vue'
|
||||||
@@ -674,7 +694,7 @@ const installCliOptions: Array<{ value: InstallTargetCli; label: string }> = [
|
|||||||
{ value: 'gemini_cli', label: 'Gemini CLI' }
|
{ value: 'gemini_cli', label: 'Gemini CLI' }
|
||||||
]
|
]
|
||||||
|
|
||||||
const installSystemOptions: Array<{ value: Exclude<InstallTargetSystem, 'auto'>; label: string }> = [
|
const installSystemOptions: Array<{ value: InstallSessionTargetSystem; label: string }> = [
|
||||||
{ value: 'macos', label: 'macOS' },
|
{ value: 'macos', label: 'macOS' },
|
||||||
{ value: 'linux', label: 'Linux' },
|
{ value: 'linux', label: 'Linux' },
|
||||||
{ value: 'windows', label: 'Windows' }
|
{ value: 'windows', label: 'Windows' }
|
||||||
@@ -708,9 +728,11 @@ const editingApiKey = ref<ApiKey | null>(null)
|
|||||||
const selectedInstallApiKey = ref<ApiKey | null>(null)
|
const selectedInstallApiKey = ref<ApiKey | null>(null)
|
||||||
const pendingFirstInstallApiKey = ref<ApiKey | null>(null)
|
const pendingFirstInstallApiKey = ref<ApiKey | null>(null)
|
||||||
const installCli = ref<InstallTargetCli>('claude_code')
|
const installCli = ref<InstallTargetCli>('claude_code')
|
||||||
const installSystem = ref<Exclude<InstallTargetSystem, 'auto'>>('linux')
|
const installSystem = ref<InstallSessionTargetSystem>('linux')
|
||||||
const installSession = ref<ApiKeyInstallSession | null>(null)
|
const installSession = ref<ApiKeyInstallSession | null>(null)
|
||||||
const installLoading = ref(false)
|
const installLoading = ref(false)
|
||||||
|
const installCopied = ref(false)
|
||||||
|
let installCopiedResetTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
const installCommand = computed(() => {
|
const installCommand = computed(() => {
|
||||||
if (!installSession.value) return ''
|
if (!installSession.value) return ''
|
||||||
@@ -731,6 +753,16 @@ onMounted(() => {
|
|||||||
loadApiKeys()
|
loadApiKeys()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
resetInstallCopiedState()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(showInstallDialog, (isOpen) => {
|
||||||
|
if (!isOpen) {
|
||||||
|
resetInstallCopiedState()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
watch(showKeyDialog, (isOpen) => {
|
watch(showKeyDialog, (isOpen) => {
|
||||||
if (!isOpen && pendingFirstInstallApiKey.value) {
|
if (!isOpen && pendingFirstInstallApiKey.value) {
|
||||||
closeCreatedKeyDialog()
|
closeCreatedKeyDialog()
|
||||||
@@ -756,6 +788,18 @@ async function loadApiKeys() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clearInstallCopiedResetTimer() {
|
||||||
|
if (installCopiedResetTimer) {
|
||||||
|
clearTimeout(installCopiedResetTimer)
|
||||||
|
installCopiedResetTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetInstallCopiedState() {
|
||||||
|
clearInstallCopiedResetTimer()
|
||||||
|
installCopied.value = false
|
||||||
|
}
|
||||||
|
|
||||||
function openEditApiKeyDialog(apiKey: ApiKey) {
|
function openEditApiKeyDialog(apiKey: ApiKey) {
|
||||||
editingApiKey.value = apiKey
|
editingApiKey.value = apiKey
|
||||||
newKeyName.value = apiKey.name || ''
|
newKeyName.value = apiKey.name || ''
|
||||||
@@ -772,7 +816,7 @@ function openCreateApiKeyDialog() {
|
|||||||
showCreateDialog.value = true
|
showCreateDialog.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
function detectCurrentSystem(): Exclude<InstallTargetSystem, 'auto'> {
|
function detectCurrentSystem(): InstallSessionTargetSystem {
|
||||||
const platform = window.navigator.platform.toLowerCase()
|
const platform = window.navigator.platform.toLowerCase()
|
||||||
const userAgent = window.navigator.userAgent.toLowerCase()
|
const userAgent = window.navigator.userAgent.toLowerCase()
|
||||||
if (platform.includes('mac')) return 'macos'
|
if (platform.includes('mac')) return 'macos'
|
||||||
@@ -783,6 +827,7 @@ function detectCurrentSystem(): Exclude<InstallTargetSystem, 'auto'> {
|
|||||||
async function openInstallDialog(apiKey: ApiKey) {
|
async function openInstallDialog(apiKey: ApiKey) {
|
||||||
selectedInstallApiKey.value = apiKey
|
selectedInstallApiKey.value = apiKey
|
||||||
installSession.value = null
|
installSession.value = null
|
||||||
|
resetInstallCopiedState()
|
||||||
showInstallDialog.value = true
|
showInstallDialog.value = true
|
||||||
await refreshInstallCommand()
|
await refreshInstallCommand()
|
||||||
}
|
}
|
||||||
@@ -792,7 +837,7 @@ async function selectInstallCli(value: InstallTargetCli) {
|
|||||||
await refreshInstallCommand()
|
await refreshInstallCommand()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function selectInstallSystem(value: Exclude<InstallTargetSystem, 'auto'>) {
|
async function selectInstallSystem(value: InstallSessionTargetSystem) {
|
||||||
installSystem.value = value
|
installSystem.value = value
|
||||||
await refreshInstallCommand()
|
await refreshInstallCommand()
|
||||||
}
|
}
|
||||||
@@ -801,6 +846,7 @@ async function refreshInstallCommand() {
|
|||||||
if (!selectedInstallApiKey.value) return
|
if (!selectedInstallApiKey.value) return
|
||||||
installLoading.value = true
|
installLoading.value = true
|
||||||
installSession.value = null
|
installSession.value = null
|
||||||
|
resetInstallCopiedState()
|
||||||
try {
|
try {
|
||||||
installSession.value = await meApi.createApiKeyInstallSession(selectedInstallApiKey.value.id, {
|
installSession.value = await meApi.createApiKeyInstallSession(selectedInstallApiKey.value.id, {
|
||||||
target_cli: installCli.value,
|
target_cli: installCli.value,
|
||||||
@@ -814,6 +860,20 @@ async function refreshInstallCommand() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function copyInstallCommand() {
|
||||||
|
if (!installCommand.value) return
|
||||||
|
const copied = await copyTextToClipboard(installCommand.value, false)
|
||||||
|
if (!copied) return
|
||||||
|
|
||||||
|
installCopied.value = true
|
||||||
|
success('安装命令已复制到剪贴板')
|
||||||
|
clearInstallCopiedResetTimer()
|
||||||
|
installCopiedResetTimer = setTimeout(() => {
|
||||||
|
installCopied.value = false
|
||||||
|
installCopiedResetTimer = null
|
||||||
|
}, 2000)
|
||||||
|
}
|
||||||
|
|
||||||
function closeCreatedKeyDialog() {
|
function closeCreatedKeyDialog() {
|
||||||
showKeyDialog.value = false
|
showKeyDialog.value = false
|
||||||
const pending = pendingFirstInstallApiKey.value
|
const pending = pendingFirstInstallApiKey.value
|
||||||
@@ -911,19 +971,22 @@ async function copyApiKey(apiKey: ApiKey) {
|
|||||||
try {
|
try {
|
||||||
// 调用后端 API 获取完整密钥
|
// 调用后端 API 获取完整密钥
|
||||||
const response = await meApi.getFullApiKey(apiKey.id)
|
const response = await meApi.getFullApiKey(apiKey.id)
|
||||||
await copyTextToClipboard(response.key, false) // 不显示内部提示
|
const copied = await copyTextToClipboard(response.key, false) // 不显示内部提示
|
||||||
success('完整密钥已复制到剪贴板')
|
if (copied) {
|
||||||
|
success('完整密钥已复制到剪贴板')
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error('复制密钥失败:', error)
|
log.error('复制密钥失败:', error)
|
||||||
showError('复制失败,请重试')
|
showError('复制失败,请重试')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function copyTextToClipboard(text: string, showToast: boolean = true) {
|
async function copyTextToClipboard(text: string, showToast: boolean = true): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
if (navigator.clipboard && window.isSecureContext) {
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
await navigator.clipboard.writeText(text)
|
await navigator.clipboard.writeText(text)
|
||||||
if (showToast) success('已复制到剪贴板')
|
if (showToast) success('已复制到剪贴板')
|
||||||
|
return true
|
||||||
} else {
|
} else {
|
||||||
const textArea = document.createElement('textarea')
|
const textArea = document.createElement('textarea')
|
||||||
textArea.value = text
|
textArea.value = text
|
||||||
@@ -938,8 +1001,12 @@ async function copyTextToClipboard(text: string, showToast: boolean = true) {
|
|||||||
const successful = document.execCommand('copy')
|
const successful = document.execCommand('copy')
|
||||||
if (successful && showToast) {
|
if (successful && showToast) {
|
||||||
success('已复制到剪贴板')
|
success('已复制到剪贴板')
|
||||||
} else if (!successful) {
|
}
|
||||||
|
if (successful) {
|
||||||
|
return true
|
||||||
|
} else {
|
||||||
showError('复制失败,请手动复制')
|
showError('复制失败,请手动复制')
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
document.body.removeChild(textArea)
|
document.body.removeChild(textArea)
|
||||||
@@ -948,6 +1015,7 @@ async function copyTextToClipboard(text: string, showToast: boolean = true) {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error('复制失败:', error)
|
log.error('复制失败:', error)
|
||||||
showError('复制失败,请手动选择文本进行复制')
|
showError('复制失败,请手动选择文本进行复制')
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user