Public Access
Compare commits
2
Commits
f6fb18318e
...
90ff337f7c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90ff337f7c | ||
|
|
bfb10b9ab2 |
+2
-2
@@ -65,7 +65,7 @@ fn main() {
|
|||||||
if users.is_empty() {
|
if users.is_empty() {
|
||||||
println!("No users.");
|
println!("No users.");
|
||||||
} else {
|
} else {
|
||||||
println!("{:<20} {}", "Username", "Created");
|
println!("{:<20} Created", "Username");
|
||||||
println!("{}", "-".repeat(40));
|
println!("{}", "-".repeat(40));
|
||||||
for (u, c) in users {
|
for (u, c) in users {
|
||||||
println!("{:<20} {}", u, c);
|
println!("{:<20} {}", u, c);
|
||||||
@@ -115,7 +115,7 @@ fn main() {
|
|||||||
if keys.is_empty() {
|
if keys.is_empty() {
|
||||||
println!("No API keys.");
|
println!("No API keys.");
|
||||||
} else {
|
} else {
|
||||||
println!("{:<20} {}", "Label", "Created");
|
println!("{:<20} Created", "Label");
|
||||||
println!("{}", "-".repeat(40));
|
println!("{}", "-".repeat(40));
|
||||||
for (l, c) in keys {
|
for (l, c) in keys {
|
||||||
println!("{:<20} {}", l, c);
|
println!("{:<20} {}", l, c);
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
#[derive(Debug, Deserialize, Clone)]
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
|
|||||||
+8
-1
@@ -27,7 +27,6 @@ pub struct SubmitAction {
|
|||||||
pub run_as: Option<String>,
|
pub run_as: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub struct ActionResult {
|
pub struct ActionResult {
|
||||||
@@ -36,6 +35,14 @@ pub struct ActionResult {
|
|||||||
pub result: Option<String>,
|
pub result: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Body for the approval API. The `approve_token` is the per-action secret
|
||||||
|
/// minted at submit time and delivered in the approval notification.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub struct ApproveRequest {
|
||||||
|
pub approve_token: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub struct LoginForm {
|
pub struct LoginForm {
|
||||||
|
|||||||
+248
-109
@@ -28,6 +28,11 @@ pub fn router(state: AppState) -> Router {
|
|||||||
// Agent API
|
// Agent API
|
||||||
.route("/api/actions", post(submit_action))
|
.route("/api/actions", post(submit_action))
|
||||||
.route("/api/actions/:id/result", get(get_result))
|
.route("/api/actions/:id/result", get(get_result))
|
||||||
|
// Approval API. Authenticated by the per-action approve_token, NOT by
|
||||||
|
// the agent API key -- an agent must not be able to approve its own
|
||||||
|
// actions, or the human-in-the-loop guarantee is worthless.
|
||||||
|
.route("/api/actions/:id/approve", post(api_approve_action))
|
||||||
|
.route("/api/actions/:id/reject", post(api_reject_action))
|
||||||
.with_state(state)
|
.with_state(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,6 +104,113 @@ fn verify_api_key(headers: &HeaderMap, state: &AppState) -> bool {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Column list for `actions`, in the order `row_to_action` expects.
|
||||||
|
const ACTION_COLUMNS: &str =
|
||||||
|
"id, chain_id, sequence, description, command, run_as, status, result, approve_token, submitted_at, resolved_at";
|
||||||
|
|
||||||
|
fn row_to_action(row: &rusqlite::Row) -> rusqlite::Result<Action> {
|
||||||
|
Ok(Action {
|
||||||
|
id: row.get(0)?,
|
||||||
|
chain_id: row.get(1)?,
|
||||||
|
sequence: row.get(2)?,
|
||||||
|
description: row.get(3)?,
|
||||||
|
command: row.get(4)?,
|
||||||
|
run_as: row.get(5)?,
|
||||||
|
status: row.get(6)?,
|
||||||
|
result: row.get(7)?,
|
||||||
|
approve_token: row.get(8)?,
|
||||||
|
submitted_at: row.get(9)?,
|
||||||
|
resolved_at: row.get(10)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fetch_action(state: &AppState, id: &str) -> Option<Action> {
|
||||||
|
let db = state.db.lock().ok()?;
|
||||||
|
db.query_row(
|
||||||
|
&format!("SELECT {ACTION_COLUMNS} FROM actions WHERE id = ?1"),
|
||||||
|
[id],
|
||||||
|
row_to_action,
|
||||||
|
)
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Look up an action by id *and* its approve_token. Both must match, so a
|
||||||
|
/// bare action id is not enough to act on it.
|
||||||
|
fn fetch_action_with_token(state: &AppState, id: &str, token: &str) -> Option<Action> {
|
||||||
|
let db = state.db.lock().ok()?;
|
||||||
|
db.query_row(
|
||||||
|
&format!("SELECT {ACTION_COLUMNS} FROM actions WHERE id = ?1 AND approve_token = ?2"),
|
||||||
|
[id, token],
|
||||||
|
row_to_action,
|
||||||
|
)
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Execute an approved action's command and record the outcome.
|
||||||
|
/// Returns the resulting status ("executed" or "failed") and the captured output.
|
||||||
|
fn execute_action(state: &AppState, action: &Action) -> (String, String) {
|
||||||
|
let output = match action.run_as.as_deref() {
|
||||||
|
Some("root") => Command::new("sudo")
|
||||||
|
.args(["-n", "sh", "-c", &action.command])
|
||||||
|
.output(),
|
||||||
|
Some(user) => Command::new("sudo")
|
||||||
|
.args(["-n", "-u", user, "sh", "-c", &action.command])
|
||||||
|
.output(),
|
||||||
|
None => Command::new("sh").args(["-c", &action.command]).output(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let (status, result) = match output {
|
||||||
|
Ok(out) => {
|
||||||
|
let stdout = sanitize_output(&String::from_utf8_lossy(&out.stdout));
|
||||||
|
let stderr = sanitize_output(&String::from_utf8_lossy(&out.stderr));
|
||||||
|
let combined = if stderr.is_empty() {
|
||||||
|
stdout
|
||||||
|
} else {
|
||||||
|
format!("{}\nSTDERR:\n{}", stdout, stderr)
|
||||||
|
};
|
||||||
|
if out.status.success() {
|
||||||
|
("executed", combined)
|
||||||
|
} else {
|
||||||
|
("failed", combined)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => ("failed", e.to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Ok(db) = state.db.lock() {
|
||||||
|
let _ = db.execute(
|
||||||
|
"UPDATE actions SET status = ?1, result = ?2, resolved_at = datetime('now') WHERE id = ?3",
|
||||||
|
[status, &result, &action.id],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
(status.to_string(), result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark an action rejected, along with every still-pending action in its chain.
|
||||||
|
fn reject_action_and_chain(state: &AppState, id: &str) {
|
||||||
|
let Ok(db) = state.db.lock() else { return };
|
||||||
|
|
||||||
|
let chain_id: Option<String> = db
|
||||||
|
.query_row("SELECT chain_id FROM actions WHERE id = ?1", [id], |row| {
|
||||||
|
row.get(0)
|
||||||
|
})
|
||||||
|
.ok()
|
||||||
|
.flatten();
|
||||||
|
|
||||||
|
let _ = db.execute(
|
||||||
|
"UPDATE actions SET status = 'rejected', resolved_at = datetime('now') WHERE id = ?1",
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
|
||||||
|
if let Some(cid) = chain_id {
|
||||||
|
let _ = db.execute(
|
||||||
|
"UPDATE actions SET status = 'rejected', resolved_at = datetime('now') WHERE chain_id = ?1 AND status = 'pending'",
|
||||||
|
[&cid],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Web UI ────────────────────────────────────────────────────────
|
// ── Web UI ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async fn login_page() -> Html<String> {
|
async fn login_page() -> Html<String> {
|
||||||
@@ -190,28 +302,15 @@ async fn dashboard(State(state): State<AppState>, jar: CookieJar) -> impl IntoRe
|
|||||||
|
|
||||||
let actions: Vec<Action> = {
|
let actions: Vec<Action> = {
|
||||||
let db = state.db.lock().unwrap();
|
let db = state.db.lock().unwrap();
|
||||||
let mut stmt = db.prepare(
|
let mut stmt = db
|
||||||
"SELECT id, chain_id, sequence, description, command, run_as, status, result, approve_token, submitted_at, resolved_at
|
.prepare(&format!(
|
||||||
FROM actions ORDER BY submitted_at DESC LIMIT 50"
|
"SELECT {ACTION_COLUMNS} FROM actions ORDER BY submitted_at DESC LIMIT 50"
|
||||||
).unwrap();
|
))
|
||||||
stmt.query_map([], |row| {
|
.unwrap();
|
||||||
Ok(Action {
|
stmt.query_map([], row_to_action)
|
||||||
id: row.get(0)?,
|
.unwrap()
|
||||||
chain_id: row.get(1)?,
|
.filter_map(|r| r.ok())
|
||||||
sequence: row.get(2)?,
|
.collect()
|
||||||
description: row.get(3)?,
|
|
||||||
command: row.get(4)?,
|
|
||||||
run_as: row.get(5)?,
|
|
||||||
status: row.get(6)?,
|
|
||||||
result: row.get(7)?,
|
|
||||||
approve_token: row.get(8)?,
|
|
||||||
submitted_at: row.get(9)?,
|
|
||||||
resolved_at: row.get(10)?,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.unwrap()
|
|
||||||
.filter_map(|r| r.ok())
|
|
||||||
.collect()
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let rows: String = actions
|
let rows: String = actions
|
||||||
@@ -308,20 +407,7 @@ async fn action_detail(
|
|||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
// Allow approve via token link (no login required)
|
// Allow approve via token link (no login required)
|
||||||
if let Some(token) = q.token {
|
if let Some(token) = q.token {
|
||||||
let action: Option<Action> = {
|
if let Some(a) = fetch_action_with_token(&state, &id, &token) {
|
||||||
let db = state.db.lock().unwrap();
|
|
||||||
db.query_row(
|
|
||||||
"SELECT id, chain_id, sequence, description, command, run_as, status, result, approve_token, submitted_at, resolved_at FROM actions WHERE id = ?1 AND approve_token = ?2",
|
|
||||||
[&id, &token],
|
|
||||||
|row| Ok(Action {
|
|
||||||
id: row.get(0)?, chain_id: row.get(1)?, sequence: row.get(2)?,
|
|
||||||
description: row.get(3)?, command: row.get(4)?, run_as: row.get(5)?,
|
|
||||||
status: row.get(6)?, result: row.get(7)?, approve_token: row.get(8)?,
|
|
||||||
submitted_at: row.get(9)?, resolved_at: row.get(10)?,
|
|
||||||
}),
|
|
||||||
).ok()
|
|
||||||
};
|
|
||||||
if let Some(a) = action {
|
|
||||||
if a.status == "pending" {
|
if a.status == "pending" {
|
||||||
let content = format!(
|
let content = format!(
|
||||||
r#"
|
r#"
|
||||||
@@ -364,56 +450,9 @@ async fn approve_action(
|
|||||||
return Redirect::to("/login").into_response();
|
return Redirect::to("/login").into_response();
|
||||||
}
|
}
|
||||||
|
|
||||||
let action: Option<Action> = {
|
if let Some(a) = fetch_action(&state, &id) {
|
||||||
let db = state.db.lock().unwrap();
|
|
||||||
db.query_row(
|
|
||||||
"SELECT id, chain_id, sequence, description, command, run_as, status, result, approve_token, submitted_at, resolved_at FROM actions WHERE id = ?1",
|
|
||||||
[&id],
|
|
||||||
|row| Ok(Action {
|
|
||||||
id: row.get(0)?, chain_id: row.get(1)?, sequence: row.get(2)?,
|
|
||||||
description: row.get(3)?, command: row.get(4)?, run_as: row.get(5)?,
|
|
||||||
status: row.get(6)?, result: row.get(7)?, approve_token: row.get(8)?,
|
|
||||||
submitted_at: row.get(9)?, resolved_at: row.get(10)?,
|
|
||||||
}),
|
|
||||||
).ok()
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Some(a) = action {
|
|
||||||
if a.status == "pending" {
|
if a.status == "pending" {
|
||||||
// Build command with run_as support
|
execute_action(&state, &a);
|
||||||
let output = match a.run_as.as_deref() {
|
|
||||||
Some("root") => Command::new("sudo")
|
|
||||||
.args(["-n", "sh", "-c", &a.command])
|
|
||||||
.output(),
|
|
||||||
Some(user) => Command::new("sudo")
|
|
||||||
.args(["-n", "-u", user, "sh", "-c", &a.command])
|
|
||||||
.output(),
|
|
||||||
None => Command::new("sh").args(["-c", &a.command]).output(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let (status, result) = match output {
|
|
||||||
Ok(out) => {
|
|
||||||
let stdout = sanitize_output(&String::from_utf8_lossy(&out.stdout));
|
|
||||||
let stderr = sanitize_output(&String::from_utf8_lossy(&out.stderr));
|
|
||||||
let combined = if stderr.is_empty() {
|
|
||||||
stdout
|
|
||||||
} else {
|
|
||||||
format!("{}\nSTDERR:\n{}", stdout, stderr)
|
|
||||||
};
|
|
||||||
if out.status.success() {
|
|
||||||
("executed", combined)
|
|
||||||
} else {
|
|
||||||
("failed", combined)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => ("failed", e.to_string()),
|
|
||||||
};
|
|
||||||
|
|
||||||
let db = state.db.lock().unwrap();
|
|
||||||
db.execute(
|
|
||||||
"UPDATE actions SET status = ?1, result = ?2, resolved_at = datetime('now') WHERE id = ?3",
|
|
||||||
[status, &result, &a.id],
|
|
||||||
).unwrap();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -429,29 +468,7 @@ async fn reject_action(
|
|||||||
return Redirect::to("/login").into_response();
|
return Redirect::to("/login").into_response();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reject this action and all pending actions in same chain
|
reject_action_and_chain(&state, &id);
|
||||||
let chain_id: Option<String> = {
|
|
||||||
let db = state.db.lock().unwrap();
|
|
||||||
db.query_row("SELECT chain_id FROM actions WHERE id = ?1", [&id], |row| {
|
|
||||||
row.get(0)
|
|
||||||
})
|
|
||||||
.ok()
|
|
||||||
.flatten()
|
|
||||||
};
|
|
||||||
|
|
||||||
let db = state.db.lock().unwrap();
|
|
||||||
db.execute(
|
|
||||||
"UPDATE actions SET status = 'rejected', resolved_at = datetime('now') WHERE id = ?1",
|
|
||||||
[&id],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
if let Some(cid) = chain_id {
|
|
||||||
db.execute(
|
|
||||||
"UPDATE actions SET status = 'rejected', resolved_at = datetime('now') WHERE chain_id = ?1 AND status = 'pending'",
|
|
||||||
[&cid],
|
|
||||||
).unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
Redirect::to("/").into_response()
|
Redirect::to("/").into_response()
|
||||||
}
|
}
|
||||||
@@ -495,12 +512,29 @@ async fn submit_action(
|
|||||||
let description = body.description.clone();
|
let description = body.description.clone();
|
||||||
let command = body.command.clone();
|
let command = body.command.clone();
|
||||||
let url = approve_url.clone();
|
let url = approve_url.clone();
|
||||||
|
let action_id = id.clone();
|
||||||
|
let run_as = body.run_as.clone();
|
||||||
|
let approve_token_out = approve_token.clone();
|
||||||
|
let api_base = state.config.base_url.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let client = reqwest::Client::new();
|
let client = reqwest::Client::new();
|
||||||
let mut req = client.post(&webhook)
|
let mut req = client.post(&webhook)
|
||||||
.json(&serde_json::json!({
|
.json(&serde_json::json!({
|
||||||
|
// `text` is kept verbatim so existing consumers keep working.
|
||||||
"text": format!("[ActionGateway] New action needs approval.\nDescription: {}\nCommand: {}\nApprove: {}", description, command, url),
|
"text": format!("[ActionGateway] New action needs approval.\nDescription: {}\nCommand: {}\nApprove: {}", description, command, url),
|
||||||
"mode": "now"
|
"mode": "now",
|
||||||
|
// Structured form, so a notifier can render buttons and call
|
||||||
|
// the approval API without scraping the text blob above.
|
||||||
|
"action": {
|
||||||
|
"id": action_id,
|
||||||
|
"description": description,
|
||||||
|
"command": command,
|
||||||
|
"run_as": run_as,
|
||||||
|
"approve_url": url,
|
||||||
|
"approve_token": approve_token_out,
|
||||||
|
"api_approve": format!("{}/api/actions/{}/approve", api_base, action_id),
|
||||||
|
"api_reject": format!("{}/api/actions/{}/reject", api_base, action_id),
|
||||||
|
}
|
||||||
}));
|
}));
|
||||||
if let Some(t) = token {
|
if let Some(t) = token {
|
||||||
req = req.header("Authorization", format!("Bearer {}", t));
|
req = req.header("Authorization", format!("Bearer {}", t));
|
||||||
@@ -520,6 +554,111 @@ async fn submit_action(
|
|||||||
.into_response()
|
.into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Approval API ──────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// These endpoints exist so a notifier (desktop popup, chat bot, phone) can
|
||||||
|
// resolve an action without driving the web UI or holding a login session.
|
||||||
|
//
|
||||||
|
// Auth is the per-action `approve_token`: a UUIDv4 minted at submit time,
|
||||||
|
// scoped to exactly one action, and delivered only in the approval
|
||||||
|
// notification. The agent API key is deliberately NOT accepted here -- the
|
||||||
|
// agent submits actions, so letting it approve them too would defeat the
|
||||||
|
// entire point of the service.
|
||||||
|
|
||||||
|
/// Resolve `token` from either the JSON body or the `?token=` query string,
|
||||||
|
/// so callers can use whichever is convenient. Body wins if both are present.
|
||||||
|
fn approve_token_from(body: &Option<Json<ApproveRequest>>, q: &ApproveQuery) -> Option<String> {
|
||||||
|
body.as_ref()
|
||||||
|
.map(|b| b.approve_token.clone())
|
||||||
|
.or_else(|| q.token.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn api_approve_action(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
Query(q): Query<ApproveQuery>,
|
||||||
|
body: Option<Json<ApproveRequest>>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let Some(token) = approve_token_from(&body, &q) else {
|
||||||
|
return (
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
Json(serde_json::json!({"error": "missing approve_token"})),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(action) = fetch_action_with_token(&state, &id, &token) else {
|
||||||
|
return (
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
Json(serde_json::json!({"error": "unknown action id or invalid approve_token"})),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
};
|
||||||
|
|
||||||
|
if action.status != "pending" {
|
||||||
|
return (
|
||||||
|
StatusCode::CONFLICT,
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"error": "action is no longer pending",
|
||||||
|
"id": action.id,
|
||||||
|
"status": action.status,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let (status, result) = execute_action(&state, &action);
|
||||||
|
|
||||||
|
(
|
||||||
|
StatusCode::OK,
|
||||||
|
Json(serde_json::json!({"id": action.id, "status": status, "result": result})),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn api_reject_action(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
Query(q): Query<ApproveQuery>,
|
||||||
|
body: Option<Json<ApproveRequest>>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let Some(token) = approve_token_from(&body, &q) else {
|
||||||
|
return (
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
Json(serde_json::json!({"error": "missing approve_token"})),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(action) = fetch_action_with_token(&state, &id, &token) else {
|
||||||
|
return (
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
Json(serde_json::json!({"error": "unknown action id or invalid approve_token"})),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
};
|
||||||
|
|
||||||
|
if action.status != "pending" {
|
||||||
|
return (
|
||||||
|
StatusCode::CONFLICT,
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"error": "action is no longer pending",
|
||||||
|
"id": action.id,
|
||||||
|
"status": action.status,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
reject_action_and_chain(&state, &action.id);
|
||||||
|
|
||||||
|
(
|
||||||
|
StatusCode::OK,
|
||||||
|
Json(serde_json::json!({"id": action.id, "status": "rejected"})),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
async fn get_result(
|
async fn get_result(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
|
|||||||
Reference in New Issue
Block a user