The code and data behind xeiaso.net
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(sponsor-panel): add Patreon OAuth login support (#1179)

* feat(sponsor-panel): add Patreon OAuth login support

Add Patreon as a second authentication provider so patrons get the same
features as GitHub Sponsors (Discord invite, team invitations, logo
submissions). Uses Patreon API v2 for identity and membership
verification via direct HTTP calls, with patreon-go for OAuth constants.

- Add optional Patreon OAuth flags (service works GitHub-only if omitted)
- Add database migration for patreon_id, provider columns
- Create patreon_oauth.go with login/callback handlers
- Update login page with conditional Patreon button
- Update dashboard to show provider-appropriate sponsor links
- Patreon users are separate identities, same tier-gated features

* feat(sponsor-panel): add --patreon-fifty-plus flag for blessed patrons

Adds a flag to bless Patreon usernames with the $50/month tier,
mirroring the existing --fifty-plus-sponsors flag for GitHub users.
Also adds debug logging for Patreon identity and membership data.

authored by

Xe Iaso and committed by
GitHub
0bed8e5a a0bcb68c

+512 -44
+2 -1
cmd/sponsor-panel/dashboard.go
··· 26 26 } 27 27 28 28 templ.Handler( 29 - templates.Base("Login", templates.Login()), 29 + templates.Base("Login", templates.Login(s.patreonOAuth != nil)), 30 30 ).ServeHTTP(w, r) 31 31 } 32 32 ··· 86 86 User: templates.UserProps{ 87 87 Login: user.Login, 88 88 AvatarURL: user.AvatarURL, 89 + Provider: user.Provider, 89 90 }, 90 91 IsSponsor: isSponsor, 91 92 SponsorAmount: monthlyAmount,
+49 -2
cmd/sponsor-panel/main.go
··· 21 21 "github.com/gorilla/sessions" 22 22 "github.com/jackc/pgx/v5/pgxpool" 23 23 _ "github.com/joho/godotenv/autoload" 24 + patreon "gopkg.in/mxpv/patreon-go.v1" 24 25 "github.com/prometheus/client_golang/prometheus/promhttp" 25 26 "golang.org/x/oauth2" 26 27 "golang.org/x/oauth2/github" ··· 41 42 logoSubmissionRepo = flag.String("logo-submission-repo", "anubis", "Repo to submit logo requests to") 42 43 sponsorTarget = flag.String("sponsor-target", "Xe", "GitHub username to sync sponsorships for") 43 44 44 - // OAuth configuration 45 + // GitHub OAuth configuration 45 46 clientID = flag.String("github-client-id", "", "GitHub OAuth Client ID") 46 47 clientSecret = flag.String("github-client-secret", "", "GitHub OAuth Client Secret") 47 48 oauthRedirect = flag.String("oauth-redirect-url", "", "OAuth redirect URL") 48 49 50 + // Patreon OAuth configuration (optional) 51 + patreonClientID = flag.String("patreon-client-id", "", "Patreon OAuth Client ID") 52 + patreonClientSecret = flag.String("patreon-client-secret", "", "Patreon OAuth Client Secret") 53 + patreonRedirect = flag.String("patreon-redirect-url", "", "Patreon OAuth redirect URL") 54 + patreonCampaignID = flag.String("patreon-campaign-id", "", "Patreon campaign ID to check pledges against") 55 + patreonFiftyPlus = flag.String("patreon-fifty-plus", "", "Comma-separated list of Patreon usernames always treated as $50+ sponsors") 56 + 49 57 //go:embed static 50 58 staticFS embed.FS 51 59 ) ··· 55 63 pool *pgxpool.Pool 56 64 ghClient *gh.Client 57 65 oauth *oauth2.Config 66 + patreonOAuth *oauth2.Config // nil if Patreon not configured 67 + patreonCampaignID string 68 + patreonFiftyPlusSpons map[string]bool // Patreon usernames always treated as $50+ 58 69 discordInvite string 59 70 fiftyPlusSponsors map[string]bool // Always treated as $50+ sponsors 60 71 sessionStore *sessions.CookieStore ··· 175 186 Scopes: []string{"read:user", "user:email", "read:org", "read:sponsors"}, 176 187 Endpoint: github.Endpoint, 177 188 } 178 - slog.Debug("main: OAuth configured", "client_id", *clientID, "redirect_url", *oauthRedirect) 189 + slog.Debug("main: GitHub OAuth configured", "client_id", *clientID, "redirect_url", *oauthRedirect) 190 + 191 + // Patreon OAuth configuration (optional) 192 + var patreonConfig *oauth2.Config 193 + if *patreonClientID != "" && *patreonClientSecret != "" && *patreonRedirect != "" { 194 + patreonConfig = &oauth2.Config{ 195 + ClientID: *patreonClientID, 196 + ClientSecret: *patreonClientSecret, 197 + RedirectURL: *patreonRedirect, 198 + Scopes: []string{"identity", "identity[email]", "campaigns.members"}, 199 + Endpoint: oauth2.Endpoint{ 200 + AuthURL: patreon.AuthorizationURL, 201 + TokenURL: patreon.AccessTokenURL, 202 + }, 203 + } 204 + slog.Info("main: Patreon OAuth configured", "client_id", *patreonClientID, "redirect_url", *patreonRedirect) 205 + } 179 206 180 207 // Parse fifty-plus sponsors list 181 208 fiftyPlusMap := make(map[string]bool) ··· 190 217 slog.Info("main: loaded fifty-plus sponsors", "count", len(fiftyPlusMap)) 191 218 } 192 219 220 + // Parse Patreon fifty-plus sponsors list 221 + patreonFiftyPlusMap := make(map[string]bool) 222 + if *patreonFiftyPlus != "" { 223 + slog.Debug("main: parsing patreon fifty-plus sponsors", "list", *patreonFiftyPlus) 224 + for _, sponsor := range strings.Split(*patreonFiftyPlus, ",") { 225 + sponsor = strings.TrimSpace(sponsor) 226 + if sponsor != "" { 227 + patreonFiftyPlusMap[sponsor] = true 228 + } 229 + } 230 + slog.Info("main: loaded patreon fifty-plus sponsors", "count", len(patreonFiftyPlusMap)) 231 + } 232 + 193 233 // Create session store 194 234 slog.Debug("main: creating session store") 195 235 sessionStore := sessions.NewCookieStore([]byte(*sessionKey)) ··· 218 258 pool: pool, 219 259 ghClient: ghClient, 220 260 oauth: oauthConfig, 261 + patreonOAuth: patreonConfig, 262 + patreonCampaignID: *patreonCampaignID, 263 + patreonFiftyPlusSpons: patreonFiftyPlusMap, 221 264 discordInvite: *discordInvite, 222 265 fiftyPlusSponsors: fiftyPlusMap, 223 266 sessionStore: sessionStore, ··· 245 288 mux.HandleFunc("/login", server.loginHandler) 246 289 mux.HandleFunc("/callback", server.callbackHandler) 247 290 mux.HandleFunc("/logout", server.logoutHandler) 291 + 292 + // Patreon OAuth handlers 293 + mux.HandleFunc("/login/patreon", server.patreonLoginHandler) 294 + mux.HandleFunc("/callback/patreon", server.patreonCallbackHandler) 248 295 249 296 // Login page handler 250 297 mux.HandleFunc("/login-page", server.loginPageHandler)
+30
cmd/sponsor-panel/migrations.go
··· 61 61 CREATE INDEX IF NOT EXISTS idx_sponsor_active ON github_sponsor_usernames(is_active); 62 62 ` 63 63 64 + const migration002 = ` 65 + -- Make github_id nullable (Patreon users won't have one) 66 + ALTER TABLE users ALTER COLUMN github_id DROP NOT NULL; 67 + 68 + -- Add patreon_id column for Patreon OAuth users 69 + ALTER TABLE users ADD COLUMN IF NOT EXISTS patreon_id TEXT UNIQUE; 70 + 71 + -- Add provider column to distinguish auth source 72 + ALTER TABLE users ADD COLUMN IF NOT EXISTS provider TEXT NOT NULL DEFAULT 'github'; 73 + 74 + -- Drop old unique constraint on login (may not exist by name) 75 + DO $$ BEGIN 76 + ALTER TABLE users DROP CONSTRAINT IF EXISTS users_login_key; 77 + EXCEPTION WHEN undefined_object THEN NULL; 78 + END $$; 79 + 80 + -- Uniqueness is now per-provider 81 + DROP INDEX IF EXISTS idx_users_provider_login; 82 + CREATE UNIQUE INDEX IF NOT EXISTS idx_users_provider_login ON users(provider, login); 83 + 84 + -- Index for Patreon lookups 85 + CREATE INDEX IF NOT EXISTS idx_users_patreon_id ON users(patreon_id); 86 + ` 87 + 64 88 // runMigrations executes the database schema migration. 65 89 func runMigrations(ctx context.Context, pool *pgxpool.Pool) error { 66 90 slog.Info("running database migrations") ··· 68 92 if err != nil { 69 93 return err 70 94 } 95 + 96 + _, err = pool.Exec(ctx, migration002) 97 + if err != nil { 98 + return err 99 + } 100 + 71 101 slog.Info("database migrations completed") 72 102 return nil 73 103 }
+57 -10
cmd/sponsor-panel/models.go
··· 9 9 "github.com/jackc/pgx/v5/pgxpool" 10 10 ) 11 11 12 - // User represents a GitHub user with cached sponsorship data. 12 + // User represents an authenticated user with cached sponsorship data. 13 13 type User struct { 14 14 ID int `json:"id" db:"id"` 15 - GitHubID int64 `json:"github_id" db:"github_id"` 15 + GitHubID *int64 `json:"github_id" db:"github_id"` 16 + PatreonID *string `json:"patreon_id" db:"patreon_id"` 17 + Provider string `json:"provider" db:"provider"` // "github" or "patreon" 16 18 Login string `json:"login" db:"login"` 17 19 AvatarURL string `json:"avatar_url" db:"avatar_url"` 18 20 Name string `json:"name" db:"name"` ··· 85 87 86 88 var user User 87 89 err := pool.QueryRow(ctx, ` 88 - SELECT id, github_id, login, avatar_url, name, email, 90 + SELECT id, github_id, patreon_id, provider, login, avatar_url, name, email, 89 91 sponsorship_data, last_sponsorship_check, created_at, updated_at 90 92 FROM users WHERE id = $1 91 93 `, userID).Scan( 92 - &user.ID, &user.GitHubID, &user.Login, &user.AvatarURL, 94 + &user.ID, &user.GitHubID, &user.PatreonID, &user.Provider, &user.Login, &user.AvatarURL, 93 95 &user.Name, &user.Email, &user.SponsorshipData, 94 96 &user.LastSponsorshipCheck, &user.CreatedAt, &user.UpdatedAt, 95 97 ) ··· 122 124 if tag.RowsAffected() > 0 { 123 125 slog.Debug("upsertUser: updated existing user", "github_id", user.GitHubID, "rows_affected", tag.RowsAffected()) 124 126 return pool.QueryRow(ctx, ` 125 - SELECT id, github_id, login, avatar_url, name, email, 127 + SELECT id, github_id, patreon_id, provider, login, avatar_url, name, email, 126 128 sponsorship_data, last_sponsorship_check, created_at, updated_at 127 129 FROM users WHERE github_id = $1 128 130 `, user.GitHubID).Scan( 129 - &user.ID, &user.GitHubID, &user.Login, &user.AvatarURL, 131 + &user.ID, &user.GitHubID, &user.PatreonID, &user.Provider, &user.Login, &user.AvatarURL, 130 132 &user.Name, &user.Email, &user.SponsorshipData, 131 133 &user.LastSponsorshipCheck, &user.CreatedAt, &user.UpdatedAt, 132 134 ) ··· 135 137 slog.Debug("upsertUser: inserting new user", "github_id", user.GitHubID, "login", user.Login) 136 138 137 139 return pool.QueryRow(ctx, ` 138 - INSERT INTO users (github_id, login, avatar_url, name, email, sponsorship_data) 139 - VALUES ($1, $2, $3, $4, $5, $6) 140 - RETURNING id, github_id, login, avatar_url, name, email, 140 + INSERT INTO users (github_id, provider, login, avatar_url, name, email, sponsorship_data) 141 + VALUES ($1, 'github', $2, $3, $4, $5, $6) 142 + RETURNING id, github_id, patreon_id, provider, login, avatar_url, name, email, 141 143 sponsorship_data, last_sponsorship_check, created_at, updated_at 142 144 `, user.GitHubID, user.Login, user.AvatarURL, user.Name, user.Email, 143 145 user.SponsorshipData).Scan( 144 - &user.ID, &user.GitHubID, &user.Login, &user.AvatarURL, 146 + &user.ID, &user.GitHubID, &user.PatreonID, &user.Provider, &user.Login, &user.AvatarURL, 145 147 &user.Name, &user.Email, &user.SponsorshipData, 146 148 &user.LastSponsorshipCheck, &user.CreatedAt, &user.UpdatedAt, 147 149 ) ··· 244 246 245 247 slog.Debug("upsertSponsorUsername: inserted new sponsor", "username", sponsor.Username, "id", sponsor.ID) 246 248 return nil 249 + } 250 + 251 + // upsertPatreonUser creates or updates a Patreon user in the database. 252 + func upsertPatreonUser(ctx context.Context, pool *pgxpool.Pool, user *User) error { 253 + slog.Debug("upsertPatreonUser: attempting upsert", "patreon_id", user.PatreonID, "login", user.Login) 254 + 255 + // Try update first 256 + tag, err := pool.Exec(ctx, ` 257 + UPDATE users 258 + SET login=$1, avatar_url=$2, name=$3, email=$4, 259 + sponsorship_data=$5, last_sponsorship_check=NOW(), updated_at=NOW() 260 + WHERE patreon_id=$6 261 + `, user.Login, user.AvatarURL, user.Name, user.Email, 262 + user.SponsorshipData, user.PatreonID) 263 + if err != nil { 264 + slog.Error("upsertPatreonUser: update failed", "err", err, "patreon_id", user.PatreonID) 265 + return err 266 + } 267 + 268 + if tag.RowsAffected() > 0 { 269 + slog.Debug("upsertPatreonUser: updated existing user", "patreon_id", user.PatreonID, "rows_affected", tag.RowsAffected()) 270 + return pool.QueryRow(ctx, ` 271 + SELECT id, github_id, patreon_id, provider, login, avatar_url, name, email, 272 + sponsorship_data, last_sponsorship_check, created_at, updated_at 273 + FROM users WHERE patreon_id = $1 274 + `, user.PatreonID).Scan( 275 + &user.ID, &user.GitHubID, &user.PatreonID, &user.Provider, &user.Login, &user.AvatarURL, 276 + &user.Name, &user.Email, &user.SponsorshipData, 277 + &user.LastSponsorshipCheck, &user.CreatedAt, &user.UpdatedAt, 278 + ) 279 + } 280 + 281 + slog.Debug("upsertPatreonUser: inserting new user", "patreon_id", user.PatreonID, "login", user.Login) 282 + 283 + return pool.QueryRow(ctx, ` 284 + INSERT INTO users (patreon_id, provider, login, avatar_url, name, email, sponsorship_data) 285 + VALUES ($1, 'patreon', $2, $3, $4, $5, $6) 286 + RETURNING id, github_id, patreon_id, provider, login, avatar_url, name, email, 287 + sponsorship_data, last_sponsorship_check, created_at, updated_at 288 + `, user.PatreonID, user.Login, user.AvatarURL, user.Name, user.Email, 289 + user.SponsorshipData).Scan( 290 + &user.ID, &user.GitHubID, &user.PatreonID, &user.Provider, &user.Login, &user.AvatarURL, 291 + &user.Name, &user.Email, &user.SponsorshipData, 292 + &user.LastSponsorshipCheck, &user.CreatedAt, &user.UpdatedAt, 293 + ) 247 294 } 248 295 249 296 // markInactiveSponsorsNotIn marks all sponsors as inactive that are not in the given usernames list.
+2 -1
cmd/sponsor-panel/oauth.go
··· 558 558 559 559 // Upsert user in database 560 560 user := &User{ 561 - GitHubID: ghUser.ID, 561 + GitHubID: &ghUser.ID, 562 + Provider: "github", 562 563 Login: ghUser.Login, 563 564 AvatarURL: ghUser.AvatarURL, 564 565 Name: ghUser.Name,
+293
cmd/sponsor-panel/patreon_oauth.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "io" 8 + "log/slog" 9 + "net/http" 10 + 11 + "github.com/gorilla/sessions" 12 + ) 13 + 14 + // Patreon API v2 response types (JSON:API format) 15 + 16 + type patreonIdentityResponse struct { 17 + Data struct { 18 + ID string `json:"id"` 19 + Attributes struct { 20 + FullName string `json:"full_name"` 21 + Vanity string `json:"vanity"` 22 + Email string `json:"email"` 23 + ImageURL string `json:"image_url"` 24 + } `json:"attributes"` 25 + Relationships struct { 26 + Memberships struct { 27 + Data []struct { 28 + ID string `json:"id"` 29 + Type string `json:"type"` 30 + } `json:"data"` 31 + } `json:"memberships"` 32 + } `json:"relationships"` 33 + } `json:"data"` 34 + Included []json.RawMessage `json:"included"` 35 + } 36 + 37 + type patreonMember struct { 38 + Type string `json:"type"` 39 + ID string `json:"id"` 40 + Attributes struct { 41 + PatronStatus string `json:"patron_status"` 42 + CurrentlyEntitledAmountCents int `json:"currently_entitled_amount_cents"` 43 + } `json:"attributes"` 44 + Relationships struct { 45 + Campaign struct { 46 + Data struct { 47 + ID string `json:"id"` 48 + Type string `json:"type"` 49 + } `json:"data"` 50 + } `json:"campaign"` 51 + } `json:"relationships"` 52 + } 53 + 54 + // patreonLoginHandler initiates the Patreon OAuth flow. 55 + func (s *Server) patreonLoginHandler(w http.ResponseWriter, r *http.Request) { 56 + if r.Method != http.MethodGet { 57 + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 58 + return 59 + } 60 + 61 + if s.patreonOAuth == nil { 62 + http.NotFound(w, r) 63 + return 64 + } 65 + 66 + slog.Debug("patreonLoginHandler: initiating Patreon OAuth flow") 67 + 68 + state, err := generateState() 69 + if err != nil { 70 + slog.Error("patreonLoginHandler: failed to generate state", "err", err) 71 + http.Error(w, "Failed to generate state", http.StatusInternalServerError) 72 + return 73 + } 74 + 75 + // Set state in cookie for CSRF protection 76 + http.SetCookie(w, &http.Cookie{ 77 + Name: "oauth_state", 78 + Value: state, 79 + Path: "/", 80 + HttpOnly: true, 81 + SameSite: http.SameSiteLaxMode, 82 + Secure: s.cookieSecure, 83 + }) 84 + 85 + url := s.patreonOAuth.AuthCodeURL(state) 86 + slog.Debug("patreonLoginHandler: redirecting to Patreon OAuth", "url", url) 87 + http.Redirect(w, r, url, http.StatusFound) 88 + } 89 + 90 + // patreonCallbackHandler handles the OAuth callback from Patreon. 91 + func (s *Server) patreonCallbackHandler(w http.ResponseWriter, r *http.Request) { 92 + if r.Method != http.MethodGet { 93 + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 94 + return 95 + } 96 + 97 + if s.patreonOAuth == nil { 98 + http.NotFound(w, r) 99 + return 100 + } 101 + 102 + slog.Debug("patreonCallbackHandler: received OAuth callback") 103 + 104 + // Verify state for CSRF protection 105 + stateCookie, err := r.Cookie("oauth_state") 106 + if err != nil { 107 + slog.Error("patreonCallbackHandler: missing oauth_state cookie") 108 + renderOAuthError(w, "Invalid OAuth state") 109 + return 110 + } 111 + 112 + state := r.URL.Query().Get("state") 113 + if state != stateCookie.Value { 114 + slog.Error("patreonCallbackHandler: oauth state mismatch") 115 + renderOAuthError(w, "Invalid OAuth state") 116 + return 117 + } 118 + 119 + // Clear the state cookie 120 + http.SetCookie(w, &http.Cookie{ 121 + Name: "oauth_state", 122 + Value: "", 123 + Path: "/", 124 + MaxAge: -1, 125 + HttpOnly: true, 126 + Secure: s.cookieSecure, 127 + }) 128 + 129 + // Exchange code for token 130 + code := r.URL.Query().Get("code") 131 + if code == "" { 132 + slog.Error("patreonCallbackHandler: missing authorization code") 133 + renderOAuthError(w, "Missing authorization code") 134 + return 135 + } 136 + 137 + token, err := s.patreonOAuth.Exchange(r.Context(), code) 138 + if err != nil { 139 + slog.Error("patreonCallbackHandler: failed to exchange token", "err", err) 140 + renderOAuthError(w, "Failed to exchange token") 141 + return 142 + } 143 + 144 + slog.Debug("patreonCallbackHandler: token exchange successful") 145 + 146 + // Fetch identity from Patreon API v2 147 + identity, err := fetchPatreonIdentity(r.Context(), token.AccessToken) 148 + if err != nil { 149 + slog.Error("patreonCallbackHandler: failed to fetch identity", "err", err) 150 + renderOAuthError(w, "Failed to fetch Patreon identity") 151 + return 152 + } 153 + 154 + slog.Debug("patreonCallbackHandler: fetched Patreon identity", 155 + "patreon_id", identity.Data.ID, 156 + "full_name", identity.Data.Attributes.FullName, 157 + "vanity", identity.Data.Attributes.Vanity) 158 + 159 + // Determine login name (prefer vanity URL, fall back to full name) 160 + login := identity.Data.Attributes.Vanity 161 + if login == "" { 162 + login = identity.Data.Attributes.FullName 163 + } 164 + 165 + // Check if user is in the Patreon fifty-plus sponsors list 166 + slog.Debug("patreonCallbackHandler: checking fifty-plus allowlist", 167 + "login", login, 168 + "vanity", identity.Data.Attributes.Vanity, 169 + "full_name", identity.Data.Attributes.FullName, 170 + "allowlist", s.patreonFiftyPlusSpons) 171 + sponsorData := `{"is_active": false}` 172 + if s.patreonFiftyPlusSpons[login] { 173 + slog.Info("patreonCallbackHandler: user in patreon fifty-plus list", "login", login) 174 + resultJSON, _ := json.Marshal(map[string]any{ 175 + "is_active": true, 176 + "monthly_amount_cents": 5000, 177 + "tier_name": "Fifty Plus Sponsor", 178 + }) 179 + sponsorData = string(resultJSON) 180 + } 181 + 182 + // Find membership matching our campaign (skip if already blessed via allowlist) 183 + slog.Debug("patreonCallbackHandler: included resources count", "count", len(identity.Included)) 184 + if sponsorData == `{"is_active": false}` { 185 + for i, raw := range identity.Included { 186 + slog.Debug("patreonCallbackHandler: included resource", "index", i, "raw", string(raw)) 187 + var member patreonMember 188 + if err := json.Unmarshal(raw, &member); err != nil { 189 + continue 190 + } 191 + if member.Type != "member" { 192 + continue 193 + } 194 + if s.patreonCampaignID != "" && member.Relationships.Campaign.Data.ID != s.patreonCampaignID { 195 + continue 196 + } 197 + 198 + isActive := member.Attributes.PatronStatus == "active_patron" 199 + amountCents := member.Attributes.CurrentlyEntitledAmountCents 200 + 201 + slog.Info("patreonCallbackHandler: found matching membership", 202 + "campaign_id", member.Relationships.Campaign.Data.ID, 203 + "patron_status", member.Attributes.PatronStatus, 204 + "amount_cents", amountCents) 205 + 206 + tierName := "Patreon Supporter" 207 + if amountCents >= 5000 { 208 + tierName = "Patreon Premium" 209 + } 210 + 211 + resultJSON, _ := json.Marshal(map[string]any{ 212 + "is_active": isActive, 213 + "monthly_amount_cents": amountCents, 214 + "tier_name": tierName, 215 + }) 216 + sponsorData = string(resultJSON) 217 + break 218 + } 219 + } 220 + 221 + slog.Debug("patreonCallbackHandler: sponsorship data", "data", sponsorData) 222 + 223 + // Upsert user in database 224 + patreonID := identity.Data.ID 225 + user := &User{ 226 + PatreonID: &patreonID, 227 + Provider: "patreon", 228 + Login: login, 229 + AvatarURL: identity.Data.Attributes.ImageURL, 230 + Name: identity.Data.Attributes.FullName, 231 + Email: identity.Data.Attributes.Email, 232 + SponsorshipData: sponsorData, 233 + } 234 + 235 + if err := upsertPatreonUser(r.Context(), s.pool, user); err != nil { 236 + slog.Error("patreonCallbackHandler: failed to upsert user", "err", err, "patreon_id", patreonID) 237 + renderOAuthError(w, "Failed to create user") 238 + return 239 + } 240 + 241 + slog.Debug("patreonCallbackHandler: user upserted successfully", "user_id", user.ID, "patreon_id", patreonID) 242 + 243 + // Create session with user ID 244 + session, err := s.sessionStore.Get(r, "session") 245 + if err != nil { 246 + slog.Debug("patreonCallbackHandler: failed to decode existing session, creating new one", "err", err) 247 + session = sessions.NewSession(s.sessionStore, "session") 248 + } 249 + session.Values["user_id"] = user.ID 250 + if err := s.sessionStore.Save(r, w, session); err != nil { 251 + slog.Error("patreonCallbackHandler: failed to save session", "err", err) 252 + renderOAuthError(w, "Failed to save session") 253 + return 254 + } 255 + 256 + slog.Info("patreonCallbackHandler: user logged in successfully", "user_id", user.ID, "login", login) 257 + 258 + http.Redirect(w, r, "/", http.StatusFound) 259 + } 260 + 261 + // fetchPatreonIdentity calls the Patreon API v2 identity endpoint. 262 + func fetchPatreonIdentity(ctx context.Context, accessToken string) (*patreonIdentityResponse, error) { 263 + url := "https://www.patreon.com/api/oauth2/v2/identity" + 264 + "?include=memberships.campaign" + 265 + "&fields[user]=full_name,vanity,email,image_url" + 266 + "&fields[member]=patron_status,currently_entitled_amount_cents" 267 + 268 + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) 269 + if err != nil { 270 + return nil, err 271 + } 272 + 273 + req.Header.Set("Authorization", "Bearer "+accessToken) 274 + req.Header.Set("Accept", "application/json") 275 + 276 + resp, err := http.DefaultClient.Do(req) 277 + if err != nil { 278 + return nil, fmt.Errorf("patreon identity request failed: %w", err) 279 + } 280 + defer resp.Body.Close() 281 + 282 + if resp.StatusCode != http.StatusOK { 283 + body, _ := io.ReadAll(resp.Body) 284 + return nil, fmt.Errorf("patreon API returned status %d: %s", resp.StatusCode, string(body)) 285 + } 286 + 287 + var identity patreonIdentityResponse 288 + if err := json.NewDecoder(resp.Body).Decode(&identity); err != nil { 289 + return nil, fmt.Errorf("failed to decode patreon identity: %w", err) 290 + } 291 + 292 + return &identity, nil 293 + }
+1 -1
cmd/sponsor-panel/templates/base_templ.go
··· 1 1 // Code generated by templ - DO NOT EDIT. 2 2 3 - // templ: version: v0.3.865 3 + // templ: version: v0.3.1001 4 4 package templates 5 5 6 6 //lint:file-ignore SA4006 This context is only used if a nested component is present.
+12 -5
cmd/sponsor-panel/templates/dashboard.templ
··· 14 14 type UserProps struct { 15 15 Login string 16 16 AvatarURL string 17 + Provider string // "github" or "patreon" 17 18 } 18 19 19 20 templ Dashboard(props DashboardProps) { ··· 27 28 if props.IsSponsor { 28 29 @DiscordCard(props.DiscordInvite) 29 30 } 30 - @SponsorshipCard(props.IsSponsor, props.SponsorAmount, props.SponsorTier) 31 + @SponsorshipCard(props.IsSponsor, props.SponsorAmount, props.SponsorTier, props.User.Provider) 31 32 </div> 32 33 <div class="grid md:grid-cols-2 gap-8 mt-8"> 33 34 if props.IsFiftyPlus { ··· 71 72 </div> 72 73 } 73 74 74 - templ SponsorshipCard(isSponsor bool, amount int, tier string) { 75 + templ SponsorshipCard(isSponsor bool, amount int, tier string, provider string) { 75 76 <div class="card card-warm p-4"> 76 77 <h2 class="card-title flex items-center gap-2"> 77 78 <svg class="w-5 h-5 text-orange-light dark:text-orangeDark-light" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> ··· 90 91 <p class="text-sm font-medium text-orange-light dark:text-orangeDark-light">Thank you for your support!</p> 91 92 } else { 92 93 <p class="card-description">You're not currently an active sponsor.</p> 93 - <a href="https://github.com/sponsors/Xe" target="_blank" class="btn btn-pink"> 94 - Become a Sponsor 95 - </a> 94 + if provider == "patreon" { 95 + <a href="https://www.patreon.com/cadey" target="_blank" class="btn btn-pink"> 96 + Become a Patron 97 + </a> 98 + } else { 99 + <a href="https://github.com/sponsors/Xe" target="_blank" class="btn btn-pink"> 100 + Become a Sponsor 101 + </a> 102 + } 96 103 <p class="text-xs text-fg-4 dark:text-fgDark-4 mt-4 leading-relaxed"> 97 104 If you're part of an organization that sponsors Anubis and see this message, please contact me@xeiaso.net for help. 98 105 </p>
+34 -14
cmd/sponsor-panel/templates/dashboard_templ.go
··· 1 1 // Code generated by templ - DO NOT EDIT. 2 2 3 - // templ: version: v0.3.865 3 + // templ: version: v0.3.1001 4 4 package templates 5 5 6 6 //lint:file-ignore SA4006 This context is only used if a nested component is present. ··· 22 22 type UserProps struct { 23 23 Login string 24 24 AvatarURL string 25 + Provider string // "github" or "patreon" 25 26 } 26 27 27 28 func Dashboard(props DashboardProps) templ.Component { ··· 56 57 var templ_7745c5c3_Var2 string 57 58 templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(props.User.Login) 58 59 if templ_7745c5c3_Err != nil { 59 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 23, Col: 63} 60 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 24, Col: 63} 60 61 } 61 62 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) 62 63 if templ_7745c5c3_Err != nil { ··· 72 73 return templ_7745c5c3_Err 73 74 } 74 75 } 75 - templ_7745c5c3_Err = SponsorshipCard(props.IsSponsor, props.SponsorAmount, props.SponsorTier).Render(ctx, templ_7745c5c3_Buffer) 76 + templ_7745c5c3_Err = SponsorshipCard(props.IsSponsor, props.SponsorAmount, props.SponsorTier, props.User.Provider).Render(ctx, templ_7745c5c3_Buffer) 76 77 if templ_7745c5c3_Err != nil { 77 78 return templ_7745c5c3_Err 78 79 } ··· 128 129 var templ_7745c5c3_Var4 string 129 130 templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(templ.SafeURL(avatarURL)) 130 131 if templ_7745c5c3_Err != nil { 131 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 47, Col: 39} 132 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 48, Col: 39} 132 133 } 133 134 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) 134 135 if templ_7745c5c3_Err != nil { ··· 141 142 var templ_7745c5c3_Var5 string 142 143 templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(login) 143 144 if templ_7745c5c3_Err != nil { 144 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 48, Col: 66} 145 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 49, Col: 66} 145 146 } 146 147 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) 147 148 if templ_7745c5c3_Err != nil { ··· 180 181 if templ_7745c5c3_Err != nil { 181 182 return templ_7745c5c3_Err 182 183 } 183 - var templ_7745c5c3_Var7 templ.SafeURL = templ.SafeURL(inviteURL) 184 - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(string(templ_7745c5c3_Var7))) 184 + var templ_7745c5c3_Var7 templ.SafeURL 185 + templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(inviteURL)) 186 + if templ_7745c5c3_Err != nil { 187 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 69, Col: 36} 188 + } 189 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) 185 190 if templ_7745c5c3_Err != nil { 186 191 return templ_7745c5c3_Err 187 192 } ··· 193 198 }) 194 199 } 195 200 196 - func SponsorshipCard(isSponsor bool, amount int, tier string) templ.Component { 201 + func SponsorshipCard(isSponsor bool, amount int, tier string, provider string) templ.Component { 197 202 return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 198 203 templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 199 204 if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { ··· 226 231 var templ_7745c5c3_Var9 string 227 232 templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(formatDollars(amount)) 228 233 if templ_7745c5c3_Err != nil { 229 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 86, Col: 29} 234 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 87, Col: 29} 230 235 } 231 236 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) 232 237 if templ_7745c5c3_Err != nil { ··· 239 244 var templ_7745c5c3_Var10 string 240 245 templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(tier) 241 246 if templ_7745c5c3_Err != nil { 242 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 89, Col: 62} 247 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 90, Col: 62} 243 248 } 244 249 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) 245 250 if templ_7745c5c3_Err != nil { ··· 250 255 return templ_7745c5c3_Err 251 256 } 252 257 } else { 253 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<p class=\"card-description\">You're not currently an active sponsor.</p><a href=\"https://github.com/sponsors/Xe\" target=\"_blank\" class=\"btn btn-pink\">Become a Sponsor</a><p class=\"text-xs text-fg-4 dark:text-fgDark-4 mt-4 leading-relaxed\">If you're part of an organization that sponsors Anubis and see this message, please contact me@xeiaso.net for help.</p>") 258 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<p class=\"card-description\">You're not currently an active sponsor.</p>") 259 + if templ_7745c5c3_Err != nil { 260 + return templ_7745c5c3_Err 261 + } 262 + if provider == "patreon" { 263 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<a href=\"https://www.patreon.com/cadey\" target=\"_blank\" class=\"btn btn-pink\">Become a Patron</a>") 264 + if templ_7745c5c3_Err != nil { 265 + return templ_7745c5c3_Err 266 + } 267 + } else { 268 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<a href=\"https://github.com/sponsors/Xe\" target=\"_blank\" class=\"btn btn-pink\">Become a Sponsor</a>") 269 + if templ_7745c5c3_Err != nil { 270 + return templ_7745c5c3_Err 271 + } 272 + } 273 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, " <p class=\"text-xs text-fg-4 dark:text-fgDark-4 mt-4 leading-relaxed\">If you're part of an organization that sponsors Anubis and see this message, please contact me@xeiaso.net for help.</p>") 254 274 if templ_7745c5c3_Err != nil { 255 275 return templ_7745c5c3_Err 256 276 } 257 277 } 258 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</div>") 278 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</div>") 259 279 if templ_7745c5c3_Err != nil { 260 280 return templ_7745c5c3_Err 261 281 } ··· 284 304 templ_7745c5c3_Var11 = templ.NopComponent 285 305 } 286 306 ctx = templ.ClearChildren(ctx) 287 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<div class=\"card card-green p-4\"><h2 class=\"card-title flex items-center gap-2\"><svg class=\"w-5 h-5 text-green-light dark:text-greenDark-light\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z\"></path></svg> Team Invitation</h2><p class=\"card-description\">Invite team members to TecharoHQ as part of your sponsorship.</p><form hx-post=\"/invite\" hx-target=\"#invite-result\" class=\"space-y-3\"><input type=\"text\" name=\"username\" placeholder=\"GitHub username\" required class=\"input\"> <button type=\"submit\" class=\"btn btn-primary w-full\">Send Invitation</button></form><div id=\"invite-result\"></div></div>") 307 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<div class=\"card card-green p-4\"><h2 class=\"card-title flex items-center gap-2\"><svg class=\"w-5 h-5 text-green-light dark:text-greenDark-light\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z\"></path></svg> Team Invitation</h2><p class=\"card-description\">Invite team members to TecharoHQ as part of your sponsorship.</p><form hx-post=\"/invite\" hx-target=\"#invite-result\" class=\"space-y-3\"><input type=\"text\" name=\"username\" placeholder=\"GitHub username\" required class=\"input\"> <button type=\"submit\" class=\"btn btn-primary w-full\">Send Invitation</button></form><div id=\"invite-result\"></div></div>") 288 308 if templ_7745c5c3_Err != nil { 289 309 return templ_7745c5c3_Err 290 310 } ··· 313 333 templ_7745c5c3_Var12 = templ.NopComponent 314 334 } 315 335 ctx = templ.ClearChildren(ctx) 316 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<div class=\"card p-4\"><h2 class=\"card-title flex items-center gap-2\"><svg class=\"w-5 h-5 text-purple-light dark:text-purpleDark-light\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z\"></path></svg> Logo Submission</h2><p class=\"card-description\">Submit your company logo for the Anubis README.</p><form hx-post=\"/logo\" hx-encoding=\"multipart/form-data\" hx-target=\"#logo-result\" class=\"space-y-3\"><input type=\"text\" name=\"company\" placeholder=\"Company Name\" required class=\"input\"> <input type=\"url\" name=\"website\" placeholder=\"Website URL\" required class=\"input\"> <input type=\"file\" name=\"logo\" accept=\"image/png,image/jpeg,image/svg+xml\" required class=\"file-input\"> <button type=\"submit\" class=\"btn btn-dark w-full\">Submit Logo</button></form><div id=\"logo-result\"></div></div>") 336 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<div class=\"card p-4\"><h2 class=\"card-title flex items-center gap-2\"><svg class=\"w-5 h-5 text-purple-light dark:text-purpleDark-light\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z\"></path></svg> Logo Submission</h2><p class=\"card-description\">Submit your company logo for the Anubis README.</p><form hx-post=\"/logo\" hx-encoding=\"multipart/form-data\" hx-target=\"#logo-result\" class=\"space-y-3\"><input type=\"text\" name=\"company\" placeholder=\"Company Name\" required class=\"input\"> <input type=\"url\" name=\"website\" placeholder=\"Website URL\" required class=\"input\"> <input type=\"file\" name=\"logo\" accept=\"image/png,image/jpeg,image/svg+xml\" required class=\"file-input\"> <button type=\"submit\" class=\"btn btn-dark w-full\">Submit Logo</button></form><div id=\"logo-result\"></div></div>") 317 337 if templ_7745c5c3_Err != nil { 318 338 return templ_7745c5c3_Err 319 339 }
+1 -1
cmd/sponsor-panel/templates/formresult_templ.go
··· 1 1 // Code generated by templ - DO NOT EDIT. 2 2 3 - // templ: version: v0.3.865 3 + // templ: version: v0.3.1001 4 4 package templates 5 5 6 6 //lint:file-ignore SA4006 This context is only used if a nested component is present.
+7 -3
cmd/sponsor-panel/templates/formsuccess_templ.go
··· 1 1 // Code generated by templ - DO NOT EDIT. 2 2 3 - // templ: version: v0.3.865 3 + // templ: version: v0.3.1001 4 4 package templates 5 5 6 6 //lint:file-ignore SA4006 This context is only used if a nested component is present. ··· 103 103 if templ_7745c5c3_Err != nil { 104 104 return templ_7745c5c3_Err 105 105 } 106 - var templ_7745c5c3_Var6 templ.SafeURL = templ.SafeURL(issueURL) 107 - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(string(templ_7745c5c3_Var6))) 106 + var templ_7745c5c3_Var6 templ.SafeURL 107 + templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(issueURL)) 108 + if templ_7745c5c3_Err != nil { 109 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/formsuccess.templ`, Line: 33, Col: 38} 110 + } 111 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) 108 112 if templ_7745c5c3_Err != nil { 109 113 return templ_7745c5c3_Err 110 114 }
+10 -2
cmd/sponsor-panel/templates/login.templ
··· 1 1 package templates 2 2 3 - templ Login() { 3 + templ Login(patreonEnabled bool) { 4 4 <div class="min-h-screen flex items-center justify-center px-8 py-20 md:px-12 md:py-28"> 5 5 <div class="max-w-md w-full"> 6 6 <div class="card p-4"> ··· 8 8 <h1 class="hero-title mb-3">Sponsor Panel</h1> 9 9 <p class="text-fg-3 dark:text-fgDark-3">Manage your sponsorship benefits</p> 10 10 </div> 11 - <a href="/login" class="btn btn-dark w-full mb-8 p-2"> 11 + <a href="/login" class="btn btn-dark w-full mb-4 p-2"> 12 12 <svg class="w-5 h-5 mr-2" viewBox="0 0 24 24" fill="currentColor"> 13 13 <path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"></path> 14 14 </svg> 15 15 Login with GitHub 16 16 </a> 17 + if patreonEnabled { 18 + <a href="/login/patreon" class="btn btn-dark w-full mb-8 p-2"> 19 + <svg class="w-5 h-5 mr-2" viewBox="0 0 24 24" fill="currentColor"> 20 + <path d="M15.386.524c-4.764 0-8.64 3.876-8.64 8.64 0 4.75 3.876 8.613 8.64 8.613 4.75 0 8.614-3.864 8.614-8.613C24 4.4 20.136.524 15.386.524M.003 23.537h4.22V.524H.003"></path> 21 + </svg> 22 + Login with Patreon 23 + </a> 24 + } 17 25 <div class="border-t border-bg-3 dark:border-bgDark-3 pt-6"> 18 26 <h3 class="font-semibold text-fg-1 dark:text-fgDark-1 mb-3 flex items-center gap-2"> 19 27 <svg class="w-4 h-4 text-orange-light dark:text-orangeDark-light" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+13 -3
cmd/sponsor-panel/templates/login_templ.go
··· 1 1 // Code generated by templ - DO NOT EDIT. 2 2 3 - // templ: version: v0.3.865 3 + // templ: version: v0.3.1001 4 4 package templates 5 5 6 6 //lint:file-ignore SA4006 This context is only used if a nested component is present. ··· 8 8 import "github.com/a-h/templ" 9 9 import templruntime "github.com/a-h/templ/runtime" 10 10 11 - func Login() templ.Component { 11 + func Login(patreonEnabled bool) templ.Component { 12 12 return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 13 13 templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 14 14 if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { ··· 29 29 templ_7745c5c3_Var1 = templ.NopComponent 30 30 } 31 31 ctx = templ.ClearChildren(ctx) 32 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"min-h-screen flex items-center justify-center px-8 py-20 md:px-12 md:py-28\"><div class=\"max-w-md w-full\"><div class=\"card p-4\"><div class=\"text-center mb-8\"><h1 class=\"hero-title mb-3\">Sponsor Panel</h1><p class=\"text-fg-3 dark:text-fgDark-3\">Manage your sponsorship benefits</p></div><a href=\"/login\" class=\"btn btn-dark w-full mb-8 p-2\"><svg class=\"w-5 h-5 mr-2\" viewBox=\"0 0 24 24\" fill=\"currentColor\"><path d=\"M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z\"></path></svg> Login with GitHub</a><div class=\"border-t border-bg-3 dark:border-bgDark-3 pt-6\"><h3 class=\"font-semibold text-fg-1 dark:text-fgDark-1 mb-3 flex items-center gap-2\"><svg class=\"w-4 h-4 text-orange-light dark:text-orangeDark-light\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M5 13l4 4L19 7\"></path></svg> Sponsor Benefits</h3><ul class=\"text-sm text-fg-3 dark:text-fgDark-3 space-y-2\"><li class=\"flex items-start gap-2\"><span class=\"text-orange-light dark:text-orangeDark-light mt-0.5\">•</span> <span><strong class=\"text-fg-1 dark:text-fgDark-1\">All sponsors:</strong> Discord community access</span></li><li class=\"flex items-start gap-2\"><span class=\"text-blue-light dark:text-blueDark-light mt-0.5\">•</span> <span><strong class=\"text-fg-1 dark:text-fgDark-1\">$50+/month:</strong> Team invitations</span></li><li class=\"flex items-start gap-2\"><span class=\"text-purple-light dark:text-purpleDark-light mt-0.5\">•</span> <span><strong class=\"text-fg-1 dark:text-fgDark-1\">All sponsors:</strong> Logo submission for README</span></li></ul></div></div><div class=\"card card-warm mt-4 p-4\"><div class=\"flex items-start gap-3\"><svg class=\"w-5 h-5 text-orange-light dark:text-orangeDark-light flex-shrink-0 mt-0.5\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z\"></path></svg><div><h3 class=\"font-semibold text-fg-1 dark:text-fgDark-1 mb-1\">Organization Sponsors</h3><p class=\"text-sm text-fg-3 dark:text-fgDark-3 leading-relaxed\">If you're sponsoring via an organization, make sure to grant the app access to that organization in your GitHub settings.</p></div></div></div></div></div>") 32 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"min-h-screen flex items-center justify-center px-8 py-20 md:px-12 md:py-28\"><div class=\"max-w-md w-full\"><div class=\"card p-4\"><div class=\"text-center mb-8\"><h1 class=\"hero-title mb-3\">Sponsor Panel</h1><p class=\"text-fg-3 dark:text-fgDark-3\">Manage your sponsorship benefits</p></div><a href=\"/login\" class=\"btn btn-dark w-full mb-4 p-2\"><svg class=\"w-5 h-5 mr-2\" viewBox=\"0 0 24 24\" fill=\"currentColor\"><path d=\"M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z\"></path></svg> Login with GitHub</a> ") 33 + if templ_7745c5c3_Err != nil { 34 + return templ_7745c5c3_Err 35 + } 36 + if patreonEnabled { 37 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<a href=\"/login/patreon\" class=\"btn btn-dark w-full mb-8 p-2\"><svg class=\"w-5 h-5 mr-2\" viewBox=\"0 0 24 24\" fill=\"currentColor\"><path d=\"M15.386.524c-4.764 0-8.64 3.876-8.64 8.64 0 4.75 3.876 8.613 8.64 8.613 4.75 0 8.614-3.864 8.614-8.613C24 4.4 20.136.524 15.386.524M.003 23.537h4.22V.524H.003\"></path></svg> Login with Patreon</a>") 38 + if templ_7745c5c3_Err != nil { 39 + return templ_7745c5c3_Err 40 + } 41 + } 42 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<div class=\"border-t border-bg-3 dark:border-bgDark-3 pt-6\"><h3 class=\"font-semibold text-fg-1 dark:text-fgDark-1 mb-3 flex items-center gap-2\"><svg class=\"w-4 h-4 text-orange-light dark:text-orangeDark-light\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M5 13l4 4L19 7\"></path></svg> Sponsor Benefits</h3><ul class=\"text-sm text-fg-3 dark:text-fgDark-3 space-y-2\"><li class=\"flex items-start gap-2\"><span class=\"text-orange-light dark:text-orangeDark-light mt-0.5\">•</span> <span><strong class=\"text-fg-1 dark:text-fgDark-1\">All sponsors:</strong> Discord community access</span></li><li class=\"flex items-start gap-2\"><span class=\"text-blue-light dark:text-blueDark-light mt-0.5\">•</span> <span><strong class=\"text-fg-1 dark:text-fgDark-1\">$50+/month:</strong> Team invitations</span></li><li class=\"flex items-start gap-2\"><span class=\"text-purple-light dark:text-purpleDark-light mt-0.5\">•</span> <span><strong class=\"text-fg-1 dark:text-fgDark-1\">All sponsors:</strong> Logo submission for README</span></li></ul></div></div><div class=\"card card-warm mt-4 p-4\"><div class=\"flex items-start gap-3\"><svg class=\"w-5 h-5 text-orange-light dark:text-orangeDark-light flex-shrink-0 mt-0.5\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z\"></path></svg><div><h3 class=\"font-semibold text-fg-1 dark:text-fgDark-1 mb-1\">Organization Sponsors</h3><p class=\"text-sm text-fg-3 dark:text-fgDark-3 leading-relaxed\">If you're sponsoring via an organization, make sure to grant the app access to that organization in your GitHub settings.</p></div></div></div></div></div>") 33 43 if templ_7745c5c3_Err != nil { 34 44 return templ_7745c5c3_Err 35 45 }
+1 -1
cmd/sponsor-panel/templates/oautherror_templ.go
··· 1 1 // Code generated by templ - DO NOT EDIT. 2 2 3 - // templ: version: v0.3.865 3 + // templ: version: v0.3.1001 4 4 package templates 5 5 6 6 //lint:file-ignore SA4006 This context is only used if a nested component is present.