diff --git a/.github/workflows/api-ci.yml b/.github/workflows/api-ci.yml index 5f5076a..60260bd 100644 --- a/.github/workflows/api-ci.yml +++ b/.github/workflows/api-ci.yml @@ -27,6 +27,9 @@ jobs: - name: Verify dependencies run: go mod verify + - name: Check formatting + run: test -z "$(gofmt -l .)" + - name: Build run: go build -v ./cmd/api/... diff --git a/.github/workflows/ui-ci.yml b/.github/workflows/ui-ci.yml new file mode 100644 index 0000000..8d342c8 --- /dev/null +++ b/.github/workflows/ui-ci.yml @@ -0,0 +1,38 @@ +name: UI CI + +on: + pull_request: + branches: [main] + paths: + - 'ui/**' + - '.github/workflows/ui-ci.yml' + +defaults: + run: + working-directory: ui + +jobs: + lint-and-format: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + cache-dependency-path: ui/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Lint + run: npm run lint + + - name: Check formatting + run: npm run format:check + + - name: Build + run: npm run build diff --git a/api/.env.example b/api/.env.example index f8d9af2..2ec0799 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 c5c926c..4e85a9f 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() { @@ -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(log, 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 fdbaf9f..b831ee2 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/invitation.go b/api/internal/handler/invitation.go new file mode 100644 index 0000000..d76e81e --- /dev/null +++ b/api/internal/handler/invitation.go @@ -0,0 +1,68 @@ +package handler + +import ( + "net/http" + "strings" + + "github.com/Devlaner/devlane/api/internal/store" + "github.com/gin-gonic/gin" +) + +// InvitationHandler serves public invite-by-token endpoints (no auth). +type InvitationHandler struct { + Winv *store.WorkspaceInviteStore + Ws *store.WorkspaceStore +} + +// GetInviteByToken returns workspace invite details by token for the invite landing page. +// GET /api/invitations/by-token/?token=... +func (h *InvitationHandler) GetInviteByToken(c *gin.Context) { + token := strings.TrimSpace(c.Query("token")) + if token == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "token required"}) + return + } + inv, err := h.Winv.GetByToken(c.Request.Context(), token) + if err != nil || inv == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Invite not found or expired"}) + return + } + w, err := h.Ws.GetByID(c.Request.Context(), inv.WorkspaceID) + if err != nil || w == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Workspace not found"}) + return + } + c.JSON(http.StatusOK, gin.H{ + "workspace_name": w.Name, + "workspace_slug": w.Slug, + "email": inv.Email, + "invitation_id": inv.ID.String(), + }) +} + +// DeclineInviteByToken removes the invitation (Ignore flow). No auth required. +// POST /api/invitations/decline/ body: { "token": "..." } +func (h *InvitationHandler) DeclineInviteByToken(c *gin.Context) { + var body struct { + Token string `json:"token" binding:"required"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "token required"}) + return + } + token := strings.TrimSpace(body.Token) + if token == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "token required"}) + return + } + inv, err := h.Winv.GetByToken(c.Request.Context(), token) + if err != nil || inv == nil { + c.Status(http.StatusNoContent) + return + } + if err := h.Winv.Delete(c.Request.Context(), inv.ID); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to decline invite"}) + return + } + c.Status(http.StatusNoContent) +} diff --git a/api/internal/handler/workspace.go b/api/internal/handler/workspace.go index 9c6489d..44ec820 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. @@ -373,6 +377,27 @@ 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", + InviteURL: inviteLink, + Extra: map[string]string{"workspace_slug": slug, "invite_id": inv.ID.String()}, + }) + } + c.JSON(http.StatusCreated, inv) } diff --git a/api/internal/mail/logger.go b/api/internal/mail/logger.go new file mode 100644 index 0000000..dc20882 --- /dev/null +++ b/api/internal/mail/logger.go @@ -0,0 +1,48 @@ +package mail + +import "log/slog" + +// LogSendAttempt logs when an email send is about to be attempted. +// inviteURL is optional (e.g. workspace invite link); empty string is omitted from logs. +func LogSendAttempt(log *slog.Logger, to, subject, kind, inviteURL string) { + if log == nil { + return + } + attrs := []any{"to", to, "subject", subject, "kind", kind} + if inviteURL != "" { + attrs = append(attrs, "invite_url", inviteURL) + } + log.Info("mail send attempt", attrs...) +} + +// LogSent logs successful email delivery. +func LogSent(log *slog.Logger, to, subject, inviteURL string) { + if log == nil { + return + } + attrs := []any{"to", to, "subject", subject} + if inviteURL != "" { + attrs = append(attrs, "invite_url", inviteURL) + } + log.Info("mail sent", attrs...) +} + +// LogFailed logs a failed email send with error and optional invite URL. +func LogFailed(log *slog.Logger, to, subject, inviteURL string, err error) { + if log == nil { + return + } + attrs := []any{"to", to, "subject", subject, "error", err} + if inviteURL != "" { + attrs = append(attrs, "invite_url", inviteURL) + } + log.Error("mail send failed", attrs...) +} + +// LogSkip logs when mail is skipped (e.g. not configured). +func LogSkip(log *slog.Logger, reason, to string, err error) { + if log == nil { + return + } + log.Warn("mail skip", "reason", reason, "to", to, "error", err) +} diff --git a/api/internal/mail/mail.go b/api/internal/mail/mail.go new file mode 100644 index 0000000..53a8cfd --- /dev/null +++ b/api/internal/mail/mail.go @@ -0,0 +1,148 @@ +package mail + +import ( + "context" + "crypto/tls" + "fmt" + "log/slog" + "net/smtp" + "strconv" + "strings" + + "github.com/Devlaner/devlane/api/internal/crypto" + "github.com/Devlaner/devlane/api/internal/store" +) + +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 that loads SMTP config from instance "email" +// settings and sends mail. If not configured or send fails, logs and returns 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 { + LogSkip(log, "instance email not configured", to, err) + return err + } + from := cfg.SenderEmail + if from == "" { + from = cfg.Username + } + if from == "" { + LogSkip(log, "sender_email and username empty", to, fmt.Errorf("sender not set")) + 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 := sendMailWithConfig(addr, cfg.Host, cfg.Port, cfg.Security, auth, from, to, msg); err != nil { + return err + } + return nil + } +} + +// sendMailWithConfig sends email using smtp.SendMail or, for port 465 with SSL, +// an explicit TLS connection (smtp.SendMail only supports STARTTLS). +func sendMailWithConfig(addr, host string, port int, security string, auth smtp.Auth, from, to string, msg []byte) error { + useImplicitTLS := port == 465 && strings.EqualFold(strings.TrimSpace(security), "SSL") + if useImplicitTLS { + conn, err := tls.Dial("tcp", addr, &tls.Config{ServerName: host}) + if err != nil { + return err + } + defer conn.Close() + client, err := smtp.NewClient(conn, host) + if err != nil { + return err + } + defer client.Close() + if err := client.Auth(auth); err != nil { + return err + } + if err := client.Mail(from); err != nil { + return err + } + if err := client.Rcpt(to); err != nil { + return err + } + w, err := client.Data() + if err != nil { + return err + } + if _, err := w.Write(msg); err != nil { + _ = w.Close() + return err + } + if err := w.Close(); err != nil { + return err + } + return client.Quit() + } + // STARTTLS (port 587) or no security: standard SendMail + return smtp.SendMail(addr, auth, from, []string{to}, msg) +} + +// sanitizeHeader removes CR/LF to prevent header injection. +func sanitizeHeader(s string) string { + return strings.NewReplacer("\r", "", "\n", "").Replace(s) +} + +func buildMessage(to, from, subject, body string) []byte { + const crlf = "\r\n" + to = sanitizeHeader(to) + from = sanitizeHeader(from) + subject = sanitizeHeader(subject) + 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/queue/consumer.go b/api/internal/queue/consumer.go index 8cfc7c2..d288c65 100644 --- a/api/internal/queue/consumer.go +++ b/api/internal/queue/consumer.go @@ -5,6 +5,7 @@ import ( "encoding/json" "log/slog" + "github.com/Devlaner/devlane/api/internal/mail" amqp "github.com/rabbitmq/amqp091-go" ) @@ -78,8 +79,8 @@ func (c *Consumer) defaultHandler(ctx context.Context, queue string, body []byte // --- Handlers for email and webhook (can be extended with real SMTP/HTTP) --- -// HandleSendEmail parses send_email task and runs the given sender. -func HandleSendEmail(sender func(ctx context.Context, to, subject, body string) error) TaskHandler { +// HandleSendEmail parses send_email task, logs attempt/result (including invite_url), and runs the given sender. +func HandleSendEmail(log *slog.Logger, sender func(ctx context.Context, to, subject, body string) error) TaskHandler { return func(ctx context.Context, queue string, body []byte) error { var msg struct { Type string `json:"type"` @@ -91,7 +92,15 @@ func HandleSendEmail(sender func(ctx context.Context, to, subject, body string) if msg.Type != TaskSendEmail { return nil } - return sender(ctx, msg.Payload.To, msg.Payload.Subject, msg.Payload.Body) + p := &msg.Payload + mail.LogSendAttempt(log, p.To, p.Subject, p.Kind, p.InviteURL) + err := sender(ctx, p.To, p.Subject, p.Body) + if err != nil { + mail.LogFailed(log, p.To, p.Subject, p.InviteURL, err) + return err + } + mail.LogSent(log, p.To, p.Subject, p.InviteURL) + return nil } } diff --git a/api/internal/queue/queue.go b/api/internal/queue/queue.go index 3acbe0b..544ac0a 100644 --- a/api/internal/queue/queue.go +++ b/api/internal/queue/queue.go @@ -25,11 +25,12 @@ const ( // SendEmailPayload is the payload for send_email task. type SendEmailPayload struct { - To string `json:"to"` - Subject string `json:"subject"` - Body string `json:"body"` - Kind string `json:"kind"` // e.g. forgot_password, workspace_invite, project_invite - Extra map[string]string `json:"extra,omitempty"` + To string `json:"to"` + Subject string `json:"subject"` + Body string `json:"body"` + Kind string `json:"kind"` // e.g. forgot_password, workspace_invite, project_invite + InviteURL string `json:"invite_url,omitempty"` // optional; logged for debugging (e.g. workspace invite link) + Extra map[string]string `json:"extra,omitempty"` } // WebhookPayload is the payload for webhook_deliver task. diff --git a/api/internal/router/router.go b/api/internal/router/router.go index e4f389f..b4201cf 100644 --- a/api/internal/router/router.go +++ b/api/internal/router/router.go @@ -23,6 +23,7 @@ type Config struct { 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. @@ -76,6 +77,10 @@ func New(cfg Config) *gin.Engine { r.GET("/api/instance/setup-status/", instanceHandler.SetupStatus) r.POST("/api/instance/setup/", instanceHandler.InstanceSetup) + invitationHandler := &handler.InvitationHandler{Winv: workspaceInviteStore, Ws: workspaceStore} + r.GET("/api/invitations/by-token/", invitationHandler.GetInviteByToken) + r.POST("/api/invitations/decline/", invitationHandler.DeclineInviteByToken) + instanceSettingsHandler := &handler.InstanceSettingsHandler{Settings: instanceSettingStore} // Services @@ -94,8 +99,19 @@ 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} diff --git a/ui/package-lock.json b/ui/package-lock.json index 23d10e3..5e134b4 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -1,12 +1,12 @@ { "name": "Devlane UI", - "version": "0.3.0", + "version": "0.3.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "Devlane UI", - "version": "0.3.0", + "version": "0.3.4", "dependencies": { "@headlessui/react": "^2.2.9", "@tailwindcss/vite": "^4.1.18", diff --git a/ui/package.json b/ui/package.json index 7a02aa8..83efb8a 100644 --- a/ui/package.json +++ b/ui/package.json @@ -1,7 +1,7 @@ { "name": "Devlane UI", "private": true, - "version": "0.3.0", + "version": "0.3.4", "type": "module", "scripts": { "dev": "vite", diff --git a/ui/src/api/client.ts b/ui/src/api/client.ts index e0a6494..531f45a 100644 --- a/ui/src/api/client.ts +++ b/ui/src/api/client.ts @@ -15,6 +15,17 @@ export const apiClient = axios.create({ }, }); +// When sending FormData (e.g. file upload), omit Content-Type so the browser sets +// multipart/form-data with the correct boundary. Otherwise the server gets +// Content-Type: application/json and cannot parse the multipart form → 400. +apiClient.interceptors.request.use((config) => { + if (config.data instanceof FormData && config.headers) { + const h = config.headers as Record; + delete h["Content-Type"]; + } + return config; +}); + /** Shape of API error response body (backend may return { error: string }) */ export interface ApiErrorResponse { error?: string; diff --git a/ui/src/api/types.ts b/ui/src/api/types.ts index 9eb77ad..ceee086 100644 --- a/ui/src/api/types.ts +++ b/ui/src/api/types.ts @@ -45,6 +45,14 @@ export interface WorkspaceInviteApiResponse { updated_at?: string; } +/** GET /api/invitations/by-token/?token=... (public) */ +export interface InviteByTokenResponse { + workspace_name: string; + workspace_slug: string; + email: string; + invitation_id: string; +} + /** Request body for POST /api/workspaces/:slug/projects/ */ export interface CreateProjectRequest { name: string; diff --git a/ui/src/components/ProjectIconModal.tsx b/ui/src/components/ProjectIconModal.tsx index 5773018..8be52d2 100644 --- a/ui/src/components/ProjectIconModal.tsx +++ b/ui/src/components/ProjectIconModal.tsx @@ -175,9 +175,10 @@ export function ProjectIconModal({ onClose, onSelect, title = "Project icon", - currentEmoji: _currentEmoji, + currentEmoji, currentIconProp, }: ProjectIconModalProps) { + void currentEmoji; // reserved for future use (e.g. pre-select emoji tab) const [tab, setTab] = useState(TAB_EMOJI); const [emojiSearch, setEmojiSearch] = useState(""); const [iconColor, setIconColor] = useState( @@ -186,6 +187,8 @@ export function ProjectIconModal({ useEffect(() => { if (open) { + // Intentional: sync form state when modal opens (kept for future use) + // eslint-disable-next-line react-hooks/set-state-in-effect setIconColor(currentIconProp?.color ?? "#6366f1"); setEmojiSearch(""); } diff --git a/ui/src/components/SetupGate.tsx b/ui/src/components/SetupGate.tsx index cb3f633..49ce61f 100644 --- a/ui/src/components/SetupGate.tsx +++ b/ui/src/components/SetupGate.tsx @@ -22,6 +22,8 @@ export function SetupGate() { useEffect(() => { if (isSetupPath) { + // Intentional: clear setup flag on setup route (kept for future use) + // eslint-disable-next-line react-hooks/set-state-in-effect setSetupRequired(false); return; } diff --git a/ui/src/components/layout/Header.tsx b/ui/src/components/layout/Header.tsx index 5408503..84533fe 100644 --- a/ui/src/components/layout/Header.tsx +++ b/ui/src/components/layout/Header.tsx @@ -17,6 +17,8 @@ export function Header() { useEffect(() => { if (!workspaceSlug) { + // Intentional: clear workspace/project when slug unmounts (kept for future use) + // eslint-disable-next-line react-hooks/set-state-in-effect setWorkspace(null); setProject(null); return; diff --git a/ui/src/components/layout/PageHeader.tsx b/ui/src/components/layout/PageHeader.tsx index a8e4cad..65fba72 100644 --- a/ui/src/components/layout/PageHeader.tsx +++ b/ui/src/components/layout/PageHeader.tsx @@ -987,7 +987,11 @@ function WorkspaceViewsHeader() { const [selectedViewId, setSelectedViewId] = useState("all"); useEffect(() => { - if (!viewDropdownOpen) setViewSearch(""); + if (!viewDropdownOpen) { + // Intentional: clear search when dropdown closes (kept for future use) + // eslint-disable-next-line react-hooks/set-state-in-effect + setViewSearch(""); + } }, [viewDropdownOpen]); const selectedView = @@ -1122,7 +1126,11 @@ function AnalyticsHeader({ workspaceSlug }: { workspaceSlug: string }) { ); useEffect(() => { - if (!openDropdown) setProjectSearch(""); + if (!openDropdown) { + // Intentional: clear search when dropdown closes (kept for future use) + // eslint-disable-next-line react-hooks/set-state-in-effect + setProjectSearch(""); + } }, [openDropdown]); return ( @@ -1194,15 +1202,17 @@ 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([]); + void workspace; + void projects; // reserved for future use (e.g. breadcrumb, project list) const [project, setProject] = useState(null); const [projectIssueCount, setProjectIssueCount] = useState(0); useEffect(() => { if (!workspaceSlug) { + // Intentional: clear when route unmounts (kept for future use) + // eslint-disable-next-line react-hooks/set-state-in-effect setWorkspace(null); setProjects([]); setProject(null); @@ -1230,6 +1240,8 @@ export function PageHeader() { useEffect(() => { if (!workspaceSlug || !projectId) { + // Intentional: clear when route unmounts (kept for future use) + // eslint-disable-next-line react-hooks/set-state-in-effect setProject(null); setProjectIssueCount(0); return; diff --git a/ui/src/components/layout/Sidebar.tsx b/ui/src/components/layout/Sidebar.tsx index a7aff58..ab60566 100644 --- a/ui/src/components/layout/Sidebar.tsx +++ b/ui/src/components/layout/Sidebar.tsx @@ -485,6 +485,8 @@ export function Sidebar() { const slugForProjects = workspaceSlug ?? workspace?.slug; useEffect(() => { if (!slugForProjects) { + // Intentional: clear projects when workspace unmounts (kept for future use) + setProjects([]); return; } @@ -515,6 +517,7 @@ export function Sidebar() { return () => { cancelled = true; }; + // eslint-disable-next-line react-hooks/exhaustive-deps -- run once on mount; setFavoriteProjectIds is stable }, []); useEffect(() => { @@ -527,6 +530,8 @@ export function Sidebar() { path === `${baseUrl}/archives` || path.startsWith(`${baseUrl}/analytics`) ) { + // Intentional: expand section when on relevant route (kept for future use) + setWorkspaceSectionExpanded(true); } if (projectId && path.startsWith(`${baseUrl}/projects/`)) { @@ -536,6 +541,8 @@ export function Sidebar() { useEffect(() => { if (!workspaceDropdownOpen) { + // Intentional: clear position when dropdown closes (kept for future use) + setDropdownPosition(null); return; } diff --git a/ui/src/components/ui/Card.tsx b/ui/src/components/ui/Card.tsx index 807e747..a39d8fc 100644 --- a/ui/src/components/ui/Card.tsx +++ b/ui/src/components/ui/Card.tsx @@ -27,7 +27,8 @@ export function Card({ ); } -export interface CardHeaderProps extends HTMLAttributes {} +/** Props for card header; extends div attributes. Kept as type for future props. */ +export type CardHeaderProps = HTMLAttributes; export function CardHeader({ className, children, ...props }: CardHeaderProps) { return ( @@ -43,7 +44,8 @@ export function CardHeader({ className, children, ...props }: CardHeaderProps) { ); } -export interface CardContentProps extends HTMLAttributes {} +/** Props for card content; extends div attributes. Kept as type for future props. */ +export type CardContentProps = HTMLAttributes; export function CardContent({ className, diff --git a/ui/src/components/work-item/DatePickerTrigger.tsx b/ui/src/components/work-item/DatePickerTrigger.tsx index be925ab..e38e2c0 100644 --- a/ui/src/components/work-item/DatePickerTrigger.tsx +++ b/ui/src/components/work-item/DatePickerTrigger.tsx @@ -1,5 +1,6 @@ import { useRef } from "react"; +/* eslint-disable react-refresh/only-export-components -- formatDateForDisplay shared util; keep in same file for future use */ export function formatDateForDisplay(isoDate: string): string { if (!isoDate) return ""; const [y, m, d] = isoDate.split("-"); @@ -24,7 +25,7 @@ export function DatePickerTrigger({ value, onChange, placeholder, - compact: _compact = true, + compact: _compact = true, // eslint-disable-line @typescript-eslint/no-unused-vars -- kept for future compact layout }: DatePickerTriggerProps) { const inputRef = useRef(null); const displayValue = value ? formatDateForDisplay(value) : ""; diff --git a/ui/src/components/work-item/Dropdown.tsx b/ui/src/components/work-item/Dropdown.tsx index 7a5df4b..7068bb2 100644 --- a/ui/src/components/work-item/Dropdown.tsx +++ b/ui/src/components/work-item/Dropdown.tsx @@ -40,6 +40,8 @@ export function Dropdown({ useLayoutEffect(() => { if (!open || !triggerRef.current) { + // Intentional: clear position when dropdown closes (kept for future use) + // eslint-disable-next-line react-hooks/set-state-in-effect setPosition(null); return; } diff --git a/ui/src/components/work-item/SelectParentModal.tsx b/ui/src/components/work-item/SelectParentModal.tsx index bc458a7..ae68673 100644 --- a/ui/src/components/work-item/SelectParentModal.tsx +++ b/ui/src/components/work-item/SelectParentModal.tsx @@ -27,13 +27,18 @@ export function SelectParentModal({ open, onClose, issues, - value: _value, + value, onChange, }: SelectParentModalProps) { + void value; // reserved for future use (e.g. pre-select current parent) const [search, setSearch] = useState(""); useEffect(() => { - if (!open) setSearch(""); + if (!open) { + // Intentional: clear search when modal closes (kept for future use) + // eslint-disable-next-line react-hooks/set-state-in-effect + setSearch(""); + } }, [open]); const q = (s: string) => s.toLowerCase().trim(); diff --git a/ui/src/contexts/AuthContext.tsx b/ui/src/contexts/AuthContext.tsx index 923f264..d1278c0 100644 --- a/ui/src/contexts/AuthContext.tsx +++ b/ui/src/contexts/AuthContext.tsx @@ -1,3 +1,4 @@ +/* eslint-disable react-refresh/only-export-components -- context file exports AuthProvider + useAuth; keep for future use */ import { createContext, useCallback, diff --git a/ui/src/contexts/FavoritesContext.tsx b/ui/src/contexts/FavoritesContext.tsx index 914b169..c05d9df 100644 --- a/ui/src/contexts/FavoritesContext.tsx +++ b/ui/src/contexts/FavoritesContext.tsx @@ -1,3 +1,4 @@ +/* eslint-disable react-refresh/only-export-components -- context file exports FavoritesProvider + useFavorites; keep for future use */ import { createContext, useContext, useState, type ReactNode } from "react"; interface FavoritesContextValue { diff --git a/ui/src/contexts/ThemeContext.tsx b/ui/src/contexts/ThemeContext.tsx index 161544a..604eb32 100644 --- a/ui/src/contexts/ThemeContext.tsx +++ b/ui/src/contexts/ThemeContext.tsx @@ -1,3 +1,4 @@ +/* eslint-disable react-refresh/only-export-components -- context file exports ThemeProvider + useTheme; keep for future use */ import { createContext, useCallback, diff --git a/ui/src/pages/AnalyticsOverviewPage.tsx b/ui/src/pages/AnalyticsOverviewPage.tsx index 2385a08..cfa7e77 100644 --- a/ui/src/pages/AnalyticsOverviewPage.tsx +++ b/ui/src/pages/AnalyticsOverviewPage.tsx @@ -28,6 +28,7 @@ export function AnalyticsOverviewPage() { useEffect(() => { if (!workspaceSlug) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: reset loading when no slug (kept for future use) setLoading(false); return; } diff --git a/ui/src/pages/AnalyticsWorkItemsPage.tsx b/ui/src/pages/AnalyticsWorkItemsPage.tsx index bf25c72..b9e9f94 100644 --- a/ui/src/pages/AnalyticsWorkItemsPage.tsx +++ b/ui/src/pages/AnalyticsWorkItemsPage.tsx @@ -104,6 +104,7 @@ export function AnalyticsWorkItemsPage() { useEffect(() => { if (!workspaceSlug) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: reset loading when no slug (kept for future use) setLoading(false); return; } diff --git a/ui/src/pages/BoardPage.tsx b/ui/src/pages/BoardPage.tsx index 3a76e09..7d0759f 100644 --- a/ui/src/pages/BoardPage.tsx +++ b/ui/src/pages/BoardPage.tsx @@ -37,6 +37,7 @@ export function BoardPage() { useEffect(() => { if (!workspaceSlug || !projectId) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: reset loading when no slug/project (kept for future use) setLoading(false); return; } diff --git a/ui/src/pages/CyclesPage.tsx b/ui/src/pages/CyclesPage.tsx index 0ea4f38..2177de4 100644 --- a/ui/src/pages/CyclesPage.tsx +++ b/ui/src/pages/CyclesPage.tsx @@ -152,6 +152,7 @@ export function CyclesPage() { useEffect(() => { if (!workspaceSlug || !projectId) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: reset loading when no slug/project (kept for future use) setLoading(false); return; } @@ -195,8 +196,11 @@ 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; + userId: string | null, + ): { name: string; avatarUrl?: string | null } | null => { + void userId; // reserved for future assignee display + return null; + }; if (loading) { return ( diff --git a/ui/src/pages/InviteAcceptPage.tsx b/ui/src/pages/InviteAcceptPage.tsx new file mode 100644 index 0000000..8175947 --- /dev/null +++ b/ui/src/pages/InviteAcceptPage.tsx @@ -0,0 +1,373 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { useNavigate, useSearchParams } from "react-router-dom"; +import { Card, CardContent, Button } from "../components/ui"; +import { useAuth } from "../contexts/AuthContext"; +import { invitationService } from "../services/invitationService"; +import { workspaceService } from "../services/workspaceService"; + +const IconCheck = () => ( + + + +); + +const IconX = () => ( + + + + +); + +const IconChevronRight = () => ( + + + +); + +const IconGlobe = () => ( + + + + + +); + +const IconClear = () => ( + + + + +); + +export function InviteAcceptPage() { + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + const { user, isLoading: authLoading } = useAuth(); + const token = searchParams.get("token") ?? ""; + + const [invite, setInvite] = useState<{ + workspace_name: string; + workspace_slug: string; + email: string; + invitation_id: string; + } | null>(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [accepting, setAccepting] = useState(false); + const [ignoring, setIgnoring] = useState(false); + const [step, setStep] = useState<"invite" | "join">("invite"); + const [joinEmail, setJoinEmail] = useState(""); + const autoAcceptDone = useRef(false); + + useEffect(() => { + if (!token.trim()) { + navigate("/", { replace: true }); + return; + } + let cancelled = false; + invitationService + .getByToken(token) + .then((data) => { + if (!cancelled) setInvite(data); + }) + .catch(() => { + if (!cancelled) setError("Invite not found or expired."); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [token, navigate]); + + const doJoinWorkspace = useCallback(async () => { + if (!token || !invite) return; + setAccepting(true); + try { + await workspaceService.joinByToken(token); + navigate(`/${invite.workspace_slug}`, { replace: true }); + } catch { + setError("Failed to join workspace. Please try again."); + } finally { + setAccepting(false); + } + }, [token, invite, navigate]); + + // When user returns from login (already authenticated), auto-accept and go to workspace + useEffect(() => { + if ( + !user || + !invite || + !token || + step !== "invite" || + autoAcceptDone.current + ) + return; + autoAcceptDone.current = true; + doJoinWorkspace(); + }, [user, invite, token, step, doJoinWorkspace]); + + const handleAccept = () => { + if (!token || !invite) return; + if (!user) { + setJoinEmail(invite.email); + setStep("join"); + return; + } + doJoinWorkspace(); + }; + + const handleIgnore = async () => { + if (!token) return; + setIgnoring(true); + try { + await invitationService.declineByToken(token); + navigate("/", { replace: true }); + } catch { + setError("Failed to decline. Please try again."); + } finally { + setIgnoring(false); + } + }; + + if (authLoading || loading) { + return ( +
+

Loading…

+
+ ); + } + + if (error && !invite) { + return ( +
+ + +

{error}

+ +
+
+
+ ); + } + + if (!invite) return null; + + // Step 2: Join [workspace] — email + Continue → login + if (step === "join") { + return ( +
+ + +

