feat(08-05): add linked providers view and provider docs

This commit is contained in:
Arthur Belleville 2026-05-15 21:10:45 +02:00
parent 59fd6b15b5
commit 6e6583636f
No known key found for this signature in database
7 changed files with 193 additions and 6 deletions

View file

@ -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). # MUST be persistent across restarts (changing it invalidates all active CSRF tokens).
SESSION_SECRET= 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. # HTTP port for cmd/web.
PORT=8080 PORT=8080

View file

@ -97,13 +97,26 @@ bootstrap-time `unpkg.com` URL is the single authoritative version pin (D-10).
## Environment variables ## Environment variables
`backend/.env` is gitignored; `backend/.env.example` is committed and lists the `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 | | 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` | | `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` | | `PORT` | HTTP port for `cmd/web` | `8080` |
| `ENV` | `development` enables slog's text handler; `production` switches to JSON | `development` | | `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 ## Common commands

View file

@ -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)
}
}

View file

@ -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)
}
}
}

View file

@ -85,6 +85,7 @@ func NewRouter(pinger Pinger, staticFS fs.FS, deps AuthDeps, tabloDeps TablosDep
r.Use(auth.RequireAuth) r.Use(auth.RequireAuth)
r.Get("/", TablosListHandler(tabloDeps)) r.Get("/", TablosListHandler(tabloDeps))
r.Post("/logout", LogoutHandler(deps)) r.Post("/logout", LogoutHandler(deps))
r.Get("/account/providers", AccountProvidersHandler(deps))
// Static segments BEFORE parametric (Pitfall 1 — chi v5 route resolution). // Static segments BEFORE parametric (Pitfall 1 — chi v5 route resolution).
r.Get("/tablos/new", TablosNewHandler(tabloDeps)) r.Get("/tablos/new", TablosNewHandler(tabloDeps))
r.Post("/tablos", TablosCreateHandler(tabloDeps)) r.Post("/tablos", TablosCreateHandler(tabloDeps))

View file

@ -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
}

View file

@ -0,0 +1,26 @@
package templates
import "backend/internal/auth"
templ AccountProvidersPage(user *auth.User, providers []LinkedProviderStatus, csrfToken string) {
@Layout("Linked providers", user, csrfToken) {
<section class="mx-auto max-w-xl">
<h1 class="mb-6 text-xl font-semibold">Linked providers</h1>
<div class="space-y-2">
for _, provider := range providers {
<div class="min-h-11 rounded border border-slate-200 bg-white px-4 py-3 sm:flex sm:items-center sm:justify-between">
<div class="text-sm font-medium text-slate-900">{ provider.Name }</div>
<div class="mt-1 text-sm text-slate-600 sm:mt-0 sm:text-right">
if provider.Connected {
<div>Connected</div>
<div class="text-slate-500">{ provider.Email }</div>
} else {
<div>Not connected</div>
}
</div>
</div>
}
</div>
</section>
}
}