oficina / estilo Public

Share Home account connection screens through Estilo

Co-Authored-By: Codex <noreply@openai.com>
Paul Campbell pushed by paul@keymail.dev dec3908b237ce5430ec06b18e2ad331acecda4c3 parent 672a447
5 files changed, +101 −0
  • homeconnect/README.md +14 −0
  • homeconnect/templates/home_link.html +9 −0
  • homeconnect/templates/oauth.html +5 −0
  • homeconnect/view.go +17 −0
  • homeconnect/view_test.go +56 −0

CI passed — run details

diff --git a/homeconnect/README.md b/homeconnect/README.md
new file mode 100644
index 0000000..59d265b
--- /dev/null
+++ b/homeconnect/README.md
@@ -0,0 +1,14 @@
+Home connection screens for Oficina apps. Parse `Templates()` with the app's
+page layout, then render `home-connect` with `PageData`. The app supplies every
+label, so its locale selection applies to the whole screen. Twelve caller
+locales are exercised by the component tests.
+
+For an OAuth continuation, render `home-oauth-head` in the document head and
+`home-oauth` in the body with the same `OAuthData`. Validate the destination
+before rendering; this package does not perform authentication or decide which
+hosts to trust. The shared Home connection package owns those checks.
+
+Both screens work with JavaScript disabled. They compose Rastrillo's existing
+attribute-based page, box and button primitives with the suite's day theme;
+there are no new colors or CSS selectors. The Back action uses the button target
+size, and addresses isolate bidirectional text and wrap at narrow widths.
diff --git a/homeconnect/templates/home_link.html b/homeconnect/templates/home_link.html
new file mode 100644
index 0000000..a4ea289
--- /dev/null
+++ b/homeconnect/templates/home_link.html
@@ -0,0 +1,9 @@
+{{define "home-connect"}}
+<header rst-page-header><div rst-page-header-titles><h1>{{.Title}}</h1></div></header>
+<div rst-box>
+ <p>{{.Body}}</p>{{if .Address}}<p style="overflow-wrap: anywhere"><strong><bdi>{{.Address}}</bdi></strong></p>{{end}}
+ {{if .PostAction}}<form method="post" action="{{.Action}}"><button rst-btn="primary" type="submit">{{.ActionLabel}}</button></form>
+ {{else}}<a rst-btn="primary" href="{{.Action}}">{{.ActionLabel}}</a>{{end}}
+ {{if .CancelLabel}}<a rst-btn="ghost" href="/">{{.CancelLabel}}</a>{{end}}
+</div>
+{{end}}
diff --git a/homeconnect/templates/oauth.html b/homeconnect/templates/oauth.html
new file mode 100644
index 0000000..12aef7a
--- /dev/null
+++ b/homeconnect/templates/oauth.html
@@ -0,0 +1,5 @@
+{{define "home-oauth-head"}}<meta http-equiv="refresh" content="0;url={{.URL}}">{{end}}
+{{define "home-oauth"}}
+<header rst-page-header><div rst-page-header-titles><h1>{{.Title}}</h1></div></header>
+<div rst-box><a rst-btn="primary" href="{{.URL}}">{{.ActionLabel}}</a></div>
+{{end}}
diff --git a/homeconnect/view.go b/homeconnect/view.go
new file mode 100644
index 0000000..7f25806
--- /dev/null
+++ b/homeconnect/view.go
@@ -0,0 +1,17 @@
+// Package homeconnect presents the suite's Home connection and OAuth screens.
+// Callers supply translated strings and already validated destinations.
+package homeconnect
+
+import "embed"
+
+//go:embed templates/*.html
+var templateFS embed.FS
+
+func Templates() embed.FS { return templateFS }
+
+type PageData struct {
+ Title, Body, Address, Action, ActionLabel, CancelLabel string
+ PostAction bool
+}
+
+type OAuthData struct{ URL, Title, ActionLabel string }
diff --git a/homeconnect/view_test.go b/homeconnect/view_test.go
new file mode 100644
index 0000000..d358223
--- /dev/null
+++ b/homeconnect/view_test.go
@@ -0,0 +1,56 @@
+package homeconnect
+
+import (
+ "bytes"
+ "html/template"
+ "strings"
+ "testing"
+)
+
+func render(t *testing.T, name string, data any) string {
+ t.Helper()
+ view, err := template.New("view").ParseFS(Templates(), "templates/*.html")
+ if err != nil {
+ t.Fatal(err)
+ }
+ var out bytes.Buffer
+ if err := view.ExecuteTemplate(&out, name, data); err != nil {
+ t.Fatal(err)
+ }
+ return out.String()
+}
+
+func TestCallerTranslationsAndScriptlessActions(t *testing.T) {
+ labels := map[string]string{"en": "Continue", "de": "Weiter", "es": "Continuar", "fr": "Continuer", "it": "Continua", "pt": "Continuar", "nl": "Doorgaan", "pl": "Kontynuuj", "ar": "متابعة", "ja": "続行", "zh": "继续", "ko": "계속"}
+ for locale, label := range labels {
+ t.Run(locale, func(t *testing.T) {
+ data := PageData{Title: label, Body: label, Address: "person@example.test", Action: "/account/home", ActionLabel: label, CancelLabel: label, PostAction: true}
+ html := render(t, "home-connect", data)
+ for _, want := range []string{label, `method="post" action="/account/home"`, `<bdi>person@example.test</bdi>`, `rst-btn="ghost"`} {
+ if !strings.Contains(html, want) {
+ t.Fatalf("missing %q", want)
+ }
+ }
+ if strings.Contains(html, "<script") {
+ t.Fatal("connection action requires script")
+ }
+ data.PostAction = false
+ if html = render(t, "home-connect", data); strings.Contains(html, "<form") || !strings.Contains(html, `href="/account/home"`) {
+ t.Fatal("navigation action is not a link")
+ }
+ })
+ }
+}
+func TestUntrustedTextAndDestinationAreEscaped(t *testing.T) {
+ html := render(t, "home-connect", PageData{Title: `<script>bad</script>`, Address: `<img src=x onerror=bad>`, Action: "javascript:bad", ActionLabel: "Go"})
+ if strings.Contains(html, "<script>") || strings.Contains(html, "<img") || !strings.Contains(html, "#ZgotmplZ") {
+ t.Fatal("unsafe connection markup", html)
+ }
+}
+func TestOAuthHeadAndBodyRetainValidatedDestination(t *testing.T) {
+ d := OAuthData{URL: "https://keymail.example.test/oauth/authorize?state=test&client_id=app", Title: "Continue", ActionLabel: "Continue"}
+ head, body := render(t, "home-oauth-head", d), render(t, "home-oauth", d)
+ if !strings.Contains(head, `http-equiv="refresh"`) || !strings.Contains(head, "keymail.example.test/oauth/authorize") || !strings.Contains(body, `href="https://keymail.example.test/oauth/authorize?state=test&amp;client_id=app"`) {
+ t.Fatal("scriptless OAuth continuation missing", head, body)
+ }
+}