feat: add account and card lifecycle controls
Test and publish / verify (push) Successful in 2m31s

This commit is contained in:
2026-07-29 12:50:13 +03:30
parent eba6b3565c
commit 71d253fb03
6 changed files with 502 additions and 33 deletions
+54 -3
View File
@@ -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")
+169 -2
View File
@@ -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 {
+36 -2
View File
@@ -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; }
+180 -19
View File
@@ -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, &notNull, &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, &currentlyActive); 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
}
+43 -5
View File
@@ -95,13 +95,19 @@
<button class="text-button task-move-button" type="submit">Move</button>
</form>
{{if or (eq .CreatorID $.User.ID) (eq $.User.Role "admin")}}
<details class="task-remove">
<summary>Remove</summary>
<form method="post" action="/board/tasks/{{.ID}}/delete">
<div class="task-lifecycle-actions">
<form method="post" action="/board/tasks/{{.ID}}/archive">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<button class="text-button" type="submit">Confirm</button>
<button class="text-button archive-task" type="submit">Archive</button>
</form>
</details>
<details class="task-remove">
<summary>Delete</summary>
<form method="post" action="/board/tasks/{{.ID}}/delete">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<button class="text-button" type="submit">Delete permanently</button>
</form>
</details>
</div>
{{end}}
</div>
</article>
@@ -111,6 +117,38 @@
</section>
{{end}}
</div>
{{if .ArchivedTasks}}
<details class="archived-cards-panel">
<summary>Archived cards <span>{{len .ArchivedTasks}}</span></summary>
<div class="archived-card-list">
{{range .ArchivedTasks}}
<article class="archived-card">
<div>
{{if .Tags}}<div class="card-tags">{{range .Tags}}<span class="board-tag {{.Color}}">{{.Name}}</span>{{end}}</div>{{end}}
<span class="task-id">#{{.ID}} · {{.Status}}</span>
<h3>{{.Title}}</h3>
<small>Originally created by {{.CreatorName}}</small>
</div>
{{if or (eq .CreatorID $.User.ID) (eq $.User.Role "admin")}}
<div class="archived-card-actions">
<form method="post" action="/board/tasks/{{.ID}}/restore">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<button class="button secondary" type="submit">Restore</button>
</form>
<details class="archived-delete">
<summary class="text-button">Delete</summary>
<form method="post" action="/board/tasks/{{.ID}}/delete">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<button class="text-button" type="submit">Delete permanently</button>
</form>
</details>
</div>
{{end}}
</article>
{{end}}
</div>
</details>
{{end}}
<script src="/static/board.js" defer></script>
{{template "shell-end" .}}
{{end}}
+20 -2
View File
@@ -24,11 +24,29 @@
<section class="card">
<header class="section-head"><div><p class="eyebrow">DIRECTORY</p><h2>{{len .Users}} teammates</h2></div></header>
<div class="user-list">
{{$current := .User}}
{{range .Users}}
<div class="user-row">
<div class="user-row {{if not .Active}}inactive{{end}}">
<span class="avatar">{{initial .DisplayName}}</span>
<div><strong>{{.DisplayName}}</strong><small>@{{.Username}}{{if .Email}} · {{.Email}}{{end}}</small></div>
<span class="badge {{if eq .Role "admin"}}approved{{else}}kind{{end}}">{{.Role}}</span>
<div class="user-badges">
<span class="badge {{if eq .Role "admin"}}approved{{else}}kind{{end}}">{{.Role}}</span>
{{if not .Active}}<span class="badge locked">Locked</span>{{end}}
</div>
{{if eq .ID $current.ID}}
<small class="current-account">Current account</small>
{{else}}
<form method="post" action="/admin/users/{{.ID}}/status" class="account-status-form">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
{{if .Active}}
<input type="hidden" name="action" value="lock">
<button class="text-button lock-account" type="submit">Lock</button>
{{else}}
<input type="hidden" name="action" value="unlock">
<button class="text-button unlock-account" type="submit">Unlock</button>
{{end}}
</form>
{{end}}
</div>
{{end}}
</div>