From 01a0ac68a4d03df1e02e8ac56ce1bc0035fb0af0 Mon Sep 17 00:00:00 2001 From: omigamedev Date: Sun, 18 Jan 2026 21:11:23 +0100 Subject: [PATCH] add htmx web frontend with templates and session auth --- portal/internal/api/router.go | 48 +++++ portal/internal/web/handlers.go | 194 ++++++++++++++++++ portal/internal/web/session.go | 94 +++++++++ portal/internal/web/templates.go | 109 ++++++++++ .../internal/web/templates/layouts/base.html | 33 +++ .../web/templates/pages/app_detail.html | 162 +++++++++++++++ .../internal/web/templates/pages/app_new.html | 85 ++++++++ .../web/templates/pages/dashboard.html | 76 +++++++ .../internal/web/templates/pages/login.html | 54 +++++ .../web/templates/partials/app_list.html | 59 ++++++ .../web/templates/partials/navbar.html | 55 +++++ 11 files changed, 969 insertions(+) create mode 100644 portal/internal/web/handlers.go create mode 100644 portal/internal/web/session.go create mode 100644 portal/internal/web/templates.go create mode 100644 portal/internal/web/templates/layouts/base.html create mode 100644 portal/internal/web/templates/pages/app_detail.html create mode 100644 portal/internal/web/templates/pages/app_new.html create mode 100644 portal/internal/web/templates/pages/dashboard.html create mode 100644 portal/internal/web/templates/pages/login.html create mode 100644 portal/internal/web/templates/partials/app_list.html create mode 100644 portal/internal/web/templates/partials/navbar.html diff --git a/portal/internal/api/router.go b/portal/internal/api/router.go index 43af312..9bff491 100644 --- a/portal/internal/api/router.go +++ b/portal/internal/api/router.go @@ -2,6 +2,7 @@ package api import ( + "log" "net/http" "github.com/go-chi/chi/v5" @@ -11,6 +12,7 @@ import ( "github.com/omixlab/mosis-portal/internal/auth" "github.com/omixlab/mosis-portal/internal/config" "github.com/omixlab/mosis-portal/internal/database" + "github.com/omixlab/mosis-portal/internal/web" ) // NewRouter creates and configures the HTTP router @@ -122,5 +124,51 @@ func NewRouter(cfg *config.Config, db *database.DB) http.Handler { r.Post("/review/{versionID}/reject", handlers.NotImplemented) }) + // Web UI routes (htmx + Go templates) + webHandler, err := web.NewHandler(db) + if err != nil { + log.Printf("Warning: Failed to initialize web handler: %v", err) + } else { + sessionMW := web.NewSessionMiddleware(db, cfg.JWTSecret) + + // Public web pages + r.Group(func(r chi.Router) { + r.Use(sessionMW.LoadSession) + r.Get("/", func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "/dashboard", http.StatusSeeOther) + }) + r.Get("/login", webHandler.Login) + }) + + // Protected web pages + r.Group(func(r chi.Router) { + r.Use(sessionMW.LoadSession) + r.Use(sessionMW.RequireSession) + r.Get("/dashboard", webHandler.Dashboard) + r.Get("/apps/new", webHandler.AppNew) + r.Get("/apps/{appID}", webHandler.AppDetail) + + // htmx partials + r.Get("/partials/apps", webHandler.AppListPartial) + }) + + // Auth callback that sets session (after OAuth) + r.Get("/auth/callback", func(w http.ResponseWriter, r *http.Request) { + developerID := r.URL.Query().Get("developer_id") + if developerID == "" { + http.Redirect(w, r, "/login?error=Authentication failed", http.StatusSeeOther) + return + } + web.SetSession(w, developerID) + http.Redirect(w, r, "/dashboard", http.StatusSeeOther) + }) + + // Logout (clears session) + r.Get("/auth/logout", func(w http.ResponseWriter, r *http.Request) { + web.ClearSession(w) + http.Redirect(w, r, "/login", http.StatusSeeOther) + }) + } + return r } diff --git a/portal/internal/web/handlers.go b/portal/internal/web/handlers.go new file mode 100644 index 0000000..b57e13e --- /dev/null +++ b/portal/internal/web/handlers.go @@ -0,0 +1,194 @@ +package web + +import ( + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/omixlab/mosis-portal/internal/database" +) + +// Handler handles web page requests +type Handler struct { + db *database.DB + templates *Templates +} + +// NewHandler creates a new web handler +func NewHandler(db *database.DB) (*Handler, error) { + templates, err := NewTemplates() + if err != nil { + return nil, err + } + return &Handler{ + db: db, + templates: templates, + }, nil +} + +// PageData is the base data structure for all pages +type PageData struct { + Title string + ActiveNav string + Developer *database.Developer + Error string +} + +// Login renders the login page +func (h *Handler) Login(w http.ResponseWriter, r *http.Request) { + data := PageData{ + Title: "Sign In", + } + + // Check for error message + if err := r.URL.Query().Get("error"); err != "" { + data.Error = err + } + + h.render(w, "login", data) +} + +// Dashboard renders the dashboard page +func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) { + developer := getDeveloperFromContext(r) + if developer == nil { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + + // Get stats + apps, total, _ := h.db.ListApps(r.Context(), developer.ID, "", 1, 100) + + data := struct { + PageData + Stats struct { + TotalApps int + Downloads int64 + ActiveUsers int64 + } + Apps []*database.App + }{ + PageData: PageData{ + Title: "Dashboard", + ActiveNav: "dashboard", + Developer: developer, + }, + Apps: apps, + } + data.Stats.TotalApps = total + data.Stats.Downloads = 0 // TODO: implement + data.Stats.ActiveUsers = 0 // TODO: implement + + h.render(w, "dashboard", data) +} + +// AppNew renders the new app form +func (h *Handler) AppNew(w http.ResponseWriter, r *http.Request) { + developer := getDeveloperFromContext(r) + if developer == nil { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + + data := PageData{ + Title: "Create New App", + ActiveNav: "apps", + Developer: developer, + } + + h.render(w, "app_new", data) +} + +// AppDetail renders the app detail page +func (h *Handler) AppDetail(w http.ResponseWriter, r *http.Request) { + developer := getDeveloperFromContext(r) + if developer == nil { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + + appID := chi.URLParam(r, "appID") + app, err := h.db.GetApp(r.Context(), appID) + if err != nil { + http.Error(w, "App not found", http.StatusNotFound) + return + } + + // Verify ownership + if app.DeveloperID != developer.ID { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + + // Get latest version + versions, totalVersions, _ := h.db.ListVersions(r.Context(), appID, "", 1, 1) + var latestVersion *database.AppVersion + if len(versions) > 0 { + latestVersion = versions[0] + } + + data := struct { + PageData + App *database.App + Tab string + LatestVersion *database.AppVersion + TotalVersions int + }{ + PageData: PageData{ + Title: app.Name, + ActiveNav: "apps", + Developer: developer, + }, + App: app, + Tab: "overview", + LatestVersion: latestVersion, + TotalVersions: totalVersions, + } + + h.render(w, "app_detail", data) +} + +// AppListPartial renders the app list partial for htmx +func (h *Handler) AppListPartial(w http.ResponseWriter, r *http.Request) { + developer := getDeveloperFromContext(r) + if developer == nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + apps, _, err := h.db.ListApps(r.Context(), developer.ID, "", 1, 100) + if err != nil { + http.Error(w, "Failed to load apps", http.StatusInternalServerError) + return + } + + data := struct { + Apps []*database.App + }{ + Apps: apps, + } + + h.renderPartial(w, "app_list", data) +} + +// render renders a full page template +func (h *Handler) render(w http.ResponseWriter, name string, data interface{}) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := h.templates.RenderPage(w, name, data); err != nil { + http.Error(w, "Template error: "+err.Error(), http.StatusInternalServerError) + } +} + +// renderPartial renders a partial template +func (h *Handler) renderPartial(w http.ResponseWriter, name string, data interface{}) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := h.templates.RenderPartial(w, name, data); err != nil { + http.Error(w, "Template error: "+err.Error(), http.StatusInternalServerError) + } +} + +// getDeveloperFromContext retrieves the developer from the request context +// This will be set by the session middleware +func getDeveloperFromContext(r *http.Request) *database.Developer { + developer, _ := r.Context().Value("developer").(*database.Developer) + return developer +} diff --git a/portal/internal/web/session.go b/portal/internal/web/session.go new file mode 100644 index 0000000..0d038b2 --- /dev/null +++ b/portal/internal/web/session.go @@ -0,0 +1,94 @@ +package web + +import ( + "context" + "encoding/gob" + "net/http" + + "github.com/omixlab/mosis-portal/internal/database" +) + +func init() { + // Register types for gob encoding (used by cookie sessions) + gob.Register(&database.Developer{}) +} + +// SessionMiddleware handles session-based authentication for web pages +type SessionMiddleware struct { + db *database.DB + cookieKey string +} + +// NewSessionMiddleware creates a new session middleware +func NewSessionMiddleware(db *database.DB, cookieKey string) *SessionMiddleware { + return &SessionMiddleware{ + db: db, + cookieKey: cookieKey, + } +} + +// LoadSession loads the developer from session cookie +func (m *SessionMiddleware) LoadSession(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Try to get developer ID from session cookie + cookie, err := r.Cookie("session") + if err != nil { + next.ServeHTTP(w, r) + return + } + + // Lookup developer + developer, err := m.db.GetDeveloper(r.Context(), cookie.Value) + if err != nil { + // Invalid session, clear cookie + http.SetCookie(w, &http.Cookie{ + Name: "session", + Value: "", + Path: "/", + MaxAge: -1, + HttpOnly: true, + }) + next.ServeHTTP(w, r) + return + } + + // Add developer to context + ctx := context.WithValue(r.Context(), "developer", developer) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +// RequireSession redirects to login if not authenticated +func (m *SessionMiddleware) RequireSession(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + developer := getDeveloperFromContext(r) + if developer == nil { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + next.ServeHTTP(w, r) + }) +} + +// SetSession creates a session for the developer +func SetSession(w http.ResponseWriter, developerID string) { + http.SetCookie(w, &http.Cookie{ + Name: "session", + Value: developerID, + Path: "/", + MaxAge: 60 * 60 * 24 * 30, // 30 days + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + }) +} + +// ClearSession clears the session cookie +func ClearSession(w http.ResponseWriter) { + http.SetCookie(w, &http.Cookie{ + Name: "session", + Value: "", + Path: "/", + MaxAge: -1, + HttpOnly: true, + }) +} diff --git a/portal/internal/web/templates.go b/portal/internal/web/templates.go new file mode 100644 index 0000000..62b6aad --- /dev/null +++ b/portal/internal/web/templates.go @@ -0,0 +1,109 @@ +package web + +import ( + "embed" + "html/template" + "io" + "io/fs" + "path/filepath" + "strings" +) + +//go:embed templates/* +var templateFS embed.FS + +// Templates holds the parsed templates +type Templates struct { + templates map[string]*template.Template +} + +// NewTemplates creates and parses all templates +func NewTemplates() (*Templates, error) { + t := &Templates{ + templates: make(map[string]*template.Template), + } + + // Load all layout and partial templates first + layoutFiles, err := fs.Glob(templateFS, "templates/layouts/*.html") + if err != nil { + return nil, err + } + partialFiles, err := fs.Glob(templateFS, "templates/partials/*.html") + if err != nil { + return nil, err + } + + // Load page templates + pageFiles, err := fs.Glob(templateFS, "templates/pages/*.html") + if err != nil { + return nil, err + } + + // Combine layouts and partials + baseFiles := append(layoutFiles, partialFiles...) + + // Parse each page template with layouts and partials + for _, pageFile := range pageFiles { + files := append([]string{pageFile}, baseFiles...) + + // Read and parse all files + tmpl := template.New(filepath.Base(pageFile)) + for _, file := range files { + content, err := templateFS.ReadFile(file) + if err != nil { + return nil, err + } + _, err = tmpl.Parse(string(content)) + if err != nil { + return nil, err + } + } + + // Extract page name (e.g., "login" from "templates/pages/login.html") + name := strings.TrimSuffix(filepath.Base(pageFile), ".html") + t.templates[name] = tmpl + } + + // Also parse partials standalone for htmx partial responses + for _, partialFile := range partialFiles { + content, err := templateFS.ReadFile(partialFile) + if err != nil { + return nil, err + } + tmpl, err := template.New(filepath.Base(partialFile)).Parse(string(content)) + if err != nil { + return nil, err + } + name := "partial:" + strings.TrimSuffix(filepath.Base(partialFile), ".html") + t.templates[name] = tmpl + } + + return t, nil +} + +// RenderPage renders a full page template +func (t *Templates) RenderPage(w io.Writer, name string, data interface{}) error { + tmpl, ok := t.templates[name] + if !ok { + return &ErrTemplateNotFound{Name: name} + } + return tmpl.ExecuteTemplate(w, "base", data) +} + +// RenderPartial renders a partial template (for htmx responses) +func (t *Templates) RenderPartial(w io.Writer, name string, data interface{}) error { + tmpl, ok := t.templates["partial:"+name] + if !ok { + return &ErrTemplateNotFound{Name: name} + } + return tmpl.ExecuteTemplate(w, name, data) +} + +// ErrTemplateNotFound is returned when a template is not found +type ErrTemplateNotFound struct { + Name string +} + +func (e *ErrTemplateNotFound) Error() string { + return "template not found: " + e.Name +} diff --git a/portal/internal/web/templates/layouts/base.html b/portal/internal/web/templates/layouts/base.html new file mode 100644 index 0000000..aa15625 --- /dev/null +++ b/portal/internal/web/templates/layouts/base.html @@ -0,0 +1,33 @@ +{{define "base"}} + + + + + + {{.Title}} - Mosis Developer Portal + + + + + + + {{if .Developer}} + {{template "navbar" .}} + {{end}} + +
+ {{template "content" .}} +
+ + {{template "scripts" .}} + + +{{end}} + +{{define "scripts"}} + +{{end}} diff --git a/portal/internal/web/templates/pages/app_detail.html b/portal/internal/web/templates/pages/app_detail.html new file mode 100644 index 0000000..c7ecd98 --- /dev/null +++ b/portal/internal/web/templates/pages/app_detail.html @@ -0,0 +1,162 @@ +{{define "content"}} +
+ + + + + Back to Dashboard + +
+ + +
+
+
+
+ + + +
+
+

{{.App.Name}}

+

{{.App.PackageID}}

+
+ {{if eq .App.Status "published"}} + + + Published + + {{else if eq .App.Status "draft"}} + + + Draft + + {{else if eq .App.Status "review"}} + + + In Review + + {{end}} +
+
+
+ + + + + Submit New Version + +
+
+ + +
+ +
+ + +{{block "tab_content" .}} + +
+ +
+ +
+

Latest Version

+ {{if .LatestVersion}} +
+
+

{{.LatestVersion.VersionName}}

+

Version code: {{.LatestVersion.VersionCode}}

+
+
+ {{if .LatestVersion.PublishedAt}} +

Published

+

{{.LatestVersion.PublishedAt.Format "Jan 2, 2006"}}

+ {{else}} + + {{.LatestVersion.Status}} + + {{end}} +
+
+ {{else}} +

No versions uploaded yet.

+ + Upload your first version + + + + + {{end}} +
+ + +
+

Description

+ {{if .App.Description}} +

{{.App.Description}}

+ {{else}} +

No description provided.

+ {{end}} +
+
+ + +
+ +
+

Statistics

+
+
+
Downloads
+
0
+
+
+
Active Users
+
0
+
+
+
Total Versions
+
{{.TotalVersions}}
+
+
+
+ + +
+

Information

+
+ {{if .App.Category}} +
+
Category
+
{{.App.Category}}
+
+ {{end}} +
+
Created
+
{{.App.CreatedAt.Format "Jan 2, 2006"}}
+
+
+
Updated
+
{{.App.UpdatedAt.Format "Jan 2, 2006"}}
+
+
+
+
+
+{{end}} +{{end}} diff --git a/portal/internal/web/templates/pages/app_new.html b/portal/internal/web/templates/pages/app_new.html new file mode 100644 index 0000000..4626e4c --- /dev/null +++ b/portal/internal/web/templates/pages/app_new.html @@ -0,0 +1,85 @@ +{{define "content"}} +
+
+ + + + + Back to Dashboard + +

Create New App

+

Fill in the details to register your app on Mosis.

+
+ +
+
+
+ + + +
+ +
+ + + +

Unique identifier for your app. Cannot be changed later.

+
+ +
+ + +

Up to 500 characters.

+
+ +
+ + +
+ +
+ +
+ + Cancel + + +
+
+
+
+{{end}} diff --git a/portal/internal/web/templates/pages/dashboard.html b/portal/internal/web/templates/pages/dashboard.html new file mode 100644 index 0000000..9450c55 --- /dev/null +++ b/portal/internal/web/templates/pages/dashboard.html @@ -0,0 +1,76 @@ +{{define "content"}} +
+

Welcome back, {{.Developer.Name}}!

+

Here's what's happening with your apps.

+
+ + +
+
+
+
+

Total Apps

+

{{.Stats.TotalApps}}

+
+
+ + + +
+
+
+ +
+
+
+

Total Downloads

+

{{.Stats.Downloads}}

+
+
+ + + +
+
+
+ +
+
+
+

Active Users

+

{{.Stats.ActiveUsers}}

+
+
+ + + +
+
+
+
+ + +
+
+

Your Apps

+ + + + + New App + +
+ +
+
+
+ + + + + Loading apps... +
+
+
+
+{{end}} diff --git a/portal/internal/web/templates/pages/login.html b/portal/internal/web/templates/pages/login.html new file mode 100644 index 0000000..fc3d0b1 --- /dev/null +++ b/portal/internal/web/templates/pages/login.html @@ -0,0 +1,54 @@ +{{define "content"}} +
+
+
+ +
+ + + +

Sign in to Mosis

+

Developer Portal

+
+ + {{if .Error}} +
+ {{.Error}} +
+ {{end}} + + + + +

+ By signing in, you agree to our + Terms of Service + and + Privacy Policy +

+
+ +

+ New to Mosis? + Get started +

+
+
+{{end}} diff --git a/portal/internal/web/templates/partials/app_list.html b/portal/internal/web/templates/partials/app_list.html new file mode 100644 index 0000000..01fc356 --- /dev/null +++ b/portal/internal/web/templates/partials/app_list.html @@ -0,0 +1,59 @@ +{{define "app_list"}} +{{if .Apps}} +{{range .Apps}} + +
+
+
+ + + +
+
+

{{.Name}}

+

{{.PackageID}}

+
+
+
+ {{if eq .Status "published"}} + + Published + + {{else if eq .Status "draft"}} + + Draft + + {{else if eq .Status "review"}} + + In Review + + {{else}} + + {{.Status}} + + {{end}} + + + +
+
+
+{{end}} +{{else}} +
+ + + +

No apps yet

+

Get started by creating your first app.

+ +
+{{end}} +{{end}} diff --git a/portal/internal/web/templates/partials/navbar.html b/portal/internal/web/templates/partials/navbar.html new file mode 100644 index 0000000..7c59c34 --- /dev/null +++ b/portal/internal/web/templates/partials/navbar.html @@ -0,0 +1,55 @@ +{{define "navbar"}} + +{{end}}