Add authenticated content editing
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
fac7588d23
commit
fd2877da9e
15 changed files with 581 additions and 37 deletions
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
32
cmd/server/migrations/postgres/002_content.sql
Normal file
32
cmd/server/migrations/postgres/002_content.sql
Normal 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;
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
73
internal/service/content.go
Normal file
73
internal/service/content.go
Normal 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) }
|
||||
|
|
@ -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"
|
||||
|
|
@ -15,11 +17,15 @@ import (
|
|||
type Handler struct {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }) {
|
||||
|
|
|
|||
|
|
@ -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; } }
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue