diff --git a/pkg/auth/auth_simple.go b/pkg/auth/auth_simple.go index 72ea242..69717f2 100644 --- a/pkg/auth/auth_simple.go +++ b/pkg/auth/auth_simple.go @@ -535,6 +535,26 @@ func (s *simpleService) handleCallback(w http.ResponseWriter, r *http.Request) { redirectParams.Set("orgs", strings.Join(githubUser.Organizations, ",")) } + // Resolve success page display customization from config rules. + if s.cfg.SuccessPage != nil { + display := s.cfg.SuccessPage.Resolve(githubUser.Login, githubUser.Organizations) + if display.Tagline != "" { + redirectParams.Set("sp_tagline", display.Tagline) + } + + if display.Media != nil { + redirectParams.Set("sp_media_type", display.Media.Type) + + if display.Media.URL != "" { + redirectParams.Set("sp_media_url", display.Media.URL) + } + + if display.Media.ASCIIArtBase64 != "" { + redirectParams.Set("sp_media_ascii", display.Media.ASCIIArtBase64) + } + } + } + redirectURL := fmt.Sprintf("%s?%s", pending.RedirectURI, redirectParams.Encode()) http.Redirect(w, r, redirectURL, http.StatusFound) } diff --git a/pkg/auth/client/client.go b/pkg/auth/client/client.go index 2f6857c..d33ad5a 100644 --- a/pkg/auth/client/client.go +++ b/pkg/auth/client/client.go @@ -306,8 +306,12 @@ func (c *client) startCallbackServer(_ context.Context, expectedState string, co } user := callbackUser{ - Login: r.URL.Query().Get("login"), - AvatarURL: r.URL.Query().Get("avatar_url"), + Login: r.URL.Query().Get("login"), + AvatarURL: r.URL.Query().Get("avatar_url"), + Tagline: r.URL.Query().Get("sp_tagline"), + MediaType: r.URL.Query().Get("sp_media_type"), + MediaURL: r.URL.Query().Get("sp_media_url"), + MediaASCIIB64: r.URL.Query().Get("sp_media_ascii"), } if orgsParam := r.URL.Query().Get("orgs"); orgsParam != "" { diff --git a/pkg/auth/client/success_page.go b/pkg/auth/client/success_page.go index 9145b04..b797725 100644 --- a/pkg/auth/client/success_page.go +++ b/pkg/auth/client/success_page.go @@ -12,37 +12,17 @@ type callbackUser struct { Login string AvatarURL string Orgs []string -} -// specialUsers get ASCII art instead of the GIF. -var specialUsers = map[string]bool{ - "samcm": true, - "mattevans": true, - "savid": true, -} - -// pandaASCIIBase64 is the base64-encoded ASCII art shown to special users. -// To update: write your raw multiline ASCII art to a file, then: -// -// base64 < art.txt | tr -d '\n' -// -// Paste the output here. -var pandaASCIIBase64 = "KiBnIG8gYSB0IHMgZSB4ICogZyBvIGEgdCBzIGUgeCAqIGcgbyBhIHQgcyBlIHggKgpnICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICBnICAKbyAvICAgICBcICAgICAgICAgICAgIFwgICAgICAgICAgICAvICAgIFwgICAgICAgbwphfCAgICAgICB8ICAgICAgICAgICAgIFwgICAgICAgICAgfCAgICAgIHwgICAgICBhCnR8ICAgICAgIGAuICAgICAgICAgICAgIHwgICAgICAgICB8ICAgICAgIDogICAgIHQKc2AgICAgICAgIHwgICAgICAgICAgICAgfCAgICAgICAgXHwgICAgICAgfCAgICAgcwplIFwgICAgICAgfCAvICAgICAgIC8gIFxcXCAgIC0tX18gXFwgICAgICAgOiAgICBlCnggIFwgICAgICBcLyAgIF8tLX5+ICAgICAgICAgIH4tLV9ffCBcICAgICB8ICAgIHggIAoqICAgXCAgICAgIFxfLX4gICAgICAgICAgICAgICAgICAgIH4tX1wgICAgfCAgICAqCmcgICAgXF8gICAgIFwgICAgICAgIF8uLS0tLS0tLS0uX19fX19fXHwgICB8ICAgIGcKbyAgICAgIFwgICAgIFxfX19fX18vLyBfIF9fXyBfIChfKF9fPiAgXCAgIHwgICAgbwphICAgICAgIFwgICAuICBDIF9fXykgIF9fX19fXyAoXyhfX19fPiAgfCAgLyAgICBhCnQgICAgICAgL1wgfCAgIEMgX19fXykvICAgICAgXCAoX19fX18+ICB8Xy8gICAgIHQKcyAgICAgIC8gL1x8ICAgQ19fX19fKSAgICAgICB8ICAoX19fPiAgIC8gIFwgICAgcwplICAgICB8ICAgKCAgIF9DX19fX18pXF9fX19fXy8gIC8vIF8vIC8gICAgIFwgICBlCnggICAgIHwgICAgXCAgfF9fICAgXFxfX19fX19fX18vLyAoX18vICAgICAgIHwgIHgKKiAgICB8IFwgICAgXF9fX18pICAgYC0tLS0gICAtLScgICAgICAgICAgICAgfCAgKgpnICAgIHwgIFxfICAgICAgICAgIF9fX1wgICAgICAgL18gICAgICAgICAgXy8gfCBnCm8gICB8ICAgICAgICAgICAgICAvICAgIHwgICAgIHwgIFwgICAgICAgICAgICB8IG8KYSAgIHwgICAgICAgICAgICAgfCAgICAvICAgICAgIFwgIFwgICAgICAgICAgIHwgYQp0ICAgfCAgICAgICAgICAvIC8gICAgfCAgICAgICAgIHwgIFwgICAgICAgICAgIHx0CnMgICB8ICAgICAgICAgLyAvICAgICAgXF9fL1xfX18vICAgIHwgICAgICAgICAgfHMKZSAgfCAgICAgICAgICAgLyAgICAgICAgfCAgICB8ICAgICAgIHwgICAgICAgICB8ZQp4ICB8ICAgICAgICAgIHwgICAgICAgICB8ICAgIHwgICAgICAgfCAgICAgICAgIHx4CiogZyBvIGEgdCBzIGUgeCAqIGcgbyBhIHQgcyBlIHggKiBnIG8gYSB0IHMgZSB4ICoK" //nolint:lll // base64 blob - -// decodePandaASCII decodes the base64-encoded ASCII art. -func decodePandaASCII() string { - b, err := base64.StdEncoding.DecodeString(pandaASCIIBase64) - if err != nil { - return "(panda art failed to decode)" - } - - return string(b) + // Display fields resolved by proxy success page rules. + Tagline string + MediaType string // "gif" or "ascii" + MediaURL string + MediaASCIIB64 string } // buildSuccessPage generates a styled HTML success page based on user info. func buildSuccessPage(user callbackUser) string { //nolint:funlen // single HTML template - isEthPandaOps := hasOrg(user.Orgs, "ethpandaops") - isSpecialUser := specialUsers[strings.ToLower(user.Login)] + hasOrgs := len(user.Orgs) > 0 login := html.EscapeString(user.Login) if login == "" { @@ -59,32 +39,42 @@ func buildSuccessPage(user callbackUser) string { //nolint:funlen // single HTML avatarHTML = `
` + strings.ToUpper(login[:1]) + `
` } - // Build the context line — what org, what just happened. + // Build the context line — org badge or generic identity note. contextHTML := `GitHub identity linked` - if isEthPandaOps { - contextHTML = `ethpandaops` + if hasOrgs { + contextHTML = fmt.Sprintf(`%s`, + html.EscapeString(strings.ToLower(user.Orgs[0]))) } // Status subtitle. statusSub := "You've successfully logged in to panda" - if isEthPandaOps { - statusSub = "You've successfully logged in to ethpandaops/panda" + if hasOrgs { + statusSub = fmt.Sprintf("You've successfully logged in to %s/panda", + strings.ToLower(user.Orgs[0])) } - // Tagline — shown after the media block. - tagline := "You can close this window and return to your terminal." - if isEthPandaOps { - tagline = "Enjoy debugging your devnet champ" + // Tagline — from config rules or default. + tagline := user.Tagline + if tagline == "" { + tagline = "You can close this window and return to your terminal." } - // Media — GIF or ASCII art for ethpandaops members. + // Media — rendered from config-driven display fields. mediaHTML := "" - if isEthPandaOps { - if isSpecialUser { - art := decodePandaASCII() - mediaHTML = fmt.Sprintf(`
%s
`, html.EscapeString(art)) - } else { - mediaHTML = `
` //nolint:lll // gif URL + + switch user.MediaType { + case "ascii": + if user.MediaASCIIB64 != "" { + if art, err := base64.StdEncoding.DecodeString(user.MediaASCIIB64); err == nil { + mediaHTML = fmt.Sprintf(`
%s
`, html.EscapeString(string(art))) + } + } + case "gif": + if user.MediaURL != "" { + mediaHTML = fmt.Sprintf( + `
`, + html.EscapeString(user.MediaURL), + ) } } diff --git a/pkg/auth/client/success_page_test.go b/pkg/auth/client/success_page_test.go index 1463da7..4d8919a 100644 --- a/pkg/auth/client/success_page_test.go +++ b/pkg/auth/client/success_page_test.go @@ -1,6 +1,7 @@ package client import ( + "encoding/base64" "os" "path/filepath" "strings" @@ -10,14 +11,6 @@ import ( "github.com/stretchr/testify/require" ) -func TestDecodePandaASCII(t *testing.T) { - t.Parallel() - - art := decodePandaASCII() - - assert.NotEmpty(t, art) -} - func TestBuildSuccessPage_Default(t *testing.T) { t.Parallel() @@ -31,12 +24,12 @@ func TestBuildSuccessPage_Default(t *testing.T) { assert.Contains(t, page, "logged in to panda") assert.Contains(t, page, "avatar.png") assert.Contains(t, page, "panda datasources") - assert.NotContains(t, page, "ethpandaops") - assert.NotContains(t, page, "casino-royale") - assert.NotContains(t, page, "`) } -func TestBuildSuccessPage_EthPandaOps(t *testing.T) { +func TestBuildSuccessPage_WithOrg(t *testing.T) { t.Parallel() page := buildSuccessPage(callbackUser{ @@ -46,55 +39,72 @@ func TestBuildSuccessPage_EthPandaOps(t *testing.T) { }) assert.Contains(t, page, "pandafan") - assert.Contains(t, page, "Enjoy debugging your devnet champ") assert.Contains(t, page, "ethpandaops") - assert.Contains(t, page, "casino-royale-bond.gif") - assert.NotContains(t, page, "`) + assert.NotContains(t, page, "
`)
 }
 
-func TestBuildSuccessPage_SpecialUserWithoutOrg(t *testing.T) {
+func TestBuildSuccessPage_InvalidASCIIBase64(t *testing.T) {
 	t.Parallel()
 
 	page := buildSuccessPage(callbackUser{
-		Login: "samcm",
-		Orgs:  []string{"some-other-org"},
+		Login:         "someone",
+		MediaType:     "ascii",
+		MediaASCIIB64: "not-valid-base64!!!",
 	})
 
-	assert.Contains(t, page, "logged in to panda")
+	// Should gracefully skip the media block.
 	assert.NotContains(t, page, " 0 && !matchesAnyOrg(orgs, r.Match.Orgs) {
+		return false
+	}
+
+	if len(r.Match.Users) > 0 && !containsLower(r.Match.Users, lowerLogin) {
+		return false
+	}
+
+	return true
+}
+
+// matchesAnyOrg returns true if the user belongs to at least one of the target orgs.
+func matchesAnyOrg(userOrgs, targetOrgs []string) bool {
+	for _, target := range targetOrgs {
+		lower := strings.ToLower(target)
+
+		for _, org := range userOrgs {
+			if strings.ToLower(org) == lower {
+				return true
+			}
+		}
+	}
+
+	return false
+}
+
+// containsLower returns true if needle is in the haystack (case-insensitive).
+func containsLower(haystack []string, needle string) bool {
+	for _, s := range haystack {
+		if strings.ToLower(s) == needle {
+			return true
+		}
+	}
+
+	return false
+}
diff --git a/pkg/auth/success_page_test.go b/pkg/auth/success_page_test.go
new file mode 100644
index 0000000..ddaf1e7
--- /dev/null
+++ b/pkg/auth/success_page_test.go
@@ -0,0 +1,199 @@
+package auth
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestSuccessPageConfig_Resolve(t *testing.T) {
+	t.Parallel()
+
+	cfg := &SuccessPageConfig{
+		Rules: []SuccessPageRule{
+			{
+				Match: SuccessPageMatch{
+					Orgs:  []string{"ethpandaops"},
+					Users: []string{"samcm", "mattevans"},
+				},
+				SuccessPageDisplay: SuccessPageDisplay{
+					Tagline: "special user tagline",
+					Media: &SuccessPageMedia{
+						Type:           "ascii",
+						ASCIIArtBase64: "dGVzdA==",
+					},
+				},
+			},
+			{
+				Match: SuccessPageMatch{
+					Orgs: []string{"ethpandaops"},
+				},
+				SuccessPageDisplay: SuccessPageDisplay{
+					Tagline: "org tagline",
+					Media: &SuccessPageMedia{
+						Type: "gif",
+						URL:  "https://example.com/cool.gif",
+					},
+				},
+			},
+		},
+		Default: &SuccessPageDisplay{
+			Tagline: "default tagline",
+		},
+	}
+
+	tests := []struct {
+		name           string
+		login          string
+		orgs           []string
+		wantTagline    string
+		wantMediaType  string
+		wantMediaURL   string
+		wantMediaASCII string
+	}{
+		{
+			name:           "special user matches first rule",
+			login:          "samcm",
+			orgs:           []string{"ethpandaops"},
+			wantTagline:    "special user tagline",
+			wantMediaType:  "ascii",
+			wantMediaASCII: "dGVzdA==",
+		},
+		{
+			name:          "org member matches second rule",
+			login:         "someone",
+			orgs:          []string{"ethpandaops"},
+			wantTagline:   "org tagline",
+			wantMediaType: "gif",
+			wantMediaURL:  "https://example.com/cool.gif",
+		},
+		{
+			name:        "no match returns default",
+			login:       "outsider",
+			orgs:        []string{"other-org"},
+			wantTagline: "default tagline",
+		},
+		{
+			name:        "special user without matching org falls through",
+			login:       "samcm",
+			orgs:        []string{"other-org"},
+			wantTagline: "default tagline",
+		},
+		{
+			name:        "empty user returns default",
+			login:       "",
+			orgs:        nil,
+			wantTagline: "default tagline",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+
+			display := cfg.Resolve(tt.login, tt.orgs)
+
+			assert.Equal(t, tt.wantTagline, display.Tagline)
+
+			if tt.wantMediaType != "" {
+				assert.NotNil(t, display.Media)
+				assert.Equal(t, tt.wantMediaType, display.Media.Type)
+				assert.Equal(t, tt.wantMediaURL, display.Media.URL)
+				assert.Equal(t, tt.wantMediaASCII, display.Media.ASCIIArtBase64)
+			} else {
+				assert.Nil(t, display.Media)
+			}
+		})
+	}
+}
+
+func TestSuccessPageConfig_Resolve_CaseInsensitive(t *testing.T) {
+	t.Parallel()
+
+	cfg := &SuccessPageConfig{
+		Rules: []SuccessPageRule{
+			{
+				Match: SuccessPageMatch{
+					Orgs:  []string{"EthPandaOps"},
+					Users: []string{"SamCM"},
+				},
+				SuccessPageDisplay: SuccessPageDisplay{
+					Tagline: "matched",
+				},
+			},
+		},
+	}
+
+	display := cfg.Resolve("samcm", []string{"ethpandaops"})
+	assert.Equal(t, "matched", display.Tagline)
+
+	display = cfg.Resolve("SAMCM", []string{"ETHPANDAOPS"})
+	assert.Equal(t, "matched", display.Tagline)
+}
+
+func TestSuccessPageConfig_Resolve_NilConfig(t *testing.T) {
+	t.Parallel()
+
+	var cfg *SuccessPageConfig
+	display := cfg.Resolve("samcm", []string{"ethpandaops"})
+	assert.Empty(t, display.Tagline)
+	assert.Nil(t, display.Media)
+}
+
+func TestSuccessPageConfig_Resolve_EmptyRulesNoDefault(t *testing.T) {
+	t.Parallel()
+
+	cfg := &SuccessPageConfig{}
+	display := cfg.Resolve("samcm", []string{"ethpandaops"})
+	assert.Empty(t, display.Tagline)
+	assert.Nil(t, display.Media)
+}
+
+func TestSuccessPageConfig_Resolve_UsersOnlyMatch(t *testing.T) {
+	t.Parallel()
+
+	cfg := &SuccessPageConfig{
+		Rules: []SuccessPageRule{
+			{
+				Match: SuccessPageMatch{
+					Users: []string{"admin"},
+				},
+				SuccessPageDisplay: SuccessPageDisplay{
+					Tagline: "admin tagline",
+				},
+			},
+		},
+	}
+
+	display := cfg.Resolve("admin", nil)
+	assert.Equal(t, "admin tagline", display.Tagline)
+
+	display = cfg.Resolve("other", nil)
+	assert.Empty(t, display.Tagline)
+}
+
+func TestSuccessPageConfig_Resolve_OrgsOnlyMatch(t *testing.T) {
+	t.Parallel()
+
+	cfg := &SuccessPageConfig{
+		Rules: []SuccessPageRule{
+			{
+				Match: SuccessPageMatch{
+					Orgs: []string{"myorg", "otherorg"},
+				},
+				SuccessPageDisplay: SuccessPageDisplay{
+					Tagline: "org member",
+				},
+			},
+		},
+	}
+
+	display := cfg.Resolve("anyone", []string{"myorg"})
+	assert.Equal(t, "org member", display.Tagline)
+
+	display = cfg.Resolve("anyone", []string{"otherorg"})
+	assert.Equal(t, "org member", display.Tagline)
+
+	display = cfg.Resolve("anyone", []string{"nope"})
+	assert.Empty(t, display.Tagline)
+}
diff --git a/pkg/proxy/server.go b/pkg/proxy/server.go
index 039d4df..973842b 100644
--- a/pkg/proxy/server.go
+++ b/pkg/proxy/server.go
@@ -97,6 +97,7 @@ func newServer(log logrus.FieldLogger, cfg ServerConfig, hostURL, port string) (
 			GitHub:      cfg.Auth.GitHub,
 			AllowedOrgs: append([]string(nil), cfg.Auth.AllowedOrgs...),
 			Tokens:      cfg.Auth.Tokens,
+			SuccessPage: cfg.Auth.SuccessPage,
 		}
 
 		authSvc, err := simpleauth.NewSimpleService(log, authCfg)
diff --git a/pkg/proxy/server_config.go b/pkg/proxy/server_config.go
index 60c94a6..ca6ae53 100644
--- a/pkg/proxy/server_config.go
+++ b/pkg/proxy/server_config.go
@@ -79,6 +79,9 @@ type AuthConfig struct {
 
 	// AccessTokenTTL is the lifetime of proxy-issued access tokens.
 	AccessTokenTTL time.Duration `yaml:"access_token_ttl,omitempty"`
+
+	// SuccessPage customizes the OAuth callback success page shown in the browser.
+	SuccessPage *simpleauth.SuccessPageConfig `yaml:"success_page,omitempty"`
 }
 
 // ClickHouseClusterConfig holds ClickHouse cluster configuration.
diff --git a/proxy-config.example.yaml b/proxy-config.example.yaml
index 3cb11a3..d2819d2 100644
--- a/proxy-config.example.yaml
+++ b/proxy-config.example.yaml
@@ -36,6 +36,26 @@ auth:
   # tokens:
   #   secret_key: "${PROXY_TOKEN_SECRET}"
 
+  # Customize the OAuth callback success page shown in the browser.
+  # Rules are evaluated in order; the first match wins.
+  # success_page:
+  #   rules:
+  #     - match:
+  #         orgs: ["ethpandaops"]
+  #         users: ["samcm", "mattevans"]
+  #       media:
+  #         type: ascii
+  #         ascii_art_base64: "base64-encoded-art-here"
+  #       tagline: "Enjoy debugging your devnet champ"
+  #     - match:
+  #         orgs: ["ethpandaops"]
+  #       media:
+  #         type: gif
+  #         url: "https://example.com/cool.gif"
+  #       tagline: "Enjoy debugging your devnet champ"
+  #   default:
+  #     tagline: "You can close this window and return to your terminal."
+
 # ClickHouse clusters
 clickhouse:
   - name: xatu