mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 09:20:22 +08:00
Merge pull request #449 from RWDai/feat/audit-admin-readonly
Add read-only audit administrator role
This commit is contained in:
@@ -153,7 +153,7 @@ pub(super) fn extract_trusted_admin_headers(
|
|||||||
let user_role = header_value_str(headers, crate::constants::TRUSTED_ADMIN_USER_ROLE_HEADER)?
|
let user_role = header_value_str(headers, crate::constants::TRUSTED_ADMIN_USER_ROLE_HEADER)?
|
||||||
.trim()
|
.trim()
|
||||||
.to_string();
|
.to_string();
|
||||||
if !user_role.eq_ignore_ascii_case("admin") {
|
if !crate::roles::can_access_admin_console(&user_role) {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let session_id = header_value_str(headers, crate::constants::TRUSTED_ADMIN_SESSION_ID_HEADER)
|
let session_id = header_value_str(headers, crate::constants::TRUSTED_ADMIN_SESSION_ID_HEADER)
|
||||||
@@ -171,7 +171,7 @@ pub(super) fn extract_trusted_admin_headers(
|
|||||||
|
|
||||||
Some(GatewayTrustedAdminHeaders {
|
Some(GatewayTrustedAdminHeaders {
|
||||||
user_id,
|
user_id,
|
||||||
user_role: "admin".to_string(),
|
user_role,
|
||||||
session_id,
|
session_id,
|
||||||
management_token_id,
|
management_token_id,
|
||||||
})
|
})
|
||||||
@@ -563,6 +563,42 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extracts_trusted_audit_admin_headers() {
|
||||||
|
let mut headers = http::HeaderMap::new();
|
||||||
|
headers.insert(
|
||||||
|
crate::constants::GATEWAY_HEADER,
|
||||||
|
"rust-phase3b".parse().unwrap(),
|
||||||
|
);
|
||||||
|
headers.insert(
|
||||||
|
crate::constants::TRUSTED_ADMIN_USER_ID_HEADER,
|
||||||
|
"audit-admin-1".parse().unwrap(),
|
||||||
|
);
|
||||||
|
headers.insert(
|
||||||
|
crate::constants::TRUSTED_ADMIN_USER_ROLE_HEADER,
|
||||||
|
"audit_admin".parse().unwrap(),
|
||||||
|
);
|
||||||
|
headers.insert(
|
||||||
|
crate::constants::TRUSTED_ADMIN_SESSION_ID_HEADER,
|
||||||
|
"sess-audit-1".parse().unwrap(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let extracted = extract_request_credentials(
|
||||||
|
&headers,
|
||||||
|
&uri("/api/admin/endpoints/health/api-formats"),
|
||||||
|
"admin:endpoints_health",
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
extracted.trusted_admin_headers,
|
||||||
|
Some(GatewayTrustedAdminHeaders {
|
||||||
|
user_id: "audit-admin-1".to_string(),
|
||||||
|
user_role: "audit_admin".to_string(),
|
||||||
|
session_id: Some("sess-audit-1".to_string()),
|
||||||
|
management_token_id: None,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ignores_trusted_admin_headers_without_gateway_marker() {
|
fn ignores_trusted_admin_headers_without_gateway_marker() {
|
||||||
let mut headers = http::HeaderMap::new();
|
let mut headers = http::HeaderMap::new();
|
||||||
|
|||||||
@@ -273,7 +273,7 @@ async fn resolve_local_admin_principal(
|
|||||||
if claims
|
if claims
|
||||||
.get("role")
|
.get("role")
|
||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
.is_some_and(|role| !role.eq_ignore_ascii_case("admin"))
|
.is_some_and(|role| !crate::roles::can_access_admin_console(role))
|
||||||
{
|
{
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
@@ -300,7 +300,7 @@ async fn resolve_local_admin_principal_from_claims(
|
|||||||
let Some(user) = state.find_user_auth_by_id(user_id).await? else {
|
let Some(user) = state.find_user_auth_by_id(user_id).await? else {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
if !user.is_active || user.is_deleted || !user.role.eq_ignore_ascii_case("admin") {
|
if !user.is_active || user.is_deleted || !crate::roles::can_access_admin_console(&user.role) {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -329,7 +329,7 @@ async fn resolve_local_admin_principal_from_claims(
|
|||||||
|
|
||||||
Ok(Some(GatewayAdminPrincipalContext {
|
Ok(Some(GatewayAdminPrincipalContext {
|
||||||
user_id: user.id,
|
user_id: user.id,
|
||||||
user_role: "admin".to_string(),
|
user_role: user.role,
|
||||||
session_id: Some(session.id),
|
session_id: Some(session.id),
|
||||||
management_token_id: None,
|
management_token_id: None,
|
||||||
management_token_permissions: None,
|
management_token_permissions: None,
|
||||||
|
|||||||
@@ -224,6 +224,19 @@ pub(crate) fn read_only_management_token_permissions() -> Vec<String> {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn audit_admin_read_only_management_token_permissions() -> Vec<String> {
|
||||||
|
let mut permissions = read_only_management_token_permissions()
|
||||||
|
.into_iter()
|
||||||
|
.collect::<BTreeSet<_>>();
|
||||||
|
permissions.extend(
|
||||||
|
PERMISSION_GROUPS
|
||||||
|
.iter()
|
||||||
|
.filter(|group| !group.assignable)
|
||||||
|
.map(|group| permission_key(group.scope, "read").to_string()),
|
||||||
|
);
|
||||||
|
permissions.into_iter().collect()
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn normalize_assignable_management_token_permissions(
|
pub(crate) fn normalize_assignable_management_token_permissions(
|
||||||
value: Option<&Value>,
|
value: Option<&Value>,
|
||||||
) -> Result<Value, String> {
|
) -> Result<Value, String> {
|
||||||
@@ -615,6 +628,64 @@ mod tests {
|
|||||||
.is_ok());
|
.is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn read_only_permissions_allow_reads_and_reject_writes() {
|
||||||
|
let decision = GatewayControlDecision::synthetic(
|
||||||
|
"/api/admin/providers".to_string(),
|
||||||
|
Some("admin_proxy".to_string()),
|
||||||
|
Some("providers_manage".to_string()),
|
||||||
|
Some("create_provider".to_string()),
|
||||||
|
Some("admin:providers".to_string()),
|
||||||
|
);
|
||||||
|
let permissions = read_only_management_token_permissions();
|
||||||
|
|
||||||
|
assert!(validate_management_token_admin_route_permission(
|
||||||
|
&http::Method::GET,
|
||||||
|
&decision,
|
||||||
|
Some(&permissions),
|
||||||
|
)
|
||||||
|
.is_ok());
|
||||||
|
assert_eq!(
|
||||||
|
validate_management_token_admin_route_permission(
|
||||||
|
&http::Method::POST,
|
||||||
|
&decision,
|
||||||
|
Some(&permissions),
|
||||||
|
)
|
||||||
|
.expect_err("read-only permissions should reject writes")
|
||||||
|
.required_permission,
|
||||||
|
"admin:providers:write"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn audit_admin_read_only_permissions_allow_management_tokens_reads_and_reject_writes() {
|
||||||
|
let decision = GatewayControlDecision::synthetic(
|
||||||
|
"/api/admin/management-tokens".to_string(),
|
||||||
|
Some("admin_proxy".to_string()),
|
||||||
|
Some("management_tokens_manage".to_string()),
|
||||||
|
Some("list_tokens".to_string()),
|
||||||
|
Some("admin:management_tokens".to_string()),
|
||||||
|
);
|
||||||
|
let permissions = audit_admin_read_only_management_token_permissions();
|
||||||
|
|
||||||
|
assert!(validate_management_token_admin_route_permission(
|
||||||
|
&http::Method::GET,
|
||||||
|
&decision,
|
||||||
|
Some(&permissions),
|
||||||
|
)
|
||||||
|
.is_ok());
|
||||||
|
assert_eq!(
|
||||||
|
validate_management_token_admin_route_permission(
|
||||||
|
&http::Method::POST,
|
||||||
|
&decision,
|
||||||
|
Some(&permissions),
|
||||||
|
)
|
||||||
|
.expect_err("read-only permissions should reject management token writes")
|
||||||
|
.required_permission,
|
||||||
|
"admin:management_tokens:write"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
fn extract_admin_route_scopes(source: &'static str) -> BTreeSet<&'static str> {
|
fn extract_admin_route_scopes(source: &'static str) -> BTreeSet<&'static str> {
|
||||||
let mut scopes = BTreeSet::new();
|
let mut scopes = BTreeSet::new();
|
||||||
let mut remaining = source;
|
let mut remaining = source;
|
||||||
|
|||||||
@@ -14,11 +14,13 @@ pub(crate) use auth::{
|
|||||||
};
|
};
|
||||||
pub(crate) use execute::{allows_control_execute_emergency, maybe_execute_via_control};
|
pub(crate) use execute::{allows_control_execute_emergency, maybe_execute_via_control};
|
||||||
pub(crate) use management_token_permissions::{
|
pub(crate) use management_token_permissions::{
|
||||||
all_assignable_management_token_permissions, management_token_permission_catalog_payload,
|
all_assignable_management_token_permissions,
|
||||||
management_token_permission_keys_from_value, management_token_permission_mode_and_summary,
|
audit_admin_read_only_management_token_permissions,
|
||||||
|
management_token_permission_catalog_payload, management_token_permission_keys_from_value,
|
||||||
|
management_token_permission_mode_and_summary,
|
||||||
management_token_permissions_cover_all_assignable_permissions,
|
management_token_permissions_cover_all_assignable_permissions,
|
||||||
management_token_required_permission, normalize_assignable_management_token_permissions,
|
management_token_required_permission, normalize_assignable_management_token_permissions,
|
||||||
validate_management_token_admin_route_permission,
|
read_only_management_token_permissions, validate_management_token_admin_route_permission,
|
||||||
};
|
};
|
||||||
pub(crate) use public::{resolve_public_request_context, GatewayPublicRequestContext};
|
pub(crate) use public::{resolve_public_request_context, GatewayPublicRequestContext};
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -503,7 +503,7 @@ fn normalize_selection_filters(
|
|||||||
.map(|value| value.trim().to_ascii_lowercase())
|
.map(|value| value.trim().to_ascii_lowercase())
|
||||||
.filter(|value| !value.is_empty() && value != "all")
|
.filter(|value| !value.is_empty() && value != "all")
|
||||||
{
|
{
|
||||||
Some(role) if matches!(role.as_str(), "user" | "admin") => Some(role),
|
Some(role) if crate::roles::normalize_assignable_user_role(&role).is_some() => Some(role),
|
||||||
Some(_) => return Err("role 参数不合法".to_string()),
|
Some(_) => return Err("role 参数不合法".to_string()),
|
||||||
None => None,
|
None => None,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -225,16 +225,13 @@ pub(super) fn validate_admin_user_password(password: &str, policy: &str) -> Resu
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn normalize_admin_user_role(value: Option<&str>) -> Result<String, String> {
|
pub(super) fn normalize_admin_user_role(value: Option<&str>) -> Result<String, String> {
|
||||||
match value
|
let role = value
|
||||||
.map(str::trim)
|
.map(str::trim)
|
||||||
.filter(|value| !value.is_empty())
|
.filter(|value| !value.is_empty())
|
||||||
.unwrap_or("user")
|
.unwrap_or("user");
|
||||||
.to_ascii_lowercase()
|
match crate::roles::normalize_assignable_user_role(role) {
|
||||||
.as_str()
|
Some(role) => Ok(role.to_string()),
|
||||||
{
|
None => Err("角色参数不合法".to_string()),
|
||||||
"user" => Ok("user".to_string()),
|
|
||||||
"admin" => Ok("admin".to_string()),
|
|
||||||
_ => Err("角色参数不合法".to_string()),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ use super::super::internal;
|
|||||||
use crate::admin_api;
|
use crate::admin_api;
|
||||||
use crate::audit::attach_admin_audit_event;
|
use crate::audit::attach_admin_audit_event;
|
||||||
use crate::control::{
|
use crate::control::{
|
||||||
|
audit_admin_read_only_management_token_permissions,
|
||||||
validate_management_token_admin_route_permission, GatewayPublicRequestContext,
|
validate_management_token_admin_route_permission, GatewayPublicRequestContext,
|
||||||
};
|
};
|
||||||
use crate::{AppState, GatewayError};
|
use crate::{AppState, GatewayError};
|
||||||
@@ -61,21 +62,32 @@ fn maybe_build_management_token_permission_denied_response(
|
|||||||
) -> Option<Response<Body>> {
|
) -> Option<Response<Body>> {
|
||||||
let decision = request_context.control_decision.as_ref()?;
|
let decision = request_context.control_decision.as_ref()?;
|
||||||
let admin_principal = decision.admin_principal.as_ref()?;
|
let admin_principal = decision.admin_principal.as_ref()?;
|
||||||
let token_id = admin_principal.management_token_id.as_deref()?;
|
let audit_admin_read_only_permissions;
|
||||||
|
let token_permissions = if crate::roles::can_write_admin_console(&admin_principal.user_role) {
|
||||||
|
admin_principal.management_token_permissions.as_deref()
|
||||||
|
} else {
|
||||||
|
audit_admin_read_only_permissions = audit_admin_read_only_management_token_permissions();
|
||||||
|
Some(audit_admin_read_only_permissions.as_slice())
|
||||||
|
};
|
||||||
let denied = validate_management_token_admin_route_permission(
|
let denied = validate_management_token_admin_route_permission(
|
||||||
&request_context.request_method,
|
&request_context.request_method,
|
||||||
decision,
|
decision,
|
||||||
admin_principal.management_token_permissions.as_deref(),
|
token_permissions,
|
||||||
)
|
)
|
||||||
.err()?;
|
.err()?;
|
||||||
|
let actor_id = admin_principal
|
||||||
|
.management_token_id
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or(admin_principal.user_id.as_str());
|
||||||
|
|
||||||
warn!(
|
warn!(
|
||||||
trace_id = %request_context.trace_id,
|
trace_id = %request_context.trace_id,
|
||||||
admin_management_token_id = %token_id,
|
admin_actor_id = %actor_id,
|
||||||
|
admin_user_role = %admin_principal.user_role,
|
||||||
route_family = decision.route_family.as_deref().unwrap_or("unknown"),
|
route_family = decision.route_family.as_deref().unwrap_or("unknown"),
|
||||||
route_kind = decision.route_kind.as_deref().unwrap_or("unknown"),
|
route_kind = decision.route_kind.as_deref().unwrap_or("unknown"),
|
||||||
required_permission = %denied.required_permission,
|
required_permission = %denied.required_permission,
|
||||||
"management token permission denied"
|
"admin route permission denied"
|
||||||
);
|
);
|
||||||
|
|
||||||
let mut response = (
|
let mut response = (
|
||||||
@@ -91,10 +103,10 @@ fn maybe_build_management_token_permission_denied_response(
|
|||||||
.into_response();
|
.into_response();
|
||||||
attach_admin_audit_event(
|
attach_admin_audit_event(
|
||||||
&mut response,
|
&mut response,
|
||||||
"admin_management_token_permission_denied",
|
"admin_route_permission_denied",
|
||||||
"permission_denied",
|
"permission_denied",
|
||||||
"management_token_permission",
|
"admin_route_permission",
|
||||||
token_id,
|
actor_id,
|
||||||
);
|
);
|
||||||
Some(response)
|
Some(response)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -228,7 +228,7 @@ async fn maybe_promote_management_token_admin_principal(
|
|||||||
let Some(user) = state.find_user_auth_by_id(&token_with_user.user.id).await? else {
|
let Some(user) = state.find_user_auth_by_id(&token_with_user.user.id).await? else {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
if !user.is_active || user.is_deleted || !user.role.eq_ignore_ascii_case("admin") {
|
if !user.is_active || user.is_deleted || !crate::roles::can_access_admin_console(&user.role) {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let management_token_permissions = match management_token_permission_keys_from_value(
|
let management_token_permissions = match management_token_permission_keys_from_value(
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ mod provider_key_auth;
|
|||||||
pub(crate) use aether_provider_transport as provider_transport;
|
pub(crate) use aether_provider_transport as provider_transport;
|
||||||
mod rate_limit;
|
mod rate_limit;
|
||||||
mod request_candidate_runtime;
|
mod request_candidate_runtime;
|
||||||
|
mod roles;
|
||||||
mod router;
|
mod router;
|
||||||
mod scheduler;
|
mod scheduler;
|
||||||
mod state;
|
mod state;
|
||||||
|
|||||||
52
apps/aether-gateway/src/roles.rs
Normal file
52
apps/aether-gateway/src/roles.rs
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
pub(crate) const ROLE_USER: &str = "user";
|
||||||
|
pub(crate) const ROLE_ADMIN: &str = "admin";
|
||||||
|
pub(crate) const ROLE_AUDIT_ADMIN: &str = "audit_admin";
|
||||||
|
|
||||||
|
pub(crate) fn is_full_admin_role(role: &str) -> bool {
|
||||||
|
role.trim().eq_ignore_ascii_case(ROLE_ADMIN)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn is_audit_admin_role(role: &str) -> bool {
|
||||||
|
role.trim().eq_ignore_ascii_case(ROLE_AUDIT_ADMIN)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn can_access_admin_console(role: &str) -> bool {
|
||||||
|
is_full_admin_role(role) || is_audit_admin_role(role)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn can_write_admin_console(role: &str) -> bool {
|
||||||
|
is_full_admin_role(role)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn normalize_assignable_user_role(role: &str) -> Option<&'static str> {
|
||||||
|
match role.trim().to_ascii_lowercase().as_str() {
|
||||||
|
ROLE_USER => Some(ROLE_USER),
|
||||||
|
ROLE_ADMIN => Some(ROLE_ADMIN),
|
||||||
|
ROLE_AUDIT_ADMIN => Some(ROLE_AUDIT_ADMIN),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{
|
||||||
|
can_access_admin_console, can_write_admin_console, normalize_assignable_user_role,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn audit_admin_can_access_but_not_write_admin_console() {
|
||||||
|
assert!(can_access_admin_console("audit_admin"));
|
||||||
|
assert!(!can_write_admin_console("audit_admin"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn assignable_roles_include_audit_admin() {
|
||||||
|
assert_eq!(
|
||||||
|
normalize_assignable_user_role(" audit_admin "),
|
||||||
|
Some("audit_admin")
|
||||||
|
);
|
||||||
|
assert_eq!(normalize_assignable_user_role("admin"), Some("admin"));
|
||||||
|
assert_eq!(normalize_assignable_user_role("user"), Some("user"));
|
||||||
|
assert_eq!(normalize_assignable_user_role("owner"), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -66,7 +66,7 @@ impl AppState {
|
|||||||
role: &str,
|
role: &str,
|
||||||
) -> Result<Vec<String>, GatewayError> {
|
) -> Result<Vec<String>, GatewayError> {
|
||||||
let mut group_ids = normalized_user_group_ids(group_ids);
|
let mut group_ids = normalized_user_group_ids(group_ids);
|
||||||
if role.trim().eq_ignore_ascii_case("admin") {
|
if crate::roles::can_access_admin_console(role) {
|
||||||
if let Some(default_group_id) = self.configured_default_user_group_id().await? {
|
if let Some(default_group_id) = self.configured_default_user_group_id().await? {
|
||||||
group_ids.remove(&default_group_id);
|
group_ids.remove(&default_group_id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ async function syncExternalAuthState(nextToken: string | null): Promise<void> {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (router.currentRoute.value.path.startsWith('/admin') && user.role !== 'admin') {
|
if (router.currentRoute.value.path.startsWith('/admin') && !authStore.canAccessAdmin) {
|
||||||
await router.replace('/dashboard')
|
await router.replace('/dashboard')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -340,7 +340,7 @@ async function handleLogin() {
|
|||||||
// 延迟一下让用户看到成功消息
|
// 延迟一下让用户看到成功消息
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
// 根据用户角色跳转到不同的仪表盘
|
// 根据用户角色跳转到不同的仪表盘
|
||||||
const targetPath = authStore.user?.role === 'admin' ? '/admin/dashboard' : '/dashboard'
|
const targetPath = authStore.canAccessAdmin ? '/admin/dashboard' : '/dashboard'
|
||||||
router.push(targetPath)
|
router.push(targetPath)
|
||||||
}, 1000)
|
}, 1000)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -135,12 +135,15 @@
|
|||||||
<SelectItem value="admin">
|
<SelectItem value="admin">
|
||||||
管理员
|
管理员
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
<SelectItem value="audit_admin">
|
||||||
|
审计管理员
|
||||||
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="rounded-xl border border-amber-200/70 bg-amber-50/70 px-3 py-2.5 text-xs leading-relaxed text-amber-800 dark:border-amber-900/50 dark:bg-amber-950/30 dark:text-amber-200">
|
<div class="rounded-xl border border-amber-200/70 bg-amber-50/70 px-3 py-2.5 text-xs leading-relaxed text-amber-800 dark:border-amber-900/50 dark:bg-amber-950/30 dark:text-amber-200">
|
||||||
{{ targetRole === 'admin' ? '提示:设置为管理员会授予用户后台管理能力。' : '提示:设置为普通用户会移除目标用户的管理员权限。' }}
|
{{ targetRole === 'admin' ? '提示:设置为管理员会授予用户完整后台管理能力。' : targetRole === 'audit_admin' ? '提示:设置为审计管理员会授予后台只读查看能力。' : '提示:设置为普通用户会移除目标用户的管理员权限。' }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -80,6 +80,9 @@
|
|||||||
<SelectItem value="admin">
|
<SelectItem value="admin">
|
||||||
管理员
|
管理员
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
<SelectItem value="audit_admin">
|
||||||
|
审计管理员
|
||||||
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -66,7 +66,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col min-w-0">
|
<div class="flex flex-col min-w-0">
|
||||||
<span class="text-xs font-semibold leading-none truncate opacity-90 text-foreground">{{ authStore.user?.username }}</span>
|
<span class="text-xs font-semibold leading-none truncate opacity-90 text-foreground">{{ authStore.user?.username }}</span>
|
||||||
<span class="text-[10px] opacity-50 leading-none mt-1.5 text-muted-foreground">{{ authStore.user?.role === 'admin' ? '管理员' : '用户' }}</span>
|
<span class="text-[10px] opacity-50 leading-none mt-1.5 text-muted-foreground">{{ currentRoleLabel }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -227,7 +227,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col min-w-0">
|
<div class="flex flex-col min-w-0">
|
||||||
<span class="text-sm font-semibold leading-none truncate text-[#191919] dark:text-white">{{ authStore.user?.username }}</span>
|
<span class="text-sm font-semibold leading-none truncate text-[#191919] dark:text-white">{{ authStore.user?.username }}</span>
|
||||||
<span class="text-[10px] text-[#91918d] dark:text-muted-foreground leading-none mt-1">{{ authStore.user?.role === 'admin' ? '管理员' : '用户' }}</span>
|
<span class="text-[10px] text-[#91918d] dark:text-muted-foreground leading-none mt-1">{{ currentRoleLabel }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
@@ -520,7 +520,7 @@ function showDebugVersionStatus(hasUpdate = true) {
|
|||||||
// 检查更新
|
// 检查更新
|
||||||
async function checkForUpdate() {
|
async function checkForUpdate() {
|
||||||
// 只有管理员才检查更新
|
// 只有管理员才检查更新
|
||||||
if (!isAdmin.value) return
|
if (!authStore.canOperateAdmin) return
|
||||||
|
|
||||||
// 同一会话内只检查一次
|
// 同一会话内只检查一次
|
||||||
const sessionKey = 'aether_update_checked'
|
const sessionKey = 'aether_update_checked'
|
||||||
@@ -567,7 +567,7 @@ onMounted(() => {
|
|||||||
syncAuthNotice()
|
syncAuthNotice()
|
||||||
|
|
||||||
// 管理员预加载模块状态(路由守卫会按需加载,这里提前加载以避免菜单闪烁)
|
// 管理员预加载模块状态(路由守卫会按需加载,这里提前加载以避免菜单闪烁)
|
||||||
if (isAdmin.value && !moduleStore.loaded && !moduleStore.loading) {
|
if (authStore.canAccessAdmin && !moduleStore.loaded && !moduleStore.loading) {
|
||||||
moduleStore.fetchModules()
|
moduleStore.fetchModules()
|
||||||
}
|
}
|
||||||
void loadVersionStatus()
|
void loadVersionStatus()
|
||||||
@@ -707,7 +707,13 @@ const navigation = computed(() => {
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
return authStore.user?.role === 'admin' ? adminNavigation : baseNavigation
|
return authStore.canAccessAdmin ? adminNavigation : baseNavigation
|
||||||
|
})
|
||||||
|
|
||||||
|
const currentRoleLabel = computed(() => {
|
||||||
|
if (authStore.isAdmin) return '管理员'
|
||||||
|
if (authStore.isAuditAdmin) return '审计管理员'
|
||||||
|
return '用户'
|
||||||
})
|
})
|
||||||
|
|
||||||
// Breadcrumbs
|
// Breadcrumbs
|
||||||
|
|||||||
@@ -12,8 +12,7 @@ export async function checkAdminAccess(
|
|||||||
authStore: ReturnType<typeof useAuthStore>,
|
authStore: ReturnType<typeof useAuthStore>,
|
||||||
moduleStore: ReturnType<typeof useModuleStore>
|
moduleStore: ReturnType<typeof useModuleStore>
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
const isAdmin = authStore.user?.role === 'admin'
|
if (!authStore.canAccessAdmin) {
|
||||||
if (!isAdmin) {
|
|
||||||
log.warn('Non-admin user attempted to access admin page, redirecting to user dashboard')
|
log.warn('Non-admin user attempted to access admin page, redirecting to user dashboard')
|
||||||
return '/dashboard'
|
return '/dashboard'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,12 +26,11 @@ export function resolveHomeRedirect(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 已登录用户首次访问首页(非返回/刷新场景),根据角色跳转到对应仪表盘
|
// 已登录用户首次访问首页(非返回/刷新场景),根据角色跳转到对应仪表盘
|
||||||
const isAdmin = authStore.user?.role === 'admin'
|
|
||||||
const redirectPath = sessionStorage.getItem('redirectPath')
|
const redirectPath = sessionStorage.getItem('redirectPath')
|
||||||
if (redirectPath && redirectPath !== '/') {
|
if (redirectPath && redirectPath !== '/') {
|
||||||
sessionStorage.removeItem('redirectPath')
|
sessionStorage.removeItem('redirectPath')
|
||||||
return redirectPath
|
return redirectPath
|
||||||
}
|
}
|
||||||
|
|
||||||
return isAdmin ? '/admin/dashboard' : '/dashboard'
|
return authStore.canAccessAdmin ? '/admin/dashboard' : '/dashboard'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,4 +105,21 @@ describe('auth store logout', () => {
|
|||||||
expect(store.user).toBeNull()
|
expect(store.user).toBeNull()
|
||||||
expect(store.token).toBeNull()
|
expect(store.token).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('separates admin access from admin operations for audit administrators', () => {
|
||||||
|
const store = useAuthStore()
|
||||||
|
|
||||||
|
store.user = {
|
||||||
|
id: 'audit-1',
|
||||||
|
username: 'auditor',
|
||||||
|
role: 'audit_admin',
|
||||||
|
is_active: true,
|
||||||
|
created_at: '2026-03-16T00:00:00Z',
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(store.isAdmin).toBe(false)
|
||||||
|
expect(store.isAuditAdmin).toBe(true)
|
||||||
|
expect(store.canAccessAdmin).toBe(true)
|
||||||
|
expect(store.canOperateAdmin).toBe(false)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const isAdmin = computed(() => user.value?.role === 'admin')
|
const isAdmin = computed(() => user.value?.role === 'admin')
|
||||||
|
const isAuditAdmin = computed(() => user.value?.role === 'audit_admin')
|
||||||
|
const canAccessAdmin = computed(() => isAdmin.value || isAuditAdmin.value)
|
||||||
|
const canOperateAdmin = computed(() => isAdmin.value)
|
||||||
|
|
||||||
async function login(email: string, password: string, authType: 'local' | 'ldap' = 'local') {
|
async function login(email: string, password: string, authType: 'local' | 'ldap' = 'local') {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
@@ -114,6 +117,9 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
error,
|
error,
|
||||||
isAuthenticated,
|
isAuthenticated,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
|
isAuditAdmin,
|
||||||
|
canAccessAdmin,
|
||||||
|
canOperateAdmin,
|
||||||
login,
|
login,
|
||||||
logout,
|
logout,
|
||||||
applyExternalLogout,
|
applyExternalLogout,
|
||||||
|
|||||||
@@ -414,7 +414,7 @@
|
|||||||
使用记录
|
使用记录
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
v-if="canCancel(task.status)"
|
v-if="authStore.canOperateAdmin && canCancel(task.status)"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="h-7 text-xs text-red-500 border-red-200 hover:bg-red-50"
|
class="h-7 text-xs text-red-500 border-red-200 hover:bg-red-50"
|
||||||
@@ -830,7 +830,7 @@
|
|||||||
|
|
||||||
<!-- 操作按钮 -->
|
<!-- 操作按钮 -->
|
||||||
<div
|
<div
|
||||||
v-if="canCancel(selectedTask.status)"
|
v-if="authStore.canOperateAdmin && canCancel(selectedTask.status)"
|
||||||
class="pt-4 border-t border-border/60"
|
class="pt-4 border-t border-border/60"
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
@@ -902,7 +902,7 @@ import { useAuthStore } from '@/stores/auth'
|
|||||||
import { log } from '@/utils/logger'
|
import { log } from '@/utils/logger'
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const isAdmin = computed(() => authStore.user?.role === 'admin')
|
const isAdmin = computed(() => authStore.canAccessAdmin)
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
const { copyToClipboard } = useClipboard()
|
const { copyToClipboard } = useClipboard()
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,9 @@
|
|||||||
<SelectItem value="admin">
|
<SelectItem value="admin">
|
||||||
管理员
|
管理员
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
<SelectItem value="audit_admin">
|
||||||
|
审计管理员
|
||||||
|
</SelectItem>
|
||||||
<SelectItem value="user">
|
<SelectItem value="user">
|
||||||
用户
|
用户
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
@@ -148,6 +151,9 @@
|
|||||||
<SelectItem value="admin">
|
<SelectItem value="admin">
|
||||||
管理员
|
管理员
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
<SelectItem value="audit_admin">
|
||||||
|
审计管理员
|
||||||
|
</SelectItem>
|
||||||
<SelectItem value="user">
|
<SelectItem value="user">
|
||||||
普通用户
|
普通用户
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
@@ -200,6 +206,7 @@
|
|||||||
|
|
||||||
<!-- 新增用户按钮 -->
|
<!-- 新增用户按钮 -->
|
||||||
<Button
|
<Button
|
||||||
|
v-if="authStore.canOperateAdmin"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
class="h-8 w-8"
|
class="h-8 w-8"
|
||||||
@@ -211,6 +218,7 @@
|
|||||||
|
|
||||||
<!-- 新增用户按钮 -->
|
<!-- 新增用户按钮 -->
|
||||||
<Button
|
<Button
|
||||||
|
v-if="authStore.canOperateAdmin"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
class="h-8 w-8"
|
class="h-8 w-8"
|
||||||
@@ -262,6 +270,7 @@
|
|||||||
清空选择
|
清空选择
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
v-if="authStore.canOperateAdmin"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="h-7 px-3 text-[11px]"
|
class="h-7 px-3 text-[11px]"
|
||||||
:disabled="(selectedCount === 0 && userGroups.length === 0) || usersStore.loading"
|
:disabled="(selectedCount === 0 && userGroups.length === 0) || usersStore.loading"
|
||||||
@@ -362,10 +371,10 @@
|
|||||||
{{ user.username }}
|
{{ user.username }}
|
||||||
</div>
|
</div>
|
||||||
<Badge
|
<Badge
|
||||||
:variant="user.role === 'admin' ? 'default' : 'secondary'"
|
:variant="userRoleBadgeVariant(user.role)"
|
||||||
class="h-5 px-1.5 py-0 text-[10px] font-medium flex-shrink-0"
|
class="h-5 px-1.5 py-0 text-[10px] font-medium flex-shrink-0"
|
||||||
>
|
>
|
||||||
{{ user.role === 'admin' ? '管理员' : '普通用户' }}
|
{{ formatUserRole(user.role) }}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -468,6 +477,7 @@
|
|||||||
<TableCell class="py-4">
|
<TableCell class="py-4">
|
||||||
<div class="flex justify-center gap-1">
|
<div class="flex justify-center gap-1">
|
||||||
<Button
|
<Button
|
||||||
|
v-if="authStore.canOperateAdmin"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
class="h-8 w-8"
|
class="h-8 w-8"
|
||||||
@@ -477,6 +487,7 @@
|
|||||||
<SquarePen class="h-4 w-4" />
|
<SquarePen class="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
v-if="authStore.canOperateAdmin"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
class="h-8 w-8"
|
class="h-8 w-8"
|
||||||
@@ -486,6 +497,7 @@
|
|||||||
<DollarSign class="h-4 w-4" />
|
<DollarSign class="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
v-if="authStore.canOperateAdmin"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
class="h-8 w-8"
|
class="h-8 w-8"
|
||||||
@@ -495,6 +507,7 @@
|
|||||||
<Key class="h-4 w-4" />
|
<Key class="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
v-if="authStore.canOperateAdmin"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
class="h-8 w-8"
|
class="h-8 w-8"
|
||||||
@@ -588,10 +601,10 @@
|
|||||||
{{ user.username }}
|
{{ user.username }}
|
||||||
</div>
|
</div>
|
||||||
<Badge
|
<Badge
|
||||||
:variant="user.role === 'admin' ? 'default' : 'secondary'"
|
:variant="userRoleBadgeVariant(user.role)"
|
||||||
class="h-5 px-1.5 py-0 text-[10px] font-medium flex-shrink-0"
|
class="h-5 px-1.5 py-0 text-[10px] font-medium flex-shrink-0"
|
||||||
>
|
>
|
||||||
{{ user.role === 'admin' ? '管理员' : '普通用户' }}
|
{{ formatUserRole(user.role) }}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -694,6 +707,7 @@
|
|||||||
|
|
||||||
<div class="grid grid-cols-2 gap-2 pt-0.5">
|
<div class="grid grid-cols-2 gap-2 pt-0.5">
|
||||||
<Button
|
<Button
|
||||||
|
v-if="authStore.canOperateAdmin"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="h-8 text-xs"
|
class="h-8 text-xs"
|
||||||
@@ -703,6 +717,7 @@
|
|||||||
编辑
|
编辑
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
v-if="authStore.canOperateAdmin"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="h-8 text-xs"
|
class="h-8 text-xs"
|
||||||
@@ -712,6 +727,7 @@
|
|||||||
资金
|
资金
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
v-if="authStore.canOperateAdmin"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="h-8 text-xs"
|
class="h-8 text-xs"
|
||||||
@@ -721,6 +737,7 @@
|
|||||||
API Keys
|
API Keys
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
v-if="authStore.canOperateAdmin"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="h-8 text-xs"
|
class="h-8 text-xs"
|
||||||
@@ -1236,6 +1253,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, watch } from 'vue'
|
import { ref, computed, onMounted, watch } from 'vue'
|
||||||
import { useUsersStore } from '@/stores/users'
|
import { useUsersStore } from '@/stores/users'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import type { User, ApiKey, UserSession, UserBatchActionResponse, UserBatchSelectionFilters, UserGroup } from '@/api/users'
|
import type { User, ApiKey, UserSession, UserBatchActionResponse, UserBatchSelectionFilters, UserGroup } from '@/api/users'
|
||||||
import { formatSessionMeta } from '@/types/session'
|
import { formatSessionMeta } from '@/types/session'
|
||||||
import { adminWalletApi, type AdminWallet } from '@/api/admin-wallets'
|
import { adminWalletApi, type AdminWallet } from '@/api/admin-wallets'
|
||||||
@@ -1310,6 +1328,7 @@ const { success, error } = useToast()
|
|||||||
const { confirmDanger } = useConfirm()
|
const { confirmDanger } = useConfirm()
|
||||||
const { copyToClipboard } = useClipboard()
|
const { copyToClipboard } = useClipboard()
|
||||||
const usersStore = useUsersStore()
|
const usersStore = useUsersStore()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
// 用户表单对话框状态
|
// 用户表单对话框状态
|
||||||
const showUserFormDialog = ref(false)
|
const showUserFormDialog = ref(false)
|
||||||
@@ -1354,6 +1373,7 @@ const userGroups = ref<UserGroup[]>([])
|
|||||||
const userRoleFilterOptions = [
|
const userRoleFilterOptions = [
|
||||||
{ value: 'all', label: '全部角色' },
|
{ value: 'all', label: '全部角色' },
|
||||||
{ value: 'admin', label: '管理员' },
|
{ value: 'admin', label: '管理员' },
|
||||||
|
{ value: 'audit_admin', label: '审计管理员' },
|
||||||
{ value: 'user', label: '普通用户' },
|
{ value: 'user', label: '普通用户' },
|
||||||
]
|
]
|
||||||
const userStatusFilterOptions = [
|
const userStatusFilterOptions = [
|
||||||
@@ -1373,9 +1393,9 @@ const filteredUsers = computed(() => {
|
|||||||
|
|
||||||
// 先排序:管理员优先,然后按创建时间倒序
|
// 先排序:管理员优先,然后按创建时间倒序
|
||||||
filtered.sort((a, b) => {
|
filtered.sort((a, b) => {
|
||||||
// 管理员优先
|
const roleRank = (role: string) => role === 'admin' ? 0 : role === 'audit_admin' ? 1 : 2
|
||||||
if (a.role === 'admin' && b.role !== 'admin') return -1
|
const roleDiff = roleRank(a.role) - roleRank(b.role)
|
||||||
if (a.role !== 'admin' && b.role === 'admin') return 1
|
if (roleDiff !== 0) return roleDiff
|
||||||
// 同角色按创建时间倒序(新用户在前)
|
// 同角色按创建时间倒序(新用户在前)
|
||||||
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
|
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
|
||||||
})
|
})
|
||||||
@@ -1437,7 +1457,7 @@ const batchSelectionFilters = computed<UserBatchSelectionFilters>(() => {
|
|||||||
const filters: UserBatchSelectionFilters = {}
|
const filters: UserBatchSelectionFilters = {}
|
||||||
const search = searchQuery.value.trim()
|
const search = searchQuery.value.trim()
|
||||||
if (search) filters.search = search
|
if (search) filters.search = search
|
||||||
if (filterRole.value === 'admin' || filterRole.value === 'user') filters.role = filterRole.value
|
if (filterRole.value === 'admin' || filterRole.value === 'audit_admin' || filterRole.value === 'user') filters.role = filterRole.value
|
||||||
if (filterStatus.value === 'active') filters.is_active = true
|
if (filterStatus.value === 'active') filters.is_active = true
|
||||||
if (filterStatus.value === 'inactive') filters.is_active = false
|
if (filterStatus.value === 'inactive') filters.is_active = false
|
||||||
if (filterGroup.value !== 'all') filters.group_id = filterGroup.value
|
if (filterGroup.value !== 'all') filters.group_id = filterGroup.value
|
||||||
@@ -1452,6 +1472,16 @@ watch([searchQuery, filterRole, filterStatus, filterGroup], () => {
|
|||||||
|
|
||||||
watch(paginatedUsers, (users) => rememberBatchPageUsers(users), { immediate: true })
|
watch(paginatedUsers, (users) => rememberBatchPageUsers(users), { immediate: true })
|
||||||
|
|
||||||
|
function formatUserRole(role: string) {
|
||||||
|
if (role === 'admin') return '管理员'
|
||||||
|
if (role === 'audit_admin') return '审计管理员'
|
||||||
|
return '普通用户'
|
||||||
|
}
|
||||||
|
|
||||||
|
function userRoleBadgeVariant(role: string) {
|
||||||
|
return role === 'admin' ? 'default' : 'secondary'
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
void refreshUsers({ preferCache: true })
|
void refreshUsers({ preferCache: true })
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ onMounted(async () => {
|
|||||||
success('登录成功')
|
success('登录成功')
|
||||||
|
|
||||||
const redirectPath = consumeRedirectPath()
|
const redirectPath = consumeRedirectPath()
|
||||||
const target = redirectPath || (authStore.user?.role === 'admin' ? '/admin/dashboard' : '/dashboard')
|
const target = redirectPath || (authStore.canAccessAdmin ? '/admin/dashboard' : '/dashboard')
|
||||||
await router.replace(target)
|
await router.replace(target)
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -498,7 +498,7 @@ const { copyToClipboard } = useClipboard()
|
|||||||
const { siteName, siteSubtitle } = useSiteInfo()
|
const { siteName, siteSubtitle } = useSiteInfo()
|
||||||
|
|
||||||
const dashboardPath = computed(() =>
|
const dashboardPath = computed(() =>
|
||||||
authStore.user?.role === 'admin' ? '/admin/dashboard' : '/dashboard'
|
authStore.canAccessAdmin ? '/admin/dashboard' : '/dashboard'
|
||||||
)
|
)
|
||||||
const baseUrl = computed(() => window.location.origin)
|
const baseUrl = computed(() => window.location.origin)
|
||||||
|
|
||||||
|
|||||||
@@ -8,10 +8,10 @@
|
|||||||
class="flex-1 min-w-0 flex flex-col"
|
class="flex-1 min-w-0 flex flex-col"
|
||||||
>
|
>
|
||||||
<Badge
|
<Badge
|
||||||
:variant="authStore.user?.role === 'admin' ? 'default' : 'secondary'"
|
:variant="authStore.isAdmin ? 'default' : 'secondary'"
|
||||||
class="uppercase tracking-[0.45em] mb-4 self-start"
|
class="uppercase tracking-[0.45em] mb-4 self-start"
|
||||||
>
|
>
|
||||||
{{ authStore.user?.role === 'admin' ? 'ADMIN MODE' : 'PERSONAL MODE' }}
|
{{ dashboardModeLabel }}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|
||||||
<!-- 主要统计卡片 -->
|
<!-- 主要统计卡片 -->
|
||||||
@@ -915,7 +915,12 @@ function setupTimelineResizeObserver() {
|
|||||||
announcementsTimelineObserver.observe(container)
|
announcementsTimelineObserver.observe(container)
|
||||||
}
|
}
|
||||||
|
|
||||||
const isAdmin = computed(() => authStore.user?.role === 'admin')
|
const isAdmin = computed(() => authStore.canAccessAdmin)
|
||||||
|
const dashboardModeLabel = computed(() => {
|
||||||
|
if (authStore.isAdmin) return 'ADMIN MODE'
|
||||||
|
if (authStore.isAuditAdmin) return 'AUDIT MODE'
|
||||||
|
return 'PERSONAL MODE'
|
||||||
|
})
|
||||||
|
|
||||||
const statCardBorders = [
|
const statCardBorders = [
|
||||||
'border-book-cloth/30 dark:border-book-cloth/25',
|
'border-book-cloth/30 dark:border-book-cloth/25',
|
||||||
|
|||||||
@@ -50,15 +50,15 @@
|
|||||||
>
|
>
|
||||||
<UsageModelTable
|
<UsageModelTable
|
||||||
:data="enhancedModelStats"
|
:data="enhancedModelStats"
|
||||||
:is-admin="authStore.isAdmin"
|
:is-admin="authStore.canAccessAdmin"
|
||||||
/>
|
/>
|
||||||
<UsageProviderTable
|
<UsageProviderTable
|
||||||
:data="providerStats"
|
:data="providerStats"
|
||||||
:is-admin="authStore.isAdmin"
|
:is-admin="authStore.canAccessAdmin"
|
||||||
/>
|
/>
|
||||||
<UsageApiFormatTable
|
<UsageApiFormatTable
|
||||||
:data="apiFormatStats"
|
:data="apiFormatStats"
|
||||||
:is-admin="authStore.isAdmin"
|
:is-admin="authStore.canAccessAdmin"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<!-- 用户:模型 + API格式(2列) -->
|
<!-- 用户:模型 + API格式(2列) -->
|
||||||
@@ -68,7 +68,7 @@
|
|||||||
>
|
>
|
||||||
<UsageModelTable
|
<UsageModelTable
|
||||||
:data="enhancedModelStats"
|
:data="enhancedModelStats"
|
||||||
:is-admin="authStore.isAdmin"
|
:is-admin="authStore.canAccessAdmin"
|
||||||
/>
|
/>
|
||||||
<UsageApiFormatTable
|
<UsageApiFormatTable
|
||||||
:data="apiFormatStats"
|
:data="apiFormatStats"
|
||||||
@@ -81,7 +81,7 @@
|
|||||||
<UsageRecordsTable
|
<UsageRecordsTable
|
||||||
:records="displayRecords"
|
:records="displayRecords"
|
||||||
:is-admin="isAdminPage"
|
:is-admin="isAdminPage"
|
||||||
:show-actual-cost="authStore.isAdmin"
|
:show-actual-cost="authStore.canAccessAdmin"
|
||||||
:loading="isLoadingRecords"
|
:loading="isLoadingRecords"
|
||||||
:time-range="timeRange"
|
:time-range="timeRange"
|
||||||
:filter-search="filterSearch"
|
:filter-search="filterSearch"
|
||||||
|
|||||||
@@ -623,7 +623,7 @@ async function loadAnnouncements(page = 1) {
|
|||||||
currentPage.value = page
|
currentPage.value = page
|
||||||
try {
|
try {
|
||||||
const response = await announcementApi.getAnnouncements({
|
const response = await announcementApi.getAnnouncements({
|
||||||
active_only: !isAdmin.value, // 管理员可以看到所有公告
|
active_only: !authStore.canAccessAdmin, // 管理员和审计管理员可以看到所有公告
|
||||||
limit: pageSize.value,
|
limit: pageSize.value,
|
||||||
offset: (page - 1) * pageSize.value
|
offset: (page - 1) * pageSize.value
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -561,7 +561,7 @@
|
|||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
<span class="text-muted-foreground">角色</span>
|
<span class="text-muted-foreground">角色</span>
|
||||||
<Badge :variant="profile?.role === 'admin' ? 'default' : 'secondary'">
|
<Badge :variant="profile?.role === 'admin' ? 'default' : 'secondary'">
|
||||||
{{ profile?.role === 'admin' ? '管理员' : '普通用户' }}
|
{{ profileRoleLabel }}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
@@ -682,6 +682,11 @@ const { setThemeMode } = useDarkMode()
|
|||||||
|
|
||||||
const profile = ref<Profile | null>(null)
|
const profile = ref<Profile | null>(null)
|
||||||
const userSessions = ref<UserSession[]>([])
|
const userSessions = ref<UserSession[]>([])
|
||||||
|
const profileRoleLabel = computed(() => {
|
||||||
|
if (profile.value?.role === 'admin') return '管理员'
|
||||||
|
if (profile.value?.role === 'audit_admin') return '审计管理员'
|
||||||
|
return '普通用户'
|
||||||
|
})
|
||||||
|
|
||||||
const profileForm = ref({
|
const profileForm = ref({
|
||||||
email: '',
|
email: '',
|
||||||
|
|||||||
Reference in New Issue
Block a user