Support both email and phone login

Auto-detect whether the user entered an email or phone number.
Email sends via Resend, phone sends via Twilio SMS. Users table
has nullable phone and email columns; verification_codes uses a
generic identifier field.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-18 08:53:29 -04:00
parent 471cc3ad8c
commit a0c4b28d1e
7 changed files with 177 additions and 56 deletions
+91 -18
View File
@@ -1,10 +1,12 @@
package main package main
import ( import (
"bytes"
"context" "context"
"crypto/rand" "crypto/rand"
"database/sql" "database/sql"
"encoding/hex" "encoding/hex"
"encoding/json"
"fmt" "fmt"
"log" "log"
"math/big" "math/big"
@@ -48,8 +50,8 @@ func (s *Server) sessionMiddleware(next http.Handler) http.Handler {
} }
var u db.User var u db.User
row := s.db.QueryRowContext(r.Context(), row := s.db.QueryRowContext(r.Context(),
"SELECT id, phone, name, created_at FROM users WHERE id = ?", sess.UserID) "SELECT id, phone, email, name, created_at FROM users WHERE id = ?", sess.UserID)
if err := row.Scan(&u.ID, &u.Phone, &u.Name, &u.CreatedAt); err != nil { if err := row.Scan(&u.ID, &u.Phone, &u.Email, &u.Name, &u.CreatedAt); err != nil {
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
return return
} }
@@ -69,55 +71,83 @@ func (s *Server) requireAuth(next http.Handler) http.Handler {
}) })
} }
// isEmail returns true if the input looks like an email address.
func isEmail(s string) bool {
return strings.Contains(s, "@")
}
func (s *Server) handleLoginPage(w http.ResponseWriter, r *http.Request) { func (s *Server) handleLoginPage(w http.ResponseWriter, r *http.Request) {
if s.currentUser(r) != nil { if s.currentUser(r) != nil {
http.Redirect(w, r, "/dashboard", http.StatusSeeOther) http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
return return
} }
pageTmpl["login"].ExecuteTemplate(w, "layout", map[string]any{ pageTmpl["login"].ExecuteTemplate(w, "layout", map[string]any{
"Step": "phone", "Step": "identify",
"AuthEnabled": true, "AuthEnabled": true,
}) })
} }
func (s *Server) handleLoginSubmit(w http.ResponseWriter, r *http.Request) { func (s *Server) handleLoginSubmit(w http.ResponseWriter, r *http.Request) {
phone := normalizePhone(r.FormValue("phone")) raw := strings.TrimSpace(r.FormValue("identifier"))
if phone == "" { if raw == "" {
http.Redirect(w, r, "/login", http.StatusSeeOther) http.Redirect(w, r, "/login", http.StatusSeeOther)
return return
} }
var identifier string
var method string // "email" or "phone"
if isEmail(raw) {
identifier = strings.ToLower(raw)
method = "email"
} else {
identifier = normalizePhone(raw)
method = "phone"
if identifier == "" {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
}
code := generateCode() code := generateCode()
expiresAt := time.Now().Add(10 * time.Minute) expiresAt := time.Now().Add(10 * time.Minute)
s.q.CreateVerificationCode(r.Context(), db.CreateVerificationCodeParams{ s.q.CreateVerificationCode(r.Context(), db.CreateVerificationCodeParams{
Phone: phone, Identifier: identifier,
Code: code, Code: code,
ExpiresAt: expiresAt, ExpiresAt: expiresAt,
}) })
if err := sendVerificationSMS(phone, code); err != nil { if method == "email" {
log.Printf("failed to send verification SMS to %s: %v", phone, err) if err := sendVerificationEmail(identifier, code); err != nil {
log.Printf("failed to send verification email to %s: %v", identifier, err)
}
} else {
if err := sendVerificationSMS(identifier, code); err != nil {
log.Printf("failed to send verification SMS to %s: %v", identifier, err)
}
} }
pageTmpl["login"].ExecuteTemplate(w, "layout", map[string]any{ pageTmpl["login"].ExecuteTemplate(w, "layout", map[string]any{
"Step": "code", "Step": "code",
"Phone": phone, "Identifier": identifier,
"Method": method,
"AuthEnabled": true, "AuthEnabled": true,
}) })
} }
func (s *Server) handleVerifyCode(w http.ResponseWriter, r *http.Request) { func (s *Server) handleVerifyCode(w http.ResponseWriter, r *http.Request) {
phone := normalizePhone(r.FormValue("phone")) identifier := strings.TrimSpace(r.FormValue("identifier"))
method := r.FormValue("method")
code := strings.TrimSpace(r.FormValue("code")) code := strings.TrimSpace(r.FormValue("code"))
vc, err := s.q.GetVerificationCode(r.Context(), db.GetVerificationCodeParams{ vc, err := s.q.GetVerificationCode(r.Context(), db.GetVerificationCodeParams{
Phone: phone, Identifier: identifier,
Code: code, Code: code,
}) })
if err != nil { if err != nil {
pageTmpl["login"].ExecuteTemplate(w, "layout", map[string]any{ pageTmpl["login"].ExecuteTemplate(w, "layout", map[string]any{
"Step": "code", "Step": "code",
"Phone": phone, "Identifier": identifier,
"Method": method,
"Error": "Invalid or expired code. Try again.", "Error": "Invalid or expired code. Try again.",
"AuthEnabled": true, "AuthEnabled": true,
}) })
@@ -127,9 +157,17 @@ func (s *Server) handleVerifyCode(w http.ResponseWriter, r *http.Request) {
s.q.MarkVerificationCodeUsed(r.Context(), vc.ID) s.q.MarkVerificationCodeUsed(r.Context(), vc.ID)
// Get or create user // Get or create user
user, err := s.q.GetUserByPhone(r.Context(), phone) var user db.User
if err == sql.ErrNoRows { if method == "email" {
user, err = s.q.CreateUser(r.Context(), phone) user, err = s.q.GetUserByEmail(r.Context(), sql.NullString{String: identifier, Valid: true})
if err == sql.ErrNoRows {
user, err = s.q.CreateUserByEmail(r.Context(), sql.NullString{String: identifier, Valid: true})
}
} else {
user, err = s.q.GetUserByPhone(r.Context(), sql.NullString{String: identifier, Valid: true})
if err == sql.ErrNoRows {
user, err = s.q.CreateUserByPhone(r.Context(), sql.NullString{String: identifier, Valid: true})
}
} }
if err != nil { if err != nil {
log.Printf("user lookup/create: %v", err) log.Printf("user lookup/create: %v", err)
@@ -278,3 +316,38 @@ func sendVerificationSMS(to, code string) error {
} }
return nil return nil
} }
func sendVerificationEmail(to, code string) error {
apiKey := os.Getenv("RESEND_API_KEY")
if apiKey == "" {
log.Printf("RESEND_API_KEY not set — code for %s is: %s", to, code)
return nil
}
fromAddr := os.Getenv("BBQ_FROM_EMAIL")
if fromAddr == "" {
fromAddr = "bbq <noreply@bbq.torrtle.co>"
}
payload := map[string]any{
"from": fromAddr,
"to": []string{to},
"subject": fmt.Sprintf("Your login code: %s", code),
"html": fmt.Sprintf(`<div style="font-family:sans-serif;max-width:400px;margin:0 auto;padding:40px 20px;"><h2 style="margin:0 0 16px;">Your login code</h2><div style="font-size:32px;font-weight:bold;letter-spacing:8px;background:#f5f0e8;border:2px solid #1a1a1a;padding:20px;text-align:center;margin:16px 0;">%s</div><p style="color:#555;font-size:14px;">This code expires in 10 minutes.</p></div>`, code),
}
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", "https://api.resend.com/emails", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+apiKey)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return fmt.Errorf("resend API returned %d", resp.StatusCode)
}
return nil
}
+7 -6
View File
@@ -55,15 +55,16 @@ type Slot struct {
type User struct { type User struct {
ID int64 ID int64
Phone string Phone sql.NullString
Email sql.NullString
Name string Name string
CreatedAt time.Time CreatedAt time.Time
} }
type VerificationCode struct { type VerificationCode struct {
ID int64 ID int64
Phone string Identifier string
Code string Code string
ExpiresAt time.Time ExpiresAt time.Time
Used int64 Used int64
} }
+9 -3
View File
@@ -72,9 +72,15 @@ SELECT COUNT(*) FROM rsvps WHERE event_id = ?;
-- name: GetUserByPhone :one -- name: GetUserByPhone :one
SELECT * FROM users WHERE phone = ?; SELECT * FROM users WHERE phone = ?;
-- name: CreateUser :one -- name: GetUserByEmail :one
SELECT * FROM users WHERE email = ?;
-- name: CreateUserByPhone :one
INSERT INTO users (phone, name) VALUES (?, '') RETURNING *; INSERT INTO users (phone, name) VALUES (?, '') RETURNING *;
-- name: CreateUserByEmail :one
INSERT INTO users (email, name) VALUES (?, '') RETURNING *;
-- name: UpdateUserName :exec -- name: UpdateUserName :exec
UPDATE users SET name = ? WHERE id = ?; UPDATE users SET name = ? WHERE id = ?;
@@ -91,11 +97,11 @@ DELETE FROM sessions WHERE token = ?;
DELETE FROM sessions WHERE expires_at <= CURRENT_TIMESTAMP; DELETE FROM sessions WHERE expires_at <= CURRENT_TIMESTAMP;
-- name: CreateVerificationCode :exec -- name: CreateVerificationCode :exec
INSERT INTO verification_codes (phone, code, expires_at) VALUES (?, ?, ?); INSERT INTO verification_codes (identifier, code, expires_at) VALUES (?, ?, ?);
-- name: GetVerificationCode :one -- name: GetVerificationCode :one
SELECT * FROM verification_codes SELECT * FROM verification_codes
WHERE phone = ? AND code = ? AND used = 0 AND expires_at > CURRENT_TIMESTAMP WHERE identifier = ? AND code = ? AND used = 0 AND expires_at > CURRENT_TIMESTAMP
ORDER BY id DESC LIMIT 1; ORDER BY id DESC LIMIT 1;
-- name: MarkVerificationCodeUsed :exec -- name: MarkVerificationCodeUsed :exec
+54 -18
View File
@@ -174,16 +174,34 @@ func (q *Queries) CreateSlot(ctx context.Context, arg CreateSlotParams) (Slot, e
return i, err return i, err
} }
const createUser = `-- name: CreateUser :one const createUserByEmail = `-- name: CreateUserByEmail :one
INSERT INTO users (phone, name) VALUES (?, '') RETURNING id, phone, name, created_at INSERT INTO users (email, name) VALUES (?, '') RETURNING id, phone, email, name, created_at
` `
func (q *Queries) CreateUser(ctx context.Context, phone string) (User, error) { func (q *Queries) CreateUserByEmail(ctx context.Context, email sql.NullString) (User, error) {
row := q.db.QueryRowContext(ctx, createUser, phone) row := q.db.QueryRowContext(ctx, createUserByEmail, email)
var i User var i User
err := row.Scan( err := row.Scan(
&i.ID, &i.ID,
&i.Phone, &i.Phone,
&i.Email,
&i.Name,
&i.CreatedAt,
)
return i, err
}
const createUserByPhone = `-- name: CreateUserByPhone :one
INSERT INTO users (phone, name) VALUES (?, '') RETURNING id, phone, email, name, created_at
`
func (q *Queries) CreateUserByPhone(ctx context.Context, phone sql.NullString) (User, error) {
row := q.db.QueryRowContext(ctx, createUserByPhone, phone)
var i User
err := row.Scan(
&i.ID,
&i.Phone,
&i.Email,
&i.Name, &i.Name,
&i.CreatedAt, &i.CreatedAt,
) )
@@ -191,17 +209,17 @@ func (q *Queries) CreateUser(ctx context.Context, phone string) (User, error) {
} }
const createVerificationCode = `-- name: CreateVerificationCode :exec const createVerificationCode = `-- name: CreateVerificationCode :exec
INSERT INTO verification_codes (phone, code, expires_at) VALUES (?, ?, ?) INSERT INTO verification_codes (identifier, code, expires_at) VALUES (?, ?, ?)
` `
type CreateVerificationCodeParams struct { type CreateVerificationCodeParams struct {
Phone string Identifier string
Code string Code string
ExpiresAt time.Time ExpiresAt time.Time
} }
func (q *Queries) CreateVerificationCode(ctx context.Context, arg CreateVerificationCodeParams) error { func (q *Queries) CreateVerificationCode(ctx context.Context, arg CreateVerificationCodeParams) error {
_, err := q.db.ExecContext(ctx, createVerificationCode, arg.Phone, arg.Code, arg.ExpiresAt) _, err := q.db.ExecContext(ctx, createVerificationCode, arg.Identifier, arg.Code, arg.ExpiresAt)
return err return err
} }
@@ -332,16 +350,34 @@ func (q *Queries) GetSlot(ctx context.Context, id int64) (Slot, error) {
return i, err return i, err
} }
const getUserByPhone = `-- name: GetUserByPhone :one const getUserByEmail = `-- name: GetUserByEmail :one
SELECT id, phone, name, created_at FROM users WHERE phone = ? SELECT id, phone, email, name, created_at FROM users WHERE email = ?
` `
func (q *Queries) GetUserByPhone(ctx context.Context, phone string) (User, error) { func (q *Queries) GetUserByEmail(ctx context.Context, email sql.NullString) (User, error) {
row := q.db.QueryRowContext(ctx, getUserByEmail, email)
var i User
err := row.Scan(
&i.ID,
&i.Phone,
&i.Email,
&i.Name,
&i.CreatedAt,
)
return i, err
}
const getUserByPhone = `-- name: GetUserByPhone :one
SELECT id, phone, email, name, created_at FROM users WHERE phone = ?
`
func (q *Queries) GetUserByPhone(ctx context.Context, phone sql.NullString) (User, error) {
row := q.db.QueryRowContext(ctx, getUserByPhone, phone) row := q.db.QueryRowContext(ctx, getUserByPhone, phone)
var i User var i User
err := row.Scan( err := row.Scan(
&i.ID, &i.ID,
&i.Phone, &i.Phone,
&i.Email,
&i.Name, &i.Name,
&i.CreatedAt, &i.CreatedAt,
) )
@@ -349,22 +385,22 @@ func (q *Queries) GetUserByPhone(ctx context.Context, phone string) (User, error
} }
const getVerificationCode = `-- name: GetVerificationCode :one const getVerificationCode = `-- name: GetVerificationCode :one
SELECT id, phone, code, expires_at, used FROM verification_codes SELECT id, identifier, code, expires_at, used FROM verification_codes
WHERE phone = ? AND code = ? AND used = 0 AND expires_at > CURRENT_TIMESTAMP WHERE identifier = ? AND code = ? AND used = 0 AND expires_at > CURRENT_TIMESTAMP
ORDER BY id DESC LIMIT 1 ORDER BY id DESC LIMIT 1
` `
type GetVerificationCodeParams struct { type GetVerificationCodeParams struct {
Phone string Identifier string
Code string Code string
} }
func (q *Queries) GetVerificationCode(ctx context.Context, arg GetVerificationCodeParams) (VerificationCode, error) { func (q *Queries) GetVerificationCode(ctx context.Context, arg GetVerificationCodeParams) (VerificationCode, error) {
row := q.db.QueryRowContext(ctx, getVerificationCode, arg.Phone, arg.Code) row := q.db.QueryRowContext(ctx, getVerificationCode, arg.Identifier, arg.Code)
var i VerificationCode var i VerificationCode
err := row.Scan( err := row.Scan(
&i.ID, &i.ID,
&i.Phone, &i.Identifier,
&i.Code, &i.Code,
&i.ExpiresAt, &i.ExpiresAt,
&i.Used, &i.Used,
+5 -2
View File
@@ -10,8 +10,11 @@ func runMigrations(database *sql.DB) {
migrations := []string{ migrations := []string{
`ALTER TABLE events ADD COLUMN description TEXT DEFAULT ''`, `ALTER TABLE events ADD COLUMN description TEXT DEFAULT ''`,
`ALTER TABLE events ADD COLUMN user_id INTEGER REFERENCES users(id)`, `ALTER TABLE events ADD COLUMN user_id INTEGER REFERENCES users(id)`,
`ALTER TABLE users RENAME COLUMN email TO phone`, // Users may have email, phone, or both. Add whichever column is missing.
`ALTER TABLE verification_codes RENAME COLUMN email TO phone`, `ALTER TABLE users ADD COLUMN phone TEXT UNIQUE`,
`ALTER TABLE users ADD COLUMN email TEXT UNIQUE`,
// Verification codes use a generic identifier column.
`ALTER TABLE verification_codes ADD COLUMN identifier TEXT NOT NULL DEFAULT ''`,
} }
for _, m := range migrations { for _, m := range migrations {
_, err := database.Exec(m) _, err := database.Exec(m)
+4 -3
View File
@@ -38,7 +38,8 @@ CREATE TABLE IF NOT EXISTS rsvps (
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
phone TEXT NOT NULL UNIQUE, phone TEXT UNIQUE,
email TEXT UNIQUE,
name TEXT NOT NULL DEFAULT '', name TEXT NOT NULL DEFAULT '',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
); );
@@ -51,7 +52,7 @@ CREATE TABLE IF NOT EXISTS sessions (
CREATE TABLE IF NOT EXISTS verification_codes ( CREATE TABLE IF NOT EXISTS verification_codes (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
phone TEXT NOT NULL, identifier TEXT NOT NULL,
code TEXT NOT NULL, code TEXT NOT NULL,
expires_at DATETIME NOT NULL, expires_at DATETIME NOT NULL,
used INTEGER NOT NULL DEFAULT 0 used INTEGER NOT NULL DEFAULT 0
@@ -61,4 +62,4 @@ CREATE INDEX IF NOT EXISTS idx_slots_event ON slots(event_id);
CREATE INDEX IF NOT EXISTS idx_claims_slot ON claims(slot_id); CREATE INDEX IF NOT EXISTS idx_claims_slot ON claims(slot_id);
CREATE INDEX IF NOT EXISTS idx_rsvps_event ON rsvps(event_id); CREATE INDEX IF NOT EXISTS idx_rsvps_event ON rsvps(event_id);
CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id); CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_verification_codes_phone ON verification_codes(phone); CREATE INDEX IF NOT EXISTS idx_verification_codes_identifier ON verification_codes(identifier);
+7 -6
View File
@@ -7,24 +7,25 @@
<div class="event-tag">Account</div> <div class="event-tag">Account</div>
<h1 class="event-title">Log in</h1> <h1 class="event-title">Log in</h1>
<p style="font-family:'DM Mono',monospace;font-size:0.8rem;color:#555;margin-top:8px;"> <p style="font-family:'DM Mono',monospace;font-size:0.8rem;color:#555;margin-top:8px;">
Enter your phone number to receive a login code. Enter your email or phone number to receive a login code.
</p> </p>
</div> </div>
<div class="claim-form-wrapper"> <div class="claim-form-wrapper">
{{if eq .Step "phone"}} {{if eq .Step "identify"}}
<form method="POST" action="/login"> <form method="POST" action="/login">
<div class="form-row"> <div class="form-row">
<label>Phone</label> <label>Email or phone</label>
<input type="tel" name="phone" placeholder="(555) 123-4567" required autofocus> <input type="text" name="identifier" placeholder="you@example.com or (555) 123-4567" required autofocus>
</div> </div>
<button class="btn-submit" type="submit">Send code &#8599;</button> <button class="btn-submit" type="submit">Send code &#8599;</button>
</form> </form>
{{else}} {{else}}
<form method="POST" action="/login/verify"> <form method="POST" action="/login/verify">
<input type="hidden" name="phone" value="{{.Phone}}"> <input type="hidden" name="identifier" value="{{.Identifier}}">
<input type="hidden" name="method" value="{{.Method}}">
<p style="font-family:'DM Mono',monospace;font-size:0.78rem;color:#555;margin-bottom:16px;"> <p style="font-family:'DM Mono',monospace;font-size:0.78rem;color:#555;margin-bottom:16px;">
Code sent to <strong>{{.Phone}}</strong> Code sent to <strong>{{.Identifier}}</strong>
</p> </p>
{{if .Error}} {{if .Error}}
<p style="color:#c44;font-size:0.85rem;margin-bottom:12px;">{{.Error}}</p> <p style="color:#c44;font-size:0.85rem;margin-bottom:12px;">{{.Error}}</p>