diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 429be93..4665f0f 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -13,8 +13,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- - uses: actions/setup-go@v5
+ - uses: actions/setup-go@v6
with:
- go-version: '1.26'
+ go-version-file: go.mod
- run: go vet ./...
- run: go test -race ./...
diff --git a/README.md b/README.md
index b773fc7..0df0c55 100644
--- a/README.md
+++ b/README.md
@@ -48,16 +48,47 @@ until mirrored into site-owned storage.
The `cms.engine` module exposes the strict service method
`CMSEngine.AdminContribution`. Hosts such as `gocodealone-multisite` can call it
-to register the CMS site manager inside the extensible admin shell.
+to register the site editor inside the extensible admin shell. Site content and
+platform administration use separate documents and mounts:
+
+```go
+editor := adminui.HandlerWithOptions(adminui.Options{
+ Mode: adminui.Editor, BasePath: "/admin/cms/sites",
+})
+platform := adminui.HandlerWithOptions(adminui.Options{
+ Mode: adminui.Platform, BasePath: "/admin/cms/platform",
+})
+```
+
+`adminui.Handler()` defaults to the editor at `/admin`. Protect every editor
+request with the host's current identity and tenant membership, and every
+platform request with current platform authority. The handlers only render UI;
+they do not replace authorization. `host.Config.AdminPlatformAccess` is required
+for tenant mutations, domain reads/writes and global cache reload. Those API
+operations fail closed when the callback is absent, including when `AdminAPI`
+is called directly. `AdminAuth` and `AdminTenantAccess` remain required for the
+private page permission projection at
+`GET /api/v1/admin/tenants/:tid/pages/permissions[?page_id=:pid]`. This endpoint
+probes the configured callbacks without dispatching writes and returns create,
+edit and delete capabilities for the specific tenant/page. UI visibility follows
+that projection; write handlers still enforce authorization independently.
+
+Each page has Content, Appearance, Publishing, HTML and Preview routes. Content
+routes select one top-level body section; plain top-level text uses a whole-body
+fallback. Section switching preserves the canonical body. The editor warns
+before leaving an unsaved page and freezes the captured draft during Save.
+Platform tenant/domain/cache controls are absent from the editor document.
The contribution requires the multisite admin scopes:
- `admin:multisite.sites:read`
-- `admin:multisite.sites:update`
- `admin:multisite.pages:read`
- `admin:multisite.pages:update`
- `admin:multisite.publish:update`
-- `admin:multisite.onboarding:plan`
+
+Platform contributions belong to the host and require its platform-specific
+permission plus current platform authority. The CMS content contribution no
+longer advertises tenant creation, domain controls or onboarding operations.
API route metadata is returned only when the caller passes `authorized: true`.
That keeps route discovery behind the host's authz check while preserving the
diff --git a/adminui/adminui.go b/adminui/adminui.go
index 85beff4..02800fe 100644
--- a/adminui/adminui.go
+++ b/adminui/adminui.go
@@ -1,34 +1,148 @@
-// Package adminui serves the minimum-viable admin web app for the
-// multisite host. Embedded static assets — no JS build chain required.
-//
-// Per gocodealone-multisite SPEC T15. The shipped surface covers:
-//
-// - Tenant list + create
-// - Domain list per tenant
-// - Page list + create + edit + delete per tenant
-// - Reload tenants cache
-//
-// The host is responsible for wrapping this handler with AdminAuth before
-// exposing it in production.
+// Package adminui provides separately rendered tenant editing and platform
+// administration surfaces. The host must authorize each mount and tenant route.
package adminui
import (
"embed"
+ "html/template"
"io/fs"
"net/http"
+ "strconv"
+ "strings"
)
//go:embed static
var staticFS embed.FS
-// Handler returns an http.Handler that serves the admin UI under the
-// path prefix passed to http.StripPrefix. Pass "/admin" to mount under
-// /admin/*.
-func Handler() http.Handler {
+type Mode string
+
+const (
+ Editor Mode = "editor"
+ Platform Mode = "platform"
+)
+
+type Options struct {
+ Mode Mode
+ BasePath string
+}
+
+// Route is the canonical UI context, also used by the host's authorization gate.
+type Route struct {
+ TenantID, PageID int64
+ View string
+ Section int
+ NewPage bool
+}
+
+// ParseRoute accepts only declared view routes. Numeric IDs must be canonical;
+// assets and arbitrary suffixes are never interpreted as tenant views.
+func ParseRoute(mode Mode, path string) (Route, bool) {
+ if path == "/" || path == "" {
+ return Route{View: "tenants"}, true
+ }
+ parts := strings.Split(strings.TrimPrefix(path, "/"), "/")
+ if mode == Platform {
+ if path == "/tenants" || path == "/tenants/new" || path == "/cache" {
+ return Route{View: strings.TrimPrefix(path, "/")}, true
+ }
+ if len(parts) == 3 && parts[0] == "tenants" && parts[2] == "domains" {
+ id, ok := canonicalID(parts[1])
+ return Route{TenantID: id, View: "domains"}, ok
+ }
+ return Route{}, false
+ }
+ if mode != Editor || len(parts) < 3 || parts[0] != "tenants" || parts[2] != "pages" {
+ return Route{}, false
+ }
+ tid, ok := canonicalID(parts[1])
+ if !ok {
+ return Route{}, false
+ }
+ rt := Route{TenantID: tid, View: "pages"}
+ if len(parts) == 3 {
+ return rt, true
+ }
+ if len(parts) < 5 || len(parts) > 6 {
+ return Route{}, false
+ }
+ if parts[3] == "new" {
+ rt.NewPage = true
+ } else {
+ rt.PageID, ok = canonicalID(parts[3])
+ if !ok {
+ return Route{}, false
+ }
+ }
+ switch parts[4] {
+ case "content", "appearance", "publishing", "html", "preview":
+ rt.View = parts[4]
+ default:
+ return Route{}, false
+ }
+ if len(parts) == 6 {
+ if rt.View != "content" {
+ return Route{}, false
+ }
+ n, valid := canonicalID(parts[5])
+ if !valid || n > 10000 {
+ return Route{}, false
+ }
+ rt.Section = int(n)
+ }
+ return rt, true
+}
+func canonicalID(value string) (int64, bool) {
+ id, err := strconv.ParseInt(value, 10, 64)
+ return id, err == nil && id > 0 && strconv.FormatInt(id, 10) == value
+}
+
+// Handler defaults to content editing. Platform controls require an explicit
+// Platform mode mount; they are absent from the editor document entirely.
+func Handler() http.Handler { return HandlerWithOptions(Options{Mode: Editor, BasePath: "/admin"}) }
+func HandlerWithOptions(options Options) http.Handler {
+ if options.Mode == "" {
+ options.Mode = Editor
+ }
+ if options.Mode != Editor && options.Mode != Platform {
+ panic("invalid CMS UI mode")
+ }
+ options.BasePath = strings.TrimSuffix(options.BasePath, "/")
+ if options.BasePath == "" || !strings.HasPrefix(options.BasePath, "/") || strings.HasPrefix(options.BasePath, "//") || strings.ContainsAny(options.BasePath, "?\\#\"<>") {
+ panic("invalid CMS UI base path")
+ }
sub, err := fs.Sub(staticFS, "static")
if err != nil {
- // Embed errors are compile-time — this branch is unreachable.
panic(err)
}
- return http.FileServer(http.FS(sub))
+ name := "index.html"
+ if options.Mode == Platform {
+ name = "platform.html"
+ }
+ raw, err := fs.ReadFile(sub, name)
+ if err != nil {
+ panic(err)
+ }
+ index := template.Must(template.New(name).Parse(string(raw)))
+ files := http.FileServer(http.FS(sub))
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Cache-Control", "private, no-store")
+ w.Header().Set("X-Robots-Tag", "noindex, nofollow")
+ if r.Method != http.MethodGet && r.Method != http.MethodHead {
+ w.WriteHeader(405)
+ return
+ }
+ switch r.URL.Path {
+ case "/admin.css", "/admin.js", "/purify.min.js":
+ files.ServeHTTP(w, r)
+ return
+ }
+ if _, ok := ParseRoute(options.Mode, r.URL.Path); !ok {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ if r.Method != http.MethodHead {
+ _ = index.Execute(w, options)
+ }
+ })
}
diff --git a/adminui/adminui_test.go b/adminui/adminui_test.go
index 5871c20..b26aac8 100644
--- a/adminui/adminui_test.go
+++ b/adminui/adminui_test.go
@@ -1,12 +1,31 @@
package adminui
import (
+ "crypto/sha256"
+ "fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
+func TestHandler_ServesPinnedSanitizerBeforeEditor(t *testing.T) {
+ h := http.StripPrefix("/admin", Handler())
+ index := httptest.NewRecorder()
+ h.ServeHTTP(index, httptest.NewRequest("GET", "/admin/", nil))
+ if sanitizer, editor := strings.Index(index.Body.String(), `src="/admin/purify.min.js"`), strings.Index(index.Body.String(), `src="/admin/admin.js"`); sanitizer < 0 || editor <= sanitizer {
+ t.Fatal("sanitizer must load locally before editor")
+ }
+ r := httptest.NewRecorder()
+ h.ServeHTTP(r, httptest.NewRequest("GET", "/admin/purify.min.js", nil))
+ if r.Code != http.StatusOK || !strings.Contains(r.Header().Get("Content-Type"), "javascript") {
+ t.Fatalf("sanitizer asset unavailable: %d %q", r.Code, r.Header().Get("Content-Type"))
+ }
+ if got := fmt.Sprintf("%x", sha256.Sum256(r.Body.Bytes())); got != "2c90a9b46d6463f26038a29b686e82bc91de01fdac9d5229e7cfe3b360134ea2" {
+ t.Fatalf("vendored distribution differs from reviewed release: %s", got)
+ }
+}
+
func TestHandler_ServesIndex(t *testing.T) {
h := http.StripPrefix("/admin", Handler())
req := httptest.NewRequest(http.MethodGet, "/admin/", nil)
@@ -15,7 +34,7 @@ func TestHandler_ServesIndex(t *testing.T) {
if rec.Code != http.StatusOK {
t.Fatalf("index: %d", rec.Code)
}
- if !strings.Contains(rec.Body.String(), "Multisite Admin") {
+ if !strings.Contains(rec.Body.String(), "Site editor") {
t.Errorf("body did not contain title; got prefix %q", rec.Body.String()[:120])
}
body := rec.Body.String()
@@ -69,7 +88,7 @@ func TestHandler_ServesJS(t *testing.T) {
"renderPreviewDocument",
"sourceAuthoritative",
"safeEditorURL",
- `setAttribute("sandbox", "")`,
+ `setAttribute("sandbox", "allow-same-origin")`,
} {
if !strings.Contains(rec.Body.String(), want) {
t.Errorf("js body missing %q", want)
diff --git a/adminui/routes_test.go b/adminui/routes_test.go
new file mode 100644
index 0000000..a7126bc
--- /dev/null
+++ b/adminui/routes_test.go
@@ -0,0 +1,33 @@
+package adminui
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+func TestSurfacesAreSeparateAndDeepLinksLoadAbsoluteAssets(t *testing.T) {
+ editor := HandlerWithOptions(Options{Mode: Editor, BasePath: "/admin/cms/sites"})
+ platform := HandlerWithOptions(Options{Mode: Platform, BasePath: "/admin/cms/platform"})
+ for _, tc := range []struct {
+ h http.Handler
+ path, include, exclude string
+ }{{editor, "/tenants/2/pages/4/content/2", "Content sections", "new-tenant-form"}, {platform, "/tenants/2/domains", "new-domain-form", "data-rich-editor"}} {
+ w := httptest.NewRecorder()
+ tc.h.ServeHTTP(w, httptest.NewRequest("GET", tc.path, nil))
+ if w.Code != 200 || !strings.Contains(w.Body.String(), tc.include) || strings.Contains(w.Body.String(), tc.exclude) {
+ t.Fatalf("wrong surface %s: %d", tc.path, w.Code)
+ }
+ if strings.Contains(w.Body.String(), `src="admin.js"`) {
+ t.Fatal("deep route has relative asset")
+ }
+ }
+ for _, path := range []string{"/tenants/02/pages", "/tenants/2/pages/4/content/0", "/tenants/2/domains", "/tenants/2/pages/4/preview/2", "/tenants/2/pages/4/content/2/escape"} {
+ w := httptest.NewRecorder()
+ editor.ServeHTTP(w, httptest.NewRequest("GET", path, nil))
+ if w.Code != 404 {
+ t.Errorf("invalid route %s: %d", path, w.Code)
+ }
+ }
+}
diff --git a/adminui/static/DOMPurify-LICENSE b/adminui/static/DOMPurify-LICENSE
new file mode 100644
index 0000000..d645695
--- /dev/null
+++ b/adminui/static/DOMPurify-LICENSE
@@ -0,0 +1,202 @@
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
diff --git a/adminui/static/DOMPurify-NOTICE.txt b/adminui/static/DOMPurify-NOTICE.txt
new file mode 100644
index 0000000..9291296
--- /dev/null
+++ b/adminui/static/DOMPurify-NOTICE.txt
@@ -0,0 +1,6 @@
+DOMPurify3.4.16, locally vendored from official signed release tag.
+Source: https://github.com/cure53/DOMPurify/releases/tag/3.4.16
+Distribution: https://raw.githubusercontent.com/cure53/DOMPurify/3.4.16/dist/purify.min.js
+SHA256: 2c90a9b46d6463f26038a29b686e82bc91de01fdac9d5229e7cfe3b360134ea2
+License: Apache2.0 OR MPL2.0; see DOMPurify-LICENSE.
+No CDN or external script/runtime dependency. Update only from a reviewed release.
diff --git a/adminui/static/admin.css b/adminui/static/admin.css
index 33670f0..4e0a8cc 100644
--- a/adminui/static/admin.css
+++ b/adminui/static/admin.css
@@ -162,3 +162,43 @@ details summary { cursor: pointer; padding: 0.5rem 0; }
border: 1px solid #ddd;
background: white;
}
+
+[data-event-tool] { display:grid; gap:.6rem; padding:1rem; border:1px solid #bbb; }
+[data-event-tool][hidden] { display:none; }
+[data-event-tool] label { display:grid; gap:.2rem; }
+[data-event-tool] input { font:inherit; padding:.5rem; width:100%; }
+button:focus-visible, a:focus-visible { outline:3px solid #154f9d; outline-offset:3px; }
+@media(max-width:640px) { body{padding:.6rem} section{padding:.8rem} form{display:block} form label{display:block;margin:.8rem 0} form .form-row{display:block} table{font-size:.85rem} th,td{padding:.4rem} header{gap:.5rem;flex-wrap:wrap} }
+/* Distinct workspaces and routed page panes. None of these view styles enter
+ the canonical content surface or saved HTML. */
+body { max-width: 1280px; background:#f5f6f8; color:#192638; padding:1.5rem 2rem; }
+[hidden] { display:none !important; }
+a { color:#1856a8; text-underline-offset:3px; }
+button, a { touch-action:manipulation; }
+button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible { outline:3px solid #2563b8; outline-offset:3px; }
+.app-header { margin-bottom:1.5rem; }
+.eyebrow { color:#4c6076; font-size:.78rem; font-weight:650; letter-spacing:.08em; text-transform:uppercase; margin:0 0 .4rem; }
+h1 { font-size:1.6rem; } h2 { margin-top:0; font-size:1.45rem; } h3 { margin-top:0; }
+.view { border:1px solid #dce2ea; padding:1.75rem; box-shadow:none; border-radius:12px; }
+.view-heading,.editor-heading { display:flex; justify-content:space-between; align-items:center; gap:1rem; margin-bottom:1.5rem; }
+#save-state { color:#4c6076; margin:.4rem 0 0; font-size:.875rem; }
+#page-tabs,.page-tabs { display:flex; flex-wrap:wrap; gap:.4rem; padding:.6rem 0; margin:0 0 1.5rem; border-bottom:1px solid #dce2ea; }
+#page-tabs a,.page-tabs a { padding:.65rem .85rem; border-radius:6px; text-decoration:none; color:#3c526d; font-weight:550; }
+#page-tabs a[aria-current],.page-tabs a[aria-current] { background:#e7eefb; color:#174985; }
+#page-editor-form { display:block; margin:0; }
+.settings-pane { max-width:640px; display:block; }
+.settings-pane label { display:block; font-weight:550; margin:1rem 0; }
+.settings-pane label input,.settings-pane label select { display:block; margin-top:.4rem; padding:.65rem; }
+.content-layout { display:grid; grid-template-columns:210px minmax(0,1fr); gap:1.5rem; }
+#section-tabs { display:flex; flex-direction:column; gap:.25rem; border-right:1px solid #dce2ea; padding-right:.8rem; }
+#section-tabs a { padding:.65rem .75rem; border-radius:6px; text-decoration:none; line-height:1.4; overflow-wrap:anywhere; }
+#section-tabs a[aria-current] { background:#e7eefb; color:#174985; font-weight:600; }
+.content-editor { min-width:0; }.content-editor .form-row { display:block; }.content-editor .rich-editor { display:grid; }
+.editor-surface { min-height:20rem; border-color:#bac7d7; padding:1rem; line-height:1.6; }
+.editor-surface section { padding:0; box-shadow:none; border:0; margin:0; background:none; }
+.editor-surface img { max-height:330px; object-fit:contain; }
+[data-editor-source] { width:100%; min-height:24rem; font:13px/1.55 ui-monospace,monospace; padding:1rem; border:1px solid #bac7d7; border-radius:6px; }
+[data-source-notice] { font-size:.875rem; color:#664300; background:#fff5db; padding:.8rem; border-radius:6px; }
+.table-wrap { overflow-x:auto; } th,td { padding:.8rem; }.sr-only { position:absolute; width:1px; height:1px; overflow:hidden; clip:rect(0,0,0,0); }
+#page-preview { width:100%; min-height:650px; border:1px solid #dce2ea; margin-top:1rem; background:white; }
+@media(max-width:700px) { body { padding:1rem; }.view { padding:1rem; }.editor-heading,.view-heading { align-items:flex-start; flex-direction:column; }.content-layout { grid-template-columns:1fr; }#section-tabs { flex-direction:row; flex-wrap:wrap; border-right:0; border-bottom:1px solid #dce2ea; padding:0 0 .8rem; }#section-tabs a { font-size:.85rem; }.editor-surface { min-height:16rem; }#page-preview { min-height:550px; } }
diff --git a/adminui/static/admin.js b/adminui/static/admin.js
index 849fda4..526d9a9 100644
--- a/adminui/static/admin.js
+++ b/adminui/static/admin.js
@@ -30,248 +30,324 @@ const api = {
createPage(tid, b) { return this.req("POST", "/tenants/" + tid + "/pages", b); },
updatePage(tid, pid, b) { return this.req("PUT", "/tenants/" + tid + "/pages/" + pid, b); },
deletePage(tid, pid) { return this.req("DELETE", "/tenants/" + tid + "/pages/" + pid); },
+ permissions(tid, pid) { return this.req("GET", "/tenants/" + tid + "/pages/permissions" + (pid ? "?page_id="+pid : "")); },
+ templates(tid) { return this.req("GET", "/tenants/" + tid + "/pages/templates"); },
+ async preview(tid, body) {
+ const res = await fetch(this.base + "/tenants/" + tid + "/pages/preview", {
+ method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body),
+ });
+ if (!res.ok) throw new Error("Preview unavailable: " + res.status);
+ return res.text();
+ },
reload() { return this.req("POST", "/reload"); },
};
-let currentTenant = null;
+const cmsMode = document.body.dataset.cmsMode;
+const cmsBase = document.body.dataset.cmsBase;
+let currentTenant = null, currentRoute = null, draftKey = "", baselineDraft = "";
+let navigationGeneration = 0, historyPosition = 0, saving = false, restoringHistory = null;
+let frozenDraftControls = null;
+let pagePermissions = {create:false, edit:false, delete:false};
+const savedPageBodies = new WeakMap();
+const pageField = (form,name) => form.querySelector(':scope > input[data-page-field][name="'+name+'"], :scope > .settings-pane > label > [data-page-field][name="'+name+'"]');
function pagePayload(form) {
syncRichEditors(form);
- const fd = new FormData(form);
- const payload = {
- path: fd.get("path"),
- title: fd.get("title"),
- status: fd.get("status"),
- body_html: fd.get("body_html"),
- template_id: fd.get("template_id") || "",
- };
- const publishAt = dateTimeLocalToISO(fd.get("publish_at"));
- if (publishAt) payload.publish_at = publishAt;
- const unpublishAt = dateTimeLocalToISO(fd.get("unpublish_at"));
- if (unpublishAt) payload.unpublish_at = unpublishAt;
+ const value = name => pageField(form,name).value;
+ const payload = {path:value("path"),title:value("title"),status:value("status"),template_id:value("template_id")||"",body_html:$("[data-rich-editor] > [data-editor-source]",form).value,body_blocks:null};
+ const saved=savedPageBodies.get(form);
+ if(saved&&saved.html===payload.body_html)payload.body_blocks=saved.blocks||null;
+ payload.publish_at=dateTimeLocalToISO(value("publish_at"))||null;
+ payload.unpublish_at=dateTimeLocalToISO(value("unpublish_at"))||null;
return payload;
}
-
-function dateTimeLocalToISO(value) {
- if (!value) return "";
- const d = new Date(value);
- if (Number.isNaN(d.getTime())) return "";
- return d.toISOString();
-}
-
-function isoToDateTimeLocal(value) {
- if (!value) return "";
- const d = new Date(value);
- if (Number.isNaN(d.getTime())) return "";
- const pad = n => String(n).padStart(2, "0");
- return (
- d.getFullYear() + "-" +
- pad(d.getMonth() + 1) + "-" +
- pad(d.getDate()) + "T" +
- pad(d.getHours()) + ":" +
- pad(d.getMinutes())
- );
-}
-
-function toast(msg, kind) {
- const t = $("#toast");
- t.textContent = msg;
- t.className = kind || "";
- t.hidden = false;
- setTimeout(() => { t.hidden = true; }, 3000);
-}
-
-function fail(err) {
- console.error(err);
- toast(err.message, "error");
-}
-
-async function refreshTenants() {
- try {
- const data = await api.listTenants();
- const tbody = $("#tenants-body");
- tbody.innerHTML = "";
- for (const t of data.tenants || []) {
- const tr = document.createElement("tr");
- tr.innerHTML =
- "