Share Home account connection screens through Estilo
Co-Authored-By: Codex <noreply@openai.com>
5 files changed,
+101
−0
homeconnect/README.md+14 −0homeconnect/templates/home_link.html+9 −0homeconnect/templates/oauth.html+5 −0homeconnect/view.go+17 −0homeconnect/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&client_id=app"`) { |
| + t.Fatal("scriptless OAuth continuation missing", head, body) |
| + } |
| +} |