diff --git a/browse/css/base.css b/browse/css/base.css
index 1d2b5ff..38f7de4 100644
--- a/browse/css/base.css
+++ b/browse/css/base.css
@@ -40,3 +40,19 @@ body {
.status-bar--error { color: #b00020; }
.status-bar--info { color: var(--primary); }
+
+/* Read-only banners — surfaced by preview-markdown.js / preview-yaml.js
+ when the listing's `writable` bit was false. Inline at the top of
+ the editor host so the user can't miss it; muted styling so it
+ doesn't fight the editor chrome. */
+.md-readonly-banner,
+.yaml-readonly-banner {
+ background: rgba(220, 53, 69, 0.10);
+ color: var(--text);
+ border-bottom: 1px solid rgba(220, 53, 69, 0.35);
+ padding: 0.4rem 0.7rem;
+ font-size: 0.85rem;
+ display: flex;
+ align-items: center;
+ gap: 0.4rem;
+}
diff --git a/browse/js/loader.js b/browse/js/loader.js
index 0621aa0..dd51bf6 100644
--- a/browse/js/loader.js
+++ b/browse/js/loader.js
@@ -37,6 +37,11 @@
modTime: e.mod_time ? new Date(e.mod_time) : null,
ext: e.is_dir ? '' : splitExt(name),
url: e.url || null,
+ // Server-computed write authority — true if the policy
+ // decider would allow a PUT for the calling principal.
+ // Absent / false means "save will 403"; preview editors
+ // read this to mount in read-only mode.
+ writable: !!e.writable,
// FS-API specific (null in server mode):
handle: null
};
diff --git a/browse/js/preview-markdown.js b/browse/js/preview-markdown.js
index fc4a996..d23e29f 100644
--- a/browse/js/preview-markdown.js
+++ b/browse/js/preview-markdown.js
@@ -304,6 +304,11 @@
function canSave(node) {
if (isZipMemberNode(node)) return false;
+ // Server-computed authority gate. The listing's `writable`
+ // bit reflects what a PUT would do — false here means the
+ // file API would 403 the save, so we mount in read-only
+ // mode rather than letting the user type and lose changes.
+ if (node.url && window.app.state.source === 'server' && !node.writable) return false;
if (node.handle && typeof node.handle.createWritable === 'function') return true;
if (node.url && window.app.state.source === 'server') return true;
return false;
@@ -510,24 +515,50 @@
var bodyText = initialParsed.body;
var initialHash = await hashContent(assembleContent(fmTextarea.value, bodyText));
- var editor = new window.toastui.Editor({
+ var writableMode = canSave(node);
+ // autofocus:false keeps the keyboard caret in the tree pane —
+ // arrow-key nav can continue through markdown files without
+ // diverting into the editor. The user clicks into the editor
+ // (or tabs to it) when they actually want to type.
+ var editorOpts = {
el: editorHost,
height: '100%',
- // WYSIWYG by default — most users want the rendered view
- // out of the gate; the markdown/WYSIWYG toggle in the
- // Toast UI toolbar still flips to source mode in one click.
- initialEditType: 'wysiwyg',
- previewStyle: 'vertical',
- initialValue: bodyText,
usageStatistics: false,
- toolbarItems: [
- ['heading', 'bold', 'italic', 'strike'],
- ['hr', 'quote'],
- ['ul', 'ol', 'task', 'indent', 'outdent'],
- ['table', 'image', 'link'],
- ['code', 'codeblock']
- ]
- });
+ autofocus: false,
+ initialValue: bodyText,
+ };
+ var editor;
+ if (!writableMode) {
+ // Read-only mount uses Toast UI's Viewer (rendered markdown,
+ // no edit toolbar, no caret). Clear visual signal that the
+ // file isn't editable — much better than letting the user
+ // type into an Editor whose Save button is disabled.
+ editor = window.toastui.Editor.factory(Object.assign({}, editorOpts, {
+ viewer: true,
+ }));
+ // Read-only banner above the editor explains why.
+ var roBanner = document.createElement('div');
+ roBanner.className = 'md-readonly-banner';
+ roBanner.innerHTML = '🔒'
+ + ' Read-only — you don\'t have write access to this file.'
+ + ' Save is disabled; changes won\'t persist.';
+ editorHost.insertBefore(roBanner, editorHost.firstChild);
+ } else {
+ editor = new window.toastui.Editor(Object.assign({}, editorOpts, {
+ // WYSIWYG by default — most users want the rendered view
+ // out of the gate; the markdown/WYSIWYG toggle in the
+ // Toast UI toolbar still flips to source mode in one click.
+ initialEditType: 'wysiwyg',
+ previewStyle: 'vertical',
+ toolbarItems: [
+ ['heading', 'bold', 'italic', 'strike'],
+ ['hr', 'quote'],
+ ['ul', 'ol', 'task', 'indent', 'outdent'],
+ ['table', 'image', 'link'],
+ ['code', 'codeblock']
+ ]
+ }));
+ }
currentInstance = {
editor: editor,
@@ -539,8 +570,7 @@
fmEl: fmTextarea
};
- var writable = canSave(node);
- if (!writable) {
+ if (!writableMode) {
saveBtn.disabled = true;
saveBtn.title = 'Save not available — read-only source.';
fmTextarea.readOnly = true;
diff --git a/browse/js/preview-yaml.js b/browse/js/preview-yaml.js
index f2568d3..47b9ac1 100644
--- a/browse/js/preview-yaml.js
+++ b/browse/js/preview-yaml.js
@@ -77,6 +77,10 @@
function canSave(node) {
if (isZipMemberNode(node)) return false;
if (node.virtual) return false;
+ // Server-computed authority gate. Mirrors the markdown editor's
+ // check — listing's `writable` bit is the same decision the
+ // file API would reach on PUT.
+ if (node.url && window.app.state.source === 'server' && !node.writable) return false;
if (node.handle && typeof node.handle.createWritable === 'function') return true;
if (node.url && window.app.state.source === 'server') return true;
return false;
@@ -454,6 +458,7 @@
window.CodeMirror.__zddcYamlLinterReady = true;
}
+ var writable = canSave(node);
var editor = window.CodeMirror(editorHost, {
value: text,
mode: 'yaml',
@@ -463,7 +468,15 @@
indentWithTabs: false,
lineWrapping: false,
gutters: ['CodeMirror-lint-markers', 'CodeMirror-linenumbers'],
- lint: { hasGutters: true }
+ lint: { hasGutters: true },
+ // autofocus:false keeps the keyboard caret in the browse
+ // tree pane so arrow-key nav can continue through yaml /
+ // .zddc files without diverting into the editor. User
+ // clicks (or tabs) into the editor when they want to type.
+ autofocus: false,
+ // CodeMirror's "nocursor" mode is the truest read-only:
+ // selection allowed for copy, no caret, no edit affordances.
+ readOnly: !writable ? 'nocursor' : false,
});
// Stash the node on the editor so the lint helper can decide
// whether to apply the .zddc schema layer.
@@ -472,11 +485,15 @@
editor.performLint();
currentEditor = editor;
- var writable = canSave(node);
if (!writable) {
saveBtn.disabled = true;
saveBtn.title = 'Save not available — read-only source.';
- editor.setOption('readOnly', true);
+ // Read-only banner above the editor explains why.
+ var roBanner = document.createElement('div');
+ roBanner.className = 'yaml-readonly-banner';
+ roBanner.innerHTML = '🔒'
+ + ' Read-only — you don\'t have write access to this file.';
+ editorHost.insertBefore(roBanner, editorHost.firstChild);
}
var initialHash = await hashContent(text);
diff --git a/zddc/internal/fs/tree.go b/zddc/internal/fs/tree.go
index fa9c93d..db15ecc 100644
--- a/zddc/internal/fs/tree.go
+++ b/zddc/internal/fs/tree.go
@@ -38,7 +38,7 @@ func safeJoin(fsRoot, relPath string) (string, bool) {
// The decider is queried per subdirectory; nil falls back to the internal
// Go evaluator (policy.InternalDecider) for tests that don't wire up
// an explicit decider.
-func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath, userEmail, baseURL string, includeHidden bool) ([]listing.FileInfo, error) {
+func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath, userEmail, baseURL string, includeHidden, elevated bool) ([]listing.FileInfo, error) {
if decider == nil {
decider = &policy.InternalDecider{}
}
@@ -93,6 +93,16 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath,
declaredSet[strings.ToLower(name)] = true
}
+ // Parent-dir chain + active-admin status. Files in this directory
+ // inherit authorization from this chain, so we compute it once
+ // and reuse for every file entry's Writable bit. Subdirectories
+ // build their own chain (the child cascade can differ — e.g. a
+ // per-user fenced home).
+ parentChain, _ := zddc.EffectivePolicy(fsRoot, absDir)
+ principal := zddc.Principal{Email: userEmail, Elevated: elevated}
+ parentActiveAdmin := elevated && userEmail != "" &&
+ zddc.IsAdminForChain(parentChain, userEmail, false)
+
for _, entry := range entries {
name := entry.Name()
@@ -162,6 +172,20 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath,
DisplayName: displayName,
Declared: declared,
}
+ // Writable surfaces whether THIS principal could PUT this file
+ // — same decision as the file API's authorizeAction would
+ // reach. Uses the parent-dir chain (computed once above);
+ // active-admin status short-circuits the per-file decider
+ // query when the principal already holds admin authority.
+ fileURL := baseURL + name
+ if parentActiveAdmin {
+ fi.Writable = true
+ } else {
+ allowed, _ := policy.AllowActionFromChainP(ctx, decider, parentChain, principal, fileURL, policy.ActionWrite)
+ if allowed {
+ fi.Writable = true
+ }
+ }
result = append(result, fi)
}
diff --git a/zddc/internal/fs/tree_test.go b/zddc/internal/fs/tree_test.go
index 31e74e8..e3610be 100644
--- a/zddc/internal/fs/tree_test.go
+++ b/zddc/internal/fs/tree_test.go
@@ -28,7 +28,7 @@ func TestListDirectory_VirtualUserHome_AppearsWhenMissing(t *testing.T) {
}
zddc.InvalidateCache(root)
- got, err := ListDirectory(context.Background(), nil, root, "Proj/working", "alice@example.com", "/Proj/working/", false)
+ got, err := ListDirectory(context.Background(), nil, root, "Proj/working", "alice@example.com", "/Proj/working/", false, false)
if err != nil {
t.Fatalf("list: %v", err)
}
@@ -56,7 +56,7 @@ func TestListDirectory_VirtualUserHome_SuppressedWhenRealExists(t *testing.T) {
}
zddc.InvalidateCache(root)
- got, err := ListDirectory(context.Background(), nil, root, "Proj/working", "alice@example.com", "/Proj/working/", false)
+ got, err := ListDirectory(context.Background(), nil, root, "Proj/working", "alice@example.com", "/Proj/working/", false, false)
if err != nil {
t.Fatalf("list: %v", err)
}
@@ -74,7 +74,7 @@ func TestListDirectory_VirtualUserHome_AnonymousNoEntry(t *testing.T) {
}
zddc.InvalidateCache(root)
- got, err := ListDirectory(context.Background(), nil, root, "Proj/working", "" /* no viewer */, "/Proj/working/", false)
+ got, err := ListDirectory(context.Background(), nil, root, "Proj/working", "" /* no viewer */, "/Proj/working/", false, false)
if err != nil {
t.Fatalf("list: %v", err)
}
@@ -92,7 +92,7 @@ func TestListDirectory_VirtualUserHome_OutsideWorkingNoEntry(t *testing.T) {
}
zddc.InvalidateCache(root)
- got, err := ListDirectory(context.Background(), nil, root, "Proj/staging", "alice@example.com", "/Proj/staging/", false)
+ got, err := ListDirectory(context.Background(), nil, root, "Proj/staging", "alice@example.com", "/Proj/staging/", false, false)
if err != nil {
t.Fatalf("list: %v", err)
}
@@ -113,7 +113,7 @@ func TestListDirectory_VirtualUserHome_DeepWorkingNoEntry(t *testing.T) {
got, err := ListDirectory(context.Background(), nil, root,
"Proj/working/alice@example.com", "alice@example.com",
- "/Proj/working/alice@example.com/", false)
+ "/Proj/working/alice@example.com/", false, false)
if err != nil {
t.Fatalf("list: %v", err)
}
@@ -132,7 +132,7 @@ func TestListDirectory_VirtualUserHome_CaseFoldWorking(t *testing.T) {
}
zddc.InvalidateCache(root)
- got, err := ListDirectory(context.Background(), nil, root, "Proj/Working", "alice@example.com", "/Proj/Working/", false)
+ got, err := ListDirectory(context.Background(), nil, root, "Proj/Working", "alice@example.com", "/Proj/Working/", false, false)
if err != nil {
t.Fatalf("list: %v", err)
}
@@ -165,7 +165,7 @@ func TestListDirectory_CanonicalProjectFolder_EmptyWhenMissing(t *testing.T) {
for _, stage := range []string{"working", "staging", "reviewing", "archive"} {
got, err := ListDirectory(context.Background(), nil, root,
- "Proj/"+stage, "alice@example.com", "/Proj/"+stage+"/", false)
+ "Proj/"+stage, "alice@example.com", "/Proj/"+stage+"/", false, false)
if err != nil {
t.Errorf("ListDirectory(Proj/%s) on missing dir: err = %v, want nil", stage, err)
continue
@@ -199,7 +199,7 @@ func TestListDirectory_NonCanonicalMissing_StillNotFound(t *testing.T) {
_, err := ListDirectory(context.Background(), nil, root,
"Proj/random-folder-that-doesnt-exist", "alice@example.com",
- "/Proj/random-folder-that-doesnt-exist/", false)
+ "/Proj/random-folder-that-doesnt-exist/", false, false)
if !os.IsNotExist(err) {
t.Errorf("ListDirectory on a non-canonical missing path: err = %v, want IsNotExist", err)
}
diff --git a/zddc/internal/handler/directory.go b/zddc/internal/handler/directory.go
index e399d22..b915802 100644
--- a/zddc/internal/handler/directory.go
+++ b/zddc/internal/handler/directory.go
@@ -117,7 +117,7 @@ func ServeDirectory(cfg config.Config, appsSrv *apps.Server, w http.ResponseWrit
// used by ?zip and ?convert= elsewhere in the dispatcher.
includeHidden := r.URL.Query().Has("hidden")
- entries, err := appfs.ListDirectory(ctx, decider, cfg.Root, dirPath, email, baseURL, includeHidden)
+ entries, err := appfs.ListDirectory(ctx, decider, cfg.Root, dirPath, email, baseURL, includeHidden, ElevatedFromContext(r))
if err != nil {
if os.IsNotExist(err) {
http.Error(w, "Not Found", http.StatusNotFound)
diff --git a/zddc/internal/listing/types.go b/zddc/internal/listing/types.go
index 93e8747..013326f 100644
--- a/zddc/internal/listing/types.go
+++ b/zddc/internal/listing/types.go
@@ -46,4 +46,19 @@ type FileInfo struct {
// page project names without a separate API. Empty when the
// entry has no `.zddc` or its `.zddc` doesn't set `title:`.
Title string `json:"title,omitempty"`
+
+ // Writable surfaces whether the calling principal has write
+ // authority on this entry — answered by running the policy decider
+ // with ActionWrite on the entry's path, with the same admin-bypass
+ // branch that the file API uses. Clients (notably the in-place
+ // markdown editor) read this to mount in read-only mode when a
+ // PUT would 403, instead of letting the user type then lose their
+ // changes on save.
+ //
+ // omitempty: the listing JSON omits the field when false so the
+ // wire payload doesn't bloat; clients should treat absence as
+ // false-or-unknown and gate writes accordingly. Read-only-by-
+ // default is the safer client-side fallback if the server forgets
+ // to populate it.
+ Writable bool `json:"writable,omitempty"`
}