feat: add reporter role for SSO auto-provisioning
All checks were successful
CI Pipeline / Rust Format Check (push) Successful in 5s
CI Pipeline / Clippy Lints (push) Successful in 52s
CI Pipeline / Rust Unit Tests (push) Successful in 1m10s
CI Pipeline / Security Audit (push) Successful in 4s
CI Pipeline / Frontend Lint & Type Check (push) Successful in 14s
CI Pipeline / Build .deb & Release (push) Has been skipped
All checks were successful
CI Pipeline / Rust Format Check (push) Successful in 5s
CI Pipeline / Clippy Lints (push) Successful in 52s
CI Pipeline / Rust Unit Tests (push) Successful in 1m10s
CI Pipeline / Security Audit (push) Successful in 4s
CI Pipeline / Frontend Lint & Type Check (push) Successful in 14s
CI Pipeline / Build .deb & Release (push) Has been skipped
This commit is contained in:
@ -36,6 +36,7 @@ pub struct AuthUser {
|
|||||||
pub enum UserRole {
|
pub enum UserRole {
|
||||||
Admin,
|
Admin,
|
||||||
Operator,
|
Operator,
|
||||||
|
Reporter,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl UserRole {
|
impl UserRole {
|
||||||
@ -43,6 +44,7 @@ impl UserRole {
|
|||||||
match s {
|
match s {
|
||||||
"admin" => Some(Self::Admin),
|
"admin" => Some(Self::Admin),
|
||||||
"operator" => Some(Self::Operator),
|
"operator" => Some(Self::Operator),
|
||||||
|
"reporter" => Some(Self::Reporter),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -51,6 +53,7 @@ impl UserRole {
|
|||||||
match self {
|
match self {
|
||||||
Self::Admin => "admin",
|
Self::Admin => "admin",
|
||||||
Self::Operator => "operator",
|
Self::Operator => "operator",
|
||||||
|
Self::Reporter => "reporter",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -58,6 +61,11 @@ impl UserRole {
|
|||||||
pub fn is_admin(&self) -> bool {
|
pub fn is_admin(&self) -> bool {
|
||||||
matches!(self, Self::Admin)
|
matches!(self, Self::Admin)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Admin and Operator can write; Reporter is read-only.
|
||||||
|
pub fn can_write(&self) -> bool {
|
||||||
|
matches!(self, Self::Admin | Self::Operator)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Shared auth configuration injected via Axum state.
|
/// Shared auth configuration injected via Axum state.
|
||||||
|
|||||||
@ -38,6 +38,7 @@ impl std::fmt::Display for HostHealthStatus {
|
|||||||
pub enum UserRole {
|
pub enum UserRole {
|
||||||
Admin,
|
Admin,
|
||||||
Operator,
|
Operator,
|
||||||
|
Reporter,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::fmt::Display for UserRole {
|
impl std::fmt::Display for UserRole {
|
||||||
@ -45,6 +46,7 @@ impl std::fmt::Display for UserRole {
|
|||||||
match self {
|
match self {
|
||||||
Self::Admin => write!(f, "admin"),
|
Self::Admin => write!(f, "admin"),
|
||||||
Self::Operator => write!(f, "operator"),
|
Self::Operator => write!(f, "operator"),
|
||||||
|
Self::Reporter => write!(f, "reporter"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -104,11 +104,11 @@ fn pem_response(pem: String, filename: &str) -> Result<Response<Body>, (StatusCo
|
|||||||
|
|
||||||
// ── Helper: admin-only guard ──────────────────────────────────────────────────
|
// ── Helper: admin-only guard ──────────────────────────────────────────────────
|
||||||
|
|
||||||
fn require_admin(user: &AuthUser) -> Result<(), (StatusCode, Json<Value>)> {
|
fn require_write_access(user: &AuthUser) -> Result<(), (StatusCode, Json<Value>)> {
|
||||||
if !user.role.is_admin() {
|
if !user.role.can_write() {
|
||||||
return Err((
|
return Err((
|
||||||
StatusCode::FORBIDDEN,
|
StatusCode::FORBIDDEN,
|
||||||
Json(json!({ "error": { "code": "forbidden", "message": "Admin role required" } })),
|
Json(json!({ "error": { "code": "forbidden", "message": "Write access required" } })),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
@ -240,7 +240,7 @@ async fn download_client_cert(
|
|||||||
auth: AuthUser,
|
auth: AuthUser,
|
||||||
Path(host_id): Path<Uuid>,
|
Path(host_id): Path<Uuid>,
|
||||||
) -> Result<Response<Body>, (StatusCode, Json<Value>)> {
|
) -> Result<Response<Body>, (StatusCode, Json<Value>)> {
|
||||||
require_admin(&auth)?;
|
require_write_access(&auth)?;
|
||||||
|
|
||||||
let cert_pem: Option<String> = sqlx::query_scalar(
|
let cert_pem: Option<String> = sqlx::query_scalar(
|
||||||
r#"SELECT cert_pem
|
r#"SELECT cert_pem
|
||||||
@ -297,7 +297,7 @@ async fn issue_client_cert(
|
|||||||
Path(host_id): Path<Uuid>,
|
Path(host_id): Path<Uuid>,
|
||||||
Json(req): Json<IssueCertRequest>,
|
Json(req): Json<IssueCertRequest>,
|
||||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||||
require_admin(&auth)?;
|
require_write_access(&auth)?;
|
||||||
|
|
||||||
// Look up the host's IP address from the database.
|
// Look up the host's IP address from the database.
|
||||||
let ip_address: String = sqlx::query_scalar("SELECT host(ip_address) FROM hosts WHERE id = $1")
|
let ip_address: String = sqlx::query_scalar("SELECT host(ip_address) FROM hosts WHERE id = $1")
|
||||||
@ -353,7 +353,7 @@ async fn renew_cert(
|
|||||||
auth: AuthUser,
|
auth: AuthUser,
|
||||||
Path(cert_id): Path<Uuid>,
|
Path(cert_id): Path<Uuid>,
|
||||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||||
require_admin(&auth)?;
|
require_write_access(&auth)?;
|
||||||
|
|
||||||
let issued = state.ca.renew_cert(cert_id, &state.db).await.map_err(|e| {
|
let issued = state.ca.renew_cert(cert_id, &state.db).await.map_err(|e| {
|
||||||
let msg = e.to_string();
|
let msg = e.to_string();
|
||||||
@ -398,7 +398,7 @@ async fn reissue_host_cert(
|
|||||||
auth: AuthUser,
|
auth: AuthUser,
|
||||||
Path(host_id): Path<Uuid>,
|
Path(host_id): Path<Uuid>,
|
||||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||||
require_admin(&auth)?;
|
require_write_access(&auth)?;
|
||||||
|
|
||||||
// Look up the host's FQDN and IP address for the new certificate CN and SANs.
|
// Look up the host's FQDN and IP address for the new certificate CN and SANs.
|
||||||
let row = sqlx::query("SELECT fqdn, host(ip_address) AS ip_address FROM hosts WHERE id = $1")
|
let row = sqlx::query("SELECT fqdn, host(ip_address) AS ip_address FROM hosts WHERE id = $1")
|
||||||
@ -475,7 +475,7 @@ async fn revoke_cert(
|
|||||||
auth: AuthUser,
|
auth: AuthUser,
|
||||||
Path(cert_id): Path<Uuid>,
|
Path(cert_id): Path<Uuid>,
|
||||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||||
require_admin(&auth)?;
|
require_write_access(&auth)?;
|
||||||
|
|
||||||
state
|
state
|
||||||
.ca
|
.ca
|
||||||
|
|||||||
@ -45,10 +45,10 @@ async fn start_cidr_scan(
|
|||||||
auth: AuthUser,
|
auth: AuthUser,
|
||||||
Json(req): Json<DiscoveryCidrRequest>,
|
Json(req): Json<DiscoveryCidrRequest>,
|
||||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||||
if !auth.role.is_admin() {
|
if !auth.role.can_write() {
|
||||||
return Err((
|
return Err((
|
||||||
StatusCode::FORBIDDEN,
|
StatusCode::FORBIDDEN,
|
||||||
Json(json!({ "error": { "code": "forbidden", "message": "Admin role required" } })),
|
Json(json!({ "error": { "code": "forbidden", "message": "Write access required" } })),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -221,10 +221,10 @@ async fn register_discovered_host(
|
|||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
Json(req): Json<RegisterDiscoveredRequest>,
|
Json(req): Json<RegisterDiscoveredRequest>,
|
||||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||||
if !auth.role.is_admin() {
|
if !auth.role.can_write() {
|
||||||
return Err((
|
return Err((
|
||||||
StatusCode::FORBIDDEN,
|
StatusCode::FORBIDDEN,
|
||||||
Json(json!({ "error": { "code": "forbidden", "message": "Admin role required" } })),
|
Json(json!({ "error": { "code": "forbidden", "message": "Write access required" } })),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -62,10 +62,10 @@ async fn create_group(
|
|||||||
auth: AuthUser,
|
auth: AuthUser,
|
||||||
Json(req): Json<CreateGroupRequest>,
|
Json(req): Json<CreateGroupRequest>,
|
||||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||||
if !auth.role.is_admin() {
|
if !auth.role.can_write() {
|
||||||
return Err((
|
return Err((
|
||||||
StatusCode::FORBIDDEN,
|
StatusCode::FORBIDDEN,
|
||||||
Json(json!({ "error": { "code": "forbidden", "message": "Admin role required" } })),
|
Json(json!({ "error": { "code": "forbidden", "message": "Write access required" } })),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -154,10 +154,10 @@ async fn update_group(
|
|||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
Json(req): Json<UpdateGroupRequest>,
|
Json(req): Json<UpdateGroupRequest>,
|
||||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||||
if !auth.role.is_admin() {
|
if !auth.role.can_write() {
|
||||||
return Err((
|
return Err((
|
||||||
StatusCode::FORBIDDEN,
|
StatusCode::FORBIDDEN,
|
||||||
Json(json!({ "error": { "code": "forbidden", "message": "Admin role required" } })),
|
Json(json!({ "error": { "code": "forbidden", "message": "Write access required" } })),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -187,10 +187,10 @@ async fn delete_group(
|
|||||||
auth: AuthUser,
|
auth: AuthUser,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||||
if !auth.role.is_admin() {
|
if !auth.role.can_write() {
|
||||||
return Err((
|
return Err((
|
||||||
StatusCode::FORBIDDEN,
|
StatusCode::FORBIDDEN,
|
||||||
Json(json!({ "error": { "code": "forbidden", "message": "Admin role required" } })),
|
Json(json!({ "error": { "code": "forbidden", "message": "Write access required" } })),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -234,10 +234,10 @@ async fn add_user_to_group(
|
|||||||
auth: AuthUser,
|
auth: AuthUser,
|
||||||
Path((id, user_id)): Path<(Uuid, Uuid)>,
|
Path((id, user_id)): Path<(Uuid, Uuid)>,
|
||||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||||
if !auth.role.is_admin() {
|
if !auth.role.can_write() {
|
||||||
return Err((
|
return Err((
|
||||||
StatusCode::FORBIDDEN,
|
StatusCode::FORBIDDEN,
|
||||||
Json(json!({ "error": { "code": "forbidden", "message": "Admin role required" } })),
|
Json(json!({ "error": { "code": "forbidden", "message": "Write access required" } })),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -276,10 +276,10 @@ async fn remove_user_from_group(
|
|||||||
auth: AuthUser,
|
auth: AuthUser,
|
||||||
Path((id, user_id)): Path<(Uuid, Uuid)>,
|
Path((id, user_id)): Path<(Uuid, Uuid)>,
|
||||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||||
if !auth.role.is_admin() {
|
if !auth.role.can_write() {
|
||||||
return Err((
|
return Err((
|
||||||
StatusCode::FORBIDDEN,
|
StatusCode::FORBIDDEN,
|
||||||
Json(json!({ "error": { "code": "forbidden", "message": "Admin role required" } })),
|
Json(json!({ "error": { "code": "forbidden", "message": "Write access required" } })),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -102,8 +102,8 @@ async fn list_health_checks(
|
|||||||
auth: AuthUser,
|
auth: AuthUser,
|
||||||
Path(host_id): Path<Uuid>,
|
Path(host_id): Path<Uuid>,
|
||||||
) -> Result<Json<HealthCheckListResponse>, (StatusCode, Json<Value>)> {
|
) -> Result<Json<HealthCheckListResponse>, (StatusCode, Json<Value>)> {
|
||||||
// RBAC check for operators
|
// RBAC: reporters can only see hosts in their groups
|
||||||
if !auth.role.is_admin() {
|
if !auth.role.can_write() {
|
||||||
let can_access = operator_can_access_host(&state.db, auth.user_id, host_id)
|
let can_access = operator_can_access_host(&state.db, auth.user_id, host_id)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
@ -208,25 +208,11 @@ async fn create_health_check(
|
|||||||
Path(host_id): Path<Uuid>,
|
Path(host_id): Path<Uuid>,
|
||||||
Json(req): Json<CreateHealthCheckRequest>,
|
Json(req): Json<CreateHealthCheckRequest>,
|
||||||
) -> Result<(StatusCode, Json<Value>), (StatusCode, Json<Value>)> {
|
) -> Result<(StatusCode, Json<Value>), (StatusCode, Json<Value>)> {
|
||||||
// RBAC check for operators
|
if !auth.role.can_write() {
|
||||||
if !auth.role.is_admin() {
|
return Err((
|
||||||
let can_access = operator_can_access_host(&state.db, auth.user_id, host_id)
|
StatusCode::FORBIDDEN,
|
||||||
.await
|
Json(json!({ "error": { "code": "forbidden", "message": "Write access required" } })),
|
||||||
.map_err(|e| {
|
));
|
||||||
tracing::error!(error = %e, "RBAC check failed");
|
|
||||||
(
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
Json(json!({ "error": { "code": "internal_error", "message": "Database error" } })),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
if !can_access {
|
|
||||||
return Err((
|
|
||||||
StatusCode::FORBIDDEN,
|
|
||||||
Json(
|
|
||||||
json!({ "error": { "code": "forbidden", "message": "Not authorized for this host" } }),
|
|
||||||
),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate check_type
|
// Validate check_type
|
||||||
@ -426,8 +412,8 @@ async fn get_health_check(
|
|||||||
auth: AuthUser,
|
auth: AuthUser,
|
||||||
Path((host_id, check_id)): Path<(Uuid, Uuid)>,
|
Path((host_id, check_id)): Path<(Uuid, Uuid)>,
|
||||||
) -> Result<Json<HealthCheckWithResult>, (StatusCode, Json<Value>)> {
|
) -> Result<Json<HealthCheckWithResult>, (StatusCode, Json<Value>)> {
|
||||||
// RBAC check for operators
|
// RBAC: reporters can only see hosts in their groups
|
||||||
if !auth.role.is_admin() {
|
if !auth.role.can_write() {
|
||||||
let can_access = operator_can_access_host(&state.db, auth.user_id, host_id)
|
let can_access = operator_can_access_host(&state.db, auth.user_id, host_id)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
@ -506,25 +492,11 @@ async fn update_health_check(
|
|||||||
Path((host_id, check_id)): Path<(Uuid, Uuid)>,
|
Path((host_id, check_id)): Path<(Uuid, Uuid)>,
|
||||||
Json(req): Json<UpdateHealthCheckRequest>,
|
Json(req): Json<UpdateHealthCheckRequest>,
|
||||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||||
// RBAC check for operators
|
if !auth.role.can_write() {
|
||||||
if !auth.role.is_admin() {
|
return Err((
|
||||||
let can_access = operator_can_access_host(&state.db, auth.user_id, host_id)
|
StatusCode::FORBIDDEN,
|
||||||
.await
|
Json(json!({ "error": { "code": "forbidden", "message": "Write access required" } })),
|
||||||
.map_err(|e| {
|
));
|
||||||
tracing::error!(error = %e, "RBAC check failed");
|
|
||||||
(
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
Json(json!({ "error": { "code": "internal_error", "message": "Database error" } })),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
if !can_access {
|
|
||||||
return Err((
|
|
||||||
StatusCode::FORBIDDEN,
|
|
||||||
Json(
|
|
||||||
json!({ "error": { "code": "forbidden", "message": "Not authorized for this host" } }),
|
|
||||||
),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify check exists and belongs to host
|
// Verify check exists and belongs to host
|
||||||
@ -746,25 +718,11 @@ async fn delete_health_check(
|
|||||||
auth: AuthUser,
|
auth: AuthUser,
|
||||||
Path((host_id, check_id)): Path<(Uuid, Uuid)>,
|
Path((host_id, check_id)): Path<(Uuid, Uuid)>,
|
||||||
) -> Result<StatusCode, (StatusCode, Json<Value>)> {
|
) -> Result<StatusCode, (StatusCode, Json<Value>)> {
|
||||||
// RBAC check for operators
|
if !auth.role.can_write() {
|
||||||
if !auth.role.is_admin() {
|
return Err((
|
||||||
let can_access = operator_can_access_host(&state.db, auth.user_id, host_id)
|
StatusCode::FORBIDDEN,
|
||||||
.await
|
Json(json!({ "error": { "code": "forbidden", "message": "Write access required" } })),
|
||||||
.map_err(|e| {
|
));
|
||||||
tracing::error!(error = %e, "RBAC check failed");
|
|
||||||
(
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
Json(json!({ "error": { "code": "internal_error", "message": "Database error" } })),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
if !can_access {
|
|
||||||
return Err((
|
|
||||||
StatusCode::FORBIDDEN,
|
|
||||||
Json(
|
|
||||||
json!({ "error": { "code": "forbidden", "message": "Not authorized for this host" } }),
|
|
||||||
),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let deleted = sqlx::query("DELETE FROM host_health_checks WHERE id = $1 AND host_id = $2")
|
let deleted = sqlx::query("DELETE FROM host_health_checks WHERE id = $1 AND host_id = $2")
|
||||||
@ -811,25 +769,11 @@ async fn test_health_check(
|
|||||||
auth: AuthUser,
|
auth: AuthUser,
|
||||||
Path((host_id, check_id)): Path<(Uuid, Uuid)>,
|
Path((host_id, check_id)): Path<(Uuid, Uuid)>,
|
||||||
) -> Result<Json<HealthCheckTestResponse>, (StatusCode, Json<Value>)> {
|
) -> Result<Json<HealthCheckTestResponse>, (StatusCode, Json<Value>)> {
|
||||||
// RBAC check for operators
|
if !auth.role.can_write() {
|
||||||
if !auth.role.is_admin() {
|
return Err((
|
||||||
let can_access = operator_can_access_host(&state.db, auth.user_id, host_id)
|
StatusCode::FORBIDDEN,
|
||||||
.await
|
Json(json!({ "error": { "code": "forbidden", "message": "Write access required" } })),
|
||||||
.map_err(|e| {
|
));
|
||||||
tracing::error!(error = %e, "RBAC check failed");
|
|
||||||
(
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
Json(json!({ "error": { "code": "internal_error", "message": "Database error" } })),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
if !can_access {
|
|
||||||
return Err((
|
|
||||||
StatusCode::FORBIDDEN,
|
|
||||||
Json(
|
|
||||||
json!({ "error": { "code": "forbidden", "message": "Not authorized for this host" } }),
|
|
||||||
),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the health check
|
// Get the health check
|
||||||
|
|||||||
@ -7,7 +7,7 @@
|
|||||||
//! GET /api/v1/hosts/{id}/groups — list groups for host
|
//! GET /api/v1/hosts/{id}/groups — list groups for host
|
||||||
//! POST /api/v1/hosts/{id}/groups — assign host to group
|
//! POST /api/v1/hosts/{id}/groups — assign host to group
|
||||||
//! DELETE /api/v1/hosts/{id}/groups/{group_id} — remove host from group
|
//! DELETE /api/v1/hosts/{id}/groups/{group_id} — remove host from group
|
||||||
//! POST /api/v1/hosts/{id}/refresh — queue on-demand refresh (operator+)
|
//! POST /api/v1/hosts/{id}/refresh — queue on-demand refresh (write access)
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, Query, State},
|
extract::{Path, Query, State},
|
||||||
@ -214,10 +214,10 @@ async fn register_host(
|
|||||||
Json(req): Json<CreateHostRequest>,
|
Json(req): Json<CreateHostRequest>,
|
||||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||||
// Admin only
|
// Admin only
|
||||||
if !auth.role.is_admin() {
|
if !auth.role.can_write() {
|
||||||
return Err((
|
return Err((
|
||||||
StatusCode::FORBIDDEN,
|
StatusCode::FORBIDDEN,
|
||||||
Json(json!({ "error": { "code": "forbidden", "message": "Admin role required" } })),
|
Json(json!({ "error": { "code": "forbidden", "message": "Write access required" } })),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -348,10 +348,10 @@ async fn remove_host(
|
|||||||
auth: AuthUser,
|
auth: AuthUser,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||||
if !auth.role.is_admin() {
|
if !auth.role.can_write() {
|
||||||
return Err((
|
return Err((
|
||||||
StatusCode::FORBIDDEN,
|
StatusCode::FORBIDDEN,
|
||||||
Json(json!({ "error": { "code": "forbidden", "message": "Admin role required" } })),
|
Json(json!({ "error": { "code": "forbidden", "message": "Write access required" } })),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -451,10 +451,10 @@ async fn add_host_to_group(
|
|||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
Json(req): Json<AddToGroupRequest>,
|
Json(req): Json<AddToGroupRequest>,
|
||||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||||
if !auth.role.is_admin() {
|
if !auth.role.can_write() {
|
||||||
return Err((
|
return Err((
|
||||||
StatusCode::FORBIDDEN,
|
StatusCode::FORBIDDEN,
|
||||||
Json(json!({ "error": { "code": "forbidden", "message": "Admin role required" } })),
|
Json(json!({ "error": { "code": "forbidden", "message": "Write access required" } })),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -496,10 +496,10 @@ async fn remove_host_from_group(
|
|||||||
auth: AuthUser,
|
auth: AuthUser,
|
||||||
Path((id, group_id)): Path<(Uuid, Uuid)>,
|
Path((id, group_id)): Path<(Uuid, Uuid)>,
|
||||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||||
if !auth.role.is_admin() {
|
if !auth.role.can_write() {
|
||||||
return Err((
|
return Err((
|
||||||
StatusCode::FORBIDDEN,
|
StatusCode::FORBIDDEN,
|
||||||
Json(json!({ "error": { "code": "forbidden", "message": "Admin role required" } })),
|
Json(json!({ "error": { "code": "forbidden", "message": "Write access required" } })),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -562,9 +562,15 @@ async fn resolve_fqdn(fqdn: &str) -> Result<String, String> {
|
|||||||
/// Requires Operator or Admin role (any authenticated user).
|
/// Requires Operator or Admin role (any authenticated user).
|
||||||
async fn refresh_host(
|
async fn refresh_host(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
_auth: AuthUser,
|
auth: AuthUser,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
) -> Result<(StatusCode, Json<Value>), (StatusCode, Json<Value>)> {
|
) -> Result<(StatusCode, Json<Value>), (StatusCode, Json<Value>)> {
|
||||||
|
if !auth.role.can_write() {
|
||||||
|
return Err((
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
Json(json!({ "error": { "code": "forbidden", "message": "Write access required" } })),
|
||||||
|
));
|
||||||
|
}
|
||||||
// Verify the host exists.
|
// Verify the host exists.
|
||||||
let exists: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM hosts WHERE id = $1)")
|
let exists: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM hosts WHERE id = $1)")
|
||||||
.bind(id)
|
.bind(id)
|
||||||
|
|||||||
@ -116,6 +116,13 @@ async fn create_job(
|
|||||||
auth: AuthUser,
|
auth: AuthUser,
|
||||||
Json(req): Json<CreateJobRequest>,
|
Json(req): Json<CreateJobRequest>,
|
||||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||||
|
if !auth.role.can_write() {
|
||||||
|
return Err(err(
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"forbidden",
|
||||||
|
"Write access required",
|
||||||
|
));
|
||||||
|
}
|
||||||
if req.host_ids.is_empty() {
|
if req.host_ids.is_empty() {
|
||||||
return Err(err(
|
return Err(err(
|
||||||
StatusCode::BAD_REQUEST,
|
StatusCode::BAD_REQUEST,
|
||||||
@ -430,13 +437,13 @@ async fn cancel_job(
|
|||||||
row.ok_or_else(|| err(StatusCode::NOT_FOUND, "not_found", "Job not found"))?;
|
row.ok_or_else(|| err(StatusCode::NOT_FOUND, "not_found", "Job not found"))?;
|
||||||
|
|
||||||
// Only admin or the job creator may cancel.
|
// Only admin or the job creator may cancel.
|
||||||
if !auth.role.is_admin() {
|
if !auth.role.can_write() {
|
||||||
let is_creator = creator_id.map_or(false, |cid| cid == auth.user_id);
|
let is_creator = creator_id.map_or(false, |cid| cid == auth.user_id);
|
||||||
if !is_creator {
|
if !is_creator {
|
||||||
return Err(err(
|
return Err(err(
|
||||||
StatusCode::FORBIDDEN,
|
StatusCode::FORBIDDEN,
|
||||||
"forbidden",
|
"forbidden",
|
||||||
"Only admin or the job creator may cancel this job",
|
"Write access required",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -535,11 +542,11 @@ async fn rollback_job(
|
|||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||||
// Admin-only operation.
|
// Admin-only operation.
|
||||||
if !auth.role.is_admin() {
|
if !auth.role.can_write() {
|
||||||
return Err(err(
|
return Err(err(
|
||||||
StatusCode::FORBIDDEN,
|
StatusCode::FORBIDDEN,
|
||||||
"forbidden",
|
"forbidden",
|
||||||
"Admin role required to create rollback jobs",
|
"Write access required",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -103,6 +103,13 @@ async fn create_window(
|
|||||||
Path(host_id): Path<Uuid>,
|
Path(host_id): Path<Uuid>,
|
||||||
Json(req): Json<CreateMaintenanceWindowRequest>,
|
Json(req): Json<CreateMaintenanceWindowRequest>,
|
||||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||||
|
if !auth.role.can_write() {
|
||||||
|
return Err(err(
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"forbidden",
|
||||||
|
"Write access required",
|
||||||
|
));
|
||||||
|
}
|
||||||
// Validate: weekly requires recurrence_day 0-6
|
// Validate: weekly requires recurrence_day 0-6
|
||||||
if req.recurrence == pm_core::models::WindowRecurrence::Weekly {
|
if req.recurrence == pm_core::models::WindowRecurrence::Weekly {
|
||||||
match req.recurrence_day {
|
match req.recurrence_day {
|
||||||
@ -218,6 +225,13 @@ async fn update_window(
|
|||||||
Path((host_id, win_id)): Path<(Uuid, Uuid)>,
|
Path((host_id, win_id)): Path<(Uuid, Uuid)>,
|
||||||
Json(req): Json<UpdateMaintenanceWindowRequest>,
|
Json(req): Json<UpdateMaintenanceWindowRequest>,
|
||||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||||
|
if !auth.role.can_write() {
|
||||||
|
return Err(err(
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"forbidden",
|
||||||
|
"Write access required",
|
||||||
|
));
|
||||||
|
}
|
||||||
// Fetch existing record (verify ownership and existence).
|
// Fetch existing record (verify ownership and existence).
|
||||||
let existing: Option<MaintenanceWindow> = sqlx::query_as(
|
let existing: Option<MaintenanceWindow> = sqlx::query_as(
|
||||||
r#"
|
r#"
|
||||||
@ -349,6 +363,13 @@ async fn delete_window(
|
|||||||
auth: AuthUser,
|
auth: AuthUser,
|
||||||
Path((host_id, win_id)): Path<(Uuid, Uuid)>,
|
Path((host_id, win_id)): Path<(Uuid, Uuid)>,
|
||||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||||
|
if !auth.role.can_write() {
|
||||||
|
return Err(err(
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"forbidden",
|
||||||
|
"Write access required",
|
||||||
|
));
|
||||||
|
}
|
||||||
let result = sqlx::query("DELETE FROM maintenance_windows WHERE id = $1 AND host_id = $2")
|
let result = sqlx::query("DELETE FROM maintenance_windows WHERE id = $1 AND host_id = $2")
|
||||||
.bind(win_id)
|
.bind(win_id)
|
||||||
.bind(host_id)
|
.bind(host_id)
|
||||||
|
|||||||
@ -169,11 +169,11 @@ pub fn router() -> Router<AppState> {
|
|||||||
|
|
||||||
const MASKED: &str = "********";
|
const MASKED: &str = "********";
|
||||||
|
|
||||||
fn admin_only(auth: &AuthUser) -> Result<(), (StatusCode, Json<Value>)> {
|
fn write_access_required(auth: &AuthUser) -> Result<(), (StatusCode, Json<Value>)> {
|
||||||
if !auth.role.is_admin() {
|
if !auth.role.can_write() {
|
||||||
return Err((
|
return Err((
|
||||||
StatusCode::FORBIDDEN,
|
StatusCode::FORBIDDEN,
|
||||||
Json(json!({ "error": { "code": "forbidden", "message": "Admin access required" } })),
|
Json(json!({ "error": { "code": "forbidden", "message": "Write access required" } })),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
@ -311,7 +311,7 @@ async fn get_settings(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
auth: AuthUser,
|
auth: AuthUser,
|
||||||
) -> Result<Json<SettingsResponse>, (StatusCode, Json<Value>)> {
|
) -> Result<Json<SettingsResponse>, (StatusCode, Json<Value>)> {
|
||||||
admin_only(&auth)?;
|
write_access_required(&auth)?;
|
||||||
let cfg = load_system_config(&state.db).await?;
|
let cfg = load_system_config(&state.db).await?;
|
||||||
// Inject read-only config values from TOML file (not stored in DB)
|
// Inject read-only config values from TOML file (not stored in DB)
|
||||||
let mut cfg = cfg;
|
let mut cfg = cfg;
|
||||||
@ -332,7 +332,7 @@ async fn update_settings(
|
|||||||
auth: AuthUser,
|
auth: AuthUser,
|
||||||
Json(req): Json<UpdateSettingsRequest>,
|
Json(req): Json<UpdateSettingsRequest>,
|
||||||
) -> Result<Json<SettingsResponse>, (StatusCode, Json<Value>)> {
|
) -> Result<Json<SettingsResponse>, (StatusCode, Json<Value>)> {
|
||||||
admin_only(&auth)?;
|
write_access_required(&auth)?;
|
||||||
|
|
||||||
// Update OIDC config
|
// Update OIDC config
|
||||||
if let Some(oidc) = req.oidc {
|
if let Some(oidc) = req.oidc {
|
||||||
@ -562,7 +562,7 @@ async fn discover_oidc(
|
|||||||
auth: AuthUser,
|
auth: AuthUser,
|
||||||
Json(req): Json<OidcDiscoveryRequest>,
|
Json(req): Json<OidcDiscoveryRequest>,
|
||||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||||
admin_only(&auth)?;
|
write_access_required(&auth)?;
|
||||||
|
|
||||||
if req.discovery_url.is_empty() {
|
if req.discovery_url.is_empty() {
|
||||||
return Err((
|
return Err((
|
||||||
@ -619,7 +619,7 @@ async fn test_oidc(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
auth: AuthUser,
|
auth: AuthUser,
|
||||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||||
admin_only(&auth)?;
|
write_access_required(&auth)?;
|
||||||
|
|
||||||
let row: Option<(bool, String, String)> = sqlx::query_as(
|
let row: Option<(bool, String, String)> = sqlx::query_as(
|
||||||
"SELECT enabled, provider_type, discovery_url FROM oidc_config WHERE id = 1",
|
"SELECT enabled, provider_type, discovery_url FROM oidc_config WHERE id = 1",
|
||||||
@ -715,7 +715,7 @@ async fn test_smtp(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
auth: AuthUser,
|
auth: AuthUser,
|
||||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||||
admin_only(&auth)?;
|
write_access_required(&auth)?;
|
||||||
|
|
||||||
let cfg = load_system_config(&state.db).await?;
|
let cfg = load_system_config(&state.db).await?;
|
||||||
|
|
||||||
@ -870,7 +870,7 @@ async fn get_ip_whitelist(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
auth: AuthUser,
|
auth: AuthUser,
|
||||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||||
admin_only(&auth)?;
|
write_access_required(&auth)?;
|
||||||
|
|
||||||
let value: Option<String> = sqlx::query_scalar(
|
let value: Option<String> = sqlx::query_scalar(
|
||||||
"SELECT value FROM system_config WHERE key = 'ip_whitelist'",
|
"SELECT value FROM system_config WHERE key = 'ip_whitelist'",
|
||||||
@ -898,7 +898,7 @@ async fn update_ip_whitelist(
|
|||||||
auth: AuthUser,
|
auth: AuthUser,
|
||||||
Json(req): Json<IpWhitelistUpdate>,
|
Json(req): Json<IpWhitelistUpdate>,
|
||||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||||
admin_only(&auth)?;
|
write_access_required(&auth)?;
|
||||||
|
|
||||||
// Validate each entry
|
// Validate each entry
|
||||||
for entry in &req.entries {
|
for entry in &req.entries {
|
||||||
@ -943,7 +943,7 @@ async fn audit_integrity(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
auth: AuthUser,
|
auth: AuthUser,
|
||||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||||
admin_only(&auth)?;
|
write_access_required(&auth)?;
|
||||||
|
|
||||||
let result = verify_integrity(&state.db).await;
|
let result = verify_integrity(&state.db).await;
|
||||||
|
|
||||||
|
|||||||
@ -504,9 +504,9 @@ async fn sso_callback(
|
|||||||
None => {
|
None => {
|
||||||
// No existing user - create new one
|
// No existing user - create new one
|
||||||
let id: Uuid = match sqlx::query_scalar(
|
let id: Uuid = match sqlx::query_scalar(
|
||||||
r#"INSERT INTO users (username, display_name, email, role, auth_provider, azure_oid, oidc_sub)
|
r#"INSERT INTO users (username, display_name, email, role, auth_provider, azure_oid, oidc_sub)
|
||||||
VALUES ($1, $2, $3, 'operator'::user_role, $4::auth_provider, $5, $6)
|
VALUES ($1, $2, $3, 'reporter'::user_role, $4::auth_provider, $5, $6)
|
||||||
RETURNING id"#,
|
RETURNING id"#,
|
||||||
)
|
)
|
||||||
.bind(&preferred_username)
|
.bind(&preferred_username)
|
||||||
.bind(&name)
|
.bind(&name)
|
||||||
@ -541,7 +541,7 @@ async fn sso_callback(
|
|||||||
id,
|
id,
|
||||||
username: preferred_username,
|
username: preferred_username,
|
||||||
display_name: name,
|
display_name: name,
|
||||||
role: "operator".to_string(),
|
role: "reporter".to_string(),
|
||||||
is_active: true,
|
is_active: true,
|
||||||
mfa_enabled: false,
|
mfa_enabled: false,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -29,6 +29,7 @@ interface NavItem {
|
|||||||
path: string
|
path: string
|
||||||
icon: React.ReactElement
|
icon: React.ReactElement
|
||||||
adminOnly?: boolean
|
adminOnly?: boolean
|
||||||
|
writeOnly?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const navGroups: { heading: string; items: NavItem[] }[] = [
|
const navGroups: { heading: string; items: NavItem[] }[] = [
|
||||||
@ -43,23 +44,23 @@ const navGroups: { heading: string; items: NavItem[] }[] = [
|
|||||||
items: [
|
items: [
|
||||||
{ label: 'Hosts', path: '/hosts', icon: <HostsIcon /> },
|
{ label: 'Hosts', path: '/hosts', icon: <HostsIcon /> },
|
||||||
{ label: 'Groups', path: '/groups', icon: <GroupsIcon /> },
|
{ label: 'Groups', path: '/groups', icon: <GroupsIcon /> },
|
||||||
{ label: 'Deploy', path: '/deployment', icon: <DeployIcon /> },
|
{ label: 'Deploy', path: '/deployment', icon: <DeployIcon />, writeOnly: true },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
heading: 'Operations',
|
heading: 'Operations',
|
||||||
items: [
|
items: [
|
||||||
{ label: 'Jobs', path: '/jobs', icon: <JobsIcon /> },
|
{ label: 'Jobs', path: '/jobs', icon: <JobsIcon /> },
|
||||||
{ label: 'Maintenance', path: '/maintenance', icon: <MaintenanceIcon /> },
|
{ label: 'Maintenance', path: '/maintenance', icon: <MaintenanceIcon />, writeOnly: true },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
heading: 'Administration',
|
heading: 'Administration',
|
||||||
items: [
|
items: [
|
||||||
{ label: 'Users', path: '/users', icon: <UsersIcon />, adminOnly: true },
|
{ label: 'Users', path: '/users', icon: <UsersIcon />, adminOnly: true },
|
||||||
{ label: 'Certificates', path: '/certificates', icon: <CertsIcon />, adminOnly: true },
|
{ label: 'Certificates', path: '/certificates', icon: <CertsIcon /> },
|
||||||
{ label: 'Reports', path: '/reports', icon: <ReportsIcon /> },
|
{ label: 'Reports', path: '/reports', icon: <ReportsIcon /> },
|
||||||
{ label: 'Settings', path: '/settings', icon: <SettingsIcon />, adminOnly: true },
|
{ label: 'Settings', path: '/settings', icon: <SettingsIcon /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@ -72,7 +73,7 @@ export default function AppLayout() {
|
|||||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null)
|
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null)
|
||||||
|
|
||||||
const isAdmin = user?.role === 'admin'
|
const isAdmin = user?.role === 'admin'
|
||||||
|
const canWrite = user?.role === 'admin' || user?.role === 'operator'
|
||||||
const handleDrawerToggle = () => setMobileOpen(!mobileOpen)
|
const handleDrawerToggle = () => setMobileOpen(!mobileOpen)
|
||||||
const handleMenuOpen = (e: React.MouseEvent<HTMLElement>) => setAnchorEl(e.currentTarget)
|
const handleMenuOpen = (e: React.MouseEvent<HTMLElement>) => setAnchorEl(e.currentTarget)
|
||||||
const handleMenuClose = () => setAnchorEl(null)
|
const handleMenuClose = () => setAnchorEl(null)
|
||||||
@ -97,7 +98,11 @@ export default function AppLayout() {
|
|||||||
<Divider />
|
<Divider />
|
||||||
<Box sx={{ flex: 1, overflowY: 'auto', py: 1 }}>
|
<Box sx={{ flex: 1, overflowY: 'auto', py: 1 }}>
|
||||||
{navGroups.map((group) => {
|
{navGroups.map((group) => {
|
||||||
const visibleItems = group.items.filter((item) => !item.adminOnly || isAdmin)
|
const visibleItems = group.items.filter((item) => {
|
||||||
|
if (item.adminOnly && !isAdmin) return false
|
||||||
|
if (item.writeOnly && !canWrite) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
if (visibleItems.length === 0) return null
|
if (visibleItems.length === 0) return null
|
||||||
return (
|
return (
|
||||||
<Box key={group.heading} sx={{ mb: 1 }}>
|
<Box key={group.heading} sx={{ mb: 1 }}>
|
||||||
|
|||||||
@ -286,7 +286,7 @@ function KeyDisplayDialog({ open, cert, hostname, onClose }: KeyDisplayDialogPro
|
|||||||
|
|
||||||
export default function CertificatesPage() {
|
export default function CertificatesPage() {
|
||||||
const user = useAuthStore((s) => s.user)
|
const user = useAuthStore((s) => s.user)
|
||||||
const isAdmin = user?.role === 'admin'
|
const canWrite = user?.role === 'admin' || user?.role === 'operator'
|
||||||
|
|
||||||
const [certs, setCerts] = useState<Certificate[]>([])
|
const [certs, setCerts] = useState<Certificate[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
@ -378,7 +378,7 @@ export default function CertificatesPage() {
|
|||||||
<Typography variant="h5" fontWeight={700} sx={{ flexGrow: 1 }}>
|
<Typography variant="h5" fontWeight={700} sx={{ flexGrow: 1 }}>
|
||||||
Certificate Management
|
Certificate Management
|
||||||
</Typography>
|
</Typography>
|
||||||
{isAdmin && (
|
{canWrite && (
|
||||||
<Button
|
<Button
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
startIcon={<SecurityIcon />}
|
startIcon={<SecurityIcon />}
|
||||||
@ -496,7 +496,7 @@ export default function CertificatesPage() {
|
|||||||
</Typography>
|
</Typography>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell align="right">
|
<TableCell align="right">
|
||||||
{isAdmin && (
|
{canWrite && (
|
||||||
<>
|
<>
|
||||||
<Tooltip title="Renew certificate">
|
<Tooltip title="Renew certificate">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@ -6,9 +6,12 @@ import {
|
|||||||
} from '@mui/material'
|
} from '@mui/material'
|
||||||
import { Add as AddIcon, Delete as DeleteIcon } from '@mui/icons-material'
|
import { Add as AddIcon, Delete as DeleteIcon } from '@mui/icons-material'
|
||||||
import { apiClient } from '../api/client'
|
import { apiClient } from '../api/client'
|
||||||
|
import { useAuthStore } from '../store/authStore'
|
||||||
import type { Group } from '../types'
|
import type { Group } from '../types'
|
||||||
|
|
||||||
export default function GroupsPage() {
|
export default function GroupsPage() {
|
||||||
|
const user = useAuthStore(state => state.user)
|
||||||
|
const canWrite = user?.role === 'admin' || user?.role === 'operator'
|
||||||
const [groups, setGroups] = useState<Group[]>([])
|
const [groups, setGroups] = useState<Group[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
@ -39,13 +42,13 @@ export default function GroupsPage() {
|
|||||||
<Container maxWidth="lg" sx={{ mt: 3 }}>
|
<Container maxWidth="lg" sx={{ mt: 3 }}>
|
||||||
<Toolbar disableGutters sx={{ mb: 2 }}>
|
<Toolbar disableGutters sx={{ mb: 2 }}>
|
||||||
<Typography variant="h5" fontWeight={700} sx={{ flexGrow: 1 }}>Groups</Typography>
|
<Typography variant="h5" fontWeight={700} sx={{ flexGrow: 1 }}>Groups</Typography>
|
||||||
<Button variant="contained" startIcon={<AddIcon />} onClick={() => setOpen(true)}>Create Group</Button>
|
{canWrite && <Button variant="contained" startIcon={<AddIcon />} onClick={() => setOpen(true)}>Create Group</Button>}
|
||||||
</Toolbar>
|
</Toolbar>
|
||||||
{loading ? <Box display="flex" justifyContent="center" mt={4}><CircularProgress /></Box> : (
|
{loading ? <Box display="flex" justifyContent="center" mt={4}><CircularProgress /></Box> : (
|
||||||
<TableContainer component={Paper}>
|
<TableContainer component={Paper}>
|
||||||
<Table size="small">
|
<Table size="small">
|
||||||
<TableHead><TableRow>
|
<TableHead><TableRow>
|
||||||
<TableCell>Name</TableCell><TableCell>Description</TableCell><TableCell>Created</TableCell><TableCell>Actions</TableCell>
|
<TableCell>Name</TableCell><TableCell>Description</TableCell><TableCell>Created</TableCell>{canWrite && <TableCell>Actions</TableCell>}
|
||||||
</TableRow></TableHead>
|
</TableRow></TableHead>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{groups.map(g => (
|
{groups.map(g => (
|
||||||
@ -53,9 +56,9 @@ export default function GroupsPage() {
|
|||||||
<TableCell sx={{ fontWeight: 600 }}>{g.name}</TableCell>
|
<TableCell sx={{ fontWeight: 600 }}>{g.name}</TableCell>
|
||||||
<TableCell>{g.description || '—'}</TableCell>
|
<TableCell>{g.description || '—'}</TableCell>
|
||||||
<TableCell>{new Date(g.created_at).toLocaleDateString()}</TableCell>
|
<TableCell>{new Date(g.created_at).toLocaleDateString()}</TableCell>
|
||||||
<TableCell>
|
{canWrite && <TableCell>
|
||||||
<Tooltip title="Delete"><IconButton size="small" color="error" onClick={() => handleDelete(g.id)}><DeleteIcon fontSize="small" /></IconButton></Tooltip>
|
<Tooltip title="Delete"><IconButton size="small" color="error" onClick={() => handleDelete(g.id)}><DeleteIcon fontSize="small" /></IconButton></Tooltip>
|
||||||
</TableCell>
|
</TableCell>}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
|
|||||||
@ -48,6 +48,7 @@ import {
|
|||||||
ContentCopy as CopyIcon,
|
ContentCopy as CopyIcon,
|
||||||
} from '@mui/icons-material'
|
} from '@mui/icons-material'
|
||||||
import { apiClient, hostsApi, maintenanceWindowsApi, healthChecksApi, certsApi } from '../api/client'
|
import { apiClient, hostsApi, maintenanceWindowsApi, healthChecksApi, certsApi } from '../api/client'
|
||||||
|
import { useAuthStore } from '../store/authStore'
|
||||||
import type {
|
import type {
|
||||||
CreateHostRequest,
|
CreateHostRequest,
|
||||||
IssuedCert,
|
IssuedCert,
|
||||||
@ -552,6 +553,8 @@ function CreateHostForm() {
|
|||||||
export default function HostDetailPage() {
|
export default function HostDetailPage() {
|
||||||
const { id } = useParams<{ id: string }>()
|
const { id } = useParams<{ id: string }>()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const user = useAuthStore(state => state.user)
|
||||||
|
const canWrite = user?.role === 'admin' || user?.role === 'operator'
|
||||||
const [host, setHost] = useState<Record<string, unknown> | null>(null)
|
const [host, setHost] = useState<Record<string, unknown> | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
@ -896,7 +899,7 @@ export default function HostDetailPage() {
|
|||||||
{String(host?.fqdn ?? '')}
|
{String(host?.fqdn ?? '')}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
{!certExists && (
|
{canWrite && !certExists && (
|
||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
size="small"
|
size="small"
|
||||||
@ -906,7 +909,7 @@ export default function HostDetailPage() {
|
|||||||
Issue Certificate
|
Issue Certificate
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{certExists && (
|
{canWrite && certExists && (
|
||||||
<Button
|
<Button
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
size="small"
|
size="small"
|
||||||
@ -942,14 +945,14 @@ export default function HostDetailPage() {
|
|||||||
<ScheduleIcon color="primary" />
|
<ScheduleIcon color="primary" />
|
||||||
<Typography variant="h6" fontWeight={600}>Maintenance Windows</Typography>
|
<Typography variant="h6" fontWeight={600}>Maintenance Windows</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Button
|
{canWrite && <Button
|
||||||
startIcon={<AddIcon />}
|
startIcon={<AddIcon />}
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
size="small"
|
size="small"
|
||||||
onClick={() => { setCreateForm(defaultForm()); setCreateOpen(true) }}
|
onClick={() => { setCreateForm(defaultForm()); setCreateOpen(true) }}
|
||||||
>
|
>
|
||||||
Add Window
|
Add Window
|
||||||
</Button>
|
</Button>}
|
||||||
</Box>
|
</Box>
|
||||||
<Divider sx={{ mb: 2 }} />
|
<Divider sx={{ mb: 2 }} />
|
||||||
|
|
||||||
@ -971,7 +974,7 @@ export default function HostDetailPage() {
|
|||||||
<TableCell>Schedule</TableCell>
|
<TableCell>Schedule</TableCell>
|
||||||
<TableCell>Recurrence</TableCell>
|
<TableCell>Recurrence</TableCell>
|
||||||
<TableCell>Status</TableCell>
|
<TableCell>Status</TableCell>
|
||||||
<TableCell align="right">Actions</TableCell>
|
{canWrite && <TableCell align="right">Actions</TableCell>}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@ -991,7 +994,7 @@ export default function HostDetailPage() {
|
|||||||
size="small"
|
size="small"
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell align="right">
|
{canWrite && <TableCell align="right">
|
||||||
<Tooltip title="Edit">
|
<Tooltip title="Edit">
|
||||||
<IconButton size="small" onClick={() => handleEditClick(w)}>
|
<IconButton size="small" onClick={() => handleEditClick(w)}>
|
||||||
<EditIcon fontSize="small" />
|
<EditIcon fontSize="small" />
|
||||||
@ -1005,7 +1008,7 @@ export default function HostDetailPage() {
|
|||||||
<DeleteIcon fontSize="small" />
|
<DeleteIcon fontSize="small" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TableCell>
|
</TableCell>}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
@ -1020,7 +1023,7 @@ export default function HostDetailPage() {
|
|||||||
<MonitorHeartIcon color="primary" />
|
<MonitorHeartIcon color="primary" />
|
||||||
<Typography variant="h6" fontWeight={600}>Health Checks</Typography>
|
<Typography variant="h6" fontWeight={600}>Health Checks</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Button
|
{canWrite && <Button
|
||||||
startIcon={<AddIcon />}
|
startIcon={<AddIcon />}
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
size="small"
|
size="small"
|
||||||
@ -1028,7 +1031,7 @@ export default function HostDetailPage() {
|
|||||||
onClick={() => { setHcCreateForm(defaultHealthCheckForm()); setHcCreateOpen(true) }}
|
onClick={() => { setHcCreateForm(defaultHealthCheckForm()); setHcCreateOpen(true) }}
|
||||||
>
|
>
|
||||||
Add Health Check
|
Add Health Check
|
||||||
</Button>
|
</Button>}
|
||||||
</Box>
|
</Box>
|
||||||
<Divider sx={{ mb: 2 }} />
|
<Divider sx={{ mb: 2 }} />
|
||||||
|
|
||||||
@ -1054,7 +1057,7 @@ export default function HostDetailPage() {
|
|||||||
<TableCell>Detail</TableCell>
|
<TableCell>Detail</TableCell>
|
||||||
<TableCell>Latency</TableCell>
|
<TableCell>Latency</TableCell>
|
||||||
<TableCell>Last Checked</TableCell>
|
<TableCell>Last Checked</TableCell>
|
||||||
<TableCell align="right">Actions</TableCell>
|
{canWrite && <TableCell align="right">Actions</TableCell>}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@ -1092,7 +1095,8 @@ export default function HostDetailPage() {
|
|||||||
<Switch
|
<Switch
|
||||||
size="small"
|
size="small"
|
||||||
checked={check.enabled}
|
checked={check.enabled}
|
||||||
onChange={() => handleToggleEnabled(check)}
|
onChange={canWrite ? () => handleToggleEnabled(check) : undefined}
|
||||||
|
disabled={!canWrite}
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
@ -1108,7 +1112,7 @@ export default function HostDetailPage() {
|
|||||||
? new Date(check.last_result.checked_at).toLocaleString()
|
? new Date(check.last_result.checked_at).toLocaleString()
|
||||||
: '—'}
|
: '—'}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell align="right">
|
{canWrite && <TableCell align="right">
|
||||||
<Tooltip title="Test now">
|
<Tooltip title="Test now">
|
||||||
<IconButton
|
<IconButton
|
||||||
size="small"
|
size="small"
|
||||||
@ -1134,7 +1138,7 @@ export default function HostDetailPage() {
|
|||||||
<DeleteIcon fontSize="small" />
|
<DeleteIcon fontSize="small" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TableCell>
|
</TableCell>}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import {
|
|||||||
import { Add as AddIcon, Refresh as RefreshIcon, Delete as DeleteIcon, CheckCircle as CheckCircleIcon, Cancel as CancelIcon, Remove as RemoveIcon } from '@mui/icons-material'
|
import { Add as AddIcon, Refresh as RefreshIcon, Delete as DeleteIcon, CheckCircle as CheckCircleIcon, Cancel as CancelIcon, Remove as RemoveIcon } from '@mui/icons-material'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { apiClient, hostsApi } from '../api/client'
|
import { apiClient, hostsApi } from '../api/client'
|
||||||
|
import { useAuthStore } from '../store/authStore'
|
||||||
import type { Host, HostHealthStatus } from '../types'
|
import type { Host, HostHealthStatus } from '../types'
|
||||||
|
|
||||||
const statusColor = (s: HostHealthStatus) =>
|
const statusColor = (s: HostHealthStatus) =>
|
||||||
@ -14,6 +15,8 @@ const statusColor = (s: HostHealthStatus) =>
|
|||||||
|
|
||||||
export default function HostsPage() {
|
export default function HostsPage() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const user = useAuthStore(state => state.user)
|
||||||
|
const canWrite = user?.role === 'admin' || user?.role === 'operator'
|
||||||
const [hosts, setHosts] = useState<Host[]>([])
|
const [hosts, setHosts] = useState<Host[]>([])
|
||||||
const [total, setTotal] = useState(0)
|
const [total, setTotal] = useState(0)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
@ -55,7 +58,7 @@ export default function HostsPage() {
|
|||||||
<TextField size="small" placeholder="Search..." value={search}
|
<TextField size="small" placeholder="Search..." value={search}
|
||||||
onChange={e => setSearch(e.target.value)} sx={{ mr: 2 }} />
|
onChange={e => setSearch(e.target.value)} sx={{ mr: 2 }} />
|
||||||
<Tooltip title="Refresh"><IconButton onClick={load}><RefreshIcon /></IconButton></Tooltip>
|
<Tooltip title="Refresh"><IconButton onClick={load}><RefreshIcon /></IconButton></Tooltip>
|
||||||
<Button variant="contained" startIcon={<AddIcon />} onClick={() => navigate('/hosts/new')} sx={{ ml: 1 }}>Add Host</Button>
|
{canWrite && <Button variant="contained" startIcon={<AddIcon />} onClick={() => navigate('/hosts/new')} sx={{ ml: 1 }}>Add Host</Button>}
|
||||||
</Toolbar>
|
</Toolbar>
|
||||||
{loading ? <Box display="flex" justifyContent="center" mt={4}><CircularProgress /></Box> : (
|
{loading ? <Box display="flex" justifyContent="center" mt={4}><CircularProgress /></Box> : (
|
||||||
<TableContainer component={Paper}>
|
<TableContainer component={Paper}>
|
||||||
@ -69,7 +72,7 @@ export default function HostsPage() {
|
|||||||
<TableCell>Health</TableCell>
|
<TableCell>Health</TableCell>
|
||||||
<TableCell>Checks</TableCell>
|
<TableCell>Checks</TableCell>
|
||||||
<TableCell>Agent</TableCell>
|
<TableCell>Agent</TableCell>
|
||||||
<TableCell>Actions</TableCell>
|
{canWrite && <TableCell>Actions</TableCell>}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@ -93,7 +96,7 @@ export default function HostsPage() {
|
|||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{h.agent_version ?? '—'}</TableCell>
|
<TableCell>{h.agent_version ?? '—'}</TableCell>
|
||||||
<TableCell onClick={e => e.stopPropagation()}>
|
{canWrite && <TableCell onClick={e => e.stopPropagation()}>
|
||||||
<Tooltip title="Request refresh">
|
<Tooltip title="Request refresh">
|
||||||
<IconButton size="small" color="primary"
|
<IconButton size="small" color="primary"
|
||||||
disabled={refreshing === h.id}
|
disabled={refreshing === h.id}
|
||||||
@ -106,7 +109,7 @@ export default function HostsPage() {
|
|||||||
<Tooltip title="Delete"><IconButton size="small" color="error">
|
<Tooltip title="Delete"><IconButton size="small" color="error">
|
||||||
<DeleteIcon fontSize="small" />
|
<DeleteIcon fontSize="small" />
|
||||||
</IconButton></Tooltip>
|
</IconButton></Tooltip>
|
||||||
</TableCell>
|
</TableCell>}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
|
|||||||
@ -33,6 +33,7 @@ import {
|
|||||||
WifiOff as WifiOffIcon,
|
WifiOff as WifiOffIcon,
|
||||||
} from '@mui/icons-material'
|
} from '@mui/icons-material'
|
||||||
import { jobsApi } from '../api/client'
|
import { jobsApi } from '../api/client'
|
||||||
|
import { useAuthStore } from '../store/authStore'
|
||||||
import { useJobWebSocket } from '../hooks/useJobWebSocket'
|
import { useJobWebSocket } from '../hooks/useJobWebSocket'
|
||||||
import type { JobStatus, JobKind, PatchJobSummary, PatchJob, PatchJobHost, JobWsEvent } from '../types'
|
import type { JobStatus, JobKind, PatchJobSummary, PatchJob, PatchJobHost, JobWsEvent } from '../types'
|
||||||
|
|
||||||
@ -153,6 +154,7 @@ interface JobRowProps {
|
|||||||
detail: PatchJob | null
|
detail: PatchJob | null
|
||||||
detailLoading: boolean
|
detailLoading: boolean
|
||||||
detailError: string | null
|
detailError: string | null
|
||||||
|
canWrite: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function JobRow({
|
function JobRow({
|
||||||
@ -166,6 +168,7 @@ function JobRow({
|
|||||||
detail,
|
detail,
|
||||||
detailLoading,
|
detailLoading,
|
||||||
detailError,
|
detailError,
|
||||||
|
canWrite,
|
||||||
}: JobRowProps) {
|
}: JobRowProps) {
|
||||||
const canCancel = job.status === 'queued' || job.status === 'pending'
|
const canCancel = job.status === 'queued' || job.status === 'pending'
|
||||||
const canRollback = job.status === 'succeeded'
|
const canRollback = job.status === 'succeeded'
|
||||||
@ -224,7 +227,7 @@ function JobRow({
|
|||||||
</Typography>
|
</Typography>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||||
<Box display="flex" gap={0.5}>
|
{canWrite ? <Box display="flex" gap={0.5}>
|
||||||
{canCancel && (
|
{canCancel && (
|
||||||
<Tooltip title="Cancel job">
|
<Tooltip title="Cancel job">
|
||||||
<span>
|
<span>
|
||||||
@ -261,7 +264,7 @@ function JobRow({
|
|||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box> : null}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|
||||||
@ -289,6 +292,8 @@ function JobRow({
|
|||||||
|
|
||||||
// ── JobsPage ──────────────────────────────────────────────────────────────────
|
// ── JobsPage ──────────────────────────────────────────────────────────────────
|
||||||
export default function JobsPage() {
|
export default function JobsPage() {
|
||||||
|
const user = useAuthStore(state => state.user)
|
||||||
|
const canWrite = user?.role === 'admin' || user?.role === 'operator'
|
||||||
const [jobs, setJobs] = useState<PatchJobSummary[]>([])
|
const [jobs, setJobs] = useState<PatchJobSummary[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
@ -512,7 +517,7 @@ export default function JobsPage() {
|
|||||||
<TableCell align="right">Failed</TableCell>
|
<TableCell align="right">Failed</TableCell>
|
||||||
<TableCell>Schedule</TableCell>
|
<TableCell>Schedule</TableCell>
|
||||||
<TableCell>Notes</TableCell>
|
<TableCell>Notes</TableCell>
|
||||||
<TableCell>Actions</TableCell>
|
{canWrite && <TableCell>Actions</TableCell>}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@ -544,6 +549,7 @@ export default function JobsPage() {
|
|||||||
detail={details[job.id] ?? null}
|
detail={details[job.id] ?? null}
|
||||||
detailLoading={detailLoading[job.id] ?? false}
|
detailLoading={detailLoading[job.id] ?? false}
|
||||||
detailError={detailError[job.id] ?? null}
|
detailError={detailError[job.id] ?? null}
|
||||||
|
canWrite={canWrite}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -37,6 +37,7 @@ import {
|
|||||||
Schedule as ScheduleIcon,
|
Schedule as ScheduleIcon,
|
||||||
} from '@mui/icons-material'
|
} from '@mui/icons-material'
|
||||||
import { maintenanceWindowsApi, hostsApi } from '../api/client'
|
import { maintenanceWindowsApi, hostsApi } from '../api/client'
|
||||||
|
import { useAuthStore } from '../store/authStore'
|
||||||
import type { Host, MaintenanceWindow, WindowRecurrence } from '../types'
|
import type { Host, MaintenanceWindow, WindowRecurrence } from '../types'
|
||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
@ -311,9 +312,10 @@ interface HostWindowsTableProps {
|
|||||||
onEdit: (w: MaintenanceWindow) => void
|
onEdit: (w: MaintenanceWindow) => void
|
||||||
onDelete: (w: MaintenanceWindow) => void
|
onDelete: (w: MaintenanceWindow) => void
|
||||||
onAdd: (hostId: string) => void
|
onAdd: (hostId: string) => void
|
||||||
|
canWrite: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function HostWindowsTable({ host, windows, onEdit, onDelete, onAdd }: HostWindowsTableProps) {
|
function HostWindowsTable({ host, windows, onEdit, onDelete, onAdd, canWrite }: HostWindowsTableProps) {
|
||||||
return (
|
return (
|
||||||
<Paper variant="outlined" sx={{ mb: 3 }}>
|
<Paper variant="outlined" sx={{ mb: 3 }}>
|
||||||
<Box
|
<Box
|
||||||
@ -336,14 +338,14 @@ function HostWindowsTable({ host, windows, onEdit, onDelete, onAdd }: HostWindow
|
|||||||
({host.fqdn})
|
({host.fqdn})
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Button
|
{canWrite && <Button
|
||||||
size="small"
|
size="small"
|
||||||
startIcon={<AddIcon />}
|
startIcon={<AddIcon />}
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
onClick={() => onAdd(host.id)}
|
onClick={() => onAdd(host.id)}
|
||||||
>
|
>
|
||||||
Add Window
|
Add Window
|
||||||
</Button>
|
</Button>}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{windows.length === 0 ? (
|
{windows.length === 0 ? (
|
||||||
@ -362,7 +364,7 @@ function HostWindowsTable({ host, windows, onEdit, onDelete, onAdd }: HostWindow
|
|||||||
<TableCell>Status</TableCell>
|
<TableCell>Status</TableCell>
|
||||||
<TableCell>Auto-Apply</TableCell>
|
<TableCell>Auto-Apply</TableCell>
|
||||||
<TableCell>Created</TableCell>
|
<TableCell>Created</TableCell>
|
||||||
<TableCell align="right">Actions</TableCell>
|
{canWrite && <TableCell align="right">Actions</TableCell>}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@ -394,7 +396,7 @@ function HostWindowsTable({ host, windows, onEdit, onDelete, onAdd }: HostWindow
|
|||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{fmtDate(w.created_at)}</TableCell>
|
<TableCell>{fmtDate(w.created_at)}</TableCell>
|
||||||
<TableCell align="right">
|
{canWrite && <TableCell align="right">
|
||||||
<Tooltip title="Edit">
|
<Tooltip title="Edit">
|
||||||
<IconButton size="small" onClick={() => onEdit(w)}>
|
<IconButton size="small" onClick={() => onEdit(w)}>
|
||||||
<EditIcon fontSize="small" />
|
<EditIcon fontSize="small" />
|
||||||
@ -405,7 +407,7 @@ function HostWindowsTable({ host, windows, onEdit, onDelete, onAdd }: HostWindow
|
|||||||
<DeleteIcon fontSize="small" />
|
<DeleteIcon fontSize="small" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TableCell>
|
</TableCell>}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
@ -418,6 +420,8 @@ function HostWindowsTable({ host, windows, onEdit, onDelete, onAdd }: HostWindow
|
|||||||
// ── Main page ──────────────────────────────────────────────────────────────────
|
// ── Main page ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function MaintenanceWindowsPage() {
|
export default function MaintenanceWindowsPage() {
|
||||||
|
const user = useAuthStore(state => state.user)
|
||||||
|
const canWrite = user?.role === 'admin' || user?.role === 'operator'
|
||||||
const [hosts, setHosts] = useState<Host[]>([])
|
const [hosts, setHosts] = useState<Host[]>([])
|
||||||
const [windowsByHost, setWindowsByHost] = useState<Record<string, MaintenanceWindow[]>>({})
|
const [windowsByHost, setWindowsByHost] = useState<Record<string, MaintenanceWindow[]>>({})
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
@ -593,9 +597,9 @@ export default function MaintenanceWindowsPage() {
|
|||||||
onEdit={handleEditClick}
|
onEdit={handleEditClick}
|
||||||
onDelete={handleDeleteClick}
|
onDelete={handleDeleteClick}
|
||||||
onAdd={handleAddClick}
|
onAdd={handleAddClick}
|
||||||
|
canWrite={canWrite}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Create dialog */}
|
{/* Create dialog */}
|
||||||
<WindowFormDialog
|
<WindowFormDialog
|
||||||
open={createOpen}
|
open={createOpen}
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import EmailIcon from '@mui/icons-material/Email'
|
|||||||
import VpnKeyIcon from '@mui/icons-material/VpnKey'
|
import VpnKeyIcon from '@mui/icons-material/VpnKey'
|
||||||
import ExploreIcon from '@mui/icons-material/Explore'
|
import ExploreIcon from '@mui/icons-material/Explore'
|
||||||
import { settingsApi } from '../api/client'
|
import { settingsApi } from '../api/client'
|
||||||
|
import { useAuthStore } from '../store/authStore'
|
||||||
import type { OidcConfigResponse, OidcDiscoveryResult, SmtpConfig, PollingConfig, NotificationConfig } from '../types'
|
import type { OidcConfigResponse, OidcDiscoveryResult, SmtpConfig, PollingConfig, NotificationConfig } from '../types'
|
||||||
|
|
||||||
type OidcForm = OidcConfigResponse & { client_secret?: string }
|
type OidcForm = OidcConfigResponse & { client_secret?: string }
|
||||||
@ -23,6 +24,8 @@ type SmtpForm = SmtpConfig & { password?: string }
|
|||||||
const KEYCLOAK_DISCOVERY_URL = 'https://keycloak.moon-dragon.us/realms/moon-dragon.us/.well-known/openid-configuration'
|
const KEYCLOAK_DISCOVERY_URL = 'https://keycloak.moon-dragon.us/realms/moon-dragon.us/.well-known/openid-configuration'
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
|
const user = useAuthStore(state => state.user)
|
||||||
|
const canWrite = user?.role === 'admin' || user?.role === 'operator'
|
||||||
const [oidc, setOidc] = useState<OidcForm>({
|
const [oidc, setOidc] = useState<OidcForm>({
|
||||||
enabled: false, provider_type: 'azure', display_name: 'Azure AD',
|
enabled: false, provider_type: 'azure', display_name: 'Azure AD',
|
||||||
discovery_url: '', client_id: '', client_secret: '', redirect_uri: '', scopes: 'openid profile email',
|
discovery_url: '', client_id: '', client_secret: '', redirect_uri: '', scopes: 'openid profile email',
|
||||||
@ -202,9 +205,9 @@ export default function SettingsPage() {
|
|||||||
<Container maxWidth="lg" sx={{ mt: 3 }}>
|
<Container maxWidth="lg" sx={{ mt: 3 }}>
|
||||||
<Toolbar disableGutters sx={{ mb: 3, justifyContent: 'space-between' }}>
|
<Toolbar disableGutters sx={{ mb: 3, justifyContent: 'space-between' }}>
|
||||||
<Typography variant="h5" fontWeight={700}>Settings</Typography>
|
<Typography variant="h5" fontWeight={700}>Settings</Typography>
|
||||||
<Button variant="contained" onClick={handleSave} disabled={saving} startIcon={saving ? <CircularProgress size={20} /> : <SaveIcon />}>
|
{canWrite && <Button variant="contained" onClick={handleSave} disabled={saving} startIcon={saving ? <CircularProgress size={20} /> : <SaveIcon />}>
|
||||||
Save Settings
|
Save Settings
|
||||||
</Button>
|
</Button>}
|
||||||
</Toolbar>
|
</Toolbar>
|
||||||
|
|
||||||
{error && <Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>{error}</Alert>}
|
{error && <Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>{error}</Alert>}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
// Core TypeScript types — expanded per milestone
|
// Core TypeScript types — expanded per milestone
|
||||||
|
|
||||||
export type UserRole = 'admin' | 'operator'
|
export type UserRole = 'admin' | 'operator' | 'reporter'
|
||||||
export type AuthProvider = 'local' | 'azure_sso' | 'keycloak' | 'oidc'
|
export type AuthProvider = 'local' | 'azure_sso' | 'keycloak' | 'oidc'
|
||||||
export type HostHealthStatus = 'pending' | 'healthy' | 'degraded' | 'unreachable'
|
export type HostHealthStatus = 'pending' | 'healthy' | 'degraded' | 'unreachable'
|
||||||
export type JobStatus = 'queued' | 'pending' | 'running' | 'succeeded' | 'failed' | 'cancelled'
|
export type JobStatus = 'queued' | 'pending' | 'running' | 'succeeded' | 'failed' | 'cancelled'
|
||||||
|
|||||||
14
migrations/015_reporter_role.sql
Normal file
14
migrations/015_reporter_role.sql
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
-- Migration 015: Add 'reporter' role to user_role enum
|
||||||
|
-- Reporter is a read-only role for SSO auto-provisioned users.
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_enum e
|
||||||
|
JOIN pg_type t ON t.oid = e.enumtypid
|
||||||
|
WHERE t.typname = 'user_role' AND e.enumlabel = 'reporter'
|
||||||
|
) THEN
|
||||||
|
ALTER TYPE user_role ADD VALUE 'reporter';
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
Reference in New Issue
Block a user