From 06fa677ec625e11c4bda25d0a7f94fd94b2032ca Mon Sep 17 00:00:00 2001 From: nfel Date: Sun, 2 Aug 2026 23:17:42 +0330 Subject: [PATCH] Add workspace management foundation --- internal/app/server.go | 33 ++++++++++++++ internal/app/static/style.css | 3 ++ internal/app/store.go | 59 +++++++++++++++++++++++++- internal/app/templates/base.html | 9 ++++ internal/app/templates/workspaces.html | 18 ++++++++ 5 files changed, 121 insertions(+), 1 deletion(-) create mode 100644 internal/app/templates/workspaces.html diff --git a/internal/app/server.go b/internal/app/server.go index 91d707d..77daa14 100644 --- a/internal/app/server.go +++ b/internal/app/server.go @@ -89,6 +89,8 @@ type PageData struct { ReportEnd string SelectedSection string Users []User + Workspaces []Workspace + Workspace Workspace Day DayDetail Week WeekDetail WorkUpdates []WorkUpdate @@ -303,6 +305,9 @@ 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("GET /admin/workspaces", s.requireAdmin(s.workspacesPage)) + mux.HandleFunc("POST /admin/workspaces", s.requireAdmin(s.csrf(s.createWorkspace))) + mux.HandleFunc("POST /workspace/switch", s.requireAuth(s.csrf(s.switchWorkspace))) 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)) @@ -481,6 +486,12 @@ func (s *Server) health(w http.ResponseWriter, r *http.Request) { } 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 && len(workspaces) > 0 { data.Workspace = workspaces[0] } + } + } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := s.templates.ExecuteTemplate(w, name, data); err != nil { log.Printf("render %s: %v", name, err) @@ -1053,6 +1064,28 @@ func (s *Server) usersPage(w http.ResponseWriter, r *http.Request) { }) } +func (s *Server) workspacesPage(w http.ResponseWriter, r *http.Request) { + u := currentUser(r) + workspaces, err := s.store.Workspaces(u.ID) + if err != nil { http.Error(w, "could not load workspaces", http.StatusInternalServerError); return } + s.render(w, "workspaces.html", PageData{Title:"Workspaces", User:u, CSRF:csrfToken(r), Workspaces:workspaces, Error:r.URL.Query().Get("error"), Flash:r.URL.Query().Get("flash"), SelectedSection:"workspaces"}) +} + +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) +} + +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}) + returnTo := r.FormValue("return_to"); if returnTo == "" || !strings.HasPrefix(returnTo,"/") { returnTo = "/" } + http.Redirect(w,r,returnTo,http.StatusSeeOther) +} + func (s *Server) createUser(w http.ResponseWriter, r *http.Request) { _, err := s.store.CreateUser(r.FormValue("username"), r.FormValue("password"), r.FormValue("display_name"), r.FormValue("email"), r.FormValue("role")) if err != nil { diff --git a/internal/app/static/style.css b/internal/app/static/style.css index 9ca260f..792421f 100644 --- a/internal/app/static/style.css +++ b/internal/app/static/style.css @@ -47,6 +47,9 @@ h2 { margin-bottom: 8px; font-size: 1.25rem; letter-spacing: -.025em; } .brand small { display: block; margin-top: 1px; color: var(--muted); font-size: .62rem; font-weight: 500; letter-spacing: .02em; } .brand-mark { display: grid; width: 35px; height: 35px; place-items: center; border-radius: 11px; background: var(--green); color: #fff; font-family: Vazirmatn, Tahoma, sans-serif; font-size: 1.25rem; } .sidebar nav { margin-top: 48px; } +.workspace-switcher { margin: 0 0 12px; } +.workspace-switcher label { display:block; color:var(--muted); font-size:.72rem; font-weight:700; text-transform:uppercase; letter-spacing:.08em; } +.workspace-switcher select { width:100%; margin-top:5px; border:1px solid var(--line); border-radius:10px; background:var(--surface); color:var(--ink); padding:8px 9px; font:inherit; } .sidebar nav a { display: flex; align-items: center; gap: 11px; min-height: 43px; margin: 4px 0; padding: 0 12px; border-radius: 10px; color: #53605a; font-size: .87rem; font-weight: 600; } .sidebar nav a:hover { background: #eef2ef; color: var(--ink); } .sidebar nav a.active { background: var(--mint); color: var(--green); } diff --git a/internal/app/store.go b/internal/app/store.go index 27109b3..9e50110 100644 --- a/internal/app/store.go +++ b/internal/app/store.go @@ -30,6 +30,21 @@ type User struct { Active bool } +type Workspace struct { + ID int64 + Name string + Slug string + CreatedAt string +} + +type WorkspaceMember struct { + WorkspaceID int64 + UserID int64 + Role string + Workspace Workspace + User User +} + type Attendance struct { ID int64 UserID int64 @@ -187,6 +202,19 @@ CREATE TABLE IF NOT EXISTS users ( active INTEGER NOT NULL DEFAULT 1 CHECK(active IN (0,1)), created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); +CREATE TABLE IF NOT EXISTS workspaces ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + slug TEXT NOT NULL UNIQUE COLLATE NOCASE, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE TABLE IF NOT EXISTS workspace_members ( + workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role TEXT NOT NULL DEFAULT 'member' CHECK(role IN ('member','admin')), + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY(workspace_id,user_id) +); CREATE TABLE IF NOT EXISTS oauth_accounts ( provider TEXT NOT NULL, provider_user_id TEXT NOT NULL, @@ -312,8 +340,14 @@ 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(?,?,?,?,?)`, "admin", hash, "Workspace Admin", "admin@localhost", "admin") - return err + if err != nil { return err } } + var workspaceCount int + if err := s.db.QueryRow(`SELECT COUNT(*) FROM workspaces`).Scan(&workspaceCount); 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 } return nil } @@ -529,6 +563,29 @@ func (s *Store) Users() ([]User, error) { return s.users(false) } +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) + 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) { + 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 } + 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) + return id, err +} + +func (s *Store) WorkspaceMember(workspaceID, userID int64) (WorkspaceMember, error) { + 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) + return m, err +} + func (s *Store) ActiveUsers() ([]User, error) { return s.users(true) } diff --git a/internal/app/templates/base.html b/internal/app/templates/base.html index ec9a950..e30a8d4 100644 --- a/internal/app/templates/base.html +++ b/internal/app/templates/base.html @@ -31,6 +31,12 @@ HamkarTeam presence