user management added
Test and publish / verify (push) Successful in 7m51s

This commit is contained in:
2026-08-04 22:31:24 +03:30
parent 9380f116d7
commit f6577a24e6
5 changed files with 438 additions and 110 deletions
+213 -79
View File
@@ -71,36 +71,36 @@ type Server struct {
} }
type PageData struct { type PageData struct {
Title string Title string
User *User User *User
CSRF string CSRF string
Flash string Flash string
Error string Error string
Today string Today string
TodayJalali string TodayJalali string
Attendance *Attendance Attendance *Attendance
Calendar Calendar Calendar Calendar
Requests []Request Requests []Request
PendingCount int PendingCount int
Stats Stats Stats Stats
OAuthGitHub bool OAuthGitHub bool
OAuthGoogle bool OAuthGoogle bool
ReportStart string ReportStart string
ReportEnd string ReportEnd string
SelectedSection string SelectedSection string
Users []User Users []User
Workspaces []Workspace Workspaces []Workspace
Workspace Workspace Workspace Workspace
WorkspaceMembers map[int64]bool WorkspaceMembers map[int64]bool
Day DayDetail Day DayDetail
Week WeekDetail Week WeekDetail
WorkUpdates []WorkUpdate WorkUpdates []WorkUpdate
ReportSummary []PersonReportSummary ReportSummary []PersonReportSummary
ReportTotals ReportTotals ReportTotals ReportTotals
Board []BoardColumn Board []BoardColumn
BoardTags []BoardTag BoardTags []BoardTag
ArchivedTasks []BoardTask ArchivedTasks []BoardTask
BoardFilter BoardFilter BoardFilter BoardFilter
} }
type Stats struct{ Present, Remote, Leave int } type Stats struct{ Present, Remote, Leave int }
@@ -310,6 +310,8 @@ func (s *Server) routes(mux *http.ServeMux) {
mux.HandleFunc("GET /admin/users", s.requireAdmin(s.usersPage)) 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", s.requireAdmin(s.csrf(s.createUser)))
mux.HandleFunc("POST /admin/users/{id}/status", s.requireAdmin(s.csrf(s.setUserStatus))) mux.HandleFunc("POST /admin/users/{id}/status", s.requireAdmin(s.csrf(s.setUserStatus)))
mux.HandleFunc("POST /admin/users/{id}/password", s.requireAdmin(s.csrf(s.changeUserPassword)))
mux.HandleFunc("POST /admin/users/{id}/delete", s.requireAdmin(s.csrf(s.deleteUser)))
mux.HandleFunc("GET /admin/workspaces", s.requireAdmin(s.workspacesPage)) mux.HandleFunc("GET /admin/workspaces", s.requireAdmin(s.workspacesPage))
mux.HandleFunc("POST /admin/workspaces", s.requireAdmin(s.csrf(s.createWorkspace))) mux.HandleFunc("POST /admin/workspaces", s.requireAdmin(s.csrf(s.createWorkspace)))
mux.HandleFunc("POST /admin/workspaces/{id}/members", s.requireAdmin(s.csrf(s.setWorkspaceMembers))) mux.HandleFunc("POST /admin/workspaces/{id}/members", s.requireAdmin(s.csrf(s.setWorkspaceMembers)))
@@ -495,8 +497,17 @@ func (s *Server) render(w http.ResponseWriter, name string, data PageData) {
if data.User != nil { if data.User != nil {
if workspaces, err := s.store.Workspaces(data.User.ID); err == nil { if workspaces, err := s.store.Workspaces(data.User.ID); err == nil {
data.Workspaces = workspaces data.Workspaces = workspaces
if data.Workspace.ID == 0 && data.User.WorkspaceID > 0 { for _, ws := range workspaces { if ws.ID == data.User.WorkspaceID { data.Workspace = ws; break } } } if data.Workspace.ID == 0 && data.User.WorkspaceID > 0 {
if data.Workspace.ID == 0 && len(workspaces) > 0 { data.Workspace = workspaces[0] } for _, ws := range workspaces {
if ws.ID == data.User.WorkspaceID {
data.Workspace = ws
break
}
}
}
if data.Workspace.ID == 0 && len(workspaces) > 0 {
data.Workspace = workspaces[0]
}
} }
} }
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
@@ -660,18 +671,20 @@ func (s *Server) buildCalendar(userID int64, selected string) Calendar {
func persianHoliday(year, month, day int) string { func persianHoliday(year, month, day int) string {
// Official public holidays for Solar Hijri 1405. Lunar holidays are // Official public holidays for Solar Hijri 1405. Lunar holidays are
// recorded using the dates published for this Persian calendar year. // recorded using the dates published for this Persian calendar year.
if year != 1405 { return "" } if year != 1405 {
return ""
}
holidays := map[string]string{ holidays := map[string]string{
"1-1":"Nowruz", "1-2":"Nowruz", "1-3":"Nowruz", "1-4":"Nowruz", "1-1": "Nowruz", "1-2": "Nowruz", "1-3": "Nowruz", "1-4": "Nowruz",
"1-12":"Islamic Republic Day", "1-13":"Nature Day", "1-12": "Islamic Republic Day", "1-13": "Nature Day",
"2-6":"Eid al-Adha", "2-14":"Eid al-Ghadir", "2-6": "Eid al-Adha", "2-14": "Eid al-Ghadir",
"3-14":"Demise of Imam Khomeini", "3-15":"Khordad Uprising", "3-14": "Demise of Imam Khomeini", "3-15": "Khordad Uprising",
"4-3":"Tasua", "4-4":"Ashura", "4-3": "Tasua", "4-4": "Ashura",
"5-13":"Arbaeen", "5-21":"Demise of Prophet Muhammad", "5-23":"Martyrdom of Imam Hassan", "5-30":"Martyrdom of Imam Reza", "5-13": "Arbaeen", "5-21": "Demise of Prophet Muhammad", "5-23": "Martyrdom of Imam Hassan", "5-30": "Martyrdom of Imam Reza",
"6-8":"Prophet Muhammad's Birthday", "6-8": "Prophet Muhammad's Birthday",
"9-3":"Martyrdom of Fatima", "9-3": "Martyrdom of Fatima",
"10-2":"Imam Ali's Birthday", "10-2": "Imam Ali's Birthday",
"11-22":"Revolution Day", "12-29":"Oil Nationalization Day", "11-22": "Revolution Day", "12-29": "Oil Nationalization Day",
} }
return holidays[fmt.Sprintf("%d-%d", month, day)] return holidays[fmt.Sprintf("%d-%d", month, day)]
} }
@@ -860,8 +873,12 @@ func (s *Server) boardPage(w http.ResponseWriter, r *http.Request) {
} }
func (s *Server) archivedBoardPage(w http.ResponseWriter, r *http.Request) { func (s *Server) archivedBoardPage(w http.ResponseWriter, r *http.Request) {
tasks, err := s.store.ArchivedBoardTasksInWorkspace(requestWorkspaceID(r)); if err != nil { http.Error(w, "could not load archived cards", http.StatusInternalServerError); return } tasks, err := s.store.ArchivedBoardTasksInWorkspace(requestWorkspaceID(r))
s.render(w, "archived.html", PageData{Title:"Archived cards", User:currentUser(r), CSRF:csrfToken(r), ArchivedTasks:tasks, Error:r.URL.Query().Get("error"), Flash:r.URL.Query().Get("flash"), SelectedSection:"board"}) if err != nil {
http.Error(w, "could not load archived cards", http.StatusInternalServerError)
return
}
s.render(w, "archived.html", PageData{Title: "Archived cards", User: currentUser(r), CSRF: csrfToken(r), ArchivedTasks: tasks, Error: r.URL.Query().Get("error"), Flash: r.URL.Query().Get("flash"), SelectedSection: "board"})
} }
func validBoardPriority(priority string) bool { func validBoardPriority(priority string) bool {
@@ -903,7 +920,10 @@ func (s *Server) createBoardTask(w http.ResponseWriter, r *http.Request) {
workspaceID := requestWorkspaceID(r) workspaceID := requestWorkspaceID(r)
if raw := r.FormValue("workspace_id"); raw != "" { if raw := r.FormValue("workspace_id"); raw != "" {
if selected, err := strconv.ParseInt(raw, 10, 64); err == nil { if selected, err := strconv.ParseInt(raw, 10, 64); err == nil {
if _, err := s.store.WorkspaceMember(selected, currentUser(r).ID); err != nil { http.Redirect(w,r,"/board?error=You+do+not+have+access+to+that+workspace",http.StatusSeeOther); return } if _, err := s.store.WorkspaceMember(selected, currentUser(r).ID); err != nil {
http.Redirect(w, r, "/board?error=You+do+not+have+access+to+that+workspace", http.StatusSeeOther)
return
}
workspaceID = selected workspaceID = selected
} }
} }
@@ -1095,61 +1115,148 @@ func (s *Server) usersPage(w http.ResponseWriter, r *http.Request) {
func (s *Server) profilePage(w http.ResponseWriter, r *http.Request) { func (s *Server) profilePage(w http.ResponseWriter, r *http.Request) {
u := currentUser(r) u := currentUser(r)
s.render(w, "profile.html", PageData{Title:"Profile", User:u, CSRF:csrfToken(r), Flash:r.URL.Query().Get("flash"), Error:r.URL.Query().Get("error"), SelectedSection:"profile"}) s.render(w, "profile.html", PageData{Title: "Profile", User: u, CSRF: csrfToken(r), Flash: r.URL.Query().Get("flash"), Error: r.URL.Query().Get("error"), SelectedSection: "profile"})
} }
func (s *Server) uploadAvatar(w http.ResponseWriter, r *http.Request) { func (s *Server) uploadAvatar(w http.ResponseWriter, r *http.Request) {
if err := r.ParseMultipartForm(3 << 20); err != nil { http.Redirect(w,r,"/profile?error=Choose+an+image+up+to+2MB",http.StatusSeeOther); return } if err := r.ParseMultipartForm(3 << 20); err != nil {
file, header, err := r.FormFile("avatar"); if err != nil || header.Size > 2<<20 { http.Redirect(w,r,"/profile?error=Choose+an+image+up+to+2MB",http.StatusSeeOther); return }; defer file.Close() http.Redirect(w, r, "/profile?error=Choose+an+image+up+to+2MB", http.StatusSeeOther)
buf := make([]byte,512); n,_ := file.Read(buf); kind := http.DetectContentType(buf[:n]); ext := map[string]string{"image/jpeg":".jpg","image/png":".png","image/webp":".webp"}[kind]; if ext=="" { http.Redirect(w,r,"/profile?error=Only+JPG,+PNG,+or+WebP+images+are+supported",http.StatusSeeOther); return } return
if _, err := file.Seek(0,0); err != nil { http.Redirect(w,r,"/profile?error=Could+not+read+image",http.StatusSeeOther); return } }
dir := filepath.Join(filepath.Dir(s.cfg.DatabasePath),"avatars"); if err := os.MkdirAll(dir,0755); err != nil { http.Error(w,"could not save avatar",500); return } file, header, err := r.FormFile("avatar")
name := fmt.Sprintf("%d%s", currentUser(r).ID, ext); dst, err := os.Create(filepath.Join(dir,name)); if err != nil { http.Error(w,"could not save avatar",500); return }; defer dst.Close(); if _,err=io.Copy(dst,file); err != nil { http.Error(w,"could not save avatar",500); return } if err != nil || header.Size > 2<<20 {
if err := s.store.SetUserAvatar(currentUser(r).ID,"/uploads/avatars/"+name); err != nil { http.Error(w,"could not save avatar",500); return } http.Redirect(w, r, "/profile?error=Choose+an+image+up+to+2MB", http.StatusSeeOther)
http.Redirect(w,r,"/profile?flash=Profile+photo+updated",http.StatusSeeOther) return
}
defer file.Close()
buf := make([]byte, 512)
n, _ := file.Read(buf)
kind := http.DetectContentType(buf[:n])
ext := map[string]string{"image/jpeg": ".jpg", "image/png": ".png", "image/webp": ".webp"}[kind]
if ext == "" {
http.Redirect(w, r, "/profile?error=Only+JPG,+PNG,+or+WebP+images+are+supported", http.StatusSeeOther)
return
}
if _, err := file.Seek(0, 0); err != nil {
http.Redirect(w, r, "/profile?error=Could+not+read+image", http.StatusSeeOther)
return
}
dir := filepath.Join(filepath.Dir(s.cfg.DatabasePath), "avatars")
if err := os.MkdirAll(dir, 0755); err != nil {
http.Error(w, "could not save avatar", 500)
return
}
name := fmt.Sprintf("%d%s", currentUser(r).ID, ext)
dst, err := os.Create(filepath.Join(dir, name))
if err != nil {
http.Error(w, "could not save avatar", 500)
return
}
defer dst.Close()
if _, err = io.Copy(dst, file); err != nil {
http.Error(w, "could not save avatar", 500)
return
}
if err := s.store.SetUserAvatar(currentUser(r).ID, "/uploads/avatars/"+name); err != nil {
http.Error(w, "could not save avatar", 500)
return
}
http.Redirect(w, r, "/profile?flash=Profile+photo+updated", http.StatusSeeOther)
} }
func (s *Server) workspacesPage(w http.ResponseWriter, r *http.Request) { func (s *Server) workspacesPage(w http.ResponseWriter, r *http.Request) {
u := currentUser(r) u := currentUser(r)
workspaces, err := s.store.AllWorkspaces() workspaces, err := s.store.AllWorkspaces()
if err != nil { http.Error(w, "could not load workspaces", http.StatusInternalServerError); return } if err != nil {
users, err := s.store.Users(); if err != nil { http.Error(w,"could not load users",500); return } http.Error(w, "could not load workspaces", http.StatusInternalServerError)
selected := int64(0); if len(workspaces)>0 { selected=workspaces[0].ID } return
if raw:=r.URL.Query().Get("workspace"); raw!="" { selected,_=strconv.ParseInt(raw,10,64) } }
users, err := s.store.Users()
if err != nil {
http.Error(w, "could not load users", 500)
return
}
selected := int64(0)
if len(workspaces) > 0 {
selected = workspaces[0].ID
}
if raw := r.URL.Query().Get("workspace"); raw != "" {
selected, _ = strconv.ParseInt(raw, 10, 64)
}
members, _ := s.store.WorkspaceMemberIDs(selected) members, _ := s.store.WorkspaceMemberIDs(selected)
selectedWorkspace := Workspace{ID:selected}; for _, ws := range workspaces { if ws.ID == selected { selectedWorkspace = ws; break } } selectedWorkspace := Workspace{ID: selected}
s.render(w, "workspaces.html", PageData{Title:"Workspaces", User:u, CSRF:csrfToken(r), Workspaces:workspaces, Users:users, Workspace:selectedWorkspace, Error:r.URL.Query().Get("error"), Flash:r.URL.Query().Get("flash"), SelectedSection:"workspaces", WorkspaceMembers:members}) for _, ws := range workspaces {
if ws.ID == selected {
selectedWorkspace = ws
break
}
}
s.render(w, "workspaces.html", PageData{Title: "Workspaces", User: u, CSRF: csrfToken(r), Workspaces: workspaces, Users: users, Workspace: selectedWorkspace, Error: r.URL.Query().Get("error"), Flash: r.URL.Query().Get("flash"), SelectedSection: "workspaces", WorkspaceMembers: members})
} }
func (s *Server) setWorkspaceMembers(w http.ResponseWriter, r *http.Request) { func (s *Server) setWorkspaceMembers(w http.ResponseWriter, r *http.Request) {
id,_ := strconv.ParseInt(r.PathValue("id"),10,64); var ids []int64; seen := map[int64]bool{} id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
for _, raw := range r.Form["user_ids"] { if userID,err:=strconv.ParseInt(raw,10,64); err==nil && !seen[userID] { ids=append(ids,userID); seen[userID]=true } } var ids []int64
seen := map[int64]bool{}
for _, raw := range r.Form["user_ids"] {
if userID, err := strconv.ParseInt(raw, 10, 64); err == nil && !seen[userID] {
ids = append(ids, userID)
seen[userID] = true
}
}
// The admin editing access must retain access to the workspace being managed. // The admin editing access must retain access to the workspace being managed.
adminID := currentUser(r).ID adminID := currentUser(r).ID
foundAdmin := false foundAdmin := false
for _, id := range ids { if id == adminID { foundAdmin = true; break } } for _, id := range ids {
if !foundAdmin { ids = append(ids, adminID) } if id == adminID {
if err:=s.store.SetWorkspaceMembers(id,ids); err!=nil { http.Redirect(w,r,"/admin/workspaces?error="+url.QueryEscape(err.Error()),http.StatusSeeOther); return } foundAdmin = true
http.Redirect(w,r,"/admin/workspaces?workspace="+strconv.FormatInt(id,10)+"&flash=Workspace+members+updated",http.StatusSeeOther) break
}
}
if !foundAdmin {
ids = append(ids, adminID)
}
if err := s.store.SetWorkspaceMembers(id, ids); err != nil {
http.Redirect(w, r, "/admin/workspaces?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
return
}
http.Redirect(w, r, "/admin/workspaces?workspace="+strconv.FormatInt(id, 10)+"&flash=Workspace+members+updated", http.StatusSeeOther)
} }
func (s *Server) createWorkspace(w http.ResponseWriter, r *http.Request) { func (s *Server) createWorkspace(w http.ResponseWriter, r *http.Request) {
u := currentUser(r) u := currentUser(r)
if _, err := s.store.CreateWorkspace(r.FormValue("name"), r.FormValue("slug"), u.ID); err != nil { http.Redirect(w,r,"/admin/workspaces?error="+url.QueryEscape(err.Error()),http.StatusSeeOther); return } if _, err := s.store.CreateWorkspace(r.FormValue("name"), r.FormValue("slug"), u.ID); err != nil {
http.Redirect(w,r,"/admin/workspaces?flash=Workspace+created",http.StatusSeeOther) http.Redirect(w, r, "/admin/workspaces?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
return
}
http.Redirect(w, r, "/admin/workspaces?flash=Workspace+created", http.StatusSeeOther)
} }
func (s *Server) switchWorkspace(w http.ResponseWriter, r *http.Request) { func (s *Server) switchWorkspace(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.FormValue("workspace_id"),10,64) id, err := strconv.ParseInt(r.FormValue("workspace_id"), 10, 64)
if err != nil { http.Error(w,"invalid workspace",http.StatusBadRequest); return } if err != nil {
if _, err := s.store.WorkspaceMember(id,currentUser(r).ID); err != nil { http.Error(w,"workspace access denied",http.StatusForbidden); return } http.Error(w, "invalid workspace", http.StatusBadRequest)
http.SetCookie(w,&http.Cookie{Name:"teammate_workspace",Value:strconv.FormatInt(id,10),Path:"/",HttpOnly:true,SameSite:http.SameSiteLaxMode,MaxAge:31536000}) return
w.Header().Set("Cache-Control", "no-store")
returnTo := r.FormValue("return_to"); if returnTo == "" || !strings.HasPrefix(returnTo,"/") { returnTo = "/admin/workspaces?workspace="+strconv.FormatInt(id,10) }
if r.FormValue("return_to") == "" {
if ref, err := url.Parse(r.Referer()); err == nil && ref.Path != "" && ref.Path != "/workspace/switch" { returnTo = ref.Path; if ref.RawQuery != "" { returnTo += "?" + ref.RawQuery } }
} }
http.Redirect(w,r,returnTo,http.StatusSeeOther) if _, err := s.store.WorkspaceMember(id, currentUser(r).ID); err != nil {
http.Error(w, "workspace access denied", http.StatusForbidden)
return
}
http.SetCookie(w, &http.Cookie{Name: "teammate_workspace", Value: strconv.FormatInt(id, 10), Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: 31536000})
w.Header().Set("Cache-Control", "no-store")
returnTo := r.FormValue("return_to")
if returnTo == "" || !strings.HasPrefix(returnTo, "/") {
returnTo = "/admin/workspaces?workspace=" + strconv.FormatInt(id, 10)
}
if r.FormValue("return_to") == "" {
if ref, err := url.Parse(r.Referer()); err == nil && ref.Path != "" && ref.Path != "/workspace/switch" {
returnTo = ref.Path
if ref.RawQuery != "" {
returnTo += "?" + ref.RawQuery
}
}
}
http.Redirect(w, r, returnTo, http.StatusSeeOther)
} }
func (s *Server) createUser(w http.ResponseWriter, r *http.Request) { func (s *Server) createUser(w http.ResponseWriter, r *http.Request) {
@@ -1180,6 +1287,29 @@ func (s *Server) setUserStatus(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/admin/users?flash="+message, http.StatusSeeOther) http.Redirect(w, r, "/admin/users?flash="+message, http.StatusSeeOther)
} }
func (s *Server) changeUserPassword(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
password := r.FormValue("password")
if password == "" || password != r.FormValue("password_confirm") {
http.Redirect(w, r, "/admin/users?error=Passwords+must+match", http.StatusSeeOther)
return
}
if err := s.store.SetUserPassword(id, password); err != nil {
http.Redirect(w, r, "/admin/users?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
return
}
http.Redirect(w, r, "/admin/users?flash=Password+updated", http.StatusSeeOther)
}
func (s *Server) deleteUser(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err := s.store.DeleteUser(id, currentUser(r).ID); err != nil {
http.Redirect(w, r, "/admin/users?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
return
}
http.Redirect(w, r, "/admin/users?flash=Account+deleted", http.StatusSeeOther)
}
func (s *Server) reportPage(w http.ResponseWriter, r *http.Request) { func (s *Server) reportPage(w http.ResponseWriter, r *http.Request) {
now := time.Now() now := time.Now()
start := now.AddDate(0, -1, 0).Format("2006-01-02") start := now.AddDate(0, -1, 0).Format("2006-01-02")
@@ -1313,7 +1443,9 @@ func currentUser(r *http.Request) *User {
func requestWorkspaceID(r *http.Request) int64 { func requestWorkspaceID(r *http.Request) int64 {
if c, err := r.Cookie("teammate_workspace"); err == nil { if c, err := r.Cookie("teammate_workspace"); err == nil {
if id, err := strconv.ParseInt(c.Value, 10, 64); err == nil && id > 0 { return id } if id, err := strconv.ParseInt(c.Value, 10, 64); err == nil && id > 0 {
return id
}
} }
return 1 return 1
} }
@@ -1330,7 +1462,9 @@ func (s *Server) withUser(next http.Handler) http.Handler {
if u, csrf, err := s.store.Session(c.Value); err == nil { if u, csrf, err := s.store.Session(c.Value); err == nil {
u.WorkspaceID = requestWorkspaceID(r) u.WorkspaceID = requestWorkspaceID(r)
if _, err := s.store.WorkspaceMember(u.WorkspaceID, u.ID); err != nil { if _, err := s.store.WorkspaceMember(u.WorkspaceID, u.ID); err != nil {
if available, lookupErr := s.store.Workspaces(u.ID); lookupErr == nil && len(available) > 0 { u.WorkspaceID = available[0].ID } if available, lookupErr := s.store.Workspaces(u.ID); lookupErr == nil && len(available) > 0 {
u.WorkspaceID = available[0].ID
}
} }
ctx := r.Context() ctx := r.Context()
ctx = context.WithValue(ctx, userKey, u) ctx = context.WithValue(ctx, userKey, u)
+21
View File
@@ -215,6 +215,27 @@ func TestLoginAttendanceAndReportFlow(t *testing.T) {
if _, err := s.store.Authenticate("navid", "temporary-password"); err != nil { if _, err := s.store.Authenticate("navid", "temporary-password"); err != nil {
t.Fatalf("unlocked teammate could not authenticate: %v", err) t.Fatalf("unlocked teammate could not authenticate: %v", err)
} }
newPassword := formRequest(t, s.http.Handler, "/admin/users/"+strconv.FormatInt(testuser.ID, 10)+"/password", url.Values{
"csrf": {csrf},
"password": {"changed-password"},
"password_confirm": {"changed-password"},
}, session)
if newPassword.Code != http.StatusSeeOther || newPassword.Header().Get("Location") != "/admin/users?flash=Password+updated" {
t.Fatalf("change password: got %d location %q", newPassword.Code, newPassword.Header().Get("Location"))
}
if _, err := s.store.Authenticate("navid", "temporary-password"); err == nil {
t.Fatal("old password still authenticates after reset")
}
if _, err := s.store.Authenticate("navid", "changed-password"); err != nil {
t.Fatalf("new password does not authenticate: %v", err)
}
deleteUser := formRequest(t, s.http.Handler, "/admin/users/"+strconv.FormatInt(testuser.ID, 10)+"/delete", url.Values{"csrf": {csrf}}, session)
if deleteUser.Code != http.StatusSeeOther || deleteUser.Header().Get("Location") != "/admin/users?flash=Account+deleted" {
t.Fatalf("delete user: got %d location %q", deleteUser.Code, deleteUser.Header().Get("Location"))
}
if _, err := s.store.Authenticate("navid", "changed-password"); err == nil {
t.Fatal("deleted account still authenticates")
}
checkIn := formRequest(t, s.http.Handler, "/attendance/check-in", url.Values{ checkIn := formRequest(t, s.http.Handler, "/attendance/check-in", url.Values{
"csrf": {csrf}, "csrf": {csrf},
+12 -1
View File
@@ -227,6 +227,17 @@ textarea { resize: vertical; }
.account-status-form .text-button, .current-account { font-size: .6rem; font-weight: 700; } .account-status-form .text-button, .current-account { font-size: .6rem; font-weight: 700; }
.account-status-form .lock-account { color: var(--red); } .account-status-form .lock-account { color: var(--red); }
.account-status-form .unlock-account { color: var(--green); } .account-status-form .unlock-account { color: var(--green); }
.account-actions { display:flex; align-items:center; justify-content:flex-end; gap:10px; }
.account-manage { position:relative; }
.account-manage > summary { cursor:pointer; list-style:none; font-size:.6rem; font-weight:700; }
.account-manage > summary::-webkit-details-marker { display:none; }
.account-manage[open] > summary { color:var(--green); }
.account-manage > form { position:absolute; z-index:5; right:0; top:calc(100% + 6px); display:grid; gap:8px; width:210px; padding:12px; border:1px solid var(--line); border-radius:10px; background:var(--white); box-shadow:var(--shadow); }
.account-manage .account-password-form { grid-template-columns:1fr; }
.account-manage label { display:grid; gap:4px; color:var(--muted); font-size:.6rem; font-weight:700; }
.account-manage input { width:100%; box-sizing:border-box; padding:7px 8px; font:inherit; font-size:.68rem; }
.account-manage .account-delete-form { top:calc(100% + 180px); }
.account-manage .delete-account { color:var(--red); }
.current-account { text-align: right; } .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 { display: flex; gap: 4px; padding: 4px; border: 1px solid var(--line); border-radius: 10px; background: #fff; }
@@ -380,7 +391,7 @@ blockquote { margin: 10px 0; padding-left: 12px; border-left: 2px solid #d8dfdb;
.card-tags { display: flex; flex-wrap: wrap; gap: 4px; } .card-tags { display: flex; flex-wrap: wrap; gap: 4px; }
.card-topline { display: flex; justify-content: space-between; gap: 8px; align-items: start; min-height: 21px; margin-bottom: 7px; } .card-topline { display: flex; justify-content: space-between; gap: 8px; align-items: start; min-height: 21px; margin-bottom: 7px; }
.card-signals { display: flex; flex-wrap: wrap; gap: 5px; justify-content: flex-end; } .card-signals { display: flex; flex-wrap: wrap; gap: 5px; justify-content: flex-end; }
.task-due { padding: 3px 5px; border-radius: 6px; background: var(--sand); color: #786039; font-size: .53rem; font-weight: 700; } .task-due { display: inline-flex; min-height: 21px; box-sizing: border-box; align-items: center; justify-content: center; padding: 3px 7px; border-radius: 6px; background: var(--sand); color: #786039; font-size: .53rem; font-weight: 700; line-height: 1; text-align: center; }
.todo-progress { padding: 3px 5px; border-radius: 6px; background: var(--mint); color: var(--green-2); font-size: .53rem; font-weight: 800; } .todo-progress { padding: 3px 5px; border-radius: 6px; background: var(--mint); color: var(--green-2); font-size: .53rem; font-weight: 800; }
.importance-emoji { display: grid; width: 21px; height: 21px; place-items: center; border-radius: 6px; background: var(--paper); font-size: .66rem; line-height: 1; } .importance-emoji { display: grid; width: 21px; height: 21px; place-items: center; border-radius: 6px; background: var(--paper); font-size: .66rem; line-height: 1; }
.importance-emoji i { font-size: .68rem; } .importance-emoji i { font-size: .68rem; }
+177 -30
View File
@@ -32,18 +32,18 @@ type User struct {
} }
type Workspace struct { type Workspace struct {
ID int64 ID int64
Name string Name string
Slug string Slug string
CreatedAt string CreatedAt string
} }
type WorkspaceMember struct { type WorkspaceMember struct {
WorkspaceID int64 WorkspaceID int64
UserID int64 UserID int64
Role string Role string
Workspace Workspace Workspace Workspace
User User User User
} }
type Attendance struct { type Attendance struct {
@@ -317,7 +317,9 @@ CREATE INDEX IF NOT EXISTS idx_board_task_tags_tag ON board_task_tags(tag_id,tas
if err := s.ensureColumn("board_tasks", "importance", "importance TEXT NOT NULL DEFAULT 'normal' CHECK(importance IN ('low','normal','high','urgent'))"); err != nil { if err := s.ensureColumn("board_tasks", "importance", "importance TEXT NOT NULL DEFAULT 'normal' CHECK(importance IN ('low','normal','high','urgent'))"); err != nil {
return err return err
} }
if err := s.ensureColumn("board_tasks", "workspace_id", "workspace_id INTEGER NOT NULL DEFAULT 1"); err != nil { return err } if err := s.ensureColumn("board_tasks", "workspace_id", "workspace_id INTEGER NOT NULL DEFAULT 1"); err != nil {
return err
}
if err := s.ensureBoardTaskTodoStatus(); err != nil { if err := s.ensureBoardTaskTodoStatus(); err != nil {
return err return err
} }
@@ -343,14 +345,22 @@ CREATE INDEX IF NOT EXISTS idx_board_task_tags_tag ON board_task_tags(tag_id,tas
} }
_, err = s.db.Exec(`INSERT INTO users(username,password_hash,display_name,email,role) VALUES(?,?,?,?,?)`, _, err = s.db.Exec(`INSERT INTO users(username,password_hash,display_name,email,role) VALUES(?,?,?,?,?)`,
"admin", hash, "Workspace Admin", "admin@localhost", "admin") "admin", hash, "Workspace Admin", "admin@localhost", "admin")
if err != nil { return err } if err != nil {
return err
}
} }
var workspaceCount int var workspaceCount int
if err := s.db.QueryRow(`SELECT COUNT(*) FROM workspaces`).Scan(&workspaceCount); err != nil { return err } if err := s.db.QueryRow(`SELECT COUNT(*) FROM workspaces`).Scan(&workspaceCount); err != nil {
if workspaceCount == 0 { return err
if _, err := s.db.Exec(`INSERT INTO workspaces(name,slug) VALUES(?,?)`, "Hamkar workspace", "hamkar"); err != nil { return err } }
if workspaceCount == 0 {
if _, err := s.db.Exec(`INSERT INTO workspaces(name,slug) VALUES(?,?)`, "Hamkar workspace", "hamkar"); err != nil {
return err
}
}
if _, err := s.db.Exec(`INSERT OR IGNORE INTO workspace_members(workspace_id,user_id,role) SELECT w.id,u.id,u.role FROM workspaces w CROSS JOIN users u`); err != nil {
return err
} }
if _, err := s.db.Exec(`INSERT OR IGNORE INTO workspace_members(workspace_id,user_id,role) SELECT w.id,u.id,u.role FROM workspaces w CROSS JOIN users u`); err != nil { return err }
return nil return nil
} }
@@ -568,45 +578,89 @@ func (s *Store) Users() ([]User, error) {
func (s *Store) Workspaces(userID int64) ([]Workspace, error) { func (s *Store) Workspaces(userID int64) ([]Workspace, error) {
rows, err := s.db.Query(`SELECT w.id,w.name,w.slug,w.created_at FROM workspaces w JOIN workspace_members m ON m.workspace_id=w.id WHERE m.user_id=? ORDER BY w.name`, userID) rows, err := s.db.Query(`SELECT w.id,w.name,w.slug,w.created_at FROM workspaces w JOIN workspace_members m ON m.workspace_id=w.id WHERE m.user_id=? ORDER BY w.name`, userID)
if err != nil { return nil, err } if err != nil {
return nil, err
}
defer rows.Close() defer rows.Close()
var out []Workspace var out []Workspace
for rows.Next() { var w Workspace; if err := rows.Scan(&w.ID,&w.Name,&w.Slug,&w.CreatedAt); err != nil { return nil, err }; out = append(out,w) } for rows.Next() {
var w Workspace
if err := rows.Scan(&w.ID, &w.Name, &w.Slug, &w.CreatedAt); err != nil {
return nil, err
}
out = append(out, w)
}
return out, rows.Err() return out, rows.Err()
} }
func (s *Store) AllWorkspaces() ([]Workspace, error) { func (s *Store) AllWorkspaces() ([]Workspace, error) {
rows, err := s.db.Query(`SELECT id,name,slug,created_at FROM workspaces ORDER BY name`); if err != nil { return nil,err }; defer rows.Close() rows, err := s.db.Query(`SELECT id,name,slug,created_at FROM workspaces ORDER BY name`)
var out []Workspace; for rows.Next() { var w Workspace; if err:=rows.Scan(&w.ID,&w.Name,&w.Slug,&w.CreatedAt); err!=nil{return nil,err}; out=append(out,w) }; return out,rows.Err() if err != nil {
return nil, err
}
defer rows.Close()
var out []Workspace
for rows.Next() {
var w Workspace
if err := rows.Scan(&w.ID, &w.Name, &w.Slug, &w.CreatedAt); err != nil {
return nil, err
}
out = append(out, w)
}
return out, rows.Err()
} }
func (s *Store) CreateWorkspace(name, slug string, ownerID int64) (int64, error) { func (s *Store) CreateWorkspace(name, slug string, ownerID int64) (int64, error) {
result, err := s.db.Exec(`INSERT INTO workspaces(name,slug) VALUES(?,?)`, strings.TrimSpace(name), strings.TrimSpace(strings.ToLower(slug))) result, err := s.db.Exec(`INSERT INTO workspaces(name,slug) VALUES(?,?)`, strings.TrimSpace(name), strings.TrimSpace(strings.ToLower(slug)))
if err != nil { return 0, err } if err != nil {
id, err := result.LastInsertId(); if err != nil { return 0, err } return 0, err
}
id, err := result.LastInsertId()
if err != nil {
return 0, err
}
_, err = s.db.Exec(`INSERT INTO workspace_members(workspace_id,user_id,role) VALUES(?,?, 'admin')`, id, ownerID) _, err = s.db.Exec(`INSERT INTO workspace_members(workspace_id,user_id,role) VALUES(?,?, 'admin')`, id, ownerID)
return id, err return id, err
} }
func (s *Store) WorkspaceMember(workspaceID, userID int64) (WorkspaceMember, error) { func (s *Store) WorkspaceMember(workspaceID, userID int64) (WorkspaceMember, error) {
var m WorkspaceMember var m WorkspaceMember
err := s.db.QueryRow(`SELECT w.id,w.name,w.slug,w.created_at,u.id,u.username,u.display_name,u.email,u.role,u.avatar_url,u.active,m.role FROM workspaces w JOIN workspace_members m ON m.workspace_id=w.id JOIN users u ON u.id=m.user_id WHERE w.id=? AND u.id=?`, workspaceID,userID).Scan(&m.Workspace.ID,&m.Workspace.Name,&m.Workspace.Slug,&m.Workspace.CreatedAt,&m.User.ID,&m.User.Username,&m.User.DisplayName,&m.User.Email,&m.User.Role,&m.User.AvatarURL,&m.User.Active,&m.Role) err := s.db.QueryRow(`SELECT w.id,w.name,w.slug,w.created_at,u.id,u.username,u.display_name,u.email,u.role,u.avatar_url,u.active,m.role FROM workspaces w JOIN workspace_members m ON m.workspace_id=w.id JOIN users u ON u.id=m.user_id WHERE w.id=? AND u.id=?`, workspaceID, userID).Scan(&m.Workspace.ID, &m.Workspace.Name, &m.Workspace.Slug, &m.Workspace.CreatedAt, &m.User.ID, &m.User.Username, &m.User.DisplayName, &m.User.Email, &m.User.Role, &m.User.AvatarURL, &m.User.Active, &m.Role)
return m, err return m, err
} }
func (s *Store) SetWorkspaceMembers(workspaceID int64, userIDs []int64) error { func (s *Store) SetWorkspaceMembers(workspaceID int64, userIDs []int64) error {
tx, err := s.db.Begin(); if err != nil { return err } tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback() defer tx.Rollback()
if _, err = tx.Exec(`DELETE FROM workspace_members WHERE workspace_id=?`, workspaceID); err != nil { return err } if _, err = tx.Exec(`DELETE FROM workspace_members WHERE workspace_id=?`, workspaceID); err != nil {
return err
}
for _, userID := range userIDs { for _, userID := range userIDs {
if _, err = tx.Exec(`INSERT INTO workspace_members(workspace_id,user_id,role) SELECT ?,id,CASE WHEN role='admin' THEN 'admin' ELSE 'member' END FROM users WHERE id=?`, workspaceID,userID); err != nil { return err } if _, err = tx.Exec(`INSERT INTO workspace_members(workspace_id,user_id,role) SELECT ?,id,CASE WHEN role='admin' THEN 'admin' ELSE 'member' END FROM users WHERE id=?`, workspaceID, userID); err != nil {
return err
}
} }
return tx.Commit() return tx.Commit()
} }
func (s *Store) WorkspaceMemberIDs(workspaceID int64) (map[int64]bool, error) { func (s *Store) WorkspaceMemberIDs(workspaceID int64) (map[int64]bool, error) {
rows, err := s.db.Query(`SELECT user_id FROM workspace_members WHERE workspace_id=?`, workspaceID); if err != nil { return nil, err }; defer rows.Close() rows, err := s.db.Query(`SELECT user_id FROM workspace_members WHERE workspace_id=?`, workspaceID)
out := map[int64]bool{}; for rows.Next() { var id int64; if err:=rows.Scan(&id); err != nil { return nil,err }; out[id]=true }; return out, rows.Err() if err != nil {
return nil, err
}
defer rows.Close()
out := map[int64]bool{}
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return nil, err
}
out[id] = true
}
return out, rows.Err()
} }
func (s *Store) ActiveUsers() ([]User, error) { func (s *Store) ActiveUsers() ([]User, error) {
@@ -615,8 +669,19 @@ func (s *Store) ActiveUsers() ([]User, error) {
func (s *Store) ActiveUsersInWorkspace(workspaceID int64) ([]User, error) { func (s *Store) ActiveUsersInWorkspace(workspaceID int64) ([]User, error) {
rows, err := s.db.Query(`SELECT u.id,u.username,u.display_name,COALESCE(u.email,''),u.role,u.avatar_url,u.active FROM users u JOIN workspace_members m ON m.user_id=u.id WHERE m.workspace_id=? AND u.active=1 ORDER BY u.display_name`, workspaceID) rows, err := s.db.Query(`SELECT u.id,u.username,u.display_name,COALESCE(u.email,''),u.role,u.avatar_url,u.active FROM users u JOIN workspace_members m ON m.user_id=u.id WHERE m.workspace_id=? AND u.active=1 ORDER BY u.display_name`, workspaceID)
if err != nil { return nil, err }; defer rows.Close() if err != nil {
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,&u.Active); err!=nil{return nil,err}; out=append(out,u) }; return out,rows.Err() return nil, err
}
defer rows.Close()
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, &u.Active); err != nil {
return nil, err
}
out = append(out, u)
}
return out, rows.Err()
} }
func (s *Store) users(activeOnly bool) ([]User, error) { func (s *Store) users(activeOnly bool) ([]User, error) {
@@ -796,6 +861,74 @@ func (s *Store) SetUserActive(userID, actorID int64, active bool) error {
return tx.Commit() return tx.Commit()
} }
func (s *Store) SetUserPassword(userID int64, password string) error {
if userID < 1 {
return errors.New("account was not found")
}
if len(password) < 8 {
return errors.New("password must be at least 8 characters")
}
hash, err := hashPassword(password)
if err != nil {
return err
}
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
result, err := tx.Exec(`UPDATE users SET password_hash=? WHERE id=?`, hash, userID)
if err != nil {
return err
}
if count, _ := result.RowsAffected(); count == 0 {
return errors.New("account was not found")
}
if _, err := tx.Exec(`DELETE FROM sessions WHERE user_id=?`, userID); err != nil {
return err
}
return tx.Commit()
}
func (s *Store) DeleteUser(userID, actorID int64) error {
if userID < 1 {
return errors.New("account was not found")
}
if userID == actorID {
return errors.New("you cannot delete your own account")
}
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
var role string
if err := tx.QueryRow(`SELECT role FROM users WHERE id=?`, userID).Scan(&role); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return errors.New("account was not found")
}
return err
}
if role == "admin" {
var admins int
if err := tx.QueryRow(`SELECT COUNT(*) FROM users WHERE role='admin'`).Scan(&admins); err != nil {
return err
}
if admins <= 1 {
return errors.New("the last administrator cannot be deleted")
}
}
// Keep historical request records when removing an administrator, while
// clearing the optional reviewer reference that has no cascade action.
if _, err := tx.Exec(`UPDATE requests SET reviewed_by=NULL WHERE reviewed_by=?`, userID); err != nil {
return err
}
if _, err := tx.Exec(`DELETE FROM users WHERE id=?`, userID); err != nil {
return err
}
return tx.Commit()
}
func (s *Store) SetUserAvatar(userID int64, avatarURL string) error { func (s *Store) SetUserAvatar(userID int64, avatarURL string) error {
_, err := s.db.Exec(`UPDATE users SET avatar_url=? WHERE id=?`, avatarURL, userID) _, err := s.db.Exec(`UPDATE users SET avatar_url=? WHERE id=?`, avatarURL, userID)
return err return err
@@ -1163,17 +1296,31 @@ func (s *Store) boardTasks(archived bool, workspaceID int64) ([]BoardTask, error
FROM board_tasks t FROM board_tasks t
JOIN users c ON c.id=t.creator_id` JOIN users c ON c.id=t.creator_id`
if archived { if archived {
query += ` WHERE t.archived_at IS NOT NULL` + func() string { if workspaceID > 0 { return ` AND t.workspace_id=?` }; return `` }() + ` query += ` WHERE t.archived_at IS NOT NULL` + func() string {
if workspaceID > 0 {
return ` AND t.workspace_id=?`
}
return ``
}() + `
ORDER BY t.archived_at DESC,t.id DESC` ORDER BY t.archived_at DESC,t.id DESC`
} else { } else {
query += ` WHERE t.archived_at IS NULL` + func() string { if workspaceID > 0 { return ` AND t.workspace_id=?` }; return `` }() + ` query += ` WHERE t.archived_at IS NULL` + func() string {
if workspaceID > 0 {
return ` AND t.workspace_id=?`
}
return ``
}() + `
ORDER BY CASE t.status ORDER BY CASE t.status
WHEN 'backlog' THEN 0 WHEN 'todo' THEN 1 WHEN 'in_progress' THEN 2 WHEN 'blocked' THEN 3 ELSE 4 END, WHEN 'backlog' THEN 0 WHEN 'todo' THEN 1 WHEN 'in_progress' THEN 2 WHEN 'blocked' THEN 3 ELSE 4 END,
t.created_at DESC` t.created_at DESC`
} }
var rows *sql.Rows var rows *sql.Rows
var err error var err error
if workspaceID > 0 { rows, err = s.db.Query(query, workspaceID) } else { rows, err = s.db.Query(query) } if workspaceID > 0 {
rows, err = s.db.Query(query, workspaceID)
} else {
rows, err = s.db.Query(query)
}
if err != nil { if err != nil {
return nil, err return nil, err
} }
+15
View File
@@ -36,6 +36,7 @@
{{if eq .ID $current.ID}} {{if eq .ID $current.ID}}
<small class="current-account">Current account</small> <small class="current-account">Current account</small>
{{else}} {{else}}
<div class="account-actions">
<form method="post" action="/admin/users/{{.ID}}/status" class="account-status-form"> <form method="post" action="/admin/users/{{.ID}}/status" class="account-status-form">
<input type="hidden" name="csrf" value="{{$.CSRF}}"> <input type="hidden" name="csrf" value="{{$.CSRF}}">
{{if .Active}} {{if .Active}}
@@ -46,6 +47,20 @@
<button class="text-button unlock-account" type="submit">Unlock</button> <button class="text-button unlock-account" type="submit">Unlock</button>
{{end}} {{end}}
</form> </form>
<details class="account-manage">
<summary class="text-button">Manage</summary>
<form method="post" action="/admin/users/{{.ID}}/password" class="account-password-form">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<label>New password<input type="password" name="password" minlength="8" required autocomplete="new-password"></label>
<label>Confirm password<input type="password" name="password_confirm" minlength="8" required autocomplete="new-password"></label>
<button class="text-button" type="submit">Change password</button>
</form>
<form method="post" action="/admin/users/{{.ID}}/delete" class="account-delete-form">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<button class="text-button delete-account" type="submit">Delete account</button>
</form>
</details>
</div>
{{end}} {{end}}
</div> </div>
{{end}} {{end}}