From e818f6b326b09c2b424b9beaf066a3213f3676ca Mon Sep 17 00:00:00 2001
From: martian56
Date: Mon, 9 Mar 2026 02:45:02 +0400
Subject: [PATCH] [FEAT] Implement Email service & Send emails for workspace
invitations
---
api/.env.example | 3 +
api/cmd/api/main.go | 16 +-
api/internal/config/config.go | 3 +
api/internal/handler/auth.go | 9 -
api/internal/handler/favorite.go | 106 ------
api/internal/handler/instance.go | 89 -----
api/internal/handler/project.go | 45 +--
api/internal/handler/upload.go | 121 -------
api/internal/handler/workspace.go | 35 +-
api/internal/mail/mail.go | 110 ++++++
api/internal/minio/minio.go | 12 -
api/internal/model/project.go | 1 -
api/internal/model/user.go | 1 -
api/internal/model/user_favorite.go | 6 +-
api/internal/router/router.go | 26 +-
api/internal/service/project.go | 14 +-
api/internal/service/workspace.go | 5 +-
api/internal/store/user_favorite.go | 53 ---
ui/src/App.tsx | 5 +-
ui/src/api/types.ts | 16 -
ui/src/components/CoverImageModal.tsx | 292 ----------------
ui/src/components/ProjectIconModal.tsx | 323 ------------------
ui/src/components/UploadImageModal.tsx | 168 ---------
ui/src/components/layout/PageHeader.tsx | 6 +-
ui/src/components/layout/Sidebar.tsx | 64 ++--
ui/src/components/work-item/CommentEditor.tsx | 2 +-
ui/src/contexts/AuthContext.tsx | 1 -
ui/src/contexts/FavoritesContext.tsx | 28 --
ui/src/lib/utils.ts | 10 -
ui/src/pages/AnalyticsWorkItemsPage.tsx | 10 +-
ui/src/pages/BoardPage.tsx | 2 +-
ui/src/pages/CyclesPage.tsx | 4 +-
ui/src/pages/InviteAcceptPage.tsx | 138 ++++++++
ui/src/pages/IssueDetailPage.tsx | 4 +-
ui/src/pages/IssueListPage.tsx | 6 +-
ui/src/pages/LoginPage.tsx | 8 +-
ui/src/pages/ProfilePage.tsx | 94 +----
ui/src/pages/ProjectsListPage.tsx | 219 ++++--------
ui/src/pages/SettingsPage.tsx | 274 ++++-----------
ui/src/pages/WorkspaceHomePage.tsx | 23 +-
ui/src/pages/WorkspaceViewsPage.tsx | 4 +-
.../instance-admin/InstanceAdminAIPage.tsx | 5 +-
.../InstanceAdminAuthenticationPage.tsx | 6 +-
.../instance-admin/InstanceAdminEmailPage.tsx | 5 +-
.../instance-admin/InstanceAdminImagePage.tsx | 5 +-
ui/src/routes/index.tsx | 20 +-
ui/src/services/instanceService.ts | 18 -
ui/src/services/projectService.ts | 3 -
ui/src/services/uploadService.ts | 21 --
ui/src/services/userService.ts | 4 +-
ui/src/services/workspaceService.ts | 14 +-
ui/src/types/index.ts | 1 -
ui/vite.config.ts | 9 -
53 files changed, 567 insertions(+), 1900 deletions(-)
delete mode 100644 api/internal/handler/favorite.go
delete mode 100644 api/internal/handler/upload.go
create mode 100644 api/internal/mail/mail.go
delete mode 100644 api/internal/store/user_favorite.go
delete mode 100644 ui/src/components/CoverImageModal.tsx
delete mode 100644 ui/src/components/ProjectIconModal.tsx
delete mode 100644 ui/src/components/UploadImageModal.tsx
delete mode 100644 ui/src/contexts/FavoritesContext.tsx
create mode 100644 ui/src/pages/InviteAcceptPage.tsx
delete mode 100644 ui/src/services/uploadService.ts
diff --git a/api/.env.example b/api/.env.example
index f8d9af2d..2ec07994 100644
--- a/api/.env.example
+++ b/api/.env.example
@@ -20,6 +20,9 @@ REDIS_DB=0
# RabbitMQ
RABBITMQ_URL=amqp://guest:guest@localhost:5672/
+# Frontend URL for invite links in emails (e.g. https://app.example.com). If unset, CORS_ORIGIN is used.
+APP_BASE_URL=https://app.example.com
+
# MinIO (S3-compatible)
MINIO_ENDPOINT=localhost:9000
MINIO_ACCESS_KEY_ID=minioadmin
diff --git a/api/cmd/api/main.go b/api/cmd/api/main.go
index c5c926c9..6be09898 100644
--- a/api/cmd/api/main.go
+++ b/api/cmd/api/main.go
@@ -12,11 +12,13 @@ import (
"github.com/Devlaner/devlane/api/internal/config"
"github.com/Devlaner/devlane/api/internal/database"
+ "github.com/Devlaner/devlane/api/internal/mail"
"github.com/Devlaner/devlane/api/internal/minio"
"github.com/Devlaner/devlane/api/internal/queue"
"github.com/Devlaner/devlane/api/internal/rabbitmq"
"github.com/Devlaner/devlane/api/internal/redis"
"github.com/Devlaner/devlane/api/internal/router"
+ "github.com/Devlaner/devlane/api/internal/store"
)
func main() {
@@ -69,12 +71,12 @@ func main() {
}
}
- // MinIO (optional: file uploads for covers, avatars, logos)
- var mc *minio.Client
- if client, err := minio.New(cfg, log); err != nil {
+ // MinIO
+ mc, err := minio.New(cfg, log)
+ if err != nil {
log.Warn("minio", "error", err)
} else {
- mc = client
+ _ = mc
}
r := router.New(router.Config{
@@ -82,8 +84,8 @@ func main() {
DB: db,
Redis: rdb,
Queue: queuePublisher,
- Minio: mc,
CORSAllowOrigin: cfg.CORSAllowOrigin,
+ AppBaseURL: cfg.AppBaseURL,
})
// Start task consumer when RabbitMQ is available
@@ -93,7 +95,9 @@ func main() {
if chConsume, err := rmq.NewChannel(); err == nil {
defer chConsume.Close()
consumer := queue.NewConsumer(chConsume, log)
- consumer.Register(queue.QueueEmails, queue.HandleSendEmail(queue.NoopEmailSender(log)))
+ instanceSettingStore := store.NewInstanceSettingStore(db)
+ emailSender := mail.NewSMTPEmailSender(instanceSettingStore, log)
+ consumer.Register(queue.QueueEmails, queue.HandleSendEmail(emailSender))
consumer.Register(queue.QueueWebhooks, queue.HandleWebhook(queue.NoopWebhookDeliverer(log)))
if err := consumer.Run(consumerCtx, []string{queue.QueueEmails, queue.QueueWebhooks}); err != nil {
log.Warn("queue consumer", "error", err)
diff --git a/api/internal/config/config.go b/api/internal/config/config.go
index fdbaf9f2..b831ee27 100644
--- a/api/internal/config/config.go
+++ b/api/internal/config/config.go
@@ -40,6 +40,8 @@ type Config struct {
MigrationsPath string
CORSAllowOrigin string
+ // AppBaseURL is the public URL of the frontend (e.g. https://app.example.com). Used for invite links in emails. If empty, CORSAllowOrigin is used.
+ AppBaseURL string
}
func (c *Config) DSN() string {
@@ -84,6 +86,7 @@ func Load() (*Config, error) {
MinIOUseSSL: minioSSL,
MigrationsPath: getEnv("MIGRATIONS_PATH", "migrations"),
CORSAllowOrigin: getEnv("CORS_ORIGIN", "http://localhost:5173"),
+ AppBaseURL: getEnv("APP_BASE_URL", ""),
}
return cfg, nil
diff --git a/api/internal/handler/auth.go b/api/internal/handler/auth.go
index 08a50639..3cce5f28 100644
--- a/api/internal/handler/auth.go
+++ b/api/internal/handler/auth.go
@@ -181,8 +181,6 @@ type UpdateMeRequest struct {
LastName *string `json:"last_name"`
DisplayName *string `json:"display_name"`
UserTimezone *string `json:"user_timezone"`
- Avatar *string `json:"avatar"`
- CoverImage *string `json:"cover_image"`
}
// UpdateMe updates the authenticated user's profile (email is not updatable).
@@ -210,12 +208,6 @@ func (h *AuthHandler) UpdateMe(c *gin.Context) {
if req.UserTimezone != nil {
user.UserTimezone = *req.UserTimezone
}
- if req.Avatar != nil {
- user.Avatar = *req.Avatar
- }
- if req.CoverImage != nil {
- user.CoverImage = *req.CoverImage
- }
if err := h.Auth.UpdateProfile(c.Request.Context(), user); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Update failed"})
return
@@ -530,7 +522,6 @@ func userResponse(u *model.User) gin.H {
"last_name": u.LastName,
"display_name": u.DisplayName,
"avatar": u.Avatar,
- "cover_image": u.CoverImage,
"is_active": u.IsActive,
"is_onboarded": u.IsOnboarded,
"date_joined": u.DateJoined,
diff --git a/api/internal/handler/favorite.go b/api/internal/handler/favorite.go
deleted file mode 100644
index 3164b3fb..00000000
--- a/api/internal/handler/favorite.go
+++ /dev/null
@@ -1,106 +0,0 @@
-package handler
-
-import (
- "net/http"
-
- "github.com/Devlaner/devlane/api/internal/middleware"
- "github.com/Devlaner/devlane/api/internal/service"
- "github.com/Devlaner/devlane/api/internal/store"
- "github.com/gin-gonic/gin"
-)
-
-// FavoriteHandler serves user favorite project endpoints.
-type FavoriteHandler struct {
- Project *service.ProjectService
- Favorites *store.UserFavoriteStore
-}
-
-// ListFavoriteProjects returns the list of favorited project IDs for the current user.
-// GET /api/users/me/favorite-projects/
-func (h *FavoriteHandler) ListFavoriteProjects(c *gin.Context) {
- user := middleware.GetUser(c)
- if user == nil {
- c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
- return
- }
- if h.Favorites == nil {
- c.JSON(http.StatusOK, gin.H{"project_ids": []string{}})
- return
- }
- ids, err := h.Favorites.ListProjectIDsByUser(c.Request.Context(), user.ID)
- if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load favorites"})
- return
- }
- strIds := make([]string, 0, len(ids))
- for _, id := range ids {
- strIds = append(strIds, id.String())
- }
- c.JSON(http.StatusOK, gin.H{"project_ids": strIds})
-}
-
-// AddFavoriteProject adds a project to the current user's favorites.
-// POST /api/workspaces/:slug/projects/:projectId/favorite
-func (h *FavoriteHandler) AddFavoriteProject(c *gin.Context) {
- user := middleware.GetUser(c)
- if user == nil {
- c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
- return
- }
- slug := c.Param("slug")
- projectID, ok := projectID(c)
- if !ok {
- return
- }
- if h.Favorites == nil || h.Project == nil {
- c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Favorites not available"})
- return
- }
- project, err := h.Project.GetByID(c.Request.Context(), slug, projectID, user.ID)
- if err != nil {
- if err == service.ErrProjectNotFound || err == service.ErrProjectForbidden {
- c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
- return
- }
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to add favorite"})
- return
- }
- if err := h.Favorites.AddProject(c.Request.Context(), user.ID, project.WorkspaceID, projectID); err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to add favorite"})
- return
- }
- c.JSON(http.StatusOK, gin.H{"project_id": projectID.String()})
-}
-
-// RemoveFavoriteProject removes a project from the current user's favorites.
-// DELETE /api/workspaces/:slug/projects/:projectId/favorite
-func (h *FavoriteHandler) RemoveFavoriteProject(c *gin.Context) {
- user := middleware.GetUser(c)
- if user == nil {
- c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
- return
- }
- projectID, ok := projectID(c)
- if !ok {
- return
- }
- if h.Favorites == nil || h.Project == nil {
- c.JSON(http.StatusOK, gin.H{"message": "ok"})
- return
- }
- // Verify user has access by resolving workspace + project
- slug := c.Param("slug")
- if _, err := h.Project.GetByID(c.Request.Context(), slug, projectID, user.ID); err != nil {
- if err == service.ErrProjectNotFound || err == service.ErrProjectForbidden {
- c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
- return
- }
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to remove favorite"})
- return
- }
- if err := h.Favorites.RemoveProject(c.Request.Context(), user.ID, projectID); err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to remove favorite"})
- return
- }
- c.JSON(http.StatusOK, gin.H{"message": "ok"})
-}
diff --git a/api/internal/handler/instance.go b/api/internal/handler/instance.go
index 1cbce22b..cf65f90f 100644
--- a/api/internal/handler/instance.go
+++ b/api/internal/handler/instance.go
@@ -3,10 +3,7 @@ package handler
import (
"crypto/rand"
"encoding/hex"
- "encoding/json"
- "io"
"net/http"
- "net/url"
"strings"
"github.com/Devlaner/devlane/api/internal/auth"
@@ -295,89 +292,3 @@ func (h *InstanceSettingsHandler) UpdateSetting(c *gin.Context) {
responseValue := decryptSectionSecrets(key, value)
c.JSON(http.StatusOK, gin.H{"key": key, "value": responseValue})
}
-
-// unsplashPhoto is a single photo from Unsplash API response.
-type unsplashPhoto struct {
- ID string `json:"id"`
- URLs struct {
- Full string `json:"full"`
- Regular string `json:"regular"`
- Thumb string `json:"thumb"`
- } `json:"urls"`
-}
-
-// unsplashSearchResponse is the Unsplash search API response.
-type unsplashSearchResponse struct {
- Results []unsplashPhoto `json:"results"`
-}
-
-// UnsplashSearchResult is a simplified photo returned by our proxy.
-type UnsplashSearchResult struct {
- ID string `json:"id"`
- URL string `json:"url"`
- Thumb string `json:"thumb"`
-}
-
-// UnsplashSearch proxies search to Unsplash API using instance image settings key (auth required).
-// GET /api/instance/unsplash/search?q=...
-func (h *InstanceSettingsHandler) UnsplashSearch(c *gin.Context) {
- if h.Settings == nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Settings not available"})
- return
- }
- row, err := h.Settings.Get(c.Request.Context(), "image")
- if err != nil || row == nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": "Unsplash is not configured"})
- return
- }
- decrypted := decryptSectionSecrets("image", row.Value)
- keyVal, _ := decrypted["unsplash_access_key"].(string)
- keyVal = strings.TrimSpace(keyVal)
- if keyVal == "" {
- c.JSON(http.StatusBadRequest, gin.H{"error": "Unsplash is not configured"})
- return
- }
-
- q := strings.TrimSpace(c.Query("q"))
- if q == "" {
- c.JSON(http.StatusOK, gin.H{"results": []UnsplashSearchResult{}})
- return
- }
-
- apiURL := "https://api.unsplash.com/search/photos?query=" + url.QueryEscape(q) + "&per_page=20"
- req, err := http.NewRequestWithContext(c.Request.Context(), http.MethodGet, apiURL, nil)
- if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to search"})
- return
- }
- req.Header.Set("Authorization", "Client-ID "+keyVal)
-
- resp, err := http.DefaultClient.Do(req)
- if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to search"})
- return
- }
- defer resp.Body.Close()
-
- if resp.StatusCode != http.StatusOK {
- _, _ = io.Copy(io.Discard, resp.Body)
- c.JSON(http.StatusBadGateway, gin.H{"error": "Unsplash search failed"})
- return
- }
-
- var payload unsplashSearchResponse
- if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Invalid response"})
- return
- }
-
- results := make([]UnsplashSearchResult, 0, len(payload.Results))
- for _, p := range payload.Results {
- u := p.URLs.Regular
- if u == "" {
- u = p.URLs.Full
- }
- results = append(results, UnsplashSearchResult{ID: p.ID, URL: u, Thumb: p.URLs.Thumb})
- }
- c.JSON(http.StatusOK, gin.H{"results": results})
-}
diff --git a/api/internal/handler/project.go b/api/internal/handler/project.go
index a4c620bf..4dc3a1fa 100644
--- a/api/internal/handler/project.go
+++ b/api/internal/handler/project.go
@@ -4,7 +4,6 @@ import (
"net/http"
"github.com/Devlaner/devlane/api/internal/middleware"
- "github.com/Devlaner/devlane/api/internal/model"
"github.com/Devlaner/devlane/api/internal/service"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
@@ -117,22 +116,19 @@ func (h *ProjectHandler) Update(c *gin.Context) {
return
}
var body struct {
- Name string `json:"name"`
- Identifier string `json:"identifier"`
- Description *string `json:"description"`
- Timezone *string `json:"timezone"`
- CoverImage *string `json:"cover_image"`
- Emoji *string `json:"emoji"`
- IconProp map[string]interface{} `json:"icon_prop"`
- ProjectLeadID *string `json:"project_lead_id"`
- DefaultAssigneeID *string `json:"default_assignee_id"`
- GuestViewAllFeatures *bool `json:"guest_view_all_features"`
- ModuleView *bool `json:"module_view"`
- CycleView *bool `json:"cycle_view"`
- IssueViewsView *bool `json:"issue_views_view"`
- PageView *bool `json:"page_view"`
- IntakeView *bool `json:"intake_view"`
- IsTimeTrackingEnabled *bool `json:"is_time_tracking_enabled"`
+ Name string `json:"name"`
+ Identifier string `json:"identifier"`
+ Description *string `json:"description"`
+ Timezone *string `json:"timezone"`
+ ProjectLeadID *string `json:"project_lead_id"`
+ DefaultAssigneeID *string `json:"default_assignee_id"`
+ GuestViewAllFeatures *bool `json:"guest_view_all_features"`
+ ModuleView *bool `json:"module_view"`
+ CycleView *bool `json:"cycle_view"`
+ IssueViewsView *bool `json:"issue_views_view"`
+ PageView *bool `json:"page_view"`
+ IntakeView *bool `json:"intake_view"`
+ IsTimeTrackingEnabled *bool `json:"is_time_tracking_enabled"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body", "detail": err.Error()})
@@ -151,19 +147,6 @@ func (h *ProjectHandler) Update(c *gin.Context) {
if body.Timezone != nil {
timezone = body.Timezone
}
- var coverImage *string
- if body.CoverImage != nil {
- coverImage = body.CoverImage
- }
- var iconProp *model.JSONMap
- if body.Emoji != nil && *body.Emoji != "" {
- // When setting emoji, clear icon_prop
- empty := model.JSONMap{}
- iconProp = &empty
- } else if len(body.IconProp) > 0 {
- ip := model.JSONMap(body.IconProp)
- iconProp = &ip
- }
var projectLeadIDPtr *uuid.UUID
if body.ProjectLeadID != nil {
if *body.ProjectLeadID == "" {
@@ -190,7 +173,7 @@ func (h *ProjectHandler) Update(c *gin.Context) {
defaultAssigneeIDPtr = &id
}
}
- p, err := h.Project.Update(c.Request.Context(), slug, projectID, user.ID, name, identifier, description, timezone, coverImage, body.Emoji, iconProp, body.ProjectLeadID != nil, projectLeadIDPtr, body.DefaultAssigneeID != nil, defaultAssigneeIDPtr, body.GuestViewAllFeatures, body.ModuleView, body.CycleView, body.IssueViewsView, body.PageView, body.IntakeView, body.IsTimeTrackingEnabled)
+ p, err := h.Project.Update(c.Request.Context(), slug, projectID, user.ID, name, identifier, description, timezone, body.ProjectLeadID != nil, projectLeadIDPtr, body.DefaultAssigneeID != nil, defaultAssigneeIDPtr, body.GuestViewAllFeatures, body.ModuleView, body.CycleView, body.IssueViewsView, body.PageView, body.IntakeView, body.IsTimeTrackingEnabled)
if err != nil {
if err == service.ErrProjectNotFound || err == service.ErrProjectForbidden {
c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
diff --git a/api/internal/handler/upload.go b/api/internal/handler/upload.go
deleted file mode 100644
index 62ffbc2f..00000000
--- a/api/internal/handler/upload.go
+++ /dev/null
@@ -1,121 +0,0 @@
-package handler
-
-import (
- "bytes"
- "fmt"
- "io"
- "net/http"
- "path/filepath"
- "strings"
- "time"
-
- "github.com/Devlaner/devlane/api/internal/middleware"
- "github.com/Devlaner/devlane/api/internal/minio"
- "github.com/gin-gonic/gin"
- "github.com/google/uuid"
-)
-
-// UploadHandler handles file uploads to MinIO.
-type UploadHandler struct {
- Minio *minio.Client
-}
-
-var allowedImageTypes = map[string]bool{
- "image/jpeg": true,
- "image/jpg": true,
- "image/png": true,
- "image/webp": true,
-}
-
-// Upload accepts a multipart file and uploads it to MinIO.
-// POST /api/upload
-// Form: file (required). Returns { "url": "/api/files/uploads/..." }.
-func (h *UploadHandler) Upload(c *gin.Context) {
- if h.Minio == nil {
- c.JSON(http.StatusServiceUnavailable, gin.H{"error": "File upload is not configured"})
- return
- }
- user := middleware.GetUser(c)
- if user == nil {
- c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
- return
- }
-
- file, err := c.FormFile("file")
- if err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": "No file provided", "detail": err.Error()})
- return
- }
-
- f, err := file.Open()
- if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read file"})
- return
- }
- defer f.Close()
-
- // Detect MIME from file bytes instead of trusting client Content-Type
- buf := make([]byte, 512)
- n, _ := io.ReadFull(f, buf)
- buf = buf[:n]
- contentType := http.DetectContentType(buf)
- // Go's DetectContentType may not recognize WebP; check magic bytes (RIFF....WEBP)
- if contentType == "application/octet-stream" && n >= 12 && string(buf[0:4]) == "RIFF" && string(buf[8:12]) == "WEBP" {
- contentType = "image/webp"
- }
- if !allowedImageTypes[contentType] {
- c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid file type. Supported: .jpeg, .jpg, .png, .webp"})
- return
- }
-
- // Recombine read bytes with remainder for upload
- body := io.MultiReader(bytes.NewReader(buf), f)
-
- ext := strings.ToLower(filepath.Ext(file.Filename))
- if ext == "" {
- ext = ".jpg"
- }
- if ext != ".jpeg" && ext != ".jpg" && ext != ".png" && ext != ".webp" {
- ext = ".jpg"
- }
-
- now := time.Now().UTC()
- objectName := fmt.Sprintf("uploads/%d/%02d/%s%s", now.Year(), now.Month(), uuid.New().String(), ext)
-
- if err := h.Minio.PutObject(c.Request.Context(), objectName, body, file.Size, contentType); err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to upload file"})
- return
- }
-
- c.JSON(http.StatusOK, gin.H{"url": "/api/files/" + objectName})
-}
-
-// ServeFile streams a file from MinIO by path.
-// GET /api/files/*path
-func (h *UploadHandler) ServeFile(c *gin.Context) {
- if h.Minio == nil {
- c.Status(http.StatusServiceUnavailable)
- return
- }
- path := strings.TrimPrefix(c.Param("path"), "/")
- if path == "" || strings.Contains(path, "..") || !strings.HasPrefix(path, "uploads/") {
- c.Status(http.StatusBadRequest)
- return
- }
-
- obj, err := h.Minio.GetObject(c.Request.Context(), path)
- if err != nil {
- c.Status(http.StatusNotFound)
- return
- }
- defer obj.Close()
-
- info, err := obj.Stat()
- if err != nil {
- c.Status(http.StatusNotFound)
- return
- }
-
- c.Header("Content-Type", info.ContentType)
- c.DataFromReader(http.StatusOK, info.Size, info.ContentType, obj, nil)
-}
diff --git a/api/internal/handler/workspace.go b/api/internal/handler/workspace.go
index 9c6489d8..50a0fb52 100644
--- a/api/internal/handler/workspace.go
+++ b/api/internal/handler/workspace.go
@@ -1,10 +1,12 @@
package handler
import (
+ "fmt"
"net/http"
"strings"
"github.com/Devlaner/devlane/api/internal/middleware"
+ "github.com/Devlaner/devlane/api/internal/queue"
"github.com/Devlaner/devlane/api/internal/service"
"github.com/Devlaner/devlane/api/internal/store"
"github.com/gin-gonic/gin"
@@ -13,8 +15,10 @@ import (
// WorkspaceHandler serves workspace and member/invite endpoints.
type WorkspaceHandler struct {
- Workspace *service.WorkspaceService
- Settings *store.InstanceSettingStore
+ Workspace *service.WorkspaceService
+ Settings *store.InstanceSettingStore
+ Queue *queue.Publisher // optional: enqueue invite emails
+ AppBaseURL string // optional: base URL for invite links (e.g. https://app.example.com)
}
// List returns the current user's workspaces.
@@ -113,9 +117,8 @@ func (h *WorkspaceHandler) Update(c *gin.Context) {
}
slug := c.Param("slug")
var body struct {
- Name string `json:"name"`
- Slug string `json:"slug"`
- Logo *string `json:"logo"`
+ Name string `json:"name"`
+ Slug string `json:"slug"`
}
_ = c.ShouldBindJSON(&body)
var name, newSlug *string
@@ -125,7 +128,7 @@ func (h *WorkspaceHandler) Update(c *gin.Context) {
if body.Slug != "" {
newSlug = &body.Slug
}
- w, err := h.Workspace.Update(c.Request.Context(), slug, user.ID, name, newSlug, body.Logo)
+ w, err := h.Workspace.Update(c.Request.Context(), slug, user.ID, name, newSlug)
if err != nil {
if err == service.ErrWorkspaceNotFound || err == service.ErrWorkspaceForbidden {
c.JSON(http.StatusNotFound, gin.H{"error": "Workspace not found"})
@@ -373,6 +376,26 @@ func (h *WorkspaceHandler) CreateInvite(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create invite"})
return
}
+
+ // Enqueue invite email when queue and base URL are configured
+ if h.Queue != nil && h.AppBaseURL != "" {
+ w, _ := h.Workspace.GetBySlug(c.Request.Context(), slug, user.ID)
+ workspaceName := slug
+ if w != nil {
+ workspaceName = w.Name
+ }
+ inviteLink := strings.TrimSuffix(h.AppBaseURL, "/") + "/invite?token=" + inv.Token
+ subject := fmt.Sprintf("You're invited to join %s on Devlane", workspaceName)
+ bodyText := fmt.Sprintf("You have been invited to join the workspace \"%s\" on Devlane.\n\nAccept your invitation by visiting:\n%s\n\nIf you don't have an account yet, you can sign up at the same link.\n", workspaceName, inviteLink)
+ _ = h.Queue.PublishSendEmail(c.Request.Context(), queue.SendEmailPayload{
+ To: inv.Email,
+ Subject: subject,
+ Body: bodyText,
+ Kind: "workspace_invite",
+ Extra: map[string]string{"workspace_slug": slug, "invite_id": inv.ID.String()},
+ })
+ }
+
c.JSON(http.StatusCreated, inv)
}
diff --git a/api/internal/mail/mail.go b/api/internal/mail/mail.go
new file mode 100644
index 00000000..6573e321
--- /dev/null
+++ b/api/internal/mail/mail.go
@@ -0,0 +1,110 @@
+package mail
+
+import (
+ "context"
+ "fmt"
+ "log/slog"
+ "net/smtp"
+ "strconv"
+ "strings"
+
+ "github.com/Devlaner/devlane/api/internal/crypto"
+ "github.com/Devlaner/devlane/api/internal/store"
+)
+
+// smtpSettings from instance "email" section (after decrypting password).
+type smtpSettings struct {
+ Host string
+ Port int
+ SenderEmail string
+ Security string
+ Username string
+ Password string
+}
+
+func getEmailSettings(ctx context.Context, s *store.InstanceSettingStore) (*smtpSettings, error) {
+ row, err := s.Get(ctx, "email")
+ if err != nil || row == nil {
+ return nil, fmt.Errorf("email settings not found")
+ }
+ v := row.Value
+ if v == nil {
+ return nil, fmt.Errorf("email settings empty")
+ }
+ host, _ := v["host"].(string)
+ port := 587
+ if p, ok := v["port"].(string); ok && p != "" {
+ if n, err := strconv.Atoi(p); err == nil {
+ port = n
+ }
+ }
+ if p, ok := v["port"].(float64); ok {
+ port = int(p)
+ }
+ sender, _ := v["sender_email"].(string)
+ security, _ := v["security"].(string)
+ username, _ := v["username"].(string)
+ passRaw, _ := v["password"].(string)
+ password := crypto.DecryptOrPlain(passRaw)
+ host = strings.TrimSpace(host)
+ if host == "" {
+ return nil, fmt.Errorf("email host not configured")
+ }
+ return &smtpSettings{
+ Host: host,
+ Port: port,
+ SenderEmail: strings.TrimSpace(sender),
+ Security: strings.TrimSpace(security),
+ Username: strings.TrimSpace(username),
+ Password: password,
+ }, nil
+}
+
+// NewSMTPEmailSender returns a sender function that loads SMTP config from instance
+// "email" settings and sends the email. If email is not configured or sending fails,
+// it logs and returns an error.
+func NewSMTPEmailSender(instanceSettings *store.InstanceSettingStore, log *slog.Logger) func(ctx context.Context, to, subject, body string) error {
+ return func(ctx context.Context, to, subject, body string) error {
+ cfg, err := getEmailSettings(ctx, instanceSettings)
+ if err != nil {
+ if log != nil {
+ log.Warn("mail skip: instance email not configured", "to", to, "error", err)
+ }
+ return err
+ }
+ from := cfg.SenderEmail
+ if from == "" {
+ from = cfg.Username
+ }
+ if from == "" {
+ if log != nil {
+ log.Warn("mail skip: sender_email and username empty", "to", to)
+ }
+ return fmt.Errorf("sender email not configured")
+ }
+ addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port)
+ auth := smtp.PlainAuth("", cfg.Username, cfg.Password, cfg.Host)
+ msg := buildMessage(to, from, subject, body)
+ if err := smtp.SendMail(addr, auth, from, []string{to}, msg); err != nil {
+ if log != nil {
+ log.Warn("mail send failed", "to", to, "error", err)
+ }
+ return err
+ }
+ if log != nil {
+ log.Info("mail sent", "to", to, "subject", subject)
+ }
+ return nil
+ }
+}
+
+func buildMessage(to, from, subject, body string) []byte {
+ const crlf = "\r\n"
+ h := "To: " + to + crlf +
+ "From: " + from + crlf +
+ "Subject: " + subject + crlf +
+ "Content-Type: text/plain; charset=UTF-8" + crlf +
+ "MIME-Version: 1.0" + crlf +
+ crlf
+ return []byte(h + body)
+}
diff --git a/api/internal/minio/minio.go b/api/internal/minio/minio.go
index 5c2f7ac8..5b6b0542 100644
--- a/api/internal/minio/minio.go
+++ b/api/internal/minio/minio.go
@@ -3,7 +3,6 @@ package minio
import (
"context"
"fmt"
- "io"
"log/slog"
"github.com/Devlaner/devlane/api/internal/config"
@@ -54,14 +53,3 @@ func New(cfg *config.Config, log *slog.Logger) (*Client, error) {
func (c *Client) Bucket() string {
return c.bucket
}
-
-// PutObject uploads data to the default bucket.
-func (c *Client) PutObject(ctx context.Context, objectName string, reader io.Reader, size int64, contentType string) error {
- _, err := c.Client.PutObject(ctx, c.bucket, objectName, reader, size, minio.PutObjectOptions{ContentType: contentType})
- return err
-}
-
-// GetObject returns a reader for the object from the default bucket.
-func (c *Client) GetObject(ctx context.Context, objectName string) (*minio.Object, error) {
- return c.Client.GetObject(ctx, c.bucket, objectName, minio.GetObjectOptions{})
-}
diff --git a/api/internal/model/project.go b/api/internal/model/project.go
index 3c6fe192..af788af1 100644
--- a/api/internal/model/project.go
+++ b/api/internal/model/project.go
@@ -51,7 +51,6 @@ type Project struct {
IntakeView bool `gorm:"default:true" json:"intake_view"`
IsTimeTrackingEnabled bool `gorm:"column:is_time_tracking_enabled;default:false" json:"is_time_tracking_enabled"`
GuestViewAllFeatures bool `gorm:"column:guest_view_all_features;default:false" json:"guest_view_all_features"`
- CoverImage string `gorm:"column:cover_image;type:text" json:"cover_image,omitempty"`
Timezone string `gorm:"default:UTC" json:"timezone"`
}
diff --git a/api/internal/model/user.go b/api/internal/model/user.go
index ef25a062..f0791687 100644
--- a/api/internal/model/user.go
+++ b/api/internal/model/user.go
@@ -17,7 +17,6 @@ type User struct {
LastName string `gorm:"column:last_name;type:varchar(255);default:''" json:"last_name"`
DisplayName string `gorm:"column:display_name;type:varchar(255)" json:"display_name"`
Avatar string `gorm:"type:text" json:"avatar,omitempty"`
- CoverImage string `gorm:"column:cover_image;type:text" json:"cover_image,omitempty"`
DateJoined time.Time `gorm:"column:date_joined;not null" json:"date_joined"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
diff --git a/api/internal/model/user_favorite.go b/api/internal/model/user_favorite.go
index 47a2867c..c61733a7 100644
--- a/api/internal/model/user_favorite.go
+++ b/api/internal/model/user_favorite.go
@@ -12,11 +12,11 @@ type UserFavorite struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
Name string `gorm:"type:varchar(255);not null" json:"name"`
Type string `gorm:"type:varchar(50);not null" json:"type"`
- EntityType string `gorm:"type:varchar(50);not null;uniqueIndex:idx_user_fav_entity" json:"entity_type"`
- EntityIdentifier uuid.UUID `gorm:"type:uuid;not null;uniqueIndex:idx_user_fav_entity" json:"entity_identifier"`
+ EntityType string `gorm:"type:varchar(50);not null" json:"entity_type"`
+ EntityIdentifier uuid.UUID `gorm:"type:uuid;not null" json:"entity_identifier"`
WorkspaceID uuid.UUID `gorm:"type:uuid;not null" json:"workspace_id"`
ProjectID *uuid.UUID `gorm:"type:uuid" json:"project_id,omitempty"`
- UserID uuid.UUID `gorm:"type:uuid;not null;uniqueIndex:idx_user_fav_entity" json:"user_id"`
+ UserID uuid.UUID `gorm:"type:uuid;not null" json:"user_id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
CreatedByID *uuid.UUID `gorm:"type:uuid" json:"created_by_id,omitempty"`
diff --git a/api/internal/router/router.go b/api/internal/router/router.go
index e4f389ff..ed0a787c 100644
--- a/api/internal/router/router.go
+++ b/api/internal/router/router.go
@@ -6,7 +6,6 @@ import (
"github.com/Devlaner/devlane/api/internal/auth"
"github.com/Devlaner/devlane/api/internal/handler"
"github.com/Devlaner/devlane/api/internal/middleware"
- "github.com/Devlaner/devlane/api/internal/minio"
"github.com/Devlaner/devlane/api/internal/queue"
"github.com/Devlaner/devlane/api/internal/redis"
"github.com/Devlaner/devlane/api/internal/service"
@@ -21,8 +20,8 @@ type Config struct {
DB *gorm.DB
Redis *redis.Client // optional: cache, locks, magic-link
Queue *queue.Publisher // optional: enqueue emails, webhooks
- Minio *minio.Client // optional: file uploads (cover images, avatars, logos)
CORSAllowOrigin string // optional: e.g. "http://localhost:5173" for UI dev
+ AppBaseURL string // optional: base URL for invite links; if empty, CORSAllowOrigin is used
}
// New builds and returns the Gin engine with /api/ and /auth/ routes.
@@ -66,7 +65,6 @@ func New(cfg Config) *gin.Engine {
userRecentVisitStore := store.NewUserRecentVisitStore(cfg.DB)
userNotifPrefStore := store.NewUserNotificationPreferenceStore(cfg.DB)
apiTokenStore := store.NewApiTokenStore(cfg.DB)
- userFavoriteStore := store.NewUserFavoriteStore(cfg.DB)
// Auth
authSvc := auth.NewService(userStore, sessionStore)
@@ -94,10 +92,20 @@ func New(cfg Config) *gin.Engine {
stickySvc := service.NewStickyService(stickyStore, workspaceStore)
recentVisitSvc := service.NewRecentVisitService(userRecentVisitStore, workspaceStore, issueStore, projectStore, pageStore)
+ // Base URL for invite links (e.g. email links to frontend)
+ appBaseURL := cfg.AppBaseURL
+ if appBaseURL == "" {
+ appBaseURL = cfg.CORSAllowOrigin
+ }
+
// Handlers
- workspaceHandler := &handler.WorkspaceHandler{Workspace: workspaceSvc, Settings: instanceSettingStore}
+ workspaceHandler := &handler.WorkspaceHandler{
+ Workspace: workspaceSvc,
+ Settings: instanceSettingStore,
+ Queue: cfg.Queue,
+ AppBaseURL: appBaseURL,
+ }
projectHandler := &handler.ProjectHandler{Project: projectSvc}
- favoriteHandler := &handler.FavoriteHandler{Project: projectSvc, Favorites: userFavoriteStore}
stateHandler := &handler.StateHandler{State: stateSvc}
labelHandler := &handler.LabelHandler{Label: labelSvc}
issueHandler := &handler.IssueHandler{Issue: issueSvc}
@@ -125,14 +133,8 @@ func New(cfg Config) *gin.Engine {
api.GET("/users/me/tokens/", authHandler.ListTokens)
api.POST("/users/me/tokens/", authHandler.CreateToken)
api.DELETE("/users/me/tokens/:id/", authHandler.RevokeToken)
- api.GET("/users/me/favorite-projects/", favoriteHandler.ListFavoriteProjects)
api.GET("/instance/settings/", instanceSettingsHandler.GetSettings)
api.PATCH("/instance/settings/:key", instanceSettingsHandler.UpdateSetting)
- api.GET("/instance/unsplash/search", instanceSettingsHandler.UnsplashSearch)
-
- uploadHandler := &handler.UploadHandler{Minio: cfg.Minio}
- api.POST("/upload", uploadHandler.Upload)
- api.GET("/files/*path", uploadHandler.ServeFile)
api.GET("/users/me/workspaces/", workspaceHandler.List)
api.GET("/users/me/workspaces/invitations/", workspaceHandler.ListUserInvitations)
api.GET("/workspace-slug-check/", workspaceHandler.SlugCheck)
@@ -159,8 +161,6 @@ func New(cfg Config) *gin.Engine {
api.GET("/workspaces/:slug/projects/:projectId/", projectHandler.Get)
api.PATCH("/workspaces/:slug/projects/:projectId/", projectHandler.Update)
api.DELETE("/workspaces/:slug/projects/:projectId/", projectHandler.Delete)
- api.POST("/workspaces/:slug/projects/:projectId/favorite", favoriteHandler.AddFavoriteProject)
- api.DELETE("/workspaces/:slug/projects/:projectId/favorite", favoriteHandler.RemoveFavoriteProject)
api.GET("/workspaces/:slug/projects/:projectId/members/", projectHandler.ListMembers)
api.POST("/workspaces/:slug/projects/:projectId/members/leave/", projectHandler.Leave)
api.GET("/workspaces/:slug/projects/:projectId/members/:pk/", projectHandler.GetMember)
diff --git a/api/internal/service/project.go b/api/internal/service/project.go
index bfbfe500..0503300d 100644
--- a/api/internal/service/project.go
+++ b/api/internal/service/project.go
@@ -78,7 +78,7 @@ func (s *ProjectService) Create(ctx context.Context, workspaceSlug, name, identi
return p, nil
}
-func (s *ProjectService) Update(ctx context.Context, workspaceSlug string, projectID uuid.UUID, userID uuid.UUID, name, identifier, description, timezone, coverImage *string, emoji *string, iconProp *model.JSONMap, projectLeadIDSet bool, projectLeadID *uuid.UUID, defaultAssigneeIDSet bool, defaultAssigneeID *uuid.UUID, guestViewAllFeatures *bool, moduleView, cycleView, issueViewsView, pageView, intakeView, isTimeTrackingEnabled *bool) (*model.Project, error) {
+func (s *ProjectService) Update(ctx context.Context, workspaceSlug string, projectID uuid.UUID, userID uuid.UUID, name, identifier, description, timezone *string, projectLeadIDSet bool, projectLeadID *uuid.UUID, defaultAssigneeIDSet bool, defaultAssigneeID *uuid.UUID, guestViewAllFeatures *bool, moduleView, cycleView, issueViewsView, pageView, intakeView, isTimeTrackingEnabled *bool) (*model.Project, error) {
p, err := s.GetByID(ctx, workspaceSlug, projectID, userID)
if err != nil {
return nil, err
@@ -95,18 +95,6 @@ func (s *ProjectService) Update(ctx context.Context, workspaceSlug string, proje
if timezone != nil {
p.Timezone = *timezone
}
- if coverImage != nil {
- p.CoverImage = *coverImage
- }
- if emoji != nil {
- p.Emoji = *emoji
- }
- if iconProp != nil {
- p.IconProp = *iconProp
- if len(*iconProp) > 0 {
- p.Emoji = "" // clear emoji when setting icon
- }
- }
if projectLeadIDSet {
p.ProjectLeadID = projectLeadID
}
diff --git a/api/internal/service/workspace.go b/api/internal/service/workspace.go
index bf587854..2584502e 100644
--- a/api/internal/service/workspace.go
+++ b/api/internal/service/workspace.go
@@ -83,7 +83,7 @@ func (s *WorkspaceService) Create(ctx context.Context, name, slug string, ownerI
return w, nil
}
-func (s *WorkspaceService) Update(ctx context.Context, slug string, userID uuid.UUID, name, newSlug, logo *string) (*model.Workspace, error) {
+func (s *WorkspaceService) Update(ctx context.Context, slug string, userID uuid.UUID, name, newSlug *string) (*model.Workspace, error) {
w, err := s.GetBySlug(ctx, slug, userID)
if err != nil {
return nil, err
@@ -91,9 +91,6 @@ func (s *WorkspaceService) Update(ctx context.Context, slug string, userID uuid.
if name != nil {
w.Name = *name
}
- if logo != nil {
- w.Logo = *logo
- }
if newSlug != nil {
slugVal := strings.TrimSpace(strings.ToLower(*newSlug))
if !slugRegex.MatchString(slugVal) {
diff --git a/api/internal/store/user_favorite.go b/api/internal/store/user_favorite.go
deleted file mode 100644
index 538f7b70..00000000
--- a/api/internal/store/user_favorite.go
+++ /dev/null
@@ -1,53 +0,0 @@
-package store
-
-import (
- "context"
-
- "github.com/Devlaner/devlane/api/internal/model"
- "github.com/google/uuid"
- "gorm.io/gorm"
- "gorm.io/gorm/clause"
-)
-
-const FavoriteEntityTypeProject = "project"
-
-// UserFavoriteStore handles user_favorites persistence.
-type UserFavoriteStore struct{ db *gorm.DB }
-
-func NewUserFavoriteStore(db *gorm.DB) *UserFavoriteStore {
- return &UserFavoriteStore{db: db}
-}
-
-// ListProjectIDsByUser returns project IDs the user has favorited.
-func (s *UserFavoriteStore) ListProjectIDsByUser(ctx context.Context, userID uuid.UUID) ([]uuid.UUID, error) {
- var ids []uuid.UUID
- err := s.db.WithContext(ctx).Model(&model.UserFavorite{}).
- Where("user_id = ? AND entity_type = ?", userID, FavoriteEntityTypeProject).
- Pluck("entity_identifier", &ids).Error
- return ids, err
-}
-
-// AddProject adds a project to the user's favorites. workspaceID is stored for the favorite record.
-// Idempotent: uses ON CONFLICT DO NOTHING so duplicate inserts are ignored.
-func (s *UserFavoriteStore) AddProject(ctx context.Context, userID, workspaceID, projectID uuid.UUID) error {
- fav := &model.UserFavorite{
- Name: "project",
- Type: "project",
- EntityType: FavoriteEntityTypeProject,
- EntityIdentifier: projectID,
- WorkspaceID: workspaceID,
- ProjectID: &projectID,
- UserID: userID,
- }
- return s.db.WithContext(ctx).Clauses(clause.OnConflict{
- Columns: []clause.Column{{Name: "user_id"}, {Name: "entity_type"}, {Name: "entity_identifier"}},
- DoNothing: true,
- }).Create(fav).Error
-}
-
-// RemoveProject removes a project from the user's favorites.
-func (s *UserFavoriteStore) RemoveProject(ctx context.Context, userID uuid.UUID, projectID uuid.UUID) error {
- return s.db.WithContext(ctx).
- Where("user_id = ? AND entity_type = ? AND entity_identifier = ?", userID, FavoriteEntityTypeProject, projectID).
- Delete(&model.UserFavorite{}).Error
-}
diff --git a/ui/src/App.tsx b/ui/src/App.tsx
index 089c692c..b0d87bbb 100644
--- a/ui/src/App.tsx
+++ b/ui/src/App.tsx
@@ -1,6 +1,5 @@
import { RouterProvider } from "react-router-dom";
import { AuthProvider } from "./contexts/AuthContext";
-import { FavoritesProvider } from "./contexts/FavoritesContext";
import { ThemeProvider } from "./contexts/ThemeContext";
import { router } from "./routes";
@@ -8,9 +7,7 @@ export default function App() {
return (
-
-
-
+
);
diff --git a/ui/src/api/types.ts b/ui/src/api/types.ts
index 9eb77ad2..2b96523c 100644
--- a/ui/src/api/types.ts
+++ b/ui/src/api/types.ts
@@ -15,7 +15,6 @@ export interface WorkspaceApiResponse {
name: string;
slug: string;
owner_id: string;
- logo?: string;
created_at?: string;
updated_at?: string;
}
@@ -51,12 +50,6 @@ export interface CreateProjectRequest {
identifier?: string;
}
-/** Project icon_prop from API (name + optional color) */
-export interface ProjectIconProp {
- name?: string;
- color?: string;
-}
-
/** Project as returned by the API (list + get) */
export interface ProjectApiResponse {
id: string;
@@ -66,9 +59,6 @@ export interface ProjectApiResponse {
identifier?: string;
slug?: string;
timezone?: string;
- cover_image?: string;
- emoji?: string;
- icon_prop?: ProjectIconProp | null;
project_lead_id?: string | null;
default_assignee_id?: string | null;
guest_view_all_features?: boolean;
@@ -198,7 +188,6 @@ export interface UserApiResponse {
last_name: string;
display_name: string;
avatar?: string;
- cover_image?: string;
is_active: boolean;
is_onboarded: boolean;
date_joined: string;
@@ -213,8 +202,6 @@ export interface UpdateMeRequest {
last_name?: string;
display_name?: string;
user_timezone?: string;
- avatar?: string;
- cover_image?: string;
}
/** POST /api/users/me/change-password/ */
@@ -381,11 +368,8 @@ export interface IssueViewApiResponse {
export interface PageApiResponse {
id: string;
name: string;
- /** Display title (may equal name); use for list display. */
- title?: string;
description_html?: string;
owned_by_id: string;
- updated_by_id?: string | null;
workspace_id: string;
access: number;
parent_id?: string | null;
diff --git a/ui/src/components/CoverImageModal.tsx b/ui/src/components/CoverImageModal.tsx
deleted file mode 100644
index 84e25d3b..00000000
--- a/ui/src/components/CoverImageModal.tsx
+++ /dev/null
@@ -1,292 +0,0 @@
-import { useCallback, useEffect, useState } from "react";
-import { Button, Modal } from "./ui";
-import {
- instanceSettingsService,
- type UnsplashSearchResult,
-} from "../services/instanceService";
-import { uploadImage } from "../services/uploadService";
-
-const TAB_UNSPLASH = "unsplash";
-const TAB_UPLOAD = "upload";
-type Tab = typeof TAB_UNSPLASH | typeof TAB_UPLOAD;
-
-export interface CoverImageModalProps {
- open: boolean;
- onClose: () => void;
- onSelect: (url: string) => void;
- title?: string;
-}
-
-export function CoverImageModal({
- open,
- onClose,
- onSelect,
- title = "Select cover image",
-}: CoverImageModalProps) {
- const [tab, setTab] = useState(TAB_UNSPLASH);
- const [unsplashQuery, setUnsplashQuery] = useState("");
- const [unsplashResults, setUnsplashResults] = useState<
- UnsplashSearchResult[]
- >([]);
- const [unsplashLoading, setUnsplashLoading] = useState(false);
- const [unsplashError, setUnsplashError] = useState(null);
- const [selectedUrl, setSelectedUrl] = useState(null);
- const [uploadFile, setUploadFile] = useState(null);
- const [uploadPreview, setUploadPreview] = useState(null);
- const [uploadLoading, setUploadLoading] = useState(false);
- const [uploadError, setUploadError] = useState(null);
-
- useEffect(() => {
- if (!open) {
- setTab(TAB_UNSPLASH);
- setUnsplashQuery("");
- setUnsplashResults([]);
- setUnsplashError(null);
- setSelectedUrl(null);
- setUploadFile(null);
- setUploadPreview(null);
- setUploadError(null);
- }
- }, [open]);
-
- const handleSearch = useCallback(async () => {
- if (!unsplashQuery.trim()) return;
- setUnsplashError(null);
- setUnsplashLoading(true);
- try {
- const { results } = await instanceSettingsService.unsplashSearch(
- unsplashQuery.trim(),
- );
- setUnsplashResults(results);
- } catch (e) {
- setUnsplashError(e instanceof Error ? e.message : "Search failed");
- setUnsplashResults([]);
- } finally {
- setUnsplashLoading(false);
- }
- }, [unsplashQuery]);
-
- const handleUnsplashSelect = useCallback(() => {
- if (selectedUrl) {
- onSelect(selectedUrl);
- onClose();
- }
- }, [selectedUrl, onSelect, onClose]);
-
- const handleFileChange = useCallback(
- (e: React.ChangeEvent) => {
- const file = e.target.files?.[0];
- if (!file) return;
- const allowed = ["image/jpeg", "image/jpg", "image/png", "image/webp"];
- if (!allowed.includes(file.type)) {
- setUploadError(
- "Invalid file type. Supported: .jpeg, .jpg, .png, .webp",
- );
- return;
- }
- setUploadError(null);
- setUploadFile(file);
- const reader = new FileReader();
- reader.onload = () => setUploadPreview(reader.result as string);
- reader.readAsDataURL(file);
- },
- [],
- );
-
- const handleDrop = useCallback((e: React.DragEvent) => {
- e.preventDefault();
- const file = e.dataTransfer.files?.[0];
- if (!file) return;
- const allowed = ["image/jpeg", "image/jpg", "image/png", "image/webp"];
- if (!allowed.includes(file.type)) {
- setUploadError("Invalid file type. Supported: .jpeg, .jpg, .png, .webp");
- return;
- }
- setUploadError(null);
- setUploadFile(file);
- const reader = new FileReader();
- reader.onload = () => setUploadPreview(reader.result as string);
- reader.readAsDataURL(file);
- }, []);
-
- const handleDragOver = useCallback(
- (e: React.DragEvent) => e.preventDefault(),
- [],
- );
-
- const handleUploadSave = useCallback(async () => {
- if (!uploadFile) return;
- setUploadError(null);
- setUploadLoading(true);
- try {
- const { url } = await uploadImage(uploadFile);
- onSelect(url);
- onClose();
- } catch (e) {
- setUploadError(e instanceof Error ? e.message : "Upload failed");
- } finally {
- setUploadLoading(false);
- }
- }, [uploadFile, onSelect, onClose]);
-
- const handleRemoveUpload = useCallback(() => {
- setUploadFile(null);
- setUploadPreview(null);
- setUploadError(null);
- }, []);
-
- const footer = (
- <>
-
- {tab === TAB_UNSPLASH && (
-
- )}
- {tab === TAB_UPLOAD && (
-
- )}
- >
- );
-
- return (
-
-
-
-
-
-
- {tab === TAB_UNSPLASH && (
-
-
- setUnsplashQuery(e.target.value)}
- onKeyDown={(e) => e.key === "Enter" && handleSearch()}
- placeholder="Search for images"
- className="min-w-0 flex-1 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--bg-surface-1)] px-3 py-2 text-sm text-[var(--txt-primary)] placeholder:text-[var(--txt-placeholder)] focus:outline-none focus:border-[var(--border-strong)]"
- />
-
-
- {unsplashError && (
-
- {unsplashError}
-
- )}
-
- {unsplashResults.map((r) => (
-
- ))}
-
-
- )}
-
- {tab === TAB_UPLOAD && (
-
- {!uploadPreview ? (
-
-
- Drag & drop image here
-
-
-
- ) : (
-
-

-
-
-
-
- )}
-
- File formats supported: .jpeg, .jpg, .png, .webp
-
- {uploadError && (
-
- {uploadError}
-
- )}
-
- )}
-
- );
-}
diff --git a/ui/src/components/ProjectIconModal.tsx b/ui/src/components/ProjectIconModal.tsx
deleted file mode 100644
index 5773018a..00000000
--- a/ui/src/components/ProjectIconModal.tsx
+++ /dev/null
@@ -1,323 +0,0 @@
-import { useState, useMemo, useEffect } from "react";
-import { Modal } from "./ui";
-
-const EMOJI_LIST = [
- "π ",
- "π",
- "π",
- "π‘",
- "π§",
- "β",
- "π―",
- "π",
- "π·οΈ",
- "π",
- "β
",
- "π",
- "π",
- "π±",
- "π»",
- "π",
- "π¨",
- "π",
- "π",
- "π₯",
- "β€οΈ",
- "π",
- "π¦",
- "π",
- "βοΈ",
- "π",
- "π",
- "π",
- "ποΈ",
- "π",
- "πͺ",
- "π§©",
- "π",
- "π οΈ",
- "πΌ",
- "π",
- "π",
-];
-
-const ICON_COLORS = [
- "#94a3b8",
- "#64748b",
- "#6366f1",
- "#3b82f6",
- "#0ea5e9",
- "#22c55e",
- "#eab308",
- "#f97316",
- "#ec4899",
- "#ffffff",
-];
-
-// Simple line icons (name -> path d)
-const ICON_PATHS: Record = {
- home: "m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z",
- star: "m12 2 3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z",
- folder:
- "M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z",
- briefcase: "M16 20V4a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16",
- zap: "M13 2 3 14h9l-1 8 10-12h-9l1-8z",
- target:
- "M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z",
- flag: "M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z",
- bookmark: "m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z",
- box: "M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z",
- globe:
- "M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z",
- cog: "M12 20a8 8 0 1 0 0-16 8 8 0 0 0 0 16z",
- heart:
- "M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z",
- rocket:
- "M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z",
- lightbulb:
- "M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5",
- palette:
- "M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z",
-};
-
-const ICON_NAMES = Object.keys(ICON_PATHS);
-
-function IconSvg({
- name,
- color,
- size = 24,
-}: {
- name: string;
- color?: string;
- size?: number;
-}) {
- const d = ICON_PATHS[name];
- if (!d) return null;
- return (
-
- );
-}
-
-/** Renders project icon (emoji or icon with color) for display in headers/sidebar. */
-export function ProjectIconDisplay({
- emoji,
- icon_prop,
- size = 20,
- className = "",
-}: {
- emoji?: string | null;
- icon_prop?: { name?: string; color?: string } | null;
- size?: number;
- className?: string;
-}) {
- if (emoji) {
- return (
-
- {emoji}
-
- );
- }
- if (icon_prop?.name && ICON_PATHS[icon_prop.name]) {
- return (
-
-
-
- );
- }
- return (
-
-
-
- );
-}
-
-export interface ProjectIconSelection {
- emoji?: string | null;
- icon_prop?: { name: string; color?: string } | null;
-}
-
-export interface ProjectIconModalProps {
- open: boolean;
- onClose: () => void;
- onSelect: (selection: ProjectIconSelection) => void;
- title?: string;
- currentEmoji?: string | null;
- currentIconProp?: { name?: string; color?: string } | null;
-}
-
-const TAB_EMOJI = "emoji";
-const TAB_ICON = "icon";
-type Tab = typeof TAB_EMOJI | typeof TAB_ICON;
-
-export function ProjectIconModal({
- open,
- onClose,
- onSelect,
- title = "Project icon",
- currentEmoji: _currentEmoji,
- currentIconProp,
-}: ProjectIconModalProps) {
- const [tab, setTab] = useState(TAB_EMOJI);
- const [emojiSearch, setEmojiSearch] = useState("");
- const [iconColor, setIconColor] = useState(
- currentIconProp?.color ?? "#6366f1",
- );
-
- useEffect(() => {
- if (open) {
- setIconColor(currentIconProp?.color ?? "#6366f1");
- setEmojiSearch("");
- }
- }, [open, currentIconProp?.color]);
-
- const filteredEmojis = useMemo(() => {
- if (!emojiSearch.trim()) return EMOJI_LIST;
- const q = emojiSearch.toLowerCase();
- return EMOJI_LIST.filter(
- (e) =>
- e.includes(q) ||
- String.fromCodePoint(e.codePointAt(0)!).toLowerCase().includes(q),
- );
- }, [emojiSearch]);
-
- const handleEmojiSelect = (emoji: string) => {
- onSelect({ emoji, icon_prop: null });
- onClose();
- };
-
- const handleIconSelect = (name: string) => {
- onSelect({ emoji: null, icon_prop: { name, color: iconColor } });
- onClose();
- };
-
- const footer = (
-
- );
-
- return (
-
-
-
-
-
-
- {tab === TAB_EMOJI && (
-
-
setEmojiSearch(e.target.value)}
- placeholder="Search"
- className="w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--bg-surface-1)] px-3 py-2 text-sm text-[var(--txt-primary)] placeholder:text-[var(--txt-placeholder)] focus:outline-none focus:border-[var(--border-strong)]"
- />
-
- {filteredEmojis.map((emoji) => (
-
- ))}
-
-
- )}
-
- {tab === TAB_ICON && (
-
-
Color
-
- {ICON_COLORS.map((c) => (
-
-
Choose an icon
-
- {ICON_NAMES.map((name) => (
-
- ))}
-
-
- )}
-
- );
-}
diff --git a/ui/src/components/UploadImageModal.tsx b/ui/src/components/UploadImageModal.tsx
deleted file mode 100644
index 5a7903e2..00000000
--- a/ui/src/components/UploadImageModal.tsx
+++ /dev/null
@@ -1,168 +0,0 @@
-import { useCallback, useEffect, useState } from "react";
-import { Button, Modal } from "./ui";
-import { uploadImage } from "../services/uploadService";
-
-export interface UploadImageModalProps {
- open: boolean;
- onClose: () => void;
- onSave: (url: string) => void;
- title?: string;
-}
-
-export function UploadImageModal({
- open,
- onClose,
- onSave,
- title = "Upload image",
-}: UploadImageModalProps) {
- const [file, setFile] = useState(null);
- const [preview, setPreview] = useState(null);
- const [loading, setLoading] = useState(false);
- const [error, setError] = useState(null);
-
- useEffect(() => {
- if (!open) {
- setFile(null);
- setPreview(null);
- setError(null);
- }
- }, [open]);
-
- const handleFileChange = useCallback(
- (e: React.ChangeEvent) => {
- const f = e.target.files?.[0];
- if (!f) return;
- const allowed = ["image/jpeg", "image/jpg", "image/png", "image/webp"];
- if (!allowed.includes(f.type)) {
- setError("Invalid file type. Supported: .jpeg, .jpg, .png, .webp");
- return;
- }
- setError(null);
- setFile(f);
- const reader = new FileReader();
- reader.onload = () => setPreview(reader.result as string);
- reader.readAsDataURL(f);
- },
- [],
- );
-
- const handleDrop = useCallback((e: React.DragEvent) => {
- e.preventDefault();
- const f = e.dataTransfer.files?.[0];
- if (!f) return;
- const allowed = ["image/jpeg", "image/jpg", "image/png", "image/webp"];
- if (!allowed.includes(f.type)) {
- setError("Invalid file type. Supported: .jpeg, .jpg, .png, .webp");
- return;
- }
- setError(null);
- setFile(f);
- const reader = new FileReader();
- reader.onload = () => setPreview(reader.result as string);
- reader.readAsDataURL(f);
- }, []);
-
- const handleDragOver = useCallback(
- (e: React.DragEvent) => e.preventDefault(),
- [],
- );
-
- const handleRemove = useCallback(() => {
- setFile(null);
- setPreview(null);
- setError(null);
- }, []);
-
- const handleUploadSave = useCallback(async () => {
- if (!file) return;
- setError(null);
- setLoading(true);
- try {
- const { url } = await uploadImage(file);
- onSave(url);
- onClose();
- } catch (e) {
- setError(e instanceof Error ? e.message : "Upload failed");
- } finally {
- setLoading(false);
- }
- }, [file, onSave, onClose]);
-
- const footer = (
- <>
- {preview && (
-
- )}
-
-
- >
- );
-
- return (
-
-
- {!preview ? (
-
-
- Drag & drop image here
-
-
-
- ) : (
-
-

-
-
-
-
- )}
-
- File formats supported: .jpeg, .jpg, .png, .webp
-
- {error && (
-
{error}
- )}
-
-
- );
-}
diff --git a/ui/src/components/layout/PageHeader.tsx b/ui/src/components/layout/PageHeader.tsx
index a8e4cad1..39de7e26 100644
--- a/ui/src/components/layout/PageHeader.tsx
+++ b/ui/src/components/layout/PageHeader.tsx
@@ -1194,10 +1194,8 @@ export function PageHeader() {
workspaceSlug?: string;
projectId?: string;
}>();
- const [_workspace, setWorkspace] = useState(
- null,
- );
- const [_projects, setProjects] = useState([]);
+ const [workspace, setWorkspace] = useState(null);
+ const [projects, setProjects] = useState([]);
const [project, setProject] = useState(null);
const [projectIssueCount, setProjectIssueCount] = useState(0);
diff --git a/ui/src/components/layout/Sidebar.tsx b/ui/src/components/layout/Sidebar.tsx
index a7aff584..a14e123c 100644
--- a/ui/src/components/layout/Sidebar.tsx
+++ b/ui/src/components/layout/Sidebar.tsx
@@ -1,15 +1,14 @@
-import { useEffect, useRef, useState } from "react";
+import { useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { Link, NavLink, useLocation, useParams } from "react-router-dom";
import { workspaceService } from "../../services/workspaceService";
import { projectService } from "../../services/projectService";
-import { favoriteService } from "../../services/favoriteService";
import type { WorkspaceApiResponse, ProjectApiResponse } from "../../api/types";
+import type { Project } from "../../types";
import { CreateWorkItemModal } from "../CreateWorkItemModal";
import { Avatar, Button } from "../ui";
import { useAuth } from "../../contexts/AuthContext";
-import { useFavorites } from "../../contexts/FavoritesContext";
-import { cn, getImageUrl } from "../../lib/utils";
+import { cn } from "../../lib/utils";
const SIDEBAR_WIDTH = 256;
const SIDEBAR_WIDTH_COLLAPSED = 0;
@@ -443,7 +442,6 @@ export function Sidebar() {
const [createWorkItemOpen, setCreateWorkItemOpen] = useState(false);
const [workspaces, setWorkspaces] = useState([]);
const [projects, setProjects] = useState([]);
- const { favoriteProjectIds, setFavoriteProjectIds } = useFavorites();
const workspaceTriggerRef = useRef(null);
const workspaceDropdownRef = useRef(null);
@@ -467,8 +465,17 @@ export function Sidebar() {
: workspace
? `/${workspace.slug}`
: "";
- const favoriteProjects = projects.filter((p) =>
- favoriteProjectIds.includes(p.id),
+ const favoriteProjects = projects.slice(0, 1);
+ const projectsForModal: Project[] = useMemo(
+ () =>
+ projects.map((p) => ({
+ id: p.id,
+ workspaceId: p.workspace_id,
+ name: p.name,
+ identifier: p.identifier ?? p.id.slice(0, 2),
+ description: p.description ?? null,
+ })),
+ [projects],
);
useEffect(() => {
@@ -502,21 +509,6 @@ export function Sidebar() {
};
}, [slugForProjects]);
- useEffect(() => {
- let cancelled = false;
- favoriteService
- .getFavoriteProjectIds()
- .then((ids) => {
- if (!cancelled) setFavoriteProjectIds(ids);
- })
- .catch(() => {
- if (!cancelled) setFavoriteProjectIds([]);
- });
- return () => {
- cancelled = true;
- };
- }, []);
-
useEffect(() => {
if (!baseUrl) return;
const path = location.pathname;
@@ -604,16 +596,8 @@ export function Sidebar() {
aria-haspopup="true"
aria-label="Workspace menu"
>
-
- {workspace?.logo && getImageUrl(workspace.logo) ? (
-
!})
- ) : (
- (workspace?.name ?? "β").slice(0, 2).toUpperCase()
- )}
+
+ {(workspace?.name ?? "β").slice(0, 2).toUpperCase()}
{workspace?.name ?? "Loadingβ¦"}
@@ -645,7 +629,7 @@ export function Sidebar() {
@@ -672,16 +656,8 @@ export function Sidebar() {
{user?.email}
-
- {workspace?.logo && getImageUrl(workspace.logo) ? (
-
!})
- ) : (
- (workspace?.name ?? "β").slice(0, 2)
- )}
+
+ {(workspace?.name ?? "β").slice(0, 2)}
@@ -1115,7 +1091,7 @@ export function Sidebar() {
open={createWorkItemOpen}
onClose={() => setCreateWorkItemOpen(false)}
workspaceSlug={workspace?.slug ?? ""}
- projects={projects}
+ projects={projectsForModal}
/>
>
);
diff --git a/ui/src/components/work-item/CommentEditor.tsx b/ui/src/components/work-item/CommentEditor.tsx
index d1ec22ce..1b06cfe6 100644
--- a/ui/src/components/work-item/CommentEditor.tsx
+++ b/ui/src/components/work-item/CommentEditor.tsx
@@ -28,7 +28,7 @@ export function CommentEditor({
StarterKit.configure({
bulletList: { keepMarks: true, keepAttributes: true },
orderedList: { keepMarks: true, keepAttributes: true },
- codeBlock: {},
+ codeBlock: true,
}),
Underline,
Placeholder.configure({
diff --git a/ui/src/contexts/AuthContext.tsx b/ui/src/contexts/AuthContext.tsx
index 923f264a..6fcaba5a 100644
--- a/ui/src/contexts/AuthContext.tsx
+++ b/ui/src/contexts/AuthContext.tsx
@@ -21,7 +21,6 @@ function mapApiUserToUser(api: UserApiResponse): User {
email: api.email ?? "",
name,
avatarUrl: api.avatar ?? null,
- coverImageUrl: api.cover_image ?? null,
};
}
diff --git a/ui/src/contexts/FavoritesContext.tsx b/ui/src/contexts/FavoritesContext.tsx
deleted file mode 100644
index 914b1691..00000000
--- a/ui/src/contexts/FavoritesContext.tsx
+++ /dev/null
@@ -1,28 +0,0 @@
-import { createContext, useContext, useState, type ReactNode } from "react";
-
-interface FavoritesContextValue {
- favoriteProjectIds: string[];
- setFavoriteProjectIds: (
- ids: string[] | ((prev: string[]) => string[]),
- ) => void;
-}
-
-const FavoritesContext = createContext(null);
-
-export function FavoritesProvider({ children }: { children: ReactNode }) {
- const [favoriteProjectIds, setFavoriteProjectIds] = useState([]);
- return (
-
- {children}
-
- );
-}
-
-export function useFavorites(): FavoritesContextValue {
- const ctx = useContext(FavoritesContext);
- if (!ctx)
- throw new Error("useFavorites must be used within FavoritesProvider");
- return ctx;
-}
diff --git a/ui/src/lib/utils.ts b/ui/src/lib/utils.ts
index 05f0c52b..9996a21e 100644
--- a/ui/src/lib/utils.ts
+++ b/ui/src/lib/utils.ts
@@ -1,6 +1,5 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
-import { config } from "../config/env";
/**
* Merges Tailwind classes with clsx, resolving conflicts via tailwind-merge.
@@ -8,12 +7,3 @@ import { config } from "../config/env";
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs));
}
-
-/** Resolve image URL for display (relative API paths get base URL prepended). */
-export function getImageUrl(url: string | null | undefined): string | null {
- if (!url || typeof url !== "string") return null;
- if (url.startsWith("http://") || url.startsWith("https://")) return url;
- const base = (config.apiBaseUrl ?? "").replace(/\/+$/, "");
- const path = url.startsWith("/") ? url : "/" + url;
- return base + path;
-}
diff --git a/ui/src/pages/AnalyticsWorkItemsPage.tsx b/ui/src/pages/AnalyticsWorkItemsPage.tsx
index bf25c723..ad7cdf51 100644
--- a/ui/src/pages/AnalyticsWorkItemsPage.tsx
+++ b/ui/src/pages/AnalyticsWorkItemsPage.tsx
@@ -226,17 +226,17 @@ export function AnalyticsWorkItemsPage() {
];
const projectRows = projects.map((p) => {
- const projIssues = issues.filter((i) => i.project_id === p.id);
+ const projIssues = issues.filter((i) => i.projectId === p.id);
return {
project: p,
- backlog: projIssues.filter((i) => getStateName(i.state_id) === "Backlog")
+ backlog: projIssues.filter((i) => getStateName(i.stateId) === "Backlog")
.length,
started: projIssues.filter(
- (i) => getStateName(i.state_id) === "In Progress",
+ (i) => getStateName(i.stateId) === "In Progress",
).length,
- unstarted: projIssues.filter((i) => getStateName(i.state_id) === "Todo")
+ unstarted: projIssues.filter((i) => getStateName(i.stateId) === "Todo")
.length,
- completed: projIssues.filter((i) => getStateName(i.state_id) === "Done")
+ completed: projIssues.filter((i) => getStateName(i.stateId) === "Done")
.length,
cancelled: 0,
};
diff --git a/ui/src/pages/BoardPage.tsx b/ui/src/pages/BoardPage.tsx
index 3a76e09e..dde8212e 100644
--- a/ui/src/pages/BoardPage.tsx
+++ b/ui/src/pages/BoardPage.tsx
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom";
-import { Card, CardContent, Badge } from "../components/ui";
+import { Card, CardContent, Badge, Avatar } from "../components/ui";
import { workspaceService } from "../services/workspaceService";
import { projectService } from "../services/projectService";
import { issueService } from "../services/issueService";
diff --git a/ui/src/pages/CyclesPage.tsx b/ui/src/pages/CyclesPage.tsx
index 0ea4f386..f4dc4a36 100644
--- a/ui/src/pages/CyclesPage.tsx
+++ b/ui/src/pages/CyclesPage.tsx
@@ -194,9 +194,7 @@ export function CyclesPage() {
const getIssueCount = (cycleId: string) =>
cycles.find((c) => c.id === cycleId)?.issue_count ?? 0;
- const getUser = (
- _userId: string | null,
- ): { name: string; avatarUrl?: string | null } | null => null;
+ const getUser = (_userId: string | null) => null;
if (loading) {
return (
diff --git a/ui/src/pages/InviteAcceptPage.tsx b/ui/src/pages/InviteAcceptPage.tsx
new file mode 100644
index 00000000..67fe4557
--- /dev/null
+++ b/ui/src/pages/InviteAcceptPage.tsx
@@ -0,0 +1,138 @@
+import { useEffect, useState } from "react";
+import { useNavigate, useSearchParams } from "react-router-dom";
+import { Button, Card, CardContent } from "../components/ui";
+import { useAuth } from "../contexts/AuthContext";
+import { workspaceService } from "../services/workspaceService";
+
+export function InviteAcceptPage() {
+ const [searchParams] = useSearchParams();
+ const token = searchParams.get("token");
+ const navigate = useNavigate();
+ const { user, isLoading: authLoading } = useAuth();
+ const [status, setStatus] = useState<
+ "idle" | "loading" | "success" | "error"
+ >("idle");
+ const [error, setError] = useState("");
+
+ useEffect(() => {
+ if (!token) {
+ setStatus("error");
+ setError("Invalid or missing invite link.");
+ return;
+ }
+ if (authLoading) return;
+ if (!user) {
+ navigate("/login", {
+ replace: true,
+ state: {
+ from: {
+ pathname: "/invite",
+ search: searchParams.toString(),
+ },
+ },
+ });
+ return;
+ }
+ let cancelled = false;
+ setStatus("loading");
+ setError("");
+ workspaceService
+ .joinByToken(token)
+ .then((workspace) => {
+ if (cancelled) return;
+ setStatus("success");
+ navigate(`/${workspace.slug}`, { replace: true });
+ })
+ .catch((err) => {
+ if (cancelled) return;
+ setStatus("error");
+ const msg =
+ err?.response?.data?.error ??
+ (err instanceof Error
+ ? err.message
+ : "Invalid or expired invite. Please request a new link.");
+ setError(String(msg));
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [token, user, authLoading, navigate, searchParams]);
+
+ if (!token) {
+ return (
+
+
+
+
+ Invalid invite link
+
+
+ This invite link is missing a token. Please use the link from your
+ invitation email.
+
+
+
+
+
+ );
+ }
+
+ if (authLoading || status === "loading") {
+ return (
+
+
+
+
+ Accepting invitationβ¦
+
+
+
+
+ );
+ }
+
+ if (status === "error") {
+ return (
+
+
+
+
+ Invite could not be accepted
+
+
+ {error}
+
+
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ Redirecting to workspaceβ¦
+
+
+
+
+ );
+}
diff --git a/ui/src/pages/IssueDetailPage.tsx b/ui/src/pages/IssueDetailPage.tsx
index 6676bcbf..ab3730ff 100644
--- a/ui/src/pages/IssueDetailPage.tsx
+++ b/ui/src/pages/IssueDetailPage.tsx
@@ -982,10 +982,10 @@ export function IssueDetailPage() {
projects={[
{
id: project.id,
- workspace_id: project.workspace_id,
+ workspaceId: project.workspace_id,
name: project.name,
identifier: project.identifier ?? project.id.slice(0, 8),
- description: project.description ?? undefined,
+ description: project.description ?? null,
},
]}
defaultProjectId={project.id}
diff --git a/ui/src/pages/IssueListPage.tsx b/ui/src/pages/IssueListPage.tsx
index 60de3428..17f24b3b 100644
--- a/ui/src/pages/IssueListPage.tsx
+++ b/ui/src/pages/IssueListPage.tsx
@@ -18,7 +18,6 @@ import type {
WorkspaceMemberApiResponse,
} from "../api/types";
import type { Priority } from "../types";
-import { getImageUrl } from "../lib/utils";
const priorityVariant: Record<
Priority,
@@ -196,8 +195,7 @@ export function IssueListPage() {
const display = m?.member_display_name?.trim();
const emailUser = m?.member_email?.split("@")[0]?.trim();
const name = display || emailUser || "Member";
- const avatarUrl = m?.member_avatar ?? null;
- return { id: userId, name, avatarUrl };
+ return { id: userId, name, avatarUrl: null as string | null };
};
const createParam = searchParams.get("create") === "1";
@@ -377,7 +375,7 @@ export function IssueListPage() {
{assignee ? (
diff --git a/ui/src/pages/LoginPage.tsx b/ui/src/pages/LoginPage.tsx
index 193434c6..98769ca9 100644
--- a/ui/src/pages/LoginPage.tsx
+++ b/ui/src/pages/LoginPage.tsx
@@ -12,8 +12,12 @@ export function LoginPage() {
const [error, setError] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
- const returnPath =
- (location.state as { from?: { pathname?: string } })?.from?.pathname ?? "/";
+ const from = (
+ location.state as { from?: { pathname?: string; search?: string } }
+ )?.from;
+ const returnPath = from
+ ? (from.pathname ?? "/") + (from.search ? `?${from.search}` : "")
+ : "/";
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
diff --git a/ui/src/pages/ProfilePage.tsx b/ui/src/pages/ProfilePage.tsx
index 69ac94c4..4a72e6a7 100644
--- a/ui/src/pages/ProfilePage.tsx
+++ b/ui/src/pages/ProfilePage.tsx
@@ -2,7 +2,6 @@ import { useParams, Link } from "react-router-dom";
import { useState, useMemo, useEffect } from "react";
import { useAuth } from "../contexts/AuthContext";
import { Avatar, Card, CardContent } from "../components/ui";
-import { getImageUrl } from "../lib/utils";
import { workspaceService } from "../services/workspaceService";
import { projectService } from "../services/projectService";
import { issueService } from "../services/issueService";
@@ -14,7 +13,7 @@ import type {
StateApiResponse,
WorkspaceMemberApiResponse,
} from "../api/types";
-// import type { Issue } from "../types"; // reserved for future use
+import type { Issue } from "../types";
type TabId = "summary" | "assigned" | "created" | "subscribed" | "activity";
@@ -120,12 +119,7 @@ export function ProfilePage() {
: [],
[profileUser, issues],
);
- const issuesAssigned = useMemo((): IssueApiResponse[] => {
- if (!profileUser?.id) return [];
- return issues.filter((i) =>
- (i.assignee_ids ?? []).includes(profileUser.id),
- );
- }, [profileUser?.id, issues]);
+ const issuesAssigned = useMemo(() => [], []); // API does not return assignee on issue yet
const issuesSubscribed = issuesAssigned.length;
const issuesSubscribedList = issuesAssigned;
@@ -184,8 +178,7 @@ export function ProfilePage() {
none: 0,
};
issuesAssigned.forEach((i) => {
- const p = i.priority ?? "none";
- counts[p] = (counts[p] ?? 0) + 1;
+ counts[i.priority] = (counts[i.priority] ?? 0) + 1;
});
return counts;
}, [issuesAssigned]);
@@ -205,28 +198,12 @@ export function ProfilePage() {
id: string;
projectId: string;
issueId: string;
- type: string;
+ action: string;
createdAt: string;
- labelName?: string;
- cycleName?: string;
- assigneeName?: string;
}> => {
if (!profileUser) return [];
- const created = issuesCreated
- .map((i) => ({
- id: `created-${i.id}`,
- projectId: i.project_id,
- issueId: i.id,
- type: "created" as const,
- createdAt: i.created_at ?? i.updated_at ?? new Date().toISOString(),
- }))
- .sort(
- (a, b) =>
- new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
- )
- .slice(0, 20);
- return created;
- }, [profileUser, issuesCreated]);
+ return [];
+ }, [profileUser]);
const projectsWithProgress = useMemo(() => {
return projects
@@ -488,15 +465,11 @@ export function ProfilePage() {
);
const width = max ? (count / max) * 100 : 0;
const barColor =
- p === "urgent"
- ? "#ef4444"
- : p === "high"
- ? "#f87171"
- : p === "medium"
- ? "#f59e0b"
- : p === "low"
- ? "#60a5fa"
- : "#94a3b8";
+ p === "urgent" || p === "high"
+ ? "var(--bg-danger-subtle)"
+ : p === "medium"
+ ? "var(--bg-warning-subtle)"
+ : "var(--bg-layer-2)";
return (
@@ -634,9 +607,7 @@ export function ProfilePage() {
@@ -659,7 +630,6 @@ export function ProfilePage() {
issues={issuesAssigned}
states={states}
projects={projects}
- members={members}
baseUrl={baseUrl}
workspaceSlug={workspace.slug}
/>
@@ -669,7 +639,6 @@ export function ProfilePage() {
issues={issuesCreated}
states={states}
projects={projects}
- members={members}
baseUrl={baseUrl}
workspaceSlug={workspace.slug}
/>
@@ -679,7 +648,6 @@ export function ProfilePage() {
issues={issuesSubscribedList}
states={states}
projects={projects}
- members={members}
baseUrl={baseUrl}
workspaceSlug={workspace.slug}
/>
@@ -701,18 +669,7 @@ export function ProfilePage() {
variant="outlined"
className="flex min-h-0 flex-1 flex-col overflow-hidden border border-[var(--border-subtle)] bg-white"
>
-
+