feat(08-04): show social sign-in controls on auth pages

This commit is contained in:
Arthur Belleville 2026-05-15 21:09:14 +02:00
parent a8b6a03eac
commit 59fd6b15b5
No known key found for this signature in database
8 changed files with 247 additions and 22 deletions

View file

@ -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.
//

View file

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

View file

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

View file

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

View file

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

View file

@ -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) {
<div class="flex min-h-[60vh] items-start justify-center pt-16">
@ui.Card(nil) {
<div class="w-full max-w-sm px-6 py-8">
<h1 class="mb-6 text-2xl font-semibold">Sign in to your account</h1>
@AuthProviderButtonsBlock(providers)
@LoginFormFragment(form, errs, csrfToken)
</div>
}
@ -70,3 +71,23 @@ templ LoginFormFragment(form LoginForm, errs LoginErrors, csrfToken string) {
})
</form>
}
templ AuthProviderButtonsBlock(providers AuthProviderButtons) {
<div class="auth-provider-stack">
@AuthProviderButtonControl(providers.Google)
@AuthProviderButtonControl(providers.Apple)
</div>
<div class="auth-provider-separator" aria-hidden="true">
<span></span>
<em>or</em>
<span></span>
</div>
}
templ AuthProviderButtonControl(provider AuthProviderButton) {
if provider.Configured {
<a class="auth-provider-button" href={ templ.SafeURL(provider.StartURL) }>{ provider.Label }</a>
} else {
<button type="button" class="auth-provider-button auth-provider-button-disabled" disabled aria-disabled="true">{ provider.DisabledLabel }</button>
}
}

View file

@ -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) {
<div class="flex min-h-[60vh] items-start justify-center pt-16">
@ui.Card(nil) {
<div class="w-full max-w-sm px-6 py-8">
<h1 class="mb-6 text-2xl font-semibold">Create your account</h1>
@AuthProviderButtonsBlock(providers)
@SignupFormFragment(form, errs, csrfToken)
</div>
}

View file

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