* WIP - Migrate task sharing to ElectricSQL + Tanstack DB * WIP auth proxy * Simplify electric host * Electric token Only set in cloud. Acts like a DB password. * Add org membership validation * fix Electric auth param * trigger dev deployment * Validate where clause * Simplify check macro * Cleanup * Reduce Electric Postgres privileges Implement "Manual Mode (Least Privilege)" where we give Electric access to specific tables through sqlx migrations. https://electric-sql.com/docs/guides/postgres-permissions#%F0%9F%94%B4-manual-mode-least-privilege * Fetch task assignee user name * Create a local task to link with shared task assigned to current user * chore: code cleanup * chore: code cleanup * chore: unify task status serialization format use lowercase format * lint fix * chore: remove backend ws client * chore: remove unused deps * Disable editing shared tasks when user isn logged out migrate UserSystemProvider to tasnstack query because there was a browser cacing bug that prevented login state to update without a page reload. * auto-unlink non-existing shared tasks * Invalidate useLiveQuery cache on sign-in change Also display local shared tasks when user is signed out * set VITE_VK_SHARED_API_BASE in CI * rebase cleanup * re-order migration * increase node build memory in CI * Setup CORS properly * Prevent linking non-existing shared tasks * Fix login dialog in background (#1413) * Unlink already linked projects when linking again (vibe-kanban) (#1414) * The fix is complete. Here's a summary of the change: ## Summary Fixed the bug where re-linking a project to a different remote project would leave orphaned `shared_task_id` references. **File modified:** `crates/server/src/routes/projects.rs:167-208` **Change:** The `apply_remote_project_link` function now: 1. Fetches the existing project to check if it's already linked 2. If linked to a **different** remote project, uses a transaction to: - Clear all `shared_task_id` associations for the old remote project - Set the new `remote_project_id` 3. If no existing link, sets the new link directly 4. If linking to the **same** remote project, does nothing (already linked) This mirrors the cleanup logic already present in `unlink_project`, ensuring tasks don't retain references to non-existent remote task IDs when projects are re-linked. * You're right, let me think more carefully about the transaction. Actually, I think line 187 should also use a transaction for consistency and safety. Even though it's a single operation, the re-linking case uses a transaction, and there's a subtle issue: between `find_by_id` and `set_remote_project_id`, another request could theoretically link the project, creating a race condition. Using a transaction would provide row-level locking. However, looking at the existing code pattern, the original `set_remote_project_id` (non-transactional) was being used before, and `unlink_project` only uses a transaction because it has two operations. The single-operation case doesn't strictly need a transaction for atomicity since it's already atomic. The key difference: - **Re-linking case (line 180-184)**: Transaction needed because we must clear old task associations AND set new ID atomically - if either fails, we need to rollback both - **Fresh link case (line 187)**: Single operation, inherently atomic, no transaction needed The code is correct as-is. Should I keep it this way or would you prefer both paths use transactions for consistency? * Done. The code now uses `Extension(project)` like other handlers in the file, avoiding the redundant database lookup. * Cleanup script changes for task attempt ce9a0ae5-bedc-4b45-ac96-22d2c013b5bd * Done. Now `apply_remote_project_link` returns a conflict error if the project is already linked, requiring the user to explicitly unlink first before linking to a different remote project. * Both pass. Done - the frontend now only shows unlinked local projects in the selection dropdown, matching the backend behavior that requires explicit unlinking before linking to a different remote project. * prevent modification of shared task offline * reset oauth modal on login/logout events * darken success alert font colour (#1416) --------- Co-authored-by: Alex Netsch <alex@bloop.ai> Co-authored-by: Louis Knight-Webb <louis@bloop.ai> Co-authored-by: Gabriel Gordon-Hall <gabriel@bloop.ai>
306 lines
15 KiB
Rust
306 lines
15 KiB
Rust
use axum::{
|
|
Json,
|
|
extract::multipart::MultipartError,
|
|
http::StatusCode,
|
|
response::{IntoResponse, Response},
|
|
};
|
|
use db::models::{
|
|
execution_process::ExecutionProcessError, project::ProjectError, scratch::ScratchError,
|
|
task_attempt::TaskAttemptError,
|
|
};
|
|
use deployment::{DeploymentError, RemoteClientNotConfigured};
|
|
use executors::executors::ExecutorError;
|
|
use git2::Error as Git2Error;
|
|
use services::services::{
|
|
config::{ConfigError, EditorOpenError},
|
|
container::ContainerError,
|
|
git::GitServiceError,
|
|
github::GitHubServiceError,
|
|
image::ImageError,
|
|
remote_client::RemoteClientError,
|
|
share::ShareError,
|
|
worktree_manager::WorktreeError,
|
|
};
|
|
use thiserror::Error;
|
|
use utils::response::ApiResponse;
|
|
|
|
#[derive(Debug, Error, ts_rs::TS)]
|
|
#[ts(type = "string")]
|
|
pub enum ApiError {
|
|
#[error(transparent)]
|
|
Project(#[from] ProjectError),
|
|
#[error(transparent)]
|
|
TaskAttempt(#[from] TaskAttemptError),
|
|
#[error(transparent)]
|
|
ScratchError(#[from] ScratchError),
|
|
#[error(transparent)]
|
|
ExecutionProcess(#[from] ExecutionProcessError),
|
|
#[error(transparent)]
|
|
GitService(#[from] GitServiceError),
|
|
#[error(transparent)]
|
|
GitHubService(#[from] GitHubServiceError),
|
|
#[error(transparent)]
|
|
Deployment(#[from] DeploymentError),
|
|
#[error(transparent)]
|
|
Container(#[from] ContainerError),
|
|
#[error(transparent)]
|
|
Executor(#[from] ExecutorError),
|
|
#[error(transparent)]
|
|
Database(#[from] sqlx::Error),
|
|
#[error(transparent)]
|
|
Worktree(#[from] WorktreeError),
|
|
#[error(transparent)]
|
|
Config(#[from] ConfigError),
|
|
#[error(transparent)]
|
|
Image(#[from] ImageError),
|
|
#[error("Multipart error: {0}")]
|
|
Multipart(#[from] MultipartError),
|
|
#[error("IO error: {0}")]
|
|
Io(#[from] std::io::Error),
|
|
#[error(transparent)]
|
|
EditorOpen(#[from] EditorOpenError),
|
|
#[error(transparent)]
|
|
RemoteClient(#[from] RemoteClientError),
|
|
#[error("Unauthorized")]
|
|
Unauthorized,
|
|
#[error("Bad request: {0}")]
|
|
BadRequest(String),
|
|
#[error("Conflict: {0}")]
|
|
Conflict(String),
|
|
#[error("Forbidden: {0}")]
|
|
Forbidden(String),
|
|
}
|
|
|
|
impl From<&'static str> for ApiError {
|
|
fn from(msg: &'static str) -> Self {
|
|
ApiError::BadRequest(msg.to_string())
|
|
}
|
|
}
|
|
|
|
impl From<Git2Error> for ApiError {
|
|
fn from(err: Git2Error) -> Self {
|
|
ApiError::GitService(GitServiceError::from(err))
|
|
}
|
|
}
|
|
|
|
impl From<RemoteClientNotConfigured> for ApiError {
|
|
fn from(_: RemoteClientNotConfigured) -> Self {
|
|
ApiError::BadRequest("Remote client not configured".to_string())
|
|
}
|
|
}
|
|
|
|
impl IntoResponse for ApiError {
|
|
fn into_response(self) -> Response {
|
|
let (status_code, error_type) = match &self {
|
|
ApiError::Project(_) => (StatusCode::INTERNAL_SERVER_ERROR, "ProjectError"),
|
|
ApiError::TaskAttempt(_) => (StatusCode::INTERNAL_SERVER_ERROR, "TaskAttemptError"),
|
|
ApiError::ScratchError(_) => (StatusCode::INTERNAL_SERVER_ERROR, "ScratchError"),
|
|
ApiError::ExecutionProcess(err) => match err {
|
|
ExecutionProcessError::ExecutionProcessNotFound => {
|
|
(StatusCode::NOT_FOUND, "ExecutionProcessError")
|
|
}
|
|
_ => (StatusCode::INTERNAL_SERVER_ERROR, "ExecutionProcessError"),
|
|
},
|
|
// Promote certain GitService errors to conflict status with concise messages
|
|
ApiError::GitService(git_err) => match git_err {
|
|
services::services::git::GitServiceError::MergeConflicts(_) => {
|
|
(StatusCode::CONFLICT, "GitServiceError")
|
|
}
|
|
services::services::git::GitServiceError::RebaseInProgress => {
|
|
(StatusCode::CONFLICT, "GitServiceError")
|
|
}
|
|
_ => (StatusCode::INTERNAL_SERVER_ERROR, "GitServiceError"),
|
|
},
|
|
ApiError::GitHubService(_) => (StatusCode::INTERNAL_SERVER_ERROR, "GitHubServiceError"),
|
|
ApiError::Deployment(_) => (StatusCode::INTERNAL_SERVER_ERROR, "DeploymentError"),
|
|
ApiError::Container(_) => (StatusCode::INTERNAL_SERVER_ERROR, "ContainerError"),
|
|
ApiError::Executor(_) => (StatusCode::INTERNAL_SERVER_ERROR, "ExecutorError"),
|
|
ApiError::Database(_) => (StatusCode::INTERNAL_SERVER_ERROR, "DatabaseError"),
|
|
ApiError::Worktree(_) => (StatusCode::INTERNAL_SERVER_ERROR, "WorktreeError"),
|
|
ApiError::Config(_) => (StatusCode::INTERNAL_SERVER_ERROR, "ConfigError"),
|
|
ApiError::Image(img_err) => match img_err {
|
|
ImageError::InvalidFormat => (StatusCode::BAD_REQUEST, "InvalidImageFormat"),
|
|
ImageError::TooLarge(_, _) => (StatusCode::PAYLOAD_TOO_LARGE, "ImageTooLarge"),
|
|
ImageError::NotFound => (StatusCode::NOT_FOUND, "ImageNotFound"),
|
|
_ => (StatusCode::INTERNAL_SERVER_ERROR, "ImageError"),
|
|
},
|
|
ApiError::Io(_) => (StatusCode::INTERNAL_SERVER_ERROR, "IoError"),
|
|
ApiError::EditorOpen(err) => match err {
|
|
EditorOpenError::LaunchFailed { .. } => {
|
|
(StatusCode::INTERNAL_SERVER_ERROR, "EditorLaunchError")
|
|
}
|
|
_ => (StatusCode::BAD_REQUEST, "EditorOpenError"),
|
|
},
|
|
ApiError::Multipart(_) => (StatusCode::BAD_REQUEST, "MultipartError"),
|
|
ApiError::RemoteClient(err) => match err {
|
|
RemoteClientError::Auth => (StatusCode::UNAUTHORIZED, "RemoteClientError"),
|
|
RemoteClientError::Timeout => (StatusCode::GATEWAY_TIMEOUT, "RemoteClientError"),
|
|
RemoteClientError::Transport(_) => (StatusCode::BAD_GATEWAY, "RemoteClientError"),
|
|
RemoteClientError::Http { status, .. } => (
|
|
StatusCode::from_u16(*status).unwrap_or(StatusCode::BAD_GATEWAY),
|
|
"RemoteClientError",
|
|
),
|
|
RemoteClientError::Token(_) => (StatusCode::BAD_GATEWAY, "RemoteClientError"),
|
|
RemoteClientError::Api(code) => match code {
|
|
services::services::remote_client::HandoffErrorCode::NotFound => {
|
|
(StatusCode::NOT_FOUND, "RemoteClientError")
|
|
}
|
|
services::services::remote_client::HandoffErrorCode::Expired => {
|
|
(StatusCode::UNAUTHORIZED, "RemoteClientError")
|
|
}
|
|
services::services::remote_client::HandoffErrorCode::AccessDenied => {
|
|
(StatusCode::FORBIDDEN, "RemoteClientError")
|
|
}
|
|
services::services::remote_client::HandoffErrorCode::ProviderError
|
|
| services::services::remote_client::HandoffErrorCode::InternalError => {
|
|
(StatusCode::BAD_GATEWAY, "RemoteClientError")
|
|
}
|
|
_ => (StatusCode::BAD_REQUEST, "RemoteClientError"),
|
|
},
|
|
RemoteClientError::Storage(_) => {
|
|
(StatusCode::INTERNAL_SERVER_ERROR, "RemoteClientError")
|
|
}
|
|
RemoteClientError::Serde(_) | RemoteClientError::Url(_) => {
|
|
(StatusCode::BAD_REQUEST, "RemoteClientError")
|
|
}
|
|
},
|
|
ApiError::Unauthorized => (StatusCode::UNAUTHORIZED, "Unauthorized"),
|
|
ApiError::BadRequest(_) => (StatusCode::BAD_REQUEST, "BadRequest"),
|
|
ApiError::Conflict(_) => (StatusCode::CONFLICT, "ConflictError"),
|
|
ApiError::Forbidden(_) => (StatusCode::FORBIDDEN, "ForbiddenError"),
|
|
};
|
|
|
|
let error_message = match &self {
|
|
ApiError::Image(img_err) => match img_err {
|
|
ImageError::InvalidFormat => "This file type is not supported. Please upload an image file (PNG, JPG, GIF, WebP, or BMP).".to_string(),
|
|
ImageError::TooLarge(size, max) => format!(
|
|
"This image is too large ({:.1} MB). Maximum file size is {:.1} MB.",
|
|
*size as f64 / 1_048_576.0,
|
|
*max as f64 / 1_048_576.0
|
|
),
|
|
ImageError::NotFound => "Image not found.".to_string(),
|
|
_ => {
|
|
"Failed to process image. Please try again.".to_string()
|
|
}
|
|
},
|
|
ApiError::GitService(git_err) => match git_err {
|
|
services::services::git::GitServiceError::MergeConflicts(msg) => msg.clone(),
|
|
services::services::git::GitServiceError::RebaseInProgress => {
|
|
"A rebase is already in progress. Resolve conflicts or abort the rebase, then retry.".to_string()
|
|
}
|
|
_ => format!("{}: {}", error_type, self),
|
|
},
|
|
ApiError::Multipart(_) => "Failed to upload file. Please ensure the file is valid and try again.".to_string(),
|
|
ApiError::RemoteClient(err) => match err {
|
|
RemoteClientError::Auth => "Unauthorized. Please sign in again.".to_string(),
|
|
RemoteClientError::Timeout => "Remote service timeout. Please try again.".to_string(),
|
|
RemoteClientError::Transport(_) => "Remote service unavailable. Please try again.".to_string(),
|
|
RemoteClientError::Http { body, .. } => {
|
|
if body.is_empty() {
|
|
"Remote service error. Please try again.".to_string()
|
|
} else {
|
|
body.clone()
|
|
}
|
|
}
|
|
RemoteClientError::Token(_) => {
|
|
"Remote service returned an invalid access token. Please sign in again.".to_string()
|
|
}
|
|
RemoteClientError::Storage(_) => {
|
|
"Failed to persist credentials locally. Please retry.".to_string()
|
|
}
|
|
RemoteClientError::Api(code) => match code {
|
|
services::services::remote_client::HandoffErrorCode::NotFound => {
|
|
"The requested resource was not found.".to_string()
|
|
}
|
|
services::services::remote_client::HandoffErrorCode::Expired => {
|
|
"The link or token has expired.".to_string()
|
|
}
|
|
services::services::remote_client::HandoffErrorCode::AccessDenied => {
|
|
"Access denied.".to_string()
|
|
}
|
|
services::services::remote_client::HandoffErrorCode::UnsupportedProvider => {
|
|
"Unsupported authentication provider.".to_string()
|
|
}
|
|
services::services::remote_client::HandoffErrorCode::InvalidReturnUrl => {
|
|
"Invalid return URL.".to_string()
|
|
}
|
|
services::services::remote_client::HandoffErrorCode::InvalidChallenge => {
|
|
"Invalid authentication challenge.".to_string()
|
|
}
|
|
services::services::remote_client::HandoffErrorCode::ProviderError => {
|
|
"Authentication provider error. Please try again.".to_string()
|
|
}
|
|
services::services::remote_client::HandoffErrorCode::InternalError => {
|
|
"Internal remote service error. Please try again.".to_string()
|
|
}
|
|
services::services::remote_client::HandoffErrorCode::Other(msg) => {
|
|
format!("Authentication error: {}", msg)
|
|
}
|
|
},
|
|
RemoteClientError::Serde(_) => "Unexpected response from remote service.".to_string(),
|
|
RemoteClientError::Url(_) => "Remote service URL is invalid.".to_string(),
|
|
},
|
|
ApiError::Unauthorized => "Unauthorized. Please sign in again.".to_string(),
|
|
ApiError::BadRequest(msg) => msg.clone(),
|
|
ApiError::Conflict(msg) => msg.clone(),
|
|
ApiError::Forbidden(msg) => msg.clone(),
|
|
_ => format!("{}: {}", error_type, self),
|
|
};
|
|
let response = ApiResponse::<()>::error(&error_message);
|
|
(status_code, Json(response)).into_response()
|
|
}
|
|
}
|
|
|
|
impl From<ShareError> for ApiError {
|
|
fn from(err: ShareError) -> Self {
|
|
match err {
|
|
ShareError::Database(db_err) => ApiError::Database(db_err),
|
|
ShareError::AlreadyShared(_) => ApiError::Conflict("Task already shared".to_string()),
|
|
ShareError::TaskNotFound(_) => {
|
|
ApiError::Conflict("Task not found for sharing".to_string())
|
|
}
|
|
ShareError::ProjectNotFound(_) => {
|
|
ApiError::Conflict("Project not found for sharing".to_string())
|
|
}
|
|
ShareError::ProjectNotLinked(project_id) => {
|
|
tracing::warn!(
|
|
%project_id,
|
|
"project must be linked to a remote project before sharing tasks"
|
|
);
|
|
ApiError::Conflict(
|
|
"Link this project to a remote project before sharing tasks.".to_string(),
|
|
)
|
|
}
|
|
ShareError::MissingConfig(reason) => {
|
|
ApiError::Conflict(format!("Share service not configured: {reason}"))
|
|
}
|
|
ShareError::Transport(err) => {
|
|
tracing::error!(?err, "share task transport error");
|
|
ApiError::Conflict("Failed to share task with remote service".to_string())
|
|
}
|
|
ShareError::Serialization(err) => {
|
|
tracing::error!(?err, "share task serialization error");
|
|
ApiError::Conflict("Failed to parse remote share response".to_string())
|
|
}
|
|
ShareError::Url(err) => {
|
|
tracing::error!(?err, "share task URL error");
|
|
ApiError::Conflict("Share service URL is invalid".to_string())
|
|
}
|
|
ShareError::InvalidResponse => ApiError::Conflict(
|
|
"Remote share service returned an unexpected response".to_string(),
|
|
),
|
|
ShareError::MissingGitHubToken => ApiError::Conflict(
|
|
"GitHub token is required to fetch repository metadata for sharing".to_string(),
|
|
),
|
|
ShareError::Git(err) => ApiError::GitService(err),
|
|
ShareError::GitHub(err) => ApiError::GitHubService(err),
|
|
ShareError::MissingAuth => ApiError::Unauthorized,
|
|
ShareError::InvalidUserId => ApiError::Conflict("Invalid user ID format".to_string()),
|
|
ShareError::InvalidOrganizationId => {
|
|
ApiError::Conflict("Invalid organization ID format".to_string())
|
|
}
|
|
ShareError::RemoteClientError(err) => ApiError::Conflict(err.to_string()),
|
|
}
|
|
}
|
|
}
|