|
| 1 | +# Plan: Add Patreon OAuth Login to Sponsor Panel |
| 2 | + |
| 3 | +## Context |
| 4 | + |
| 5 | +The sponsor-panel (`cmd/sponsor-panel/`) currently only supports GitHub OAuth login. Patreon patrons have no way to access sponsor benefits (Discord invite, team invitations, logo submissions). This change adds Patreon as a second authentication provider so patrons get feature parity with GitHub Sponsors. |
| 6 | + |
| 7 | +**Design decisions:** |
| 8 | + |
| 9 | +- User-token based verification (no saasproxy dependency) |
| 10 | +- Separate identities (no account linking between GitHub and Patreon) |
| 11 | +- Patreon API v2 via direct HTTP calls; `patreon-go` library used only for OAuth URL constants |
| 12 | +- OAuth scopes: `identity`, `identity[email]`, `campaigns.members` |
| 13 | +- Team invite: Patreon $50+ users see the same card and input a GitHub username |
| 14 | + |
| 15 | +--- |
| 16 | + |
| 17 | +## Step 1: Database Migration |
| 18 | + |
| 19 | +**File:** `cmd/sponsor-panel/migrations.go` |
| 20 | + |
| 21 | +Add `migration002` constant with idempotent DDL: |
| 22 | + |
| 23 | +- `ALTER TABLE users ALTER COLUMN github_id DROP NOT NULL` |
| 24 | +- `ADD COLUMN IF NOT EXISTS patreon_id TEXT UNIQUE` |
| 25 | +- `ADD COLUMN IF NOT EXISTS provider TEXT NOT NULL DEFAULT 'github'` |
| 26 | +- Drop `users_login_key` unique constraint, replace with `UNIQUE INDEX (provider, login)` |
| 27 | +- Add `idx_users_patreon_id` index |
| 28 | + |
| 29 | +Update `runMigrations()` to execute `migration002` after `migrationSchema`. |
| 30 | + |
| 31 | +--- |
| 32 | + |
| 33 | +## Step 2: Update User Model |
| 34 | + |
| 35 | +**File:** `cmd/sponsor-panel/models.go` |
| 36 | + |
| 37 | +- Change `User.GitHubID` from `int64` to `*int64` (nullable) |
| 38 | +- Add `PatreonID *string` and `Provider string` fields |
| 39 | +- Update all `SELECT`/`Scan` calls in `getUserByID`, `upsertUser` to include new columns |
| 40 | +- Add `upsertPatreonUser(ctx, pool, user)` — same pattern as `upsertUser` but upserts by `patreon_id`, sets `provider='patreon'` |
| 41 | + |
| 42 | +--- |
| 43 | + |
| 44 | +## Step 3: Add Patreon OAuth Config |
| 45 | + |
| 46 | +**File:** `cmd/sponsor-panel/main.go` |
| 47 | + |
| 48 | +New flags (all optional — service still works GitHub-only if omitted): |
| 49 | + |
| 50 | +- `--patreon-client-id` |
| 51 | +- `--patreon-client-secret` |
| 52 | +- `--patreon-redirect-url` |
| 53 | +- `--patreon-campaign-id` (to match pledge against) |
| 54 | + |
| 55 | +Add to `Server` struct: |
| 56 | + |
| 57 | +```go |
| 58 | +patreonOAuth *oauth2.Config // nil if not configured |
| 59 | +patreonCampaignID string |
| 60 | +``` |
| 61 | + |
| 62 | +In `main()`, conditionally create `oauth2.Config` using `patreon.AuthorizationURL` and `patreon.AccessTokenURL` from `gopkg.in/mxpv/patreon-go.v1`. |
| 63 | + |
| 64 | +Register new routes: |
| 65 | + |
| 66 | +``` |
| 67 | +/login/patreon → server.patreonLoginHandler |
| 68 | +/callback/patreon → server.patreonCallbackHandler |
| 69 | +``` |
| 70 | + |
| 71 | +--- |
| 72 | + |
| 73 | +## Step 4: Patreon OAuth Handlers (new file) |
| 74 | + |
| 75 | +**File:** `cmd/sponsor-panel/patreon_oauth.go` (new) |
| 76 | + |
| 77 | +### `patreonLoginHandler` |
| 78 | + |
| 79 | +Mirrors `loginHandler` in `oauth.go`: generate state, set CSRF cookie, redirect to `s.patreonOAuth.AuthCodeURL(state)`. Returns 404 if `s.patreonOAuth == nil`. |
| 80 | + |
| 81 | +### `patreonCallbackHandler` |
| 82 | + |
| 83 | +1. Validate state cookie (same CSRF pattern as GitHub) |
| 84 | +2. Exchange code via `s.patreonOAuth.Exchange(ctx, code)` |
| 85 | +3. Call Patreon API v2 identity endpoint: |
| 86 | + ``` |
| 87 | + GET https://www.patreon.com/api/oauth2/v2/identity |
| 88 | + ?include=memberships.campaign |
| 89 | + &fields[user]=full_name,vanity,email,image_url |
| 90 | + &fields[member]=patron_status,currently_entitled_amount_cents |
| 91 | + ``` |
| 92 | +4. Parse JSON:API response to extract user identity and membership data |
| 93 | +5. Find membership matching `s.patreonCampaignID` |
| 94 | +6. Build `SponsorshipData` JSON: `{is_active, monthly_amount_cents, tier_name}` |
| 95 | +7. Call `upsertPatreonUser()` with `provider="patreon"`, login = vanity or full_name |
| 96 | +8. Create session (same `user_id` in gorilla/sessions cookie) |
| 97 | +9. Redirect to `/` |
| 98 | + |
| 99 | +### Response types (define in same file): |
| 100 | + |
| 101 | +- `patreonIdentityResponse` — JSON:API envelope with user data + included memberships |
| 102 | +- `patreonMember` — membership attributes (patron_status, currently_entitled_amount_cents) + campaign relationship |
| 103 | + |
| 104 | +--- |
| 105 | + |
| 106 | +## Step 5: Update Login Template |
| 107 | + |
| 108 | +**File:** `cmd/sponsor-panel/templates/login.templ` |
| 109 | + |
| 110 | +Change signature to `templ Login(patreonEnabled bool)`. Add a "Login with Patreon" button (with Patreon SVG icon) conditionally rendered when `patreonEnabled` is true, linking to `/login/patreon`. |
| 111 | + |
| 112 | +--- |
| 113 | + |
| 114 | +## Step 6: Update Dashboard for Provider Awareness |
| 115 | + |
| 116 | +**File:** `cmd/sponsor-panel/templates/dashboard.templ` |
| 117 | + |
| 118 | +- Add `Provider string` to `UserProps` |
| 119 | +- In `SponsorshipCard`, when user is not a sponsor: show Patreon link for `provider == "patreon"`, GitHub Sponsors link otherwise |
| 120 | + |
| 121 | +**File:** `cmd/sponsor-panel/dashboard.go` |
| 122 | + |
| 123 | +- `loginPageHandler`: pass `s.patreonOAuth != nil` to `templates.Login()` |
| 124 | +- `dashboardHandler`: set `UserProps.Provider` from `user.Provider` |
| 125 | + |
| 126 | +--- |
| 127 | + |
| 128 | +## Step 7: Generate & Build |
| 129 | + |
| 130 | +1. `go tool templ generate` (regenerate `*_templ.go` files) |
| 131 | +2. `go build ./cmd/sponsor-panel/` |
| 132 | +3. `npm test` (`go test ./...`) |
| 133 | + |
| 134 | +--- |
| 135 | + |
| 136 | +## Files Modified |
| 137 | + |
| 138 | +| File | Change | |
| 139 | +| --------------------------------------------- | ----------------------------------------------- | |
| 140 | +| `cmd/sponsor-panel/migrations.go` | Add migration002 | |
| 141 | +| `cmd/sponsor-panel/models.go` | Update User struct, add upsertPatreonUser | |
| 142 | +| `cmd/sponsor-panel/main.go` | Add flags, Server fields, routes | |
| 143 | +| `cmd/sponsor-panel/patreon_oauth.go` | **New** — Patreon login/callback handlers | |
| 144 | +| `cmd/sponsor-panel/oauth.go` | No changes (existing GitHub flow untouched) | |
| 145 | +| `cmd/sponsor-panel/dashboard.go` | Pass patreonEnabled and Provider | |
| 146 | +| `cmd/sponsor-panel/templates/login.templ` | Add Patreon button | |
| 147 | +| `cmd/sponsor-panel/templates/dashboard.templ` | Add Provider to props, conditional sponsor link | |
| 148 | + |
| 149 | +## Existing Code to Reuse |
| 150 | + |
| 151 | +- `generateState()` in `oauth.go:20` — reuse for Patreon CSRF state |
| 152 | +- `SponsorshipData` struct in `models.go:27` — same JSON format for both providers |
| 153 | +- `User.IsSponsorAtTier()` in `models.go:35` — works provider-agnostically |
| 154 | +- Session management via `getSessionUser()` in `oauth.go:634` — no changes needed |
| 155 | +- `patreon.AuthorizationURL` / `patreon.AccessTokenURL` from `gopkg.in/mxpv/patreon-go.v1` |
| 156 | +- All feature handlers (`inviteHandler`, `logoHandler`) — work unchanged since they only check `IsSponsorAtTier()` |
| 157 | + |
| 158 | +## Verification |
| 159 | + |
| 160 | +1. **Build**: `go build ./cmd/sponsor-panel/` compiles without errors |
| 161 | +2. **Tests**: `npm test` passes |
| 162 | +3. **GitHub flow unchanged**: Login with GitHub still works identically |
| 163 | +4. **Patreon login**: With Patreon OAuth credentials set, clicking "Login with Patreon" redirects to Patreon, callback creates user with `provider=patreon` |
| 164 | +5. **Tier gating**: A Patreon user pledging $50+/month sees team invite card; $1+ sees logo submission and Discord |
| 165 | +6. **No Patreon config**: When Patreon flags are omitted, login page only shows GitHub button, `/login/patreon` returns 404 |
0 commit comments