diff --git a/CONTEXT.md b/CONTEXT.md index 6cffd9e..78ef7ef 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -44,6 +44,10 @@ _Avoid_: Nextcloud middleware, HaRP ACL, admin bypass, gating the top-menu boots The tree of named items along the left of an ExApp page. The ExApp supplies the tree. The selected item is the page on display. It plays the same role as the navigation in Nextcloud Files. An ExApp that does not use App navigation shows its own page instead. _Avoid_: Top Menu, sidebar, NcAppNavigation +**Dialog**: +A modal window on an ExApp page, in the same Nextcloud theme as that page. It does not require App navigation. The page waits for the user's choice; that choice is not a server request. The heading, the message, and the button word are plain text. The heading may be omitted. The fixed buttons are the English words OK and Cancel. One of three kinds: a Message (short text at info, warning, or error, dismissed with one button), a Confirm (yes or no; the agreeing button may be destructive), or a Prompt (one entered value, or cancelled). A Prompt with no value cannot be agreed. A successful Prompt value has no surrounding spaces. The caller may supply a starting value. A Prompt field has no label of its own; the message is the text above it. +_Avoid_: popup, browser alert/confirm/prompt, Notification, toast, HTML message + **Top Menu visibility**: Whether the ExApp app icon in the Nextcloud top menu is shown to all logged-in users or to Nextcloud admins only. Configured per deploy via env `TOP_MENU_ADMIN_REQUIRED` (`0` or `1`); the ExApp passes the value to AppAPI when registering the top-menu entry on enable. Independent of route `access_level` in info.xml and of Required Groups. _Avoid_: route access_level, Required Groups, AppAPI group ACL diff --git a/README.md b/README.md index 284ce3d..bd94320 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # go-nc-exapp -Shared Go library for Nextcloud ExApp Services: AppAPI authentication, OCS JSON calls, per-user ExApp preferences, Notifications, Users and Groups, an optional Required Groups Access Gate, and an optional App navigation shell. +Shared Go library for Nextcloud ExApp Services: AppAPI authentication, OCS JSON calls, per-user ExApp preferences, Notifications, Users and Groups, an optional Required Groups Access Gate, an optional App navigation shell, and a Dialog. Import: `gitea.neitzel.de/konrad/go-nc-exapp` (package `gonexapp`). @@ -18,7 +18,8 @@ Import: `gitea.neitzel.de/konrad/go-nc-exapp` (package `gonexapp`). - **Groups** — Users and Groups reads: `UserGroups`, `GroupMembers`, `ListGroups` (no search/paging). Directory calls (`GroupMembers` / `ListGroups`) run as the Credentials user and need an admin or subadmin - **Access Gate** — optional Required Groups enforcement (`Wrap` + `Check`), English denied HTML for browsers (200 + `frame-ancestors 'self'`), positive membership cache; default skip for lifecycle paths and **`/js/`** top-menu scripts; env helpers for `REQUIRED_GROUPS` / `REQUIRED_GROUPS_CACHE_SECONDS` - **Top Menu visibility** — `TopMenuAdminRequired` helper for deploy env `TOP_MENU_ADMIN_REQUIRED` (`0` / `1` for AppAPI top-menu OCS) -- **App navigation** — optional Files-style shell (`AppNavigation.Handler`). The ExApp supplies the tree, the page for each item, and an optional header. The selected item is the `item` query parameter (override with `SelectKey`). Other query parameters, including a Visit folder, are left in place. By default the shell loads the Nextcloud theme stylesheets, paints `--image-background`, and copies the surrounding page’s `data-theme-*` markers; set `DisableTheme` to skip the stylesheets. An item may set `Icon` to a same-origin image URL. `NextcloudIcons` names the core SVGs Nextcloud already serves (`NextcloudIcons.Folder`, `NextcloudIcons.Password`, and the rest). Not mounting the handler keeps the ExApp’s own page. Lifecycle routes, HaRP startup, and Top Menu registration stay in the ExApp. +- **App navigation** — optional Files-style shell (`AppNavigation.Handler`). The ExApp supplies the tree, the page for each item, and an optional header. The selected item is the `item` query parameter (override with `SelectKey`). Other query parameters, including a Visit folder, are left in place. By default the shell loads the Nextcloud theme stylesheets, paints `--image-background`, and copies the surrounding page’s `data-theme-*` markers; set `DisableTheme` to skip the stylesheets. An item may set `Icon` to a same-origin image URL. `NextcloudIcons` names the core SVGs Nextcloud already serves (`NextcloudIcons.Folder`, `NextcloudIcons.Password`, and the rest). The shell includes the Dialog. Not mounting the handler keeps the ExApp’s own page. Lifecycle routes, HaRP startup, and Top Menu registration stay in the ExApp. +- **Dialog** — `DialogHTML()` is the modal for a Message, a Confirm, or a Prompt. Insert it on a page the ExApp renders itself; App navigation already includes it. The page calls `exappDialog.message`, `exappDialog.confirm`, or `exappDialog.prompt` and waits for the choice. Fixed buttons are the English words OK and Cancel. The agreeing button’s word may be replaced. The choice stays in the page. **Excluded** diff --git a/dialog.go b/dialog.go new file mode 100644 index 0000000..2796d6f --- /dev/null +++ b/dialog.go @@ -0,0 +1,93 @@ +package gonexapp + +import ( + _ "embed" + "html/template" +) + +//go:embed dialog.js +var dialogJS string + +// DialogHTML is the Dialog markup and script for an ExApp page. +// App navigation includes it. A page the ExApp renders itself inserts the +// same fragment. The page then calls exappDialog.message, exappDialog.confirm, +// or exappDialog.prompt and waits for the user's choice. +func DialogHTML() template.HTML { + return template.HTML(dialogStyle + dialogMarkup + "") +} + +const dialogStyle = `` + +const dialogMarkup = ` +
+ + +

+ +
+ + +
+
+
` diff --git a/dialog.js b/dialog.js new file mode 100644 index 0000000..ecdece0 --- /dev/null +++ b/dialog.js @@ -0,0 +1,128 @@ +(function () { + var dialog = document.getElementById("exapp-dialog"); + if (!dialog || dialog.getAttribute("data-ready") === "1") return; + dialog.setAttribute("data-ready", "1"); + + var panel = document.getElementById("exapp-dialog-panel"); + var heading = document.getElementById("exapp-dialog-heading"); + var severity = document.getElementById("exapp-dialog-severity"); + var message = document.getElementById("exapp-dialog-message"); + var field = document.getElementById("exapp-dialog-value"); + var cancelBtn = document.getElementById("exapp-dialog-cancel"); + var agreeBtn = document.getElementById("exapp-dialog-agree"); + var queue = []; + var busy = false; + var finish = null; + + function textOf(value) { + return value == null ? "" : String(value); + } + + function trimmed() { + return field.value.trim(); + } + + function refreshAgree() { + agreeBtn.disabled = !field.hidden && trimmed() === ""; + } + + function settle(result) { + var done = finish; + finish = null; + if (dialog.open) dialog.close(); + busy = false; + if (done) done(result); + pump(); + } + + function decline() { + if (!finish) return; + var kind = dialog.getAttribute("data-kind"); + if (kind === "confirm") settle(false); + else if (kind === "prompt") settle(null); + else settle(undefined); + } + + function agree() { + if (!finish || agreeBtn.disabled) return; + var kind = dialog.getAttribute("data-kind"); + if (kind === "confirm") settle(true); + else if (kind === "prompt") settle(trimmed()); + else settle(undefined); + } + + cancelBtn.addEventListener("click", function (e) { + e.stopPropagation(); + decline(); + }); + agreeBtn.addEventListener("click", function (e) { + e.stopPropagation(); + agree(); + }); + field.addEventListener("input", refreshAgree); + dialog.addEventListener("cancel", function (e) { + e.preventDefault(); + decline(); + }); + dialog.addEventListener("click", function (e) { + if (e.target === dialog) decline(); + }); + dialog.addEventListener("keydown", function (e) { + if (e.key !== "Enter") return; + e.preventDefault(); + agree(); + }); + panel.addEventListener("click", function (e) { + e.stopPropagation(); + }); + + function show(kind, opts, done) { + opts = opts || {}; + finish = done; + dialog.setAttribute("data-kind", kind); + var head = textOf(opts.heading); + heading.textContent = head; + heading.hidden = head === ""; + message.textContent = textOf(opts.text); + var sev = ""; + if (kind === "message") { + sev = opts.severity === "warning" || opts.severity === "error" ? opts.severity : "info"; + dialog.setAttribute("data-severity", sev); + } else { + dialog.removeAttribute("data-severity"); + } + severity.textContent = sev; + severity.hidden = sev === ""; + var isPrompt = kind === "prompt"; + field.hidden = !isPrompt; + field.value = isPrompt ? textOf(opts.value) : ""; + cancelBtn.hidden = kind === "message"; + agreeBtn.textContent = kind !== "message" && opts.agree ? textOf(opts.agree) : "OK"; + agreeBtn.classList.toggle("destructive", kind === "confirm" && !!opts.destructive); + refreshAgree(); + if (!dialog.open) dialog.showModal(); + if (isPrompt) field.focus(); + else if (!agreeBtn.disabled) agreeBtn.focus(); + } + + function pump() { + if (busy || queue.length === 0) return; + busy = true; + queue.shift()(); + } + + function enqueue(kind, opts) { + return new Promise(function (resolve) { + queue.push(function () { + show(kind, opts, resolve); + }); + pump(); + }); + } + + window.exappDialog = { + message: function (opts) { return enqueue("message", opts); }, + confirm: function (opts) { return enqueue("confirm", opts); }, + prompt: function (opts) { return enqueue("prompt", opts); } + }; +})(); diff --git a/dialog_test.go b/dialog_test.go new file mode 100644 index 0000000..7bbdd8a --- /dev/null +++ b/dialog_test.go @@ -0,0 +1,511 @@ +package gonexapp_test + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + "time" + + "github.com/chromedp/chromedp" + "github.com/chromedp/chromedp/kb" + + "gitea.neitzel.de/konrad/go-nc-exapp" +) + +func TestMain(m *testing.M) { + srv := httptest.NewServer(dialogFixture()) + defer srv.Close() + fixtureURL = srv.URL + + path := browserExecutable() + if path == "" { + fmt.Fprintln(os.Stderr, "dialog tests need Chrome, Edge, or Chromium (set EXAPP_BROWSER)") + os.Exit(1) + } + opts := append(chromedp.DefaultExecAllocatorOptions[:], + chromedp.ExecPath(path), + chromedp.WindowSize(1200, 800), + ) + var cancel context.CancelFunc + allocCtx, cancel = chromedp.NewExecAllocator(context.Background(), opts...) + code := m.Run() + cancel() + os.Exit(code) +} + +var ( + fixtureURL string + allocCtx context.Context +) + +func dialogFixture() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("GET /bare", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprint(w, "Dialog", + gonexapp.DialogHTML(), fixtureControls(), "") + }) + nav := gonexapp.AppNavigation{ + DefaultID: "home", + Items: func(*http.Request) []gonexapp.Item { + return []gonexapp.Item{{ID: "home", Label: "Home"}} + }, + Page: func(*http.Request, string) (string, bool) { + return fixtureControls(), true + }, + } + mux.Handle("GET /shell", nav.Handler()) + return mux +} + +func fixtureControls() string { + return ` + + + + + + + + + + + +

+`
+}
+
+func browserExecutable() string {
+	if p := os.Getenv("EXAPP_BROWSER"); p != "" {
+		return p
+	}
+	candidates := []string{
+		"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
+		"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
+		"/Applications/Chromium.app/Contents/MacOS/Chromium",
+	}
+	for _, c := range candidates {
+		if st, err := os.Stat(c); err == nil && !st.IsDir() {
+			return c
+		}
+	}
+	return ""
+}
+
+func drive(t *testing.T, path string, actions ...chromedp.Action) {
+	t.Helper()
+	ctx, cancel := chromedp.NewContext(allocCtx)
+	t.Cleanup(cancel)
+	ctx, cancel = context.WithTimeout(ctx, 25*time.Second)
+	t.Cleanup(cancel)
+	steps := append([]chromedp.Action{chromedp.Navigate(fixtureURL + path)}, actions...)
+	if err := chromedp.Run(ctx, steps...); err != nil {
+		t.Fatal(err)
+	}
+}
+
+func clickButton(label string) chromedp.Action {
+	expr := fmt.Sprintf(`(() => {
+	  const btn = [...document.querySelectorAll("#exapp-dialog button")].find((b) => b.textContent.trim() === %q && !b.hidden);
+	  if (!btn) return "missing";
+	  btn.click();
+	  return "clicked";
+	})()`, label)
+	return chromedp.ActionFunc(func(ctx context.Context) error {
+		var result string
+		if err := chromedp.Evaluate(expr, &result).Do(ctx); err != nil {
+			return err
+		}
+		if result != "clicked" {
+			return fmt.Errorf("button %q: %s", label, result)
+		}
+		return nil
+	})
+}
+
+func waitJS(expr, want string, contains bool) chromedp.Action {
+	return chromedp.ActionFunc(func(ctx context.Context) error {
+		deadline, ok := ctx.Deadline()
+		if !ok {
+			deadline = time.Now().Add(5 * time.Second)
+		}
+		var last string
+		for {
+			var got string
+			err := chromedp.Evaluate(expr, &got).Do(ctx)
+			if err == nil {
+				last = got
+				if contains && strings.Contains(got, want) {
+					return nil
+				}
+				if !contains && strings.TrimSpace(got) == want {
+					return nil
+				}
+			}
+			if time.Now().After(deadline) {
+				return fmt.Errorf("%s = %q, want %q", expr, last, want)
+			}
+			if err := chromedp.Sleep(40 * time.Millisecond).Do(ctx); err != nil {
+				return err
+			}
+		}
+	})
+}
+
+func waitDialogText(id, want string) chromedp.Action {
+	return waitJS(fmt.Sprintf(`document.getElementById("%s").textContent`, id), want, false)
+}
+
+func waitLog(want string) chromedp.Action {
+	return waitJS(`document.getElementById("log").textContent`, want, true)
+}
+
+func openShows(t *testing.T, path, button, severity, heading, message string) {
+	t.Helper()
+	drive(t, path,
+		chromedp.WaitVisible("#"+button, chromedp.ByQuery),
+		chromedp.Click("#"+button, chromedp.ByQuery),
+		waitDialogText("exapp-dialog-severity", severity),
+		waitDialogText("exapp-dialog-message", message),
+		chromedp.ActionFunc(func(ctx context.Context) error {
+			var head string
+			var hidden bool
+			if err := chromedp.Evaluate(`document.getElementById("exapp-dialog-heading").textContent`, &head).Do(ctx); err != nil {
+				return err
+			}
+			if err := chromedp.Evaluate(`document.getElementById("exapp-dialog-heading").hidden`, &hidden).Do(ctx); err != nil {
+				return err
+			}
+			if heading == "" {
+				if !hidden {
+					return fmt.Errorf("heading visible %q", head)
+				}
+				return nil
+			}
+			if hidden || strings.TrimSpace(head) != heading {
+				return fmt.Errorf("heading %q hidden %v, want %q", head, hidden, heading)
+			}
+			var bold int
+			if err := chromedp.Evaluate(`document.querySelectorAll("#exapp-dialog b, #exapp-dialog i").length`, &bold).Do(ctx); err != nil {
+				return err
+			}
+			if bold != 0 {
+				return fmt.Errorf("dialog interpreted HTML, %d elements", bold)
+			}
+			var label string
+			if err := chromedp.Evaluate(`document.getElementById("exapp-dialog-agree").textContent`, &label).Do(ctx); err != nil {
+				return err
+			}
+			if strings.TrimSpace(label) != "OK" {
+				return fmt.Errorf("message button %q, want OK", label)
+			}
+			return nil
+		}),
+	)
+}
+
+func TestMessageOnBarePage(t *testing.T) {
+	openShows(t, "/bare", "open-info", "info", "Notice", "Hello x")
+}
+
+func TestMessageOnAppNavigation(t *testing.T) {
+	openShows(t, "/shell", "open-info", "info", "Notice", "Hello x")
+}
+
+func TestMessageSeveritiesAndDismiss(t *testing.T) {
+	cases := []struct {
+		button   string
+		severity string
+		how      string
+	}{
+		{"open-warning", "warning", "ok"},
+		{"open-error", "error", "enter"},
+		{"open-info", "info", "escape"},
+		{"open-plain", "info", "backdrop"},
+	}
+	for _, tc := range cases {
+		t.Run(tc.how, func(t *testing.T) {
+			var dismiss chromedp.Action
+			switch tc.how {
+			case "ok":
+				dismiss = clickButton("OK")
+			case "enter":
+				dismiss = chromedp.KeyEvent(kb.Enter)
+			case "escape":
+				dismiss = chromedp.KeyEvent(kb.Escape)
+			case "backdrop":
+				dismiss = chromedp.MouseClickXY(8, 8)
+			}
+			drive(t, "/bare",
+				chromedp.Click("#"+tc.button, chromedp.ByQuery),
+				waitDialogText("exapp-dialog-severity", tc.severity),
+				dismiss,
+				waitLog("dismissed"),
+			)
+		})
+	}
+}
+
+func TestMessageWithoutHeading(t *testing.T) {
+	openShows(t, "/bare", "open-plain", "info", "", "Only the message")
+}
+
+func TestConfirmYesNoAndDestructive(t *testing.T) {
+	t.Run("cancel", func(t *testing.T) {
+		drive(t, "/bare",
+			chromedp.Click("#open-confirm", chromedp.ByQuery),
+			waitDialogText("exapp-dialog-message", "Proceed?"),
+			clickButton("Cancel"),
+			waitLog("no"),
+		)
+	})
+	t.Run("escape", func(t *testing.T) {
+		drive(t, "/bare",
+			chromedp.Click("#open-confirm", chromedp.ByQuery),
+			waitDialogText("exapp-dialog-message", "Proceed?"),
+			chromedp.KeyEvent(kb.Escape),
+			waitLog("no"),
+		)
+	})
+	t.Run("backdrop", func(t *testing.T) {
+		drive(t, "/bare",
+			chromedp.Click("#open-confirm", chromedp.ByQuery),
+			waitDialogText("exapp-dialog-message", "Proceed?"),
+			chromedp.MouseClickXY(8, 8),
+			waitLog("no"),
+		)
+	})
+	t.Run("enter", func(t *testing.T) {
+		drive(t, "/bare",
+			chromedp.Click("#open-confirm", chromedp.ByQuery),
+			waitDialogText("exapp-dialog-message", "Proceed?"),
+			chromedp.KeyEvent(kb.Enter),
+			waitLog("yes"),
+		)
+	})
+	t.Run("delete", func(t *testing.T) {
+		drive(t, "/bare",
+			chromedp.Click("#open-delete", chromedp.ByQuery),
+			waitDialogText("exapp-dialog-heading", "Remove domain"),
+			chromedp.ActionFunc(func(ctx context.Context) error {
+				var plain, destructive string
+				if err := chromedp.Evaluate(`getComputedStyle(document.getElementById("exapp-dialog-cancel")).backgroundColor`, &plain).Do(ctx); err != nil {
+					return err
+				}
+				if err := chromedp.Evaluate(`getComputedStyle(document.getElementById("exapp-dialog-agree")).backgroundColor`, &destructive).Do(ctx); err != nil {
+					return err
+				}
+				if plain == destructive {
+					return fmt.Errorf("destructive button color %q matches Cancel", destructive)
+				}
+				var label string
+				if err := chromedp.Evaluate(`document.getElementById("exapp-dialog-agree").textContent`, &label).Do(ctx); err != nil {
+					return err
+				}
+				if strings.TrimSpace(label) != "Delete" {
+					return fmt.Errorf("agree label %q", label)
+				}
+				return nil
+			}),
+			clickButton("Delete"),
+			waitLog("yes"),
+		)
+	})
+	t.Run("button word is plain text", func(t *testing.T) {
+		drive(t, "/bare",
+			chromedp.Click("#open-html-button", chromedp.ByQuery),
+			waitDialogText("exapp-dialog-message", "Go?"),
+			chromedp.ActionFunc(func(ctx context.Context) error {
+				var n int
+				var label string
+				if err := chromedp.Evaluate(`document.querySelectorAll("#exapp-dialog i").length`, &n).Do(ctx); err != nil {
+					return err
+				}
+				if n != 0 {
+					return fmt.Errorf("button word was interpreted as HTML")
+				}
+				if err := chromedp.Evaluate(`document.getElementById("exapp-dialog-agree").textContent`, &label).Do(ctx); err != nil {
+					return err
+				}
+				if strings.TrimSpace(label) != "Go" {
+					return fmt.Errorf("agree label %q", label)
+				}
+				return nil
+			}),
+		)
+	})
+}
+
+func TestPromptValueAndCancel(t *testing.T) {
+	t.Run("empty stays inactive", func(t *testing.T) {
+		drive(t, "/bare",
+			chromedp.Click("#open-prompt", chromedp.ByQuery),
+			waitDialogText("exapp-dialog-message", "Folder name"),
+			waitDialogText("exapp-dialog-heading", "New folder"),
+			chromedp.ActionFunc(func(ctx context.Context) error {
+				var disabled bool
+				if err := chromedp.Evaluate(`document.getElementById("exapp-dialog-agree").disabled`, &disabled).Do(ctx); err != nil {
+					return err
+				}
+				if !disabled {
+					return fmt.Errorf("agree button active on an empty Prompt")
+				}
+				return chromedp.KeyEvent(kb.Enter).Do(ctx)
+			}),
+			chromedp.Sleep(200*time.Millisecond),
+			chromedp.ActionFunc(func(ctx context.Context) error {
+				var open bool
+				if err := chromedp.Evaluate(`document.getElementById("exapp-dialog").open`, &open).Do(ctx); err != nil {
+					return err
+				}
+				if !open {
+					return fmt.Errorf("Enter closed an empty Prompt")
+				}
+				return nil
+			}),
+			clickButton("Cancel"),
+			waitLog("cancelled"),
+		)
+	})
+	t.Run("spaces only stay inactive", func(t *testing.T) {
+		drive(t, "/bare",
+			chromedp.Click("#open-blank", chromedp.ByQuery),
+			waitDialogText("exapp-dialog-message", "Name"),
+			chromedp.ActionFunc(func(ctx context.Context) error {
+				var disabled bool
+				var raw string
+				if err := chromedp.Evaluate(`document.getElementById("exapp-dialog-agree").disabled`, &disabled).Do(ctx); err != nil {
+					return err
+				}
+				if err := chromedp.Evaluate(`document.getElementById("exapp-dialog-value").value`, &raw).Do(ctx); err != nil {
+					return err
+				}
+				if raw != "   " {
+					return fmt.Errorf("starting value %q", raw)
+				}
+				if !disabled {
+					return fmt.Errorf("agree button active for spaces")
+				}
+				return nil
+			}),
+		)
+	})
+	t.Run("prefill trims on agree", func(t *testing.T) {
+		drive(t, "/bare",
+			chromedp.Click("#open-prefill", chromedp.ByQuery),
+			waitDialogText("exapp-dialog-message", "Name"),
+			chromedp.ActionFunc(func(ctx context.Context) error {
+				var raw string
+				if err := chromedp.Evaluate(`document.getElementById("exapp-dialog-value").value`, &raw).Do(ctx); err != nil {
+					return err
+				}
+				if raw != "  hi  " {
+					return fmt.Errorf("starting value %q", raw)
+				}
+				return nil
+			}),
+			clickButton("OK"),
+			waitLog(`value:"hi"`),
+		)
+	})
+	t.Run("inner spaces stay", func(t *testing.T) {
+		drive(t, "/bare",
+			chromedp.Click("#open-inner", chromedp.ByQuery),
+			waitDialogText("exapp-dialog-message", "Name"),
+			clickButton("OK"),
+			waitLog(`value:"a b"`),
+		)
+	})
+	t.Run("escape cancels", func(t *testing.T) {
+		drive(t, "/bare",
+			chromedp.Click("#open-prompt", chromedp.ByQuery),
+			waitDialogText("exapp-dialog-message", "Folder name"),
+			chromedp.KeyEvent(kb.Escape),
+			waitLog("cancelled"),
+		)
+	})
+	t.Run("typed value", func(t *testing.T) {
+		drive(t, "/bare",
+			chromedp.Click("#open-prompt", chromedp.ByQuery),
+			waitDialogText("exapp-dialog-message", "Folder name"),
+			chromedp.SendKeys("#exapp-dialog-value", "  mail ", chromedp.ByQuery),
+			chromedp.KeyEvent(kb.Enter),
+			waitLog(`value:"mail"`),
+		)
+	})
+}
+
+func TestOneDialogAtATime(t *testing.T) {
+	drive(t, "/bare",
+		chromedp.Click("#open-three", chromedp.ByQuery),
+		waitDialogText("exapp-dialog-message", "First"),
+		chromedp.ActionFunc(func(ctx context.Context) error {
+			var n int
+			if err := chromedp.Evaluate(`document.querySelectorAll("dialog[open]").length`, &n).Do(ctx); err != nil {
+				return err
+			}
+			if n != 1 {
+				return fmt.Errorf("open dialogs %d, want 1", n)
+			}
+			return nil
+		}),
+		clickButton("OK"),
+		waitDialogText("exapp-dialog-message", "Second"),
+		clickButton("OK"),
+		waitDialogText("exapp-dialog-message", "Third"),
+		clickButton("OK"),
+		waitLog("1\n2\n3"),
+	)
+}
diff --git a/doc.go b/doc.go
index 18c71eb..7b75311 100644
--- a/doc.go
+++ b/doc.go
@@ -1,4 +1,4 @@
 // Package gonexapp provides AppAPI credentials, OCS JSON calls, ExApp user
-// preferences, an optional Required Groups Access Gate, and an optional
-// App navigation shell for Nextcloud ExApp Services.
+// preferences, an optional Required Groups Access Gate, an optional
+// App navigation shell, and a Dialog for Nextcloud ExApp Services.
 package gonexapp
diff --git a/go.mod b/go.mod
index e037897..f518972 100644
--- a/go.mod
+++ b/go.mod
@@ -1,3 +1,14 @@
 module gitea.neitzel.de/konrad/go-nc-exapp
 
 go 1.27.0
+
+require (
+	github.com/chromedp/cdproto v0.0.0-20260714215040-dc233986426f // indirect
+	github.com/chromedp/chromedp v0.16.0 // indirect
+	github.com/chromedp/sysutil v1.1.0 // indirect
+	github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68 // indirect
+	github.com/gobwas/httphead v0.1.0 // indirect
+	github.com/gobwas/pool v0.2.1 // indirect
+	github.com/gobwas/ws v1.4.0 // indirect
+	golang.org/x/sys v0.47.0 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..ce22f3f
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,17 @@
+github.com/chromedp/cdproto v0.0.0-20260714215040-dc233986426f h1:0Z1zcSLEmnj2c2CmJYBqewtS6pxhB39bNWUSEUAWjgk=
+github.com/chromedp/cdproto v0.0.0-20260714215040-dc233986426f/go.mod h1:RwFsSODCtFExll+GhHM6R92SARHR3Z3oipaxLHj46C0=
+github.com/chromedp/chromedp v0.16.0 h1:rOO4deOm4CbZgBCa8mD9g2rDyIoNs0BkgvNrlbp5ouk=
+github.com/chromedp/chromedp v0.16.0/go.mod h1:rbuGKFT1vMcFcFqKfPIO1GpX/N+2s8onm2qMxZLbU5U=
+github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
+github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
+github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68 h1:KZaTBSyshWX3MP5jukJcNSuXDQTO+rNpt0J564dX/eg=
+github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
+github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
+github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
+github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
+github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
+github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
+github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
+golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
diff --git a/navigation.go b/navigation.go
index 236fd1a..858a0fa 100644
--- a/navigation.go
+++ b/navigation.go
@@ -105,6 +105,7 @@ func (n AppNavigation) serve(w http.ResponseWriter, r *http.Request) {
 		Missing:  msg,
 		Title:    title,
 		Theme:    !n.DisableTheme,
+		Dialog:   DialogHTML(),
 	})
 }
 
@@ -135,6 +136,7 @@ type shellView struct {
 	Missing  string
 	Title    string
 	Theme    bool
+	Dialog   template.HTML
 }
 
 type itemView struct {
@@ -295,6 +297,7 @@ var shellTmpl = template.Must(template.New("shell").Parse(`
 
 
 
+{{.Dialog}}