Skip to content

Commit 7194d7f

Browse files
bscottclaude
andauthored
v0.5.0 - Optional Login Support & Beautiful Themes πŸ”πŸŽ¨ (#73)
* docs: Add implementation plan for optional login support in settings Brainstorm document covering: - Design decisions for optional single-user auth - Impact analysis for existing installations (zero breaking changes) - Hybrid approach using env vars and settings UI - UI/UX mockups for Settings and Login pages - Security considerations (bcrypt, sessions, lockout recovery) - Phased implementation steps * docs: Update login plan with SMTP prerequisite and password reset flow Confirmed decisions: - Login toggle in Settings page, default OFF - SMTP must be configured before login can be enabled (for password recovery) - Added password reset flow via email as primary lockout recovery - Updated UI mockups with Forgot Password page * docs: Add CLI password reset option for Docker deployments New recovery features: - --reset-password flag for interactive password reset - --new-password flag for non-interactive/scripted reset - --disable-auth flag to completely remove authentication Usage examples for Docker: docker exec -it subtrackr /app/subtrackr --reset-password docker compose run --rm subtrackr --reset-password * Add theming system and fix authentication conflicts - Implement 5 beautiful themes: Default, Dark, Christmas, Midnight, Ocean - Christmas theme includes festive snowfall animation - Midnight theme features purple glow effects - Remove old dark mode system to prevent theme conflicts - Fix dark theme hover states for proper contrast - Add theme persistence with localStorage and server storage - Add screenshots for documentation (Christmas, Ocean, Login) - Update README with themes gallery - Exclude screenshots from Docker builds * Update Go version to 1.24 for GitHub Actions compatibility --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent ade832a commit 7194d7f

33 files changed

+2367
-94
lines changed

β€Ž.dockerignoreβ€Ž

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
*.md
88
docs/
99
LICENSE
10+
screenshots/
1011

1112
# Development files
1213
docker-compose.yml

β€ŽDockerfileβ€Ž

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# Build stage
2-
FROM golang:1.21 AS builder
2+
FROM golang:1.24 AS builder
33

44
# Install build dependencies
55
RUN apt-get update && apt-get install -y \

β€ŽPLAN-login-settings.mdβ€Ž

Lines changed: 448 additions & 0 deletions
Large diffs are not rendered by default.

β€ŽREADME.mdβ€Ž

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,38 @@ A self-hosted subscription management application built with Go and HTMX. Track
88

99
![SubTrackr Mobile View](mobile-screenshot.png)
1010

11+
## 🎨 Themes
12+
13+
Personalize your SubTrackr experience with 5 beautiful themes:
14+
15+
<table>
16+
<tr>
17+
<td align="center">
18+
<img src="screenshots/christmas.png" alt="Christmas Theme" width="600"/><br/>
19+
<b>Christmas πŸŽ„</b><br/>
20+
Festive and jolly! (with snowfall animation)
21+
</td>
22+
</tr>
23+
<tr>
24+
<td align="center">
25+
<img src="screenshots/ocean.png" alt="Ocean Theme" width="600"/><br/>
26+
<b>Ocean</b><br/>
27+
Cool and refreshing
28+
</td>
29+
</tr>
30+
<tr>
31+
<td align="center">
32+
<img src="screenshots/login.png" alt="Login Page" width="600"/><br/>
33+
<b>Optional Authentication</b><br/>
34+
Secure your data with optional login support
35+
</td>
36+
</tr>
37+
</table>
38+
39+
**Available themes:** Default (Light), Dark, Christmas πŸŽ„, Midnight (Purple), Ocean (Cyan)
40+
41+
Themes persist across all pages and are saved per user. Change themes anytime from Settings β†’ Appearance.
42+
1143
![Version](https://img.shields.io/github/v/release/bscott/subtrackr?logo=github&label=version)
1244
![Go Version](https://img.shields.io/badge/go-%3E%3D1.21-00ADD8)
1345
![License](https://img.shields.io/badge/license-AGPL--3.0-green)
@@ -20,6 +52,7 @@ A self-hosted subscription management application built with Go and HTMX. Track
2052
- πŸ“ˆ **Analytics**: Visualize spending by category and track savings
2153
- πŸ”” **Email Notifications**: Get reminders before subscriptions renew
2254
- πŸ“€ **Data Export**: Export your data as CSV, JSON, or iCal format
55+
- 🎨 **Beautiful Themes**: 5 stunning themes including a festive Christmas theme with snowfall animation
2356
- 🌍 **Multi-Currency Support**: Support for USD, EUR, GBP, JPY, RUB, SEK, PLN, and INR (with optional real-time conversion)
2457
- 🐳 **Docker Ready**: Easy deployment with Docker
2558
- πŸ”’ **Self-Hosted**: Your data stays on your server

β€Žcmd/server/main.goβ€Ž

Lines changed: 120 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
package main
22

33
import (
4+
"crypto/subtle"
5+
"flag"
6+
"fmt"
47
"html/template"
58
"log"
69
"math"
@@ -12,12 +15,20 @@ import (
1215
"subtrackr/internal/middleware"
1316
"subtrackr/internal/repository"
1417
"subtrackr/internal/service"
18+
"syscall"
1519
"time"
1620

1721
"github.com/gin-gonic/gin"
22+
"golang.org/x/term"
1823
)
1924

2025
func main() {
26+
// CLI flags
27+
resetPassword := flag.Bool("reset-password", false, "Reset admin password (interactive or with --new-password)")
28+
newPassword := flag.String("new-password", "", "New password for admin (non-interactive, use with --reset-password)")
29+
disableAuth := flag.Bool("disable-auth", false, "Disable authentication and remove credentials")
30+
flag.Parse()
31+
2132
// Load configuration
2233
cfg := config.Load()
2334

@@ -47,10 +58,29 @@ func main() {
4758
emailService := service.NewEmailService(settingsService)
4859
logoService := service.NewLogoService()
4960

61+
// Handle CLI commands (run before starting HTTP server)
62+
if *disableAuth {
63+
handleDisableAuth(settingsService)
64+
return
65+
}
66+
67+
if *resetPassword {
68+
handleResetPassword(settingsService, *newPassword)
69+
return
70+
}
71+
72+
// Initialize session service (get or generate session secret)
73+
sessionSecret, err := settingsService.GetOrGenerateSessionSecret()
74+
if err != nil {
75+
log.Fatal("Failed to initialize session secret:", err)
76+
}
77+
sessionService := service.NewSessionService(sessionSecret)
78+
5079
// Initialize handlers
5180
subscriptionHandler := handlers.NewSubscriptionHandler(subscriptionService, settingsService, currencyService, emailService, logoService)
5281
settingsHandler := handlers.NewSettingsHandler(settingsService)
5382
categoryHandler := handlers.NewCategoryHandler(categoryService)
83+
authHandler := handlers.NewAuthHandler(settingsService, sessionService, emailService)
5484

5585
// Setup Gin router
5686
if cfg.Environment == "production" {
@@ -126,8 +156,11 @@ func main() {
126156
})
127157
})
128158

159+
// Apply auth middleware
160+
router.Use(middleware.AuthMiddleware(settingsService, sessionService))
161+
129162
// Routes
130-
setupRoutes(router, subscriptionHandler, settingsHandler, settingsService, categoryHandler)
163+
setupRoutes(router, subscriptionHandler, settingsHandler, settingsService, categoryHandler, authHandler)
131164

132165
// Seed sample data if database is empty
133166
// Commented out - no sample data by default
@@ -201,6 +234,15 @@ func loadTemplates() *template.Template {
201234
"templates/smtp-message.html",
202235
"templates/form-errors.html",
203236
"templates/error.html",
237+
"templates/login.html",
238+
"templates/login-error.html",
239+
"templates/forgot-password.html",
240+
"templates/forgot-password-error.html",
241+
"templates/forgot-password-success.html",
242+
"templates/reset-password.html",
243+
"templates/reset-password-error.html",
244+
"templates/reset-password-success.html",
245+
"templates/auth-message.html",
204246
}
205247

206248
var parsedCount int
@@ -250,7 +292,12 @@ func loadTemplates() *template.Template {
250292
return tmpl
251293
}
252294

253-
func setupRoutes(router *gin.Engine, handler *handlers.SubscriptionHandler, settingsHandler *handlers.SettingsHandler, settingsService *service.SettingsService, categoryHandler *handlers.CategoryHandler) {
295+
func setupRoutes(router *gin.Engine, handler *handlers.SubscriptionHandler, settingsHandler *handlers.SettingsHandler, settingsService *service.SettingsService, categoryHandler *handlers.CategoryHandler, authHandler *handlers.AuthHandler) {
296+
// Auth routes (public)
297+
router.GET("/login", authHandler.ShowLoginPage)
298+
router.GET("/forgot-password", authHandler.ShowForgotPasswordPage)
299+
router.GET("/reset-password", authHandler.ShowResetPasswordPage)
300+
254301
// Web routes
255302
router.GET("/", handler.Dashboard)
256303
router.GET("/dashboard", handler.Dashboard)
@@ -306,6 +353,21 @@ func setupRoutes(router *gin.Engine, handler *handlers.SubscriptionHandler, sett
306353
api.POST("/categories", categoryHandler.CreateCategory)
307354
api.PUT("/categories/:id", categoryHandler.UpdateCategory)
308355
api.DELETE("/categories/:id", categoryHandler.DeleteCategory)
356+
357+
// Auth routes
358+
api.POST("/auth/login", authHandler.Login)
359+
api.GET("/auth/logout", authHandler.Logout)
360+
api.POST("/auth/forgot-password", authHandler.ForgotPassword)
361+
api.POST("/auth/reset-password", authHandler.ResetPassword)
362+
363+
// Auth settings routes
364+
api.POST("/settings/auth/setup", settingsHandler.SetupAuth)
365+
api.POST("/settings/auth/disable", settingsHandler.DisableAuth)
366+
api.GET("/settings/auth/status", settingsHandler.GetAuthStatus)
367+
368+
// Theme settings routes
369+
api.GET("/settings/theme", settingsHandler.GetTheme)
370+
api.POST("/settings/theme", settingsHandler.SetTheme)
309371
}
310372

311373
// Public API routes (require API key authentication)
@@ -413,3 +475,59 @@ func checkAndSendRenewalReminders(subscriptionService *service.SubscriptionServi
413475

414476
log.Printf("Renewal reminder check complete: %d sent, %d failed", sentCount, failedCount)
415477
}
478+
479+
// handleResetPassword handles the --reset-password CLI command
480+
func handleResetPassword(settingsService *service.SettingsService, newPassword string) {
481+
var password string
482+
483+
if newPassword != "" {
484+
// Non-interactive mode
485+
password = newPassword
486+
} else {
487+
// Interactive mode - prompt for password
488+
fmt.Print("Enter new admin password: ")
489+
passwordBytes, err := term.ReadPassword(int(syscall.Stdin))
490+
if err != nil {
491+
log.Fatal("Failed to read password:", err)
492+
}
493+
fmt.Println()
494+
495+
fmt.Print("Confirm password: ")
496+
confirmBytes, err := term.ReadPassword(int(syscall.Stdin))
497+
if err != nil {
498+
log.Fatal("Failed to read confirmation:", err)
499+
}
500+
fmt.Println()
501+
502+
// Use constant-time comparison to prevent timing attacks
503+
if subtle.ConstantTimeCompare(passwordBytes, confirmBytes) != 1 {
504+
log.Fatal("Passwords do not match")
505+
}
506+
507+
password = string(passwordBytes)
508+
}
509+
510+
// Validate password length
511+
if len(password) < 8 {
512+
log.Fatal("Password must be at least 8 characters long")
513+
}
514+
515+
// Update password
516+
if err := settingsService.SetAuthPassword(password); err != nil {
517+
log.Fatal("Failed to update password:", err)
518+
}
519+
520+
fmt.Println("βœ“ Admin password reset successfully")
521+
os.Exit(0)
522+
}
523+
524+
// handleDisableAuth handles the --disable-auth CLI command
525+
func handleDisableAuth(settingsService *service.SettingsService) {
526+
if err := settingsService.DisableAuth(); err != nil {
527+
log.Fatal("Failed to disable authentication:", err)
528+
}
529+
530+
fmt.Println("βœ“ Authentication disabled successfully")
531+
fmt.Println(" Note: Credentials are preserved and can be re-enabled from Settings")
532+
os.Exit(0)
533+
}

β€Žgo.modβ€Ž

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
module subtrackr
22

3-
go 1.21
3+
go 1.24.0
44

55
require (
66
github.com/dromara/carbon/v2 v2.6.11
77
github.com/gin-gonic/gin v1.9.1
8+
github.com/gorilla/sessions v1.4.0
89
github.com/stretchr/testify v1.11.1
10+
golang.org/x/crypto v0.46.0
11+
golang.org/x/term v0.38.0
912
gorm.io/driver/sqlite v1.5.4
1013
gorm.io/gorm v1.25.5
1114
)
@@ -20,6 +23,7 @@ require (
2023
github.com/go-playground/universal-translator v0.18.1 // indirect
2124
github.com/go-playground/validator/v10 v10.14.0 // indirect
2225
github.com/goccy/go-json v0.10.2 // indirect
26+
github.com/gorilla/securecookie v1.1.2 // indirect
2327
github.com/jinzhu/inflection v1.0.0 // indirect
2428
github.com/jinzhu/now v1.1.5 // indirect
2529
github.com/json-iterator/go v1.1.12 // indirect
@@ -34,10 +38,9 @@ require (
3438
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
3539
github.com/ugorji/go/codec v1.2.11 // indirect
3640
golang.org/x/arch v0.3.0 // indirect
37-
golang.org/x/crypto v0.9.0 // indirect
38-
golang.org/x/net v0.10.0 // indirect
39-
golang.org/x/sys v0.8.0 // indirect
40-
golang.org/x/text v0.9.0 // indirect
41+
golang.org/x/net v0.47.0 // indirect
42+
golang.org/x/sys v0.39.0 // indirect
43+
golang.org/x/text v0.32.0 // indirect
4144
google.golang.org/protobuf v1.30.0 // indirect
4245
gopkg.in/yaml.v3 v3.0.1 // indirect
4346
)

β€Žgo.sumβ€Ž

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,12 @@ github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaS
2929
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
3030
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
3131
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
32+
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
33+
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
34+
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
35+
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
36+
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
37+
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
3238
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
3339
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
3440
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
@@ -72,16 +78,18 @@ github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZ
7278
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
7379
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
7480
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
75-
golang.org/x/crypto v0.9.0 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g=
76-
golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0=
77-
golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
78-
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
81+
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
82+
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
83+
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
84+
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
7985
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
8086
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
81-
golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU=
82-
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
83-
golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE=
84-
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
87+
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
88+
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
89+
golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
90+
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
91+
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
92+
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
8593
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
8694
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
8795
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=

0 commit comments

Comments
Β (0)