Add authenticated content editing

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Daniel Noulet 2026-09-19 14:56:49 +02:00
parent fac7588d23
commit fd2877da9e
15 changed files with 581 additions and 37 deletions

View file

@ -80,9 +80,10 @@ func main() {
repository.NewSessionRepository(adminDB), repository.NewSessionRepository(adminDB),
cfg.SessionTTL, cfg.SessionTTL,
) )
content := service.NewContentService(repository.NewContentRepository(applicationDB), auth)
google := identity.GoogleProvider{ClientID: cfg.GoogleClientID, ClientSecret: cfg.GoogleClientSecret, RedirectURL: cfg.GoogleRedirectURL} google := identity.GoogleProvider{ClientID: cfg.GoogleClientID, ClientSecret: cfg.GoogleClientSecret, RedirectURL: cfg.GoogleRedirectURL}
log.Printf("CMS listening on %s", cfg.HTTPAddr) log.Printf("CMS listening on %s", cfg.HTTPAddr)
if err = http.ListenAndServe(cfg.HTTPAddr, web.NewHandler(auth, google).Routes(cfg.AllowedOrigins)); err != nil { if err = http.ListenAndServe(cfg.HTTPAddr, web.NewHandlerWithContent(auth, google, content).Routes(cfg.AllowedOrigins)); err != nil {
log.Fatal(err) log.Fatal(err)
} }
} }

View file

