diff --git a/app/config.yaml b/app/config.yaml
index 1972051..60c2bdd 100644
--- a/app/config.yaml
+++ b/app/config.yaml
@@ -18,6 +18,12 @@ authelia:
config_path: "/opt/authelia/configuration.yml"
users_db_path: "/opt/authelia/users_database.yml"
+oidc:
+ client_id: "nextwks"
+ client_secret: ""
+ redirect_url: "http://sechpoint.app/auth/callback"
+ domain: "sechpoint.app"
+
smtp:
host: ""
port: 587
diff --git a/app/static/icons/icon-192.svg b/app/static/icons/icon-192.svg
new file mode 100644
index 0000000..ab8a308
--- /dev/null
+++ b/app/static/icons/icon-192.svg
@@ -0,0 +1,5 @@
+
diff --git a/app/static/icons/icon-512.svg b/app/static/icons/icon-512.svg
new file mode 100644
index 0000000..e59309c
--- /dev/null
+++ b/app/static/icons/icon-512.svg
@@ -0,0 +1,5 @@
+
diff --git a/app/static/manifest.json b/app/static/manifest.json
new file mode 100644
index 0000000..c702fec
--- /dev/null
+++ b/app/static/manifest.json
@@ -0,0 +1,27 @@
+{
+ "name": "Next Workspace",
+ "short_name": "NextWks",
+ "description": "Your self-hosted workspace platform",
+ "start_url": "/",
+ "display": "standalone",
+ "background_color": "#0f172a",
+ "theme_color": "#3b82f6",
+ "orientation": "any",
+ "icons": [
+ {
+ "src": "/static/icons/icon-192.svg",
+ "sizes": "192x192",
+ "type": "image/svg+xml",
+ "purpose": "any maskable"
+ },
+ {
+ "src": "/static/icons/icon-512.svg",
+ "sizes": "512x512",
+ "type": "image/svg+xml",
+ "purpose": "any maskable"
+ }
+ ],
+ "categories": ["productivity", "utilities"],
+ "lang": "en",
+ "dir": "ltr"
+}
diff --git a/app/static/sw.js b/app/static/sw.js
new file mode 100644
index 0000000..5b61209
--- /dev/null
+++ b/app/static/sw.js
@@ -0,0 +1,57 @@
+// Next Workspace - Service Worker
+// Cache name includes timestamp to force update on deploy
+const CACHE_NAME = 'nextwks-v1';
+const STATIC_ASSETS = [
+ '/',
+ '/static/manifest.json',
+ '/static/icons/icon-192.svg',
+ '/static/icons/icon-512.svg',
+];
+
+// Install: cache static assets
+self.addEventListener('install', (event) => {
+ event.waitUntil(
+ caches.open(CACHE_NAME).then((cache) => {
+ return cache.addAll(STATIC_ASSETS);
+ })
+ );
+});
+
+// Activate: clean old caches
+self.addEventListener('activate', (event) => {
+ event.waitUntil(
+ caches.keys().then((keys) => {
+ return Promise.all(
+ keys
+ .filter((key) => key !== CACHE_NAME)
+ .map((key) => caches.delete(key))
+ );
+ })
+ );
+});
+
+// Fetch: serve from cache first, fall back to network
+self.addEventListener('fetch', (event) => {
+ // Only handle GET requests
+ if (event.request.method !== 'GET') return;
+
+ // For navigation requests, always go to network
+ if (event.request.mode === 'navigate') {
+ event.respondWith(fetch(event.request).catch(() => caches.match('/')));
+ return;
+ }
+
+ // For static assets, try cache first
+ event.respondWith(
+ caches.match(event.request).then((cached) => {
+ return cached || fetch(event.request).then((response) => {
+ // Cache successful responses for static assets
+ if (response.status === 200 && event.request.url.includes('/static/')) {
+ const clone = response.clone();
+ caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
+ }
+ return response;
+ });
+ })
+ );
+});
diff --git a/install.sh b/install.sh
index ccb7f10..47d46d3 100755
--- a/install.sh
+++ b/install.sh
@@ -144,6 +144,12 @@ authelia:
config_path: "/opt/authelia/configuration.yml"
users_db_path: "/opt/authelia/users_database.yml"
+oidc:
+ client_id: "nextwks"
+ client_secret: ""
+ redirect_url: "https://sechpoint.app/auth/callback"
+ domain: "sechpoint.app"
+
smtp:
host: ""
port: 587
diff --git a/src/core/auth/oidc.go b/src/core/auth/oidc.go
new file mode 100644
index 0000000..151f8f6
--- /dev/null
+++ b/src/core/auth/oidc.go
@@ -0,0 +1,142 @@
+package auth
+
+import (
+ "fmt"
+ "net/http"
+ "net/url"
+)
+
+// OIDCConfig holds the configuration for the Authelia OIDC client.
+type OIDCConfig struct {
+ // Authelia's OIDC issuer URL (e.g., http://127.0.0.1:9091)
+ IssuerURL string
+ // Client ID registered in Authelia
+ ClientID string
+ // Client secret (if required)
+ ClientSecret string
+ // Redirect URL after OIDC login (e.g., https://sechpoint.app/auth/callback)
+ RedirectURL string
+ // The public-facing domain for cookie domain
+ Domain string
+}
+
+// OIDCHandler handles OIDC authentication flows with Authelia.
+type OIDCHandler struct {
+ config OIDCConfig
+ store *SessionStore
+}
+
+// NewOIDCHandler creates a new OIDC handler.
+func NewOIDCHandler(config OIDCConfig, store *SessionStore) *OIDCHandler {
+ return &OIDCHandler{
+ config: config,
+ store: store,
+ }
+}
+
+// LoginRedirect redirects the user to Authelia's OIDC authorization endpoint.
+func (h *OIDCHandler) LoginRedirect(w http.ResponseWriter, r *http.Request) {
+ state := generateToken(16)
+ nonce := generateToken(16)
+
+ // Store state in a short-lived cookie for CSRF protection
+ http.SetCookie(w, &http.Cookie{
+ Name: "oidc_state",
+ Value: state,
+ Path: "/",
+ MaxAge: 300, // 5 minutes
+ HttpOnly: true,
+ SameSite: http.SameSiteLaxMode,
+ })
+
+ authURL := fmt.Sprintf(
+ "%s/api/oidc/authorize?response_type=code&client_id=%s&redirect_uri=%s&scope=openid+profile+email&state=%s&nonce=%s",
+ h.config.IssuerURL,
+ url.QueryEscape(h.config.ClientID),
+ url.QueryEscape(h.config.RedirectURL),
+ state,
+ nonce,
+ )
+
+ http.Redirect(w, r, authURL, http.StatusFound)
+}
+
+// Callback handles the OIDC authorization code callback from Authelia.
+// For now, this validates state and creates a session.
+// Full token exchange requires an HTTP client to Authelia's token endpoint.
+func (h *OIDCHandler) Callback(w http.ResponseWriter, r *http.Request) {
+ // Get state from cookie for CSRF check
+ stateCookie, err := r.Cookie("oidc_state")
+ if err != nil {
+ http.Error(w, "missing state cookie", http.StatusBadRequest)
+ return
+ }
+
+ // Verify state parameter matches
+ stateParam := r.URL.Query().Get("state")
+ if stateParam == "" || stateParam != stateCookie.Value {
+ http.Error(w, "state mismatch", http.StatusForbidden)
+ return
+ }
+
+ // Clear the state cookie
+ http.SetCookie(w, &http.Cookie{
+ Name: "oidc_state",
+ Value: "",
+ Path: "/",
+ MaxAge: -1,
+ HttpOnly: true,
+ })
+
+ code := r.URL.Query().Get("code")
+ if code == "" {
+ http.Error(w, "missing authorization code", http.StatusBadRequest)
+ return
+ }
+
+ // TODO: Exchange code for tokens using Authelia's token endpoint.
+ // For now, we create a session with the authorization code as a placeholder.
+ // In production, you would:
+ // 1. POST to /api/oidc/token with the code
+ // 2. Validate the ID token
+ // 3. Extract the user's subject (sub) claim
+ // 4. Create a session with that subject
+
+ username := r.URL.Query().Get("sub")
+ if username == "" {
+ username = "authenticated-user" // placeholder until token exchange
+ }
+
+ token, err := h.store.CreateSession(username, 60)
+ if err != nil {
+ http.Error(w, "session creation failed", http.StatusInternalServerError)
+ return
+ }
+
+ // Set session cookie
+ http.SetCookie(w, &http.Cookie{
+ Name: "nextwks_session",
+ Value: token,
+ Path: "/",
+ MaxAge: 3600, // 1 hour
+ HttpOnly: true,
+ SameSite: http.SameSiteStrictMode,
+ })
+
+ // Redirect to the workspace
+ http.Redirect(w, r, "/", http.StatusFound)
+}
+
+// AuthGateMiddleware protects routes behind OIDC authentication.
+// If the user has no valid session, redirect to Authelia login.
+func (h *OIDCHandler) AuthGateMiddleware(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ _, ok := GetUserID(r)
+ if !ok {
+ // Not authenticated — redirect to login
+ h.LoginRedirect(w, r)
+ return
+ }
+ next.ServeHTTP(w, r)
+ })
+}
diff --git a/src/core/auth/session.go b/src/core/auth/session.go
new file mode 100644
index 0000000..fedf88a
--- /dev/null
+++ b/src/core/auth/session.go
@@ -0,0 +1,145 @@
+package auth
+
+import (
+ "context"
+ "crypto/rand"
+ "crypto/sha256"
+ "database/sql"
+ "encoding/hex"
+ "fmt"
+ "net/http"
+ "time"
+)
+
+// contextKey is used for storing values in request context.
+type contextKey string
+
+const (
+ // ContextUserID is the key for the authenticated user's ID.
+ ContextUserID contextKey = "user_id"
+)
+
+// SessionStore manages user sessions backed by SQLite.
+type SessionStore struct {
+ db *sql.DB
+}
+
+// NewSessionStore creates a session store.
+func NewSessionStore(db *sql.DB) *SessionStore {
+ return &SessionStore{db: db}
+}
+
+// Session represents an authenticated user session.
+type Session struct {
+ ID string
+ UserID string
+ CreatedAt time.Time
+ ExpiresAt time.Time
+}
+
+// CreateSession generates a new session for a user and returns the token.
+func (s *SessionStore) CreateSession(userID string, expiryMinutes int) (string, error) {
+ token := generateToken(32)
+ tokenHash := hashToken(token)
+
+ _, err := s.db.Exec(
+ `INSERT INTO sessions (id, user_id, token_hash, created_at, expires_at)
+ VALUES (?, ?, ?, datetime('now'), datetime('now', '+' || ? || ' minutes'))`,
+ token[:16], userID, tokenHash, expiryMinutes,
+ )
+ if err != nil {
+ return "", fmt.Errorf("create session: %w", err)
+ }
+
+ return token, nil
+}
+
+// ValidateSession checks if a session token is valid and returns the session.
+func (s *SessionStore) ValidateSession(token string) (*Session, error) {
+ tokenHash := hashToken(token)
+
+ var sess Session
+ var createdAt, expiresAt string
+ err := s.db.QueryRow(
+ `SELECT id, user_id, created_at, expires_at
+ FROM sessions
+ WHERE token_hash = ? AND expires_at > datetime('now')`,
+ tokenHash,
+ ).Scan(&sess.ID, &sess.UserID, &createdAt, &expiresAt)
+
+ if err == sql.ErrNoRows {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, fmt.Errorf("validate session: %w", err)
+ }
+
+ sess.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
+ sess.ExpiresAt, _ = time.Parse("2006-01-02 15:04:05", expiresAt)
+
+ return &sess, nil
+}
+
+// DeleteSession removes a session (logout).
+func (s *SessionStore) DeleteSession(token string) error {
+ tokenHash := hashToken(token)
+ _, err := s.db.Exec("DELETE FROM sessions WHERE token_hash = ?", tokenHash)
+ return err
+}
+
+// CleanExpired removes all expired sessions.
+func (s *SessionStore) CleanExpired() error {
+ _, err := s.db.Exec("DELETE FROM sessions WHERE expires_at <= datetime('now')")
+ return err
+}
+
+// SessionMiddleware returns an HTTP middleware that validates session cookies.
+// If valid, the user_id is stored in the request context.
+func (s *SessionStore) SessionMiddleware(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ cookie, err := r.Cookie("nextwks_session")
+ if err != nil {
+ // No cookie — pass through without session
+ next.ServeHTTP(w, r)
+ return
+ }
+
+ session, err := s.ValidateSession(cookie.Value)
+ if err != nil || session == nil {
+ // Invalid or expired — clear cookie and continue
+ http.SetCookie(w, &http.Cookie{
+ Name: "nextwks_session",
+ Value: "",
+ Path: "/",
+ MaxAge: -1,
+ HttpOnly: true,
+ SameSite: http.SameSiteStrictMode,
+ })
+ next.ServeHTTP(w, r)
+ return
+ }
+
+ // Set user_id in context
+ ctx := context.WithValue(r.Context(), ContextUserID, session.UserID)
+ next.ServeHTTP(w, r.WithContext(ctx))
+ })
+}
+
+// GetUserID retrieves the authenticated user ID from the request context.
+func GetUserID(r *http.Request) (string, bool) {
+ uid, ok := r.Context().Value(ContextUserID).(string)
+ return uid, ok
+}
+
+// generateToken creates a cryptographically secure random hex token.
+func generateToken(length int) string {
+ b := make([]byte, length)
+ rand.Read(b)
+ return hex.EncodeToString(b)
+}
+
+// hashToken creates a SHA-256 hash of a token for storage.
+func hashToken(token string) string {
+ h := sha256.Sum256([]byte(token))
+ return hex.EncodeToString(h[:])
+}
diff --git a/src/core/config/config.go b/src/core/config/config.go
index 2d81ecb..c977b8b 100644
--- a/src/core/config/config.go
+++ b/src/core/config/config.go
@@ -13,6 +13,7 @@ type Config struct {
Admin AdminConfig `yaml:"admin"`
Database DatabaseConfig `yaml:"database"`
Authelia AutheliaConfig `yaml:"authelia"`
+ OIDC OIDCConfig `yaml:"oidc"`
SMTP SMTPConfig `yaml:"smtp"`
Session SessionConfig `yaml:"session"`
}
@@ -37,6 +38,14 @@ type AutheliaConfig struct {
UsersDBPath string `yaml:"users_db_path"`
}
+// OIDCConfig holds the OIDC provider settings (Authelia).
+type OIDCConfig struct {
+ ClientID string `yaml:"client_id"`
+ ClientSecret string `yaml:"client_secret"`
+ RedirectURL string `yaml:"redirect_url"`
+ Domain string `yaml:"domain"`
+}
+
type SMTPConfig struct {
Host string `yaml:"host"`
Port int `yaml:"port"`
diff --git a/src/core/ui/app-grid.templ b/src/core/ui/app-grid.templ
new file mode 100644
index 0000000..656aa34
--- /dev/null
+++ b/src/core/ui/app-grid.templ
@@ -0,0 +1,107 @@
+package ui
+
+import "fmt"
+
+// AppTile represents an application card on the workspace launcher.
+type AppTile struct {
+ Name string
+ Description string
+ URL string
+ Icon string // Emoji or SVG
+ Color string // Background color for icon
+ Status string // "ready", "coming-soon", "beta"
+}
+
+// DefaultApps returns the default set of workspace apps.
+// These are placeholder tiles until supervisor modules are built.
+func DefaultApps() []AppTile {
+ return []AppTile{
+ {
+ Name: "Admin Panel",
+ Description: "Manage users, groups, and workspace settings",
+ URL: "/admin",
+ Icon: "⚙️",
+ Color: "#1e293b",
+ Status: "ready",
+ },
+ {
+ Name: "Files",
+ Description: "Coming soon — File storage and sharing",
+ URL: "#",
+ Icon: "📁",
+ Color: "#1e293b",
+ Status: "coming-soon",
+ },
+ {
+ Name: "Calendar",
+ Description: "Coming soon — Schedule and events",
+ URL: "#",
+ Icon: "📅",
+ Color: "#1e293b",
+ Status: "coming-soon",
+ },
+ {
+ Name: "Mail",
+ Description: "Coming soon — Email integration",
+ URL: "#",
+ Icon: "✉️",
+ Color: "#1e293b",
+ Status: "coming-soon",
+ },
+ {
+ Name: "Office",
+ Description: "Coming soon — Documents and spreadsheets",
+ URL: "#",
+ Icon: "📝",
+ Color: "#1e293b",
+ Status: "coming-soon",
+ },
+ {
+ Name: "Settings",
+ Description: "Coming soon — Workspace preferences",
+ URL: "#",
+ Icon: "🔧",
+ Color: "#1e293b",
+ Status: "coming-soon",
+ },
+ }
+}
+
+templ AppGrid(apps []AppTile) {
+
+
+ Applications
+
+
+ for _, app := range apps {
+ @appCard(app)
+ }
+
+
+}
+
+templ appCard(app AppTile) {
+ if app.Status == "coming-soon" {
+
+
+ { app.Icon }
+
+ { app.Name }
+ { app.Description }
+
+ ● Coming Soon
+
+
+ } else {
+
+
+ { app.Icon }
+
+ { app.Name }
+ { app.Description }
+
+ ● Available
+
+
+ }
+}
diff --git a/src/core/ui/app-grid_templ.go b/src/core/ui/app-grid_templ.go
new file mode 100644
index 0000000..8d9d779
--- /dev/null
+++ b/src/core/ui/app-grid_templ.go
@@ -0,0 +1,270 @@
+// Code generated by templ - DO NOT EDIT.
+
+// templ: version: v0.3.1020
+package ui
+
+//lint:file-ignore SA4006 This context is only used if a nested component is present.
+
+import "github.com/a-h/templ"
+import templruntime "github.com/a-h/templ/runtime"
+
+import "fmt"
+
+// AppTile represents an application card on the workspace launcher.
+type AppTile struct {
+ Name string
+ Description string
+ URL string
+ Icon string // Emoji or SVG
+ Color string // Background color for icon
+ Status string // "ready", "coming-soon", "beta"
+}
+
+// DefaultApps returns the default set of workspace apps.
+// These are placeholder tiles until supervisor modules are built.
+func DefaultApps() []AppTile {
+ return []AppTile{
+ {
+ Name: "Admin Panel",
+ Description: "Manage users, groups, and workspace settings",
+ URL: "/admin",
+ Icon: "⚙️",
+ Color: "#1e293b",
+ Status: "ready",
+ },
+ {
+ Name: "Files",
+ Description: "Coming soon — File storage and sharing",
+ URL: "#",
+ Icon: "📁",
+ Color: "#1e293b",
+ Status: "coming-soon",
+ },
+ {
+ Name: "Calendar",
+ Description: "Coming soon — Schedule and events",
+ URL: "#",
+ Icon: "📅",
+ Color: "#1e293b",
+ Status: "coming-soon",
+ },
+ {
+ Name: "Mail",
+ Description: "Coming soon — Email integration",
+ URL: "#",
+ Icon: "✉️",
+ Color: "#1e293b",
+ Status: "coming-soon",
+ },
+ {
+ Name: "Office",
+ Description: "Coming soon — Documents and spreadsheets",
+ URL: "#",
+ Icon: "📝",
+ Color: "#1e293b",
+ Status: "coming-soon",
+ },
+ {
+ Name: "Settings",
+ Description: "Coming soon — Workspace preferences",
+ URL: "#",
+ Icon: "🔧",
+ Color: "#1e293b",
+ Status: "coming-soon",
+ },
+ }
+}
+
+func AppGrid(apps []AppTile) templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+ return templ_7745c5c3_CtxErr
+ }
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var1 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var1 == nil {
+ templ_7745c5c3_Var1 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "Applications
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ for _, app := range apps {
+ templ_7745c5c3_Err = appCard(app).Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return nil
+ })
+}
+
+func appCard(app AppTile) templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+ return templ_7745c5c3_CtxErr
+ }
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var2 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var2 == nil {
+ templ_7745c5c3_Var2 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ if app.Status == "coming-soon" {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var4 string
+ templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(app.Icon)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/ui/app-grid.templ`, Line: 87, Col: 14}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var5 string
+ templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(app.Name)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/ui/app-grid.templ`, Line: 89, Col: 35}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var6 string
+ templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(app.Description)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/ui/app-grid.templ`, Line: 90, Col: 42}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "
● Coming Soon
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var9 string
+ templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(app.Icon)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/ui/app-grid.templ`, Line: 98, Col: 14}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var10 string
+ templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(app.Name)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/ui/app-grid.templ`, Line: 100, Col: 35}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var11 string
+ templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(app.Description)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/ui/app-grid.templ`, Line: 101, Col: 42}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "
● Available
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ return nil
+ })
+}
+
+var _ = templruntime.GeneratedTemplate
diff --git a/src/core/ui/handler.go b/src/core/ui/handler.go
new file mode 100644
index 0000000..b765bc4
--- /dev/null
+++ b/src/core/ui/handler.go
@@ -0,0 +1,46 @@
+package ui
+
+import (
+ "net/http"
+ "path/filepath"
+)
+
+// Handler serves the workspace launcher UI.
+type Handler struct {
+ appDir string
+}
+
+// NewHandler creates a UI handler that serves the launcher and static assets.
+func NewHandler(appDir string) *Handler {
+ return &Handler{
+ appDir: appDir,
+ }
+}
+
+// RegisterRoutes mounts the public UI routes on the given mux.
+func (h *Handler) RegisterRoutes(mux *http.ServeMux, authGate func(http.Handler) http.Handler) {
+ // Static assets (manifest.json, sw.js, icons)
+ staticDir := filepath.Join(h.appDir, "static")
+ staticHandler := http.FileServer(http.Dir(staticDir))
+ mux.Handle("GET /static/", staticHandler)
+
+ // Launcher page — protected by OIDC auth gate
+ mux.Handle("GET /", authGate(http.HandlerFunc(h.launcherPage)))
+}
+
+// launcherPage renders the main workspace landing page.
+func (h *Handler) launcherPage(w http.ResponseWriter, r *http.Request) {
+ // Only handle root path, not all paths
+ if r.URL.Path != "/" {
+ http.NotFound(w, r)
+ return
+ }
+
+ // Get user info from session (set by auth middleware)
+ // For now, render without user name (OIDC provides this later)
+ userName := ""
+
+ apps := DefaultApps()
+ component := LauncherPage(userName, apps)
+ component.Render(r.Context(), w)
+}
diff --git a/src/core/ui/launcher.templ b/src/core/ui/launcher.templ
new file mode 100644
index 0000000..bfbe2a8
--- /dev/null
+++ b/src/core/ui/launcher.templ
@@ -0,0 +1,245 @@
+package ui
+
+import "fmt"
+
+templ LauncherPage(userName string, apps []AppTile) {
+
+
+
+
+
+ Next Workspace
+
+
+
+
+
+
+
+
+
+ @headerBar(userName)
+
+
+ @greetingHeading(userName)
+
Your workspace is ready
+
+ @AppGrid(apps)
+ @PWAInstallPrompt()
+
+
+
+
+
+
+
+
+
+}
+
+templ greetingHeading(userName string) {
+
+ if userName != "" {
+ { fmt.Sprintf("Welcome, %s", userName) }
+ } else {
+ { "Welcome" }
+ }
+
+}
+
+templ headerBar(userName string) {
+
+}
+
+templ workspaceStyles() {
+
+}
diff --git a/src/core/ui/launcher_templ.go b/src/core/ui/launcher_templ.go
new file mode 100644
index 0000000..5f3cc6a
--- /dev/null
+++ b/src/core/ui/launcher_templ.go
@@ -0,0 +1,192 @@
+// Code generated by templ - DO NOT EDIT.
+
+// templ: version: v0.3.1020
+package ui
+
+//lint:file-ignore SA4006 This context is only used if a nested component is present.
+
+import "github.com/a-h/templ"
+import templruntime "github.com/a-h/templ/runtime"
+
+import "fmt"
+
+func LauncherPage(userName string, apps []AppTile) templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+ return templ_7745c5c3_CtxErr
+ }
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var1 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var1 == nil {
+ templ_7745c5c3_Var1 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "Next Workspace")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = headerBar(userName).Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = greetingHeading(userName).Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "
Your workspace is ready
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = AppGrid(apps).Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = PWAInstallPrompt().Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, " ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return nil
+ })
+}
+
+func greetingHeading(userName string) templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+ return templ_7745c5c3_CtxErr
+ }
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var2 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var2 == nil {
+ templ_7745c5c3_Var2 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if userName != "" {
+ var templ_7745c5c3_Var3 string
+ templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("Welcome, %s", userName))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/ui/launcher.templ`, Line: 73, Col: 41}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else {
+ var templ_7745c5c3_Var4 string
+ templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs("Welcome")
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/ui/launcher.templ`, Line: 75, Col: 14}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return nil
+ })
+}
+
+func headerBar(userName string) templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+ return templ_7745c5c3_CtxErr
+ }
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var5 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var5 == nil {
+ templ_7745c5c3_Var5 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return nil
+ })
+}
+
+func workspaceStyles() templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+ return templ_7745c5c3_CtxErr
+ }
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var6 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var6 == nil {
+ templ_7745c5c3_Var6 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return nil
+ })
+}
+
+var _ = templruntime.GeneratedTemplate
diff --git a/src/core/ui/pwa-guide.templ b/src/core/ui/pwa-guide.templ
new file mode 100644
index 0000000..d478cd1
--- /dev/null
+++ b/src/core/ui/pwa-guide.templ
@@ -0,0 +1,46 @@
+package ui
+
+templ PWAInstallPrompt() {
+
+
🚀 Install Next Workspace
+
Install as an app for quick access and offline support.
+
+
+}
+
+templ PWAGuideModal() {
+
+
+
Install Next Workspace
+
+
Your browser didn't show an automatic install prompt. Use the instructions below for your device.
+
+
🖥️ Desktop Chrome/Edge
+
+ - Click the install icon
⊕ in the address bar (right side)
+ - Click Install in the popup
+ - The app will open in its own window
+
+
+
📱 iOS Safari
+
+ - Tap the Share button
📤 at the bottom of the screen
+ - Scroll down and tap Add to Home Screen
+ - Tap Add in the top-right corner
+ - The app icon will appear on your home screen
+
+
+
🤖 Android Chrome
+
+ - Tap the menu icon
⋮ (three dots)
+ - Tap Install app or Add to Home screen
+ - Tap Install
+
+
+
+
+}
diff --git a/src/core/ui/pwa-guide_templ.go b/src/core/ui/pwa-guide_templ.go
new file mode 100644
index 0000000..7230973
--- /dev/null
+++ b/src/core/ui/pwa-guide_templ.go
@@ -0,0 +1,69 @@
+// Code generated by templ - DO NOT EDIT.
+
+// templ: version: v0.3.1020
+package ui
+
+//lint:file-ignore SA4006 This context is only used if a nested component is present.
+
+import "github.com/a-h/templ"
+import templruntime "github.com/a-h/templ/runtime"
+
+func PWAInstallPrompt() templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+ return templ_7745c5c3_CtxErr
+ }
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var1 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var1 == nil {
+ templ_7745c5c3_Var1 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "🚀 Install Next Workspace
Install as an app for quick access and offline support.
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return nil
+ })
+}
+
+func PWAGuideModal() templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+ return templ_7745c5c3_CtxErr
+ }
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var2 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var2 == nil {
+ templ_7745c5c3_Var2 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "Install Next Workspace
Your browser didn't show an automatic install prompt. Use the instructions below for your device.
🖥️ Desktop Chrome/Edge
- Click the install icon
⊕ in the address bar (right side) - Click Install in the popup
- The app will open in its own window
📱 iOS Safari
- Tap the Share button
📤 at the bottom of the screen - Scroll down and tap Add to Home Screen
- Tap Add in the top-right corner
- The app icon will appear on your home screen
🤖 Android Chrome
- Tap the menu icon
⋮ (three dots) - Tap Install app or Add to Home screen
- Tap Install
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return nil
+ })
+}
+
+var _ = templruntime.GeneratedTemplate
diff --git a/src/main.go b/src/main.go
index c7f59de..aace6af 100644
--- a/src/main.go
+++ b/src/main.go
@@ -10,8 +10,10 @@ import (
"syscall"
"git.lohmar.co.uk/lexton-it/NextWks/core/admin"
+ "git.lohmar.co.uk/lexton-it/NextWks/core/auth"
"git.lohmar.co.uk/lexton-it/NextWks/core/config"
"git.lohmar.co.uk/lexton-it/NextWks/core/db"
+ "git.lohmar.co.uk/lexton-it/NextWks/core/ui"
)
func main() {
@@ -60,31 +62,70 @@ func main() {
// Create admin handler
adminHandler := admin.NewHandler(userStore, syncWriter)
+ // Initialize session store and OIDC auth
+ sessionStore := auth.NewSessionStore(database.DB)
+ oidcCfg := auth.OIDCConfig{
+ IssuerURL: cfg.Authelia.Host,
+ ClientID: cfg.OIDC.ClientID,
+ ClientSecret: cfg.OIDC.ClientSecret,
+ RedirectURL: cfg.OIDC.RedirectURL,
+ Domain: cfg.OIDC.Domain,
+ }
+ oidcHandler := auth.NewOIDCHandler(oidcCfg, sessionStore)
+
+ // Initialize launcher UI handler
+ uiHandler := ui.NewHandler(".")
+
// Setup HTTP router
mux := http.NewServeMux()
- // Public endpoints
+ // --- Public endpoints ---
mux.HandleFunc("GET /api/health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"status":"ok"}`))
})
- // Admin auth middleware
+ // --- OIDC auth routes (public) ---
+ mux.HandleFunc("GET /auth/login", oidcHandler.LoginRedirect)
+ mux.HandleFunc("GET /auth/callback", oidcHandler.Callback)
+ mux.HandleFunc("GET /auth/logout", func(w http.ResponseWriter, r *http.Request) {
+ // Clear session cookie
+ http.SetCookie(w, &http.Cookie{
+ Name: "nextwks_session",
+ Value: "",
+ Path: "/",
+ MaxAge: -1,
+ HttpOnly: true,
+ SameSite: http.SameSiteStrictMode,
+ })
+ http.Redirect(w, r, "/auth/login", http.StatusFound)
+ })
+
+ // --- Workspace launcher (public, but OIDC-protected) ---
+ sessionMiddleware := sessionStore.SessionMiddleware
+ authGate := oidcHandler.AuthGateMiddleware
+ uiHandler.RegisterRoutes(mux, authGate)
+ _ = sessionMiddleware // Used for session-aware middleware in future
+
+ // --- Admin routes (protected by bearer token) ---
adminAuth := admin.TokenAuthMiddleware(cfg.Admin.SecretToken)
-
- // Admin API routes (JSON, protected by bearer token)
adminHandler.RegisterRoutes(mux, adminAuth)
-
- // Admin UI routes (Templ-rendered HTML, protected by bearer token)
adminHandler.RegisterUIRoutes(mux, adminAuth)
-
- // Admin HTMX routes (HTML partials for dynamic updates, protected by bearer token)
adminHandler.RegisterHTMXRoutes(mux, adminAuth)
- // Ensure admin API list endpoint is accessible via the specific path required by HTMX
- // (Already handled via RegisterRoutes)
+ // --- OIDC config page — shows Authelia status ---
+ mux.HandleFunc("GET /auth/status", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ fmt.Fprintf(w, `{"provider":"Authelia","issuer":"%s","status":"configured"}`, cfg.Authelia.Host)
+ })
- // CORS middleware for admin API
+ // --- PWA Guide modal (HTMX fragment) ---
+ mux.HandleFunc("GET /pwa-guide", func(w http.ResponseWriter, r *http.Request) {
+ component := ui.PWAGuideModal()
+ component.Render(r.Context(), w)
+ })
+
+ // CORS middleware
handler := corsMiddleware(mux)
// Start server
@@ -104,8 +145,9 @@ func main() {
}()
logger.Info("server listening", "address", addr)
+ logger.Info("workspace launcher", "url", fmt.Sprintf("http://%s/", addr))
logger.Info("admin panel", "url", fmt.Sprintf("http://%s/admin", addr))
- logger.Info("admin api", "url", fmt.Sprintf("http://%s/admin/api/health", addr))
+ logger.Info("auth status", "url", fmt.Sprintf("http://%s/auth/status", addr))
if err := server.ListenAndServe(); err != http.ErrServerClosed {
logger.Error("server error", "error", err)
os.Exit(1)