feat(08-04): show social sign-in controls on auth pages
This commit is contained in:
parent
a8b6a03eac
commit
59fd6b15b5
8 changed files with 247 additions and 22 deletions
|
|
@ -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.
|
||||
//
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue