oficina / home Public

Share the team chooser across native suite apps

Paul Campbell pushed by paul@keymail.dev dbccdaee94df1c08392245fc56839909bda4e7e2 parent 8e6ba3f
9 files changed, +169 −28
  • internal/home/app_chooser_test.go +88 −0
  • internal/home/directory.go +4 −2
  • internal/home/directory_catalog.go +6 −0
  • internal/home/directory_catalog_test.go +4 −4
  • internal/home/directory_runtime.go +31 −6
  • internal/home/directory_runtime_test.go +6 −1
  • internal/home/docs_chooser.go +27 −12
  • internal/home/templates/docs_chooser.html +2 −2
  • internal/home/templates/docs_empty.html +1 −1

CI cancelled — superseded by a newer push — run details

diff --git a/internal/home/app_chooser_test.go b/internal/home/app_chooser_test.go
new file mode 100644
index 0000000..5bc9afc
--- /dev/null
+++ b/internal/home/app_chooser_test.go
@@ -0,0 +1,88 @@
+package home
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+func TestNativeAppChoicesKeepTeamAndAppBoundaries(t *testing.T) {
+ for _, app := range []string{"docs", "sheets", "slides", "calendar", "meet", "memoria"} {
+ t.Run(app, func(t *testing.T) {
+ f := newMountedChooser(t)
+ cookie := f.session(t, chooserPerson)
+ path := "/apps/" + app
+ if w := f.request("GET", path, "", "", nil); w.Code != 303 || !strings.Contains(w.Header().Get("Location"), "return_to=%2Fapps%2F"+app) {
+ t.Fatal("lost app after sign-in", w.Code, w.Header())
+ }
+ empty := f.request("GET", path, "", "", cookie)
+ if empty.Code != 200 || !strings.Contains(empty.Body.String(), "No teams available") {
+ t.Fatal("empty", empty.Code)
+ }
+ a := f.team(t, "A1", "alpha", "Alpha", chooserPerson)
+ b := f.team(t, "B2", "beta", "Beta", chooserPerson)
+ c := f.team(t, "C3", "foreign", "Hidden other team", chooserOther)
+ if app != "docs" {
+ for _, row := range []struct {
+ id int64
+ label string
+ }{{a, "alpha"}, {b, "beta"}, {c, "foreign"}} {
+ _, err := f.p.directory.db.Writer().Exec(`INSERT INTO team_instances VALUES(?,?,?,?,?)`, row.id, app, "https://"+row.label+"."+app+".oficina.works", row.label+"-"+app, "live")
+ if err != nil {
+ t.Fatal(err)
+ }
+ }
+ }
+ w := f.request("GET", path, "", "", cookie)
+ if w.Code != 200 || !strings.Contains(w.Body.String(), `action="`+path+`/team"`) || strings.Contains(w.Body.String(), "Hidden other team") {
+ t.Fatal("choice boundaries", w.Code, w.Body.String())
+ }
+ picked := f.request("POST", path+"/team", directoryOrigin, "team=A1", cookie)
+ assertContinuationPage(t, picked, "https://alpha."+app+".oficina.works/auth/start?return_to=%2F")
+ if _, err := f.p.directory.db.Writer().Exec(`DELETE FROM memberships WHERE team_id=? AND person_ref=?`, a, chooserPerson); err != nil {
+ t.Fatal(err)
+ }
+ if w := f.request("POST", path+"/team", directoryOrigin, "team=A1", cookie); w.Code != 404 {
+ t.Fatal("stale team selection accepted", w.Code)
+ }
+ single := f.request("GET", path, "", "", cookie)
+ if single.Code != 303 || single.Header().Get("Location") != "https://beta."+app+".oficina.works/auth/start?return_to=%2F" {
+ t.Fatal("one-team shortcut", single.Code, single.Header())
+ }
+ if w := f.request("POST", path+"/team", "https://attacker.example", "team=B2", cookie); w.Code != 403 {
+ t.Fatal("CSRF", w.Code)
+ }
+ if w := f.request("GET", path+"?return_to=https://attacker.example", "", "", cookie); w.Code != 404 {
+ t.Fatal("untrusted navigation", w.Code)
+ }
+ r := httptest.NewRequest("GET", directoryOrigin+path, nil)
+ r.AddCookie(cookie)
+ r.AddCookie(cookie)
+ dup := httptest.NewRecorder()
+ f.handler.ServeHTTP(dup, r)
+ if dup.Code != http.StatusBadRequest {
+ t.Fatal("duplicate cookie", dup.Code)
+ }
+ wrongApp := "mail"
+ if _, err := f.p.directory.db.Writer().Exec(`UPDATE team_instances SET origin=? WHERE team_id=? AND app=?`, "https://beta."+wrongApp+".oficina.works", b, app); err != nil {
+ t.Fatal(err)
+ }
+ if w := f.request("GET", path, "", "", cookie); w.Code != 503 || w.Header().Get("Location") != "" {
+ t.Fatal("wrong-app origin accepted", w.Code)
+ }
+ })
+ }
+}
+
+func TestProviderAndUnknownAppsDoNotBecomeTeamConsumers(t *testing.T) {
+ f := newMountedChooser(t)
+ for _, app := range []string{"mail", "files", "unknown", "Docs"} {
+ if _, err := f.p.directory.newAppChooser(f.store, app); err == nil {
+ t.Fatal("unintegrated app accepted", app)
+ }
+ if w := f.request("GET", "/apps/"+app, "", "", f.session(t, chooserPerson)); w.Code != 404 {
+ t.Fatal("unintegrated route", app, w.Code)
+ }
+ }
+}
diff --git a/internal/home/directory.go b/internal/home/directory.go
index 20150fc..cead9f3 100644
--- a/internal/home/directory.go
+++ b/internal/home/directory.go
@@ -70,8 +70,10 @@ func (a *App) directoryRouter() http.Handler {
r.Post(handoff.CallbackPath, a.consumer.Callback)
r.Group(func(r chi.Router) {
r.Use(csrf.Protect(a.origins.Directory))
- r.Get(docsEntryPath, a.directory.docs.Entry)
- r.Post(docsChoicePath, a.directory.docs.Select)
+ for _, chooser := range a.directory.choosers {
+ r.Get(chooser.entryPath, chooser.Entry)
+ r.Post(chooser.choicePath, chooser.Select)
+ }
r.Get(handoff.StartPath, a.consumer.Start)
r.Get(handoff.FinishPath, a.consumer.Finish)
r.Get("/signin", func(w http.ResponseWriter, r *http.Request) {
diff --git a/internal/home/directory_catalog.go b/internal/home/directory_catalog.go
index cf1b8e1..6cdab5a 100644
--- a/internal/home/directory_catalog.go
+++ b/internal/home/directory_catalog.go
@@ -25,6 +25,12 @@ func directoryCatalog(cfg Runtime) []catalogApp {
{Name: "Files", Status: "Separate sign-in"},
}
for i := range apps {
+ for _, app := range directoryApps() {
+ if app.name == apps[i].Name && app.integrated && apps[i].Href == "" {
+ apps[i].Href = "/apps/" + app.slug
+ apps[i].Status = "Choose a team"
+ }
+ }
if origin := cfg.appPreviewOrigins[strings.ToLower(apps[i].Name)]; origin != "" {
apps[i].Href = origin + "/signin"
apps[i].Status = "Open preview"
diff --git a/internal/home/directory_catalog_test.go b/internal/home/directory_catalog_test.go
index 4194ce4..2debd23 100644
--- a/internal/home/directory_catalog_test.go
+++ b/internal/home/directory_catalog_test.go
@@ -23,10 +23,10 @@ func TestDirectoryCatalogRequiresSigninAndDoesNotInventLaunchDestinations(t *tes
t.Fatal("missing app", name)
}
}
- if strings.Count(body, "<a ") != 1 || !strings.Contains(body, `href="/apps/docs"`) || !strings.Contains(body, "Choose a team") || strings.Contains(body, "<script") {
+ if strings.Count(body, "<a ") != 6 || !strings.Contains(body, `href="/apps/docs"`) || !strings.Contains(body, "Choose a team") || strings.Contains(body, "<script") {
t.Fatal("catalog advertised unsupported launch", body)
}
- if strings.Count(body, "Not connected to Home yet") != 4 || strings.Count(body, "Coming soon") != 1 || strings.Count(body, "Separate sign-in") != 2 {
+ if strings.Count(body, "Not connected to Home yet") != 0 || strings.Count(body, "Coming soon") != 0 || strings.Count(body, "Separate sign-in") != 2 {
t.Fatal("catalog readiness missing", body)
}
entry := f.request("GET", docsEntryPath, "", "", cookie)
@@ -47,7 +47,7 @@ func TestDirectoryCatalogPreviewKeepsDocsChooserPolicy(t *testing.T) {
cookie := f.session(t, chooserPerson)
page := f.request("GET", "/?return_to=https://evil.example", "", "", cookie)
body := page.Body.String()
- if page.Code != 200 || !strings.Contains(body, `href="https://overnight-docs.bds.oncarlos.com/signin"`) || !strings.Contains(body, "Open preview") || strings.Contains(body, "Choose a team") || strings.Contains(body, "evil.example") || strings.Count(body, "<a ") != 1 {
+ if page.Code != 200 || !strings.Contains(body, `href="https://overnight-docs.bds.oncarlos.com/signin"`) || !strings.Contains(body, "Open preview") || strings.Count(body, "Choose a team") != 5 || strings.Contains(body, "evil.example") || strings.Count(body, "<a ") != 6 {
t.Fatal("preview navigation incorrect", page.Code, body)
}
noStore(t, page)
@@ -141,7 +141,7 @@ func TestConfiguredAppsLaunchOnlyOperatorDestinations(t *testing.T) {
f.handler = app.Router()
page := f.request("GET", "/?app=slides&origin=https://attacker.example", "", "", f.session(t, chooserPerson))
body := page.Body.String()
- if page.Code != 200 || strings.Count(body, "<a ") != 3 || !strings.Contains(body, `href="https://sheets.example/signin"`) || !strings.Contains(body, `href="https://memoria.example/signin"`) || !strings.Contains(body, `href="/apps/docs"`) || strings.Contains(body, "attacker.example") {
+ if page.Code != 200 || strings.Count(body, "<a ") != 6 || !strings.Contains(body, `href="https://sheets.example/signin"`) || !strings.Contains(body, `href="https://memoria.example/signin"`) || !strings.Contains(body, `href="/apps/docs"`) || strings.Contains(body, "attacker.example") {
t.Fatal(page.Code, body)
}
noStore(t, page)
diff --git a/internal/home/directory_runtime.go b/internal/home/directory_runtime.go
index a829720..cc8b4b3 100644
--- a/internal/home/directory_runtime.go
+++ b/internal/home/directory_runtime.go
@@ -14,7 +14,14 @@ type directoryApp struct {
// The registry describes shipped integration, not team standing or readiness.
// Provisioning remains disabled until the audited operator path is available.
func directoryApps() []directoryApp {
- return []directoryApp{{slug: "docs", name: "Docs", integrated: true}}
+ return []directoryApp{
+ {slug: "docs", name: "Docs", integrated: true},
+ {slug: "sheets", name: "Sheets", integrated: true},
+ {slug: "slides", name: "Slides", integrated: true},
+ {slug: "calendar", name: "Calendar", integrated: true},
+ {slug: "meet", name: "Meet", integrated: true},
+ {slug: "memoria", name: "Memoria", integrated: true},
+ }
}
func directoryRouteAllowed(route directorydata.Route) bool {
@@ -27,9 +34,10 @@ func directoryRouteAllowed(route directorydata.Route) bool {
}
type directoryFeatures struct {
- roster *rosterHandler
- store *directorydata.Store
- docs *docsChooser
+ roster *rosterHandler
+ store *directorydata.Store
+ docs *docsChooser
+ choosers []*docsChooser
}
func (a *App) newDirectoryFeatures() (*directoryFeatures, error) {
@@ -47,6 +55,17 @@ func (a *App) newDirectoryFeatures() (*directoryFeatures, error) {
if err != nil {
return nil, errExchangeConfig
}
+ choosers := []*docsChooser{chooser}
+ for _, app := range directoryApps() {
+ if app.slug == "docs" || !app.integrated {
+ continue
+ }
+ c, err := a.newAppChooser(store, app.slug)
+ if err != nil {
+ return nil, errExchangeConfig
+ }
+ choosers = append(choosers, c)
+ }
var source rosterReadSource
if a.runtime.roster.mode == "serve" {
source, err = directorydata.NewRosterSource(a.db.Writer(), a.secrets.Sealer, directorydata.Config{Apps: apps, ValidateRoute: directoryRouteAllowed}, a.origins.Directory)
@@ -58,11 +77,17 @@ func (a *App) newDirectoryFeatures() (*directoryFeatures, error) {
if err != nil {
return nil, err
}
- return &directoryFeatures{store: store, docs: chooser, roster: handler}, nil
+ return &directoryFeatures{store: store, docs: chooser, roster: handler, choosers: choosers}, nil
}
func chooserPath(r *http.Request) bool {
- return r.URL.Path == docsEntryPath || r.URL.Path == docsChoicePath
+ for _, app := range directoryApps() {
+ path := "/apps/" + app.slug
+ if app.integrated && (r.URL.Path == path || r.URL.Path == path+"/team") {
+ return true
+ }
+ }
+ return false
}
// Place this before host and method routing so their refusals are not cached.
diff --git a/internal/home/directory_runtime_test.go b/internal/home/directory_runtime_test.go
index 98b466e..ff240d7 100644
--- a/internal/home/directory_runtime_test.go
+++ b/internal/home/directory_runtime_test.go
@@ -122,9 +122,14 @@ func TestDirectoryRuntimeAnonymousReturnAndRoleSchemaSeparation(t *testing.T) {
}
}
apps := directoryApps()
- if len(apps) != 1 || apps[0].slug != "docs" || !apps[0].integrated || apps[0].provisionable || apps[0].foreignOrigin != "" {
+ if len(apps) != 6 || apps[0].slug != "docs" || !apps[0].integrated || apps[0].provisionable || apps[0].foreignOrigin != "" {
t.Fatal("registry advertised unsupported provisioning")
}
+ for _, app := range apps {
+ if app.provisionable || app.foreignOrigin != "" {
+ t.Fatal("entry enabled provisioning")
+ }
+ }
apps[0].slug = "changed"
if directoryApps()[0].slug != "docs" {
t.Fatal("registry shared mutable backing")
diff --git a/internal/home/docs_chooser.go b/internal/home/docs_chooser.go
index b4513e5..bfaf180 100644
--- a/internal/home/docs_chooser.go
+++ b/internal/home/docs_chooser.go
@@ -22,19 +22,34 @@ type docsChoices interface {
}
type docsChooser struct {
- app *App
- store docsChoices
- selectHandler http.Handler
+ app *App
+ store docsChoices
+ appID, appName, entryPath, choicePath string
+ selectHandler http.Handler
}
// newDocsChooser prepares handlers without mounting routes or applying schema.
// The parent directory router must supply its existing sessions middleware and
// exact-host guard. Selection carries its own same-origin CSRF protection.
func (a *App) newDocsChooser(store docsChoices) (*docsChooser, error) {
+ return a.newAppChooser(store, "docs")
+}
+
+func (a *App) newAppChooser(store docsChoices, appID string) (*docsChooser, error) {
+ name := ""
+ for _, app := range directoryApps() {
+ if app.slug == appID && app.integrated {
+ name = app.name
+ }
+ }
+ if name == "" {
+ return nil, errExchangeConfig
+ }
+
if a == nil || a.runtime == nil || a.runtime.role != "directory" || a.renderer == nil || a.origins.validate() != nil || store == nil {
return nil, errors.New("invalid Docs chooser configuration")
}
- c := &docsChooser{app: a, store: store}
+ c := &docsChooser{app: a, store: store, appID: appID, appName: name, entryPath: "/apps/" + appID, choicePath: "/apps/" + appID + "/team"}
c.selectHandler = csrf.Protect(a.origins.Directory)(http.HandlerFunc(c.choose))
return c, nil
}
@@ -45,7 +60,7 @@ func (c *docsChooser) Entry(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
- if r.URL.Path != docsEntryPath || r.URL.RawQuery != "" || r.URL.ForceQuery {
+ if r.URL.Path != c.entryPath || r.URL.RawQuery != "" || r.URL.ForceQuery {
http.NotFound(w, r)
return
}
@@ -53,18 +68,18 @@ func (c *docsChooser) Entry(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
- choices, err := c.store.Choices(r.Context(), subject, "docs")
+ choices, err := c.store.Choices(r.Context(), subject, c.appID)
if err != nil {
c.unavailable(w)
return
}
switch len(choices) {
case 0:
- c.app.render(w, http.StatusOK, "docs_empty.html", nil)
+ c.app.render(w, http.StatusOK, "docs_empty.html", map[string]any{"AppName": c.appName})
case 1:
c.redirect(w, r, choices[0])
default:
- c.app.render(w, http.StatusOK, "docs_chooser.html", struct{ Choices []directorydata.Choice }{choices})
+ c.app.render(w, http.StatusOK, "docs_chooser.html", map[string]any{"Choices": choices, "AppName": c.appName, "ChoicePath": c.choicePath})
}
}
func (c *docsChooser) Select(w http.ResponseWriter, r *http.Request) {
@@ -74,7 +89,7 @@ func (c *docsChooser) Select(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
- if r.URL.Path != docsChoicePath || r.URL.RawQuery != "" || r.URL.ForceQuery {
+ if r.URL.Path != c.choicePath || r.URL.RawQuery != "" || r.URL.ForceQuery {
http.NotFound(w, r)
return
}
@@ -100,7 +115,7 @@ func (c *docsChooser) choose(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
return
}
- choices, err := c.store.Choices(r.Context(), subject, "docs")
+ choices, err := c.store.Choices(r.Context(), subject, c.appID)
if err != nil {
c.unavailable(w)
return
@@ -116,7 +131,7 @@ func (c *docsChooser) choose(w http.ResponseWriter, r *http.Request) {
func (c *docsChooser) subject(w http.ResponseWriter, r *http.Request) (string, bool) {
session, ok := sessions.Current(r)
if !ok {
- target := handoff.StartPath + "?" + url.Values{"return_to": {docsEntryPath}}.Encode()
+ target := handoff.StartPath + "?" + url.Values{"return_to": {c.entryPath}}.Encode()
if r.Method == http.MethodPost {
// Start a new GET navigation after the form response. The submitted
// selection is discarded; sign-in returns to current team choices.
@@ -153,5 +168,5 @@ func (c *docsChooser) continueToDocs(w http.ResponseWriter, choice directorydata
// Callers supply either a validated stored app origin plus the fixed app path,
// or the validated directory origin plus the fixed sign-in continuation path.
func (c *docsChooser) renderContinuation(w http.ResponseWriter, destination string) {
- c.app.renderContinuation(w, destination, "Continue to Docs")
+ c.app.renderContinuation(w, destination, "Continue to "+c.appName)
}
diff --git a/internal/home/templates/docs_chooser.html b/internal/home/templates/docs_chooser.html
index 5447580..f8de751 100644
--- a/internal/home/templates/docs_chooser.html
+++ b/internal/home/templates/docs_chooser.html
@@ -1,8 +1,8 @@
{{define "title"}}Choose a team · Oficina{{end}}
{{define "content"}}
<h1>Choose a team</h1>
-<p>Open Docs for one of your teams.</p>
-<form method="post" action="/apps/docs/team" rst-form>
+<p>Open {{.AppName}} for one of your teams.</p>
+<form method="post" action="{{.ChoicePath}}" rst-form>
{{range .Choices}}
<button type="submit" name="team" value="{{.SQID}}" rst-btn>{{.Name}}</button>
{{end}}
diff --git a/internal/home/templates/docs_empty.html b/internal/home/templates/docs_empty.html
index 4525085..483356e 100644
--- a/internal/home/templates/docs_empty.html
+++ b/internal/home/templates/docs_empty.html
@@ -1,6 +1,6 @@
{{define "title"}}No teams available · Oficina{{end}}
{{define "content"}}
<h1>No teams available</h1>
-<p>This account has no team with Docs available.</p>
+<p>This account has no team with {{.AppName}} available.</p>
<p><a href="/">Back to Home</a></p>
{{end}}