From fd2877da9e2c2c75546ad809d6059d06ac07d7e4 Mon Sep 17 00:00:00 2001 From: Daniel Noulet Date: Sat, 19 Sep 2026 14:56:49 +0200 Subject: [PATCH] Add authenticated content editing Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cmd/server/main.go | 3 +- .../migrations/postgres/002_content.sql | 32 +++++ internal/domain/models.go | 21 +++ internal/persistence/mapping.go | 10 +- internal/persistence/models.go | 28 ++++ internal/repository/gorm.go | 48 +++++-- internal/repository/interfaces.go | 8 ++ internal/service/auth.go | 16 +++ internal/service/content.go | 73 ++++++++++ internal/web/handlers.go | 103 +++++++++++++- ui/app/src/App.tsx | 133 +++++++++++++++--- ui/app/src/api.ts | 75 +++++++++- ui/app/src/auth.tsx | 6 +- ui/app/src/styles.css | 34 ++++- ui/changelog.md | 28 ++++ 15 files changed, 581 insertions(+), 37 deletions(-) create mode 100644 cmd/server/migrations/postgres/002_content.sql create mode 100644 internal/service/content.go diff --git a/cmd/server/main.go b/cmd/server/main.go index 1b8588c..fdc6aa1 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -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) } } diff --git a/cmd/server/migrations/postgres/002_content.sql b/cmd/server/migrations/postgres/002_content.sql new file mode 100644 index 0000000..342d711 --- /dev/null +++ b/cmd/server/migrations/postgres/002_content.sql @@ -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; diff --git a/internal/domain/models.go b/internal/domain/models.go index d70d40f..9540a2c 100644 --- a/internal/domain/models.go +++ b/internal/domain/models.go @@ -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 +} diff --git a/internal/persistence/mapping.go b/internal/persistence/mapping.go index d9df460..6f4377f 100644 --- a/internal/persistence/mapping.go +++ b/internal/persistence/mapping.go @@ -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} diff --git a/internal/persistence/models.go b/internal/persistence/models.go index d351cd5..7629342 100644 --- a/internal/persistence/models.go +++ b/internal/persistence/models.go @@ -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 { diff --git a/internal/repository/gorm.go b/internal/repository/gorm.go index dda3aab..bf0e9dd 100644 --- a/internal/repository/gorm.go +++ b/internal/repository/gorm.go @@ -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 + }) +} diff --git a/internal/repository/interfaces.go b/internal/repository/interfaces.go index 70d0d4c..48a9a0a 100644 --- a/internal/repository/interfaces.go +++ b/internal/repository/interfaces.go @@ -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 +} diff --git a/internal/service/auth.go b/internal/service/auth.go index 26118e6..2bb7245 100644 --- a/internal/service/auth.go +++ b/internal/service/auth.go @@ -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 { diff --git a/internal/service/content.go b/internal/service/content.go new file mode 100644 index 0000000..5b6555f --- /dev/null +++ b/internal/service/content.go @@ -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) } diff --git a/internal/web/handlers.go b/internal/web/handlers.go index fb2c6e0..73b4226 100644 --- a/internal/web/handlers.go +++ b/internal/web/handlers.go @@ -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 diff --git a/ui/app/src/App.tsx b/ui/app/src/App.tsx index 011816c..718de98 100644 --- a/ui/app/src/App.tsx +++ b/ui/app/src/App.tsx @@ -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
-
CMS{user && !admin && }
+
CMS{user && }
{children}
} -function Login({ admin = false }: { admin?: boolean }) { +function Login() { const { user, loading, error, signIn } = useAuth() - const navigate = useNavigate() - if (!admin && user) return - if (loading) return

Checking your session…

- return
- {admin ? 'Administration' : 'Welcome'} -

{admin ? 'Admin sign in' : 'Sign in to CMS'}

-

{admin ? 'Use your administrator account to continue.' : 'Sign in securely with your external identity provider.'}

+ if (user) return + if (loading) return

Checking your session…

+ return
+ Welcome

Sign in to CMS

+

Sign in securely with your external identity provider.

{error &&

{error}

} - {!admin && } - {admin &&

Administrator authentication is handled by the backend.

} - {admin && } +
} -function ProtectedTest() { +function RequireAuth({ children }: { children: React.ReactNode }) { const { user, loading } = useAuth() const location = useLocation() if (loading) return

Loading…

if (!user) return - return
Authenticated

Test page

You are signed in and can access protected content.

{user.email ?? user.name ? `Signed in as ${user.email ?? user.name}` : 'Your session is active.'}
+ return <>{children} +} + +function ContentCard({ item }: { item: Content }) { + return
+ {item.status} +

{item.title}

+

{item.summary}

+ Read more +
+} + +function ContentList() { + const [items, setItems] = useState([]) + const [cursor, setCursor] = useState(undefined) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const loadingMore = useRef(false) + const sentinel = useRef(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
+
Journal

Latest content

+ {error &&

{error}

} + {!loading && !error && items.length === 0 &&

No content yet

There is nothing published here yet.

} +
{items.map(item => )}
+
{loading ?

Loading content…

: cursor ?

Loading more…

: items.length > 0 ?

You’ve reached the end.

: null}
+
+} + +function ContentDetail() { + const { id = '' } = useParams() + const { user } = useAuth() + const canEdit = Boolean(user?.capabilities?.canEdit) + const [item, setItem] = useState(null) + const [editing, setEditing] = useState(false) + const [draft, setDraft] = useState(null) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [error, setError] = useState(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

Loading content…

+ if (error || !item || !draft) return

{error ?? 'Content not found.'}

Back to content
+ 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
+ ← All content + {error &&

{error}

} + {editing ?
+ +