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).
|
# 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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
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.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))
|
||||||
|
|
|
||||||
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