+ Join + + {invite.workspace_name} +

+

+ Log in to start managing work with your team. +

+ +
+ +
+ setJoinEmail(e.target.value)} + placeholder="you@example.com" + className="h-9 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--bg-surface-1)] py-2 pl-3 pr-9 text-sm text-[var(--txt-primary)] placeholder:text-[var(--txt-placeholder)] focus:outline-none focus:border-[var(--border-strong)]" + autoComplete="email" + /> + {joinEmail && ( + + )} +
+
+ + + +

+ Already have an account?{" "} + +

+ +

+ By signing in, you understand and agree to our{" "} + + Terms of Service + {" "} + and{" "} + + Privacy Policy + + . +

+
+
+
+ ); + } + + return ( +
+ + +

+ You have been invited to {invite.workspace_name} +

+

+ Your workspace is where you'll create projects, collaborate on work + items, and organize different streams of work in your Devlane + account. +

+ + {error && ( +

+ {error} +

+ )} + +
+ + + +
+
+
+
+ ); +} diff --git a/ui/src/pages/InviteSignUpPage.tsx b/ui/src/pages/InviteSignUpPage.tsx new file mode 100644 index 0000000..6775d66 --- /dev/null +++ b/ui/src/pages/InviteSignUpPage.tsx @@ -0,0 +1,318 @@ +import { useEffect, useMemo, useState } from "react"; +import { useNavigate, useLocation, Link } from "react-router-dom"; +import { Card, CardContent, Button } from "../components/ui"; +import { useAuth } from "../contexts/AuthContext"; +import { authService } from "../services/authService"; +import { workspaceService } from "../services/workspaceService"; + +const IconGlobe = () => ( + + + + + +); + +const IconCheck = () => ( + + + +); + +const IconEye = () => ( + + + + +); + +const IconEyeOff = () => ( + + + + + + +); + +function usePasswordRequirements(password: string) { + return useMemo( + () => ({ + minLength: password.length >= 8, + upper: /[A-Z]/.test(password), + lower: /[a-z]/.test(password), + number: /\d/.test(password), + special: /[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]/.test(password), + }), + [password], + ); +} + +function PasswordRequirement({ met, label }: { met: boolean; label: string }) { + return ( +
+ + {met ? : null} + + {label} +
+ ); +} + +export function InviteSignUpPage() { + const navigate = useNavigate(); + const location = useLocation(); + const { setUserFromApi, user } = useAuth(); + + const state = location.state as { + email?: string; + token?: string; + workspaceName?: string; + workspaceSlug?: string; + } | null; + + const email = (state?.email ?? "").trim(); + const token = (state?.token ?? "").trim(); + const workspaceName = state?.workspaceName ?? "the workspace"; + const workspaceSlug = (state?.workspaceSlug ?? "").trim(); + + const [password, setPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [showPassword, setShowPassword] = useState(false); + const [showConfirmPassword, setShowConfirmPassword] = useState(false); + const [error, setError] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + + const req = usePasswordRequirements(password); + const allMet = + req.minLength && req.upper && req.lower && req.number && req.special; + const passwordsMatch = + password === confirmPassword && confirmPassword.length > 0; + + useEffect(() => { + if (!email || !token) { + navigate("/", { replace: true }); + } + }, [email, token, navigate]); + + useEffect(() => { + if (user) { + navigate("/", { replace: true }); + } + }, [user, navigate]); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(""); + if (!allMet) { + setError("Please meet all password requirements."); + return; + } + if (!passwordsMatch) { + setError("Passwords do not match."); + return; + } + setIsSubmitting(true); + try { + const apiUser = await authService.signUp({ + email, + password, + invite_token: token, + }); + setUserFromApi(apiUser); + await workspaceService.joinByToken(token); + navigate(`/${workspaceSlug}`, { replace: true }); + } catch (err: unknown) { + const message = + err && typeof err === "object" && "response" in err + ? (err as { response?: { data?: { error?: string } } }).response?.data + ?.error + : null; + setError(message ?? "Something went wrong. Please try again."); + } finally { + setIsSubmitting(false); + } + } + + if (!email || !token) { + return ( +
+

Loading…

+
+ ); + } + + return ( +
+ + +

+ Join + + {workspaceName} +

+

+ Set a password to create your account and join the workspace. +

+ +
+
+ + +
+ +
+ +
+ setPassword(e.target.value)} + placeholder="Create a password" + className="w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--bg-surface-1)] py-2 pl-3 pr-9 text-sm text-[var(--txt-primary)] placeholder:text-[var(--txt-placeholder)] focus:outline-none focus:border-[var(--border-strong)]" + autoComplete="new-password" + /> + +
+
+ + + + + +
+
+ +
+ +
+ setConfirmPassword(e.target.value)} + placeholder="Confirm password" + className="w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--bg-surface-1)] py-2 pl-3 pr-9 text-sm text-[var(--txt-primary)] placeholder:text-[var(--txt-placeholder)] focus:outline-none focus:border-[var(--border-strong)]" + autoComplete="new-password" + /> + +
+
+ + {error && ( +

{error}

+ )} + + +
+ +

