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) { +
+
+ +
+
+ + if userName != "" { + + + + + + + + } +
+
+} + +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, "
NextWks
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if userName != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "
") + 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() { + +} 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

  1. Click the install icon in the address bar (right side)
  2. Click Install in the popup
  3. The app will open in its own window

📱 iOS Safari

  1. Tap the Share button 📤 at the bottom of the screen
  2. Scroll down and tap Add to Home Screen
  3. Tap Add in the top-right corner
  4. The app icon will appear on your home screen

🤖 Android Chrome

  1. Tap the menu icon (three dots)
  2. Tap Install app or Add to Home screen
  3. 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)