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.
|
// 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) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
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 != "" {
|
if errs.Email != "" || errs.Password != "" {
|
||||||
// Re-populate the email field but NOT the password (T-2-01).
|
// 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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -120,13 +120,13 @@ func SignupPostHandler(deps AuthDeps) http.HandlerFunc {
|
||||||
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||||
if socialOnly, socialErr := deps.Queries.IsSocialOnlyUserByEmail(ctx, normalized); socialErr == nil && socialOnly {
|
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."
|
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
|
return
|
||||||
}
|
}
|
||||||
// Unique-constraint violation on email (T-2-19).
|
// Unique-constraint violation on email (T-2-19).
|
||||||
// Specific error message is acceptable on signup per CONTEXT.md specifics.
|
// Specific error message is acceptable on signup per CONTEXT.md specifics.
|
||||||
errs.Email = "That email is already in use."
|
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
|
return
|
||||||
}
|
}
|
||||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||||
|
|
@ -158,21 +158,21 @@ func SignupPostHandler(deps AuthDeps) http.HandlerFunc {
|
||||||
// renderSignupError writes a validation-error response.
|
// renderSignupError writes a validation-error response.
|
||||||
// For HTMX requests it renders only the form fragment; for plain requests it
|
// For HTMX requests it renders only the form fragment; for plain requests it
|
||||||
// renders the full page (D-19, D-25).
|
// 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.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
w.WriteHeader(status)
|
w.WriteHeader(status)
|
||||||
if r.Header.Get("HX-Request") == "true" {
|
if r.Header.Get("HX-Request") == "true" {
|
||||||
_ = templates.SignupFormFragment(form, errs, csrf.Token(r)).Render(r.Context(), w)
|
_ = templates.SignupFormFragment(form, errs, csrf.Token(r)).Render(r.Context(), w)
|
||||||
} else {
|
} 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.
|
// 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) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
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) {
|
if deps.Limiter != nil && !deps.Limiter.Allow(key) {
|
||||||
var rateLimitErrs templates.LoginErrors
|
var rateLimitErrs templates.LoginErrors
|
||||||
rateLimitErrs.General = "Too many attempts. Try again in a minute."
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -229,7 +229,7 @@ func LoginPostHandler(deps AuthDeps) http.HandlerFunc {
|
||||||
}
|
}
|
||||||
|
|
||||||
if errs.Email != "" || errs.Password != "" {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -242,7 +242,7 @@ func LoginPostHandler(deps AuthDeps) http.HandlerFunc {
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
// D-20: same generic message as wrong-password to prevent enumeration (T-2-03).
|
// D-20: same generic message as wrong-password to prevent enumeration (T-2-03).
|
||||||
errs.General = errInvalidCreds
|
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
|
return
|
||||||
}
|
}
|
||||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
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
|
// Social-only accounts have no local password. Keep the same generic
|
||||||
// credential failure used for unknown email and wrong password.
|
// credential failure used for unknown email and wrong password.
|
||||||
errs.General = errInvalidCreds
|
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
|
return
|
||||||
}
|
}
|
||||||
ok, err := auth.Verify(user.PasswordHash.String, password)
|
ok, err := auth.Verify(user.PasswordHash.String, password)
|
||||||
if err != nil || !ok {
|
if err != nil || !ok {
|
||||||
// D-20: uses the same constant as unknown-email case (single source of truth).
|
// D-20: uses the same constant as unknown-email case (single source of truth).
|
||||||
errs.General = errInvalidCreds
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -294,16 +294,25 @@ func LoginPostHandler(deps AuthDeps) http.HandlerFunc {
|
||||||
// renderLoginError writes a login validation-error response.
|
// renderLoginError writes a login validation-error response.
|
||||||
// For HTMX requests it renders only the form fragment; for plain requests it
|
// For HTMX requests it renders only the form fragment; for plain requests it
|
||||||
// renders the full page (D-19).
|
// 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.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
w.WriteHeader(status)
|
w.WriteHeader(status)
|
||||||
if r.Header.Get("HX-Request") == "true" {
|
if r.Header.Get("HX-Request") == "true" {
|
||||||
_ = templates.LoginFormFragment(form, errs, csrf.Token(r)).Render(r.Context(), w)
|
_ = templates.LoginFormFragment(form, errs, csrf.Token(r)).Render(r.Context(), w)
|
||||||
} else {
|
} 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
|
// LogoutHandler handles POST /logout: deletes the session row and clears the
|
||||||
// cookie, then redirects to /login.
|
// cookie, then redirects to /login.
|
||||||
//
|
//
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,34 @@ func newTestRouterWithLimiter(q *sqlc.Queries, store *auth.Store, rl *auth.Limit
|
||||||
return router
|
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
|
// 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
|
// from the rendered form HTML. Returns the token string and any Set-Cookie
|
||||||
// headers (including the gorilla_csrf cookie) from the response.
|
// headers (including the gorilla_csrf cookie) from the response.
|
||||||
|
|
@ -137,6 +165,47 @@ func getSessionCookie(rec *httptest.ResponseRecorder) *http.Cookie {
|
||||||
|
|
||||||
// ---- Signup Tests ----
|
// ---- 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) {
|
func TestSignup_Success(t *testing.T) {
|
||||||
pool, cleanup := setupTestDB(t)
|
pool, cleanup := setupTestDB(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
@ -478,6 +547,47 @@ func TestSignup_AlreadyAuthedBouncesHome(t *testing.T) {
|
||||||
|
|
||||||
// ---- Login Tests ----
|
// ---- 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) {
|
func TestLogin_Success(t *testing.T) {
|
||||||
pool, cleanup := setupTestDB(t)
|
pool, cleanup := setupTestDB(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
|
||||||
|
|
@ -62,8 +62,8 @@ func NewRouter(pinger Pinger, staticFS fs.FS, deps AuthDeps, tabloDeps TablosDep
|
||||||
// Auth pages — redirect to / if already authenticated.
|
// Auth pages — redirect to / if already authenticated.
|
||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
r.Use(auth.RedirectIfAuthed)
|
r.Use(auth.RedirectIfAuthed)
|
||||||
r.Get("/signup", SignupPageHandler())
|
r.Get("/signup", SignupPageHandler(deps))
|
||||||
r.Get("/login", LoginPageHandler())
|
r.Get("/login", LoginPageHandler(deps))
|
||||||
})
|
})
|
||||||
|
|
||||||
// Signup and login POSTs are intentionally outside the RedirectIfAuthed group:
|
// Signup and login POSTs are intentionally outside the RedirectIfAuthed group:
|
||||||
|
|
|
||||||
|
|
@ -116,3 +116,65 @@
|
||||||
outline: 2px solid #b91c1c;
|
outline: 2px solid #b91c1c;
|
||||||
outline-offset: 2px;
|
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
|
Password string
|
||||||
General 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.
|
// LoginPage renders the full /login page wrapped in the base Layout.
|
||||||
// It delegates the form section to LoginFormFragment so HTMX can swap just the
|
// It delegates the form section to LoginFormFragment so HTMX can swap just the
|
||||||
// form on validation errors without re-rendering the surrounding shell.
|
// 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) {
|
@Layout("Sign in", nil, csrfToken) {
|
||||||
<div class="flex min-h-[60vh] items-start justify-center pt-16">
|
<div class="flex min-h-[60vh] items-start justify-center pt-16">
|
||||||
@ui.Card(nil) {
|
@ui.Card(nil) {
|
||||||
<div class="w-full max-w-sm px-6 py-8">
|
<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>
|
<h1 class="mb-6 text-2xl font-semibold">Sign in to your account</h1>
|
||||||
|
@AuthProviderButtonsBlock(providers)
|
||||||
@LoginFormFragment(form, errs, csrfToken)
|
@LoginFormFragment(form, errs, csrfToken)
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
@ -70,3 +71,23 @@ templ LoginFormFragment(form LoginForm, errs LoginErrors, csrfToken string) {
|
||||||
})
|
})
|
||||||
</form>
|
</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.
|
// SignupPage renders the full /signup page wrapped in the base Layout.
|
||||||
// It delegates the form section to SignupFormFragment so HTMX can swap just the
|
// It delegates the form section to SignupFormFragment so HTMX can swap just the
|
||||||
// form on validation errors without re-rendering the surrounding shell.
|
// 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) {
|
@Layout("Sign up", nil, csrfToken) {
|
||||||
<div class="flex min-h-[60vh] items-start justify-center pt-16">
|
<div class="flex min-h-[60vh] items-start justify-center pt-16">
|
||||||
@ui.Card(nil) {
|
@ui.Card(nil) {
|
||||||
<div class="w-full max-w-sm px-6 py-8">
|
<div class="w-full max-w-sm px-6 py-8">
|
||||||
<h1 class="mb-6 text-2xl font-semibold">Create your account</h1>
|
<h1 class="mb-6 text-2xl font-semibold">Create your account</h1>
|
||||||
|
@AuthProviderButtonsBlock(providers)
|
||||||
@SignupFormFragment(form, errs, csrfToken)
|
@SignupFormFragment(form, errs, csrfToken)
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ import (
|
||||||
// expected form attributes and that email value round-trips correctly.
|
// expected form attributes and that email value round-trips correctly.
|
||||||
func TestSignupPage_RendersForm(t *testing.T) {
|
func TestSignupPage_RendersForm(t *testing.T) {
|
||||||
var buf bytes.Buffer
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("SignupPage.Render: %v", err)
|
t.Fatalf("SignupPage.Render: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -56,7 +56,7 @@ func TestSignupFormFragment_RendersErrors(t *testing.T) {
|
||||||
// (security requirement T-2-01, D-25).
|
// (security requirement T-2-01, D-25).
|
||||||
func TestSignupPage_DoesNotEchoPassword(t *testing.T) {
|
func TestSignupPage_DoesNotEchoPassword(t *testing.T) {
|
||||||
var buf bytes.Buffer
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("SignupPage.Render: %v", err)
|
t.Fatalf("SignupPage.Render: %v", err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue