feat(ui): implement workspace launcher landing page, OIDC auth gate, and PWA shell

This commit is contained in:
Claus Lohmar 2026-06-14 13:20:34 +00:00
parent 65f00a336a
commit 61d339b940
17 changed files with 1431 additions and 12 deletions

View file

@ -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

View file

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="192" height="192" viewBox="0 0 192 192">
<rect width="192" height="192" rx="32" fill="#1e293b"/>
<rect x="32" y="32" width="128" height="128" rx="24" fill="#3b82f6"/>
<path d="M72 72 L120 72 M72 96 L104 96 M72 120 L88 120" stroke="#ffffff" stroke-width="8" stroke-linecap="round" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 354 B

View file

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
<rect width="512" height="512" rx="64" fill="#1e293b"/>
<rect x="96" y="96" width="320" height="320" rx="48" fill="#3b82f6"/>
<path d="M176 240 L336 240 M176 304 L288 304 M176 368 L224 368" stroke="#ffffff" stroke-width="16" stroke-linecap="round" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 363 B

27
app/static/manifest.json Normal file
View file

@ -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"
}

57
app/static/sw.js Normal file
View file

@ -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;
});
})
);
});

View file

@ -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

142
src/core/auth/oidc.go Normal file
View file

@ -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)
})
}

145
src/core/auth/session.go Normal file
View file

@ -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[:])
}

View file

@ -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"`

107
src/core/ui/app-grid.templ Normal file
View file

@ -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) {
<section>
<h2 style="font-size:1.125rem;font-weight:600;margin-bottom:1rem;">
Applications
</h2>
<div class="app-grid">
for _, app := range apps {
@appCard(app)
}
</div>
</section>
}
templ appCard(app AppTile) {
if app.Status == "coming-soon" {
<a href="#" class="app-card" style="opacity:0.6;cursor:default;" aria-disabled="true">
<div class="app-icon" style={ fmt.Sprintf("background:%s", app.Color) }>
{ app.Icon }
</div>
<div class="app-name">{ app.Name }</div>
<div class="app-desc">{ app.Description }</div>
<div class="app-badge">
<span style="color:#f59e0b;">● Coming Soon</span>
</div>
</a>
} else {
<a href={ templ.URL(app.URL) } class="app-card">
<div class="app-icon" style={ fmt.Sprintf("background:%s", app.Color) }>
{ app.Icon }
</div>
<div class="app-name">{ app.Name }</div>
<div class="app-desc">{ app.Description }</div>
<div class="app-badge">
<span style="color:#22c55e;">● Available</span>
</div>
</a>
}
}

View file

