feat: add admin dashboard
This commit is contained in:
139
apps/server/internal/httpserver/admin_handlers.go
Normal file
139
apps/server/internal/httpserver/admin_handlers.go
Normal 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
|
||||
}
|
||||
26
apps/server/internal/httpserver/app_files.go
Normal file
26
apps/server/internal/httpserver/app_files.go
Normal 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"))
|
||||
})
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user