feat: add admin dashboard

This commit is contained in:
2026-04-29 09:44:57 -04:00
parent e319a5d092
commit baf7a497eb
11 changed files with 939 additions and 67 deletions

View File

@@ -0,0 +1,2 @@
-- SQLite cannot drop columns without rebuilding the table. Keep role on rollback.

View File

@@ -0,0 +1,2 @@
ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'viewer';

View File

@@ -14,13 +14,22 @@ type Repository struct {
}
type DocumentRecord struct {
ID string
Path string
CurrentHash string
Title string
Tags []string
CreatedAt time.Time
UpdatedAt time.Time
ID string `json:"id"`
Path string `json:"path"`
CurrentHash string `json:"currentHash"`
Title string `json:"title"`
Tags []string `json:"tags"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type UserRecord struct {
ID string `json:"id"`
Email string `json:"email"`
DisplayName string `json:"displayName"`
Role string `json:"role"`
CreatedAt time.Time `json:"createdAt"`
LastSeenAt time.Time `json:"lastSeenAt,omitempty"`
}
type AttachmentRecord struct {
@@ -151,6 +160,82 @@ func (r *Repository) ListDocuments(ctx context.Context) ([]DocumentRecord, error
return records, rows.Err()
}
func (r *Repository) ListUsers(ctx context.Context) ([]UserRecord, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT id, email, COALESCE(display_name, ''), COALESCE(role, 'viewer'), created_at, COALESCE(last_seen_at, '')
FROM users
ORDER BY created_at DESC
`)
if err != nil {
return nil, fmt.Errorf("list users: %w", err)
}
defer rows.Close()
var users []UserRecord
for rows.Next() {
var (
user UserRecord
created string
lastSeen string
)
if err := rows.Scan(&user.ID, &user.Email, &user.DisplayName, &user.Role, &created, &lastSeen); err != nil {
return nil, fmt.Errorf("scan user: %w", err)
}
createdAt, err := time.Parse(time.RFC3339, created)
if err != nil {
return nil, fmt.Errorf("parse user created_at: %w", err)
}
user.CreatedAt = createdAt
if lastSeen != "" {
parsed, err := time.Parse(time.RFC3339, lastSeen)
if err != nil {
return nil, fmt.Errorf("parse user last_seen_at: %w", err)
}
user.LastSeenAt = parsed
}
users = append(users, user)
}
return users, rows.Err()
}
func (r *Repository) UpsertUser(ctx context.Context, user UserRecord) (UserRecord, error) {
now := time.Now().UTC()
if user.ID == "" {
user.ID = "user:" + user.Email
}
if user.Role == "" {
user.Role = "viewer"
}
if user.DisplayName == "" {
user.DisplayName = user.Email
}
user.CreatedAt = now
user.LastSeenAt = now
if _, err := r.db.ExecContext(ctx, `
INSERT INTO users (id, email, display_name, role, created_at, last_seen_at)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(email) DO UPDATE SET
display_name = excluded.display_name,
role = excluded.role,
last_seen_at = excluded.last_seen_at
`, user.ID, user.Email, user.DisplayName, user.Role, now.Format(time.RFC3339), now.Format(time.RFC3339)); err != nil {
return UserRecord{}, fmt.Errorf("upsert user %s: %w", user.Email, err)
}
users, err := r.ListUsers(ctx)
if err != nil {
return UserRecord{}, err
}
for _, candidate := range users {
if candidate.Email == user.Email {
return candidate, nil
}
}
return UserRecord{}, sql.ErrNoRows
}
func (r *Repository) SaveAttachment(ctx context.Context, record AttachmentRecord) error {
if _, err := r.db.ExecContext(ctx, `
INSERT INTO attachments (hash, original_name, content_type, size_bytes, created_at)

View File

@@ -0,0 +1,139 @@
package httpserver
import (
"encoding/json"
"net/http"
"strings"
"time"
"github.com/tim/md-hub-secure/apps/server/internal/docs"
)
type adminUserInput struct {
Email string `json:"email"`
DisplayName string `json:"displayName"`
Role string `json:"role"`
}
func (s *Server) handleAdminLogin(w http.ResponseWriter, r *http.Request) {
var input adminUserInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
return
}
user, ok := s.normalizeAdminUserInput(input)
if !ok {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "email is required"})
return
}
if user.Role == "viewer" {
user.Role = "admin"
}
created, err := s.repository.UpsertUser(r.Context(), user)
if err != nil {
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{
"user": created,
"session": map[string]any{
"mode": "foundation",
"createdAt": time.Now().UTC(),
},
})
}
func (s *Server) handleAdminUsers(w http.ResponseWriter, r *http.Request) {
users, err := s.repository.ListUsers(r.Context())
if err != nil {
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{"users": users})
}
func (s *Server) handleAdminCreateUser(w http.ResponseWriter, r *http.Request) {
var input adminUserInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
return
}
user, ok := s.normalizeAdminUserInput(input)
if !ok {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "email is required"})
return
}
created, err := s.repository.UpsertUser(r.Context(), user)
if err != nil {
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSONWithStatus(w, http.StatusCreated, map[string]any{"user": created})
}
func (s *Server) handleAdminWorkspace(w http.ResponseWriter, r *http.Request) {
documents, err := s.documents.ListDocuments(r.Context())
if err != nil {
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
users, err := s.repository.ListUsers(r.Context())
if err != nil {
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{
"workspace": map[string]any{
"sourceDir": s.config.Content.SourceDir,
"storeDir": s.config.Content.StoreDir,
"databasePath": s.config.Database.Path,
"documentCount": len(documents),
"userCount": len(users),
"webDistDir": s.config.Web.DistDir,
},
"documents": documents,
})
}
func (s *Server) handleAdminWorkspaceSync(w http.ResponseWriter, r *http.Request) {
changes, err := s.documents.SyncSourceDir(r.Context())
if err != nil {
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{
"changes": changes,
"syncedAt": time.Now().UTC(),
})
}
func (s *Server) normalizeAdminUserInput(input adminUserInput) (docs.UserRecord, bool) {
email := strings.TrimSpace(strings.ToLower(input.Email))
if email == "" {
return docs.UserRecord{}, false
}
role := strings.TrimSpace(strings.ToLower(input.Role))
switch role {
case "admin", "editor", "viewer":
default:
role = "viewer"
}
displayName := strings.TrimSpace(input.DisplayName)
if displayName == "" {
displayName = email
}
return docs.UserRecord{
Email: email,
DisplayName: displayName,
Role: role,
}, true
}

View File

@@ -0,0 +1,26 @@
package httpserver
import (
"net/http"
"os"
"path/filepath"
"strings"
)
func (s *Server) appFileServer() http.Handler {
files := http.FileServer(http.Dir(s.config.Web.DistDir))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cleanPath := filepath.Clean(strings.TrimPrefix(r.URL.Path, "/"))
if cleanPath == "." {
cleanPath = "index.html"
}
target := filepath.Join(s.config.Web.DistDir, cleanPath)
if info, err := os.Stat(target); err == nil && !info.IsDir() {
files.ServeHTTP(w, r)
return
}
http.ServeFile(w, r, filepath.Join(s.config.Web.DistDir, "index.html"))
})
}

View File

@@ -76,6 +76,11 @@ func New(deps Dependencies) (http.Handler, error) {
router.Get("/", server.handleIndex)
router.Get("/health", server.handleHealth)
router.Get("/ws", server.handleWebSocket)
router.Post("/api/admin/login", server.handleAdminLogin)
router.Get("/api/admin/users", server.handleAdminUsers)
router.Post("/api/admin/users", server.handleAdminCreateUser)
router.Get("/api/admin/workspace", server.handleAdminWorkspace)
router.Post("/api/admin/workspace/sync", server.handleAdminWorkspaceSync)
router.Get("/docs", server.handleDocsIndex)
router.Get("/docs/*", server.handleDocument)
router.Post("/api/uploads", server.handleUpload)
@@ -83,12 +88,17 @@ func New(deps Dependencies) (http.Handler, error) {
router.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(mustSub("static"))))
if server.webEnabled {
router.Handle("/app/*", http.StripPrefix("/app/", http.FileServer(http.Dir(deps.Config.Web.DistDir))))
router.Get("/app", server.handleAppRedirect)
router.Handle("/app/*", http.StripPrefix("/app/", server.appFileServer()))
}
return router, nil
}
func (s *Server) handleAppRedirect(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/app/", http.StatusMovedPermanently)
}
func mustSub(path string) http.FileSystem {
sub, err := fsSub(path)
if err != nil {