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),
cfg.SessionTTL,
)
content := service.NewContentService(repository.NewContentRepository(applicationDB), auth)
google := identity.GoogleProvider{ClientID: cfg.GoogleClientID, ClientSecret: cfg.GoogleClientSecret, RedirectURL: cfg.GoogleRedirectURL}
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)
}
}

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
Email, Name string
Provider, ProviderSubject string
Role string
Editor bool
CreatedAt time.Time
}
type Admin struct {
@ -20,3 +22,22 @@ type Session struct {
AdminID *uint
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"
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 {
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 {
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
Provider string `gorm:"not null"`
ProviderSubject string `gorm:"uniqueIndex;not null"`
Role string `gorm:"not null;default:''"`
Editor bool `gorm:"not null;default:false"`
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" }
type AdminModel struct {

View file

@ -27,15 +27,21 @@ func (r *GORMUserRepository) FindOrCreateByIdentity(ctx context.Context, d domai
}
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 }
func NewAdminRepository(db *gorm.DB) IAdminRepository { return &GORMAdminRepository{db} }
func (r *GORMAdminRepository) FindByEmail(ctx context.Context, email string) (domain.Admin, error) {
var m persistence.AdminModel
e := r.db.WithContext(ctx).Where("email = ?", email).First(&m).Error
if e != nil {
return domain.Admin{}, e
if err := r.db.WithContext(ctx).Where("email = ?", email).First(&m).Error; err != nil {
return domain.Admin{}, err
}
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 (r *GORMSessionRepository) Create(ctx context.Context, d domain.Session) error {
model := persistence.SessionFromDomain(d)
return r.db.WithContext(ctx).Create(&model).Error
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
}
func (r *GORMSessionRepository) FindValid(ctx context.Context, t string) (domain.Session, error) {
var m persistence.SessionModel
e := r.db.WithContext(ctx).Where("token = ? AND expires_at > ?", t, time.Now()).First(&m).Error
if e != nil {
return domain.Session{}, e
if err := r.db.WithContext(ctx).Where("token = ? AND expires_at > ?", t, time.Now()).First(&m).Error; err != nil {
return domain.Session{}, err
}
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 {
FindOrCreateByIdentity(context.Context, domain.User) (domain.User, error)
}
type IUserLookup interface {
FindByID(context.Context, uint) (domain.User, error)
}
type IAdminRepository interface {
FindByEmail(context.Context, string) (domain.Admin, error)
}
@ -15,3 +18,8 @@ type ISessionRepository interface {
Create(context.Context, 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) {
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) {
b := make([]byte, 32)
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"
"encoding/base64"
"encoding/json"
"fmt"
"github.com/example/cms/internal/domain"
"github.com/example/cms/internal/identity"
"github.com/example/cms/internal/service"
"github.com/example/cms/internal/web/mapping"
@ -13,13 +15,17 @@ import (
)
type Handler struct {
auth *service.AuthService
google identity.GoogleProvider
auth *service.AuthService
google identity.GoogleProvider
content service.IContentService
}
func NewHandler(a *service.AuthService, google identity.GoogleProvider) *Handler {
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 {
mux := http.NewServeMux()
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("/admin/login", h.adminLogin)
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)
}
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"})
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) {
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"})
}
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 {
header := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "))
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("Vary", "Origin")
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" {
w.WriteHeader(204)
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'
function Shell({ children, admin = false }: { children: React.ReactNode; admin?: boolean }) {
function Shell({ children }: { children: React.ReactNode }) {
const { user, signOut } = useAuth()
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}
</main>
}
function Login({ admin = false }: { admin?: boolean }) {
function Login() {
const { user, loading, error, signIn } = useAuth()
const navigate = useNavigate()
if (!admin && user) return <Navigate to="/test" replace />
if (loading) return <Shell admin={admin}><div className="card"><p>Checking your session…</p></div></Shell>
return <Shell admin={admin}><section className="card auth-card">
<span className="eyebrow">{admin ? 'Administration' : 'Welcome'}</span>
<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>
if (user) return <Navigate to="/protected" replace />
if (loading) return <Shell><div className="card"><p>Checking your session…</p></div></Shell>
return <Shell><section className="card auth-card">
<span className="eyebrow">Welcome</span><h1>Sign in to CMS</h1>
<p className="muted">Sign in securely with your external identity provider.</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>}
{admin && <p className="muted small">Administrator authentication is handled by the backend.</p>}
{admin && <button className="secondary-button" onClick={() => navigate('/')}>Return to home</button>}
<button className="google-button" onClick={() => void signIn()}><span aria-hidden="true">G</span> Continue with Google</button>
</section></Shell>
}
function ProtectedTest() {
function RequireAuth({ children }: { children: React.ReactNode }) {
const { user, loading } = useAuth()
const location = useLocation()
if (loading) return <Shell><div className="card"><p>Loading…</p></div></Shell>
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() {
return <Routes>
<Route path="/" element={<Login />} />
<Route path="/admin/login" element={<Login admin />} />
<Route path="/test" element={<ProtectedTest />} />
<Route path="/protected" element={<RequireAuth><ProtectedTest /></RequireAuth>} />
<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 />} />
</Routes>
}

View file

@ -3,11 +3,14 @@ export type User = {
name?: string
email?: string
picture?: string
capabilities?: Record<string, boolean>
}
export type SessionResponse = {
authenticated?: boolean
user?: User
capabilities?: Record<string, boolean>
canEdit?: boolean
token?: string
accessToken?: string
sessionToken?: string
@ -18,6 +21,7 @@ export const apiConfig = {
google: import.meta.env.VITE_GOOGLE_AUTH_ENDPOINT ?? '/api/auth/google',
session: import.meta.env.VITE_SESSION_ENDPOINT ?? '/api/auth/session',
logout: import.meta.env.VITE_LOGOUT_ENDPOINT ?? '/api/auth/logout',
content: import.meta.env.VITE_CONTENT_ENDPOINT ?? '/api/content',
}
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.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> {
@ -47,3 +59,64 @@ export async function signOut() {
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)
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
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 }) {

View file

@ -2,7 +2,7 @@
* { box-sizing: border-box; }
body { margin: 0; min-width: 320px; }
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; }
.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; }
@ -20,4 +20,36 @@ p { line-height: 1.6; }
.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; }
.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: 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
unit tests.
- 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`.