Add workspace management foundation
Test and publish / verify (push) Has been cancelled

This commit is contained in:
2026-08-02 23:17:42 +03:30
parent b0f486f026
commit 06fa677ec6
5 changed files with 121 additions and 1 deletions
+33
View File
@@ -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 {
+3
View File
@@ -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); }
+58 -1
View File
@@ -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)
}
+9
View File
@@ -31,6 +31,12 @@
<span>Hamkar<small>Team presence</small></span>
</a>
<nav>
{{if .Workspace.ID}}
<form method="post" action="/workspace/switch" class="workspace-switcher">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<label>Workspace<select name="workspace_id" onchange="this.form.submit()">{{range .Workspaces}}<option value="{{.ID}}" {{if eq .ID $.Workspace.ID}}selected{{end}}>{{.Name}}</option>{{end}}</select></label>
</form>
{{end}}
<a href="/" class="{{if eq .SelectedSection "dashboard"}}active{{end}}">
<span class="nav-icon"></span> Overview
</a>
@@ -55,6 +61,9 @@
<a href="/admin/users" class="{{if eq .SelectedSection "users"}}active{{end}}">
<span class="nav-icon"></span> Teammates
</a>
<a href="/admin/workspaces" class="{{if eq .SelectedSection "workspaces"}}active{{end}}">
<span class="nav-icon"></span> Workspaces
</a>
<a href="/reports" class="{{if eq .SelectedSection "reports"}}active{{end}}">
<span class="nav-icon"></span> Reports
</a>
+18
View File
@@ -0,0 +1,18 @@
{{define "workspaces.html"}}
{{template "shell-start" .}}
<header class="page-head"><div><p class="eyebrow">ADMIN</p><h1>Workspaces</h1><p>Create separate spaces for teams and switch between them.</p></div></header>
{{template "notice" .}}
<div class="two-column users-layout">
<section class="card form-card"><p class="eyebrow">NEW WORKSPACE</p><h2>Create a workspace</h2>
<form method="post" action="/admin/workspaces" class="stack-form"><input type="hidden" name="csrf" value="{{.CSRF}}">
<label>Workspace name<input name="name" required maxlength="100" placeholder="Product team"></label>
<label>Workspace slug<input name="slug" required maxlength="60" pattern="[a-z0-9-]+" placeholder="product-team"><small>Lowercase letters, numbers, and hyphens.</small></label>
<button class="button primary full">Create workspace</button>
</form>
</section>
<section class="card"><header class="section-head"><div><p class="eyebrow">YOUR SPACES</p><h2>{{len .Workspaces}} workspaces</h2></div></header>
<div class="user-list">{{range .Workspaces}}<div class="user-row"><span class="avatar"></span><div><strong>{{.Name}}</strong><small>{{.Slug}}</small></div>{{if eq .ID $.Workspace.ID}}<span class="badge approved">Current</span>{{else}}<form method="post" action="/workspace/switch"><input type="hidden" name="csrf" value="{{$.CSRF}}"><input type="hidden" name="workspace_id" value="{{.ID}}"><button class="text-button" type="submit">Switch</button></form>{{end}}</div>{{end}}</div>
</section>
</div>
{{template "shell-end" .}}
{{end}}