NEXUS-1: Initial commit of Nexus
This commit is contained in:
117
internal/handler/auth.go
Normal file
117
internal/handler/auth.go
Normal file
@@ -0,0 +1,117 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"git.arcline.it/ArclineIT/nexus/internal/auth"
|
||||
"git.arcline.it/ArclineIT/nexus/internal/config"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// AuthHandler handles authentication endpoints.
|
||||
type AuthHandler struct {
|
||||
cfg *config.Config
|
||||
}
|
||||
|
||||
// NewAuthHandler creates a new AuthHandler.
|
||||
func NewAuthHandler(cfg *config.Config) *AuthHandler {
|
||||
return &AuthHandler{cfg: cfg}
|
||||
}
|
||||
|
||||
// LoginRequest is the expected body for POST /auth/login.
|
||||
type LoginRequest struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
// Login handles POST /auth/login.
|
||||
// TODO: validate credentials against database.
|
||||
func (h *AuthHandler) Login() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var req LoginRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
respondError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
if req.Email == "" || req.Password == "" {
|
||||
respondError(w, http.StatusBadRequest, "email and password are required")
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: verify password, look up user from DB
|
||||
// For now, generate a token pair with a placeholder user ID
|
||||
userID := uuid.New()
|
||||
|
||||
tokens, err := auth.GenerateTokenPair(h.cfg, userID, req.Email)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to generate tokens")
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, tokens)
|
||||
}
|
||||
}
|
||||
|
||||
// RefreshRequest is the expected body for POST /auth/refresh.
|
||||
type RefreshRequest struct {
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
}
|
||||
|
||||
// Refresh handles POST /auth/refresh.
|
||||
func (h *AuthHandler) Refresh() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var req RefreshRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
respondError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
if req.RefreshToken == "" {
|
||||
respondError(w, http.StatusBadRequest, "refresh_token is required")
|
||||
return
|
||||
}
|
||||
|
||||
// Validate the refresh token
|
||||
claims, err := auth.ValidateToken(h.cfg, req.RefreshToken)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusUnauthorized, "invalid or expired refresh token")
|
||||
return
|
||||
}
|
||||
|
||||
if claims.TokenType != "refresh" {
|
||||
respondError(w, http.StatusUnauthorized, "token is not a refresh token")
|
||||
return
|
||||
}
|
||||
|
||||
userID, err := uuid.Parse(claims.Subject)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "invalid user ID in token")
|
||||
return
|
||||
}
|
||||
|
||||
tokens, err := auth.GenerateTokenPair(h.cfg, userID, claims.Email)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to generate tokens")
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, tokens)
|
||||
}
|
||||
}
|
||||
|
||||
// Me handles GET /auth/me — returns the authenticated user's info.
|
||||
func (h *AuthHandler) Me() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
// The Authenticate middleware has already injected user info into context.
|
||||
userID := r.Context().Value("user_id")
|
||||
userEmail := r.Context().Value("user_email")
|
||||
|
||||
respondJSON(w, http.StatusOK, map[string]any{
|
||||
"id": userID,
|
||||
"email": userEmail,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
13
internal/handler/health.go
Normal file
13
internal/handler/health.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package handler
|
||||
|
||||
import "net/http"
|
||||
|
||||
// Ready handles GET /health and GET /ready.
|
||||
func Ready() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
respondJSON(w, http.StatusOK, map[string]string{
|
||||
"status": "healthy",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
23
internal/handler/response.go
Normal file
23
internal/handler/response.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// respondJSON writes a JSON response with the given status code.
|
||||
func respondJSON(w http.ResponseWriter, status int, data any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
if data != nil {
|
||||
if err := json.NewEncoder(w).Encode(data); err != nil {
|
||||
http.Error(w, `{"error":"failed to encode response"}`, http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// respondError writes a JSON error response.
|
||||
func respondError(w http.ResponseWriter, status int, message string) {
|
||||
respondJSON(w, status, map[string]string{"error": message})
|
||||
}
|
||||
|
||||
43
internal/handler/templates/base.html
Normal file
43
internal/handler/templates/base.html
Normal file
@@ -0,0 +1,43 @@
|
||||
{{define "base"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Nexus Control Panel</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><text y='28' font-size='28'>⚡</text></svg>">
|
||||
<style>
|
||||
@keyframes fade-in {
|
||||
from { opacity: 0; transform: translateY(-4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.animate-fade-in {
|
||||
animation: fade-in 0.2s ease-out;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 flex items-center justify-center p-4">
|
||||
<div class="w-full max-w-md">
|
||||
<!-- Logo -->
|
||||
<div class="text-center mb-8">
|
||||
<div class="text-4xl mb-2">⚡</div>
|
||||
<h1 class="text-2xl font-bold text-white tracking-tight">Nexus</h1>
|
||||
<p class="text-slate-400 text-sm mt-1">Control Panel</p>
|
||||
</div>
|
||||
|
||||
<!-- Card -->
|
||||
<div class="bg-slate-800/50 backdrop-blur-sm border border-slate-700/50 rounded-xl shadow-2xl p-8">
|
||||
{{template "content" .}}
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<p class="text-center text-slate-500 text-xs mt-6">
|
||||
Arcline Platform · Single Sign-On
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
|
||||
58
internal/handler/templates/forgot-password.html
Normal file
58
internal/handler/templates/forgot-password.html
Normal file
@@ -0,0 +1,58 @@
|
||||
{{define "content"}}
|
||||
<div id="auth-form">
|
||||
<h2 class="text-xl font-semibold text-white mb-2">Reset your password</h2>
|
||||
<p class="text-slate-400 text-sm mb-6">Enter your email address and we'll send you a link to reset your password.</p>
|
||||
|
||||
{{if .Success}}
|
||||
<div class="bg-emerald-500/10 border border-emerald-500/30 text-emerald-400 rounded-lg px-4 py-3 mb-6 text-sm animate-fade-in" role="alert">
|
||||
{{.Success}}
|
||||
</div>
|
||||
|
||||
<p class="text-center text-slate-400 text-sm mt-6">
|
||||
<a href="/login" class="text-indigo-400 hover:text-indigo-300 font-medium transition-colors">Back to sign in</a>
|
||||
</p>
|
||||
{{else}}
|
||||
{{if .Error}}
|
||||
<div class="bg-red-500/10 border border-red-500/30 text-red-400 rounded-lg px-4 py-3 mb-6 text-sm animate-fade-in" role="alert">
|
||||
{{.Error}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<form
|
||||
hx-post="/forgot-password"
|
||||
hx-target="#auth-form"
|
||||
hx-swap="outerHTML"
|
||||
hx-disabled-elt="button[type=submit], input"
|
||||
class="space-y-5"
|
||||
>
|
||||
<div>
|
||||
<label for="email" class="block text-sm font-medium text-slate-300 mb-1.5">Email address</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
value="{{.Email}}"
|
||||
required
|
||||
autofocus
|
||||
autocomplete="email"
|
||||
class="w-full px-4 py-2.5 bg-slate-700/50 border border-slate-600 rounded-lg text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-colors"
|
||||
placeholder="you@example.com"
|
||||
>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full py-2.5 px-4 bg-indigo-600 hover:bg-indigo-500 text-white font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:ring-offset-slate-800 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span class="htmx-indicator">Send reset link</span>
|
||||
<span class="htmx-request hidden">Sending...</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="text-center text-slate-400 text-sm mt-6">
|
||||
<a href="/login" class="text-indigo-400 hover:text-indigo-300 font-medium transition-colors">Back to sign in</a>
|
||||
</p>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
79
internal/handler/templates/login.html
Normal file
79
internal/handler/templates/login.html
Normal file
@@ -0,0 +1,79 @@
|
||||
{{define "content"}}
|
||||
<div id="auth-form">
|
||||
<h2 class="text-xl font-semibold text-white mb-6">Sign in to your account</h2>
|
||||
|
||||
{{if .Error}}
|
||||
<div class="bg-red-500/10 border border-red-500/30 text-red-400 rounded-lg px-4 py-3 mb-6 text-sm animate-fade-in" role="alert">
|
||||
{{.Error}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .Success}}
|
||||
<div class="bg-emerald-500/10 border border-emerald-500/30 text-emerald-400 rounded-lg px-4 py-3 mb-6 text-sm animate-fade-in" role="alert">
|
||||
{{.Success}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<form
|
||||
hx-post="/login"
|
||||
hx-target="#auth-form"
|
||||
hx-swap="outerHTML"
|
||||
hx-disabled-elt="button[type=submit], input"
|
||||
class="space-y-5"
|
||||
>
|
||||
<div>
|
||||
<label for="email" class="block text-sm font-medium text-slate-300 mb-1.5">Email address</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
value="{{.Email}}"
|
||||
required
|
||||
autofocus
|
||||
autocomplete="email"
|
||||
class="w-full px-4 py-2.5 bg-slate-700/50 border border-slate-600 rounded-lg text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-colors"
|
||||
placeholder="you@example.com"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-medium text-slate-300 mb-1.5">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
name="password"
|
||||
required
|
||||
autocomplete="current-password"
|
||||
class="w-full px-4 py-2.5 bg-slate-700/50 border border-slate-600 rounded-lg text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-colors"
|
||||
placeholder="••••••••"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<label class="flex items-center text-slate-400">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="remember"
|
||||
class="rounded border-slate-600 bg-slate-700 text-indigo-500 focus:ring-indigo-500 mr-2"
|
||||
>
|
||||
Remember me
|
||||
</label>
|
||||
<a href="/forgot-password" class="text-indigo-400 hover:text-indigo-300 transition-colors">Forgot password?</a>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full py-2.5 px-4 bg-indigo-600 hover:bg-indigo-500 text-white font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:ring-offset-slate-800 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span class="htmx-indicator">Sign in</span>
|
||||
<span class="htmx-request hidden">Signing in...</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="text-center text-slate-400 text-sm mt-6">
|
||||
Don't have an account?
|
||||
<a href="/signup" class="text-indigo-400 hover:text-indigo-300 font-medium transition-colors">Create one</a>
|
||||
</p>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
68
internal/handler/templates/reset-password.html
Normal file
68
internal/handler/templates/reset-password.html
Normal file
@@ -0,0 +1,68 @@
|
||||
{{define "content"}}
|
||||
<div id="auth-form">
|
||||
<h2 class="text-xl font-semibold text-white mb-6">Set a new password</h2>
|
||||
|
||||
{{if .Error}}
|
||||
<div class="bg-red-500/10 border border-red-500/30 text-red-400 rounded-lg px-4 py-3 mb-6 text-sm animate-fade-in" role="alert">
|
||||
{{.Error}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .Success}}
|
||||
<div class="bg-emerald-500/10 border border-emerald-500/30 text-emerald-400 rounded-lg px-4 py-3 mb-6 text-sm animate-fade-in" role="alert">
|
||||
{{.Success}}
|
||||
</div>
|
||||
<p class="text-center text-slate-400 text-sm mt-6">
|
||||
<a href="/login" class="text-indigo-400 hover:text-indigo-300 font-medium transition-colors">Sign in with your new password</a>
|
||||
</p>
|
||||
{{else}}
|
||||
<form
|
||||
hx-post="/reset-password"
|
||||
hx-target="#auth-form"
|
||||
hx-swap="outerHTML"
|
||||
hx-disabled-elt="button[type=submit], input"
|
||||
class="space-y-5"
|
||||
>
|
||||
<input type="hidden" name="token" value="{{.Token}}">
|
||||
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-medium text-slate-300 mb-1.5">New password</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
name="password"
|
||||
required
|
||||
minlength="8"
|
||||
autofocus
|
||||
autocomplete="new-password"
|
||||
class="w-full px-4 py-2.5 bg-slate-700/50 border border-slate-600 rounded-lg text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-colors"
|
||||
placeholder="At least 8 characters"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="password_confirm" class="block text-sm font-medium text-slate-300 mb-1.5">Confirm new password</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password_confirm"
|
||||
name="password_confirm"
|
||||
required
|
||||
minlength="8"
|
||||
autocomplete="new-password"
|
||||
class="w-full px-4 py-2.5 bg-slate-700/50 border border-slate-600 rounded-lg text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-colors"
|
||||
placeholder="Repeat your password"
|
||||
>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full py-2.5 px-4 bg-indigo-600 hover:bg-indigo-500 text-white font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:ring-offset-slate-800 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span class="htmx-indicator">Reset password</span>
|
||||
<span class="htmx-request hidden">Resetting...</span>
|
||||
</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
90
internal/handler/templates/signup.html
Normal file
90
internal/handler/templates/signup.html
Normal file
@@ -0,0 +1,90 @@
|
||||
{{define "content"}}
|
||||
<div id="auth-form">
|
||||
<h2 class="text-xl font-semibold text-white mb-6">Create your account</h2>
|
||||
|
||||
{{if .Error}}
|
||||
<div class="bg-red-500/10 border border-red-500/30 text-red-400 rounded-lg px-4 py-3 mb-6 text-sm animate-fade-in" role="alert">
|
||||
{{.Error}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<form
|
||||
hx-post="/signup"
|
||||
hx-target="#auth-form"
|
||||
hx-swap="outerHTML"
|
||||
hx-disabled-elt="button[type=submit], input"
|
||||
class="space-y-5"
|
||||
>
|
||||
<div>
|
||||
<label for="display_name" class="block text-sm font-medium text-slate-300 mb-1.5">Full name</label>
|
||||
<input
|
||||
type="text"
|
||||
id="display_name"
|
||||
name="display_name"
|
||||
value="{{.DisplayName}}"
|
||||
required
|
||||
autofocus
|
||||
autocomplete="name"
|
||||
class="w-full px-4 py-2.5 bg-slate-700/50 border border-slate-600 rounded-lg text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-colors"
|
||||
placeholder="Jane Smith"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="email" class="block text-sm font-medium text-slate-300 mb-1.5">Email address</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
value="{{.Email}}"
|
||||
required
|
||||
autocomplete="email"
|
||||
class="w-full px-4 py-2.5 bg-slate-700/50 border border-slate-600 rounded-lg text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-colors"
|
||||
placeholder="you@example.com"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-medium text-slate-300 mb-1.5">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
name="password"
|
||||
required
|
||||
minlength="8"
|
||||
autocomplete="new-password"
|
||||
class="w-full px-4 py-2.5 bg-slate-700/50 border border-slate-600 rounded-lg text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-colors"
|
||||
placeholder="At least 8 characters"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="password_confirm" class="block text-sm font-medium text-slate-300 mb-1.5">Confirm password</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password_confirm"
|
||||
name="password_confirm"
|
||||
required
|
||||
minlength="8"
|
||||
autocomplete="new-password"
|
||||
class="w-full px-4 py-2.5 bg-slate-700/50 border border-slate-600 rounded-lg text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-colors"
|
||||
placeholder="Repeat your password"
|
||||
>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full py-2.5 px-4 bg-indigo-600 hover:bg-indigo-500 text-white font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:ring-offset-slate-800 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span class="htmx-indicator">Create account</span>
|
||||
<span class="htmx-request hidden">Creating account...</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="text-center text-slate-400 text-sm mt-6">
|
||||
Already have an account?
|
||||
<a href="/login" class="text-indigo-400 hover:text-indigo-300 font-medium transition-colors">Sign in</a>
|
||||
</p>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
390
internal/handler/webui.go
Normal file
390
internal/handler/webui.go
Normal file
@@ -0,0 +1,390 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.arcline.it/ArclineIT/nexus/internal/auth"
|
||||
"git.arcline.it/ArclineIT/nexus/internal/config"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
//go:embed templates
|
||||
var templateFS embed.FS
|
||||
|
||||
// UITemplateData holds data passed to UI templates.
|
||||
type UITemplateData struct {
|
||||
Error string
|
||||
Success string
|
||||
Email string
|
||||
DisplayName string
|
||||
Token string
|
||||
}
|
||||
|
||||
// UIHandler serves the web UI pages and handles HTMX form submissions.
|
||||
type UIHandler struct {
|
||||
cfg *config.Config
|
||||
templates *template.Template
|
||||
}
|
||||
|
||||
// NewUIHandler creates a new UIHandler.
|
||||
func NewUIHandler(cfg *config.Config) (*UIHandler, error) {
|
||||
tmpl, err := template.ParseFS(templateFS, "templates/*.html")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &UIHandler{cfg: cfg, templates: tmpl}, nil
|
||||
}
|
||||
|
||||
// ServePage renders a full page (base + named template).
|
||||
func (h *UIHandler) ServePage(w http.ResponseWriter, data any, templateName string) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := h.templates.ExecuteTemplate(w, "base", data); err != nil {
|
||||
http.Error(w, "failed to render page", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// ServeFragment renders only the content fragment (for HTMX swaps).
|
||||
func (h *UIHandler) ServeFragment(w http.ResponseWriter, data any, templateName string) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := h.templates.ExecuteTemplate(w, templateName, data); err != nil {
|
||||
http.Error(w, "failed to render fragment", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// Root redirects / to /login.
|
||||
func (h *UIHandler) Root() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Login
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// LoginPage serves GET /login.
|
||||
func (h *UIHandler) LoginPage() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
data := &UITemplateData{}
|
||||
// Check for success message from signup
|
||||
if msg := r.URL.Query().Get("registered"); msg == "1" {
|
||||
data.Success = "Account created successfully. Please sign in."
|
||||
}
|
||||
if msg := r.URL.Query().Get("reset"); msg == "1" {
|
||||
data.Success = "Password reset successfully. Please sign in."
|
||||
}
|
||||
h.ServePage(w, data, "login")
|
||||
}
|
||||
}
|
||||
|
||||
// LoginSubmit handles POST /login (HTMX form submission).
|
||||
func (h *UIHandler) LoginSubmit() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
email := strings.TrimSpace(r.FormValue("email"))
|
||||
password := r.FormValue("password")
|
||||
|
||||
data := &UITemplateData{Email: email}
|
||||
|
||||
if email == "" || password == "" {
|
||||
data.Error = "Email and password are required."
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
h.ServeFragment(w, data, "login")
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: validate credentials against database
|
||||
// For now, accept any credentials and generate tokens
|
||||
userID := uuid.New()
|
||||
|
||||
tokens, err := auth.GenerateTokenPair(h.cfg, userID, email)
|
||||
if err != nil {
|
||||
data.Error = "Something went wrong. Please try again."
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
h.ServeFragment(w, data, "login")
|
||||
return
|
||||
}
|
||||
|
||||
// Set access token as a cookie
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "nexus_access_token",
|
||||
Value: tokens.AccessToken,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: r.TLS != nil,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: int(h.cfg.Auth.AccessTokenDuration.Seconds()),
|
||||
})
|
||||
|
||||
// Set refresh token as a cookie
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "nexus_refresh_token",
|
||||
Value: tokens.RefreshToken,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: r.TLS != nil,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: int(h.cfg.Auth.RefreshTokenDuration.Seconds()),
|
||||
})
|
||||
|
||||
// Tell HTMX to redirect to the dashboard
|
||||
w.Header().Set("HX-Redirect", "/dashboard")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Signup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// SignupPage serves GET /signup.
|
||||
func (h *UIHandler) SignupPage() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
h.ServePage(w, &UITemplateData{}, "signup")
|
||||
}
|
||||
}
|
||||
|
||||
// SignupSubmit handles POST /signup (HTMX form submission).
|
||||
func (h *UIHandler) SignupSubmit() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
displayName := strings.TrimSpace(r.FormValue("display_name"))
|
||||
email := strings.TrimSpace(r.FormValue("email"))
|
||||
password := r.FormValue("password")
|
||||
passwordConfirm := r.FormValue("password_confirm")
|
||||
|
||||
data := &UITemplateData{
|
||||
Email: email,
|
||||
DisplayName: displayName,
|
||||
}
|
||||
|
||||
// Validate
|
||||
if displayName == "" {
|
||||
data.Error = "Full name is required."
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
h.ServeFragment(w, data, "signup")
|
||||
return
|
||||
}
|
||||
if email == "" {
|
||||
data.Error = "Email address is required."
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
h.ServeFragment(w, data, "signup")
|
||||
return
|
||||
}
|
||||
if password == "" {
|
||||
data.Error = "Password is required."
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
h.ServeFragment(w, data, "signup")
|
||||
return
|
||||
}
|
||||
if len(password) < 8 {
|
||||
data.Error = "Password must be at least 8 characters."
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
h.ServeFragment(w, data, "signup")
|
||||
return
|
||||
}
|
||||
if password != passwordConfirm {
|
||||
data.Error = "Passwords do not match."
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
h.ServeFragment(w, data, "signup")
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: check if email already exists in database
|
||||
// TODO: hash password with bcrypt and store user
|
||||
|
||||
// Redirect to login with success message
|
||||
w.Header().Set("HX-Redirect", "/login?registered=1")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Forgot Password
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ForgotPasswordPage serves GET /forgot-password.
|
||||
func (h *UIHandler) ForgotPasswordPage() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
h.ServePage(w, &UITemplateData{}, "forgot-password")
|
||||
}
|
||||
}
|
||||
|
||||
// ForgotPasswordSubmit handles POST /forgot-password (HTMX form submission).
|
||||
func (h *UIHandler) ForgotPasswordSubmit() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
email := strings.TrimSpace(r.FormValue("email"))
|
||||
|
||||
data := &UITemplateData{Email: email}
|
||||
|
||||
if email == "" {
|
||||
data.Error = "Email address is required."
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
h.ServeFragment(w, data, "forgot-password")
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: look up user in database, generate reset token, send email
|
||||
// For now, always show success to prevent email enumeration
|
||||
data.Success = "If an account exists for " + email + ", you will receive a password reset link shortly."
|
||||
|
||||
h.ServeFragment(w, data, "forgot-password")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reset Password
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ResetPasswordPage serves GET /reset-password.
|
||||
func (h *UIHandler) ResetPasswordPage() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
token := r.URL.Query().Get("token")
|
||||
|
||||
data := &UITemplateData{Token: token}
|
||||
|
||||
if token == "" {
|
||||
data.Error = "Invalid or missing reset token."
|
||||
h.ServePage(w, data, "reset-password")
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: validate reset token exists and hasn't expired
|
||||
h.ServePage(w, data, "reset-password")
|
||||
}
|
||||
}
|
||||
|
||||
// ResetPasswordSubmit handles POST /reset-password (HTMX form submission).
|
||||
func (h *UIHandler) ResetPasswordSubmit() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
token := r.FormValue("token")
|
||||
password := r.FormValue("password")
|
||||
passwordConfirm := r.FormValue("password_confirm")
|
||||
|
||||
data := &UITemplateData{Token: token}
|
||||
|
||||
if token == "" {
|
||||
data.Error = "Invalid or missing reset token."
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
h.ServeFragment(w, data, "reset-password")
|
||||
return
|
||||
}
|
||||
|
||||
if password == "" {
|
||||
data.Error = "Password is required."
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
h.ServeFragment(w, data, "reset-password")
|
||||
return
|
||||
}
|
||||
if len(password) < 8 {
|
||||
data.Error = "Password must be at least 8 characters."
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
h.ServeFragment(w, data, "reset-password")
|
||||
return
|
||||
}
|
||||
if password != passwordConfirm {
|
||||
data.Error = "Passwords do not match."
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
h.ServeFragment(w, data, "reset-password")
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: validate reset token, look up user, hash new password, save
|
||||
|
||||
// Redirect to login with success message
|
||||
w.Header().Set("HX-Redirect", "/login?reset=1")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dashboard
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// DashboardPage serves GET /dashboard — simple placeholder for now.
|
||||
func (h *UIHandler) DashboardPage() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
userID, _ := r.Context().Value("user_id").(string)
|
||||
userEmail, _ := r.Context().Value("user_email").(string)
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
// Simple inline dashboard — can be moved to a template later
|
||||
w.Write([]byte(`<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Nexus — Dashboard</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><text y='28' font-size='28'>⚡</text></svg>">
|
||||
</head>
|
||||
<body class="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900">
|
||||
<nav class="border-b border-slate-700/50 bg-slate-800/50 backdrop-blur-sm">
|
||||
<div class="max-w-6xl mx-auto px-4 py-3 flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-2xl">⚡</span>
|
||||
<span class="text-white font-semibold text-lg">Nexus</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="text-slate-400 text-sm">` + userEmail + `</span>
|
||||
<form hx-post="/logout" hx-target="body" class="inline">
|
||||
<button type="submit" class="text-slate-400 hover:text-white text-sm transition-colors">Sign out</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<main class="max-w-6xl mx-auto px-4 py-12">
|
||||
<div class="bg-slate-800/50 backdrop-blur-sm border border-slate-700/50 rounded-xl shadow-2xl p-8">
|
||||
<h2 class="text-2xl font-bold text-white mb-2">Welcome back</h2>
|
||||
<p class="text-slate-400 mb-6">You are signed in as <span class="text-white font-medium">` + userEmail + `</span>.</p>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div class="bg-slate-700/50 rounded-lg p-4 border border-slate-600/50">
|
||||
<div class="text-slate-400 text-sm mb-1">User ID</div>
|
||||
<div class="text-white font-mono text-sm break-all">` + userID + `</div>
|
||||
</div>
|
||||
<div class="bg-slate-700/50 rounded-lg p-4 border border-slate-600/50">
|
||||
<div class="text-slate-400 text-sm mb-1">Connected Apps</div>
|
||||
<div class="text-white text-sm">None yet</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>`))
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Logout
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Logout handles POST /logout — clears auth cookies and redirects to login.
|
||||
func (h *UIHandler) Logout() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
// Clear cookies
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "nexus_access_token",
|
||||
Value: "",
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
MaxAge: -1,
|
||||
})
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "nexus_refresh_token",
|
||||
Value: "",
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
MaxAge: -1,
|
||||
})
|
||||
|
||||
w.Header().Set("HX-Redirect", "/login")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user