@ -0,0 +1,32 @@
-- +goose Up
ALTER TABLE users ADD COLUMN IF NOT EXISTS role TEXT NOT NULL DEFAULT '';
ALTER TABLE users ADD COLUMN IF NOT EXISTS editor BOOLEAN NOT NULL DEFAULT FALSE;
CREATE TABLE IF NOT EXISTS content (
id BIGSERIAL PRIMARY KEY,
slug TEXT NOT NULL UNIQUE,
title TEXT NOT NULL,
summary TEXT NOT NULL DEFAULT '',
body TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'draft',
published BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
created_by BIGINT REFERENCES users(id),
updated_by BIGINT REFERENCES users(id)
);
CREATE INDEX IF NOT EXISTS idx_content_updated_at ON content(updated_at DESC, created_at DESC, id DESC);
CREATE TABLE IF NOT EXISTS content_history (
id BIGSERIAL PRIMARY KEY,
content_id BIGINT NOT NULL REFERENCES content(id) ON DELETE CASCADE,
operation TEXT NOT NULL,
actor_id BIGINT REFERENCES users(id),
actor_admin_id BIGINT,
created_at TIMESTAMPTZ NOT NULL,
snapshot JSONB NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_content_history_content_id ON content_history(content_id, created_at DESC);
-- +goose Down
DROP TABLE IF EXISTS content_history;
DROP TABLE IF EXISTS content;
ALTER TABLE users DROP COLUMN IF EXISTS editor;
ALTER TABLE users DROP COLUMN IF EXISTS role;

View file

@ -6,6 +6,8 @@ type User struct {
ID uint ID uint
Email, Name string Email, Name string
Provider, ProviderSubject string Provider, ProviderSubject string
Role string
Editor bool
CreatedAt time.Time CreatedAt time.Time
} }
type Admin struct { type Admin struct {
@ -20,3 +22,22 @@ type Session struct {
AdminID *uint AdminID *uint
ExpiresAt, CreatedAt time.Time ExpiresAt, CreatedAt time.Time
} }
type Content struct {
ID uint
Slug, Title, Summary, Body string
Status string
Published bool
CreatedAt, UpdatedAt time.Time
CreatedBy, UpdatedBy uint
}
type ContentHistory struct {
ID uint
ContentID uint
Operation string
ActorID *uint
ActorAdminID *uint
CreatedAt time.Time
Snapshot string
}

View file

@ -3,10 +3,16 @@ package persistence
import "github.com/example/cms/internal/domain" import "github.com/example/cms/internal/domain"
func UserToDomain(m UserModel) domain.User { func UserToDomain(m UserModel) domain.User {
return domain.User{ID: m.ID, Email: m.Email, Name: m.Name, Provider: m.Provider, ProviderSubject: m.ProviderSubject, CreatedAt: m.CreatedAt} return domain.User{ID: m.ID, Email: m.Email, Name: m.Name, Provider: m.Provider, ProviderSubject: m.ProviderSubject, Role: m.Role, Editor: m.Editor, CreatedAt: m.CreatedAt}
} }
func UserFromDomain(d domain.User) UserModel { func UserFromDomain(d domain.User) UserModel {
return UserModel{ID: d.ID, Email: d.Email, Name: d.Name, Provider: d.Provider, ProviderSubject: d.ProviderSubject, CreatedAt: d.CreatedAt} return UserModel{ID: d.ID, Email: d.Email, Name: d.Name, Provider: d.Provider, ProviderSubject: d.ProviderSubject, Role: d.Role, Editor: d.Editor, CreatedAt: d.CreatedAt}
}
func ContentToDomain(m ContentModel) domain.Content {
return domain.Content{ID: m.ID, Slug: m.Slug, Title: m.Title, Summary: m.Summary, Body: m.Body, Status: m.Status, Published: m.Published, CreatedAt: m.CreatedAt, UpdatedAt: m.UpdatedAt, CreatedBy: m.CreatedBy, UpdatedBy: m.UpdatedBy}
}
func ContentFromDomain(d domain.Content) ContentModel {
return ContentModel{ID: d.ID, Slug: d.Slug, Title: d.Title, Summary: d.Summary, Body: d.Body, Status: d.Status, Published: d.Published, CreatedAt: d.CreatedAt, UpdatedAt: d.UpdatedAt, CreatedBy: d.CreatedBy, UpdatedBy: d.UpdatedBy}
} }
func AdminToDomain(m AdminModel) domain.Admin { func AdminToDomain(m AdminModel) domain.Admin {
return domain.Admin{ID: m.ID, Email: m.Email, Name: m.Name, CreatedAt: m.CreatedAt} return domain.Admin{ID: m.ID, Email: m.Email, Name: m.Name, CreatedAt: m.CreatedAt}

View file

@ -8,9 +8,37 @@ type UserModel struct {
Name string Name string
Provider string `gorm:"not null"` Provider string `gorm:"not null"`
ProviderSubject string `gorm:"uniqueIndex;not null"` ProviderSubject string `gorm:"uniqueIndex;not null"`
Role string `gorm:"not null;default:''"`
Editor bool `gorm:"not null;default:false"`
CreatedAt time.Time CreatedAt time.Time
} }
type ContentModel struct {
ID uint `gorm:"primaryKey"`
Slug string `gorm:"uniqueIndex;not null"`
Title string `gorm:"not null"`
Summary string
Body string `gorm:"not null"`
Status string `gorm:"not null;default:'draft'"`
Published bool `gorm:"not null;default:false"`
CreatedAt, UpdatedAt time.Time
CreatedBy, UpdatedBy uint
}
func (ContentModel) TableName() string { return "content" }
type ContentHistoryModel struct {
ID uint `gorm:"primaryKey"`
ContentID uint `gorm:"index;not null"`
Operation string `gorm:"not null"`
ActorID *uint
ActorAdminID *uint
CreatedAt time.Time
Snapshot string `gorm:"type:jsonb;not null"`
}
func (ContentHistoryModel) TableName() string { return "content_history" }
func (UserModel) TableName() string { return "users" } func (UserModel) TableName() string { return "users" }
type AdminModel struct { type AdminModel struct {

View file

@ -27,15 +27,21 @@ func (r *GORMUserRepository) FindOrCreateByIdentity(ctx context.Context, d domai
} }
return persistence.UserToDomain(m), nil return persistence.UserToDomain(m), nil
} }
func (r *GORMUserRepository) FindByID(ctx context.Context, id uint) (domain.User, error) {
var m persistence.UserModel
if err := r.db.WithContext(ctx).First(&m, id).Error; err != nil {
return domain.User{}, err
}
return persistence.UserToDomain(m), nil
}
type GORMAdminRepository struct{ db *gorm.DB } type GORMAdminRepository struct{ db *gorm.DB }
func NewAdminRepository(db *gorm.DB) IAdminRepository { return &GORMAdminRepository{db} } func NewAdminRepository(db *gorm.DB) IAdminRepository { return &GORMAdminRepository{db} }
func (r *GORMAdminRepository) FindByEmail(ctx context.Context, email string) (domain.Admin, error) { func (r *GORMAdminRepository) FindByEmail(ctx context.Context, email string) (domain.Admin, error) {
var m persistence.AdminModel var m persistence.AdminModel
e := r.db.WithContext(ctx).Where("email = ?", email).First(&m).Error if err := r.db.WithContext(ctx).Where("email = ?", email).First(&m).Error; err != nil {
if e != nil { return domain.Admin{}, err
return domain.Admin{}, e
} }
return persistence.AdminToDomain(m), nil return persistence.AdminToDomain(m), nil
} }
@ -44,14 +50,40 @@ type GORMSessionRepository struct{ db *gorm.DB }
func NewSessionRepository(db *gorm.DB) ISessionRepository { return &GORMSessionRepository{db} } func NewSessionRepository(db *gorm.DB) ISessionRepository { return &GORMSessionRepository{db} }
func (r *GORMSessionRepository) Create(ctx context.Context, d domain.Session) error { func (r *GORMSessionRepository) Create(ctx context.Context, d domain.Session) error {
model := persistence.SessionFromDomain(d) return r.db.WithContext(ctx).Create(&persistence.SessionModel{ID: d.ID, Token: d.Token, UserID: d.UserID, AdminID: d.AdminID, ExpiresAt: d.ExpiresAt, CreatedAt: d.CreatedAt}).Error
return r.db.WithContext(ctx).Create(&model).Error
} }
func (r *GORMSessionRepository) FindValid(ctx context.Context, t string) (domain.Session, error) { func (r *GORMSessionRepository) FindValid(ctx context.Context, t string) (domain.Session, error) {
var m persistence.SessionModel var m persistence.SessionModel
e := r.db.WithContext(ctx).Where("token = ? AND expires_at > ?", t, time.Now()).First(&m).Error if err := r.db.WithContext(ctx).Where("token = ? AND expires_at > ?", t, time.Now()).First(&m).Error; err != nil {
if e != nil { return domain.Session{}, err
return domain.Session{}, e
} }
return persistence.SessionToDomain(m), nil return persistence.SessionToDomain(m), nil
} }
type GORMContentRepository struct{ db *gorm.DB }
func NewContentRepository(db *gorm.DB) IContentRepository { return &GORMContentRepository{db} }
func (r *GORMContentRepository) List(ctx context.Context, limit, offset int) ([]domain.Content, error) {
var rows []persistence.ContentModel
err := r.db.WithContext(ctx).Order("updated_at DESC, created_at DESC, id DESC").Limit(limit).Offset(offset).Find(&rows).Error
out := make([]domain.Content, len(rows))
for i := range rows {
out[i] = persistence.ContentToDomain(rows[i])
}
return out, err
}
func (r *GORMContentRepository) FindBySlug(ctx context.Context, slug string) (domain.Content, error) {
var row persistence.ContentModel
if err := r.db.WithContext(ctx).Where("slug = ?", slug).First(&row).Error; err != nil {
return domain.Content{}, err
}
return persistence.ContentToDomain(row), nil
}
func (r *GORMContentRepository) Update(ctx context.Context, d domain.Content, h domain.ContentHistory) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&persistence.ContentModel{}).Where("id = ?", d.ID).Updates(map[string]any{"slug": d.Slug, "title": d.Title, "summary": d.Summary, "body": d.Body, "status": d.Status, "published": d.Published, "updated_at": d.UpdatedAt, "updated_by": d.UpdatedBy}).Error; err != nil {
return err
}
return tx.Create(&persistence.ContentHistoryModel{ContentID: h.ContentID, Operation: h.Operation, ActorID: h.ActorID, ActorAdminID: h.ActorAdminID, CreatedAt: h.CreatedAt, Snapshot: h.Snapshot}).Error
})
}

