Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 55 additions & 3 deletions client/src/pages/dashboard.rs
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ pub fn DashboardPage() -> impl IntoView {
</div>

<div class="dashboard-section">
<div class="section-header">
<div class="section-header">
<h2>"Your Projects"</h2>
<A href="/new" class="btn-primary">
"+ New Project"
Expand All @@ -213,6 +213,7 @@ pub fn DashboardPage() -> impl IntoView {
set_error=set_error
set_loading=set_loading
projects=projects
set_projects=set_projects
user_login=user_login_rc.clone()
/>
</div>
Expand Down Expand Up @@ -336,8 +337,52 @@ fn DashboardProjectList(
set_error: WriteSignal<Option<String>>,
set_loading: WriteSignal<bool>,
projects: ReadSignal<Vec<ProjectSummary>>,
set_projects: WriteSignal<Vec<ProjectSummary>>, // <--- Receive Setter
user_login: Rc<String>,
) -> impl IntoView {

// Handler for deletion logic
let handle_delete = move |slug: String| {
let prompt_text = format!(
"⚠️ DESTRUCTIVE ACTION\n\nThis will permanently delete the project '{}' and its Docker image.\n\nPlease type the project name to confirm:",
slug
);

// FIX: Match against Ok(Some(input)) to handle the Result wrapper
if let Ok(Some(input)) = window().prompt_with_message(&prompt_text) {
if input == slug {
let slug_clone = slug.clone();

spawn_local(async move {
let url = format!("{}/api/project/{}", api_base(), slug_clone);

let req = Request::delete(&url)
.credentials(RequestCredentials::Include)
.send()
.await;

match req {
Ok(resp) => {
if resp.ok() {
set_projects.update(|list| {
list.retain(|p| p.slug != slug_clone);
});
let _ = window().alert_with_message("Project and Docker image deleted.");
} else {
let _ = window().alert_with_message("Failed to delete project. Check server logs.");
}
},
Err(_) => {
let _ = window().alert_with_message("Network error occurred.");
}
}
});
} else {
let _ = window().alert_with_message("Project name mismatch. Deletion cancelled.");
}
}
};

view! {
{move || match error.get() {
Some(err) => view! {
Expand Down Expand Up @@ -371,6 +416,8 @@ fn DashboardProjectList(
key=|p| p.slug.clone()
children=move |proj| {
let login = user_login_clone.as_ref().clone();
let delete_slug = proj.slug.clone();

view! {
<div class="project-card">
<div class="card-header">
Expand All @@ -379,11 +426,16 @@ fn DashboardProjectList(
<div class="card-body">
<p class="card-meta">"Image: "<code>{proj.image_tag.clone()}</code></p>
</div>
<div class="card-footer">
<div class="card-footer" style="display: flex;">
<A href=format!("/{}/{}", login, proj.slug)
class="btn-card">
"View"
</A>
<button
class="btn-danger"
on:click=move |_| handle_delete(delete_slug.clone())>
"Delete"
</button>
</div>
</div>
}
Expand All @@ -395,4 +447,4 @@ fn DashboardProjectList(
}
}}
}
}
}
23 changes: 23 additions & 0 deletions client/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -799,3 +799,26 @@ body {
.dot { opacity: 0.6; transition: opacity 0.2s; }
.terminal-header:hover .dot { opacity: 1; }



.btn-danger {
padding: 8px 16px;
background: transparent;
color: var(--text-muted);
border: 1px solid var(--border);
border-radius: 6px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
font-size: 0.9rem;
text-decoration: none;
margin-left: 8px; /* Gap between View and Delete */
}

.btn-danger:hover {
background: rgba(220, 38, 38, 0.1); /* Darker Red bg */
border-color: #dc2626;
color: #dc2626;
box-shadow: 0 0 15px rgba(220, 38, 38, 0.2);
transform: translateY(-1px);
}
66 changes: 64 additions & 2 deletions server/src/handlers/project.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
use axum::{
extract::{Path, Query, State},
routing::{get, post},
routing::{get, post,delete},
Router, Json,
http::StatusCode,
};
use bollard::container::{CreateContainerOptions, Config};
use bollard::models::HostConfig;
use bollard::image::CommitContainerOptions;
use bollard::image::{CommitContainerOptions, RemoveImageOptions};
use tower_sessions::Session;
use uuid::Uuid;
use serde::Deserialize;
Expand All @@ -22,6 +22,7 @@ pub fn routes() -> Router<AppState> {
Router::new()
.route("/api/my-projects", get(list_user_projects))
.route("/api/project/:username/:slug", get(get_project))
.route("/api/project/:slug", delete(delete_project)) // <--- New Route
.route("/api/search-projects", get(search_projects))
.route("/api/publish", post(publish_handler))
}
Expand Down Expand Up @@ -243,3 +244,64 @@ pub async fn get_project(
"owner_id": owner_id
})))
}

pub async fn delete_project(
State(state): State<AppState>,
session: Session,
Path(slug): Path<String>,
) -> Result<StatusCode, (StatusCode, String)> {
// 1. Authenticate User
let user: Option<User> = session.get("user")
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Session Error: {}", e)))?;

let user = user.ok_or((StatusCode::UNAUTHORIZED, "Unauthorized".to_string()))?;

// 2. Fetch Image Tag & Verify Ownership (Before Deletion)
// We need the image tag to clean up Docker, and we need to verify ownership strictly.
let record: Option<(String,)> = sqlx::query_as(
"SELECT image_tag FROM projects WHERE slug = $1 AND owner_id = $2"
)
.bind(&slug)
.bind(user.id)
.fetch_optional(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB Error: {}", e)))?;

let image_tag = match record {
Some(r) => r.0,
None => return Err((StatusCode::NOT_FOUND, "Project not found or access denied".to_string())),
};

// 3. Delete from Database (Source of Truth)
let db_result = sqlx::query(
"DELETE FROM projects WHERE slug = $1 AND owner_id = $2"
)
.bind(&slug)
.bind(user.id)
.execute(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB Delete Error: {}", e)))?;

if db_result.rows_affected() == 0 {
// This is unlikely given step 2, but handles race conditions
return Err((StatusCode::NOT_FOUND, "Project not found".to_string()));
}

// 4. Purge Docker Image
// We use force=true to kill any active containers using this image.
// We map errors but do NOT fail the request if Docker fails (e.g., image already manualy deleted).
let remove_opts = RemoveImageOptions {
force: true, // Force removal even if containers are running
noprune: false,
};

if let Err(e) = state.docker.remove_image(&image_tag, Some(remove_opts), None).await {
// Log it, but don't fail the request to the client, as the DB entry is already gone.
eprintln!("Warning: Failed to remove docker image {}: {}", image_tag, e);
} else {
println!("Cleaned up image: {}", image_tag);
}

Ok(StatusCode::OK)
}
11 changes: 8 additions & 3 deletions server/src/router.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,19 +16,24 @@ pub fn create_router(state: AppState) -> Result<Router, Box<dyn std::error::Erro
.with_same_site(tower_sessions::cookie::SameSite::Lax)
.with_expiry(Expiry::OnInactivity(time::Duration::minutes(60)));

// 1. DYNAMIC ORIGIN: Reads from env, defaults to localhost for dev
let frontend_url = std::env::var("FRONTEND_URL")
.unwrap_or_else(|_| "http://localhost:8080".to_string());

let app = Router::new()
.merge(auth::routes())
.merge(spawn::routes())
.merge(project::routes())
.route("/ws/:session_id", get(websocket::ws_handler))
.layer(tower_http::cors::CorsLayer::new()
.allow_origin("http://localhost:8080".parse::<axum::http::HeaderValue>()?)
.allow_methods([Method::GET, Method::POST, Method::OPTIONS])
.allow_origin(frontend_url.parse::<axum::http::HeaderValue>()?)
// 2. ALLOW DELETE METHOD
.allow_methods([Method::GET, Method::POST, Method::DELETE, Method::OPTIONS])
.allow_headers([CONTENT_TYPE, AUTHORIZATION])
.allow_credentials(true)
)
.layer(session_layer)
.with_state(state);

Ok(app)
}
}