diff --git a/internal/app/server.go b/internal/app/server.go index e6f02e2..3651b7c 100644 --- a/internal/app/server.go +++ b/internal/app/server.go @@ -96,6 +96,7 @@ type PageData struct { ReportTotals ReportTotals Board []BoardColumn BoardTags []BoardTag + ArchivedTasks []BoardTask } type Stats struct{ Present, Remote, Leave int } @@ -270,6 +271,8 @@ func (s *Server) routes(mux *http.ServeMux) { mux.HandleFunc("GET /board", s.requireAuth(s.boardPage)) mux.HandleFunc("POST /board/tasks", s.requireAuth(s.csrf(s.createBoardTask))) mux.HandleFunc("POST /board/tasks/{id}/move", s.requireAuth(s.csrf(s.moveBoardTask))) + mux.HandleFunc("POST /board/tasks/{id}/archive", s.requireAuth(s.csrf(s.archiveBoardTask))) + mux.HandleFunc("POST /board/tasks/{id}/restore", s.requireAuth(s.csrf(s.restoreBoardTask))) mux.HandleFunc("POST /board/tasks/{id}/delete", s.requireAuth(s.csrf(s.deleteBoardTask))) mux.HandleFunc("POST /board/tasks/{id}/tags", s.requireAuth(s.csrf(s.setBoardTaskTags))) mux.HandleFunc("POST /board/tags", s.requireAuth(s.csrf(s.createBoardTag))) @@ -278,6 +281,7 @@ func (s *Server) routes(mux *http.ServeMux) { mux.HandleFunc("POST /admin/requests/{id}/review", s.requireAdmin(s.csrf(s.reviewRequest))) mux.HandleFunc("GET /admin/users", s.requireAdmin(s.usersPage)) mux.HandleFunc("POST /admin/users", s.requireAdmin(s.csrf(s.createUser))) + mux.HandleFunc("POST /admin/users/{id}/status", s.requireAdmin(s.csrf(s.setUserStatus))) mux.HandleFunc("GET /reports", s.requireAdmin(s.reportPage)) mux.HandleFunc("GET /reports/attendance.csv", s.requireAdmin(s.reportCSV)) mux.HandleFunc("GET /reports/work-updates.csv", s.requireAdmin(s.workUpdatesCSV)) @@ -744,7 +748,12 @@ func (s *Server) boardPage(w http.ResponseWriter, r *http.Request) { http.Error(w, "could not load team board", http.StatusInternalServerError) return } - users, err := s.store.Users() + archivedTasks, err := s.store.ArchivedBoardTasks() + if err != nil { + http.Error(w, "could not load archived cards", http.StatusInternalServerError) + return + } + users, err := s.store.ActiveUsers() if err != nil { http.Error(w, "could not load teammates", http.StatusInternalServerError) return @@ -770,7 +779,8 @@ func (s *Server) boardPage(w http.ResponseWriter, r *http.Request) { } s.render(w, "board.html", PageData{ Title: "Team board", User: currentUser(r), CSRF: csrfToken(r), Users: users, Board: columns, BoardTags: tags, - TodayJalali: jalali.FromTime(time.Now()).String(), Error: r.URL.Query().Get("error"), + ArchivedTasks: archivedTasks, + TodayJalali: jalali.FromTime(time.Now()).String(), Error: r.URL.Query().Get("error"), Flash: r.URL.Query().Get("flash"), SelectedSection: "board", }) } @@ -848,7 +858,29 @@ func (s *Server) deleteBoardTask(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/board?error="+url.QueryEscape(err.Error()), http.StatusSeeOther) return } - http.Redirect(w, r, "/board?flash=Task+removed", http.StatusSeeOther) + http.Redirect(w, r, "/board?flash=Task+permanently+deleted", http.StatusSeeOther) +} + +func (s *Server) archiveBoardTask(w http.ResponseWriter, r *http.Request) { + s.setBoardTaskArchived(w, r, true) +} + +func (s *Server) restoreBoardTask(w http.ResponseWriter, r *http.Request) { + s.setBoardTaskArchived(w, r, false) +} + +func (s *Server) setBoardTaskArchived(w http.ResponseWriter, r *http.Request, archived bool) { + id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64) + user := currentUser(r) + if err := s.store.SetBoardTaskArchived(id, user.ID, user.Role == "admin", archived); err != nil { + http.Redirect(w, r, "/board?error="+url.QueryEscape(err.Error()), http.StatusSeeOther) + return + } + message := "Task+restored" + if archived { + message = "Task+archived" + } + http.Redirect(w, r, "/board?flash="+message, http.StatusSeeOther) } func (s *Server) setBoardTaskTags(w http.ResponseWriter, r *http.Request) { @@ -920,6 +952,25 @@ func (s *Server) createUser(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/admin/users?flash=Teammate+account+created", http.StatusSeeOther) } +func (s *Server) setUserStatus(w http.ResponseWriter, r *http.Request) { + id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64) + action := r.FormValue("action") + if action != "lock" && action != "unlock" { + http.Redirect(w, r, "/admin/users?error=Choose+a+valid+account+action", http.StatusSeeOther) + return + } + active := action == "unlock" + if err := s.store.SetUserActive(id, currentUser(r).ID, active); err != nil { + http.Redirect(w, r, "/admin/users?error="+url.QueryEscape(err.Error()), http.StatusSeeOther) + return + } + message := "Account+locked" + if active { + message = "Account+unlocked" + } + http.Redirect(w, r, "/admin/users?flash="+message, http.StatusSeeOther) +} + func (s *Server) reportPage(w http.ResponseWriter, r *http.Request) { now := time.Now() start := now.AddDate(0, -1, 0).Format("2006-01-02") diff --git a/internal/app/server_test.go b/internal/app/server_test.go index 6a5fbec..5cab6b9 100644 --- a/internal/app/server_test.go +++ b/internal/app/server_test.go @@ -1,6 +1,7 @@ package app import ( + "database/sql" "net/http" "net/http/httptest" "net/url" @@ -12,6 +13,62 @@ import ( "time" ) +func TestExistingDatabaseAddsNewColumns(t *testing.T) { + path := filepath.Join(t.TempDir(), "legacy.db") + db, err := sql.Open("sqlite", path) + if err != nil { + t.Fatal(err) + } + _, err = db.Exec(`CREATE TABLE users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE COLLATE NOCASE, + password_hash TEXT, + display_name TEXT NOT NULL, + email TEXT UNIQUE COLLATE NOCASE, + role TEXT NOT NULL DEFAULT 'member', + avatar_url TEXT NOT NULL DEFAULT '', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + INSERT INTO users(username,display_name,role) VALUES('legacy-admin','Legacy Admin','admin'); + CREATE TABLE board_tasks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'backlog', + assignee_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + creator_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + due_date TEXT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + INSERT INTO board_tasks(title,creator_id) VALUES('Legacy card',1)`) + if err != nil { + t.Fatal(err) + } + if err := db.Close(); err != nil { + t.Fatal(err) + } + + store, err := OpenStore(path) + if err != nil { + t.Fatalf("open legacy database: %v", err) + } + defer store.Close() + var active bool + if err := store.db.QueryRow(`SELECT active FROM users WHERE username='legacy-admin'`).Scan(&active); err != nil { + t.Fatalf("read migrated account state: %v", err) + } + if !active { + t.Fatal("legacy account was not active after migration") + } + var archivedAt sql.NullString + if err := store.db.QueryRow(`SELECT archived_at FROM board_tasks WHERE title='Legacy card'`).Scan(&archivedAt); err != nil { + t.Fatalf("read migrated card archive state: %v", err) + } + if archivedAt.Valid { + t.Fatalf("legacy card was unexpectedly archived: %q", archivedAt.String) + } +} + func TestLoginAttendanceAndReportFlow(t *testing.T) { s, err := New(Config{ Addr: ":0", @@ -62,6 +119,67 @@ func TestLoginAttendanceAndReportFlow(t *testing.T) { if _, err := s.store.Authenticate("sara", "temporary-password"); err != nil { t.Fatalf("created teammate could not authenticate: %v", err) } + users, err := s.store.Users() + if err != nil { + t.Fatal(err) + } + var sara User + for _, user := range users { + if user.Username == "sara" { + sara = user + break + } + } + if sara.ID == 0 || !sara.Active { + t.Fatalf("created teammate missing or inactive: %#v", sara) + } + saraToken, _, err := s.store.CreateSession(sara.ID) + if err != nil { + t.Fatal(err) + } + lockUser := formRequest(t, s.http.Handler, "/admin/users/"+strconv.FormatInt(sara.ID, 10)+"/status", url.Values{ + "csrf": {csrf}, + "action": {"lock"}, + }, session) + if lockUser.Code != http.StatusSeeOther || lockUser.Header().Get("Location") != "/admin/users?flash=Account+locked" { + t.Fatalf("lock user: got %d location %q", lockUser.Code, lockUser.Header().Get("Location")) + } + if _, err := s.store.Authenticate("sara", "temporary-password"); err == nil || !strings.Contains(err.Error(), "locked") { + t.Fatalf("locked teammate authentication result: %v", err) + } + if _, _, err := s.store.Session(saraToken); err == nil { + t.Fatal("locking a teammate did not revoke the active session") + } + if activeUsers, err := s.store.ActiveUsers(); err != nil || len(activeUsers) != 1 { + t.Fatalf("locked teammate was not hidden from active users: %#v, %v", activeUsers, err) + } + if roster, err := s.store.DayRoster(time.Now().Format("2006-01-02")); err != nil || len(roster) != 1 { + t.Fatalf("locked teammate was not hidden from the team roster: %#v, %v", roster, err) + } + usersRequest := httptest.NewRequest(http.MethodGet, "/admin/users", nil) + usersRequest.AddCookie(session) + usersResponse := httptest.NewRecorder() + s.http.Handler.ServeHTTP(usersResponse, usersRequest) + if usersResponse.Code != http.StatusOK || !strings.Contains(usersResponse.Body.String(), "Locked") || !strings.Contains(usersResponse.Body.String(), "Unlock") { + t.Fatalf("locked account state missing from admin directory: %d %s", usersResponse.Code, usersResponse.Body.String()) + } + lockSelf := formRequest(t, s.http.Handler, "/admin/users/1/status", url.Values{ + "csrf": {csrf}, + "action": {"lock"}, + }, session) + if lockSelf.Code != http.StatusSeeOther || !strings.Contains(lockSelf.Header().Get("Location"), "error=") { + t.Fatalf("administrator could lock their own account: %d %q", lockSelf.Code, lockSelf.Header().Get("Location")) + } + unlockUser := formRequest(t, s.http.Handler, "/admin/users/"+strconv.FormatInt(sara.ID, 10)+"/status", url.Values{ + "csrf": {csrf}, + "action": {"unlock"}, + }, session) + if unlockUser.Code != http.StatusSeeOther || unlockUser.Header().Get("Location") != "/admin/users?flash=Account+unlocked" { + t.Fatalf("unlock user: got %d location %q", unlockUser.Code, unlockUser.Header().Get("Location")) + } + if _, err := s.store.Authenticate("sara", "temporary-password"); err != nil { + t.Fatalf("unlocked teammate could not authenticate: %v", err) + } checkIn := formRequest(t, s.http.Handler, "/attendance/check-in", url.Values{ "csrf": {csrf}, @@ -564,6 +682,15 @@ func TestSharedBoardCreateAssignMoveAndPermissions(t *testing.T) { t.Fatalf("board page does not include %q", expected) } } + adminBoardRequest := httptest.NewRequest(http.MethodGet, "/board", nil) + adminBoardRequest.AddCookie(adminCookie) + adminBoardResponse := httptest.NewRecorder() + s.http.Handler.ServeHTTP(adminBoardResponse, adminBoardRequest) + for _, expected := range []string{"Archive", "Delete", "Delete permanently"} { + if !strings.Contains(adminBoardResponse.Body.String(), expected) { + t.Fatalf("card owner controls do not include %q", expected) + } + } tagPath := "/board/tasks/" + strconv.FormatInt(tasks[0].ID, 10) + "/tags" setTags := formRequest(t, s.http.Handler, tagPath, url.Values{ @@ -609,13 +736,53 @@ func TestSharedBoardCreateAssignMoveAndPermissions(t *testing.T) { t.Fatal("non-creator removed a board task") } + archivePath := "/board/tasks/" + strconv.FormatInt(tasks[0].ID, 10) + "/archive" + deniedArchive := formRequest(t, s.http.Handler, archivePath, url.Values{"csrf": {memberCSRF}}, memberCookie) + if deniedArchive.Code != http.StatusSeeOther || !strings.Contains(deniedArchive.Header().Get("Location"), "error=") { + t.Fatalf("non-creator archive was not rejected: %d %q", deniedArchive.Code, deniedArchive.Header().Get("Location")) + } + adminArchive := formRequest(t, s.http.Handler, archivePath, url.Values{"csrf": {adminCSRF}}, adminCookie) + if adminArchive.Code != http.StatusSeeOther || adminArchive.Header().Get("Location") != "/board?flash=Task+archived" { + t.Fatalf("admin archive: got %d location %q", adminArchive.Code, adminArchive.Header().Get("Location")) + } + if active, _ := s.store.BoardTasks(); len(active) != 0 { + t.Fatalf("archived card remained on active board: %#v", active) + } + archived, err := s.store.ArchivedBoardTasks() + if err != nil || len(archived) != 1 || archived[0].Title != "Ship the onboarding flow" { + t.Fatalf("archived cards: %#v, %v", archived, err) + } + archivedPageRequest := httptest.NewRequest(http.MethodGet, "/board", nil) + archivedPageRequest.AddCookie(adminCookie) + archivedPage := httptest.NewRecorder() + s.http.Handler.ServeHTTP(archivedPage, archivedPageRequest) + for _, expected := range []string{"Archived cards", "Ship the onboarding flow", "Restore", "Delete permanently"} { + if !strings.Contains(archivedPage.Body.String(), expected) { + t.Fatalf("archived card panel does not include %q", expected) + } + } + restorePath := "/board/tasks/" + strconv.FormatInt(tasks[0].ID, 10) + "/restore" + adminRestore := formRequest(t, s.http.Handler, restorePath, url.Values{"csrf": {adminCSRF}}, adminCookie) + if adminRestore.Code != http.StatusSeeOther || adminRestore.Header().Get("Location") != "/board?flash=Task+restored" { + t.Fatalf("admin restore: got %d location %q", adminRestore.Code, adminRestore.Header().Get("Location")) + } + if active, _ := s.store.BoardTasks(); len(active) != 1 { + t.Fatalf("restored card did not return to board: %#v", active) + } + adminArchive = formRequest(t, s.http.Handler, archivePath, url.Values{"csrf": {adminCSRF}}, adminCookie) + if adminArchive.Code != http.StatusSeeOther { + t.Fatalf("second admin archive status: %d", adminArchive.Code) + } adminDelete := formRequest(t, s.http.Handler, deletePath, url.Values{"csrf": {adminCSRF}}, adminCookie) - if adminDelete.Code != http.StatusSeeOther { - t.Fatalf("admin delete status: %d", adminDelete.Code) + if adminDelete.Code != http.StatusSeeOther || adminDelete.Header().Get("Location") != "/board?flash=Task+permanently+deleted" { + t.Fatalf("admin delete: got %d location %q", adminDelete.Code, adminDelete.Header().Get("Location")) } if remaining, _ := s.store.BoardTasks(); len(remaining) != 0 { t.Fatal("admin could not remove the board task") } + if remaining, _ := s.store.ArchivedBoardTasks(); len(remaining) != 0 { + t.Fatal("permanently deleted card remained in archive") + } } func formRequest(t *testing.T, handler http.Handler, path string, values url.Values, cookie *http.Cookie) *httptest.ResponseRecorder { diff --git a/internal/app/static/style.css b/internal/app/static/style.css index 3af80b9..6f8cd64 100644 --- a/internal/app/static/style.css +++ b/internal/app/static/style.css @@ -186,9 +186,18 @@ textarea { resize: vertical; } .admin-note { padding: 6px 8px; border-radius: 6px; background: var(--paper); color: #46534d !important; } .users-layout > .card { align-self: start; } .user-list { margin: 0 -26px -10px; } -.user-row { display: grid; grid-template-columns: 40px 1fr auto; gap: 12px; align-items: center; padding: 14px 26px; border-top: 1px solid var(--line); } +.user-row { display: grid; grid-template-columns: 40px 1fr auto 58px; gap: 12px; align-items: center; padding: 14px 26px; border-top: 1px solid var(--line); } .user-row strong { display: block; font-size: .8rem; } .user-row small { display: block; margin-top: 3px; color: var(--muted); font-size: .66rem; } +.user-row.inactive { background: var(--paper); } +.user-row.inactive .avatar, .user-row.inactive > div:nth-child(2) { opacity: .55; } +.user-badges { display: flex; flex-wrap: wrap; gap: 5px; justify-content: flex-end; } +.badge.locked { background: #f5e6e4; color: #9d4a44; } +.account-status-form { text-align: right; } +.account-status-form .text-button, .current-account { font-size: .6rem; font-weight: 700; } +.account-status-form .lock-account { color: var(--red); } +.account-status-form .unlock-account { color: var(--green); } +.current-account { text-align: right; } .filter-tabs { display: flex; gap: 4px; padding: 4px; border: 1px solid var(--line); border-radius: 10px; background: #fff; } .filter-tabs a { padding: 7px 11px; border-radius: 7px; color: var(--muted); font-size: .68rem; font-weight: 600; } @@ -336,13 +345,36 @@ blockquote { margin: 10px 0; padding-left: 12px; border-left: 2px solid #d8dfdb; .task-actions .text-button { margin: 0; } .task-move-button { display: none; } .task-actions form:focus-within .task-move-button { display: inline-block; } +.task-lifecycle-actions { display: flex; gap: 9px; align-items: center; } +.task-lifecycle-actions > form { display: flex; } +.archive-task { color: var(--green); font-weight: 700; } .task-remove { position: relative; color: var(--muted); font-size: .58rem; } .task-remove > summary { cursor: pointer; list-style: none; text-decoration: underline; } .task-remove > summary::-webkit-details-marker { display: none; } .task-remove[open] > summary { visibility: hidden; } -.task-remove form { position: absolute; right: 0; bottom: -1px; } +.task-remove form { position: absolute; right: 0; bottom: -1px; width: max-content; } .task-remove form .text-button { color: var(--red); font-weight: 700; } .kanban-empty { display: grid; min-height: 74px; place-items: center; border: 1px dashed #b8c2bd; border-radius: 10px; color: var(--muted); font-size: .62rem; } +.archived-cards-panel { max-width: 1380px; margin: 10px auto 0; border: 1px solid var(--line); border-radius: 13px; background: var(--white); } +.archived-cards-panel > summary { padding: 14px 17px; color: var(--muted); font-size: .7rem; font-weight: 700; cursor: pointer; list-style: none; } +.archived-cards-panel > summary::-webkit-details-marker { display: none; } +.archived-cards-panel > summary::before { content: "▸"; display: inline-block; margin-right: 8px; transition: transform .15s ease; } +.archived-cards-panel[open] > summary::before { transform: rotate(90deg); } +.archived-cards-panel > summary span { display: inline-grid; min-width: 22px; height: 22px; margin-left: 6px; place-items: center; border-radius: 20px; background: var(--paper); font-size: .58rem; } +.archived-card-list { border-top: 1px solid var(--line); } +.archived-card { display: flex; justify-content: space-between; gap: 20px; align-items: center; padding: 14px 17px; border-bottom: 1px solid var(--line); } +.archived-card:last-child { border-bottom: 0; } +.archived-card h3 { margin: 4px 0 2px; font-size: .76rem; } +.archived-card small { color: var(--muted); font-size: .58rem; } +.archived-card .card-tags { margin-bottom: 6px; } +.archived-card-actions { display: flex; gap: 8px; align-items: center; flex: 0 0 auto; } +.archived-card-actions .button { min-height: 34px; padding: 7px 12px; font-size: .61rem; } +.archived-delete { position: relative; } +.archived-delete > summary { color: var(--red); font-size: .6rem; font-weight: 700; cursor: pointer; list-style: none; } +.archived-delete > summary::-webkit-details-marker { display: none; } +.archived-delete[open] > summary { visibility: hidden; } +.archived-delete form { position: absolute; right: 0; bottom: 0; width: max-content; } +.archived-delete form .text-button { color: var(--red); font-weight: 700; } .updates-layout { grid-template-columns: minmax(390px, .8fr) minmax(460px, 1.2fr); } .update-form-card { align-self: start; } @@ -648,6 +680,8 @@ html[data-theme="dark"] .kanban-cards.drag-over { background: rgba(107, 172, 142 .tag-create-form .button { grid-column: 1 / -1; } .kanban-board { grid-template-columns: repeat(4, 82vw); margin-right: -15px; padding-right: 0; padding-left: 15px; scroll-snap-type: x mandatory; } .kanban-column { scroll-snap-align: start; } + .archived-card { align-items: flex-start; flex-direction: column; gap: 11px; } + .archived-card-actions { width: 100%; justify-content: flex-end; } .filter-tabs { display: none; } .day-summary-card, .roster-card { padding: 18px; } .week-toolbar { display: block; padding: 14px; } diff --git a/internal/app/store.go b/internal/app/store.go index e7558d2..ba5db44 100644 --- a/internal/app/store.go +++ b/internal/app/store.go @@ -27,6 +27,7 @@ type User struct { Email string Role string AvatarURL string + Active bool } type Attendance struct { @@ -103,6 +104,7 @@ type BoardTask struct { CreatorName string DueDate string CreatedAt string + ArchivedAt string Tags []BoardTag } @@ -132,6 +134,38 @@ func (s *Store) Close() error { return s.db.Close() } func (s *Store) Ping(ctx context.Context) error { return s.db.PingContext(ctx) } +func (s *Store) ensureColumn(table, column, definition string) error { + rows, err := s.db.Query(`PRAGMA table_info(` + table + `)`) + if err != nil { + return err + } + found := false + for rows.Next() { + var cid, notNull, primaryKey int + var name, columnType string + var defaultValue any + if err := rows.Scan(&cid, &name, &columnType, ¬Null, &defaultValue, &primaryKey); err != nil { + rows.Close() + return err + } + if name == column { + found = true + } + } + if err := rows.Err(); err != nil { + rows.Close() + return err + } + if err := rows.Close(); err != nil { + return err + } + if found { + return nil + } + _, err = s.db.Exec(`ALTER TABLE ` + table + ` ADD COLUMN ` + definition) + return err +} + func (s *Store) migrate() error { const schema = ` CREATE TABLE IF NOT EXISTS users ( @@ -142,6 +176,7 @@ CREATE TABLE IF NOT EXISTS users ( email TEXT UNIQUE COLLATE NOCASE, role TEXT NOT NULL DEFAULT 'member' CHECK(role IN ('member','admin')), avatar_url TEXT NOT NULL DEFAULT '', + active INTEGER NOT NULL DEFAULT 1 CHECK(active IN (0,1)), created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS oauth_accounts ( @@ -200,6 +235,7 @@ CREATE TABLE IF NOT EXISTS board_tasks ( assignee_id INTEGER REFERENCES users(id) ON DELETE SET NULL, creator_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, due_date TEXT, + archived_at DATETIME, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX IF NOT EXISTS idx_board_tasks_status ON board_tasks(status,created_at); @@ -219,6 +255,12 @@ CREATE INDEX IF NOT EXISTS idx_board_task_tags_tag ON board_task_tags(tag_id,tas if _, err := s.db.Exec(schema); err != nil { return err } + if err := s.ensureColumn("users", "active", "active INTEGER NOT NULL DEFAULT 1 CHECK(active IN (0,1))"); err != nil { + return err + } + if err := s.ensureColumn("board_tasks", "archived_at", "archived_at DATETIME"); err != nil { + return err + } var count int if err := s.db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&count); err != nil { return err @@ -297,12 +339,15 @@ func tokenHash(token string) string { func (s *Store) Authenticate(identifier, password string) (*User, error) { var u User var hash string - err := s.db.QueryRow(`SELECT id,username,password_hash,display_name,COALESCE(email,''),role,avatar_url + err := s.db.QueryRow(`SELECT id,username,password_hash,display_name,COALESCE(email,''),role,avatar_url,active FROM users WHERE username=? OR email=?`, strings.TrimSpace(identifier), strings.TrimSpace(identifier)). - Scan(&u.ID, &u.Username, &hash, &u.DisplayName, &u.Email, &u.Role, &u.AvatarURL) + Scan(&u.ID, &u.Username, &hash, &u.DisplayName, &u.Email, &u.Role, &u.AvatarURL, &u.Active) if err != nil || !verifyPassword(hash, password) { return nil, errors.New("invalid email, username, or password") } + if !u.Active { + return nil, errors.New("this account is locked; contact an administrator") + } return &u, nil } @@ -394,7 +439,20 @@ func validUsername(username string) bool { } func (s *Store) Users() ([]User, error) { - rows, err := s.db.Query(`SELECT id,username,display_name,COALESCE(email,''),role,avatar_url FROM users ORDER BY display_name`) + return s.users(false) +} + +func (s *Store) ActiveUsers() ([]User, error) { + return s.users(true) +} + +func (s *Store) users(activeOnly bool) ([]User, error) { + query := `SELECT id,username,display_name,COALESCE(email,''),role,avatar_url,active FROM users` + if activeOnly { + query += ` WHERE active=1` + } + query += ` ORDER BY display_name` + rows, err := s.db.Query(query) if err != nil { return nil, err } @@ -402,7 +460,7 @@ func (s *Store) Users() ([]User, error) { var out []User for rows.Next() { var u User - if err := rows.Scan(&u.ID, &u.Username, &u.DisplayName, &u.Email, &u.Role, &u.AvatarURL); err != nil { + if err := rows.Scan(&u.ID, &u.Username, &u.DisplayName, &u.Email, &u.Role, &u.AvatarURL, &u.Active); err != nil { return nil, err } out = append(out, u) @@ -419,8 +477,16 @@ func (s *Store) CreateSession(userID int64) (token, csrf string, err error) { if err != nil { return "", "", err } - _, err = s.db.Exec(`INSERT INTO sessions(token_hash,user_id,csrf_token,expires_at) VALUES(?,?,?,?)`, - tokenHash(token), userID, csrf, time.Now().Add(30*24*time.Hour)) + result, err := s.db.Exec(`INSERT INTO sessions(token_hash,user_id,csrf_token,expires_at) + SELECT ?,id,?,? FROM users WHERE id=? AND active=1`, + tokenHash(token), csrf, time.Now().Add(30*24*time.Hour), userID) + if err != nil { + return "", "", err + } + count, _ := result.RowsAffected() + if count == 0 { + return "", "", errors.New("account is locked or was not found") + } return } @@ -434,15 +500,24 @@ func (s *Store) UpsertOAuthUser(provider, providerID, username, email, displayNa } defer tx.Rollback() var userID int64 - err = tx.QueryRow(`SELECT user_id FROM oauth_accounts WHERE provider=? AND provider_user_id=?`, provider, providerID).Scan(&userID) + var active bool + err = tx.QueryRow(`SELECT o.user_id,u.active FROM oauth_accounts o + JOIN users u ON u.id=o.user_id + WHERE o.provider=? AND o.provider_user_id=?`, provider, providerID).Scan(&userID, &active) if err == nil { + if !active { + return 0, errors.New("account is locked") + } return userID, tx.Commit() } if !errors.Is(err, sql.ErrNoRows) { return 0, err } if email != "" { - err = tx.QueryRow(`SELECT id FROM users WHERE email=?`, email).Scan(&userID) + err = tx.QueryRow(`SELECT id,active FROM users WHERE email=?`, email).Scan(&userID, &active) + if err == nil && !active { + return 0, errors.New("account is locked") + } } if email == "" || errors.Is(err, sql.ErrNoRows) { username = strings.TrimSpace(username) @@ -487,10 +562,10 @@ func (s *Store) UpsertOAuthUser(provider, providerID, username, email, displayNa func (s *Store) Session(token string) (*User, string, error) { var u User var csrf string - err := s.db.QueryRow(`SELECT u.id,u.username,u.display_name,COALESCE(u.email,''),u.role,u.avatar_url,s.csrf_token + err := s.db.QueryRow(`SELECT u.id,u.username,u.display_name,COALESCE(u.email,''),u.role,u.avatar_url,u.active,s.csrf_token FROM sessions s JOIN users u ON u.id=s.user_id - WHERE s.token_hash=? AND s.expires_at>?`, tokenHash(token), time.Now()). - Scan(&u.ID, &u.Username, &u.DisplayName, &u.Email, &u.Role, &u.AvatarURL, &csrf) + WHERE s.token_hash=? AND s.expires_at>? AND u.active=1`, tokenHash(token), time.Now()). + Scan(&u.ID, &u.Username, &u.DisplayName, &u.Email, &u.Role, &u.AvatarURL, &u.Active, &csrf) if err != nil { return nil, "", err } @@ -501,6 +576,53 @@ func (s *Store) DeleteSession(token string) { _, _ = s.db.Exec(`DELETE FROM sessions WHERE token_hash=?`, tokenHash(token)) } +func (s *Store) SetUserActive(userID, actorID int64, active bool) error { + if userID < 1 { + return errors.New("account was not found") + } + if !active && userID == actorID { + return errors.New("you cannot lock your own account") + } + tx, err := s.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + var role string + var currentlyActive bool + if err := tx.QueryRow(`SELECT role,active FROM users WHERE id=?`, userID).Scan(&role, ¤tlyActive); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return errors.New("account was not found") + } + return err + } + if currentlyActive == active { + return tx.Commit() + } + if !active && role == "admin" { + var activeAdmins int + if err := tx.QueryRow(`SELECT COUNT(*) FROM users WHERE role='admin' AND active=1`).Scan(&activeAdmins); err != nil { + return err + } + if activeAdmins <= 1 { + return errors.New("the last active administrator cannot be locked") + } + } + activeValue := 0 + if active { + activeValue = 1 + } + if _, err := tx.Exec(`UPDATE users SET active=? WHERE id=?`, activeValue, userID); err != nil { + return err + } + if !active { + if _, err := tx.Exec(`DELETE FROM sessions WHERE user_id=?`, userID); err != nil { + return err + } + } + return tx.Commit() +} + func (s *Store) TodayAttendance(userID int64, day string) (*Attendance, error) { var a Attendance err := s.db.QueryRow(`SELECT id,user_id,day, @@ -661,6 +783,7 @@ func (s *Store) DayRoster(day string) ([]DayRosterRow, error) { ),'') FROM users u LEFT JOIN attendance a ON a.user_id=u.id AND a.day=? + WHERE u.active=1 ORDER BY u.display_name`, day, day) if err != nil { return nil, err @@ -820,16 +943,32 @@ func (s *Store) CreateBoardTask(creatorID int64, title, description, status stri } func (s *Store) BoardTasks() ([]BoardTask, error) { - rows, err := s.db.Query(`SELECT + return s.boardTasks(false) +} + +func (s *Store) ArchivedBoardTasks() ([]BoardTask, error) { + return s.boardTasks(true) +} + +func (s *Store) boardTasks(archived bool) ([]BoardTask, error) { + query := `SELECT t.id,t.title,t.description,t.status,t.assignee_id, COALESCE(a.display_name,''),COALESCE(a.username,''), - t.creator_id,c.display_name,COALESCE(t.due_date,''),CAST(t.created_at AS TEXT) + t.creator_id,c.display_name,COALESCE(t.due_date,''),CAST(t.created_at AS TEXT), + COALESCE(CAST(t.archived_at AS TEXT),'') FROM board_tasks t JOIN users c ON c.id=t.creator_id - LEFT JOIN users a ON a.id=t.assignee_id - ORDER BY CASE t.status + LEFT JOIN users a ON a.id=t.assignee_id` + if archived { + query += ` WHERE t.archived_at IS NOT NULL + ORDER BY t.archived_at DESC,t.id DESC` + } else { + query += ` WHERE t.archived_at IS NULL + ORDER BY CASE t.status WHEN 'backlog' THEN 0 WHEN 'in_progress' THEN 1 WHEN 'blocked' THEN 2 ELSE 3 END, - t.created_at,t.id`) + t.created_at,t.id` + } + rows, err := s.db.Query(query) if err != nil { return nil, err } @@ -839,7 +978,7 @@ func (s *Store) BoardTasks() ([]BoardTask, error) { if err := rows.Scan( &task.ID, &task.Title, &task.Description, &task.Status, &task.AssigneeID, &task.AssigneeName, &task.AssigneeUser, &task.CreatorID, &task.CreatorName, - &task.DueDate, &task.CreatedAt, + &task.DueDate, &task.CreatedAt, &task.ArchivedAt, ); err != nil { return nil, err } @@ -880,7 +1019,7 @@ func (s *Store) MoveBoardTask(id int64, status string) error { if !validBoardStatus(status) { return errors.New("invalid board column") } - result, err := s.db.Exec(`UPDATE board_tasks SET status=? WHERE id=?`, status, id) + result, err := s.db.Exec(`UPDATE board_tasks SET status=? WHERE id=? AND archived_at IS NULL`, status, id) if err != nil { return err } @@ -891,6 +1030,28 @@ func (s *Store) MoveBoardTask(id int64, status string) error { return nil } +func (s *Store) SetBoardTaskArchived(id, userID int64, admin, archived bool) error { + archiveValue := any(nil) + if archived { + archiveValue = time.Now().UTC() + } + query := `UPDATE board_tasks SET archived_at=? WHERE id=?` + args := []any{archiveValue, id} + if !admin { + query += ` AND creator_id=?` + args = append(args, userID) + } + result, err := s.db.Exec(query, args...) + if err != nil { + return err + } + count, _ := result.RowsAffected() + if count == 0 { + return errors.New("only the creator or an admin can archive or restore this task") + } + return nil +} + func (s *Store) DeleteBoardTask(id, userID int64, admin bool) error { query := `DELETE FROM board_tasks WHERE id=?` args := []any{id} @@ -904,7 +1065,7 @@ func (s *Store) DeleteBoardTask(id, userID int64, admin bool) error { } count, _ := result.RowsAffected() if count == 0 { - return errors.New("only the creator or an admin can remove this task") + return errors.New("only the creator or an admin can delete this task") } return nil } diff --git a/internal/app/templates/board.html b/internal/app/templates/board.html index 0a368fb..de0ad75 100644 --- a/internal/app/templates/board.html +++ b/internal/app/templates/board.html @@ -95,13 +95,19 @@ {{if or (eq .CreatorID $.User.ID) (eq $.User.Role "admin")}} -
- Remove -
+
+ - + -
+
+ Delete +
+ + +
+
+ {{end}} @@ -111,6 +117,38 @@ {{end}} +{{if .ArchivedTasks}} +
+ Archived cards {{len .ArchivedTasks}} +
+ {{range .ArchivedTasks}} +
+
+ {{if .Tags}}
{{range .Tags}}{{.Name}}{{end}}
{{end}} + #{{.ID}} · {{.Status}} +

{{.Title}}

+ Originally created by {{.CreatorName}} +
+ {{if or (eq .CreatorID $.User.ID) (eq $.User.Role "admin")}} +
+
+ + +
+
+ Delete +
+ + +
+
+
+ {{end}} +
+ {{end}} +
+
+{{end}} {{template "shell-end" .}} {{end}} diff --git a/internal/app/templates/users.html b/internal/app/templates/users.html index d32b308..18bbf66 100644 --- a/internal/app/templates/users.html +++ b/internal/app/templates/users.html @@ -24,11 +24,29 @@

DIRECTORY

{{len .Users}} teammates

+ {{$current := .User}} {{range .Users}} -
+
{{initial .DisplayName}}
{{.DisplayName}}@{{.Username}}{{if .Email}} · {{.Email}}{{end}}
- {{.Role}} +
+ {{.Role}} + {{if not .Active}}Locked{{end}} +
+ {{if eq .ID $current.ID}} + + {{else}} + + {{end}}
{{end}}