View file

@ -8,6 +8,9 @@ import (
type IUserRepository interface { type IUserRepository interface {
FindOrCreateByIdentity(context.Context, domain.User) (domain.User, error) FindOrCreateByIdentity(context.Context, domain.User) (domain.User, error)
} }
type IUserLookup interface {
FindByID(context.Context, uint) (domain.User, error)
}
type IAdminRepository interface { type IAdminRepository interface {
FindByEmail(context.Context, string) (domain.Admin, error) FindByEmail(context.Context, string) (domain.Admin, error)
} }
@ -15,3 +18,8 @@ type ISessionRepository interface {
Create(context.Context, domain.Session) error Create(context.Context, domain.Session) error
FindValid(context.Context, string) (domain.Session, error) FindValid(context.Context, string) (domain.Session, error)
} }
type IContentRepository interface {
List(context.Context, int, int) ([]domain.Content, error)
FindBySlug(context.Context, string) (domain.Content, error)
Update(context.Context, domain.Content, domain.ContentHistory) error
}

View file

@ -72,6 +72,22 @@ func (s *AuthService) AdminLogin(ctx context.Context, email string) (domain.Admi
func (s *AuthService) AdminAuthenticate(ctx context.Context, t string) (domain.Session, error) { func (s *AuthService) AdminAuthenticate(ctx context.Context, t string) (domain.Session, error) {
return s.adminSessions.FindValid(ctx, t) return s.adminSessions.FindValid(ctx, t)
} }
func (s *AuthService) UserByID(ctx context.Context, id uint) (domain.User, error) {
if lookup, ok := s.users.(repository.IUserLookup); ok {
return lookup.FindByID(ctx, id)
}
return domain.User{}, errors.New("user lookup unavailable")
}
func (s *AuthService) CanEdit(ctx context.Context, session domain.Session) bool {
if session.AdminID != nil {
return true
}
if session.UserID == nil {
return false
}
u, err := s.UserByID(ctx, *session.UserID)
return err == nil && (u.Editor || u.Role == "editor" || u.Role == "admin")
}
func newToken() (string, error) { func newToken() (string, error) {
b := make([]byte, 32) b := make([]byte, 32)
if _, e := rand.Read(b); e != nil { if _, e := rand.Read(b); e != nil {

View file

@ -0,0 +1,73 @@
package service
import (
"context"
"encoding/json"
"errors"
"github.com/example/cms/internal/domain"
"github.com/example/cms/internal/repository"
"regexp"
"strings"
"time"
)
type IContentService interface {
List(context.Context, int, int) ([]domain.Content, error)
Get(context.Context, string) (domain.Content, error)
Update(context.Context, domain.Content, domain.Session) (domain.Content, error)
}
type ContentService struct {
repo repository.IContentRepository
auth *AuthService
}
func NewContentService(r repository.IContentRepository, a *AuthService) *ContentService {
return &ContentService{repo: r, auth: a}
}
func (s *ContentService) List(ctx context.Context, limit, offset int) ([]domain.Content, error) {
if limit < 1 {
limit = 20
}
if limit > 100 {
limit = 100
}
if offset < 0 {
offset = 0
}
return s.repo.List(ctx, limit, offset)
}
func (s *ContentService) Get(ctx context.Context, slug string) (domain.Content, error) {
if !validSlug(slug) {
return domain.Content{}, errors.New("invalid slug")
}
return s.repo.FindBySlug(ctx, slug)
}
func (s *ContentService) Update(ctx context.Context, c domain.Content, session domain.Session) (domain.Content, error) {
if s.auth == nil || !s.auth.CanEdit(ctx, session) {
return domain.Content{}, errors.New("forbidden")
}
if !validSlug(c.Slug) || strings.TrimSpace(c.Title) == "" || strings.TrimSpace(c.Body) == "" {
return domain.Content{}, errors.New("invalid content")
}
old, err := s.repo.FindBySlug(ctx, c.Slug)
if err != nil {
return domain.Content{}, err
}
c.ID = old.ID
c.CreatedAt = old.CreatedAt
c.CreatedBy = old.CreatedBy
c.UpdatedAt = time.Now()
if session.UserID != nil {
c.UpdatedBy = *session.UserID
}
snapshot, _ := json.Marshal(c)
h := domain.ContentHistory{ContentID: c.ID, Operation: "updated", CreatedAt: c.UpdatedAt, Snapshot: string(snapshot), ActorID: session.UserID, ActorAdminID: session.AdminID}
if err = s.repo.Update(ctx, c, h); err != nil {
return domain.Content{}, err
}
return c, nil
}
var slugRE = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)
func validSlug(s string) bool { return len(s) <= 200 && slugRE.MatchString(s) }

View file

@ -4,6 +4,8 @@ import (
"crypto/rand" "crypto/rand"
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"fmt"
"github.com/example/cms/internal/domain"
"github.com/example/cms/internal/identity" "github.com/example/cms/internal/identity"
"github.com/example/cms/internal/service" "github.com/example/cms/internal/service"
"github.com/example/cms/internal/web/mapping" "github.com/example/cms/internal/web/mapping"
@ -15,11 +17,15 @@ import (
type Handler struct { type Handler struct {
auth *service.AuthService auth *service.AuthService
google identity.GoogleProvider google identity.GoogleProvider
content service.IContentService
} }
func NewHandler(a *service.AuthService, google identity.GoogleProvider) *Handler { func NewHandler(a *service.AuthService, google identity.GoogleProvider) *Handler {
return &Handler{auth: a, google: google} return &Handler{auth: a, google: google}
} }
func NewHandlerWithContent(a *service.AuthService, google identity.GoogleProvider, content service.IContentService) *Handler {
return &Handler{auth: a, google: google, content: content}
}
func (h *Handler) Routes(origins string) http.Handler { func (h *Handler) Routes(origins string) http.Handler {
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { jsonWrite(w, 200, map[string]string{"status": "ok"}) }) mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { jsonWrite(w, 200, map[string]string{"status": "ok"}) })
@ -29,6 +35,9 @@ func (h *Handler) Routes(origins string) http.Handler {
mux.HandleFunc("/api/auth/session", h.session) mux.HandleFunc("/api/auth/session", h.session)
mux.HandleFunc("/admin/login", h.adminLogin) mux.HandleFunc("/admin/login", h.adminLogin)
mux.HandleFunc("/api/test/protected", h.protected) mux.HandleFunc("/api/test/protected", h.protected)
mux.HandleFunc("/api/protected", h.protected)
mux.HandleFunc("/api/content", h.contentList)
mux.HandleFunc("/api/content/", h.contentDetail)
return cors(mux, origins) return cors(mux, origins)
} }
func (h *Handler) googleLogin(w http.ResponseWriter, r *http.Request) { func (h *Handler) googleLogin(w http.ResponseWriter, r *http.Request) {
@ -77,7 +86,12 @@ func (h *Handler) session(w http.ResponseWriter, r *http.Request) {
jsonWrite(w, http.StatusUnauthorized, map[string]string{"authenticated": "false"}) jsonWrite(w, http.StatusUnauthorized, map[string]string{"authenticated": "false"})
return return
} }
jsonWrite(w, http.StatusOK, map[string]bool{"authenticated": true}) u, err := h.auth.UserByID(r.Context(), *s.UserID)
if err != nil {
jsonWrite(w, http.StatusUnauthorized, map[string]string{"authenticated": "false"})
return
}
jsonWrite(w, http.StatusOK, map[string]any{"authenticated": true, "user": u, "canEdit": h.auth.CanEdit(r.Context(), s)})
} }
func (h *Handler) login(w http.ResponseWriter, r *http.Request) { func (h *Handler) login(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" { if r.Method != "POST" {
@ -128,6 +142,87 @@ func (h *Handler) protected(w http.ResponseWriter, r *http.Request) {
} }
jsonWrite(w, 200, map[string]string{"status": "authenticated"}) jsonWrite(w, 200, map[string]string{"status": "authenticated"})
} }
func (h *Handler) sessionForRequest(r *http.Request) (domain.Session, bool) {
t := bearerOrCookie(r)
if t == "" {
return domain.Session{}, false
}
if s, e := h.auth.Authenticate(r.Context(), t); e == nil {
return s, true
}
if s, e := h.auth.AdminAuthenticate(r.Context(), t); e == nil {
return s, true
}
return domain.Session{}, false
}
func (h *Handler) contentList(w http.ResponseWriter, r *http.Request) {
if h.content == nil {
jsonWrite(w, 503, map[string]string{"error": "content unavailable"})
return
}
if _, ok := h.sessionForRequest(r); !ok {
jsonWrite(w, 401, map[string]string{"error": "authentication required"})
return
}
if r.Method != "GET" {
jsonWrite(w, http.StatusMethodNotAllowed, nil)
return
}
limit, offset := 20, 0
_, _ = fmt.Sscanf(r.URL.Query().Get("limit"), "%d", &limit)
_, _ = fmt.Sscanf(r.URL.Query().Get("offset"), "%d", &offset)
items, e := h.content.List(r.Context(), limit, offset)
if e != nil {
jsonWrite(w, 500, map[string]string{"error": e.Error()})
return
}
jsonWrite(w, 200, map[string]any{"items": items, "limit": limit, "offset": offset})
}
func (h *Handler) contentDetail(w http.ResponseWriter, r *http.Request) {
if h.content == nil {
jsonWrite(w, 503, map[string]string{"error": "content unavailable"})
return
}
slug := strings.TrimPrefix(r.URL.Path, "/api/content/")
if r.Method == "GET" {
if _, ok := h.sessionForRequest(r); !ok {
jsonWrite(w, 401, map[string]string{"error": "authentication required"})
return
}
c, e := h.content.Get(r.Context(), slug)
if e != nil {
jsonWrite(w, 404, map[string]string{"error": "content not found"})
return
}
jsonWrite(w, 200, c)
return
}
if r.Method != "PUT" {
jsonWrite(w, 405, nil)
return
}
s, ok := h.sessionForRequest(r)
if !ok {
jsonWrite(w, 401, map[string]string{"error": "authentication required"})
return
}
if !h.auth.CanEdit(r.Context(), s) {
jsonWrite(w, 403, map[string]string{"error": "forbidden"})
return
}
var c domain.Content
if !decode(r, &c) {
jsonWrite(w, 400, map[string]string{"error": "invalid JSON"})
return
}
c.Slug = slug
out, e := h.content.Update(r.Context(), c, s)
if e != nil {
jsonWrite(w, 400, map[string]string{"error": e.Error()})
return
}
jsonWrite(w, 200, out)
}
func bearerOrCookie(r *http.Request) string { func bearerOrCookie(r *http.Request) string {
header := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")) header := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "))
if header != "" { if header != "" {
@ -155,7 +250,7 @@ func cors(next http.Handler, origin string) http.Handler {
w.Header().Set("Access-Control-Allow-Origin", origin) w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Vary", "Origin") w.Header().Set("Vary", "Origin")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, OPTIONS")
if r.Method == "OPTIONS" { if r.Method == "OPTIONS" {
w.WriteHeader(204) w.WriteHeader(204)
return return

View file

@ -1,43 +1,140 @@
import { Navigate, Route, Routes, Link, useLocation, useNavigate } from 'react-router-dom' import { useEffect, useRef, useState } from 'react'
import { Link, Navigate, Route, Routes, useLocation, useNavigate, useParams } from 'react-router-dom'
import { getContent, listContent, updateContent, type Content, type ContentUpdate } from './api'
import { useAuth } from './auth' import { useAuth } from './auth'
function Shell({ children, admin = false }: { children: React.ReactNode; admin?: boolean }) { function Shell({ children }: { children: React.ReactNode }) {
const { user, signOut } = useAuth() const { user, signOut } = useAuth()
return <main className="shell"> return <main className="shell">
<header className="header"><Link className="brand" to="/">CMS</Link>{user && !admin && <button className="text-button" onClick={() => void signOut()}>Sign out</button>}</header> <header className="header"><Link className="brand" to="/">CMS</Link>{user && <button className="text-button" onClick={() => void signOut()}>Sign out</button>}</header>
{children} {children}
</main> </main>
} }
function Login({ admin = false }: { admin?: boolean }) { function Login() {
const { user, loading, error, signIn } = useAuth() const { user, loading, error, signIn } = useAuth()
const navigate = useNavigate() if (user) return <Navigate to="/protected" replace />
if (!admin && user) return <Navigate to="/test" replace /> if (loading) return <Shell><div className="card"><p>Checking your session…</p></div></Shell>
if (loading) return <Shell admin={admin}><div className="card"><p>Checking your session…</p></div></Shell> return <Shell><section className="card auth-card">
return <Shell admin={admin}><section className="card auth-card"> <span className="eyebrow">Welcome</span><h1>Sign in to CMS</h1>
<span className="eyebrow">{admin ? 'Administration' : 'Welcome'}</span> <p className="muted">Sign in securely with your external identity provider.</p>
<h1>{admin ? 'Admin sign in' : 'Sign in to CMS'}</h1>
<p className="muted">{admin ? 'Use your administrator account to continue.' : 'Sign in securely with your external identity provider.'}</p>
{error && <p className="error" role="alert">{error}</p>} {error && <p className="error" role="alert">{error}</p>}
{!admin && <button className="google-button" onClick={() => void signIn()}><span aria-hidden="true">G</span> Continue with Google</button>} <button className="google-button" onClick={() => void signIn()}><span aria-hidden="true">G</span> Continue with Google</button>
{admin && <p className="muted small">Administrator authentication is handled by the backend.</p>}
{admin && <button className="secondary-button" onClick={() => navigate('/')}>Return to home</button>}
</section></Shell> </section></Shell>
} }
function ProtectedTest() { function RequireAuth({ children }: { children: React.ReactNode }) {
const { user, loading } = useAuth() const { user, loading } = useAuth()
const location = useLocation() const location = useLocation()
if (loading) return <Shell><div className="card"><p>Loading…</p></div></Shell> if (loading) return <Shell><div className="card"><p>Loading…</p></div></Shell>
if (!user) return <Navigate to="/" state={{ from: location }} replace /> if (!user) return <Navigate to="/" state={{ from: location }} replace />
return <Shell><section className="card"><span className="eyebrow">Authenticated</span><h1>Test page</h1><p>You are signed in and can access protected content.</p><div className="profile">{user.email ?? user.name ? `Signed in as ${user.email ?? user.name}` : 'Your session is active.'}</div></section></Shell> return <>{children}</>
}
function ContentCard({ item }: { item: Content }) {
return <article className="content-card">
<span className="status">{item.status}</span>
<h2><Link to={`/content/${item.id}`}>{item.title}</Link></h2>
<p>{item.summary}</p>
<Link className="read-link" to={`/content/${item.id}`}>Read more <span aria-hidden="true">→</span></Link>
</article>
}
function ContentList() {
const [items, setItems] = useState<Content[]>([])
const [cursor, setCursor] = useState<string | null | undefined>(undefined)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const loadingMore = useRef(false)
const sentinel = useRef<HTMLDivElement>(null)
const load = async (next = false) => {
if (loadingMore.current || (next && !cursor)) return
loadingMore.current = true
setLoading(true)
try {
const response = await listContent(next ? cursor : undefined)
const page = Array.isArray(response) ? response : response.items ?? response.content ?? response.data ?? []
setItems(current => next ? [...current, ...page] : page)
setCursor(Array.isArray(response) ? null : response.nextCursor ?? response.cursor ?? (response.hasMore ? undefined : null))
setError(null)
} catch (err) {
setError(err instanceof Error ? err.message : 'Unable to load content.')
} finally {
loadingMore.current = false
setLoading(false)
}
}
useEffect(() => { void load() }, [])
useEffect(() => {
const node = sentinel.current
if (!node) return
const observer = new IntersectionObserver(entries => { if (entries[0]?.isIntersecting) void load(true) }, { rootMargin: '240px' })
observer.observe(node)
return () => observer.disconnect()
}, [cursor])
return <Shell><section className="content-page">
<div className="page-heading"><div><span className="eyebrow">Journal</span><h1>Latest content</h1></div></div>
{error && <p className="error" role="alert">{error}</p>}
{!loading && !error && items.length === 0 && <div className="card empty-state"><h2>No content yet</h2><p className="muted">There is nothing published here yet.</p></div>}
<div className="content-grid">{items.map(item => <ContentCard item={item} key={item.id} />)}</div>
<div ref={sentinel} className="list-status" aria-live="polite">{loading ? <p className="muted">Loading content…</p> : cursor ? <p className="muted">Loading more…</p> : items.length > 0 ? <p className="muted">You’ve reached the end.</p> : null}</div>
</section></Shell>
}
function ContentDetail() {
const { id = '' } = useParams()
const { user } = useAuth()
const canEdit = Boolean(user?.capabilities?.canEdit)
const [item, setItem] = useState<Content | null>(null)
const [editing, setEditing] = useState(false)
const [draft, setDraft] = useState<ContentUpdate | null>(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const navigate = useNavigate()
useEffect(() => {
let active = true
setLoading(true)
void getContent(id).then(value => { if (active) { setItem(value); setDraft({ title: value.title, summary: value.summary, body: value.body, status: value.status }) } })
.catch(err => active && setError(err instanceof Error ? err.message : 'Unable to load content.'))
.finally(() => active && setLoading(false))
return () => { active = false }
}, [id])
if (loading) return <Shell><div className="card"><p>Loading content…</p></div></Shell>
if (error || !item || !draft) return <Shell><div className="card"><p className="error" role="alert">{error ?? 'Content not found.'}</p><Link to="/content">Back to content</Link></div></Shell>
const save = async () => {
setSaving(true); setError(null)
try { const saved = await updateContent(id, draft); setItem(saved); setDraft({ title: saved.title, summary: saved.summary, body: saved.body, status: saved.status }); setEditing(false) }
catch (err) { setError(err instanceof Error ? err.message : 'Unable to save changes.') }
finally { setSaving(false) }
}
return <Shell><article className="detail-page">
<Link className="back-link" to="/content">← All content</Link>
{error && <p className="error" role="alert">{error}</p>}
{editing ? <div className="editor">
<label>Title<input value={draft.title} onChange={e => setDraft({ ...draft, title: e.target.value })} /></label>
<label>Summary<textarea rows={3} value={draft.summary} onChange={e => setDraft({ ...draft, summary: e.target.value })} /></label>
<label>Body<textarea rows={12} value={draft.body} onChange={e => setDraft({ ...draft, body: e.target.value })} /></label>
<label>Status<select value={draft.status} onChange={e => setDraft({ ...draft, status: e.target.value })}><option value="draft">Draft</option><option value="published">Published</option><option value="archived">Archived</option></select></label>
<div className="actions"><button className="primary-button" disabled={saving} onClick={() => void save()}>{saving ? 'Saving…' : 'Save changes'}</button><button className="secondary-button" onClick={() => setEditing(false)}>Cancel</button></div>
</div> : <><div className="detail-heading"><span className="status">{item.status}</span><h1>{item.title}</h1><p className="muted">{item.summary}</p>{canEdit && <button className="primary-button edit-button" onClick={() => setEditing(true)}>Edit</button>}</div><div className="body-copy">{item.body}</div></>}
</article></Shell>
}
function ProtectedTest() {
const { user } = useAuth()
return <Shell><section className="card protected-card"><span className="eyebrow">Authenticated</span><h1>Protected content</h1><p>You are signed in and can access protected content.</p><div className="profile">{user?.email ?? user?.name ? `Signed in as ${user.email ?? user.name}` : 'Your session is active.'}</div><Link className="primary-button inline-button" to="/content">Browse content</Link></section></Shell>
} }
export function App() { export function App() {
return <Routes> return <Routes>
<Route path="/" element={<Login />} /> <Route path="/" element={<Login />} />
<Route path="/admin/login" element={<Login admin />} /> <Route path="/protected" element={<RequireAuth><ProtectedTest /></RequireAuth>} />
<Route path="/test" element={<ProtectedTest />} /> <Route path="/test" element={<RequireAuth><ProtectedTest /></RequireAuth>} />
<Route path="/content" element={<RequireAuth><ContentList /></RequireAuth>} />
<Route path="/content/:id" element={<RequireAuth><ContentDetail /></RequireAuth>} />
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>
} }

View file

@ -3,11 +3,14 @@ export type User = {
name?: string name?: string
email?: string email?: string
picture?: string picture?: string
capabilities?: Record<string, boolean>
} }
export type SessionResponse = { export type SessionResponse = {
authenticated?: boolean authenticated?: boolean
user?: User user?: User
capabilities?: Record<string, boolean>
canEdit?: boolean
token?: string token?: string
accessToken?: string accessToken?: string
sessionToken?: string sessionToken?: string
@ -18,6 +21,7 @@ export const apiConfig = {
google: import.meta.env.VITE_GOOGLE_AUTH_ENDPOINT ?? '/api/auth/google', google: import.meta.env.VITE_GOOGLE_AUTH_ENDPOINT ?? '/api/auth/google',
session: import.meta.env.VITE_SESSION_ENDPOINT ?? '/api/auth/session', session: import.meta.env.VITE_SESSION_ENDPOINT ?? '/api/auth/session',
logout: import.meta.env.VITE_LOGOUT_ENDPOINT ?? '/api/auth/logout', logout: import.meta.env.VITE_LOGOUT_ENDPOINT ?? '/api/auth/logout',
content: import.meta.env.VITE_CONTENT_ENDPOINT ?? '/api/content',
} }
export function apiUrl(path: string) { export function apiUrl(path: string) {
@ -32,7 +36,15 @@ export async function requestSession(): Promise<SessionResponse | null> {
}) })
if (response.status === 401 || response.status === 404) return null if (response.status === 401 || response.status === 404) return null
if (!response.ok) throw new Error('Unable to check your session.') if (!response.ok) throw new Error('Unable to check your session.')
return (await response.json()) as SessionResponse const value = (await response.json()) as SessionResponse & { CanEdit?: boolean; User?: User }
if (value.User && !value.user) value.user = value.User
if (value.canEdit === undefined && value.CanEdit !== undefined) value.canEdit = value.CanEdit
if (value.user) value.user = normalizeUser(value.user)
return value
}
function normalizeUser(value: User & { ID?: string; Email?: string; Name?: string; Editor?: boolean }): User {
return { id: value.id ?? value.ID, email: value.email ?? value.Email, name: value.name ?? value.Name, picture: value.picture, capabilities: { ...(value.capabilities ?? {}), ...(value.Editor === true ? { canEdit: true } : {}) } }
} }
export async function signInWithGoogle(): Promise<SessionResponse> { export async function signInWithGoogle(): Promise<SessionResponse> {
@ -47,3 +59,64 @@ export async function signOut() {
headers: { Accept: 'application/json' }, headers: { Accept: 'application/json' },
}) })
} }
export type Content = {
id: string
title: string
summary: string
body: string
status: string
createdAt?: string
updatedAt?: string
}
export type ContentListResponse = {
items?: Content[]
content?: Content[]
data?: Content[]
nextCursor?: string | null
cursor?: string | null
hasMore?: boolean
offset?: number
limit?: number
}
function normalizeContent(value: Content & { ID?: string; Slug?: string; Title?: string; Summary?: string; Body?: string; Status?: string }): Content {
return { id: value.id ?? value.Slug ?? String(value.ID ?? ''), title: value.title ?? value.Title ?? '', summary: value.summary ?? value.Summary ?? '', body: value.body ?? value.Body ?? '', status: value.status ?? value.Status ?? '', createdAt: value.createdAt, updatedAt: value.updatedAt }
}
async function contentRequest<T>(path = '', init?: RequestInit): Promise<T> {
const response = await fetch(apiUrl(`${apiConfig.content}${path}`), {
credentials: 'include',
...init,
headers: { Accept: 'application/json', ...(init?.body ? { 'Content-Type': 'application/json' } : {}), ...init?.headers },
})
if (!response.ok) {
if (response.status === 401) throw new Error('Please sign in to view content.')
throw new Error((await response.text()) || 'Unable to load content.')
}
return (await response.json()) as T
}
export async function listContent(cursor?: string | null): Promise<ContentListResponse> {
const query = cursor ? `?offset=${encodeURIComponent(cursor)}` : ''
const response = await contentRequest<ContentListResponse | (Content & { ID?: string; Slug?: string; Title?: string; Summary?: string; Body?: string; Status?: string })[]>(query)
if (Array.isArray(response)) return { items: response.map(normalizeContent), nextCursor: null }
const items = (response.items ?? response.content ?? response.data ?? []).map(normalizeContent)
const offset = response.offset ?? (cursor ? Number(cursor) : 0)
const limit = response.limit ?? 20
return { ...response, items, nextCursor: items.length >= limit ? String(offset + items.length) : null }
}
export function getContent(id: string): Promise<Content> {
return contentRequest<Content>(`/${encodeURIComponent(id)}`).then(value => normalizeContent(value as Content & { ID?: string; Slug?: string; Title?: string; Summary?: string; Body?: string; Status?: string }))
}
export type ContentUpdate = Pick<Content, 'title' | 'summary' | 'body' | 'status'>
export function updateContent(id: string, content: ContentUpdate): Promise<Content> {
return contentRequest<Content>(`/${encodeURIComponent(id)}`, {
method: 'PUT',
body: JSON.stringify(content),
}).then(value => normalizeContent(value as Content & { ID?: string; Slug?: string; Title?: string; Summary?: string; Body?: string; Status?: string }))
}

View file

@ -11,10 +11,12 @@ type AuthState = {
const AuthContext = createContext<AuthState | undefined>(undefined) const AuthContext = createContext<AuthState | undefined>(undefined)
function getUser(result: { authenticated?: boolean; user?: User; token?: string; accessToken?: string; sessionToken?: string }) { function getUser(result: { authenticated?: boolean; user?: User; capabilities?: Record<string, boolean>; canEdit?: boolean; token?: string; accessToken?: string; sessionToken?: string }) {
const token = result.token ?? result.accessToken ?? result.sessionToken const token = result.token ?? result.accessToken ?? result.sessionToken
if (token) sessionStorage.setItem('cms.auth.token', token) if (token) sessionStorage.setItem('cms.auth.token', token)
return result.authenticated === false ? null : result.user ?? (token ? {} : null) if (result.authenticated === false) return null
if (!result.user && !token && result.authenticated !== true) return null
return { ...(result.user ?? {}), capabilities: { ...result.capabilities, ...(result.canEdit !== undefined ? { canEdit: result.canEdit } : {}), ...result.user?.capabilities } }
} }
export function AuthProvider({ children }: { children: ReactNode }) { export function AuthProvider({ children }: { children: ReactNode }) {

View file

@ -2,7 +2,7 @@
* { box-sizing: border-box; } * { box-sizing: border-box; }
body { margin: 0; min-width: 320px; } body { margin: 0; min-width: 320px; }
button, a { font: inherit; } button, a { font: inherit; }
.shell { min-height: 100vh; max-width: 760px; margin: auto; padding: 0 24px; } .shell { min-height: 100vh; max-width: 960px; margin: auto; padding: 0 24px 64px; }
.header { height: 76px; display: flex; align-items: center; justify-content: space-between; } .header { height: 76px; display: flex; align-items: center; justify-content: space-between; }
.brand { color: #172033; font-size: 1.15rem; font-weight: 750; text-decoration: none; letter-spacing: -.02em; } .brand { color: #172033; font-size: 1.15rem; font-weight: 750; text-decoration: none; letter-spacing: -.02em; }
.text-button { border: 0; background: transparent; color: #536176; cursor: pointer; padding: 8px; } .text-button { border: 0; background: transparent; color: #536176; cursor: pointer; padding: 8px; }
@ -20,4 +20,36 @@ p { line-height: 1.6; }
.secondary-button { margin-top: 20px; border: 0; background: #edf1f8; color: #344563; } .secondary-button { margin-top: 20px; border: 0; background: #edf1f8; color: #344563; }
.error { padding: 10px 12px; border-radius: 8px; color: #a32d2d; background: #fff0f0; font-size: .9rem; } .error { padding: 10px 12px; border-radius: 8px; color: #a32d2d; background: #fff0f0; font-size: .9rem; }
.profile { margin-top: 24px; padding: 14px; border-radius: 8px; background: #f5f7fb; color: #536176; } .profile { margin-top: 24px; padding: 14px; border-radius: 8px; background: #f5f7fb; color: #536176; }
.content-page, .detail-page { padding-top: 6vh; }
.page-heading { display: flex; justify-content: space-between; align-items: end; margin-bottom: 28px; }
.content-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; }
.content-card { padding: 26px; background: #fff; border: 1px solid #e3e8f0; border-radius: 14px; box-shadow: 0 10px 30px #17203308; }
.content-card h2 { margin: 12px 0 8px; font-size: 1.3rem; letter-spacing: -.025em; }
.content-card h2 a { color: #172033; text-decoration: none; }
.content-card h2 a:hover, .read-link:hover { color: #536fc4; }
.content-card p { color: #657286; margin: 0 0 18px; }
.status { display: inline-block; color: #536fc4; background: #eef2ff; border-radius: 999px; padding: 4px 9px; font-size: .72rem; font-weight: 700; text-transform: uppercase; letter-spacing: .06em; }
.read-link, .back-link { color: #344f9b; font-weight: 650; text-decoration: none; }
.list-status { text-align: center; min-height: 54px; padding: 14px; }
.empty-state { max-width: none; text-align: center; margin-top: 0; }
.detail-page { max-width: 720px; margin: auto; }
.back-link { display: inline-block; margin-bottom: 32px; }
.detail-heading { position: relative; padding-bottom: 26px; border-bottom: 1px solid #e3e8f0; }
.detail-heading h1 { margin-top: 14px; }
.detail-heading .muted { font-size: 1.1rem; }
.edit-button { position: absolute; top: 0; right: 0; }
.body-copy { white-space: pre-wrap; line-height: 1.8; padding-top: 28px; }
.editor { display: grid; gap: 18px; }
.editor label { display: grid; gap: 7px; color: #344563; font-size: .9rem; font-weight: 650; }
.editor input, .editor textarea, .editor select { width: 100%; border: 1px solid #ccd4e0; border-radius: 8px; padding: 11px 12px; background: #fff; color: #172033; font: inherit; font-weight: 400; }
.editor textarea { resize: vertical; }
.actions { display: flex; gap: 10px; margin-top: 4px; }
.primary-button, .secondary-button { border: 0; border-radius: 8px; min-height: 42px; padding: 0 17px; cursor: pointer; font-weight: 650; text-decoration: none; display: inline-flex; align-items: center; justify-content: center; }
.primary-button { background: #536fc4; color: #fff; }
.primary-button:hover { background: #405bac; }
.primary-button:disabled { opacity: .6; cursor: wait; }
.actions .secondary-button { margin: 0; background: #edf1f8; color: #344563; }
.inline-button { margin-top: 22px; }
.protected-card { text-align: center; }
@media (max-width: 480px) { .shell { padding: 0 16px; } .card { padding: 28px 22px; } } @media (max-width: 480px) { .shell { padding: 0 16px; } .card { padding: 28px 22px; } }
@media (max-width: 640px) { .content-grid { grid-template-columns: 1fr; } .content-page, .detail-page { padding-top: 3vh; } .edit-button { position: static; margin-top: 10px; } }

View file

@ -30,3 +30,31 @@ React. Keep this changelog in `ui/changelog.md`.
- Added environment examples, ignore rules, README documentation, and backend - Added environment examples, ignore rules, README documentation, and backend
unit tests. unit tests.
- Verified Go tests and prepared frontend typecheck/build scripts. - Verified Go tests and prepared frontend typecheck/build scripts.
## 2026-09-19 — Content and authorization
### Prompt
Fix the missing protected page and add a blog-like content experience with
infinite scrolling summaries, detail links, capability-controlled editing and
saving. Persist logged-in users so authorities can be assigned. Add a content
data model with creation/edit audit information and a history table recording
changes. Implement the domain model, mappers, web handlers, services,
repositories, migrations, and pages.
### Tasks completed
- Added persisted user role/editor authorization fields and generic `canEdit`
capability evaluation; administrators can edit through the same capability.
- Added content and content-history domain models, GORM models, explicit
mappers, repositories, services, and a Goose PostgreSQL migration.
- Added authenticated content listing/detail/update endpoints with pagination,
validation, 401/403 handling, audit fields, and transactional history
snapshots.
- Added `/protected` and `/api/protected`; retained `/test` and
`/api/test/protected` compatibility routes.
- Added the authenticated infinite-scroll content page, detail page, and
capability-gated edit/save mode.
- Exposed the authenticated user and generic capabilities in the session
response; no admin link was added to user-facing navigation.
- Revalidated with `go test ./...`, `npm run typecheck`, and `npm run build`.