This commit is contained in:
@@ -89,6 +89,8 @@ type PageData struct {
|
|||||||
ReportEnd string
|
ReportEnd string
|
||||||
SelectedSection string
|
SelectedSection string
|
||||||
Users []User
|
Users []User
|
||||||
|
Workspaces []Workspace
|
||||||
|
Workspace Workspace
|
||||||
Day DayDetail
|
Day DayDetail
|
||||||
Week WeekDetail
|
Week WeekDetail
|
||||||
WorkUpdates []WorkUpdate
|
WorkUpdates []WorkUpdate
|
||||||
@@ -303,6 +305,9 @@ 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("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", s.requireAdmin(s.reportPage))
|
||||||
mux.HandleFunc("GET /reports/attendance.csv", s.requireAdmin(s.reportCSV))
|
mux.HandleFunc("GET /reports/attendance.csv", s.requireAdmin(s.reportCSV))
|
||||||
mux.HandleFunc("GET /reports/work-updates.csv", s.requireAdmin(s.workUpdatesCSV))
|
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) {
|
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")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
if err := s.templates.ExecuteTemplate(w, name, data); err != nil {
|
if err := s.templates.ExecuteTemplate(w, name, data); err != nil {
|
||||||
log.Printf("render %s: %v", name, err)
|
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) {
|
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"))
|
_, err := s.store.CreateUser(r.FormValue("username"), r.FormValue("password"), r.FormValue("display_name"), r.FormValue("email"), r.FormValue("role"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -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 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; }
|
.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; }
|
.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 { 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:hover { background: #eef2ef; color: var(--ink); }
|
||||||
.sidebar nav a.active { background: var(--mint); color: var(--green); }
|
.sidebar nav a.active { background: var(--mint); color: var(--green); }
|
||||||
|
|||||||
+58
-1
@@ -30,6 +30,21 @@ type User struct {
|
|||||||
Active bool
|
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 {
|
type Attendance struct {
|
||||||
ID int64
|
ID int64
|
||||||
UserID int64
|
UserID int64
|
||||||
@@ -187,6 +202,19 @@ CREATE TABLE IF NOT EXISTS users (
|
|||||||
active INTEGER NOT NULL DEFAULT 1 CHECK(active IN (0,1)),
|
active INTEGER NOT NULL DEFAULT 1 CHECK(active IN (0,1)),
|
||||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
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 (
|
CREATE TABLE IF NOT EXISTS oauth_accounts (
|
||||||
provider TEXT NOT NULL,
|
provider TEXT NOT NULL,
|
||||||
provider_user_id 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(?,?,?,?,?)`,
|
_, 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")
|
||||||
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -529,6 +563,29 @@ func (s *Store) Users() ([]User, error) {
|
|||||||
return s.users(false)
|
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) {
|
func (s *Store) ActiveUsers() ([]User, error) {
|
||||||
return s.users(true)
|
return s.users(true)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,12 @@
|
|||||||
<span>Hamkar<small>Team presence</small></span>
|
<span>Hamkar<small>Team presence</small></span>
|
||||||
</a>
|
</a>
|
||||||
<nav>
|
<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}}">
|
<a href="/" class="{{if eq .SelectedSection "dashboard"}}active{{end}}">
|
||||||
<span class="nav-icon">⌂</span> Overview
|
<span class="nav-icon">⌂</span> Overview
|
||||||
</a>
|
</a>
|
||||||
@@ -55,6 +61,9 @@
|
|||||||
<a href="/admin/users" class="{{if eq .SelectedSection "users"}}active{{end}}">
|
<a href="/admin/users" class="{{if eq .SelectedSection "users"}}active{{end}}">
|
||||||
<span class="nav-icon">◎</span> Teammates
|
<span class="nav-icon">◎</span> Teammates
|
||||||
</a>
|
</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}}">
|
<a href="/reports" class="{{if eq .SelectedSection "reports"}}active{{end}}">
|
||||||
<span class="nav-icon">↧</span> Reports
|
<span class="nav-icon">↧</span> Reports
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -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}}
|
||||||
Reference in New Issue
Block a user