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 {
Title string
User *User
CSRF string
Flash string
Error string
Today string
TodayJalali string
Attendance *Attendance
Calendar Calendar
Requests []Request
PendingCount int
Stats Stats
OAuthGitHub bool
OAuthGoogle bool
ReportStart string
ReportEnd string
SelectedSection string
Users []User
Workspaces []Workspace
Workspace Workspace
Title string
User *User
CSRF string
Flash string
Error string
Today string
TodayJalali string
Attendance *Attendance
Calendar Calendar
Requests []Request
PendingCount int
Stats Stats
OAuthGitHub bool
OAuthGoogle bool
ReportStart string
ReportEnd string
SelectedSection string
Users []User
Workspaces []Workspace
Workspace Workspace
WorkspaceMembers map[int64]bool
Day DayDetail
Week WeekDetail
WorkUpdates []WorkUpdate
ReportSummary []PersonReportSummary
ReportTotals ReportTotals
Board []BoardColumn
BoardTags []BoardTag
ArchivedTasks []BoardTask
BoardFilter BoardFilter
Day DayDetail
Week WeekDetail
WorkUpdates []WorkUpdate
ReportSummary []PersonReportSummary
ReportTotals ReportTotals
Board []BoardColumn
BoardTags []BoardTag
ArchivedTasks []BoardTask
BoardFilter BoardFilter
}
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("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}/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("POST /admin/workspaces", s.requireAdmin(s.csrf(s.createWorkspace)))
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 workspaces, err := s.store.Workspaces(data.User.ID); err == nil {
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 && len(workspaces) > 0 { data.Workspace = workspaces[0] }
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 && len(workspaces) > 0 {
data.Workspace = workspaces[0]
}
}
}
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 {
// Official public holidays for Solar Hijri 1405. Lunar holidays are
// recorded using the dates published for this Persian calendar year.
if year != 1405 { return "" }
if year != 1405 {
return ""
}
holidays := map[string]string{
"1-1":"Nowruz", "1-2":"Nowruz", "1-3":"Nowruz", "1-4":"Nowruz",
"1-12":"Islamic Republic Day", "1-13":"Nature Day",
"2-6":"Eid al-Adha", "2-14":"Eid al-Ghadir",
"3-14":"Demise of Imam Khomeini", "3-15":"Khordad Uprising",
"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",
"6-8":"Prophet Muhammad's Birthday",
"9-3":"Martyrdom of Fatima",
"10-2":"Imam Ali's Birthday",
"11-22":"Revolution Day", "12-29":"Oil Nationalization Day",
"1-1": "Nowruz", "1-2": "Nowruz", "1-3": "Nowruz", "1-4": "Nowruz",
"1-12": "Islamic Republic Day", "1-13": "Nature Day",
"2-6": "Eid al-Adha", "2-14": "Eid al-Ghadir",
"3-14": "Demise of Imam Khomeini", "3-15": "Khordad Uprising",
"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",
"6-8": "Prophet Muhammad's Birthday",
"9-3": "Martyrdom of Fatima",
"10-2": "Imam Ali's Birthday",
"11-22": "Revolution Day", "12-29": "Oil Nationalization 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) {
tasks, err := s.store.ArchivedBoardTasksInWorkspace(requestWorkspaceID(r)); 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"})
tasks, err := s.store.ArchivedBoardTasksInWorkspace(requestWorkspaceID(r))
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 {
@@ -903,7 +920,10 @@ func (s *Server) createBoardTask(w http.ResponseWriter, r *http.Request) {
workspaceID := requestWorkspaceID(r)
if raw := r.FormValue("workspace_id"); raw != "" {
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
}
}
@@ -1095,61 +1115,148 @@ func (s *Server) usersPage(w http.ResponseWriter, r *http.Request) {
func (s *Server) profilePage(w http.ResponseWriter, r *http.Request) {
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) {
if err := r.ParseMultipartForm(3 << 20); err != nil { http.Redirect(w,r,"/profile?error=Choose+an+image+up+to+2MB",http.StatusSeeOther); return }
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()
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)
if err := r.ParseMultipartForm(3 << 20); err != nil {
http.Redirect(w, r, "/profile?error=Choose+an+image+up+to+2MB", http.StatusSeeOther)
return
}
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()
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) {
u := currentUser(r)
workspaces, err := s.store.AllWorkspaces()
if err != nil { http.Error(w, "could not load workspaces", http.StatusInternalServerError); return }
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) }
if err != nil {
http.Error(w, "could not load workspaces", http.StatusInternalServerError)
return
}
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)
selectedWorkspace := Workspace{ID:selected}; 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})
selectedWorkspace := Workspace{ID: selected}
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) {
id,_ := strconv.ParseInt(r.PathValue("id"),10,64); 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 } }
id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
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.
adminID := currentUser(r).ID
foundAdmin := false
for _, id := range ids { if id == adminID { foundAdmin = true; 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)
for _, id := range ids {
if id == adminID {
foundAdmin = true
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) {
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 }
http.Redirect(w,r,"/admin/workspaces?flash=Workspace+created",http.StatusSeeOther)
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
}
http.Redirect(w, r, "/admin/workspaces?flash=Workspace+created", http.StatusSeeOther)
}
func (s *Server) switchWorkspace(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.FormValue("workspace_id"),10,64)
if err != nil { http.Error(w,"invalid workspace",http.StatusBadRequest); return }
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 } }
id, err := strconv.ParseInt(r.FormValue("workspace_id"), 10, 64)
if err != nil {
http.Error(w, "invalid workspace", http.StatusBadRequest)
return
}
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) {
@@ -1180,6 +1287,29 @@ func (s *Server) setUserStatus(w http.ResponseWriter, r *http.Request) {
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) {
now := time.Now()
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 {
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
}
@@ -1330,7 +1462,9 @@ func (s *Server) withUser(next http.Handler) http.Handler {
if u, csrf, err := s.store.Session(c.Value); err == nil {
u.WorkspaceID = requestWorkspaceID(r)
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 = context.WithValue(ctx, userKey, u)