Skip to content

Commit eee9ab6

Browse files
committed
feat: real heatmap endpoints, mobile layout fixes, steward OTP sign-in
- 3 new endpoints replace simulated dashboard data (giver activity, driver opportunities, steward workload) - mobile bottom nav: fix Tailwind purge bug with dynamic grid-cols - mobile main column: drop 420px cap, prevent horizontal overflow - steward sign-in: passwordless 6-digit OTP, 10-min expiry, role-gated - replace forgot-password with Contact admin button on steward sign-in
1 parent 0bbf0d6 commit eee9ab6

19 files changed

Lines changed: 777 additions & 25 deletions

File tree

cmd/api/main.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,8 @@ func main() {
200200
app.Post("/auth/login", authLimit, authH.Login)
201201
app.Post("/auth/password/reset/request", authLimit, authH.RequestPasswordReset)
202202
app.Post("/auth/password/reset/confirm", authLimit, authH.ConfirmPasswordReset)
203+
app.Post("/auth/steward/otp/request", authLimit, authH.RequestStewardOTP)
204+
app.Post("/auth/steward/otp/verify", authLimit, authH.VerifyStewardOTP)
203205
app.Post("/auth/logout", authed, authH.Logout)
204206
app.Get("/auth/me", authed, authH.Me)
205207
app.Post("/auth/password/reset-request", authLimit, authH.RequestPasswordReset)
@@ -216,6 +218,7 @@ func main() {
216218
// Giver — payment initiation is rate-limited and CSRF-protected
217219
app.Post("/giver/deposits/initialize", authed, paymentLimit, giverH.InitializeDeposit)
218220
app.Get("/giver/deposits/:reference", authed, giverH.GetDeposit)
221+
app.Get("/giver/activity", authed, giverH.Activity)
219222

220223
// Encouragement notes
221224
app.Post("/notes", authed, noteH.Submit)
@@ -248,6 +251,7 @@ func main() {
248251
// Driver
249252
app.Post("/driver/apply", authed, driverH.Apply)
250253
app.Get("/driver/me", authed, driverH.Me)
254+
app.Get("/driver/opportunities", authed, driverH.Opportunities)
251255
app.Get("/driver/impact", authed, ratingH.MyImpact)
252256
app.Get("/driver/average", authed, ratingH.MyAverage)
253257

@@ -289,6 +293,7 @@ func main() {
289293
}
290294
steward := app.Group("/steward", authed, middleware.RequireSteward())
291295
steward.Get("/queue", stewardH.Queue)
296+
steward.Get("/workload", stewardH.Workload)
292297
steward.Get("/applications/:id", stewardH.Application)
293298
steward.Post("/applications/:id/decisions", stewardH.Decide)
294299
steward.Get("/audit", stewardH.Audit)

internal/email/sender.go

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,13 @@ func (s *Sender) SendPasswordReset(ctx context.Context, toEmail, token, appBaseU
5454
return s.send(ctx, toEmail, "Reset your Akin password", html)
5555
}
5656

57+
// SendStewardOTP delivers a 6-digit sign-in code to a steward. The code is
58+
// the credential — no URL — so the recipient types it back into the app.
59+
func (s *Sender) SendStewardOTP(ctx context.Context, toEmail, code string) error {
60+
html := stewardOTPHTML(toEmail, code)
61+
return s.send(ctx, toEmail, "Your Akin steward sign-in code", html)
62+
}
63+
5764
func (s *Sender) send(ctx context.Context, to, subject, html string) error {
5865
if !s.Configured() {
5966
slog.Warn("email not configured — skipping send", "to", to, "subject", subject)
@@ -225,6 +232,59 @@ func verifyHTML(toEmail, verifyURL string) string {
225232
</html>`, toEmail, verifyURL, verifyURL, verifyURL)
226233
}
227234

235+
func stewardOTPHTML(toEmail, code string) string {
236+
return fmt.Sprintf(`<!DOCTYPE html>
237+
<html lang="en">
238+
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Steward sign-in code</title></head>
239+
<body style="margin:0;padding:0;background:#F5EFE6;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;">
240+
<table width="100%%" cellpadding="0" cellspacing="0" style="background:#F5EFE6;padding:40px 20px;">
241+
<tr><td align="center">
242+
<table width="100%%" cellpadding="0" cellspacing="0" style="max-width:480px;background:#FBF8F2;border-radius:18px;border:1px solid #E2DBCB;overflow:hidden;">
243+
<tr>
244+
<td style="padding:32px 40px 0;">
245+
<table cellpadding="0" cellspacing="0">
246+
<tr>
247+
<td style="font-size:24px;font-weight:600;color:#1B2A4E;letter-spacing:-0.03em;">akin</td>
248+
<td style="font-size:24px;font-weight:600;color:#D97757;letter-spacing:-0.03em;padding-left:1px;">.</td>
249+
</tr>
250+
</table>
251+
</td>
252+
</tr>
253+
<tr>
254+
<td style="padding:28px 40px 0;">
255+
<h1 style="margin:0;font-size:26px;font-weight:500;color:#1B2A4E;letter-spacing:-0.02em;line-height:1.15;">Steward sign-in code.</h1>
256+
<p style="margin:14px 0 0;font-size:14px;color:#8B8680;line-height:1.65;">
257+
Use the code below to finish signing in as <strong style="color:#1A1A1A;font-weight:500;">%s</strong>.
258+
It expires in 10 minutes.
259+
</p>
260+
</td>
261+
</tr>
262+
<tr>
263+
<td style="padding:28px 40px 0;">
264+
<div style="background:#1B2A4E;border-radius:14px;padding:22px 28px;text-align:center;">
265+
<div style="font-family:'SF Mono',Menlo,Consolas,monospace;font-size:32px;letter-spacing:0.4em;color:#FBF8F2;font-weight:500;">%s</div>
266+
</div>
267+
<p style="margin:16px 0 0;font-size:11px;color:#C9C3B8;line-height:1.6;">
268+
If you didn't request this, ignore the email — your account stays secure.
269+
Never share this code with anyone, including Akin staff.
270+
</p>
271+
</td>
272+
</tr>
273+
<tr>
274+
<td style="padding:32px 40px;">
275+
<hr style="border:none;border-top:1px solid #E2DBCB;margin:0 0 20px;">
276+
<p style="margin:0;font-size:11px;color:#C9C3B8;line-height:1.6;">
277+
You're receiving this because the email %s is registered as a steward on Akin.
278+
</p>
279+
</td>
280+
</tr>
281+
</table>
282+
</td></tr>
283+
</table>
284+
</body>
285+
</html>`, toEmail, code, toEmail)
286+
}
287+
228288
func resetHTML(toEmail, resetURL string) string {
229289
return fmt.Sprintf(`<!DOCTYPE html>
230290
<html lang="en">

internal/handlers/auth.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,3 +100,31 @@ func (h *AuthHandler) ConfirmPasswordReset(c *fiber.Ctx) error {
100100
}
101101
return c.JSON(fiber.Map{"ok": true})
102102
}
103+
104+
func (h *AuthHandler) RequestStewardOTP(c *fiber.Ctx) error {
105+
var req struct {
106+
Email string `json:"email"`
107+
}
108+
if err := c.BodyParser(&req); err != nil {
109+
return c.Status(400).JSON(fiber.Map{"error": "invalid_body"})
110+
}
111+
_ = h.svc.RequestStewardOTP(c.Context(), req.Email)
112+
// Always 200 — don't reveal whether the email exists or is a steward.
113+
return c.JSON(fiber.Map{"ok": true})
114+
}
115+
116+
func (h *AuthHandler) VerifyStewardOTP(c *fiber.Ctx) error {
117+
var req struct {
118+
Email string `json:"email"`
119+
Code string `json:"code"`
120+
}
121+
if err := c.BodyParser(&req); err != nil {
122+
return c.Status(400).JSON(fiber.Map{"error": "invalid_body"})
123+
}
124+
st, err := h.svc.VerifyStewardOTP(c.Context(), req.Email, req.Code)
125+
if err != nil {
126+
return fail(c, err, "otp_failed")
127+
}
128+
setSessionCookie(c, h.cfg, st.Token, st.Session.ExpiresAt)
129+
return c.JSON(toUserResponse(st.User))
130+
}

internal/handlers/driver.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,18 @@ func (h *DriverHandler) Me(c *fiber.Ctx) error {
5454
return c.JSON(d)
5555
}
5656

57+
func (h *DriverHandler) Opportunities(c *fiber.Ctx) error {
58+
user := middleware.CurrentUser(c)
59+
if user == nil {
60+
return c.Status(401).JSON(fiber.Map{"error": "not_authenticated"})
61+
}
62+
out, err := h.svc.Opportunities()
63+
if err != nil {
64+
return c.Status(500).JSON(fiber.Map{"error": "opportunities_failed"})
65+
}
66+
return c.JSON(out)
67+
}
68+
5769
func (h *DriverHandler) Queue(c *fiber.Ctx) error {
5870
items, err := h.svc.ListPending()
5971
if err != nil {

internal/handlers/giver.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,18 @@ func (h *GiverHandler) InitializeDeposit(c *fiber.Ctx) error {
3838
return c.JSON(fiber.Map{"authorizationUrl": result.AuthorizationURL, "reference": result.Reference})
3939
}
4040

41+
func (h *GiverHandler) Activity(c *fiber.Ctx) error {
42+
user := middleware.CurrentUser(c)
43+
if user == nil {
44+
return c.Status(401).JSON(fiber.Map{"error": "not_authenticated"})
45+
}
46+
matrix, err := h.svc.Activity(c.Context(), user.ID, 4)
47+
if err != nil {
48+
return c.Status(500).JSON(fiber.Map{"error": "activity_failed"})
49+
}
50+
return c.JSON(fiber.Map{"weeks": matrix})
51+
}
52+
4153
func (h *GiverHandler) GetDeposit(c *fiber.Ctx) error {
4254
user := middleware.CurrentUser(c)
4355
if user == nil {

internal/handlers/helpers.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,9 @@ func httpErr(err error) (status int, code string) {
120120
{service.ErrAlreadyVerified, 409, "already_verified"},
121121
// password reset
122122
{service.ErrResetTokenInvalid, 400, "reset_token_invalid"},
123+
// steward OTP
124+
{service.ErrOTPInvalid, 400, "otp_invalid"},
125+
{service.ErrNotSteward, 403, "not_steward"},
123126
// ratings
124127
{service.ErrAlreadyRated, 409, "already_rated"},
125128
{service.ErrInvalidScore, 400, "invalid_score"},

internal/handlers/steward.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,14 @@ func (h *StewardHandler) Queue(c *fiber.Ctx) error {
2929
return c.JSON(fiber.Map{"items": out})
3030
}
3131

32+
func (h *StewardHandler) Workload(c *fiber.Ctx) error {
33+
out, err := h.svc.Workload()
34+
if err != nil {
35+
return c.Status(500).JSON(fiber.Map{"error": "workload_failed"})
36+
}
37+
return c.JSON(out)
38+
}
39+
3240
func (h *StewardHandler) Application(c *fiber.Ctx) error {
3341
steward := middleware.CurrentUser(c)
3442
id, err := uuid.Parse(c.Params("id"))

internal/models/models.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,11 @@ type User struct {
2323
EmailVerifyToken string `gorm:"index" json:"-"`
2424
PasswordResetToken string `gorm:"index" json:"-"`
2525
PasswordResetExpiresAt *time.Time `json:"-"`
26+
// OTP for the steward-only passwordless sign-in. Stored as a SHA-256
27+
// hash so a DB leak doesn't yield usable codes; cleared on successful
28+
// verify. Expiry is short (10 min) and gated by role at the handler.
29+
OTPCodeHash string `json:"-"`
30+
OTPExpiresAt *time.Time `json:"-"`
2631
CreatedAt time.Time `json:"createdAt"`
2732
UpdatedAt time.Time `json:"updatedAt"`
2833
}

internal/repository/user.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,3 +70,17 @@ func (r *UserRepo) UpdatePassword(id uuid.UUID, hash string) error {
7070
"password_reset_expires_at": nil,
7171
}).Error
7272
}
73+
74+
func (r *UserRepo) SetOTP(id uuid.UUID, codeHash string, expiresAt time.Time) error {
75+
return r.db.Model(&models.User{}).Where("id = ?", id).Updates(map[string]any{
76+
"otp_code_hash": codeHash,
77+
"otp_expires_at": expiresAt,
78+
}).Error
79+
}
80+
81+
func (r *UserRepo) ClearOTP(id uuid.UUID) error {
82+
return r.db.Model(&models.User{}).Where("id = ?", id).Updates(map[string]any{
83+
"otp_code_hash": "",
84+
"otp_expires_at": nil,
85+
}).Error
86+
}

internal/service/auth.go

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,13 @@ package service
33
import (
44
"context"
55
"crypto/rand"
6+
"crypto/sha256"
7+
"crypto/subtle"
68
"encoding/base64"
79
"encoding/hex"
810
"errors"
11+
"fmt"
12+
"math/big"
913
"strings"
1014
"time"
1115

@@ -26,6 +30,8 @@ var (
2630
ErrPasswordTooShort = errors.New("password_too_short")
2731
ErrEmailInvalid = errors.New("email_invalid")
2832
ErrResetTokenInvalid = errors.New("reset_token_invalid")
33+
ErrOTPInvalid = errors.New("otp_invalid")
34+
ErrNotSteward = errors.New("not_steward")
2935
)
3036

3137
type AuthService struct {
@@ -180,6 +186,82 @@ func (s *AuthService) ConfirmPasswordReset(token, newPassword string) error {
180186
return nil
181187
}
182188

189+
// RequestStewardOTP generates a 6-digit code, stores its hash, and emails the
190+
// plain code. Silent on unknown email or non-steward user to avoid leaking
191+
// who is/isn't elevated. Codes expire in 10 minutes.
192+
func (s *AuthService) RequestStewardOTP(ctx context.Context, emailAddr string) error {
193+
emailAddr = strings.ToLower(strings.TrimSpace(emailAddr))
194+
user, err := s.users.FindByEmail(emailAddr)
195+
if err != nil {
196+
return nil // silent
197+
}
198+
if !user.IsSteward() {
199+
return nil // silent — don't reveal role
200+
}
201+
202+
code, err := newOTPCode()
203+
if err != nil {
204+
return err
205+
}
206+
hash := hashOTP(code)
207+
expiresAt := time.Now().Add(10 * time.Minute)
208+
209+
if err := s.users.SetOTP(user.ID, hash, expiresAt); err != nil {
210+
return err
211+
}
212+
213+
if s.mailer != nil && s.mailer.Configured() {
214+
_ = s.mailer.SendStewardOTP(ctx, user.Email, code)
215+
}
216+
217+
audit.Record(s.db, user.ID.String(), "steward_otp_requested", user.ID.String(), nil)
218+
return nil
219+
}
220+
221+
// VerifyStewardOTP checks the code in constant time, issues a session if it
222+
// matches and is unexpired, and clears the code so it can't be replayed.
223+
func (s *AuthService) VerifyStewardOTP(ctx context.Context, emailAddr, code string) (*SessionToken, error) {
224+
emailAddr = strings.ToLower(strings.TrimSpace(emailAddr))
225+
code = strings.TrimSpace(code)
226+
user, err := s.users.FindByEmail(emailAddr)
227+
if err != nil {
228+
return nil, ErrOTPInvalid
229+
}
230+
if !user.IsSteward() {
231+
return nil, ErrNotSteward
232+
}
233+
if user.OTPCodeHash == "" || user.OTPExpiresAt == nil || time.Now().After(*user.OTPExpiresAt) {
234+
return nil, ErrOTPInvalid
235+
}
236+
expected, got := []byte(user.OTPCodeHash), []byte(hashOTP(code))
237+
if subtle.ConstantTimeCompare(expected, got) != 1 {
238+
return nil, ErrOTPInvalid
239+
}
240+
_ = s.users.ClearOTP(user.ID)
241+
242+
st, err := s.issueSession(user)
243+
if err != nil {
244+
return nil, err
245+
}
246+
audit.Record(s.db, user.ID.String(), "steward_otp_verified", user.ID.String(), nil)
247+
return st, nil
248+
}
249+
250+
// newOTPCode returns a 6-digit numeric code (zero-padded). Uses crypto/rand
251+
// so each code is unpredictable.
252+
func newOTPCode() (string, error) {
253+
n, err := rand.Int(rand.Reader, big.NewInt(1_000_000))
254+
if err != nil {
255+
return "", err
256+
}
257+
return fmt.Sprintf("%06d", n.Int64()), nil
258+
}
259+
260+
func hashOTP(code string) string {
261+
sum := sha256.Sum256([]byte(code))
262+
return hex.EncodeToString(sum[:])
263+
}
264+
183265
func (s *AuthService) issueSession(user *models.User) (*SessionToken, error) {
184266
raw, err := newToken()
185267
if err != nil {

0 commit comments

Comments
 (0)