From 59fd6b15b574a6374297ba18079fadef69e9775c Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Fri, 15 May 2026 21:09:14 +0200 Subject: [PATCH] feat(08-04): show social sign-in controls on auth pages --- backend/internal/web/handlers_auth.go | 41 +++++--- backend/internal/web/handlers_auth_test.go | 110 +++++++++++++++++++++ backend/internal/web/router.go | 4 +- backend/internal/web/ui/button.css | 62 ++++++++++++ backend/templates/auth_forms.go | 22 +++++ backend/templates/auth_login.templ | 23 ++++- backend/templates/auth_signup.templ | 3 +- backend/templates/auth_signup_test.go | 4 +- 8 files changed, 247 insertions(+), 22 deletions(-) diff --git a/backend/internal/web/handlers_auth.go b/backend/internal/web/handlers_auth.go index 8198e5c..b55c5a0 100644 --- a/backend/internal/web/handlers_auth.go +++ b/backend/internal/web/handlers_auth.go @@ -54,10 +54,10 @@ func clientIP(r *http.Request) string { } // SignupPageHandler renders the GET /signup page with an empty form. -func SignupPageHandler() http.HandlerFunc { +func SignupPageHandler(deps AuthDeps) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") - _ = templates.SignupPage(templates.SignupForm{}, templates.SignupErrors{}, csrf.Token(r)).Render(r.Context(), w) + _ = templates.SignupPage(templates.SignupForm{}, templates.SignupErrors{}, csrf.Token(r), providerButtons(deps)).Render(r.Context(), w) } } @@ -95,7 +95,7 @@ func SignupPostHandler(deps AuthDeps) http.HandlerFunc { if errs.Email != "" || errs.Password != "" { // Re-populate the email field but NOT the password (T-2-01). - renderSignupError(w, r, templates.SignupForm{Email: email}, errs, http.StatusUnprocessableEntity) + renderSignupError(w, r, templates.SignupForm{Email: email}, errs, providerButtons(deps), http.StatusUnprocessableEntity) return } @@ -120,13 +120,13 @@ func SignupPostHandler(deps AuthDeps) http.HandlerFunc { if errors.As(err, &pgErr) && pgErr.Code == "23505" { if socialOnly, socialErr := deps.Queries.IsSocialOnlyUserByEmail(ctx, normalized); socialErr == nil && socialOnly { errs.Email = "An account already exists for this email. Sign in with your provider." - renderSignupError(w, r, templates.SignupForm{Email: email}, errs, http.StatusUnprocessableEntity) + renderSignupError(w, r, templates.SignupForm{Email: email}, errs, providerButtons(deps), http.StatusUnprocessableEntity) return } // Unique-constraint violation on email (T-2-19). // Specific error message is acceptable on signup per CONTEXT.md specifics. errs.Email = "That email is already in use." - renderSignupError(w, r, templates.SignupForm{Email: email}, errs, http.StatusUnprocessableEntity) + renderSignupError(w, r, templates.SignupForm{Email: email}, errs, providerButtons(deps), http.StatusUnprocessableEntity) return } http.Error(w, "internal server error", http.StatusInternalServerError) @@ -158,21 +158,21 @@ func SignupPostHandler(deps AuthDeps) http.HandlerFunc { // renderSignupError writes a validation-error response. // For HTMX requests it renders only the form fragment; for plain requests it // renders the full page (D-19, D-25). -func renderSignupError(w http.ResponseWriter, r *http.Request, form templates.SignupForm, errs templates.SignupErrors, status int) { +func renderSignupError(w http.ResponseWriter, r *http.Request, form templates.SignupForm, errs templates.SignupErrors, providers templates.AuthProviderButtons, status int) { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(status) if r.Header.Get("HX-Request") == "true" { _ = templates.SignupFormFragment(form, errs, csrf.Token(r)).Render(r.Context(), w) } else { - _ = templates.SignupPage(form, errs, csrf.Token(r)).Render(r.Context(), w) + _ = templates.SignupPage(form, errs, csrf.Token(r), providers).Render(r.Context(), w) } } // LoginPageHandler renders the GET /login page with an empty form. -func LoginPageHandler() http.HandlerFunc { +func LoginPageHandler(deps AuthDeps) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") - _ = templates.LoginPage(templates.LoginForm{}, templates.LoginErrors{}, csrf.Token(r)).Render(r.Context(), w) + _ = templates.LoginPage(templates.LoginForm{}, templates.LoginErrors{}, csrf.Token(r), providerButtons(deps)).Render(r.Context(), w) } } @@ -210,7 +210,7 @@ func LoginPostHandler(deps AuthDeps) http.HandlerFunc { if deps.Limiter != nil && !deps.Limiter.Allow(key) { var rateLimitErrs templates.LoginErrors rateLimitErrs.General = "Too many attempts. Try again in a minute." - renderLoginError(w, r, templates.LoginForm{Email: email}, rateLimitErrs, http.StatusTooManyRequests) + renderLoginError(w, r, templates.LoginForm{Email: email}, rateLimitErrs, providerButtons(deps), http.StatusTooManyRequests) return } @@ -229,7 +229,7 @@ func LoginPostHandler(deps AuthDeps) http.HandlerFunc { } if errs.Email != "" || errs.Password != "" { - renderLoginError(w, r, templates.LoginForm{Email: email}, errs, http.StatusUnprocessableEntity) + renderLoginError(w, r, templates.LoginForm{Email: email}, errs, providerButtons(deps), http.StatusUnprocessableEntity) return } @@ -242,7 +242,7 @@ func LoginPostHandler(deps AuthDeps) http.HandlerFunc { if errors.Is(err, pgx.ErrNoRows) { // D-20: same generic message as wrong-password to prevent enumeration (T-2-03). errs.General = errInvalidCreds - renderLoginError(w, r, templates.LoginForm{Email: email}, errs, http.StatusUnauthorized) + renderLoginError(w, r, templates.LoginForm{Email: email}, errs, providerButtons(deps), http.StatusUnauthorized) return } http.Error(w, "internal server error", http.StatusInternalServerError) @@ -254,14 +254,14 @@ func LoginPostHandler(deps AuthDeps) http.HandlerFunc { // Social-only accounts have no local password. Keep the same generic // credential failure used for unknown email and wrong password. errs.General = errInvalidCreds - renderLoginError(w, r, templates.LoginForm{Email: email}, errs, http.StatusUnauthorized) + renderLoginError(w, r, templates.LoginForm{Email: email}, errs, providerButtons(deps), http.StatusUnauthorized) return } ok, err := auth.Verify(user.PasswordHash.String, password) if err != nil || !ok { // D-20: uses the same constant as unknown-email case (single source of truth). errs.General = errInvalidCreds - renderLoginError(w, r, templates.LoginForm{Email: email}, errs, http.StatusUnauthorized) + renderLoginError(w, r, templates.LoginForm{Email: email}, errs, providerButtons(deps), http.StatusUnauthorized) return } @@ -294,16 +294,25 @@ func LoginPostHandler(deps AuthDeps) http.HandlerFunc { // renderLoginError writes a login validation-error response. // For HTMX requests it renders only the form fragment; for plain requests it // renders the full page (D-19). -func renderLoginError(w http.ResponseWriter, r *http.Request, form templates.LoginForm, errs templates.LoginErrors, status int) { +func renderLoginError(w http.ResponseWriter, r *http.Request, form templates.LoginForm, errs templates.LoginErrors, providers templates.AuthProviderButtons, status int) { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(status) if r.Header.Get("HX-Request") == "true" { _ = templates.LoginFormFragment(form, errs, csrf.Token(r)).Render(r.Context(), w) } else { - _ = templates.LoginPage(form, errs, csrf.Token(r)).Render(r.Context(), w) + _ = templates.LoginPage(form, errs, csrf.Token(r), providers).Render(r.Context(), w) } } +func providerButtons(deps AuthDeps) templates.AuthProviderButtons { + providers := templates.EmptyAuthProviderButtons() + providers.Google.Configured = deps.OAuth.Google.Configured() + providers.Google.StartURL = "/auth/google/start" + providers.Apple.Configured = deps.OAuth.Apple.Configured() + providers.Apple.StartURL = "/auth/apple/start" + return providers +} + // LogoutHandler handles POST /logout: deletes the session row and clears the // cookie, then redirects to /login. // diff --git a/backend/internal/web/handlers_auth_test.go b/backend/internal/web/handlers_auth_test.go index 0bd5e0a..1c2bc5e 100644 --- a/backend/internal/web/handlers_auth_test.go +++ b/backend/internal/web/handlers_auth_test.go @@ -54,6 +54,34 @@ func newTestRouterWithLimiter(q *sqlc.Queries, store *auth.Store, rl *auth.Limit return router } +func newAuthPageRouter(t *testing.T, deps AuthDeps) http.Handler { + t.Helper() + router, err := NewRouter(stubPinger{}, os.DirFS("./static"), deps, TablosDeps{}, TasksDeps{}, FilesDeps{}, testCSRFKey, "dev", "localhost") + if err != nil { + t.Fatalf("NewRouter: %v", err) + } + return router +} + +func configuredProviderDeps() AuthDeps { + return AuthDeps{ + OAuth: auth.OAuthConfig{ + Google: auth.GoogleProviderConfig{ + ClientID: "google-client", + ClientSecret: "google-secret", + RedirectURL: "https://xtablo.test/auth/google/callback", + }, + Apple: auth.AppleProviderConfig{ + ClientID: "com.xtablo.web", + TeamID: "TEAMID1234", + KeyID: "KEYID1234", + PrivateKey: "apple-private-key", + RedirectURL: "https://xtablo.test/auth/apple/callback", + }, + }, + } +} + // getCSRFToken performs a GET request to path and extracts the CSRF token // from the rendered form HTML. Returns the token string and any Set-Cookie // headers (including the gorilla_csrf cookie) from the response. @@ -137,6 +165,47 @@ func getSessionCookie(rec *httptest.ResponseRecorder) *http.Cookie { // ---- Signup Tests ---- +func TestSignupProviderButtonsConfigured(t *testing.T) { + router := newAuthPageRouter(t, configuredProviderDeps()) + + req := httptest.NewRequest(http.MethodGet, "/signup", nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + body := rec.Body.String() + for _, want := range []string{ + "Continue with Google", + "Continue with Apple", + `href="/auth/google/start"`, + `href="/auth/apple/start"`, + ">or<", + `name="email"`, + `name="password"`, + } { + if !strings.Contains(body, want) { + t.Fatalf("signup page missing %q; body: %s", want, body) + } + } +} + +func TestSignupProviderButtonsDisabledWhenConfigMissing(t *testing.T) { + router := newAuthPageRouter(t, AuthDeps{}) + + req := httptest.NewRequest(http.MethodGet, "/signup", nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + body := rec.Body.String() + for _, want := range []string{"Google sign-in not configured", "Apple sign-in not configured"} { + if !strings.Contains(body, want) { + t.Fatalf("signup page missing disabled copy %q; body: %s", want, body) + } + } + if strings.Contains(body, `href="/auth/google/start"`) || strings.Contains(body, `href="/auth/apple/start"`) { + t.Fatalf("disabled provider buttons must not include actionable start hrefs; body: %s", body) + } +} + func TestSignup_Success(t *testing.T) { pool, cleanup := setupTestDB(t) defer cleanup() @@ -478,6 +547,47 @@ func TestSignup_AlreadyAuthedBouncesHome(t *testing.T) { // ---- Login Tests ---- +func TestLoginProviderButtonsConfigured(t *testing.T) { + router := newAuthPageRouter(t, configuredProviderDeps()) + + req := httptest.NewRequest(http.MethodGet, "/login", nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + body := rec.Body.String() + for _, want := range []string{ + "Continue with Google", + "Continue with Apple", + `href="/auth/google/start"`, + `href="/auth/apple/start"`, + ">or<", + `name="email"`, + `name="password"`, + } { + if !strings.Contains(body, want) { + t.Fatalf("login page missing %q; body: %s", want, body) + } + } +} + +func TestLoginProviderButtonsDisabledWhenConfigMissing(t *testing.T) { + router := newAuthPageRouter(t, AuthDeps{}) + + req := httptest.NewRequest(http.MethodGet, "/login", nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + body := rec.Body.String() + for _, want := range []string{"Google sign-in not configured", "Apple sign-in not configured"} { + if !strings.Contains(body, want) { + t.Fatalf("login page missing disabled copy %q; body: %s", want, body) + } + } + if strings.Contains(body, `href="/auth/google/start"`) || strings.Contains(body, `href="/auth/apple/start"`) { + t.Fatalf("disabled provider buttons must not include actionable start hrefs; body: %s", body) + } +} + func TestLogin_Success(t *testing.T) { pool, cleanup := setupTestDB(t) defer cleanup() diff --git a/backend/internal/web/router.go b/backend/internal/web/router.go index 13c975a..8617c59 100644 --- a/backend/internal/web/router.go +++ b/backend/internal/web/router.go @@ -62,8 +62,8 @@ func NewRouter(pinger Pinger, staticFS fs.FS, deps AuthDeps, tabloDeps TablosDep // Auth pages — redirect to / if already authenticated. r.Group(func(r chi.Router) { r.Use(auth.RedirectIfAuthed) - r.Get("/signup", SignupPageHandler()) - r.Get("/login", LoginPageHandler()) + r.Get("/signup", SignupPageHandler(deps)) + r.Get("/login", LoginPageHandler(deps)) }) // Signup and login POSTs are intentionally outside the RedirectIfAuthed group: diff --git a/backend/internal/web/ui/button.css b/backend/internal/web/ui/button.css index 1444006..f165417 100644 --- a/backend/internal/web/ui/button.css +++ b/backend/internal/web/ui/button.css @@ -116,3 +116,65 @@ outline: 2px solid #b91c1c; outline-offset: 2px; } + +/* Phase 8: neutral provider controls for social sign-in. */ + +.auth-provider-stack { + display: grid; + gap: 8px; + margin-bottom: 16px; +} + +.auth-provider-button { + display: inline-flex; + min-height: 44px; + width: 100%; + align-items: center; + justify-content: center; + border-radius: 0.375rem; + border: 1px solid #e2e8f0; + background-color: #ffffff; + padding: 0.625rem 1rem; + color: #0f172a; + font-size: 1rem; + font-weight: 600; + line-height: 1.25; + text-align: center; + text-decoration: none; +} + +.auth-provider-button:hover { + background-color: #f8fafc; +} + +.auth-provider-button:focus-visible { + outline: 2px solid #2563eb; + outline-offset: 2px; +} + +.auth-provider-button-disabled, +.auth-provider-button-disabled:hover { + background-color: #f1f5f9; + color: #94a3b8; + cursor: not-allowed; +} + +.auth-provider-separator { + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: center; + gap: 8px; + margin: 16px 0; + color: #64748b; + font-size: 0.875rem; + line-height: 1.4; +} + +.auth-provider-separator span { + height: 1px; + background-color: #e2e8f0; +} + +.auth-provider-separator em { + font-style: normal; +} diff --git a/backend/templates/auth_forms.go b/backend/templates/auth_forms.go index 4b80db6..8c3a8a7 100644 --- a/backend/templates/auth_forms.go +++ b/backend/templates/auth_forms.go @@ -32,3 +32,25 @@ type LoginErrors struct { Password string General string } + +// AuthProviderButton carries one social sign-in entry point into the auth pages. +type AuthProviderButton struct { + Label string + DisabledLabel string + StartURL string + Configured bool +} + +// AuthProviderButtons groups the equal-prominence provider controls shown above +// the email/password auth forms. +type AuthProviderButtons struct { + Google AuthProviderButton + Apple AuthProviderButton +} + +func EmptyAuthProviderButtons() AuthProviderButtons { + return AuthProviderButtons{ + Google: AuthProviderButton{Label: "Continue with Google", DisabledLabel: "Google sign-in not configured"}, + Apple: AuthProviderButton{Label: "Continue with Apple", DisabledLabel: "Apple sign-in not configured"}, + } +} diff --git a/backend/templates/auth_login.templ b/backend/templates/auth_login.templ index 139170c..92ed607 100644 --- a/backend/templates/auth_login.templ +++ b/backend/templates/auth_login.templ @@ -5,12 +5,13 @@ import "backend/internal/web/ui" // LoginPage renders the full /login page wrapped in the base Layout. // It delegates the form section to LoginFormFragment so HTMX can swap just the // form on validation errors without re-rendering the surrounding shell. -templ LoginPage(form LoginForm, errs LoginErrors, csrfToken string) { +templ LoginPage(form LoginForm, errs LoginErrors, csrfToken string, providers AuthProviderButtons) { @Layout("Sign in", nil, csrfToken) {
@ui.Card(nil) {

Sign in to your account

+ @AuthProviderButtonsBlock(providers) @LoginFormFragment(form, errs, csrfToken)
} @@ -70,3 +71,23 @@ templ LoginFormFragment(form LoginForm, errs LoginErrors, csrfToken string) { }) } + +templ AuthProviderButtonsBlock(providers AuthProviderButtons) { +
+ @AuthProviderButtonControl(providers.Google) + @AuthProviderButtonControl(providers.Apple) +
+ +} + +templ AuthProviderButtonControl(provider AuthProviderButton) { + if provider.Configured { + { provider.Label } + } else { + + } +} diff --git a/backend/templates/auth_signup.templ b/backend/templates/auth_signup.templ index 6acd258..61116dc 100644 --- a/backend/templates/auth_signup.templ +++ b/backend/templates/auth_signup.templ @@ -5,12 +5,13 @@ import "backend/internal/web/ui" // SignupPage renders the full /signup page wrapped in the base Layout. // It delegates the form section to SignupFormFragment so HTMX can swap just the // form on validation errors without re-rendering the surrounding shell. -templ SignupPage(form SignupForm, errs SignupErrors, csrfToken string) { +templ SignupPage(form SignupForm, errs SignupErrors, csrfToken string, providers AuthProviderButtons) { @Layout("Sign up", nil, csrfToken) {
@ui.Card(nil) {

Create your account

+ @AuthProviderButtonsBlock(providers) @SignupFormFragment(form, errs, csrfToken)
} diff --git a/backend/templates/auth_signup_test.go b/backend/templates/auth_signup_test.go index 05a240f..07f74bd 100644 --- a/backend/templates/auth_signup_test.go +++ b/backend/templates/auth_signup_test.go @@ -11,7 +11,7 @@ import ( // expected form attributes and that email value round-trips correctly. func TestSignupPage_RendersForm(t *testing.T) { var buf bytes.Buffer - err := SignupPage(SignupForm{Email: "x@y.z"}, SignupErrors{}, "testtoken").Render(context.Background(), &buf) + err := SignupPage(SignupForm{Email: "x@y.z"}, SignupErrors{}, "testtoken", EmptyAuthProviderButtons()).Render(context.Background(), &buf) if err != nil { t.Fatalf("SignupPage.Render: %v", err) } @@ -56,7 +56,7 @@ func TestSignupFormFragment_RendersErrors(t *testing.T) { // (security requirement T-2-01, D-25). func TestSignupPage_DoesNotEchoPassword(t *testing.T) { var buf bytes.Buffer - err := SignupPage(SignupForm{Email: "a@b.com", Password: "hunter2hunter2"}, SignupErrors{}, "testtoken").Render(context.Background(), &buf) + err := SignupPage(SignupForm{Email: "a@b.com", Password: "hunter2hunter2"}, SignupErrors{}, "testtoken", EmptyAuthProviderButtons()).Render(context.Background(), &buf) if err != nil { t.Fatalf("SignupPage.Render: %v", err) }