diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-01-20 11:34:33 -1000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-01-20 11:34:33 -1000 |
| commit | 08bbcf18b1207153983261652b4a43a9b36f386c (patch) | |
| tree | e6665608c7c8a87d6c789cf8b4c56d466df6bb8b | |
| parent | 07ba815e8517ee2d3a5fa531361bbd09bdfcbaa7 (diff) | |
Add session-based authentication
Implement secure authentication using scs session manager with SQLite
backing store and bcrypt password hashing.
- Add users and sessions tables (migration 004)
- Create internal/auth package with Service, Middleware, and Handlers
- Protect all routes except /login, /logout, /static/*
- Add login page template and logout button to dashboard
- Default credentials: admin/changeme (configurable via env vars)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
| -rw-r--r-- | AUDITOR_ROLE.md | 46 | ||||
| -rw-r--r-- | SESSION_STATE.md | 33 | ||||
| -rw-r--r-- | cmd/dashboard/main.go | 106 | ||||
| -rw-r--r-- | go.mod | 8 | ||||
| -rw-r--r-- | go.sum | 7 | ||||
| -rw-r--r-- | internal/auth/auth.go | 142 | ||||
| -rw-r--r-- | internal/auth/handlers.go | 111 | ||||
| -rw-r--r-- | internal/auth/middleware.go | 50 | ||||
| -rw-r--r-- | internal/store/sqlite.go | 5 | ||||
| -rw-r--r-- | migrations/004_add_auth.sql | 20 | ||||
| -rw-r--r-- | web/templates/index.html | 6 | ||||
| -rw-r--r-- | web/templates/login.html | 57 |
12 files changed, 546 insertions, 45 deletions
diff --git a/AUDITOR_ROLE.md b/AUDITOR_ROLE.md new file mode 100644 index 0000000..1210a9e --- /dev/null +++ b/AUDITOR_ROLE.md @@ -0,0 +1,46 @@ +# Senior Go Architect & Security Lead Persona + +**Role:** You are acting as a **Senior Go Architect and Security Lead**. +**Project Context:** I am building a unified personal dashboard using Go 1.21, SQLite (caching layer), chi router, and HTMX. + +**Shared Standards (CLAUDE.md):** +* **Efficiency:** Prioritize surgical edits over full-file rewrites. +* **Tools:** Use terminal commands (`go test`, `go build`, `grep`) to verify state before planning. +* **Architecture:** Handler -> Store (SQLite) -> API Clients. +* **State:** Maintain `SESSION_STATE.md` as the source of truth for handoffs. + +**Gemini Architect Persona:** +* You are the **Lead Architect**. +* **Constraint:** You **DO NOT** write or edit Project Source Code (e.g., `.go`, `.html`, `.js`). +* **Responsibility:** You **DO** write and update documentation and instruction files (e.g., `SESSION_STATE.md`, `instructions.md`, `issues/*.md`). Your job is to prepare surgical plans for the implementation agent (Claude Code) to execute. +* **Constraint:** If the user rejects a proposed change, do NOT try again - IMMEDIATELY stop and ask for clarification from the user. +* **Known issue:** You cannot access the project's `cmd/dashboard/main.go` entrypoint for an unknown reason. However, the implementation agent CAN. You may give it generic directions (like "remove XXXX dependency from main.go") instead of precise instructions, for this file ONLY. + +**Workflow Instructions:** + +1. **Analyze:** + * When pointed to a task or file, use tools (`read_file`, `grep`, `ls`) to understand the current state. + * Identify specific lines needing fixes based on `SECURITY_CHECKLIST.md` or the current feature requirement. + +2. **Bug Handling Protocol:** + * **Create Issue:** When a bug is identified, create a file in `issues/` (e.g., `issues/bug_00X_description.md`). + * **Document:** Describe the bug, root cause, and a plan to fix it. + * **Reproduction:** ALWAYS include instructions for a reproduction test case (preferably an automated `_test.go` file) in the issue document. + * **State:** Update `SESSION_STATE.md` to track the issue. + +3. **Document:** + * Update `SESSION_STATE.md` with the "Next Steps" and current context. + +4. **Draft Instructions:** + * **DO NOT** output the prompt in the chat. + * **WRITE** the "Surgical Prompt" to a file named `instructions.md`. + * The prompt in `instructions.md` must be concise, include specific file paths, and define the exact logic changes needed for the implementation agent. + * **TDD:** For bugs, instructions must follow a Test-Driven Development approach: Write Test -> Verify Fail -> Fix Code -> Verify Pass. + +**Tool Usage Protocol:** +* **Execution:** When you state you are creating or updating a file (e.g., `instructions.md`, `SESSION_STATE.md`), you **MUST** execute the `write_file` tool. Do not just describe the content; write it to the disk. + +**Self-Improvement:** +* **Meta-Review:** Periodically (e.g., after completing a major phase or encountering friction), suggest refinements to this Role Definition (`ARCHITECT_ROLE.md`) to better align with the user's needs and project workflow. + +**Why we do this:** We are managing token usage and rate limits. By using you to plan and the implementation agent to execute, we ensure work is structured, documented, and smooth. diff --git a/SESSION_STATE.md b/SESSION_STATE.md index cff3103..8a2920b 100644 --- a/SESSION_STATE.md +++ b/SESSION_STATE.md @@ -1,19 +1,22 @@ # Session State -**Current Phase:** Phase 3: Write Operations & UI Integration -**Current Step:** Step 6: Trello Tasks Heuristic & Tasks Tab +## Active Task +None - Authentication implementation complete. -**Recent Completed Steps:** -* Phase 3 Step 1: Trello Write Ops (Backend) -* Phase 3 Step 2: Trello Lists Support -* Phase 3 Step 3: Trello UI (Boards & Add Card) -* Phase 3 Step 4: Todoist Write Ops -* Phase 3 Step 5: Fix Tasks Tab (Identified as needing Heuristic) +## Recent Changes +* **Task 002:** Implemented session-based authentication. + * Added `scs` session manager with SQLite store. + * Added `bcrypt` password hashing via `golang.org/x/crypto`. + * Created `internal/auth` package with `Service`, `Middleware`, and `Handlers`. + * Created migration `004_add_auth.sql` (users + sessions tables). + * Created `login.html` template. + * Added logout button to dashboard header. + * Protected all routes except `/login`, `/logout`, and `/static/*`. + * Default user: `admin` / `changeme` (configurable via `DEFAULT_USER`/`DEFAULT_PASS` env vars). +* **Task 001:** Removed Obsidian functionality. -**Current Focus:** -Implementing heuristics to extract actionable tasks from Trello boards and displaying them in a unified list on the Tasks tab. - -**Next Steps:** -1. Implement Heuristics in Handlers. -2. Create Trello Tasks Partial. -3. Update Tasks Tab Template. +## Next Steps +1. **Commit** the authentication changes. +2. **Test manually** by running the server and logging in. +3. **Optional:** Add auth tests to `internal/auth`. +4. **Optional:** Add password change functionality. diff --git a/cmd/dashboard/main.go b/cmd/dashboard/main.go index 6e013e8..14664fc 100644 --- a/cmd/dashboard/main.go +++ b/cmd/dashboard/main.go @@ -2,18 +2,23 @@ package main import ( "context" + "html/template" "log" "net/http" "os" "os/signal" + "path/filepath" "syscall" "time" + "github.com/alexedwards/scs/sqlite3store" + "github.com/alexedwards/scs/v2" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "github.com/joho/godotenv" "task-dashboard/internal/api" + "task-dashboard/internal/auth" "task-dashboard/internal/config" "task-dashboard/internal/handlers" "task-dashboard/internal/store" @@ -36,6 +41,38 @@ func main() { } defer db.Close() + // Initialize session manager + sessionManager := scs.New() + sessionManager.Store = sqlite3store.New(db.DB()) + sessionManager.Lifetime = 24 * time.Hour + sessionManager.Cookie.Secure = false // Set to true in production with HTTPS + sessionManager.Cookie.SameSite = http.SameSiteLaxMode + + // Initialize auth service + authService := auth.NewService(db.DB()) + + // Ensure default admin user exists (use env vars for credentials) + defaultUser := os.Getenv("DEFAULT_USER") + defaultPass := os.Getenv("DEFAULT_PASS") + if defaultUser == "" { + defaultUser = "admin" + } + if defaultPass == "" { + defaultPass = "changeme" + } + if err := authService.EnsureDefaultUser(defaultUser, defaultPass); err != nil { + log.Printf("Warning: failed to ensure default user: %v", err) + } + + // Parse templates for auth handlers + authTemplates, err := template.ParseGlob(filepath.Join(cfg.TemplateDir, "*.html")) + if err != nil { + log.Printf("Warning: failed to parse auth templates: %v", err) + } + + // Initialize auth handlers + authHandlers := auth.NewHandlers(authService, sessionManager, authTemplates) + // Initialize API clients todoistClient := api.NewTodoistClient(cfg.TodoistAPIKey) trelloClient := api.NewTrelloClient(cfg.TrelloAPIKey, cfg.TrelloToken) @@ -52,43 +89,54 @@ func main() { // Set up router r := chi.NewRouter() - // Middleware + // Global middleware r.Use(middleware.Logger) r.Use(middleware.Recoverer) r.Use(middleware.Timeout(60 * time.Second)) + r.Use(sessionManager.LoadAndSave) // Session middleware must be applied globally - // Routes - r.Get("/", h.HandleDashboard) - r.Post("/api/refresh", h.HandleRefresh) - r.Get("/api/tasks", h.HandleGetTasks) - r.Get("/api/meals", h.HandleGetMeals) - r.Get("/api/boards", h.HandleGetBoards) - - // Tab routes for HTMX (using new TabsHandler) - r.Get("/tabs/tasks", tabsHandler.HandleTasks) - r.Get("/tabs/planning", tabsHandler.HandlePlanning) - r.Get("/tabs/meals", tabsHandler.HandleMeals) - r.Post("/tabs/refresh", h.HandleRefreshTab) - - // Trello card operations - r.Post("/cards", h.HandleCreateCard) - r.Post("/cards/complete", h.HandleCompleteCard) - - // Todoist task operations - r.Post("/tasks", h.HandleCreateTask) - r.Post("/tasks/complete", h.HandleCompleteTask) + // Public routes (no auth required) + r.Get("/login", authHandlers.HandleLoginPage) + r.Post("/login", authHandlers.HandleLogin) + r.Post("/logout", authHandlers.HandleLogout) - // Unified task completion (for Tasks tab Atoms) - r.Post("/complete-atom", h.HandleCompleteAtom) - - // Unified Quick Add (for Tasks tab) - r.Post("/unified-add", h.HandleUnifiedAdd) - r.Get("/partials/lists", h.HandleGetListsOptions) - - // Serve static files + // Serve static files (public) fileServer := http.FileServer(http.Dir("web/static")) r.Handle("/static/*", http.StripPrefix("/static/", fileServer)) + // Protected routes (auth required) + r.Group(func(r chi.Router) { + r.Use(authHandlers.Middleware().RequireAuth) + + // Dashboard + r.Get("/", h.HandleDashboard) + r.Post("/api/refresh", h.HandleRefresh) + r.Get("/api/tasks", h.HandleGetTasks) + r.Get("/api/meals", h.HandleGetMeals) + r.Get("/api/boards", h.HandleGetBoards) + + // Tab routes for HTMX + r.Get("/tabs/tasks", tabsHandler.HandleTasks) + r.Get("/tabs/planning", tabsHandler.HandlePlanning) + r.Get("/tabs/meals", tabsHandler.HandleMeals) + r.Post("/tabs/refresh", h.HandleRefreshTab) + + // Trello card operations + r.Post("/cards", h.HandleCreateCard) + r.Post("/cards/complete", h.HandleCompleteCard) + + // Todoist task operations + r.Post("/tasks", h.HandleCreateTask) + r.Post("/tasks/complete", h.HandleCompleteTask) + + // Unified task completion (for Tasks tab Atoms) + r.Post("/complete-atom", h.HandleCompleteAtom) + + // Unified Quick Add (for Tasks tab) + r.Post("/unified-add", h.HandleUnifiedAdd) + r.Get("/partials/lists", h.HandleGetListsOptions) + }) + // Start server addr := ":" + cfg.Port srv := &http.Server{ @@ -1,6 +1,6 @@ module task-dashboard -go 1.21 +go 1.24.0 require ( github.com/go-chi/chi/v5 v5.2.3 @@ -8,3 +8,9 @@ require ( ) require github.com/joho/godotenv v1.5.1 + +require ( + github.com/alexedwards/scs/sqlite3store v0.0.0-20251002162104-209de6e426de // indirect + github.com/alexedwards/scs/v2 v2.9.0 // indirect + golang.org/x/crypto v0.47.0 // indirect +) @@ -1,6 +1,13 @@ +github.com/alexedwards/scs/sqlite3store v0.0.0-20251002162104-209de6e426de h1:c72K9HLu6K442et0j3BUL/9HEYaUJouLkkVANdmqTOo= +github.com/alexedwards/scs/sqlite3store v0.0.0-20251002162104-209de6e426de/go.mod h1:Iyk7S76cxGaiEX/mSYmTZzYehp4KfyylcLaV3OnToss= +github.com/alexedwards/scs/v2 v2.9.0 h1:xa05mVpwTBm1iLeTMNFfAWpKUm4fXAW7CeAViqBVS90= +github.com/alexedwards/scs/v2 v2.9.0/go.mod h1:ToaROZxyKukJKT/xLcVQAChi5k6+Pn1Gvmdl7h3RRj8= github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE= github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0= github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= +golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= diff --git a/internal/auth/auth.go b/internal/auth/auth.go new file mode 100644 index 0000000..a602dad --- /dev/null +++ b/internal/auth/auth.go @@ -0,0 +1,142 @@ +package auth + +import ( + "database/sql" + "errors" + "time" + + "golang.org/x/crypto/bcrypt" +) + +var ( + ErrInvalidCredentials = errors.New("invalid username or password") + ErrUserNotFound = errors.New("user not found") + ErrUserExists = errors.New("username already exists") +) + +// User represents an authenticated user +type User struct { + ID int64 + Username string + PasswordHash string + CreatedAt time.Time +} + +// Service handles authentication operations +type Service struct { + db *sql.DB +} + +// NewService creates a new auth service +func NewService(db *sql.DB) *Service { + return &Service{db: db} +} + +// Authenticate verifies username and password, returns user ID if valid +func (s *Service) Authenticate(username, password string) (*User, error) { + user, err := s.GetUserByUsername(username) + if err != nil { + if errors.Is(err, ErrUserNotFound) { + return nil, ErrInvalidCredentials + } + return nil, err + } + + if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil { + return nil, ErrInvalidCredentials + } + + return user, nil +} + +// GetUserByUsername retrieves a user by username +func (s *Service) GetUserByUsername(username string) (*User, error) { + var user User + err := s.db.QueryRow( + `SELECT id, username, password_hash, created_at FROM users WHERE username = ?`, + username, + ).Scan(&user.ID, &user.Username, &user.PasswordHash, &user.CreatedAt) + + if err == sql.ErrNoRows { + return nil, ErrUserNotFound + } + if err != nil { + return nil, err + } + + return &user, nil +} + +// GetUserByID retrieves a user by ID +func (s *Service) GetUserByID(id int64) (*User, error) { + var user User + err := s.db.QueryRow( + `SELECT id, username, password_hash, created_at FROM users WHERE id = ?`, + id, + ).Scan(&user.ID, &user.Username, &user.PasswordHash, &user.CreatedAt) + + if err == sql.ErrNoRows { + return nil, ErrUserNotFound + } + if err != nil { + return nil, err + } + + return &user, nil +} + +// CreateUser creates a new user with the given username and password +func (s *Service) CreateUser(username, password string) (*User, error) { + // Check if user exists + _, err := s.GetUserByUsername(username) + if err == nil { + return nil, ErrUserExists + } + if !errors.Is(err, ErrUserNotFound) { + return nil, err + } + + // Hash password + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return nil, err + } + + // Insert user + result, err := s.db.Exec( + `INSERT INTO users (username, password_hash) VALUES (?, ?)`, + username, string(hash), + ) + if err != nil { + return nil, err + } + + id, err := result.LastInsertId() + if err != nil { + return nil, err + } + + return s.GetUserByID(id) +} + +// UserCount returns the number of users in the database +func (s *Service) UserCount() (int, error) { + var count int + err := s.db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&count) + return count, err +} + +// EnsureDefaultUser creates a default admin user if no users exist +func (s *Service) EnsureDefaultUser(username, password string) error { + count, err := s.UserCount() + if err != nil { + return err + } + + if count == 0 { + _, err = s.CreateUser(username, password) + return err + } + + return nil +} diff --git a/internal/auth/handlers.go b/internal/auth/handlers.go new file mode 100644 index 0000000..17bcabd --- /dev/null +++ b/internal/auth/handlers.go @@ -0,0 +1,111 @@ +package auth + +import ( + "html/template" + "log" + "net/http" + + "github.com/alexedwards/scs/v2" +) + +// Handlers provides HTTP handlers for authentication +type Handlers struct { + service *Service + sessions *scs.SessionManager + middleware *Middleware + templates *template.Template +} + +// NewHandlers creates new auth handlers +func NewHandlers(service *Service, sessions *scs.SessionManager, templates *template.Template) *Handlers { + return &Handlers{ + service: service, + sessions: sessions, + middleware: NewMiddleware(sessions), + templates: templates, + } +} + +// Middleware returns the auth middleware for use in routes +func (h *Handlers) Middleware() *Middleware { + return h.middleware +} + +// HandleLoginPage renders the login form +func (h *Handlers) HandleLoginPage(w http.ResponseWriter, r *http.Request) { + // If already logged in, redirect to dashboard + if h.middleware.IsAuthenticated(r) { + http.Redirect(w, r, "/", http.StatusSeeOther) + return + } + + data := struct { + Error string + }{ + Error: "", + } + + if err := h.templates.ExecuteTemplate(w, "login.html", data); err != nil { + http.Error(w, "Failed to render template", http.StatusInternalServerError) + log.Printf("Error rendering login template: %v", err) + } +} + +// HandleLogin processes login form submission +func (h *Handlers) HandleLogin(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "Failed to parse form", http.StatusBadRequest) + return + } + + username := r.FormValue("username") + password := r.FormValue("password") + + if username == "" || password == "" { + h.renderLoginError(w, "Username and password are required") + return + } + + user, err := h.service.Authenticate(username, password) + if err != nil { + log.Printf("Login failed for user %s: %v", username, err) + h.renderLoginError(w, "Invalid username or password") + return + } + + // Regenerate session token to prevent session fixation + if err := h.sessions.RenewToken(r.Context()); err != nil { + http.Error(w, "Failed to create session", http.StatusInternalServerError) + log.Printf("Failed to renew session token: %v", err) + return + } + + // Set user ID in session + h.middleware.SetUserID(r, user.ID) + + log.Printf("User %s logged in successfully", username) + http.Redirect(w, r, "/", http.StatusSeeOther) +} + +// HandleLogout processes logout +func (h *Handlers) HandleLogout(w http.ResponseWriter, r *http.Request) { + if err := h.middleware.ClearSession(r); err != nil { + log.Printf("Error clearing session: %v", err) + } + + http.Redirect(w, r, "/login", http.StatusSeeOther) +} + +func (h *Handlers) renderLoginError(w http.ResponseWriter, errorMsg string) { + data := struct { + Error string + }{ + Error: errorMsg, + } + + w.WriteHeader(http.StatusUnauthorized) + if err := h.templates.ExecuteTemplate(w, "login.html", data); err != nil { + http.Error(w, "Failed to render template", http.StatusInternalServerError) + log.Printf("Error rendering login template: %v", err) + } +} diff --git a/internal/auth/middleware.go b/internal/auth/middleware.go new file mode 100644 index 0000000..7710328 --- /dev/null +++ b/internal/auth/middleware.go @@ -0,0 +1,50 @@ +package auth + +import ( + "net/http" + + "github.com/alexedwards/scs/v2" +) + +const SessionKeyUserID = "user_id" + +// Middleware provides authentication middleware +type Middleware struct { + sessions *scs.SessionManager +} + +// NewMiddleware creates a new auth middleware +func NewMiddleware(sessions *scs.SessionManager) *Middleware { + return &Middleware{sessions: sessions} +} + +// RequireAuth redirects to login if not authenticated +func (m *Middleware) RequireAuth(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !m.IsAuthenticated(r) { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + next.ServeHTTP(w, r) + }) +} + +// IsAuthenticated checks if the current request has a valid session +func (m *Middleware) IsAuthenticated(r *http.Request) bool { + return m.sessions.Exists(r.Context(), SessionKeyUserID) +} + +// GetUserID returns the authenticated user's ID from the session +func (m *Middleware) GetUserID(r *http.Request) int64 { + return m.sessions.GetInt64(r.Context(), SessionKeyUserID) +} + +// SetUserID sets the user ID in the session (called after successful login) +func (m *Middleware) SetUserID(r *http.Request, userID int64) { + m.sessions.Put(r.Context(), SessionKeyUserID, userID) +} + +// ClearSession removes the user ID from the session (called on logout) +func (m *Middleware) ClearSession(r *http.Request) error { + return m.sessions.Destroy(r.Context()) +} diff --git a/internal/store/sqlite.go b/internal/store/sqlite.go index dac3321..7961f35 100644 --- a/internal/store/sqlite.go +++ b/internal/store/sqlite.go @@ -59,6 +59,11 @@ func (s *Store) Close() error { return s.db.Close() } +// DB returns the underlying database connection +func (s *Store) DB() *sql.DB { + return s.db +} + // runMigrations executes all migration files in order func (s *Store) runMigrations() error { // Get migration files diff --git a/migrations/004_add_auth.sql b/migrations/004_add_auth.sql new file mode 100644 index 0000000..065b8e3 --- /dev/null +++ b/migrations/004_add_auth.sql @@ -0,0 +1,20 @@ +-- Authentication tables + +-- Users table +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_users_username ON users(username); + +-- Sessions table (required by scs sqlite3store) +CREATE TABLE IF NOT EXISTS sessions ( + token TEXT PRIMARY KEY, + data BLOB NOT NULL, + expiry REAL NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_sessions_expiry ON sessions(expiry); diff --git a/web/templates/index.html b/web/templates/index.html index b341c17..54bb0c6 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -19,6 +19,12 @@ class="bg-primary-600 hover:bg-primary-700 text-white px-4 py-2 rounded-lg transition-colors font-medium no-print"> <span id="refresh-text">Refresh</span> </button> + <form method="POST" action="/logout" class="no-print"> + <button type="submit" + class="text-gray-600 hover:text-gray-900 px-3 py-2 rounded-lg transition-colors font-medium"> + Logout + </button> + </form> </div> </header> diff --git a/web/templates/login.html b/web/templates/login.html new file mode 100644 index 0000000..e5ce9e4 --- /dev/null +++ b/web/templates/login.html @@ -0,0 +1,57 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>Login - Personal Dashboard</title> + <link rel="stylesheet" href="/static/css/output.css"> +</head> +<body class="min-h-screen flex items-center justify-center bg-gray-50"> + <div class="w-full max-w-md p-8"> + <div class="bg-white rounded-xl shadow-lg p-8"> + <h1 class="text-2xl font-bold text-gray-900 text-center mb-8">Personal Dashboard</h1> + + {{if .Error}} + <div class="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm"> + {{.Error}} + </div> + {{end}} + + <form method="POST" action="/login" class="space-y-6"> + <div> + <label for="username" class="block text-sm font-medium text-gray-700 mb-2"> + Username + </label> + <input + type="text" + id="username" + name="username" + required + autofocus + class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 transition-colors" + placeholder="Enter your username"> + </div> + + <div> + <label for="password" class="block text-sm font-medium text-gray-700 mb-2"> + Password + </label> + <input + type="password" + id="password" + name="password" + required + class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 transition-colors" + placeholder="Enter your password"> + </div> + + <button + type="submit" + class="w-full bg-primary-600 hover:bg-primary-700 text-white font-medium py-3 px-4 rounded-lg transition-colors"> + Sign In + </button> + </form> + </div> + </div> +</body> +</html> |