+ Already have an account?{" "} + + Sign in + +

+
+
+
+ ); +} diff --git a/ui/src/pages/IssueListPage.tsx b/ui/src/pages/IssueListPage.tsx index 60de342..7639977 100644 --- a/ui/src/pages/IssueListPage.tsx +++ b/ui/src/pages/IssueListPage.tsx @@ -143,6 +143,7 @@ export function IssueListPage() { useEffect(() => { if (!workspaceSlug || !projectId) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: reset loading when no slug/project (kept for future use) setLoading(false); return; } @@ -203,7 +204,10 @@ export function IssueListPage() { const createParam = searchParams.get("create") === "1"; useEffect(() => { - if (createParam && projectId) setCreateOpen(true); + if (createParam && projectId) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: open create modal from URL (kept for future use) + setCreateOpen(true); + } }, [createParam, projectId]); const handleCloseCreate = () => { diff --git a/ui/src/pages/LoginPage.tsx b/ui/src/pages/LoginPage.tsx index 193434c..77176db 100644 --- a/ui/src/pages/LoginPage.tsx +++ b/ui/src/pages/LoginPage.tsx @@ -7,14 +7,20 @@ export function LoginPage() { const navigate = useNavigate(); const location = useLocation(); const { login } = useAuth(); - const [email, setEmail] = useState(""); + + const state = location.state as { + from?: { pathname?: string; search?: string }; + email?: string; + } | null; + const from = state?.from; + const returnPath = from ? (from.pathname ?? "/") + (from.search ?? "") : "/"; + const prefilledEmail = state?.email ?? ""; + + const [email, setEmail] = useState(prefilledEmail); const [password, setPassword] = useState(""); const [error, setError] = useState(""); const [isSubmitting, setIsSubmitting] = useState(false); - const returnPath = - (location.state as { from?: { pathname?: string } })?.from?.pathname ?? "/"; - async function handleSubmit(e: React.FormEvent) { e.preventDefault(); setError(""); diff --git a/ui/src/pages/ModulesPage.tsx b/ui/src/pages/ModulesPage.tsx index 311cbad..9afd5d9 100644 --- a/ui/src/pages/ModulesPage.tsx +++ b/ui/src/pages/ModulesPage.tsx @@ -132,6 +132,7 @@ export function ModulesPage() { useEffect(() => { if (!workspaceSlug || !projectId) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: reset loading when no slug/project (kept for future use) setLoading(false); return; } diff --git a/ui/src/pages/NotificationsPage.tsx b/ui/src/pages/NotificationsPage.tsx index bd01f79..8aef24e 100644 --- a/ui/src/pages/NotificationsPage.tsx +++ b/ui/src/pages/NotificationsPage.tsx @@ -45,6 +45,7 @@ export function NotificationsPage() { useEffect(() => { if (!workspaceSlug) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: reset loading when no slug (kept for future use) setLoading(false); return; } @@ -99,6 +100,7 @@ export function NotificationsPage() { useEffect(() => { if (!workspaceSlug || !selectedItem) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: clear selection when route changes (kept for future use) setSelectedProject(null); setSelectedIssue(null); return; diff --git a/ui/src/pages/PagesPage.tsx b/ui/src/pages/PagesPage.tsx index 6f1404f..a2b5694 100644 --- a/ui/src/pages/PagesPage.tsx +++ b/ui/src/pages/PagesPage.tsx @@ -154,6 +154,7 @@ export function PagesPage() { useEffect(() => { if (!workspaceSlug || !projectId) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: reset loading when no slug/project (kept for future use) setLoading(false); return; } @@ -194,8 +195,11 @@ export function PagesPage() { : pages.filter((p) => p.archived_at); const getUser = ( - _userId: string | null, - ): { name: string; avatarUrl?: string | null } | null => null; + userId: string | null, + ): { name: string; avatarUrl?: string | null } | null => { + void userId; // reserved for future assignee display + return null; + }; if (loading) { return ( diff --git a/ui/src/pages/ProfilePage.tsx b/ui/src/pages/ProfilePage.tsx index 69ac94c..fd9f34d 100644 --- a/ui/src/pages/ProfilePage.tsx +++ b/ui/src/pages/ProfilePage.tsx @@ -55,11 +55,11 @@ export function ProfilePage() { useEffect(() => { if (!workspaceSlug) { - setLoading(false); + queueMicrotask(() => setLoading(false)); return; } let cancelled = false; - setLoading(true); + queueMicrotask(() => setLoading(true)); workspaceService .getBySlug(workspaceSlug) .then((w) => { @@ -125,7 +125,7 @@ export function ProfilePage() { return issues.filter((i) => (i.assignee_ids ?? []).includes(profileUser.id), ); - }, [profileUser?.id, issues]); + }, [profileUser, issues]); const issuesSubscribed = issuesAssigned.length; const issuesSubscribedList = issuesAssigned; @@ -235,7 +235,7 @@ export function ProfilePage() { ...p, progress: 0, })); - }, [workspace?.id]); + }, [workspace?.id, projects]); const projectStateBreakdown = useMemo(() => { return projectsWithProgress.map((p) => { @@ -292,17 +292,17 @@ export function ProfilePage() { {/* Main content */}
{/* Tabs */} -
+
{tabs.map((tab) => ( + + {pendingInviteMenuId === inv.id && ( +
e.stopPropagation()} + > + + +
+ )} +
); }) @@ -4302,7 +4411,9 @@ export function SettingsPage() { setProjectStates(list ?? []); setProjectStateModalOpen(false); setProjectStateEdit(null); - } catch {} + } catch { + // Intentionally empty (kept for future use) + } }} > {projectStateEdit ? "Save" : "Create"} @@ -4419,7 +4530,9 @@ export function SettingsPage() { setProjectLabels(list ?? []); setProjectLabelModalOpen(false); setProjectLabelEdit(null); - } catch {} + } catch { + // Intentionally empty (kept for future use) + } }} > {projectLabelEdit ? "Save" : "Create"} diff --git a/ui/src/pages/ViewsPage.tsx b/ui/src/pages/ViewsPage.tsx index 0aee711..8a5db07 100644 --- a/ui/src/pages/ViewsPage.tsx +++ b/ui/src/pages/ViewsPage.tsx @@ -21,6 +21,7 @@ export function ViewsPage() { useEffect(() => { if (!workspaceSlug || !projectId) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: reset loading when no slug/project (kept for future use) setLoading(false); return; } diff --git a/ui/src/pages/WorkspaceHomePage.tsx b/ui/src/pages/WorkspaceHomePage.tsx index 1999d4d..fdab2a4 100644 --- a/ui/src/pages/WorkspaceHomePage.tsx +++ b/ui/src/pages/WorkspaceHomePage.tsx @@ -310,7 +310,9 @@ export function WorkspaceHomePage() { const { user } = useAuth(); const { workspaceSlug } = useParams<{ workspaceSlug: string }>(); const [workspace, setWorkspace] = useState(null); + // projects state reserved for future use (e.g. project list on home) const [_projects, setProjects] = useState([]); + void _projects; const [quicklinks, setQuicklinks] = useState([]); const [stickies, setStickies] = useState([]); const [recents, setRecents] = useState([]); diff --git a/ui/src/pages/WorkspaceViewsPage.tsx b/ui/src/pages/WorkspaceViewsPage.tsx index c62dee3..73727aa 100644 --- a/ui/src/pages/WorkspaceViewsPage.tsx +++ b/ui/src/pages/WorkspaceViewsPage.tsx @@ -164,6 +164,7 @@ export function WorkspaceViewsPage() { useEffect(() => { if (!workspaceSlug) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: reset loading when no slug (kept for future use) setLoading(false); return; } @@ -213,11 +214,31 @@ export function WorkspaceViewsPage() { 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 getLabelNames = (_labelIds: string[] = []) => [] as string[]; - const getCycleName = (_projectId: string, _cycleId: string | null) => null; - const getModuleName = (_projectId: string, _moduleId: string | null) => null; + userId: string | null, + ): { name: string; avatarUrl?: string | null } | null => { + void userId; // reserved for future use + return null; + }; + const getLabelNames = (labelIds: string[] = []): string[] => { + void labelIds; // reserved for future use + return []; + }; + const getCycleName = ( + projectId: string, + cycleId: string | null, + ): string | null => { + void projectId; + void cycleId; // reserved for future use + return null; + }; + const getModuleName = ( + projectId: string, + moduleId: string | null, + ): string | null => { + void projectId; + void moduleId; // reserved for future use + return null; + }; if (loading) { return ( diff --git a/ui/src/pages/instance-admin/InstanceAdminAuthenticationPage.tsx b/ui/src/pages/instance-admin/InstanceAdminAuthenticationPage.tsx index 2decc8d..537a121 100644 --- a/ui/src/pages/instance-admin/InstanceAdminAuthenticationPage.tsx +++ b/ui/src/pages/instance-admin/InstanceAdminAuthenticationPage.tsx @@ -128,7 +128,8 @@ export function InstanceAdminAuthenticationPage() { gitlab: false, }); const [loading, setLoading] = useState(true); - const [_saving, setSaving] = useState(false); + const [saving, setSaving] = useState(false); + void saving; // reserved for future use (e.g. disable submit while saving) const [error, setError] = useState(""); useEffect(() => { diff --git a/ui/src/routes/index.tsx b/ui/src/routes/index.tsx index b0b0856..5d0db89 100644 --- a/ui/src/routes/index.tsx +++ b/ui/src/routes/index.tsx @@ -1,3 +1,4 @@ +/* eslint-disable react-refresh/only-export-components -- routes file exports router + layout components; keep for future use */ import { lazy, Suspense } from "react"; import { createBrowserRouter, Navigate, Outlet } from "react-router-dom"; import { AppShell, InstanceAdminLayout } from "../components/layout"; @@ -151,6 +152,16 @@ const InstanceSetupCompletePage = lazy(() => page({ InstanceSetupCompletePage: m.InstanceSetupCompletePage }), ), ); +const InviteAcceptPage = lazy(() => + import("../pages/InviteAcceptPage").then((m) => + page({ InviteAcceptPage: m.InviteAcceptPage }), + ), +); +const InviteSignUpPage = lazy(() => + import("../pages/InviteSignUpPage").then((m) => + page({ InviteSignUpPage: m.InviteSignUpPage }), + ), +); const PageFallback = () => (
@@ -288,6 +299,21 @@ const router = createBrowserRouter([ ), }, + { + path: "invite", + element: ( + }> + + + ), + children: [ + { index: true, element: }, + { + path: "sign-up", + element: , + }, + ], + }, { element: , children: [ diff --git a/ui/src/services/authService.ts b/ui/src/services/authService.ts index 91afc2d..1da274e 100644 --- a/ui/src/services/authService.ts +++ b/ui/src/services/authService.ts @@ -1,8 +1,12 @@ import { apiClient } from "../api/client"; -import type { UserApiResponse, SignInRequest } from "../api/types"; +import type { + UserApiResponse, + SignInRequest, + SignUpRequest, +} from "../api/types"; /** - * Auth API: sign-in, sign-out, current user. + * Auth API: sign-in, sign-up, sign-out, current user. */ export const authService = { async signIn(payload: SignInRequest): Promise { @@ -13,6 +17,18 @@ export const authService = { return data; }, + /** + * Sign up a new user. When instance has allow_public_signup off, invite_token is required. + * POST /auth/sign-up/ + */ + async signUp(payload: SignUpRequest): Promise { + const { data } = await apiClient.post( + "/auth/sign-up/", + payload, + ); + return data; + }, + async signOut(): Promise { await apiClient.post("/auth/sign-out/"); }, diff --git a/ui/src/services/invitationService.ts b/ui/src/services/invitationService.ts new file mode 100644 index 0000000..cbedd04 --- /dev/null +++ b/ui/src/services/invitationService.ts @@ -0,0 +1,27 @@ +import { apiClient } from "../api/client"; +import type { InviteByTokenResponse } from "../api/types"; + +/** + * Public invitation APIs (no auth required). + */ +export const invitationService = { + /** + * Get invite details by token for the invite landing page. + * GET /api/invitations/by-token/?token=... + */ + async getByToken(token: string): Promise { + const { data } = await apiClient.get( + "/api/invitations/by-token/", + { params: { token } }, + ); + return data; + }, + + /** + * Decline (ignore) an invitation by token. + * POST /api/invitations/decline/ + */ + async declineByToken(token: string): Promise { + await apiClient.post("/api/invitations/decline/", { token }); + }, +}; diff --git a/ui/src/services/workspaceService.ts b/ui/src/services/workspaceService.ts index c909983..45cfde3 100644 --- a/ui/src/services/workspaceService.ts +++ b/ui/src/services/workspaceService.ts @@ -122,4 +122,16 @@ export const workspaceService = { `/api/workspaces/${encodeURIComponent(workspaceSlug)}/invitations/${encodeURIComponent(invitePk)}/`, ); }, + + /** + * Accept a workspace invitation by token (current user is added to the workspace). + * 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/styles/tokens.css b/ui/src/styles/tokens.css index dabef44..5567423 100644 --- a/ui/src/styles/tokens.css +++ b/ui/src/styles/tokens.css @@ -169,8 +169,8 @@ --red-900: oklch(0.8834 0.0616 18.39); --bg-screen: var(--neutral-200); - --bg-canvas: var(--neutral-black); --bg-surface-1: var(--neutral-100); + --bg-canvas: var(--bg-surface-1); /* same as sidebar in dark theme */ --bg-surface-2: var(--neutral-200); --bg-layer-1: var(--neutral-200); --bg-layer-1-hover: var(--neutral-300);