feat(08-05): add linked providers view and provider docs
This commit is contained in:
parent
59fd6b15b5
commit
6e6583636f
7 changed files with 193 additions and 6 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
44
backend/internal/web/handlers_account.go
Normal file
44
backend/internal/web/handlers_account.go
Normal 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)
|
||||
}
|
||||
}
|
||||
78
backend/internal/web/handlers_account_test.go
Normal file
78
backend/internal/web/handlers_account_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
8
backend/templates/account_providers.go
Normal file
8
backend/templates/account_providers.go
Normal 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
|
||||
}
|
||||
26
backend/templates/account_providers.templ
Normal file
26
backend/templates/account_providers.templ
Normal 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>
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue