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 -

- -
- ) : ( -
- Preview -
- -
-
- )} -

- 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 -

- -
- ) : ( -
- Preview -
- -
-
- )} -

- 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" > -
    +
    - {/* Overlay: icon + name + identifier, inside cover, no border */} -
    - - - -
    -

    +

    +

    {project.name}

    -

    +

    {project.identifier ?? project.id.slice(0, 8)}

    -
    - {/* Description */} -
    -

    - {project.description || "No description"} -

    -
    - - {/* Bottom: avatars + settings */} -
    - - {visibleMembers.length === 0 ? ( - - No members - - ) : ( - <> - {visibleMembers.map((user) => ( - - ))} - {extraCount > 0 && ( - - +{extraCount} - - )} - - )} - - - + {/* Description */} +
    +

    + {project.description || "No description"} +

    +
    -
    + {/* Bottom: avatars (link to project) + settings */} +
    + + {visibleMembers.length === 0 ? ( + + No members + + ) : ( + <> + {visibleMembers.map((user) => ( + + ))} + {extraCount > 0 && ( + + +{extraCount} + + )} + + )} + + + + +
    +
    ); })} diff --git a/ui/src/pages/SettingsPage.tsx b/ui/src/pages/SettingsPage.tsx index dd1a64f4..da93475b 100644 --- a/ui/src/pages/SettingsPage.tsx +++ b/ui/src/pages/SettingsPage.tsx @@ -7,13 +7,6 @@ import { useSearchParams, } from "react-router-dom"; import { Card, CardContent, Button, Avatar, Modal } from "../components/ui"; -import { CoverImageModal } from "../components/CoverImageModal"; -import { UploadImageModal } from "../components/UploadImageModal"; -import { - ProjectIconModal, - ProjectIconDisplay, -} from "../components/ProjectIconModal"; -import { getImageUrl } from "../lib/utils"; import { useAuth } from "../contexts/AuthContext"; import { useTheme } from "../contexts/ThemeContext"; import { workspaceService } from "../services/workspaceService"; @@ -500,9 +493,40 @@ const IconMessageCircle = () => ( ); -// Reserved for future nav/settings use: -// const IconHome = () => ( ); -// const IconTruck = () => ( ... ); +const IconHome = () => ( + + + + +); +const IconTruck = () => ( + + + + + + +); const IconLayers = () => ( (null); - const [accountCoverModalOpen, setAccountCoverModalOpen] = useState(false); - const [accountAvatarModalOpen, setAccountAvatarModalOpen] = useState(false); - const [projectCoverModalOpen, setProjectCoverModalOpen] = useState(false); - const [projectIconModalOpen, setProjectIconModalOpen] = useState(false); - const [workspaceLogoModalOpen, setWorkspaceLogoModalOpen] = useState(false); const navigate = useNavigate(); const timezoneOptions = useMemo(() => getTimezoneOptions(), []); @@ -1294,8 +1313,8 @@ export function SettingsPage() {

    @@ -1370,6 +1389,13 @@ export function SettingsPage() {

    {projects.map((proj) => { const isSelected = selectedProjectId === proj.id; + const projectIcon = proj.name + .toLowerCase() + .includes("mobile") ? ( + + ) : ( + + ); return (
    -
    - +
    +
    @@ -1663,34 +1650,6 @@ export function SettingsPage() { )} - setAccountCoverModalOpen(false)} - onSelect={async (url) => { - try { - const api = await userService.updateMe({ - cover_image: url, - }); - setUserFromApi(api); - } catch { - // error could be shown in modal or toast - } - }} - title="Select cover image" - /> - setAccountAvatarModalOpen(false)} - onSave={async (url) => { - try { - const api = await userService.updateMe({ avatar: url }); - setUserFromApi(api); - } catch { - // error shown in modal - } - }} - title="Upload profile picture" - />
    )} @@ -2349,49 +2308,24 @@ export function SettingsPage() { {isProjectsTab && selectedProject && projectSection === "general" && (
    -
    -
    - {!getImageUrl(selectedProject?.cover_image) && ( -
    - )} -
    +
    +
    -
    - -
    -

    +

    + + + +
    +

    {projectName || selectedProject.name}

    -

    +

    {selectedProject.identifier} Β· Public

    @@ -2572,57 +2506,6 @@ export function SettingsPage() { )}

    )} - {workspaceSlug && selectedProjectId && ( - <> - setProjectCoverModalOpen(false)} - onSelect={async (url) => { - try { - const updated = await projectService.update( - workspaceSlug, - selectedProjectId, - { cover_image: url }, - ); - setProjects((prev) => - prev.map((p) => (p.id === updated.id ? updated : p)), - ); - } catch { - // error could be shown - } - }} - title="Select project cover" - /> - setProjectIconModalOpen(false)} - currentEmoji={selectedProject?.emoji} - currentIconProp={selectedProject?.icon_prop} - onSelect={async (selection) => { - try { - const payload = - selection.emoji != null - ? { emoji: selection.emoji, icon_prop: undefined } - : { - emoji: undefined, - icon_prop: selection.icon_prop ?? undefined, - }; - const updated = await projectService.update( - workspaceSlug, - selectedProjectId, - payload, - ); - setProjects((prev) => - prev.map((p) => (p.id === updated.id ? updated : p)), - ); - } catch { - // error could be shown - } - }} - title="Project icon" - /> - - )}
    )} @@ -3472,16 +3355,8 @@ export function SettingsPage() { {!isAccountTab && !isProjectsTab && section === "general" && (
    -
    - {workspace?.logo && getImageUrl(workspace.logo) ? ( - - ) : ( - (workspace?.name?.charAt(0).toUpperCase() ?? "") - )} +
    + {workspace.name.charAt(0).toUpperCase()}

    @@ -3492,7 +3367,6 @@ export function SettingsPage() {

    )} diff --git a/ui/src/pages/WorkspaceHomePage.tsx b/ui/src/pages/WorkspaceHomePage.tsx index 1999d4d1..d47b4f05 100644 --- a/ui/src/pages/WorkspaceHomePage.tsx +++ b/ui/src/pages/WorkspaceHomePage.tsx @@ -310,7 +310,7 @@ export function WorkspaceHomePage() { const { user } = useAuth(); const { workspaceSlug } = useParams<{ workspaceSlug: string }>(); const [workspace, setWorkspace] = useState(null); - const [_projects, setProjects] = useState([]); + const [projects, setProjects] = useState([]); const [quicklinks, setQuicklinks] = useState([]); const [stickies, setStickies] = useState([]); const [recents, setRecents] = useState([]); @@ -386,8 +386,14 @@ export function WorkspaceHomePage() { .catch(() => {}); } }; - // Reserved for future use (e.g. refresh recents list): - // const refetchRecents = () => { if (workspaceSlug) { recentsService.list(workspaceSlug).then(setRecents).catch(() => {}); } }; + const refetchRecents = () => { + if (workspaceSlug) { + recentsService + .list(workspaceSlug) + .then(setRecents) + .catch(() => {}); + } + }; const handleCloseQuicklink = () => { setAddQuicklinkOpen(false); @@ -408,8 +414,15 @@ export function WorkspaceHomePage() { setQuicklinkSubmitting(false); } }; - // Reserved for future use (delete quicklink from UI): - // const handleDeleteQuicklink = async (id: string) => { if (!workspaceSlug) return; try { await quickLinksService.delete(workspaceSlug, id); refetchQuicklinks(); } catch {} }; + const handleDeleteQuicklink = async (id: string) => { + if (!workspaceSlug) return; + try { + await quickLinksService.delete(workspaceSlug, id); + refetchQuicklinks(); + } catch { + // already handled by interceptor + } + }; const handleCloseSticky = () => { setAddStickyOpen(false); setStickyContent(""); diff --git a/ui/src/pages/WorkspaceViewsPage.tsx b/ui/src/pages/WorkspaceViewsPage.tsx index c62dee31..5b1a14c3 100644 --- a/ui/src/pages/WorkspaceViewsPage.tsx +++ b/ui/src/pages/WorkspaceViewsPage.tsx @@ -212,9 +212,7 @@ export function WorkspaceViewsPage() { projects.find((p) => p.id === projectId); const getStateName = (stateId: string | null | undefined) => stateId ? (states.find((s) => s.id === stateId)?.name ?? stateId) : "β€”"; - const getUser = ( - _userId: string | null, - ): { name: string; avatarUrl?: string | null } | null => null; + const getUser = (_userId: string | null) => null; const getLabelNames = (_labelIds: string[] = []) => [] as string[]; const getCycleName = (_projectId: string, _cycleId: string | null) => null; const getModuleName = (_projectId: string, _moduleId: string | null) => null; diff --git a/ui/src/pages/instance-admin/InstanceAdminAIPage.tsx b/ui/src/pages/instance-admin/InstanceAdminAIPage.tsx index edf4c746..4c045416 100644 --- a/ui/src/pages/instance-admin/InstanceAdminAIPage.tsx +++ b/ui/src/pages/instance-admin/InstanceAdminAIPage.tsx @@ -48,10 +48,7 @@ export function InstanceAdminAIPage() { const apiKeyToSend = apiKeyLocal !== undefined ? apiKeyLocal : ai.api_key; const payload: InstanceAISection = { ...ai, api_key: apiKeyToSend ?? "" }; instanceSettingsService - .updateSection( - "ai", - payload as import("../../api/types").InstanceSettingSectionValue, - ) + .updateSection("ai", payload) .then((res) => { setApiKeyLocal(undefined); if (res.value) diff --git a/ui/src/pages/instance-admin/InstanceAdminAuthenticationPage.tsx b/ui/src/pages/instance-admin/InstanceAdminAuthenticationPage.tsx index 2decc8d8..cf15384b 100644 --- a/ui/src/pages/instance-admin/InstanceAdminAuthenticationPage.tsx +++ b/ui/src/pages/instance-admin/InstanceAdminAuthenticationPage.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from "react"; +import { useEffect, useState } from "react"; import { Button, Skeleton } from "../../components/ui"; import { instanceSettingsService } from "../../services/instanceService"; import { getApiErrorMessage } from "../../api/client"; @@ -78,7 +78,7 @@ const IconGitLab = () => ( const AUTH_MODES: Array<{ key: keyof InstanceAuthSection; - Icon: () => React.ReactElement; + Icon: () => JSX.Element; name: string; desc: string; action?: string; @@ -128,7 +128,7 @@ export function InstanceAdminAuthenticationPage() { gitlab: false, }); const [loading, setLoading] = useState(true); - const [_saving, setSaving] = useState(false); + const [saving, setSaving] = useState(false); const [error, setError] = useState(""); useEffect(() => { diff --git a/ui/src/pages/instance-admin/InstanceAdminEmailPage.tsx b/ui/src/pages/instance-admin/InstanceAdminEmailPage.tsx index 64c89fb4..6ebbec6f 100644 --- a/ui/src/pages/instance-admin/InstanceAdminEmailPage.tsx +++ b/ui/src/pages/instance-admin/InstanceAdminEmailPage.tsx @@ -64,10 +64,7 @@ export function InstanceAdminEmailPage() { password: passwordToSend ?? "", }; instanceSettingsService - .updateSection( - "email", - payload as import("../../api/types").InstanceSettingSectionValue, - ) + .updateSection("email", payload) .then((res) => { setSmtpPasswordLocal(undefined); if (res.value) diff --git a/ui/src/pages/instance-admin/InstanceAdminImagePage.tsx b/ui/src/pages/instance-admin/InstanceAdminImagePage.tsx index e7797888..83bfd405 100644 --- a/ui/src/pages/instance-admin/InstanceAdminImagePage.tsx +++ b/ui/src/pages/instance-admin/InstanceAdminImagePage.tsx @@ -54,10 +54,7 @@ export function InstanceAdminImagePage() { unsplash_access_key: accessKeyToSend ?? "", }; instanceSettingsService - .updateSection( - "image", - payload as import("../../api/types").InstanceSettingSectionValue, - ) + .updateSection("image", payload) .then((res) => { setAccessKeyLocal(undefined); if (res.value) diff --git a/ui/src/routes/index.tsx b/ui/src/routes/index.tsx index b0b08566..64fb8e29 100644 --- a/ui/src/routes/index.tsx +++ b/ui/src/routes/index.tsx @@ -133,8 +133,11 @@ const InstanceAdminCreateWorkspacePage = lazy(() => }), ), ); -// Reserved for future instance-admin login route: -// const InstanceAdminLoginPage = lazy(() => import("../pages/instance-admin").then((m) => page({ InstanceAdminLoginPage: m.InstanceAdminLoginPage }))); +const InstanceAdminLoginPage = lazy(() => + import("../pages/instance-admin").then((m) => + page({ InstanceAdminLoginPage: m.InstanceAdminLoginPage }), + ), +); const InstanceSetupWelcomePage = lazy(() => import("../pages/setup").then((m) => @@ -151,6 +154,11 @@ const InstanceSetupCompletePage = lazy(() => page({ InstanceSetupCompletePage: m.InstanceSetupCompletePage }), ), ); +const InviteAcceptPage = lazy(() => + import("../pages/InviteAcceptPage").then((m) => + page({ InviteAcceptPage: m.InviteAcceptPage }), + ), +); const PageFallback = () => (
    @@ -288,6 +296,14 @@ const router = createBrowserRouter([ ), }, + { + path: "invite", + element: ( + }> + + + ), + }, { element: , children: [ diff --git a/ui/src/services/instanceService.ts b/ui/src/services/instanceService.ts index 1fb98752..e6efb42d 100644 --- a/ui/src/services/instanceService.ts +++ b/ui/src/services/instanceService.ts @@ -27,13 +27,6 @@ export const instanceService = { }, }; -/** Unsplash search result item from proxy. */ -export interface UnsplashSearchResult { - id: string; - url: string; - thumb: string; -} - /** Instance admin settings (requires auth). */ export const instanceSettingsService = { async getSettings(): Promise { @@ -53,15 +46,4 @@ export const instanceSettingsService = { }>(`/api/instance/settings/${encodeURIComponent(key)}`, { value }); return data; }, - - /** Search Unsplash (uses instance image API key). GET /api/instance/unsplash/search?q= */ - async unsplashSearch( - q: string, - ): Promise<{ results: UnsplashSearchResult[] }> { - const { data } = await apiClient.get<{ results: UnsplashSearchResult[] }>( - "/api/instance/unsplash/search", - { params: { q: q.trim() } }, - ); - return data; - }, }; diff --git a/ui/src/services/projectService.ts b/ui/src/services/projectService.ts index d4f00343..3d0ba77f 100644 --- a/ui/src/services/projectService.ts +++ b/ui/src/services/projectService.ts @@ -59,9 +59,6 @@ export const projectService = { identifier?: string; description?: string; timezone?: string; - cover_image?: string; - emoji?: string; - icon_prop?: { name?: string; color?: string } | null; /** When present, use empty string to clear; omit to leave unchanged. */ project_lead_id?: string; /** When present, use empty string to clear; omit to leave unchanged. */ diff --git a/ui/src/services/uploadService.ts b/ui/src/services/uploadService.ts deleted file mode 100644 index 97ccd0c1..00000000 --- a/ui/src/services/uploadService.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { apiClient } from "../api/client"; - -const ALLOWED_TYPES = ["image/jpeg", "image/jpg", "image/png", "image/webp"]; - -export interface UploadResponse { - url: string; -} - -/** - * Upload an image file. Returns the URL path to use (e.g. /api/files/uploads/...). - * Supported: .jpeg, .jpg, .png, .webp - */ -export async function uploadImage(file: File): Promise { - if (!ALLOWED_TYPES.includes(file.type)) { - throw new Error("Invalid file type. Supported: .jpeg, .jpg, .png, .webp"); - } - const form = new FormData(); - form.append("file", file); - const { data } = await apiClient.post("/api/upload", form); - return data; -} diff --git a/ui/src/services/userService.ts b/ui/src/services/userService.ts index b0f30ab8..809d3775 100644 --- a/ui/src/services/userService.ts +++ b/ui/src/services/userService.ts @@ -53,7 +53,9 @@ export const userService = { return data; }, - async createToken(payload: CreateTokenRequest): Promise<{ + async createToken( + payload: CreateTokenRequest, + ): Promise<{ token: string; label: string; description: string; diff --git a/ui/src/services/workspaceService.ts b/ui/src/services/workspaceService.ts index c9099835..be4f4c3a 100644 --- a/ui/src/services/workspaceService.ts +++ b/ui/src/services/workspaceService.ts @@ -78,7 +78,7 @@ export const workspaceService = { */ async update( slug: string, - payload: { name?: string; slug?: string; logo?: string }, + payload: { name?: string; slug?: string }, ): Promise { const { data } = await apiClient.patch( `/api/workspaces/${encodeURIComponent(slug)}/`, @@ -122,4 +122,16 @@ export const workspaceService = { `/api/workspaces/${encodeURIComponent(workspaceSlug)}/invitations/${encodeURIComponent(invitePk)}/`, ); }, + + /** + * Accept a workspace invitation by token (from invite email link). + * POST /api/workspaces/join/ + */ + async joinByToken(token: string): Promise { + const { data } = await apiClient.post( + "/api/workspaces/join/", + { token }, + ); + return data; + }, }; diff --git a/ui/src/types/index.ts b/ui/src/types/index.ts index 0258ba25..08e29826 100644 --- a/ui/src/types/index.ts +++ b/ui/src/types/index.ts @@ -9,7 +9,6 @@ export interface User { email: string; name: string; avatarUrl?: string | null; - coverImageUrl?: string | null; } export interface Workspace { diff --git a/ui/vite.config.ts b/ui/vite.config.ts index 401b4d48..764235e0 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -12,18 +12,9 @@ export default defineConfig({ "react-vendor": ["react", "react-dom"], router: ["react-router-dom"], charts: ["recharts"], - tiptap: [ - "@tiptap/react", - "@tiptap/starter-kit", - "@tiptap/extension-placeholder", - "@tiptap/extension-underline", - "@tiptap/extension-link", - ], "ui-vendor": ["@headlessui/react", "lucide-react"], - "core-vendor": ["axios", "clsx", "tailwind-merge"], }, }, }, - chunkSizeWarningLimit: 600, }, });