From 6e6583636f49e8696953115565efcc47a0c9596e Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Fri, 15 May 2026 21:10:45 +0200 Subject: [PATCH] feat(08-05): add linked providers view and provider docs --- backend/.env.example | 17 ++++ backend/README.md | 25 ++++-- backend/internal/web/handlers_account.go | 44 +++++++++++ backend/internal/web/handlers_account_test.go | 78 +++++++++++++++++++ backend/internal/web/router.go | 1 + backend/templates/account_providers.go | 8 ++ backend/templates/account_providers.templ | 26 +++++++ 7 files changed, 193 insertions(+), 6 deletions(-) create mode 100644 backend/internal/web/handlers_account.go create mode 100644 backend/internal/web/handlers_account_test.go create mode 100644 backend/templates/account_providers.go create mode 100644 backend/templates/account_providers.templ diff --git a/backend/.env.example b/backend/.env.example index 7a8a35d..3e70db4 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -12,6 +12,23 @@ TEST_DATABASE_URL=postgres://xtablo:xtablo@localhost:5432/xtablo?sslmode=disable # MUST be persistent across restarts (changing it invalidates all active CSRF tokens). SESSION_SECRET= +# --------------------------------------------------------------------------- +# Social sign-in providers (optional in local/dev) +# --------------------------------------------------------------------------- + +# Google OAuth/OIDC. Leave blank to render a disabled Google button locally. +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= +GOOGLE_REDIRECT_URL=http://localhost:8080/auth/google/callback + +# Sign in with Apple. Leave blank to render a disabled Apple button locally. +APPLE_CLIENT_ID= +APPLE_TEAM_ID= +APPLE_KEY_ID= +# Use a PEM value with escaped newlines, or inject the multiline value from your secret manager. +APPLE_PRIVATE_KEY= +APPLE_REDIRECT_URL=http://localhost:8080/auth/apple/callback + # HTTP port for cmd/web. PORT=8080 diff --git a/backend/README.md b/backend/README.md index 572ae54..695aaea 100644 --- a/backend/README.md +++ b/backend/README.md @@ -97,13 +97,26 @@ bootstrap-time `unpkg.com` URL is the single authoritative version pin (D-10). ## Environment variables `backend/.env` is gitignored; `backend/.env.example` is committed and lists the -three keys consumed by `cmd/web` (and `cmd/worker` for `DATABASE_URL`): +keys consumed by `cmd/web` and `cmd/worker`. -| Variable | Description | Default | -| -------------- | ------------------------------------------------------------------------ | ---------------------------------------------------------------- | -| `DATABASE_URL` | Postgres DSN used by the web + worker binaries and by `just migrate` | `postgres://xtablo:xtablo@localhost:5432/xtablo?sslmode=disable` | -| `PORT` | HTTP port for `cmd/web` | `8080` | -| `ENV` | `development` enables slog's text handler; `production` switches to JSON | `development` | +| Variable | Description | Default | +| ------------------------ | ------------------------------------------------------------------------ | ---------------------------------------------------------------- | +| `DATABASE_URL` | Postgres DSN used by the web + worker binaries and by `just migrate` | `postgres://xtablo:xtablo@localhost:5432/xtablo?sslmode=disable` | +| `PORT` | HTTP port for `cmd/web` | `8080` | +| `ENV` | `development` enables slog's text handler; `production` switches to JSON | `development` | +| `GOOGLE_CLIENT_ID` | Google OAuth client ID | blank | +| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret | blank | +| `GOOGLE_REDIRECT_URL` | Google callback URL, usually `/auth/google/callback` | `http://localhost:8080/auth/google/callback` | +| `APPLE_CLIENT_ID` | Apple Services ID | blank | +| `APPLE_TEAM_ID` | Apple developer team ID | blank | +| `APPLE_KEY_ID` | Apple private key ID | blank | +| `APPLE_PRIVATE_KEY` | Apple ES256 private key PEM, usually injected by secret manager | blank | +| `APPLE_REDIRECT_URL` | Apple callback URL, usually `/auth/apple/callback` | `http://localhost:8080/auth/apple/callback` | + +Google and Apple config are optional in local development. When a provider is +missing required config, the login and signup pages keep the provider button +visible but disabled with a not-configured label. No real provider secrets should +be committed to `.env.example`. ## Common commands diff --git a/backend/internal/web/handlers_account.go b/backend/internal/web/handlers_account.go new file mode 100644 index 0000000..1a2d251 --- /dev/null +++ b/backend/internal/web/handlers_account.go @@ -0,0 +1,44 @@ +package web + +import ( + "net/http" + + "backend/internal/auth" + "backend/templates" + + "github.com/gorilla/csrf" +) + +func AccountProvidersHandler(deps AuthDeps) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + _, user, ok := auth.Authed(r.Context()) + if !ok { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + + identities, err := deps.Queries.ListUserIdentitiesByUser(r.Context(), user.ID) + if err != nil { + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + + statuses := []templates.LinkedProviderStatus{ + {Name: "Google"}, + {Name: "Apple"}, + } + for _, identity := range identities { + switch identity.Provider { + case "google": + statuses[0].Connected = true + statuses[0].Email = identity.Email + case "apple": + statuses[1].Connected = true + statuses[1].Email = identity.Email + } + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _ = templates.AccountProvidersPage(user, statuses, csrf.Token(r)).Render(r.Context(), w) + } +} diff --git a/backend/internal/web/handlers_account_test.go b/backend/internal/web/handlers_account_test.go new file mode 100644 index 0000000..4ee5905 --- /dev/null +++ b/backend/internal/web/handlers_account_test.go @@ -0,0 +1,78 @@ +package web + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "backend/internal/auth" + "backend/internal/db/sqlc" +) + +func TestLinkedProviders_UnauthRedirectsToLogin(t *testing.T) { + router := newAuthPageRouter(t, AuthDeps{}) + + req := httptest.NewRequest(http.MethodGet, "/account/providers", nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusSeeOther { + t.Fatalf("status = %d; want 303", rec.Code) + } + if loc := rec.Header().Get("Location"); loc != "/login" { + t.Fatalf("Location = %q; want /login", loc) + } +} + +func TestLinkedProviders_ShowsConnectedAndNotConnectedRows(t *testing.T) { + pool, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + q := sqlc.New(pool) + store := auth.NewStore(q) + user := preInsertUser(t, ctx, q, "providers@example.com", "correct-horse-12chars") + if _, err := q.InsertUserIdentity(ctx, sqlc.InsertUserIdentityParams{ + UserID: user.ID, + Provider: "google", + ProviderSubject: "providers-google-subject", + Email: "providers@example.com", + EmailVerified: true, + }); err != nil { + t.Fatalf("InsertUserIdentity: %v", err) + } + cookieValue, _, err := store.Create(ctx, user.ID) + if err != nil { + t.Fatalf("Create session: %v", err) + } + router := newTestRouter(q, store) + + req := httptest.NewRequest(http.MethodGet, "/account/providers", nil) + req.AddCookie(&http.Cookie{Name: auth.SessionCookieName, Value: cookieValue}) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d; want 200; body: %s", rec.Code, rec.Body.String()) + } + body := rec.Body.String() + for _, want := range []string{ + "Linked providers", + "Google", + "Connected", + "providers@example.com", + "Apple", + "Not connected", + } { + if !strings.Contains(body, want) { + t.Fatalf("body missing %q; body: %s", want, body) + } + } + for _, notWant := range []string{"Unlink", "Add password"} { + if strings.Contains(body, notWant) { + t.Fatalf("body must not contain %q; body: %s", notWant, body) + } + } +} diff --git a/backend/internal/web/router.go b/backend/internal/web/router.go index 8617c59..9be471d 100644 --- a/backend/internal/web/router.go +++ b/backend/internal/web/router.go @@ -85,6 +85,7 @@ func NewRouter(pinger Pinger, staticFS fs.FS, deps AuthDeps, tabloDeps TablosDep r.Use(auth.RequireAuth) r.Get("/", TablosListHandler(tabloDeps)) r.Post("/logout", LogoutHandler(deps)) + r.Get("/account/providers", AccountProvidersHandler(deps)) // Static segments BEFORE parametric (Pitfall 1 — chi v5 route resolution). r.Get("/tablos/new", TablosNewHandler(tabloDeps)) r.Post("/tablos", TablosCreateHandler(tabloDeps)) diff --git a/backend/templates/account_providers.go b/backend/templates/account_providers.go new file mode 100644 index 0000000..35fe087 --- /dev/null +++ b/backend/templates/account_providers.go @@ -0,0 +1,8 @@ +package templates + +// LinkedProviderStatus is one row in the minimal account provider status view. +type LinkedProviderStatus struct { + Name string + Email string + Connected bool +} diff --git a/backend/templates/account_providers.templ b/backend/templates/account_providers.templ new file mode 100644 index 0000000..2f26128 --- /dev/null +++ b/backend/templates/account_providers.templ @@ -0,0 +1,26 @@ +package templates + +import "backend/internal/auth" + +templ AccountProvidersPage(user *auth.User, providers []LinkedProviderStatus, csrfToken string) { + @Layout("Linked providers", user, csrfToken) { +
+

Linked providers

+
+ for _, provider := range providers { +
+
{ provider.Name }
+
+ if provider.Connected { +
Connected
+
{ provider.Email }
+ } else { +
Not connected
+ } +
+
+ } +
+
+ } +}