diff --git a/cmd/tracks/main.go b/cmd/tracks/main.go index 34815036..9d7d3d53 100644 --- a/cmd/tracks/main.go +++ b/cmd/tracks/main.go @@ -1,8 +1,10 @@ package main import ( + "embed" "flag" "fmt" + "html/template" "log" "github.com/TracksApp/tracks/internal/config" @@ -13,6 +15,9 @@ import ( "github.com/gin-gonic/gin" ) +//go:embed web/templates/*.html +var templateFS embed.FS + func main() { // Parse command line flags port := flag.Int("port", 0, "Port to run the server on (overrides SERVER_PORT env var)") @@ -71,6 +76,9 @@ func main() { } func setupRoutes(router *gin.Engine, cfg *config.Config) { + // Parse embedded templates + tmpl := template.Must(template.ParseFS(templateFS, "web/templates/*.html")) + // Initialize services authService := services.NewAuthService(cfg.Auth.JWTSecret) todoService := services.NewTodoService() @@ -82,6 +90,28 @@ func setupRoutes(router *gin.Engine, cfg *config.Config) { todoHandler := handlers.NewTodoHandler(todoService) projectHandler := handlers.NewProjectHandler(projectService) contextHandler := handlers.NewContextHandler(contextService) + webHandler := handlers.NewWebHandler(authService, tmpl) + + // Web UI routes (public) + router.GET("/login", webHandler.ShowLogin) + router.POST("/login", webHandler.HandleLogin) + router.GET("/logout", webHandler.HandleLogout) + + // Web UI routes (protected) + webProtected := router.Group("") + webProtected.Use(middleware.AuthMiddleware(cfg.Auth.JWTSecret)) + { + webProtected.GET("/", webHandler.ShowDashboard) + webProtected.GET("/dashboard", webHandler.ShowDashboard) + + // Admin web routes + webAdmin := webProtected.Group("/admin") + webAdmin.Use(middleware.AdminMiddleware()) + { + webAdmin.GET("/users", webHandler.ShowAdminUsers) + webAdmin.POST("/users", webHandler.HandleCreateUser) + } + } // Public routes api := router.Group("/api") diff --git a/cmd/tracks/web/templates/admin_users.html b/cmd/tracks/web/templates/admin_users.html new file mode 100644 index 00000000..d53f8b78 --- /dev/null +++ b/cmd/tracks/web/templates/admin_users.html @@ -0,0 +1,218 @@ +{{define "content"}} + + + + +
+ + + + + + + + + + + + + {{range .Users}} + + + + + + + + + {{end}} + +
IDUsernameNameRoleCreatedActions
{{.ID}}{{.Login}}{{.FirstName}} {{.LastName}} + {{if .IsAdmin}} + ADMIN + {{else}} + User + {{end}} + {{.CreatedAt.Format "2006-01-02"}} + +
+
+ + + + + +{{end}} diff --git a/cmd/tracks/web/templates/base.html b/cmd/tracks/web/templates/base.html new file mode 100644 index 00000000..18a6608c --- /dev/null +++ b/cmd/tracks/web/templates/base.html @@ -0,0 +1,289 @@ + + + + + + {{.Title}} - Tracks + + + + {{if .User}} +
+
+

📝 Tracks GTD

+ +
+
+ {{end}} + +
+ {{if .Error}} +
{{.Error}}
+ {{end}} + {{if .Success}} +
{{.Success}}
+ {{end}} + + {{template "content" .}} +
+ + + + diff --git a/cmd/tracks/web/templates/dashboard.html b/cmd/tracks/web/templates/dashboard.html new file mode 100644 index 00000000..fde5bfcd --- /dev/null +++ b/cmd/tracks/web/templates/dashboard.html @@ -0,0 +1,187 @@ +{{define "content"}} + + +
+

Welcome back, {{.User.FirstName}}! 👋

+

Here's your productivity overview

+
+ +
+
+
{{.Stats.ActiveTodos}}
+
Active Todos
+
+
+
{{.Stats.ActiveProjects}}
+
Active Projects
+
+
+
{{.Stats.ActiveContexts}}
+
Active Contexts
+
+
+
{{.Stats.CompletedToday}}
+
Completed Today
+
+
+ +
+ ➕ New Todo + 📁 New Project + 🏷️ New Context + {{if .User.IsAdmin}} + 👥 Manage Users + {{end}} +
+ +
+

Recent Todos

+ {{if .RecentTodos}} + {{range .RecentTodos}} +
+
+ {{.Description}} +
+ {{if .Context}}{{.Context.Name}}{{end}} + {{if .Project}} • {{.Project.Name}}{{end}} +
+
+ {{.State}} +
+ {{end}} + {{else}} +
+
📭
+

No todos yet. Create your first one to get started!

+
+ {{end}} +
+{{end}} diff --git a/cmd/tracks/web/templates/login.html b/cmd/tracks/web/templates/login.html new file mode 100644 index 00000000..294d4baa --- /dev/null +++ b/cmd/tracks/web/templates/login.html @@ -0,0 +1,83 @@ +{{define "content"}} + + + + +
+
+ + + {{if .FirstTime}} +
+ First time? Default login is:
+ Username: admin
+ Password: admin +
+ {{end}} + +
+
+ + +
+ +
+ + +
+ + +
+
+
+{{end}} diff --git a/internal/handlers/web_handler.go b/internal/handlers/web_handler.go new file mode 100644 index 00000000..ee2daed3 --- /dev/null +++ b/internal/handlers/web_handler.go @@ -0,0 +1,185 @@ +package handlers + +import ( + "html/template" + "net/http" + "time" + + "github.com/TracksApp/tracks/internal/database" + "github.com/TracksApp/tracks/internal/middleware" + "github.com/TracksApp/tracks/internal/models" + "github.com/TracksApp/tracks/internal/services" + "github.com/gin-gonic/gin" +) + +// WebHandler handles web UI routes +type WebHandler struct { + authService *services.AuthService + templates *template.Template +} + +// NewWebHandler creates a new WebHandler +func NewWebHandler(authService *services.AuthService, templates *template.Template) *WebHandler { + return &WebHandler{ + authService: authService, + templates: templates, + } +} + +// ShowLogin displays the login page +func (h *WebHandler) ShowLogin(c *gin.Context) { + // Check if this is first time (no users except admin) + var count int64 + database.DB.Model(&models.User{}).Count(&count) + + data := gin.H{ + "Title": "Login", + "FirstTime": count <= 1, + } + + c.Header("Content-Type", "text/html; charset=utf-8") + // Execute base template with content template + err := h.templates.Lookup("base.html").Execute(c.Writer, data) + if err != nil { + c.String(500, "Template error: "+err.Error()) + return + } +} + +// HandleLogin processes login form submission +func (h *WebHandler) HandleLogin(c *gin.Context) { + login := c.PostForm("login") + password := c.PostForm("password") + + // Authenticate user + resp, err := h.authService.Login(services.LoginRequest{ + Login: login, + Password: password, + }) + + if err != nil { + var count int64 + database.DB.Model(&models.User{}).Count(&count) + + data := gin.H{ + "Title": "Login", + "Error": "Invalid username or password", + "FirstTime": count <= 1, + } + c.Header("Content-Type", "text/html; charset=utf-8") + h.templates.ExecuteTemplate(c.Writer, "base.html", data) + return + } + + // Set session cookie + c.SetCookie("tracks_token", resp.Token, 60*60*24*7, "/", "", false, true) + + // Redirect to dashboard + c.Redirect(http.StatusFound, "/") +} + +// HandleLogout logs out the user +func (h *WebHandler) HandleLogout(c *gin.Context) { + // Clear session cookie + c.SetCookie("tracks_token", "", -1, "/", "", false, true) + c.Redirect(http.StatusFound, "/login") +} + +// ShowDashboard displays the dashboard +func (h *WebHandler) ShowDashboard(c *gin.Context) { + user, _ := middleware.GetCurrentUser(c) + + // Get statistics + var stats struct { + ActiveTodos int64 + ActiveProjects int64 + ActiveContexts int64 + CompletedToday int64 + } + + database.DB.Model(&models.Todo{}).Where("user_id = ? AND state = ?", user.ID, "active").Count(&stats.ActiveTodos) + database.DB.Model(&models.Project{}).Where("user_id = ? AND state = ?", user.ID, "active").Count(&stats.ActiveProjects) + database.DB.Model(&models.Context{}).Where("user_id = ? AND state = ?", user.ID, "active").Count(&stats.ActiveContexts) + + today := time.Now().Truncate(24 * time.Hour) + database.DB.Model(&models.Todo{}). + Where("user_id = ? AND state = ? AND completed_at >= ?", user.ID, "completed", today). + Count(&stats.CompletedToday) + + // Get recent todos + var recentTodos []models.Todo + database.DB. + Preload("Context"). + Preload("Project"). + Where("user_id = ?", user.ID). + Order("created_at DESC"). + Limit(10). + Find(&recentTodos) + + data := gin.H{ + "Title": "Dashboard", + "User": user, + "Stats": stats, + "RecentTodos": recentTodos, + } + + c.Header("Content-Type", "text/html; charset=utf-8") + h.templates.ExecuteTemplate(c.Writer, "base.html", data) +} + +// ShowAdminUsers displays the user management page +func (h *WebHandler) ShowAdminUsers(c *gin.Context) { + user, _ := middleware.GetCurrentUser(c) + + // Get all users + var users []models.User + database.DB.Order("id ASC").Find(&users) + + data := gin.H{ + "Title": "User Management", + "User": user, + "Users": users, + } + + c.Header("Content-Type", "text/html; charset=utf-8") + h.templates.ExecuteTemplate(c.Writer, "base.html", data) +} + +// HandleCreateUser processes user creation form +func (h *WebHandler) HandleCreateUser(c *gin.Context) { + user, _ := middleware.GetCurrentUser(c) + + login := c.PostForm("login") + password := c.PostForm("password") + firstName := c.PostForm("first_name") + lastName := c.PostForm("last_name") + isAdmin := c.PostForm("is_admin") == "true" + + // Create user + _, err := h.authService.CreateUser(services.CreateUserRequest{ + Login: login, + Password: password, + FirstName: firstName, + LastName: lastName, + IsAdmin: isAdmin, + }) + + if err != nil { + // Get all users for re-rendering + var users []models.User + database.DB.Order("id ASC").Find(&users) + + data := gin.H{ + "Title": "User Management", + "User": user, + "Users": users, + "Error": err.Error(), + } + c.Header("Content-Type", "text/html; charset=utf-8") + h.templates.ExecuteTemplate(c.Writer, "base.html", data) + return + } + + // Redirect back to users page with success message + c.Redirect(http.StatusFound, "/admin/users?success=User created successfully") +}