18 changed files with 1028 additions and 10 deletions
@ -0,0 +1,122 @@ |
|||
package auth |
|||
|
|||
import ( |
|||
"errors" |
|||
"html/template" |
|||
"net/http" |
|||
"strconv" |
|||
"time" |
|||
|
|||
webtemplates "github.com/domagojzecevic/cammonitor/internal/web/templates" |
|||
"github.com/go-chi/chi/v5" |
|||
) |
|||
|
|||
type Handler struct { |
|||
store *Store |
|||
sessionTTL time.Duration |
|||
} |
|||
|
|||
func NewHandler(store *Store, sessionTTL time.Duration) *Handler { |
|||
return &Handler{store: store, sessionTTL: sessionTTL} |
|||
} |
|||
|
|||
func (h *Handler) LoginPage(w http.ResponseWriter, _ *http.Request) { |
|||
w.Header().Set("Content-Type", "text/html; charset=utf-8") |
|||
_ = loginTemplate.ExecuteTemplate(w, webtemplates.Login, nil) |
|||
} |
|||
|
|||
func (h *Handler) Login(w http.ResponseWriter, r *http.Request) { |
|||
if err := r.ParseForm(); err != nil { |
|||
http.Error(w, "invalid form", http.StatusBadRequest) |
|||
return |
|||
} |
|||
|
|||
user, err := h.store.Authenticate(r.FormValue("username"), r.FormValue("password")) |
|||
if errors.Is(err, ErrInvalidCredentials) { |
|||
http.Error(w, "invalid credentials", http.StatusUnauthorized) |
|||
return |
|||
} |
|||
if err != nil { |
|||
http.Error(w, "login failed", http.StatusInternalServerError) |
|||
return |
|||
} |
|||
|
|||
token, err := h.store.CreateSession(user.ID, h.sessionTTL) |
|||
if err != nil { |
|||
http.Error(w, "create session failed", http.StatusInternalServerError) |
|||
return |
|||
} |
|||
|
|||
http.SetCookie(w, &http.Cookie{ |
|||
Name: sessionCookieName, |
|||
Value: token, |
|||
Path: "/", |
|||
Expires: time.Now().UTC().Add(h.sessionTTL), |
|||
HttpOnly: true, |
|||
SameSite: http.SameSiteLaxMode, |
|||
}) |
|||
http.Redirect(w, r, "/", http.StatusSeeOther) |
|||
} |
|||
|
|||
func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) { |
|||
if cookie, err := r.Cookie(sessionCookieName); err == nil { |
|||
_ = h.store.DeleteSession(cookie.Value) |
|||
} |
|||
|
|||
http.SetCookie(w, &http.Cookie{ |
|||
Name: sessionCookieName, |
|||
Value: "", |
|||
Path: "/", |
|||
Expires: time.Unix(0, 0), |
|||
MaxAge: -1, |
|||
HttpOnly: true, |
|||
SameSite: http.SameSiteLaxMode, |
|||
}) |
|||
http.Redirect(w, r, "/login", http.StatusSeeOther) |
|||
} |
|||
|
|||
func (h *Handler) UsersPage(w http.ResponseWriter, _ *http.Request) { |
|||
users, err := h.store.ListUsers() |
|||
if err != nil { |
|||
http.Error(w, "list users failed", http.StatusInternalServerError) |
|||
return |
|||
} |
|||
|
|||
w.Header().Set("Content-Type", "text/html; charset=utf-8") |
|||
_ = adminUsersTemplate.ExecuteTemplate(w, webtemplates.AdminUsers, struct { |
|||
Users []User |
|||
}{Users: users}) |
|||
} |
|||
|
|||
func (h *Handler) CreateUser(w http.ResponseWriter, r *http.Request) { |
|||
if err := r.ParseForm(); err != nil { |
|||
http.Error(w, "invalid form", http.StatusBadRequest) |
|||
return |
|||
} |
|||
|
|||
if err := h.store.CreateUser(r.FormValue("username"), r.FormValue("password"), r.FormValue("is_admin") == "on"); err != nil { |
|||
http.Error(w, "create user failed", http.StatusBadRequest) |
|||
return |
|||
} |
|||
|
|||
http.Redirect(w, r, "/admin/users", http.StatusSeeOther) |
|||
} |
|||
|
|||
func (h *Handler) DeleteUser(w http.ResponseWriter, r *http.Request) { |
|||
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) |
|||
if err != nil { |
|||
http.Error(w, "invalid user id", http.StatusBadRequest) |
|||
return |
|||
} |
|||
|
|||
if err := h.store.DeleteUser(id); err != nil { |
|||
http.Error(w, "delete user failed", http.StatusInternalServerError) |
|||
return |
|||
} |
|||
|
|||
http.Redirect(w, r, "/admin/users", http.StatusSeeOther) |
|||
} |
|||
|
|||
var loginTemplate = template.Must(template.ParseFS(webtemplates.FS, webtemplates.Login)) |
|||
|
|||
var adminUsersTemplate = template.Must(template.ParseFS(webtemplates.FS, webtemplates.AdminUsers)) |
|||
@ -0,0 +1,192 @@ |
|||
package auth |
|||
|
|||
import ( |
|||
"bytes" |
|||
"html/template" |
|||
"net/http" |
|||
"net/http/httptest" |
|||
"net/url" |
|||
"os" |
|||
"path/filepath" |
|||
"strings" |
|||
"testing" |
|||
"time" |
|||
) |
|||
|
|||
func TestLoginPageRenders(t *testing.T) { |
|||
handler, _, _ := newTestHandler(t) |
|||
|
|||
request := httptest.NewRequest(http.MethodGet, "/login", nil) |
|||
response := httptest.NewRecorder() |
|||
|
|||
handler.LoginPage(response, request) |
|||
|
|||
if response.Code != http.StatusOK { |
|||
t.Fatalf("expected 200, got %d", response.Code) |
|||
} |
|||
if body := response.Body.String(); !strings.Contains(body, `<form`) || !strings.Contains(body, `name="username"`) { |
|||
t.Fatalf("login page missing expected form fields: %s", body) |
|||
} |
|||
|
|||
if got, want := strings.TrimSpace(response.Body.String()), strings.TrimSpace(readTemplateFile(t, "login.html")); got != want { |
|||
t.Fatalf("login page was not rendered from template file\nwant:\n%s\n\ngot:\n%s", want, got) |
|||
} |
|||
} |
|||
|
|||
func TestLoginValidCredentialsSetSessionCookieAndRedirect(t *testing.T) { |
|||
handler, store, _ := newTestHandler(t) |
|||
if err := store.CreateUser("alice", "secret", false); err != nil { |
|||
t.Fatalf("create user: %v", err) |
|||
} |
|||
|
|||
form := url.Values{"username": {"alice"}, "password": {"secret"}} |
|||
request := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode())) |
|||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
|||
response := httptest.NewRecorder() |
|||
|
|||
handler.Login(response, request) |
|||
|
|||
if response.Code != http.StatusSeeOther { |
|||
t.Fatalf("expected 303, got %d", response.Code) |
|||
} |
|||
if location := response.Header().Get("Location"); location != "/" { |
|||
t.Fatalf("expected redirect to /, got %q", location) |
|||
} |
|||
|
|||
cookie := findCookie(response.Result().Cookies(), sessionCookieName) |
|||
if cookie == nil || cookie.Value == "" { |
|||
t.Fatalf("expected session cookie, got %#v", response.Result().Cookies()) |
|||
} |
|||
if _, err := store.GetSession(cookie.Value); err != nil { |
|||
t.Fatalf("session cookie was not persisted: %v", err) |
|||
} |
|||
} |
|||
|
|||
func TestLoginInvalidCredentialsReturnsUnauthorized(t *testing.T) { |
|||
handler, store, _ := newTestHandler(t) |
|||
if err := store.CreateUser("alice", "secret", false); err != nil { |
|||
t.Fatalf("create user: %v", err) |
|||
} |
|||
|
|||
form := url.Values{"username": {"alice"}, "password": {"wrong"}} |
|||
request := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode())) |
|||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
|||
response := httptest.NewRecorder() |
|||
|
|||
handler.Login(response, request) |
|||
|
|||
if response.Code != http.StatusUnauthorized { |
|||
t.Fatalf("expected 401, got %d", response.Code) |
|||
} |
|||
} |
|||
|
|||
func TestAdminUsersRequiresAdmin(t *testing.T) { |
|||
handler, store, mux := newTestHandler(t) |
|||
if err := store.CreateUser("alice", "secret", false); err != nil { |
|||
t.Fatalf("create regular user: %v", err) |
|||
} |
|||
if err := store.CreateUser("admin", "secret", true); err != nil { |
|||
t.Fatalf("create admin user: %v", err) |
|||
} |
|||
|
|||
mux.Handle("/admin/users", RequireAuth(store)(RequireAdmin(http.HandlerFunc(handler.UsersPage)))) |
|||
|
|||
request := httptest.NewRequest(http.MethodGet, "/admin/users", nil) |
|||
response := httptest.NewRecorder() |
|||
mux.ServeHTTP(response, request) |
|||
if response.Code != http.StatusFound { |
|||
t.Fatalf("expected unauthenticated redirect, got %d", response.Code) |
|||
} |
|||
if location := response.Header().Get("Location"); location != "/login" { |
|||
t.Fatalf("expected redirect to /login, got %q", location) |
|||
} |
|||
|
|||
regularCookie := sessionCookie(t, store, "alice", "secret") |
|||
request = httptest.NewRequest(http.MethodGet, "/admin/users", nil) |
|||
request.AddCookie(regularCookie) |
|||
response = httptest.NewRecorder() |
|||
mux.ServeHTTP(response, request) |
|||
if response.Code != http.StatusForbidden { |
|||
t.Fatalf("expected regular user forbidden, got %d", response.Code) |
|||
} |
|||
|
|||
adminCookie := sessionCookie(t, store, "admin", "secret") |
|||
request = httptest.NewRequest(http.MethodGet, "/admin/users", nil) |
|||
request.AddCookie(adminCookie) |
|||
response = httptest.NewRecorder() |
|||
mux.ServeHTTP(response, request) |
|||
if response.Code != http.StatusOK { |
|||
t.Fatalf("expected admin page, got %d", response.Code) |
|||
} |
|||
if !strings.Contains(response.Body.String(), "admin") { |
|||
t.Fatalf("admin page missing user list: %s", response.Body.String()) |
|||
} |
|||
|
|||
users, err := store.ListUsers() |
|||
if err != nil { |
|||
t.Fatalf("list users: %v", err) |
|||
} |
|||
if got, want := strings.TrimSpace(response.Body.String()), strings.TrimSpace(renderTemplateFile(t, "admin_users.html", struct { |
|||
Users []User |
|||
}{Users: users})); got != want { |
|||
t.Fatalf("admin users page was not rendered from template file\nwant:\n%s\n\ngot:\n%s", want, got) |
|||
} |
|||
} |
|||
|
|||
func newTestHandler(t *testing.T) (*Handler, *Store, *http.ServeMux) { |
|||
t.Helper() |
|||
|
|||
database := openTestDB(t) |
|||
store := NewStore(database) |
|||
handler := NewHandler(store, time.Hour) |
|||
|
|||
return handler, store, http.NewServeMux() |
|||
} |
|||
|
|||
func sessionCookie(t *testing.T, store *Store, username, password string) *http.Cookie { |
|||
t.Helper() |
|||
|
|||
user, err := store.Authenticate(username, password) |
|||
if err != nil { |
|||
t.Fatalf("authenticate %s: %v", username, err) |
|||
} |
|||
token, err := store.CreateSession(user.ID, time.Hour) |
|||
if err != nil { |
|||
t.Fatalf("create session: %v", err) |
|||
} |
|||
return &http.Cookie{Name: sessionCookieName, Value: token} |
|||
} |
|||
|
|||
func findCookie(cookies []*http.Cookie, name string) *http.Cookie { |
|||
for _, cookie := range cookies { |
|||
if cookie.Name == name { |
|||
return cookie |
|||
} |
|||
} |
|||
return nil |
|||
} |
|||
|
|||
func readTemplateFile(t *testing.T, name string) string { |
|||
t.Helper() |
|||
|
|||
content, err := os.ReadFile(filepath.Join("..", "web", "templates", name)) |
|||
if err != nil { |
|||
t.Fatalf("read template %s: %v", name, err) |
|||
} |
|||
return string(content) |
|||
} |
|||
|
|||
func renderTemplateFile(t *testing.T, name string, data any) string { |
|||
t.Helper() |
|||
|
|||
tmpl, err := template.ParseFiles(filepath.Join("..", "web", "templates", name)) |
|||
if err != nil { |
|||
t.Fatalf("parse template %s: %v", name, err) |
|||
} |
|||
|
|||
var output bytes.Buffer |
|||
if err := tmpl.ExecuteTemplate(&output, name, data); err != nil { |
|||
t.Fatalf("execute template %s: %v", name, err) |
|||
} |
|||
return output.String() |
|||
} |
|||
@ -0,0 +1,50 @@ |
|||
package auth |
|||
|
|||
import ( |
|||
"context" |
|||
"net/http" |
|||
) |
|||
|
|||
const sessionCookieName = "session" |
|||
|
|||
type contextKey string |
|||
|
|||
const userContextKey contextKey = "user" |
|||
|
|||
func RequireAuth(store *Store) func(http.Handler) http.Handler { |
|||
return func(next http.Handler) http.Handler { |
|||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
|||
cookie, err := r.Cookie(sessionCookieName) |
|||
if err != nil || cookie.Value == "" { |
|||
http.Redirect(w, r, "/login", http.StatusFound) |
|||
return |
|||
} |
|||
|
|||
session, err := store.GetSession(cookie.Value) |
|||
if err != nil { |
|||
http.Redirect(w, r, "/login", http.StatusFound) |
|||
return |
|||
} |
|||
|
|||
ctx := context.WithValue(r.Context(), userContextKey, session.User) |
|||
next.ServeHTTP(w, r.WithContext(ctx)) |
|||
}) |
|||
} |
|||
} |
|||
|
|||
func RequireAdmin(next http.Handler) http.Handler { |
|||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
|||
user, ok := UserFromContext(r.Context()) |
|||
if !ok || !user.IsAdmin { |
|||
http.Error(w, "forbidden", http.StatusForbidden) |
|||
return |
|||
} |
|||
|
|||
next.ServeHTTP(w, r) |
|||
}) |
|||
} |
|||
|
|||
func UserFromContext(ctx context.Context) (*User, bool) { |
|||
user, ok := ctx.Value(userContextKey).(*User) |
|||
return user, ok |
|||
} |
|||
@ -0,0 +1,208 @@ |
|||
package auth |
|||
|
|||
import ( |
|||
"crypto/rand" |
|||
"database/sql" |
|||
"encoding/base64" |
|||
"errors" |
|||
"fmt" |
|||
"strings" |
|||
"time" |
|||
|
|||
"golang.org/x/crypto/bcrypt" |
|||
) |
|||
|
|||
var ErrInvalidCredentials = errors.New("invalid credentials") |
|||
var ErrSessionNotFound = errors.New("session not found") |
|||
|
|||
type Store struct { |
|||
db *sql.DB |
|||
} |
|||
|
|||
type User struct { |
|||
ID int64 |
|||
Username string |
|||
IsAdmin bool |
|||
} |
|||
|
|||
type Session struct { |
|||
Token string |
|||
UserID int64 |
|||
ExpiresAt time.Time |
|||
User *User |
|||
} |
|||
|
|||
func NewStore(database *sql.DB) *Store { |
|||
return &Store{db: database} |
|||
} |
|||
|
|||
func (s *Store) EnsureAdmin(username, password string) error { |
|||
username = strings.TrimSpace(username) |
|||
if username == "" || password == "" { |
|||
return fmt.Errorf("admin username and password are required") |
|||
} |
|||
|
|||
var count int |
|||
if err := s.db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&count); err != nil { |
|||
return fmt.Errorf("count users: %w", err) |
|||
} |
|||
if count > 0 { |
|||
return nil |
|||
} |
|||
|
|||
return s.CreateUser(username, password, true) |
|||
} |
|||
|
|||
func (s *Store) CreateUser(username, password string, isAdmin bool) error { |
|||
username = strings.TrimSpace(username) |
|||
if username == "" { |
|||
return fmt.Errorf("username is required") |
|||
} |
|||
if password == "" { |
|||
return fmt.Errorf("password is required") |
|||
} |
|||
|
|||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) |
|||
if err != nil { |
|||
return fmt.Errorf("hash password: %w", err) |
|||
} |
|||
|
|||
adminValue := 0 |
|||
if isAdmin { |
|||
adminValue = 1 |
|||
} |
|||
|
|||
if _, err := s.db.Exec( |
|||
`INSERT INTO users (username, password_hash, is_admin) VALUES (?, ?, ?)`, |
|||
username, |
|||
string(hash), |
|||
adminValue, |
|||
); err != nil { |
|||
return fmt.Errorf("create user: %w", err) |
|||
} |
|||
|
|||
return nil |
|||
} |
|||
|
|||
func (s *Store) DeleteUser(id int64) error { |
|||
if _, err := s.db.Exec(`DELETE FROM users WHERE id = ?`, id); err != nil { |
|||
return fmt.Errorf("delete user: %w", err) |
|||
} |
|||
return nil |
|||
} |
|||
|
|||
func (s *Store) ListUsers() ([]User, error) { |
|||
rows, err := s.db.Query(`SELECT id, username, is_admin FROM users ORDER BY username`) |
|||
if err != nil { |
|||
return nil, fmt.Errorf("list users: %w", err) |
|||
} |
|||
defer rows.Close() |
|||
|
|||
var users []User |
|||
for rows.Next() { |
|||
var user User |
|||
var isAdmin int |
|||
if err := rows.Scan(&user.ID, &user.Username, &isAdmin); err != nil { |
|||
return nil, fmt.Errorf("scan user: %w", err) |
|||
} |
|||
user.IsAdmin = isAdmin != 0 |
|||
users = append(users, user) |
|||
} |
|||
if err := rows.Err(); err != nil { |
|||
return nil, fmt.Errorf("iterate users: %w", err) |
|||
} |
|||
|
|||
return users, nil |
|||
} |
|||
|
|||
func (s *Store) Authenticate(username, password string) (*User, error) { |
|||
var user User |
|||
var hash string |
|||
var isAdmin int |
|||
err := s.db.QueryRow( |
|||
`SELECT id, username, password_hash, is_admin FROM users WHERE username = ?`, |
|||
strings.TrimSpace(username), |
|||
).Scan(&user.ID, &user.Username, &hash, &isAdmin) |
|||
if errors.Is(err, sql.ErrNoRows) { |
|||
return nil, ErrInvalidCredentials |
|||
} |
|||
if err != nil { |
|||
return nil, fmt.Errorf("load user: %w", err) |
|||
} |
|||
|
|||
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)); err != nil { |
|||
return nil, ErrInvalidCredentials |
|||
} |
|||
|
|||
user.IsAdmin = isAdmin != 0 |
|||
return &user, nil |
|||
} |
|||
|
|||
func (s *Store) CreateSession(userID int64, ttl time.Duration) (string, error) { |
|||
tokenBytes := make([]byte, 32) |
|||
if _, err := rand.Read(tokenBytes); err != nil { |
|||
return "", fmt.Errorf("generate session token: %w", err) |
|||
} |
|||
token := base64.RawURLEncoding.EncodeToString(tokenBytes) |
|||
expiresAt := time.Now().UTC().Add(ttl) |
|||
|
|||
if _, err := s.db.Exec( |
|||
`INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)`, |
|||
token, |
|||
userID, |
|||
expiresAt.Format(time.RFC3339Nano), |
|||
); err != nil { |
|||
return "", fmt.Errorf("create session: %w", err) |
|||
} |
|||
|
|||
return token, nil |
|||
} |
|||
|
|||
func (s *Store) GetSession(token string) (*Session, error) { |
|||
var session Session |
|||
var expiresAt string |
|||
var user User |
|||
var isAdmin int |
|||
err := s.db.QueryRow( |
|||
`SELECT s.token, s.user_id, s.expires_at, u.username, u.is_admin |
|||
FROM sessions s |
|||
JOIN users u ON u.id = s.user_id |
|||
WHERE s.token = ?`, |
|||
token, |
|||
).Scan(&session.Token, &session.UserID, &expiresAt, &user.Username, &isAdmin) |
|||
if errors.Is(err, sql.ErrNoRows) { |
|||
return nil, ErrSessionNotFound |
|||
} |
|||
if err != nil { |
|||
return nil, fmt.Errorf("load session: %w", err) |
|||
} |
|||
|
|||
parsedExpiresAt, err := time.Parse(time.RFC3339Nano, expiresAt) |
|||
if err != nil { |
|||
return nil, fmt.Errorf("parse session expiry: %w", err) |
|||
} |
|||
if !parsedExpiresAt.After(time.Now().UTC()) { |
|||
_ = s.DeleteSession(token) |
|||
return nil, ErrSessionNotFound |
|||
} |
|||
|
|||
user.ID = session.UserID |
|||
user.IsAdmin = isAdmin != 0 |
|||
session.ExpiresAt = parsedExpiresAt |
|||
session.User = &user |
|||
return &session, nil |
|||
} |
|||
|
|||
func (s *Store) DeleteSession(token string) error { |
|||
if _, err := s.db.Exec(`DELETE FROM sessions WHERE token = ?`, token); err != nil { |
|||
return fmt.Errorf("delete session: %w", err) |
|||
} |
|||
return nil |
|||
} |
|||
|
|||
func (s *Store) PurgeExpiredSessions() error { |
|||
if _, err := s.db.Exec(`DELETE FROM sessions WHERE expires_at <= ?`, time.Now().UTC().Format(time.RFC3339Nano)); err != nil { |
|||
return fmt.Errorf("purge expired sessions: %w", err) |
|||
} |
|||
return nil |
|||
} |
|||
@ -0,0 +1,101 @@ |
|||
package auth |
|||
|
|||
import ( |
|||
"database/sql" |
|||
"testing" |
|||
"time" |
|||
|
|||
"github.com/domagojzecevic/cammonitor/internal/db" |
|||
) |
|||
|
|||
func TestStoreAuthenticateHappyPathAndWrongPassword(t *testing.T) { |
|||
database := openTestDB(t) |
|||
store := NewStore(database) |
|||
|
|||
if err := store.CreateUser("alice", "secret", false); err != nil { |
|||
t.Fatalf("create user: %v", err) |
|||
} |
|||
|
|||
user, err := store.Authenticate("alice", "secret") |
|||
if err != nil { |
|||
t.Fatalf("authenticate valid user: %v", err) |
|||
} |
|||
if user.Username != "alice" { |
|||
t.Fatalf("expected alice, got %q", user.Username) |
|||
} |
|||
if user.IsAdmin { |
|||
t.Fatal("expected regular user") |
|||
} |
|||
|
|||
if _, err := store.Authenticate("alice", "wrong"); err == nil { |
|||
t.Fatal("expected wrong password to fail") |
|||
} |
|||
} |
|||
|
|||
func TestStoreExpiredSessionIsRejected(t *testing.T) { |
|||
database := openTestDB(t) |
|||
store := NewStore(database) |
|||
|
|||
if err := store.CreateUser("alice", "secret", false); err != nil { |
|||
t.Fatalf("create user: %v", err) |
|||
} |
|||
|
|||
user, err := store.Authenticate("alice", "secret") |
|||
if err != nil { |
|||
t.Fatalf("authenticate: %v", err) |
|||
} |
|||
|
|||
token, err := store.CreateSession(user.ID, -time.Minute) |
|||
if err != nil { |
|||
t.Fatalf("create session: %v", err) |
|||
} |
|||
|
|||
if _, err := store.GetSession(token); err == nil { |
|||
t.Fatal("expected expired session to fail") |
|||
} |
|||
} |
|||
|
|||
func TestEnsureAdminCreatesFirstRunAdminOnlyWhenEmpty(t *testing.T) { |
|||
database := openTestDB(t) |
|||
store := NewStore(database) |
|||
|
|||
if err := store.EnsureAdmin("admin", "secret"); err != nil { |
|||
t.Fatalf("ensure admin: %v", err) |
|||
} |
|||
|
|||
users, err := store.ListUsers() |
|||
if err != nil { |
|||
t.Fatalf("list users: %v", err) |
|||
} |
|||
if len(users) != 1 || users[0].Username != "admin" || !users[0].IsAdmin { |
|||
t.Fatalf("unexpected users after bootstrap: %#v", users) |
|||
} |
|||
|
|||
if err := store.EnsureAdmin("other", "secret"); err != nil { |
|||
t.Fatalf("ensure admin second run: %v", err) |
|||
} |
|||
|
|||
users, err = store.ListUsers() |
|||
if err != nil { |
|||
t.Fatalf("list users second run: %v", err) |
|||
} |
|||
if len(users) != 1 { |
|||
t.Fatalf("expected no second bootstrap user, got %d", len(users)) |
|||
} |
|||
} |
|||
|
|||
func openTestDB(t *testing.T) *sql.DB { |
|||
t.Helper() |
|||
|
|||
database, err := db.Open(t.TempDir() + "/test.db") |
|||
if err != nil { |
|||
t.Fatalf("open database: %v", err) |
|||
} |
|||
t.Cleanup(func() { |
|||
if err := database.Close(); err != nil { |
|||
t.Fatalf("close database: %v", err) |
|||
} |
|||
}) |
|||
|
|||
return database |
|||
} |
|||
@ -0,0 +1,39 @@ |
|||
<!doctype html> |
|||
<html lang="en"> |
|||
<head> |
|||
<meta charset="utf-8"> |
|||
<meta name="viewport" content="width=device-width, initial-scale=1"> |
|||
<title>Users - CamMonitor</title> |
|||
<script src="https://cdn.tailwindcss.com"></script> |
|||
</head> |
|||
<body class="min-h-screen bg-slate-950 p-6 text-slate-100"> |
|||
<main class="mx-auto max-w-3xl space-y-6"> |
|||
<header class="flex items-center justify-between"> |
|||
<h1 class="text-2xl font-semibold">Users</h1> |
|||
<form method="post" action="/logout"><button class="rounded bg-slate-800 px-3 py-2">Sign out</button></form> |
|||
</header> |
|||
<form method="post" action="/admin/users" class="grid gap-3 md:grid-cols-[1fr_1fr_auto_auto]"> |
|||
<input name="username" placeholder="Username" class="rounded bg-slate-900 px-3 py-2 ring-1 ring-slate-700"> |
|||
<input name="password" type="password" placeholder="Password" class="rounded bg-slate-900 px-3 py-2 ring-1 ring-slate-700"> |
|||
<label class="flex items-center gap-2 text-sm"><input name="is_admin" type="checkbox"> Admin</label> |
|||
<button class="rounded bg-indigo-500 px-3 py-2 font-medium text-white">Add</button> |
|||
</form> |
|||
<table class="w-full border-collapse text-left"> |
|||
<thead><tr class="border-b border-slate-800"><th class="py-2">Username</th><th class="py-2">Role</th><th class="py-2"></th></tr></thead> |
|||
<tbody> |
|||
{{range .Users}} |
|||
<tr class="border-b border-slate-900"> |
|||
<td class="py-2">{{.Username}}</td> |
|||
<td class="py-2">{{if .IsAdmin}}admin{{else}}user{{end}}</td> |
|||
<td class="py-2 text-right"> |
|||
<form method="post" action="/admin/users/{{.ID}}/delete"> |
|||
<button class="rounded bg-slate-800 px-3 py-1">Delete</button> |
|||
</form> |
|||
</td> |
|||
</tr> |
|||
{{end}} |
|||
</tbody> |
|||
</table> |
|||
</main> |
|||
</body> |
|||
</html> |
|||
@ -0,0 +1,25 @@ |
|||
<!doctype html> |
|||
<html lang="en"> |
|||
<head> |
|||
<meta charset="utf-8"> |
|||
<meta name="viewport" content="width=device-width, initial-scale=1"> |
|||
<title>Login - CamMonitor</title> |
|||
<script src="https://cdn.tailwindcss.com"></script> |
|||
</head> |
|||
<body class="min-h-screen bg-slate-950 text-slate-100"> |
|||
<main class="mx-auto flex min-h-screen max-w-sm items-center px-6"> |
|||
<form method="post" action="/login" class="w-full space-y-4"> |
|||
<h1 class="text-2xl font-semibold">CamMonitor</h1> |
|||
<label class="block space-y-1"> |
|||
<span class="text-sm text-slate-300">Username</span> |
|||
<input name="username" autocomplete="username" class="w-full rounded bg-slate-900 px-3 py-2 text-slate-100 ring-1 ring-slate-700"> |
|||
</label> |
|||
<label class="block space-y-1"> |
|||
<span class="text-sm text-slate-300">Password</span> |
|||
<input name="password" type="password" autocomplete="current-password" class="w-full rounded bg-slate-900 px-3 py-2 text-slate-100 ring-1 ring-slate-700"> |
|||
</label> |
|||
<button type="submit" class="w-full rounded bg-indigo-500 px-3 py-2 font-medium text-white">Sign in</button> |
|||
</form> |
|||
</main> |
|||
</body> |
|||
</html> |
|||
@ -0,0 +1,13 @@ |
|||
package templates |
|||
|
|||
import "embed" |
|||
|
|||
const ( |
|||
Login = "login.html" |
|||
AdminUsers = "admin_users.html" |
|||
) |
|||
|
|||
// FS exposes file-backed HTML templates for handlers that render web pages.
|
|||
//
|
|||
//go:embed *.html
|
|||
var FS embed.FS |
|||
Loading…
Reference in new issue