@ -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, "<section><h2 style=\"font-size:1.125rem;font-weight:600;margin-bottom:1rem;\">Applications</h2><div class=\"app-grid\">")
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, "</div></section>")
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, "<a href=\"#\" class=\"app-card\" style=\"opacity:0.6;cursor:default;\" aria-disabled=\"true\"><div class=\"app-icon\" style=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(fmt.Sprintf("background:%s", app.Color))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/ui/app-grid.templ`, Line: 86, Col: 72}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
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
}
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, "</div><div class=\"app-name\">")
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, "</div><div class=\"app-desc\">")
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, "</div><div class=\"app-badge\"><span style=\"color:#f59e0b;\">● Coming Soon</span></div></a>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 templ.SafeURL
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL(app.URL))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/ui/app-grid.templ`, Line: 96, Col: 30}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\" class=\"app-card\"><div class=\"app-icon\" style=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(fmt.Sprintf("background:%s", app.Color))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `core/ui/app-grid.templ`, Line: 97, Col: 72}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\">")
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, "</div><div class=\"app-name\">")
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, "</div><div class=\"app-desc\">")
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, "</div><div class=\"app-badge\"><span style=\"color:#22c55e;\">● Available</span></div></a>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
return nil
})
}
var _ = templruntime.GeneratedTemplate

46
src/core/ui/handler.go Normal file
View file

@ -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)
}

245
src/core/ui/launcher.templ Normal file
View file

@ -0,0 +1,245 @@
package ui
import "fmt"
templ LauncherPage(userName string, apps []AppTile) {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Next Workspace</title>
<link rel="manifest" href="/static/manifest.json"/>
<meta name="theme-color" content="#3b82f6"/>
<meta name="apple-mobile-web-app-capable" content="yes"/>
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"/>
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
<style>{ workspaceStyles() }</style>
</head>
<body>
<div class="workspace">
@headerBar(userName)
<main class="main">
<div class="greeting">
@greetingHeading(userName)
<p>Your workspace is ready</p>
</div>
@AppGrid(apps)
@PWAInstallPrompt()
</main>
</div>
<div id="pwa-modal" class="modal-overlay" style="display:none;"
hx-target="this" hx-swap="innerHTML">
</div>
<script>
// PWA install prompt handler
let deferredPrompt = null;
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault();
deferredPrompt = e;
document.getElementById('pwa-install-btn').style.display = 'inline-flex';
});
function installPWA() {
if (deferredPrompt) {
deferredPrompt.prompt();
deferredPrompt.userChoice.then(() => { deferredPrompt = null; });
} else {
htmx.ajax('GET', '/pwa-guide', { target: '#pwa-modal', swap: 'innerHTML' });
document.getElementById('pwa-modal').style.display = 'flex';
}
}
function closePWAModal() {
document.getElementById('pwa-modal').style.display = 'none';
}
// Close modal on overlay click
document.addEventListener('click', (e) => {
if (e.target.classList.contains('modal-overlay')) {
closePWAModal();
}
});
</script>
</body>
</html>
}
templ greetingHeading(userName string) {
<h1>
if userName != "" {
{ fmt.Sprintf("Welcome, %s", userName) }
} else {
{ "Welcome" }
}
</h1>
}
templ headerBar(userName string) {
<header class="header">
<div class="header-left">
<span class="logo">NextWks</span>
</div>
<div class="header-right">
<button id="pwa-install-btn" class="btn-icon" onclick="installPWA()" title="Install to Desktop" style="display:none;">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
</button>
if userName != "" {
<a href="/auth/logout" class="btn-icon" title="Sign out">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
<polyline points="16 17 21 12 16 7"/>
<line x1="21" y1="12" x2="9" y2="12"/>
</svg>
</a>
}
</div>
</header>
}
templ workspaceStyles() {
<style type="text/css">
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0f172a;
--surface: #1e293b;
--surface-2: #334155;
--border: #475569;
--text: #f1f5f9;
--text-muted: #94a3b8;
--primary: #3b82f6;
--primary-hover: #2563eb;
--radius: 12px;
}
html { font-size: 14px; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
}
.workspace { display: flex; flex-direction: column; min-height: 100vh; }
.header {
display: flex; justify-content: space-between; align-items: center;
padding: 0.75rem 1.5rem;
background: var(--surface);
border-bottom: 1px solid var(--border);
}
.logo { font-size: 1.25rem; font-weight: 700; color: var(--primary); }
.header-right { display: flex; gap: 0.5rem; align-items: center; }
.btn-icon {
display: inline-flex; align-items: center; justify-content: center;
width: 36px; height: 36px;
border: none; border-radius: 8px;
background: transparent; color: var(--text-muted);
cursor: pointer; transition: all 0.15s;
}
.btn-icon:hover { background: var(--surface-2); color: var(--text); }
.main {
flex: 1;
max-width: 1200px;
width: 100%;
margin: 0 auto;
padding: 2rem 1.5rem;
}
.greeting { margin-bottom: 2rem; }
.greeting h1 { font-size: 1.75rem; font-weight: 700; margin-bottom: 0.25rem; }
.greeting p { color: var(--text-muted); font-size: 1rem; }
.app-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}
.app-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.5rem;
transition: all 0.15s;
cursor: pointer;
text-decoration: none;
color: inherit;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.app-card:hover {
border-color: var(--primary);
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(0,0,0,0.2);
}
.app-card .app-icon {
width: 48px; height: 48px;
border-radius: 12px;
display: flex; align-items: center; justify-content: center;
font-size: 1.25rem;
}
.app-card .app-name { font-size: 1rem; font-weight: 600; }
.app-card .app-desc { font-size: 0.875rem; color: var(--text-muted); line-height: 1.4; }
.app-card .app-badge {
font-size: 0.75rem;
color: var(--text-muted);
margin-top: auto;
padding-top: 0.5rem;
}
.pwa-prompt {
background: linear-gradient(135deg, var(--surface), var(--surface-2));
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.5rem;
text-align: center;
}
.pwa-prompt h3 { margin-bottom: 0.5rem; }
.pwa-prompt p { color: var(--text-muted); margin-bottom: 1rem; font-size: 0.875rem; }
.btn {
display: inline-flex; align-items: center;
padding: 0.625rem 1.25rem;
border: none; border-radius: 8px;
cursor: pointer; font-size: 0.875rem; font-weight: 500;
transition: background 0.15s;
text-decoration: none;
}
.btn-primary { background: var(--primary); color: white; }
.btn-primary:hover { background: var(--primary-hover); }
.modal-overlay {
position: fixed; inset: 0;
background: rgba(0,0,0,0.6);
display: flex; align-items: center; justify-content: center;
z-index: 1000;
}
.modal {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 2rem;
max-width: 480px;
width: 90%;
max-height: 80vh;
overflow-y: auto;
}
.modal h2 { margin-bottom: 1rem; }
.modal p { color: var(--text-muted); margin-bottom: 1rem; font-size: 0.875rem; }
.modal ol { margin-left: 1.25rem; margin-bottom: 1rem; }
.modal li { margin-bottom: 0.5rem; font-size: 0.875rem; color: var(--text); }
.modal code {
background: var(--bg);
padding: 0.125rem 0.375rem;
border-radius: 4px;
font-size: 0.8125rem;
}
.modal-close {
float: right;
background: none; border: none;
color: var(--text-muted); cursor: pointer;
font-size: 1.25rem;
}
.modal-close:hover { color: var(--text); }
</style>
}

View file

@ -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, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Next Workspace</title><link rel=\"manifest\" href=\"/static/manifest.json\"><meta name=\"theme-color\" content=\"#3b82f6\"><meta name=\"apple-mobile-web-app-capable\" content=\"yes\"><meta name=\"apple-mobile-web-app-status-bar-style\" content=\"black-translucent\"><script src=\"https://unpkg.com/htmx.org@2.0.4\"></script><style>{ workspaceStyles() }</style></head><body><div class=\"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, "<main class=\"main\"><div class=\"greeting\">")
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, "<p>Your workspace is ready</p></div>")
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, "</main></div><div id=\"pwa-modal\" class=\"modal-overlay\" style=\"display:none;\" hx-target=\"this\" hx-swap=\"innerHTML\"></div><script>\n\t\t\t\t// PWA install prompt handler\n\t\t\t\tlet deferredPrompt = null;\n\t\t\t\twindow.addEventListener('beforeinstallprompt', (e) => {\n\t\t\t\t\te.preventDefault();\n\t\t\t\t\tdeferredPrompt = e;\n\t\t\t\t\tdocument.getElementById('pwa-install-btn').style.display = 'inline-flex';\n\t\t\t\t});\n\n\t\t\t\tfunction installPWA() {\n\t\t\t\t\tif (deferredPrompt) {\n\t\t\t\t\t\tdeferredPrompt.prompt();\n\t\t\t\t\t\tdeferredPrompt.userChoice.then(() => { deferredPrompt = null; });\n\t\t\t\t\t} else {\n\t\t\t\t\t\thtmx.ajax('GET', '/pwa-guide', { target: '#pwa-modal', swap: 'innerHTML' });\n\t\t\t\t\t\tdocument.getElementById('pwa-modal').style.display = 'flex';\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tfunction closePWAModal() {\n\t\t\t\t\tdocument.getElementById('pwa-modal').style.display = 'none';\n\t\t\t\t}\n\n\t\t\t\t// Close modal on overlay click\n\t\t\t\tdocument.addEventListener('click', (e) => {\n\t\t\t\t\tif (e.target.classList.contains('modal-overlay')) {\n\t\t\t\t\t\tclosePWAModal();\n\t\t\t\t\t}\n\t\t\t\t});\n\t\t\t</script></body></html>")
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, "<h1>")
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, "</h1>")
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, "<header class=\"header\"><div class=\"header-left\"><span class=\"logo\">NextWks</span></div><div class=\"header-right\"><button id=\"pwa-install-btn\" class=\"btn-icon\" onclick=\"installPWA()\" title=\"Install to Desktop\" style=\"display:none;\"><svg width=\"20\" height=\"20\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path d=\"M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4\"></path> <polyline points=\"7 10 12 15 17 10\"></polyline> <line x1=\"12\" y1=\"15\" x2=\"12\" y2=\"3\"></line></svg></button> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if userName != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<a href=\"/auth/logout\" class=\"btn-icon\" title=\"Sign out\"><svg width=\"20\" height=\"20\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path d=\"M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4\"></path> <polyline points=\"16 17 21 12 16 7\"></polyline> <line x1=\"21\" y1=\"12\" x2=\"9\" y2=\"12\"></line></svg></a>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</div></header>")
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, "<style type=\"text/css\">\n\t\t*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }\n\t\t:root {\n\t\t\t--bg: #0f172a;\n\t\t\t--surface: #1e293b;\n\t\t\t--surface-2: #334155;\n\t\t\t--border: #475569;\n\t\t\t--text: #f1f5f9;\n\t\t\t--text-muted: #94a3b8;\n\t\t\t--primary: #3b82f6;\n\t\t\t--primary-hover: #2563eb;\n\t\t\t--radius: 12px;\n\t\t}\n\t\thtml { font-size: 14px; }\n\t\tbody {\n\t\t\tfont-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;\n\t\t\tbackground: var(--bg);\n\t\t\tcolor: var(--text);\n\t\t\tmin-height: 100vh;\n\t\t}\n\t\t.workspace { display: flex; flex-direction: column; min-height: 100vh; }\n\t\t.header {\n\t\t\tdisplay: flex; justify-content: space-between; align-items: center;\n\t\t\tpadding: 0.75rem 1.5rem;\n\t\t\tbackground: var(--surface);\n\t\t\tborder-bottom: 1px solid var(--border);\n\t\t}\n\t\t.logo { font-size: 1.25rem; font-weight: 700; color: var(--primary); }\n\t\t.header-right { display: flex; gap: 0.5rem; align-items: center; }\n\t\t.btn-icon {\n\t\t\tdisplay: inline-flex; align-items: center; justify-content: center;\n\t\t\twidth: 36px; height: 36px;\n\t\t\tborder: none; border-radius: 8px;\n\t\t\tbackground: transparent; color: var(--text-muted);\n\t\t\tcursor: pointer; transition: all 0.15s;\n\t\t}\n\t\t.btn-icon:hover { background: var(--surface-2); color: var(--text); }\n\t\t.main {\n\t\t\tflex: 1;\n\t\t\tmax-width: 1200px;\n\t\t\twidth: 100%;\n\t\t\tmargin: 0 auto;\n\t\t\tpadding: 2rem 1.5rem;\n\t\t}\n\t\t.greeting { margin-bottom: 2rem; }\n\t\t.greeting h1 { font-size: 1.75rem; font-weight: 700; margin-bottom: 0.25rem; }\n\t\t.greeting p { color: var(--text-muted); font-size: 1rem; }\n\t\t.app-grid {\n\t\t\tdisplay: grid;\n\t\t\tgrid-template-columns: repeat(auto-fill, minmax(240px, 1fr));\n\t\t\tgap: 1rem;\n\t\t\tmargin-bottom: 2rem;\n\t\t}\n\t\t.app-card {\n\t\t\tbackground: var(--surface);\n\t\t\tborder: 1px solid var(--border);\n\t\t\tborder-radius: var(--radius);\n\t\t\tpadding: 1.5rem;\n\t\t\ttransition: all 0.15s;\n\t\t\tcursor: pointer;\n\t\t\ttext-decoration: none;\n\t\t\tcolor: inherit;\n\t\t\tdisplay: flex;\n\t\t\tflex-direction: column;\n\t\t\tgap: 0.75rem;\n\t\t}\n\t\t.app-card:hover {\n\t\t\tborder-color: var(--primary);\n\t\t\ttransform: translateY(-2px);\n\t\t\tbox-shadow: 0 8px 24px rgba(0,0,0,0.2);\n\t\t}\n\t\t.app-card .app-icon {\n\t\t\twidth: 48px; height: 48px;\n\t\t\tborder-radius: 12px;\n\t\t\tdisplay: flex; align-items: center; justify-content: center;\n\t\t\tfont-size: 1.25rem;\n\t\t}\n\t\t.app-card .app-name { font-size: 1rem; font-weight: 600; }\n\t\t.app-card .app-desc { font-size: 0.875rem; color: var(--text-muted); line-height: 1.4; }\n\t\t.app-card .app-badge {\n\t\t\tfont-size: 0.75rem;\n\t\t\tcolor: var(--text-muted);\n\t\t\tmargin-top: auto;\n\t\t\tpadding-top: 0.5rem;\n\t\t}\n\t\t.pwa-prompt {\n\t\t\tbackground: linear-gradient(135deg, var(--surface), var(--surface-2));\n\t\t\tborder: 1px solid var(--border);\n\t\t\tborder-radius: var(--radius);\n\t\t\tpadding: 1.5rem;\n\t\t\ttext-align: center;\n\t\t}\n\t\t.pwa-prompt h3 { margin-bottom: 0.5rem; }\n\t\t.pwa-prompt p { color: var(--text-muted); margin-bottom: 1rem; font-size: 0.875rem; }\n\t\t.btn {\n\t\t\tdisplay: inline-flex; align-items: center;\n\t\t\tpadding: 0.625rem 1.25rem;\n\t\t\tborder: none; border-radius: 8px;\n\t\t\tcursor: pointer; font-size: 0.875rem; font-weight: 500;\n\t\t\ttransition: background 0.15s;\n\t\t\ttext-decoration: none;\n\t\t}\n\t\t.btn-primary { background: var(--primary); color: white; }\n\t\t.btn-primary:hover { background: var(--primary-hover); }\n\t\t.modal-overlay {\n\t\t\tposition: fixed; inset: 0;\n\t\t\tbackground: rgba(0,0,0,0.6);\n\t\t\tdisplay: flex; align-items: center; justify-content: center;\n\t\t\tz-index: 1000;\n\t\t}\n\t\t.modal {\n\t\t\tbackground: var(--surface);\n\t\t\tborder: 1px solid var(--border);\n\t\t\tborder-radius: var(--radius);\n\t\t\tpadding: 2rem;\n\t\t\tmax-width: 480px;\n\t\t\twidth: 90%;\n\t\t\tmax-height: 80vh;\n\t\t\toverflow-y: auto;\n\t\t}\n\t\t.modal h2 { margin-bottom: 1rem; }\n\t\t.modal p { color: var(--text-muted); margin-bottom: 1rem; font-size: 0.875rem; }\n\t\t.modal ol { margin-left: 1.25rem; margin-bottom: 1rem; }\n\t\t.modal li { margin-bottom: 0.5rem; font-size: 0.875rem; color: var(--text); }\n\t\t.modal code {\n\t\t\tbackground: var(--bg);\n\t\t\tpadding: 0.125rem 0.375rem;\n\t\t\tborder-radius: 4px;\n\t\t\tfont-size: 0.8125rem;\n\t\t}\n\t\t.modal-close {\n\t\t\tfloat: right;\n\t\t\tbackground: none; border: none;\n\t\t\tcolor: var(--text-muted); cursor: pointer;\n\t\t\tfont-size: 1.25rem;\n\t\t}\n\t\t.modal-close:hover { color: var(--text); }\n\t</style>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate

View file

@ -0,0 +1,46 @@
package ui
templ PWAInstallPrompt() {
<div class="pwa-prompt" id="pwa-prompt">
<h3>🚀 Install Next Workspace</h3>
<p>Install as an app for quick access and offline support.</p>
<button class="btn btn-primary" onclick="installPWA()">
Install to Desktop
</button>
</div>
}
templ PWAGuideModal() {
<div class="modal" onclick="event.stopPropagation()">
<button class="modal-close" onclick="closePWAModal()">&times;</button>
<h2>Install Next Workspace</h2>
<p>Your browser didn't show an automatic install prompt. Use the instructions below for your device.</p>
<h3 style="margin-bottom:0.5rem;font-size:0.875rem;">🖥️ Desktop Chrome/Edge</h3>
<ol>
<li>Click the <strong>install icon</strong> <code>⊕</code> in the address bar (right side)</li>
<li>Click <strong>Install</strong> in the popup</li>
<li>The app will open in its own window</li>
</ol>
<h3 style="margin-bottom:0.5rem;font-size:0.875rem;margin-top:1rem;">📱 iOS Safari</h3>
<ol>
<li>Tap the <strong>Share button</strong> <code>📤</code> at the bottom of the screen</li>
<li>Scroll down and tap <strong>Add to Home Screen</strong></li>
<li>Tap <strong>Add</strong> in the top-right corner</li>
<li>The app icon will appear on your home screen</li>
</ol>
<h3 style="margin-bottom:0.5rem;font-size:0.875rem;margin-top:1rem;">🤖 Android Chrome</h3>
<ol>
<li>Tap the <strong>menu icon</strong> <code>⋮</code> (three dots)</li>
<li>Tap <strong>Install app</strong> or <strong>Add to Home screen</strong></li>
<li>Tap <strong>Install</strong></li>
</ol>
<button class="btn btn-primary" style="margin-top:1rem;width:100%;" onclick="closePWAModal()">
Got it
</button>
</div>
}

View file

@ -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, "<div class=\"pwa-prompt\" id=\"pwa-prompt\"><h3>🚀 Install Next Workspace</h3><p>Install as an app for quick access and offline support.</p><button class=\"btn btn-primary\" onclick=\"installPWA()\">Install to Desktop</button></div>")
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, "<div class=\"modal\" onclick=\"event.stopPropagation()\"><button class=\"modal-close\" onclick=\"closePWAModal()\">&times;</button><h2>Install Next Workspace</h2><p>Your browser didn't show an automatic install prompt. Use the instructions below for your device.</p><h3 style=\"margin-bottom:0.5rem;font-size:0.875rem;\">🖥️ Desktop Chrome/Edge</h3><ol><li>Click the <strong>install icon</strong> <code>⊕</code> in the address bar (right side)</li><li>Click <strong>Install</strong> in the popup</li><li>The app will open in its own window</li></ol><h3 style=\"margin-bottom:0.5rem;font-size:0.875rem;margin-top:1rem;\">📱 iOS Safari</h3><ol><li>Tap the <strong>Share button</strong> <code>📤</code> at the bottom of the screen</li><li>Scroll down and tap <strong>Add to Home Screen</strong></li><li>Tap <strong>Add</strong> in the top-right corner</li><li>The app icon will appear on your home screen</li></ol><h3 style=\"margin-bottom:0.5rem;font-size:0.875rem;margin-top:1rem;\">🤖 Android Chrome</h3><ol><li>Tap the <strong>menu icon</strong> <code>⋮</code> (three dots)</li><li>Tap <strong>Install app</strong> or <strong>Add to Home screen</strong></li><li>Tap <strong>Install</strong></li></ol><button class=\"btn btn-primary\" style=\"margin-top:1rem;width:100%;\" onclick=\"closePWAModal()\">Got it</button></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate

View file

@ -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)