This commit is contained in:
Tomas Dvorak
2026-05-05 09:48:07 +02:00
parent d854614a87
commit 48c3e15a38
295 changed files with 178381 additions and 1039 deletions
+22
View File
@@ -0,0 +1,22 @@
# Auth Service Environment Configuration
# This service stays active for standalone auth flows and internal admin management.
# SaaS billing is handled by apps/backend + Paddle.
PORT=8081
APP_ENV=development
DATABASE_URL=postgresql://user:password@host/database?sslmode=require
FRONTEND_URL=http://localhost:3000
JWT_SECRET=change-me-in-production
NEON_AUTH_URL=https://ep-mute-water-alem1v8u.neonauth.c-3.eu-central-1.aws.neon.tech/neondb/auth
SMTP_HOST=smtp.purelymail.com
SMTP_PORT=465
SMTP_USERNAME=noreply@example.com
SMTP_PASSWORD=
EMAIL_FROM=noreply@example.com
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GOOGLE_REDIRECT_URL=http://localhost:8081/api/auth/oauth/google/callback
+21
View File
@@ -0,0 +1,21 @@
# Environment variables
.env
# Binary
auth-service
*.exe
# Go
vendor/
*.log
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
+35
View File
@@ -0,0 +1,35 @@
# Build stage
FROM golang:1.26.2-alpine AS builder
WORKDIR /app
# Install build dependencies
RUN apk add --no-cache git
# Copy go mod files
COPY go.mod go.sum ./
RUN go mod download
# Copy source code
COPY . .
# Build the application
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o /app/auth-service ./cmd/api
# Final stage
FROM alpine:3.22
WORKDIR /app
# Install ca-certificates for HTTPS
RUN apk add --no-cache ca-certificates
# Copy binary from builder
COPY --from=builder /app/auth-service /app/
# Copy migrations
COPY --from=builder /app/migrations /app/migrations
EXPOSE 8080
CMD ["/app/auth-service"]
+42
View File
@@ -0,0 +1,42 @@
# Bookra Auth Service
Standalone auth + internal admin service for Bookra.
Primary responsibilities:
- email/password auth
- magic-link auth
- Google OAuth when configured
- internal admin dashboard / remote service management
- optional Neon JWT verification support
Not primary billing service:
- SaaS billing lives in `apps/backend`
- Paddle config belongs in backend/frontend env
## Commands
```bash
go run ./cmd/api
go test ./...
go build ./...
```
## Core Routes
- `GET /health`
- `POST /api/auth/register`
- `POST /api/auth/login`
- `POST /api/auth/magic-link`
- `POST /api/auth/verify`
- `POST /api/auth/refresh`
- `GET /api/auth/me`
- `GET /api/auth/providers`
- `GET /api/auth/oauth/google`
- `GET /api/auth/oauth/google/callback`
- `GET /admin`
- `GET /admin/api/config`
- `GET /admin/api/stats`
See [apps/auth-service/.env.example](/home/tdvorak/Desktop/PROG+HTML/Bookra/apps/auth-service/.env.example:1).
+121
View File
@@ -0,0 +1,121 @@
package main
import (
"bookra/apps/auth-service/internal/config"
"bookra/apps/auth-service/internal/db"
"bookra/apps/auth-service/internal/email"
"bookra/apps/auth-service/internal/handlers"
"context"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
_ "github.com/jackc/pgx/v5/stdlib"
"github.com/joho/godotenv"
"github.com/pressly/goose/v3"
)
func main() {
_ = godotenv.Load()
cfg, err := config.Load()
if err != nil {
log.Fatalf("Failed to load config: %v", err)
}
if os.Getenv("APP_ENV") == "production" {
gin.SetMode(gin.ReleaseMode)
}
database, err := db.New(cfg.DatabaseURL)
if err != nil {
log.Fatalf("Failed to connect to database: %v", err)
}
defer database.Close()
if err := runMigrations(cfg.DatabaseURL); err != nil {
log.Printf("Migration warning: %v", err)
}
emailSvc := email.New(email.Config{
Host: cfg.SMTPHost,
Port: cfg.SMTPPort,
Username: cfg.SMTPUsername,
Password: cfg.SMTPPassword,
From: cfg.EmailFrom,
})
handler, err := handlers.New(database, emailSvc, cfg)
if err != nil {
log.Fatalf("Failed to initialize handlers: %v", err)
}
r := gin.Default()
r.Use(cors.New(cors.Config{
AllowOrigins: []string{cfg.FrontendURL, "http://localhost:3000", "http://localhost:5173"},
AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
AllowHeaders: []string{"Origin", "Content-Type", "Accept", "Authorization"},
ExposeHeaders: []string{"Content-Length"},
AllowCredentials: true,
MaxAge: 12 * time.Hour,
}))
r.GET("/health", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "healthy", "service": "auth"})
})
handler.RegisterRoutes(r)
srv := &http.Server{
Addr: ":" + cfg.Port,
Handler: r,
}
go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("Failed to start server: %v", err)
}
}()
log.Printf("Auth service running on port %s", cfg.Port)
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("Shutting down server...")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Printf("Server forced to shutdown: %v", err)
}
log.Println("Server exited")
}
func runMigrations(databaseURL string) error {
db, err := goose.OpenDBWithDriver("pgx", databaseURL)
if err != nil {
return fmt.Errorf("goose open db: %w", err)
}
defer db.Close()
if err := goose.SetDialect("postgres"); err != nil {
return fmt.Errorf("goose set dialect: %w", err)
}
if err := goose.Up(db, "migrations"); err != nil {
return fmt.Errorf("goose up: %w", err)
}
return nil
}
+59
View File
@@ -0,0 +1,59 @@
module bookra/apps/auth-service
go 1.26.2
require (
github.com/gin-contrib/cors v1.7.7
github.com/gin-gonic/gin v1.12.0
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.9.1
github.com/joho/godotenv v1.5.1
github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible
github.com/pressly/goose/v3 v3.27.0
github.com/stripe/stripe-go/v83 v83.2.1
golang.org/x/crypto v0.50.0
golang.org/x/oauth2 v0.36.0
)
require (
cloud.google.com/go/compute/metadata v0.3.0 // indirect
github.com/MicahParks/jwkset v0.11.0 // indirect
github.com/MicahParks/keyfunc/v3 v3.8.0 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mfridman/interpolate v0.0.2 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/arch v0.23.0 // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/text v0.36.0 // indirect
golang.org/x/time v0.9.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
)
+146
View File
@@ -0,0 +1,146 @@
cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc=
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
github.com/MicahParks/jwkset v0.11.0 h1:yc0zG+jCvZpWgFDFmvs8/8jqqVBG9oyIbmBtmjOhoyQ=
github.com/MicahParks/jwkset v0.11.0/go.mod h1:U2oRhRaLgDCLjtpGL2GseNKGmZtLs/3O7p+OZaL5vo0=
github.com/MicahParks/keyfunc/v3 v3.8.0 h1:Hx2dgIjAXGk9slakM6rV9BOeaWDPEXXZ4Us8guNBfds=
github.com/MicahParks/keyfunc/v3 v3.8.0/go.mod h1:z66bkCviwqfg2YUp+Jcc/xRE9IXLcMq6DrgV/+Htru0=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gin-contrib/cors v1.7.7 h1:Oh9joP463x7Mw72vhvJ61YQm8ODh9b04YR7vsOErD0Q=
github.com/gin-contrib/cors v1.7.7/go.mod h1:K5tW0RkzJtWSiOdikXloy8VEZlgdVNpHNw8FpjUPNrE=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.9.1 h1:uwrxJXBnx76nyISkhr33kQLlUqjv7et7b9FjCen/tdc=
github.com/jackc/pgx/v5 v5.9.1/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible h1:jdpOPRN1zP63Td1hDQbZW73xKmzDvZHzVdNYxhnTMDA=
github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible/go.mod h1:1c7szIrayyPPB/987hsnvNzLushdWf4o/79s3P08L8A=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pressly/goose/v3 v3.27.0 h1:/D30gVTuQhu0WsNZYbJi4DMOsx1lNq+6SkLe+Wp59BM=
github.com/pressly/goose/v3 v3.27.0/go.mod h1:3ZBeCXqzkgIRvrEMDkYh1guvtoJTU5oMMuDdkutoM78=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/stripe/stripe-go/v83 v83.2.1 h1:8WPhpMjr8VyMWKUsCMoVvlWxYazuL5edajKX/RulfbA=
github.com/stripe/stripe-go/v83 v83.2.1/go.mod h1:nRyDcLrJtwPPQUnKAFs9Bt1NnQvNhNiF6V19XHmPISE=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg=
golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/libc v1.68.0 h1:PJ5ikFOV5pwpW+VqCK1hKJuEWsonkIJhhIXyuF/91pQ=
modernc.org/libc v1.68.0/go.mod h1:NnKCYeoYgsEqnY3PgvNgAeaJnso968ygU8Z0DxjoEc0=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
+79
View File
@@ -0,0 +1,79 @@
package auth
import (
"context"
"errors"
"fmt"
"net/url"
"strings"
"time"
"github.com/MicahParks/keyfunc/v3"
"github.com/golang-jwt/jwt/v5"
)
type NeonVerifier struct {
jwks keyfunc.Keyfunc
expectedIssuer string
enabled bool
cancel context.CancelFunc
}
func NewNeonVerifier(neonAuthURL string) (*NeonVerifier, error) {
trimmed := strings.TrimRight(strings.TrimSpace(neonAuthURL), "/")
if trimmed == "" {
return &NeonVerifier{enabled: false}, nil
}
parsed, err := url.Parse(trimmed)
if err != nil {
return nil, fmt.Errorf("parse neon auth url: %w", err)
}
expectedIssuer := fmt.Sprintf("%s://%s", parsed.Scheme, parsed.Host)
jwksURL := fmt.Sprintf("%s/.well-known/jwks.json", trimmed)
ctx, cancel := context.WithCancel(context.Background())
jwks, err := keyfunc.NewDefaultCtx(ctx, []string{jwksURL})
if err != nil {
cancel()
return nil, fmt.Errorf("create neon jwks: %w", err)
}
return &NeonVerifier{jwks: jwks, expectedIssuer: expectedIssuer, enabled: true, cancel: cancel}, nil
}
func (v *NeonVerifier) Enabled() bool {
return v != nil && v.enabled
}
func (v *NeonVerifier) Close() {
if v != nil && v.cancel != nil {
v.cancel()
}
}
func (v *NeonVerifier) Verify(tokenString string) (*Claims, error) {
if !v.Enabled() {
return nil, errors.New("neon auth verifier is disabled")
}
token, err := jwt.Parse(tokenString, v.jwks.Keyfunc,
jwt.WithIssuer(v.expectedIssuer),
jwt.WithValidMethods([]string{"EdDSA"}),
jwt.WithAudience(v.expectedIssuer),
jwt.WithLeeway(15*time.Second),
)
if err != nil {
return nil, err
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok || !token.Valid {
return nil, errors.New("invalid neon claims")
}
subject, _ := claims["sub"].(string)
email, _ := claims["email"].(string)
name, _ := claims["name"].(string)
if name == "" {
name, _ = claims["display_name"].(string)
}
if strings.TrimSpace(subject) == "" {
return nil, errors.New("missing neon subject")
}
return &Claims{UserID: subject, Email: email, Name: name, Role: "authenticated", Type: "access"}, nil
}
+333
View File
@@ -0,0 +1,333 @@
package auth
import (
"context"
"crypto/rand"
"encoding/base64"
"fmt"
"time"
"bookra/apps/auth-service/internal/db"
"bookra/apps/auth-service/internal/email"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
)
const (
accessTokenTTL = 24 * time.Hour
refreshTokenTTL = 30 * 24 * time.Hour
)
type Service struct {
db *db.DB
email *email.Service
jwtSecret []byte
frontendURL string
}
type TokenPair struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token,omitempty"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
}
type Claims struct {
UserID string `json:"sub"`
Email string `json:"email"`
Name string `json:"name,omitempty"`
Role string `json:"role,omitempty"`
Type string `json:"type"`
jwt.RegisteredClaims
}
func NewService(database *db.DB, emailSvc *email.Service, jwtSecret string, frontendURL string) *Service {
return &Service{
db: database,
email: emailSvc,
jwtSecret: []byte(jwtSecret),
frontendURL: frontendURL,
}
}
func (s *Service) GenerateMagicLink(ctx context.Context, emailAddr string, locale string) error {
user, err := s.db.GetUserByEmail(ctx, emailAddr)
if err != nil {
return fmt.Errorf("get user: %w", err)
}
if user == nil {
user = &db.User{
Email: emailAddr,
Provider: "email",
}
user, err = s.db.CreateUser(ctx, user)
if err != nil {
return fmt.Errorf("create user: %w", err)
}
}
token := generateRandomToken(32)
expiresAt := time.Now().Add(15 * time.Minute)
if err := s.db.CreateMagicLink(ctx, token, emailAddr, user.ID, expiresAt); err != nil {
return fmt.Errorf("create magic link: %w", err)
}
magicURL := fmt.Sprintf("%s/auth/callback?token=%s", s.frontendURL, token)
var name string
if user.Name != nil {
name = *user.Name
}
if err := s.email.SendMagicLink(emailAddr, name, magicURL, locale); err != nil {
return fmt.Errorf("send email: %w", err)
}
return nil
}
func (s *Service) VerifyMagicLink(ctx context.Context, token string) (*TokenPair, error) {
ml, err := s.db.GetMagicLink(ctx, token)
if err != nil {
return nil, fmt.Errorf("get magic link: %w", err)
}
if ml == nil || ml.Used {
return nil, fmt.Errorf("invalid or used token")
}
if time.Now().After(ml.ExpiresAt) {
return nil, fmt.Errorf("token expired")
}
if err := s.db.MarkMagicLinkUsed(ctx, token); err != nil {
return nil, fmt.Errorf("mark used: %w", err)
}
user, err := s.db.GetUserByID(ctx, ml.UserID)
if err != nil {
return nil, fmt.Errorf("get user: %w", err)
}
if user == nil {
return nil, fmt.Errorf("user not found")
}
if err := s.db.UpdateLastLogin(ctx, user.ID); err != nil {
return nil, fmt.Errorf("update login: %w", err)
}
return s.generateTokens(user)
}
func (s *Service) OAuthLoginOrCreate(ctx context.Context, provider, providerID, email, name string) (*TokenPair, error) {
user, err := s.db.GetUserByProviderID(ctx, provider, providerID)
if err != nil {
return nil, fmt.Errorf("get user by provider: %w", err)
}
if user == nil {
existing, err := s.db.GetUserByEmail(ctx, email)
if err != nil {
return nil, fmt.Errorf("check existing email: %w", err)
}
if existing != nil {
existing.Provider = provider
existing.ProviderID = &providerID
existing.Name = &name
existing.EmailVerified = true
if err := s.db.UpdateUser(ctx, existing); err != nil {
return nil, fmt.Errorf("link provider: %w", err)
}
user = existing
} else {
user = &db.User{
Email: email,
Name: &name,
Provider: provider,
ProviderID: &providerID,
EmailVerified: true,
}
user, err = s.db.CreateUser(ctx, user)
if err != nil {
return nil, fmt.Errorf("create oauth user: %w", err)
}
}
}
if err := s.db.UpdateLastLogin(ctx, user.ID); err != nil {
return nil, fmt.Errorf("update login: %w", err)
}
return s.generateTokens(user)
}
func (s *Service) RegisterWithPassword(ctx context.Context, email, password, name string) (*TokenPair, error) {
existing, err := s.db.GetUserByEmail(ctx, email)
if err != nil {
return nil, fmt.Errorf("check existing: %w", err)
}
if existing != nil {
return nil, fmt.Errorf("email already registered")
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return nil, fmt.Errorf("hash password: %w", err)
}
hashStr := string(hash)
user := &db.User{
Email: email,
Name: &name,
PasswordHash: &hashStr,
Provider: "email",
EmailVerified: false,
}
user, err = s.db.CreateUser(ctx, user)
if err != nil {
return nil, fmt.Errorf("create user: %w", err)
}
return s.generateTokens(user)
}
func (s *Service) LoginWithPassword(ctx context.Context, email, password string) (*TokenPair, error) {
user, err := s.db.GetUserByEmail(ctx, email)
if err != nil {
return nil, fmt.Errorf("get user: %w", err)
}
if user == nil || user.PasswordHash == nil {
return nil, fmt.Errorf("invalid credentials")
}
if err := bcrypt.CompareHashAndPassword([]byte(*user.PasswordHash), []byte(password)); err != nil {
return nil, fmt.Errorf("invalid credentials")
}
if err := s.db.UpdateLastLogin(ctx, user.ID); err != nil {
return nil, fmt.Errorf("update login: %w", err)
}
return s.generateTokens(user)
}
func (s *Service) generateTokens(user *db.User) (*TokenPair, error) {
now := time.Now()
return s.generateTokensAt(user, now)
}
func (s *Service) generateTokensAt(user *db.User, now time.Time) (*TokenPair, error) {
name := ""
if user.Name != nil {
name = *user.Name
}
accessTokenString, err := s.signToken(user, name, "access", now, accessTokenTTL)
if err != nil {
return nil, fmt.Errorf("sign access token: %w", err)
}
refreshTokenString, err := s.signToken(user, name, "refresh", now, refreshTokenTTL)
if err != nil {
return nil, fmt.Errorf("sign refresh token: %w", err)
}
return &TokenPair{
AccessToken: accessTokenString,
RefreshToken: refreshTokenString,
TokenType: "Bearer",
ExpiresIn: int(accessTokenTTL.Seconds()),
}, nil
}
func (s *Service) VerifyToken(tokenString string) (*Claims, error) {
return s.verifyTokenOfType(tokenString, "access")
}
func (s *Service) VerifyRefreshToken(tokenString string) (*Claims, error) {
return s.verifyTokenOfType(tokenString, "refresh")
}
func (s *Service) RefreshTokens(ctx context.Context, refreshToken string) (*TokenPair, error) {
claims, err := s.VerifyRefreshToken(refreshToken)
if err != nil {
return nil, err
}
user := &db.User{
ID: uuid.MustParse(claims.UserID),
Email: claims.Email,
}
if claims.Name != "" {
user.Name = &claims.Name
}
if s.db != nil {
storedUser, err := s.db.GetUserByID(ctx, user.ID)
if err != nil {
return nil, fmt.Errorf("get user: %w", err)
}
if storedUser == nil {
return nil, fmt.Errorf("user not found")
}
user = storedUser
}
return s.generateTokens(user)
}
func (s *Service) verifyTokenOfType(tokenString string, expectedType string) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return s.jwtSecret, nil
})
if err != nil {
return nil, err
}
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
if claims.Type != expectedType {
return nil, fmt.Errorf("invalid token type")
}
return claims, nil
}
return nil, fmt.Errorf("invalid token")
}
func (s *Service) signToken(user *db.User, name string, tokenType string, now time.Time, ttl time.Duration) (string, error) {
claims := Claims{
UserID: user.ID.String(),
Email: user.Email,
Name: name,
Role: "authenticated",
Type: tokenType,
RegisteredClaims: jwt.RegisteredClaims{
Issuer: "bookra-auth",
Subject: user.ID.String(),
Audience: jwt.ClaimStrings{"bookra"},
ID: generateRandomToken(12),
ExpiresAt: jwt.NewNumericDate(now.Add(ttl)),
IssuedAt: jwt.NewNumericDate(now),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(s.jwtSecret)
}
func generateRandomToken(length int) string {
b := make([]byte, length)
rand.Read(b)
return base64.URLEncoding.EncodeToString(b)
}
@@ -0,0 +1,88 @@
package auth
import (
"context"
"testing"
"time"
"bookra/apps/auth-service/internal/db"
"github.com/google/uuid"
)
func TestGenerateTokensProducesVerifiableAccessAndRefreshTokens(t *testing.T) {
service := NewService(nil, nil, "test-secret", "http://localhost:3000")
name := "Token Tester"
user := &db.User{
ID: uuid.MustParse("019daeaa-bc14-7712-9224-e347a96bd5c3"),
Email: "tester@bookra.dev",
Name: &name,
}
tokens, err := service.generateTokensAt(user, time.Now().UTC())
if err != nil {
t.Fatalf("generate tokens: %v", err)
}
accessClaims, err := service.VerifyToken(tokens.AccessToken)
if err != nil {
t.Fatalf("verify access token: %v", err)
}
if accessClaims.Type != "access" {
t.Fatalf("expected access type, got %s", accessClaims.Type)
}
refreshClaims, err := service.VerifyRefreshToken(tokens.RefreshToken)
if err != nil {
t.Fatalf("verify refresh token: %v", err)
}
if refreshClaims.Type != "refresh" {
t.Fatalf("expected refresh type, got %s", refreshClaims.Type)
}
if _, err := service.VerifyToken(tokens.RefreshToken); err == nil {
t.Fatal("expected refresh token to fail access verification")
}
if _, err := service.VerifyRefreshToken(tokens.AccessToken); err == nil {
t.Fatal("expected access token to fail refresh verification")
}
}
func TestRefreshTokensReturnsRotatedPair(t *testing.T) {
service := NewService(nil, nil, "test-secret", "http://localhost:3000")
user := &db.User{
ID: uuid.MustParse("019daeaa-bc14-7712-9224-e347a96bd5c3"),
Email: "tester@bookra.dev",
}
original, err := service.generateTokens(user)
if err != nil {
t.Fatalf("generate tokens: %v", err)
}
refreshed, err := service.RefreshTokens(context.Background(), original.RefreshToken)
if err != nil {
t.Fatalf("refresh tokens: %v", err)
}
if refreshed.AccessToken == original.AccessToken {
t.Fatal("expected rotated access token")
}
if refreshed.RefreshToken == original.RefreshToken {
t.Fatal("expected rotated refresh token")
}
if _, err := service.VerifyToken(refreshed.AccessToken); err != nil {
t.Fatalf("verify refreshed access token: %v", err)
}
if _, err := service.VerifyRefreshToken(refreshed.RefreshToken); err != nil {
t.Fatalf("verify refreshed refresh token: %v", err)
}
}
func TestRefreshTokensRejectsInvalidToken(t *testing.T) {
service := NewService(nil, nil, "test-secret", "http://localhost:3000")
if _, err := service.RefreshTokens(context.Background(), "bad-token"); err == nil {
t.Fatal("expected invalid refresh token error")
}
}
@@ -0,0 +1,464 @@
package billing
import (
"context"
"encoding/json"
"errors"
"fmt"
"slices"
"strings"
"time"
"bookra/apps/auth-service/internal/config"
"bookra/apps/auth-service/internal/db"
"github.com/stripe/stripe-go/v83"
"github.com/stripe/stripe-go/v83/checkout/session"
"github.com/stripe/stripe-go/v83/customer"
"github.com/stripe/stripe-go/v83/subscription"
"github.com/stripe/stripe-go/v83/webhook"
)
var (
ErrStripeNotConfigured = errors.New("stripe is not configured")
ErrStripeWebhookMissing = errors.New("stripe webhook secret is not configured")
ErrStripeSignatureMissing = errors.New("stripe signature is missing")
ErrPlanNotConfigured = errors.New("stripe plan is not configured")
ErrCustomerMappingNotFound = errors.New("stripe customer mapping not found")
)
var allowedWebhookEvents = []string{
"checkout.session.completed",
"customer.subscription.created",
"customer.subscription.updated",
"customer.subscription.deleted",
"customer.subscription.paused",
"customer.subscription.resumed",
"invoice.paid",
"invoice.payment_failed",
"payment_intent.succeeded",
"payment_intent.payment_failed",
}
type Service struct {
cfg *config.Config
db *db.DB
}
type CheckoutSession struct {
URL string `json:"url"`
}
type SubscriptionSnapshot struct {
CustomerID string `json:"customerId,omitempty"`
SubscriptionID string `json:"subscriptionId,omitempty"`
Status string `json:"status"`
PlanCode string `json:"planCode,omitempty"`
Currency string `json:"currency,omitempty"`
PriceID string `json:"priceId,omitempty"`
CancelAtPeriodEnd bool `json:"cancelAtPeriodEnd"`
CurrentPeriodStart *time.Time `json:"currentPeriodStart,omitempty"`
CurrentPeriodEnd *time.Time `json:"currentPeriodEnd,omitempty"`
PaymentMethod *PaymentMethod `json:"paymentMethod,omitempty"`
LastSyncedAt *time.Time `json:"lastSyncedAt,omitempty"`
CheckoutURLAvailable bool `json:"checkoutUrlAvailable"`
SyncAvailable bool `json:"syncAvailable"`
}
type PaymentMethod struct {
Brand string `json:"brand"`
Last4 string `json:"last4"`
}
type UserIdentity struct {
ID string
Email string
Name string
}
type userCustomerMapping struct {
CustomerID string `json:"customerId"`
UpdatedAt time.Time `json:"updatedAt"`
}
func NewService(cfg *config.Config, database *db.DB) *Service {
return &Service{cfg: cfg, db: database}
}
func (s *Service) GetSubscription(ctx context.Context, userID string) (SubscriptionSnapshot, error) {
mapping, ok, err := s.getCustomerMapping(ctx, userID)
if err != nil {
return SubscriptionSnapshot{}, err
}
if !ok {
return s.noneSnapshot(), nil
}
snapshot, ok, err := s.getCustomerSnapshot(ctx, mapping.CustomerID)
if err != nil {
return SubscriptionSnapshot{}, err
}
if !ok {
snapshot = SubscriptionSnapshot{
CustomerID: mapping.CustomerID,
Status: "none",
}
}
snapshot.CheckoutURLAvailable = s.checkoutAvailableForPlan(snapshot.PlanCode)
snapshot.SyncAvailable = s.cfg.StripeSecretConfigured()
return snapshot, nil
}
func (s *Service) CreateCheckoutSession(ctx context.Context, user UserIdentity, planCode string, currency string) (CheckoutSession, error) {
priceID, resolvedPlanCode, resolvedCurrency, err := s.priceForPlan(planCode, currency)
if err != nil {
return CheckoutSession{}, err
}
if s.cfg.StripeSecretKey == "" {
return CheckoutSession{}, ErrStripeNotConfigured
}
customerID, err := s.ensureCustomer(ctx, user)
if err != nil {
return CheckoutSession{}, err
}
stripe.Key = s.cfg.StripeSecretKey
params := &stripe.CheckoutSessionParams{
Mode: stripe.String(string(stripe.CheckoutSessionModeSubscription)),
Customer: stripe.String(customerID),
ClientReferenceID: stripe.String(user.ID),
SuccessURL: stripe.String(fmt.Sprintf("%s/dashboard?billing=success", strings.TrimRight(s.cfg.FrontendURL, "/"))),
CancelURL: stripe.String(fmt.Sprintf("%s/dashboard?billing=cancelled", strings.TrimRight(s.cfg.FrontendURL, "/"))),
LineItems: []*stripe.CheckoutSessionLineItemParams{
{
Price: stripe.String(priceID),
Quantity: stripe.Int64(1),
},
},
Metadata: map[string]string{
"user_id": user.ID,
"plan_code": resolvedPlanCode,
"currency": resolvedCurrency,
},
SubscriptionData: &stripe.CheckoutSessionSubscriptionDataParams{
TrialPeriodDays: stripe.Int64(30),
Metadata: map[string]string{
"user_id": user.ID,
"plan_code": resolvedPlanCode,
"currency": resolvedCurrency,
},
},
}
checkoutSession, err := session.New(params)
if err != nil {
return CheckoutSession{}, err
}
return CheckoutSession{URL: checkoutSession.URL}, nil
}
func (s *Service) Refresh(ctx context.Context, userID string) (SubscriptionSnapshot, error) {
mapping, ok, err := s.getCustomerMapping(ctx, userID)
if err != nil {
return SubscriptionSnapshot{}, err
}
if !ok {
return s.noneSnapshot(), nil
}
if s.cfg.StripeSecretKey == "" {
return SubscriptionSnapshot{}, ErrStripeNotConfigured
}
return s.syncStripeDataToKV(ctx, mapping.CustomerID)
}
func (s *Service) HandleWebhook(ctx context.Context, signature string, payload []byte) error {
if s.cfg.StripeSecretKey == "" {
return nil
}
if s.cfg.StripeWebhookSecret == "" {
return ErrStripeWebhookMissing
}
if signature == "" {
return ErrStripeSignatureMissing
}
event, err := webhook.ConstructEvent(payload, signature, s.cfg.StripeWebhookSecret)
if err != nil {
return err
}
if !slices.Contains(allowedWebhookEvents, string(event.Type)) {
return nil
}
customerID := extractCustomerID(event)
if customerID == "" {
return nil
}
_, err = s.syncStripeDataToKV(ctx, customerID)
return err
}
func (s *Service) ensureCustomer(ctx context.Context, user UserIdentity) (string, error) {
mapping, ok, err := s.getCustomerMapping(ctx, user.ID)
if err != nil {
return "", err
}
if ok && mapping.CustomerID != "" {
return mapping.CustomerID, nil
}
stripe.Key = s.cfg.StripeSecretKey
params := &stripe.CustomerParams{
Email: stripe.String(user.Email),
Metadata: map[string]string{
"user_id": user.ID,
},
}
if strings.TrimSpace(user.Name) != "" {
params.Name = stripe.String(strings.TrimSpace(user.Name))
}
createdCustomer, err := customer.New(params)
if err != nil {
return "", err
}
if err := s.storeCustomerMapping(ctx, user.ID, createdCustomer.ID); err != nil {
return "", err
}
return createdCustomer.ID, nil
}
func (s *Service) syncStripeDataToKV(ctx context.Context, customerID string) (SubscriptionSnapshot, error) {
stripe.Key = s.cfg.StripeSecretKey
params := &stripe.SubscriptionListParams{Customer: stripe.String(customerID)}
params.Status = stripe.String("all")
params.AddExpand("data.default_payment_method")
params.AddExpand("data.items.data.price")
iter := subscription.List(params)
selected := (*stripe.Subscription)(nil)
for iter.Next() {
current := iter.Subscription()
if selected == nil || subscriptionRank(current) > subscriptionRank(selected) {
selected = current
}
}
if iter.Err() != nil {
return SubscriptionSnapshot{}, iter.Err()
}
now := time.Now().UTC()
snapshot := SubscriptionSnapshot{
CustomerID: customerID,
Status: "none",
LastSyncedAt: &now,
CheckoutURLAvailable: s.cfg.StripeCheckoutReady(),
SyncAvailable: s.cfg.StripeSecretConfigured(),
}
if selected != nil {
snapshot.SubscriptionID = selected.ID
snapshot.Status = string(selected.Status)
snapshot.CancelAtPeriodEnd = selected.CancelAtPeriodEnd
if len(selected.Items.Data) > 0 {
item := selected.Items.Data[0]
if item.Price != nil {
snapshot.PriceID = item.Price.ID
snapshot.PlanCode = s.planCodeForPrice(snapshot.PriceID)
snapshot.Currency = normalizeCurrency(string(item.Price.Currency))
}
snapshot.CurrentPeriodStart = unixPtr(item.CurrentPeriodStart)
snapshot.CurrentPeriodEnd = unixPtr(item.CurrentPeriodEnd)
}
if selected.DefaultPaymentMethod != nil && selected.DefaultPaymentMethod.Card != nil {
snapshot.PaymentMethod = &PaymentMethod{
Brand: string(selected.DefaultPaymentMethod.Card.Brand),
Last4: selected.DefaultPaymentMethod.Card.Last4,
}
}
}
if err := s.db.PutKV(ctx, customerSnapshotKey(customerID), snapshot); err != nil {
return SubscriptionSnapshot{}, err
}
return snapshot, nil
}
func (s *Service) storeCustomerMapping(ctx context.Context, userID string, customerID string) error {
mapping := userCustomerMapping{
CustomerID: customerID,
UpdatedAt: time.Now().UTC(),
}
return s.db.PutKV(ctx, userCustomerKey(userID), mapping)
}
func (s *Service) getCustomerMapping(ctx context.Context, userID string) (userCustomerMapping, bool, error) {
var mapping userCustomerMapping
ok, err := s.db.GetKV(ctx, userCustomerKey(userID), &mapping)
if err != nil {
return userCustomerMapping{}, false, err
}
if !ok || mapping.CustomerID == "" {
return userCustomerMapping{}, false, nil
}
return mapping, true, nil
}
func (s *Service) getCustomerSnapshot(ctx context.Context, customerID string) (SubscriptionSnapshot, bool, error) {
var snapshot SubscriptionSnapshot
ok, err := s.db.GetKV(ctx, customerSnapshotKey(customerID), &snapshot)
if err != nil {
return SubscriptionSnapshot{}, false, err
}
return snapshot, ok, nil
}
func (s *Service) noneSnapshot() SubscriptionSnapshot {
return SubscriptionSnapshot{
Status: "none",
CheckoutURLAvailable: s.cfg.StripeCheckoutReady(),
SyncAvailable: s.cfg.StripeSecretConfigured(),
}
}
func (s *Service) priceForPlan(planCode string, currency string) (string, string, string, error) {
planCode = normalizePlanCode(strings.TrimSpace(planCode))
if planCode == "" {
planCode = s.defaultPlanCode()
}
if planCode == "" {
return "", "", "", ErrPlanNotConfigured
}
resolvedCurrency := normalizeCurrency(currency)
priceID := strings.TrimSpace(s.cfg.StripePriceIDs[planCode+":"+resolvedCurrency])
if priceID == "" && resolvedCurrency != "czk" {
priceID = strings.TrimSpace(s.cfg.StripePriceIDs[planCode+":czk"])
if priceID != "" {
resolvedCurrency = "czk"
}
}
if priceID == "" {
priceID = strings.TrimSpace(s.cfg.StripePriceIDs[planCode])
}
if priceID == "" {
switch planCode {
case "pro":
priceID = strings.TrimSpace(s.cfg.StripePriceIDs["growth"])
case "business":
priceID = strings.TrimSpace(s.cfg.StripePriceIDs["multi-location"])
}
}
if priceID == "" {
return "", "", "", ErrPlanNotConfigured
}
return priceID, planCode, resolvedCurrency, nil
}
func (s *Service) defaultPlanCode() string {
for _, planCode := range []string{"pro", "monthly", "growth", "starter", "business", "multi-location"} {
if strings.TrimSpace(s.cfg.StripePriceIDs[planCode]) != "" {
return normalizePlanCode(planCode)
}
if strings.TrimSpace(s.cfg.StripePriceIDs[normalizePlanCode(planCode)+":czk"]) != "" {
return normalizePlanCode(planCode)
}
}
return ""
}
func (s *Service) planCodeForPrice(priceID string) string {
for planCode, configuredPriceID := range s.cfg.StripePriceIDs {
if strings.TrimSpace(configuredPriceID) == priceID {
return normalizePlanCode(strings.Split(planCode, ":")[0])
}
}
return ""
}
func (s *Service) hasConfiguredPrices() bool {
return s.defaultPlanCode() != ""
}
func (s *Service) checkoutAvailableForPlan(planCode string) bool {
if !s.cfg.StripeSecretConfigured() {
return false
}
if strings.TrimSpace(planCode) == "" {
return s.hasConfiguredPrices()
}
_, _, _, err := s.priceForPlan(planCode, "czk")
return err == nil
}
func normalizePlanCode(planCode string) string {
switch planCode {
case "growth":
return "pro"
case "multi-location":
return "business"
default:
return planCode
}
}
func normalizeCurrency(currency string) string {
switch strings.ToLower(strings.TrimSpace(currency)) {
case "usd":
return "usd"
default:
return "czk"
}
}
func userCustomerKey(userID string) string {
return "stripe:user:" + userID
}
func customerSnapshotKey(customerID string) string {
return "stripe:customer:" + customerID
}
func unixPtr(value int64) *time.Time {
if value == 0 {
return nil
}
t := time.Unix(value, 0).UTC()
return &t
}
func subscriptionRank(subscription *stripe.Subscription) int {
switch subscription.Status {
case stripe.SubscriptionStatusActive:
return 100
case stripe.SubscriptionStatusTrialing:
return 90
case stripe.SubscriptionStatusPastDue:
return 80
case stripe.SubscriptionStatusUnpaid:
return 70
case stripe.SubscriptionStatusIncomplete:
return 60
case stripe.SubscriptionStatusPaused:
return 50
case stripe.SubscriptionStatusCanceled:
return 10
default:
return 0
}
}
func extractCustomerID(event stripe.Event) string {
var payload map[string]any
if err := json.Unmarshal(event.Data.Raw, &payload); err != nil {
return ""
}
value, ok := payload["customer"]
if !ok {
return ""
}
customerID, _ := value.(string)
return customerID
}
@@ -0,0 +1,75 @@
package billing
import (
"errors"
"testing"
"bookra/apps/auth-service/internal/config"
)
func TestPriceForPlanUsesConfiguredPlanCodesOnly(t *testing.T) {
service := NewService(&config.Config{
StripePriceIDs: map[string]string{
"monthly": "price_monthly",
"growth": "price_growth",
},
}, nil)
priceID, planCode, currency, err := service.priceForPlan("growth", "czk")
if err != nil {
t.Fatalf("price for plan: %v", err)
}
if priceID != "price_growth" || planCode != "pro" || currency != "czk" {
t.Fatalf("expected pro mapping, got price=%q plan=%q currency=%q", priceID, planCode, currency)
}
priceID, planCode, currency, err = service.priceForPlan("", "usd")
if err != nil {
t.Fatalf("default price for plan: %v", err)
}
if priceID != "price_monthly" || planCode != "monthly" || currency != "usd" {
t.Fatalf("expected monthly default, got price=%q plan=%q currency=%q", priceID, planCode, currency)
}
_, _, _, err = service.priceForPlan("price_attacker_controlled", "czk")
if !errors.Is(err, ErrPlanNotConfigured) {
t.Fatalf("expected ErrPlanNotConfigured, got %v", err)
}
}
func TestKVKeyShape(t *testing.T) {
if got := userCustomerKey("user_123"); got != "stripe:user:user_123" {
t.Fatalf("unexpected user key: %s", got)
}
if got := customerSnapshotKey("cus_123"); got != "stripe:customer:cus_123" {
t.Fatalf("unexpected customer key: %s", got)
}
}
func TestCheckoutAvailableForPlanRequiresSecret(t *testing.T) {
service := NewService(&config.Config{
StripePriceIDs: map[string]string{
"pro:czk": "price_pro_czk",
},
}, nil)
if service.checkoutAvailableForPlan("pro") {
t.Fatal("expected checkout unavailable without stripe secret")
}
}
func TestCheckoutAvailableForPlanRequiresConfiguredPlan(t *testing.T) {
service := NewService(&config.Config{
StripeSecretKey: "sk_test_123",
StripePriceIDs: map[string]string{
"pro:czk": "price_pro_czk",
},
}, nil)
if !service.checkoutAvailableForPlan("pro") {
t.Fatal("expected pro checkout available")
}
if service.checkoutAvailableForPlan("business") {
t.Fatal("expected business checkout unavailable without configured price")
}
}
+113
View File
@@ -0,0 +1,113 @@
package config
import (
"fmt"
"os"
"strconv"
"strings"
)
type Config struct {
AppEnv string
Port string
DatabaseURL string
FrontendURL string
JWTSecret string
NeonAuthURL string
SMTPHost string
SMTPPort int
SMTPUsername string
SMTPPassword string
EmailFrom string
GoogleClientID string
GoogleClientSecret string
GoogleRedirectURL string
StripeSecretKey string
StripeWebhookSecret string
StripePriceIDs map[string]string
}
func Load() (*Config, error) {
port := getEnv("PORT", "8081")
dbURL := getEnv("DATABASE_URL", "")
if dbURL == "" {
return nil, fmt.Errorf("DATABASE_URL is required")
}
smtpPort, _ := strconv.Atoi(getEnv("SMTP_PORT", "465"))
return &Config{
AppEnv: getEnv("APP_ENV", "development"),
Port: port,
DatabaseURL: dbURL,
FrontendURL: getEnv("FRONTEND_URL", "http://localhost:3000"),
JWTSecret: getEnv("JWT_SECRET", "change-me-in-production"),
NeonAuthURL: getEnv("NEON_AUTH_URL", ""),
SMTPHost: getEnv("SMTP_HOST", "smtp.purelymail.com"),
SMTPPort: smtpPort,
SMTPUsername: getEnvAllowEmpty("SMTP_USERNAME", "noreply@tdvorak.dev"),
SMTPPassword: getEnv("SMTP_PASSWORD", ""),
EmailFrom: getEnv("EMAIL_FROM", "noreply@tdvorak.dev"),
GoogleClientID: getEnv("GOOGLE_CLIENT_ID", ""),
GoogleClientSecret: getEnv("GOOGLE_CLIENT_SECRET", ""),
GoogleRedirectURL: getEnv("GOOGLE_REDIRECT_URL", ""),
StripeSecretKey: getEnv("STRIPE_SECRET_KEY", ""),
StripeWebhookSecret: getEnv("STRIPE_WEBHOOK_SECRET", ""),
StripePriceIDs: map[string]string{
"monthly": getEnv("STRIPE_PRICE_ID", ""),
"starter": getEnv("STRIPE_STARTER_PRICE_ID", ""),
"growth": getEnv("STRIPE_GROWTH_PRICE_ID", ""),
"multi-location": getEnv("STRIPE_MULTI_LOCATION_PRICE_ID", ""),
"pro": getEnv("STRIPE_PRO_PRICE_ID", ""),
"business": getEnv("STRIPE_BUSINESS_PRICE_ID", ""),
"starter:czk": getEnv("STRIPE_STARTER_CZK_PRICE_ID", ""),
"starter:usd": getEnv("STRIPE_STARTER_USD_PRICE_ID", ""),
"pro:czk": getEnv("STRIPE_PRO_CZK_PRICE_ID", ""),
"pro:usd": getEnv("STRIPE_PRO_USD_PRICE_ID", ""),
"business:czk": getEnv("STRIPE_BUSINESS_CZK_PRICE_ID", ""),
"business:usd": getEnv("STRIPE_BUSINESS_USD_PRICE_ID", ""),
},
}, nil
}
func getEnv(key, defaultVal string) string {
if v := os.Getenv(key); v != "" {
return v
}
return defaultVal
}
func getEnvAllowEmpty(key, defaultVal string) string {
if v, ok := os.LookupEnv(key); ok {
return v
}
return defaultVal
}
func (cfg *Config) StripeSecretConfigured() bool {
return strings.TrimSpace(cfg.StripeSecretKey) != ""
}
func (cfg *Config) StripeWebhookConfigured() bool {
return strings.TrimSpace(cfg.StripeWebhookSecret) != ""
}
func (cfg *Config) StripeHasAnyPriceConfigured() bool {
for _, priceID := range cfg.StripePriceIDs {
if strings.TrimSpace(priceID) != "" {
return true
}
}
return false
}
func (cfg *Config) StripeCheckoutReady() bool {
return cfg.StripeSecretConfigured() && cfg.StripeHasAnyPriceConfigured()
}
@@ -0,0 +1,75 @@
package config
import (
"os"
"testing"
)
func TestStripeReadinessHelpers(t *testing.T) {
cfg := &Config{
StripeSecretKey: "sk_test_123",
StripePriceIDs: map[string]string{
"pro:czk": "price_123",
},
}
if !cfg.StripeSecretConfigured() {
t.Fatal("expected secret configured")
}
if cfg.StripeWebhookConfigured() {
t.Fatal("expected webhook not configured")
}
if !cfg.StripeHasAnyPriceConfigured() {
t.Fatal("expected prices configured")
}
if !cfg.StripeCheckoutReady() {
t.Fatal("expected checkout ready")
}
}
func TestStripeCheckoutReadyRequiresSecretAndPrice(t *testing.T) {
cfg := &Config{
StripePriceIDs: map[string]string{
"pro:czk": "price_123",
},
}
if cfg.StripeCheckoutReady() {
t.Fatal("expected checkout not ready without secret")
}
cfg.StripeSecretKey = "sk_test_123"
cfg.StripePriceIDs = map[string]string{}
if cfg.StripeCheckoutReady() {
t.Fatal("expected checkout not ready without price")
}
}
func TestLoadDefaultsAuthServicePortTo8081(t *testing.T) {
originals := map[string]string{}
for _, key := range []string{
"PORT",
"DATABASE_URL",
} {
originals[key] = os.Getenv(key)
}
t.Cleanup(func() {
for key, value := range originals {
if value == "" {
_ = os.Unsetenv(key)
continue
}
_ = os.Setenv(key, value)
}
})
_ = os.Unsetenv("PORT")
_ = os.Setenv("DATABASE_URL", "postgresql://localhost/bookra")
cfg, err := Load()
if err != nil {
t.Fatalf("load config: %v", err)
}
if cfg.Port != "8081" {
t.Fatalf("expected default port 8081, got %s", cfg.Port)
}
}
+140
View File
@@ -0,0 +1,140 @@
package db
import (
"context"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
type DB struct {
pool *pgxpool.Pool
}
func New(databaseURL string) (*DB, error) {
config, err := pgxpool.ParseConfig(databaseURL)
if err != nil {
return nil, fmt.Errorf("parse database config: %w", err)
}
pool, err := pgxpool.NewWithConfig(context.Background(), config)
if err != nil {
return nil, fmt.Errorf("create pool: %w", err)
}
if err := pool.Ping(context.Background()); err != nil {
return nil, fmt.Errorf("ping database: %w", err)
}
return &DB{pool: pool}, nil
}
func (db *DB) Close() {
db.pool.Close()
}
func (db *DB) Pool() *pgxpool.Pool {
return db.pool
}
func (db *DB) QueryRow(ctx context.Context, sql string, args ...interface{}) pgx.Row {
return db.pool.QueryRow(ctx, sql, args...)
}
func (db *DB) Query(ctx context.Context, sql string, args ...interface{}) (pgx.Rows, error) {
return db.pool.Query(ctx, sql, args...)
}
func (db *DB) Exec(ctx context.Context, sql string, args ...interface{}) error {
_, err := db.pool.Exec(ctx, sql, args...)
return err
}
// Stats contains database statistics for the admin dashboard
type Stats struct {
TotalUsers int64 `json:"totalUsers"`
UsersToday int64 `json:"usersToday"`
UsersThisWeek int64 `json:"usersThisWeek"`
UsersThisMonth int64 `json:"usersThisMonth"`
ActiveUsers7Days int64 `json:"activeUsers7Days"`
ActiveUsers30Days int64 `json:"activeUsers30Days"`
MagicLinksSent int64 `json:"magicLinksSent"`
MagicLinksUsed int64 `json:"magicLinksUsed"`
MagicLinksPending int64 `json:"magicLinksPending"`
OAuthUsers int64 `json:"oauthUsers"`
PasswordUsers int64 `json:"passwordUsers"`
}
// GetStats returns database statistics for the admin dashboard
func (db *DB) GetStats(ctx context.Context) (*Stats, error) {
stats := &Stats{}
// Total users
err := db.pool.QueryRow(ctx, `SELECT COUNT(*) FROM users`).Scan(&stats.TotalUsers)
if err != nil {
return nil, err
}
// Users created today
err = db.pool.QueryRow(ctx, `SELECT COUNT(*) FROM users WHERE created_at >= CURRENT_DATE`).Scan(&stats.UsersToday)
if err != nil {
return nil, err
}
// Users created this week
err = db.pool.QueryRow(ctx, `SELECT COUNT(*) FROM users WHERE created_at >= CURRENT_DATE - INTERVAL '7 days'`).Scan(&stats.UsersThisWeek)
if err != nil {
return nil, err
}
// Users created this month
err = db.pool.QueryRow(ctx, `SELECT COUNT(*) FROM users WHERE created_at >= CURRENT_DATE - INTERVAL '30 days'`).Scan(&stats.UsersThisMonth)
if err != nil {
return nil, err
}
// Active users (logged in) in last 7 days
err = db.pool.QueryRow(ctx, `SELECT COUNT(*) FROM users WHERE last_login_at >= CURRENT_DATE - INTERVAL '7 days'`).Scan(&stats.ActiveUsers7Days)
if err != nil {
return nil, err
}
// Active users in last 30 days
err = db.pool.QueryRow(ctx, `SELECT COUNT(*) FROM users WHERE last_login_at >= CURRENT_DATE - INTERVAL '30 days'`).Scan(&stats.ActiveUsers30Days)
if err != nil {
return nil, err
}
// Magic links sent (total)
err = db.pool.QueryRow(ctx, `SELECT COUNT(*) FROM magic_links`).Scan(&stats.MagicLinksSent)
if err != nil {
return nil, err
}
// Magic links used
err = db.pool.QueryRow(ctx, `SELECT COUNT(*) FROM magic_links WHERE used = TRUE`).Scan(&stats.MagicLinksUsed)
if err != nil {
return nil, err
}
// Pending magic links
err = db.pool.QueryRow(ctx, `SELECT COUNT(*) FROM magic_links WHERE used = FALSE AND expires_at > NOW()`).Scan(&stats.MagicLinksPending)
if err != nil {
return nil, err
}
// OAuth users
err = db.pool.QueryRow(ctx, `SELECT COUNT(*) FROM users WHERE provider != 'email'`).Scan(&stats.OAuthUsers)
if err != nil {
return nil, err
}
// Password users
err = db.pool.QueryRow(ctx, `SELECT COUNT(*) FROM users WHERE password_hash IS NOT NULL`).Scan(&stats.PasswordUsers)
if err != nil {
return nil, err
}
return stats, nil
}
+224
View File
@@ -0,0 +1,224 @@
package db
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
type User struct {
ID uuid.UUID `json:"id"`
Email string `json:"email"`
Name *string `json:"name,omitempty"`
PasswordHash *string `json:"-"`
EmailVerified bool `json:"email_verified"`
Provider string `json:"provider"`
ProviderID *string `json:"provider_id,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
LastLoginAt *time.Time `json:"last_login_at,omitempty"`
}
type MagicLink struct {
Token string `json:"token"`
UserID uuid.UUID `json:"user_id"`
Email string `json:"email"`
Used bool `json:"used"`
ExpiresAt time.Time `json:"expires_at"`
CreatedAt time.Time `json:"created_at"`
}
func (db *DB) GetUserByEmail(ctx context.Context, email string) (*User, error) {
var user User
var name, passwordHash, providerID *string
var lastLoginAt *time.Time
err := db.QueryRow(ctx, `
SELECT id, email, name, password_hash, email_verified, provider, provider_id, created_at, updated_at, last_login_at
FROM users
WHERE email = $1
`, email).Scan(
&user.ID, &user.Email, &name, &passwordHash,
&user.EmailVerified, &user.Provider, &providerID,
&user.CreatedAt, &user.UpdatedAt, &lastLoginAt,
)
if err == pgx.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
user.Name = name
user.PasswordHash = passwordHash
user.ProviderID = providerID
user.LastLoginAt = lastLoginAt
return &user, nil
}
func (db *DB) GetUserByID(ctx context.Context, id uuid.UUID) (*User, error) {
var user User
var name, passwordHash, providerID *string
var lastLoginAt *time.Time
err := db.QueryRow(ctx, `
SELECT id, email, name, password_hash, email_verified, provider, provider_id, created_at, updated_at, last_login_at
FROM users
WHERE id = $1
`, id).Scan(
&user.ID, &user.Email, &name, &passwordHash,
&user.EmailVerified, &user.Provider, &providerID,
&user.CreatedAt, &user.UpdatedAt, &lastLoginAt,
)
if err == pgx.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
user.Name = name
user.PasswordHash = passwordHash
user.ProviderID = providerID
user.LastLoginAt = lastLoginAt
return &user, nil
}
func (db *DB) GetUserByProviderID(ctx context.Context, provider, providerID string) (*User, error) {
var user User
var name, passwordHash *string
var lastLoginAt *time.Time
err := db.QueryRow(ctx, `
SELECT id, email, name, password_hash, email_verified, provider, provider_id, created_at, updated_at, last_login_at
FROM users
WHERE provider = $1 AND provider_id = $2
`, provider, providerID).Scan(
&user.ID, &user.Email, &name, &passwordHash,
&user.EmailVerified, &user.Provider, &user.ProviderID,
&user.CreatedAt, &user.UpdatedAt, &lastLoginAt,
)
if err == pgx.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
user.Name = name
user.PasswordHash = passwordHash
user.LastLoginAt = lastLoginAt
return &user, nil
}
func (db *DB) CreateUser(ctx context.Context, user *User) (*User, error) {
if user.ID == uuid.Nil {
user.ID = uuid.Must(uuid.NewV7())
}
now := time.Now()
user.CreatedAt = now
user.UpdatedAt = now
_, err := db.pool.Exec(ctx, `
INSERT INTO users (id, email, name, password_hash, email_verified, provider, provider_id, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $8)
`, user.ID, user.Email, user.Name, user.PasswordHash, user.EmailVerified, user.Provider, user.ProviderID, now)
if err != nil {
return nil, fmt.Errorf("create user: %w", err)
}
return user, nil
}
func (db *DB) UpdateUser(ctx context.Context, user *User) error {
user.UpdatedAt = time.Now()
_, err := db.pool.Exec(ctx, `
UPDATE users
SET email = $2, name = $3, password_hash = $4, email_verified = $5,
provider = $6, provider_id = $7, updated_at = $8, last_login_at = $9
WHERE id = $1
`, user.ID, user.Email, user.Name, user.PasswordHash, user.EmailVerified,
user.Provider, user.ProviderID, user.UpdatedAt, user.LastLoginAt)
return err
}
func (db *DB) UpdateLastLogin(ctx context.Context, userID uuid.UUID) error {
_, err := db.pool.Exec(ctx, `
UPDATE users SET last_login_at = NOW(), updated_at = NOW() WHERE id = $1
`, userID)
return err
}
func (db *DB) CreateMagicLink(ctx context.Context, token string, email string, userID uuid.UUID, expiresAt time.Time) error {
_, err := db.pool.Exec(ctx, `
INSERT INTO magic_links (token, user_id, email, expires_at, created_at)
VALUES ($1, $2, $3, $4, NOW())
`, token, userID, email, expiresAt)
return err
}
func (db *DB) GetMagicLink(ctx context.Context, token string) (*MagicLink, error) {
var ml MagicLink
var userID uuid.UUID
err := db.QueryRow(ctx, `
SELECT token, user_id, email, used, expires_at, created_at
FROM magic_links
WHERE token = $1
`, token).Scan(&ml.Token, &userID, &ml.Email, &ml.Used, &ml.ExpiresAt, &ml.CreatedAt)
if err == pgx.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
ml.UserID = userID
return &ml, nil
}
func (db *DB) MarkMagicLinkUsed(ctx context.Context, token string) error {
_, err := db.pool.Exec(ctx, `UPDATE magic_links SET used = true WHERE token = $1`, token)
return err
}
func (db *DB) PutKV(ctx context.Context, key string, value any) error {
payload, err := json.Marshal(value)
if err != nil {
return fmt.Errorf("marshal kv value: %w", err)
}
_, err = db.pool.Exec(ctx, `
INSERT INTO stripe_kv (key, value, created_at, updated_at)
VALUES ($1, $2, NOW(), NOW())
ON CONFLICT (key) DO UPDATE
SET value = EXCLUDED.value, updated_at = NOW()
`, key, payload)
return err
}
func (db *DB) GetKV(ctx context.Context, key string, dest any) (bool, error) {
var payload []byte
err := db.QueryRow(ctx, `
SELECT value
FROM stripe_kv
WHERE key = $1
`, key).Scan(&payload)
if err == pgx.ErrNoRows {
return false, nil
}
if err != nil {
return false, err
}
if err := json.Unmarshal(payload, dest); err != nil {
return false, fmt.Errorf("unmarshal kv value: %w", err)
}
return true, nil
}
@@ -0,0 +1,77 @@
package email
import (
"crypto/tls"
"fmt"
"net/smtp"
"github.com/jordan-wright/email"
)
type Config struct {
Host string
Port int
Username string
Password string
From string
}
type Service struct {
config Config
}
func New(config Config) *Service {
return &Service{config: config}
}
// SendMagicLink sends a magic link authentication email with proper branding
func (s *Service) SendMagicLink(toEmail, toName, linkURL, locale string) error {
template := MagicLinkEmail(toName, linkURL, locale)
return s.sendTemplate(toEmail, template)
}
// SendWelcomeEmail sends a welcome email to new users
func (s *Service) SendWelcomeEmail(toEmail, name, locale string) error {
template := WelcomeEmail(name, locale)
return s.sendTemplate(toEmail, template)
}
// SendBookingConfirmation sends booking confirmation to customers
func (s *Service) SendBookingConfirmation(toEmail, customerName, businessName, serviceName, dateTime, location, locale string) error {
template := BookingConfirmationEmail(customerName, businessName, serviceName, dateTime, location, locale)
return s.sendTemplate(toEmail, template)
}
// SendPasswordReset sends password reset email
func (s *Service) SendPasswordReset(toEmail, name, resetURL, locale string) error {
template := PasswordResetEmail(name, resetURL, locale)
return s.sendTemplate(toEmail, template)
}
// sendTemplate sends an email using the provided template
func (s *Service) sendTemplate(toEmail string, template EmailTemplate) error {
e := email.NewEmail()
e.From = fmt.Sprintf("Bookra <%s>", s.config.From)
e.To = []string{toEmail}
e.Subject = template.Subject
e.Text = []byte(template.Text)
e.HTML = []byte(template.HTML)
return s.send(e)
}
// send delivers the email via SMTP
func (s *Service) send(e *email.Email) error {
addr := fmt.Sprintf("%s:%d", s.config.Host, s.config.Port)
var auth smtp.Auth
if s.config.Username != "" {
auth = smtp.PlainAuth("", s.config.Username, s.config.Password, s.config.Host)
}
if s.config.Port == 465 {
return e.SendWithTLS(addr, auth, &tls.Config{ServerName: s.config.Host})
}
return e.Send(addr, auth)
}
@@ -0,0 +1,743 @@
package email
import (
"fmt"
)
// Bookra Design System - Warm editorial aesthetic
// Canvas: warm cream backgrounds (#fbf9f6)
// Ink: warm dark brown (#2a221e)
// Accent: terracotta (#a65c3e)
// Logo bg: #24201d, Logo text: #f7f2e8
const (
canvas = "#fbf9f6" // Warm cream background
canvasSubtle = "#f5f2ed" // Slightly darker cream
ink = "#2a221e" // Warm dark brown
inkMuted = "#5c514a" // Muted brown
inkSubtle = "#8b7f76" // Light muted brown
accent = "#a65c3e" // Terracotta
accentHover = "#8f4d33" // Darker terracotta
accentSubtle = "#f5ebe7" // Light terracotta tint
logoBg = "#24201d" // Logo dark brown
logoText = "#f7f2e8" // Logo cream
border = "#e8e2da" // Warm border
white = "#ffffff"
)
type EmailTemplate struct {
Subject string
HTML string
Text string
}
func MagicLinkEmail(toName, magicURL string, locale string) EmailTemplate {
if locale == "cs" {
return magicLinkEmailCS(toName, magicURL)
}
return magicLinkEmailEN(toName, magicURL)
}
func WelcomeEmail(name string, locale string) EmailTemplate {
if locale == "cs" {
return welcomeEmailCS(name)
}
return welcomeEmailEN(name)
}
func BookingConfirmationEmail(customerName, businessName, serviceName, dateTime, location string, locale string) EmailTemplate {
if locale == "cs" {
return bookingConfirmationCS(customerName, businessName, serviceName, dateTime, location)
}
return bookingConfirmationEN(customerName, businessName, serviceName, dateTime, location)
}
func PasswordResetEmail(name, resetURL string, locale string) EmailTemplate {
if locale == "cs" {
return passwordResetCS(name, resetURL)
}
return passwordResetEN(name, resetURL)
}
func magicLinkEmailEN(toName, magicURL string) EmailTemplate {
subject := "Your sign-in link for Bookra"
if toName == "" {
toName = "there"
}
html := fmt.Sprintf(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>%s</title>
<style>
body { margin: 0; padding: 0; font-family: 'Newsreader', Georgia, serif; background: %s; -webkit-font-smoothing: antialiased; }
.container { max-width: 600px; margin: 0 auto; background: %s; }
.header { background: %s; padding: 48px 40px; text-align: center; border-bottom: 1px solid %s; }
.logo { width: 56px; height: 56px; background: %s; border-radius: 14px; display: inline-flex; align-items: center; justify-content: center; font-family: 'Space Grotesk', sans-serif; font-size: 28px; font-weight: 700; color: %s; }
.brand { color: %s; font-family: 'Space Grotesk', sans-serif; font-size: 24px; font-weight: 600; margin-top: 16px; letter-spacing: -0.02em; }
.tagline { color: %s; font-size: 15px; margin-top: 6px; font-style: italic; }
.content { padding: 48px 40px; }
.greeting { font-family: 'Space Grotesk', sans-serif; font-size: 20px; font-weight: 600; color: %s; margin-bottom: 16px; }
.message { font-size: 17px; line-height: 1.6; color: %s; margin-bottom: 32px; }
.button-wrap { margin: 40px 0; }
.button { display: inline-block; background: %s; color: %s; padding: 16px 40px; text-decoration: none; border-radius: 8px; font-family: 'Space Grotesk', sans-serif; font-weight: 500; font-size: 15px; transition: background 0.2s; }
.button:hover { background: %s; }
.link-box { background: %s; border: 1px solid %s; border-radius: 8px; padding: 20px; margin: 32px 0; }
.link-label { font-size: 12px; font-weight: 600; color: %s; text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 8px; font-family: 'Space Grotesk', sans-serif; }
.link-url { font-size: 14px; color: %s; word-break: break-all; font-family: 'JetBrains Mono', monospace; }
.expiry { background: %s; border-left: 3px solid %s; padding: 16px 20px; margin: 32px 0; font-size: 15px; color: %s; }
.help { font-size: 15px; color: %s; margin-top: 40px; padding-top: 32px; border-top: 1px solid %s; font-style: italic; }
.footer { background: %s; padding: 32px 40px; text-align: center; border-top: 1px solid %s; }
.footer-text { font-size: 14px; color: %s; }
.footer-links { margin-top: 12px; }
.footer-links a { color: %s; text-decoration: none; font-size: 13px; margin: 0 12px; font-family: 'Space Grotesk', sans-serif; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="logo">B</div>
<div class="brand">Bookra</div>
<div class="tagline">Calm booking software</div>
</div>
<div class="content">
<div class="greeting">Hi %s,</div>
<div class="message">
You requested a sign-in link for your Bookra account. Click below to access your account securely — no password needed.
</div>
<div class="button-wrap">
<a href="%s" class="button">Sign In to Bookra</a>
</div>
<div class="link-box">
<div class="link-label">Or copy this link</div>
<div class="link-url">%s</div>
</div>
<div class="expiry">
This link expires in <strong>15 minutes</strong> for security.
</div>
<div class="help">
Didn't request this? You can safely ignore it — someone may have entered your email by mistake.
</div>
</div>
<div class="footer">
<div class="footer-text">© 2024 Bookra</div>
<div class="footer-links">
<a href="https://bookra.tdvorak.dev/privacy">Privacy</a>
<a href="https://bookra.tdvorak.dev/terms">Terms</a>
</div>
</div>
</div>
</body>
</html>`,
subject, canvas, white, canvas, border,
logoBg, logoText, ink, inkSubtle,
ink, inkMuted,
accent, white, accentHover,
canvasSubtle, border, inkSubtle, inkMuted,
accentSubtle, accent, accent,
inkSubtle, border,
canvas, border, inkMuted, inkMuted,
toName, magicURL, magicURL)
text := fmt.Sprintf(`Bookra — Sign-in Link
Hi %s,
Sign in to Bookra (link expires in 15 minutes):
%s
Didn't request this? You can safely ignore this email.
© 2024 Bookra`, toName, magicURL)
return EmailTemplate{Subject: subject, HTML: html, Text: text}
}
func magicLinkEmailCS(toName, magicURL string) EmailTemplate {
subject := "Váš přihlašovací odkaz do Bookra"
if toName == "" {
toName = "vás"
}
html := fmt.Sprintf(`<!DOCTYPE html>
<html lang="cs">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>%s</title>
<style>
body { margin: 0; padding: 0; font-family: 'Newsreader', Georgia, serif; background: %s; -webkit-font-smoothing: antialiased; }
.container { max-width: 600px; margin: 0 auto; background: %s; }
.header { background: %s; padding: 48px 40px; text-align: center; border-bottom: 1px solid %s; }
.logo { width: 56px; height: 56px; background: %s; border-radius: 14px; display: inline-flex; align-items: center; justify-content: center; font-family: 'Space Grotesk', sans-serif; font-size: 28px; font-weight: 700; color: %s; }
.brand { color: %s; font-family: 'Space Grotesk', sans-serif; font-size: 24px; font-weight: 600; margin-top: 16px; letter-spacing: -0.02em; }
.tagline { color: %s; font-size: 15px; margin-top: 6px; font-style: italic; }
.content { padding: 48px 40px; }
.greeting { font-family: 'Space Grotesk', sans-serif; font-size: 20px; font-weight: 600; color: %s; margin-bottom: 16px; }
.message { font-size: 17px; line-height: 1.6; color: %s; margin-bottom: 32px; }
.button-wrap { margin: 40px 0; }
.button { display: inline-block; background: %s; color: %s; padding: 16px 40px; text-decoration: none; border-radius: 8px; font-family: 'Space Grotesk', sans-serif; font-weight: 500; font-size: 15px; }
.button:hover { background: %s; }
.link-box { background: %s; border: 1px solid %s; border-radius: 8px; padding: 20px; margin: 32px 0; }
.link-label { font-size: 12px; font-weight: 600; color: %s; text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 8px; font-family: 'Space Grotesk', sans-serif; }
.link-url { font-size: 14px; color: %s; word-break: break-all; font-family: 'JetBrains Mono', monospace; }
.expiry { background: %s; border-left: 3px solid %s; padding: 16px 20px; margin: 32px 0; font-size: 15px; color: %s; }
.help { font-size: 15px; color: %s; margin-top: 40px; padding-top: 32px; border-top: 1px solid %s; font-style: italic; }
.footer { background: %s; padding: 32px 40px; text-align: center; border-top: 1px solid %s; }
.footer-text { font-size: 14px; color: %s; }
.footer-links { margin-top: 12px; }
.footer-links a { color: %s; text-decoration: none; font-size: 13px; margin: 0 12px; font-family: 'Space Grotesk', sans-serif; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="logo">B</div>
<div class="brand">Bookra</div>
<div class="tagline">Klidný rezervační software</div>
</div>
<div class="content">
<div class="greeting">Dobrý den %s,</div>
<div class="message">
Požádali jste o přihlašovací odkaz k účtu Bookra. Klikněte níže pro bezpečný přístup — heslo není potřeba.
</div>
<div class="button-wrap">
<a href="%s" class="button">Přihlásit se do Bookra</a>
</div>
<div class="link-box">
<div class="link-label">Nebo zkopírujte tento odkaz</div>
<div class="link-url">%s</div>
</div>
<div class="expiry">
Tento odkaz vyprší za <strong>15 minut</strong> z bezpečnostních důvodů.
</div>
<div class="help">
Nepožádali jste o tento email? Můžete ho bezpečně ignorovat.
</div>
</div>
<div class="footer">
<div class="footer-text">© 2024 Bookra</div>
<div class="footer-links">
<a href="https://bookra.tdvorak.dev/privacy">Ochrana soukromí</a>
<a href="https://bookra.tdvorak.dev/terms">Podmínky</a>
</div>
</div>
</div>
</body>
</html>`,
subject, canvas, white, canvas, border,
logoBg, logoText, ink, inkSubtle,
ink, inkMuted,
accent, white, accentHover,
canvasSubtle, border, inkSubtle, inkMuted,
accentSubtle, accent, accent,
inkSubtle, border,
canvas, border, inkMuted, inkMuted,
toName, magicURL, magicURL)
text := fmt.Sprintf(`Bookra — Přihlašovací odkaz
Dobrý den %s,
Přihlaste se do Bookra (odkaz vyprší za 15 minut):
%s
Nepožádali jste o tento email? Můžete ho ignorovat.
© 2024 Bookra`, toName, magicURL)
return EmailTemplate{Subject: subject, HTML: html, Text: text}
}
func welcomeEmailEN(name string) EmailTemplate {
subject := "Welcome to Bookra"
html := fmt.Sprintf(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>%s</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,500;1,6..72,400&display=swap');
body { margin: 0; padding: 0; font-family: 'Newsreader', Georgia, serif; background: %s; -webkit-font-smoothing: antialiased; }
.container { max-width: 600px; margin: 0 auto; background: %s; }
.header { background: %s; padding: 48px 40px; text-align: center; border-bottom: 1px solid %s; }
.logo { width: 56px; height: 56px; background: %s; border-radius: 14px; display: inline-flex; align-items: center; justify-content: center; font-family: 'Space Grotesk', sans-serif; font-size: 28px; font-weight: 700; color: %s; }
.brand { color: %s; font-family: 'Space Grotesk', sans-serif; font-size: 24px; font-weight: 600; margin-top: 16px; }
.content { padding: 48px 40px; }
.greeting { font-family: 'Space Grotesk', sans-serif; font-size: 28px; font-weight: 600; color: %s; margin-bottom: 16px; }
.message { font-size: 18px; line-height: 1.6; color: %s; margin-bottom: 32px; }
.features { background: %s; border-radius: 12px; padding: 32px; margin: 32px 0; }
.feature { display: flex; align-items: flex-start; gap: 16px; margin-bottom: 20px; }
.feature:last-child { margin-bottom: 0; }
.feature-icon { width: 24px; height: 24px; background: %s; border-radius: 6px; display: flex; align-items: center; justify-content: center; color: %s; font-size: 14px; flex-shrink: 0; font-family: 'Space Grotesk', sans-serif; }
.feature-text { font-size: 16px; color: %s; line-height: 1.5; }
.button-wrap { margin: 40px 0; text-align: center; }
.button { display: inline-block; background: %s; color: %s; padding: 16px 40px; text-decoration: none; border-radius: 8px; font-family: 'Space Grotesk', sans-serif; font-weight: 500; font-size: 15px; }
.footer { background: %s; padding: 32px 40px; text-align: center; border-top: 1px solid %s; }
.footer-text { font-size: 14px; color: %s; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="logo">B</div>
<div class="brand">Bookra</div>
</div>
<div class="content">
<div class="greeting">Welcome, %s</div>
<div class="message">
Thanks for joining Bookra. We're here to help you manage bookings with calm and clarity.
</div>
<div class="features">
<div class="feature">
<div class="feature-icon">✓</div>
<div class="feature-text"><strong>Smart scheduling</strong> — Automatic conflict detection and buffer times</div>
</div>
<div class="feature">
<div class="feature-icon">✓</div>
<div class="feature-text"><strong>Customer insights</strong> — History and preferences at your fingertips</div>
</div>
<div class="feature">
<div class="feature-icon">✓</div>
<div class="feature-text"><strong>Reminders</strong> — Reduce no-shows with gentle notifications</div>
</div>
</div>
<div class="button-wrap">
<a href="https://bookra.tdvorak.dev/dashboard" class="button">Open Dashboard</a>
</div>
</div>
<div class="footer">
<div class="footer-text">© 2024 Bookra</div>
</div>
</div>
</body>
</html>`,
subject, canvas, white, canvas, border,
logoBg, logoText, ink,
ink, inkMuted, canvasSubtle,
accent, white, inkMuted,
accent, white,
canvas, border, inkMuted,
name)
text := fmt.Sprintf(`Welcome to Bookra, %s
Thanks for joining. We're here to help you manage bookings with calm and clarity.
Get started: https://bookra.tdvorak.dev/dashboard
© 2024 Bookra`, name)
return EmailTemplate{Subject: subject, HTML: html, Text: text}
}
func welcomeEmailCS(name string) EmailTemplate {
subject := "Vítejte v Bookra"
html := fmt.Sprintf(`<!DOCTYPE html>
<html lang="cs">
<head>
<meta charset="UTF-8">
<title>%s</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,500;1,6..72,400&display=swap');
body { margin: 0; padding: 0; font-family: 'Newsreader', Georgia, serif; background: %s; -webkit-font-smoothing: antialiased; }
.container { max-width: 600px; margin: 0 auto; background: %s; }
.header { background: %s; padding: 48px 40px; text-align: center; border-bottom: 1px solid %s; }
.logo { width: 56px; height: 56px; background: %s; border-radius: 14px; display: inline-flex; align-items: center; justify-content: center; font-family: 'Space Grotesk', sans-serif; font-size: 28px; font-weight: 700; color: %s; }
.brand { color: %s; font-family: 'Space Grotesk', sans-serif; font-size: 24px; font-weight: 600; margin-top: 16px; }
.content { padding: 48px 40px; }
.greeting { font-family: 'Space Grotesk', sans-serif; font-size: 28px; font-weight: 600; color: %s; margin-bottom: 16px; }
.message { font-size: 18px; line-height: 1.6; color: %s; margin-bottom: 32px; }
.features { background: %s; border-radius: 12px; padding: 32px; margin: 32px 0; }
.feature { display: flex; align-items: flex-start; gap: 16px; margin-bottom: 20px; }
.feature:last-child { margin-bottom: 0; }
.feature-icon { width: 24px; height: 24px; background: %s; border-radius: 6px; display: flex; align-items: center; justify-content: center; color: %s; font-size: 14px; flex-shrink: 0; }
.feature-text { font-size: 16px; color: %s; line-height: 1.5; }
.button-wrap { margin: 40px 0; text-align: center; }
.button { display: inline-block; background: %s; color: %s; padding: 16px 40px; text-decoration: none; border-radius: 8px; font-family: 'Space Grotesk', sans-serif; font-weight: 500; font-size: 15px; }
.footer { background: %s; padding: 32px 40px; text-align: center; border-top: 1px solid %s; }
.footer-text { font-size: 14px; color: %s; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="logo">B</div>
<div class="brand">Bookra</div>
</div>
<div class="content">
<div class="greeting">Vítejte, %s</div>
<div class="message">
Děkujeme za registraci. Pomůžeme vám spravovat rezervace s klidem a přehledem.
</div>
<div class="features">
<div class="feature">
<div class="feature-icon">✓</div>
<div class="feature-text"><strong>Chytré plánování</strong> — Automatická detekce konfliktů</div>
</div>
<div class="feature">
<div class="feature-icon">✓</div>
<div class="feature-text"><strong>Přehled o zákaznících</strong> — Historie a preference</div>
</div>
<div class="feature">
<div class="feature-icon">✓</div>
<div class="feature-text"><strong>Připomenutí</strong> — Méně zapomenutých termínů</div>
</div>
</div>
<div class="button-wrap">
<a href="https://bookra.tdvorak.dev/dashboard" class="button">Otevřít aplikaci</a>
</div>
</div>
<div class="footer">
<div class="footer-text">© 2024 Bookra</div>
</div>
</div>
</body>
</html>`,
subject, canvas, white, canvas, border,
logoBg, logoText, ink,
ink, inkMuted, canvasSubtle,
accent, white, inkMuted,
accent, white,
canvas, border, inkMuted,
name)
text := fmt.Sprintf(`Vítejte v Bookra, %s
Děkujeme za registraci. Pomůžeme vám spravovat rezervace s klidem.
Otevřít aplikaci: https://bookra.tdvorak.dev/dashboard
© 2024 Bookra`, name)
return EmailTemplate{Subject: subject, HTML: html, Text: text}
}
func bookingConfirmationEN(customerName, businessName, serviceName, dateTime, location string) EmailTemplate {
subject := fmt.Sprintf("Confirmed: %s with %s", serviceName, businessName)
html := fmt.Sprintf(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>%s</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Newsreader:ital,opsz,wght@0,6..72,400&display=swap');
body { margin: 0; padding: 0; font-family: 'Newsreader', Georgia, serif; background: %s; -webkit-font-smoothing: antialiased; }
.container { max-width: 600px; margin: 0 auto; background: %s; }
.header { background: %s; padding: 48px 40px; text-align: center; border-bottom: 1px solid %s; }
.logo { width: 56px; height: 56px; background: %s; border-radius: 14px; display: inline-flex; align-items: center; justify-content: center; font-family: 'Space Grotesk', sans-serif; font-size: 28px; font-weight: 700; color: %s; }
.brand { color: %s; font-family: 'Space Grotesk', sans-serif; font-size: 24px; font-weight: 600; margin-top: 16px; }
.content { padding: 48px 40px; }
.badge { display: inline-block; background: %s; color: %s; padding: 8px 16px; border-radius: 20px; font-size: 13px; font-weight: 600; margin-bottom: 24px; font-family: 'Space Grotesk', sans-serif; text-transform: uppercase; letter-spacing: 0.05em; }
.greeting { font-family: 'Space Grotesk', sans-serif; font-size: 24px; font-weight: 600; color: %s; margin-bottom: 8px; }
.message { font-size: 17px; color: %s; margin-bottom: 32px; }
.details { background: %s; border-radius: 12px; padding: 28px; margin: 32px 0; }
.detail-row { display: flex; padding: 14px 0; border-bottom: 1px solid %s; }
.detail-row:last-child { border-bottom: none; }
.detail-label { width: 120px; font-size: 12px; font-weight: 600; color: %s; text-transform: uppercase; letter-spacing: 0.05em; font-family: 'Space Grotesk', sans-serif; }
.detail-value { flex: 1; font-size: 16px; color: %s; font-weight: 500; }
.help { font-size: 15px; color: %s; margin-top: 32px; padding-top: 32px; border-top: 1px solid %s; font-style: italic; }
.footer { background: %s; padding: 32px 40px; text-align: center; border-top: 1px solid %s; }
.footer-text { font-size: 14px; color: %s; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="logo">B</div>
<div class="brand">Bookra</div>
</div>
<div class="content">
<div class="badge">Confirmed</div>
<div class="greeting">Hello %s,</div>
<div class="message">
Your booking with <strong>%s</strong> is confirmed.
</div>
<div class="details">
<div class="detail-row">
<div class="detail-label">Service</div>
<div class="detail-value">%s</div>
</div>
<div class="detail-row">
<div class="detail-label">When</div>
<div class="detail-value">%s</div>
</div>
<div class="detail-row">
<div class="detail-label">Where</div>
<div class="detail-value">%s</div>
</div>
</div>
<div class="help">
Need to reschedule? Contact %s directly.
</div>
</div>
<div class="footer">
<div class="footer-text">© 2024 Bookra</div>
</div>
</div>
</body>
</html>`,
subject, canvas, white, canvas, border,
logoBg, logoText, ink,
accentSubtle, accent, ink, inkMuted,
canvasSubtle, border, inkSubtle, ink,
inkSubtle, border,
canvas, border, inkMuted,
customerName, businessName, serviceName, dateTime, location, businessName)
text := fmt.Sprintf(`Booking Confirmed
Hello %s,
Your booking with %s is confirmed.
Service: %s
When: %s
Where: %s
Need to reschedule? Contact %s.
© 2024 Bookra`,
customerName, businessName, serviceName, dateTime, location, businessName)
return EmailTemplate{Subject: subject, HTML: html, Text: text}
}
func bookingConfirmationCS(customerName, businessName, serviceName, dateTime, location string) EmailTemplate {
subject := fmt.Sprintf("Potvrzeno: %s v %s", serviceName, businessName)
html := fmt.Sprintf(`<!DOCTYPE html>
<html lang="cs">
<head>
<meta charset="UTF-8">
<title>%s</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Newsreader:ital,opsz,wght@0,6..72,400&display=swap');
body { margin: 0; padding: 0; font-family: 'Newsreader', Georgia, serif; background: %s; -webkit-font-smoothing: antialiased; }
.container { max-width: 600px; margin: 0 auto; background: %s; }
.header { background: %s; padding: 48px 40px; text-align: center; border-bottom: 1px solid %s; }
.logo { width: 56px; height: 56px; background: %s; border-radius: 14px; display: inline-flex; align-items: center; justify-content: center; font-family: 'Space Grotesk', sans-serif; font-size: 28px; font-weight: 700; color: %s; }
.brand { color: %s; font-family: 'Space Grotesk', sans-serif; font-size: 24px; font-weight: 600; margin-top: 16px; }
.content { padding: 48px 40px; }
.badge { display: inline-block; background: %s; color: %s; padding: 8px 16px; border-radius: 20px; font-size: 13px; font-weight: 600; margin-bottom: 24px; font-family: 'Space Grotesk', sans-serif; text-transform: uppercase; letter-spacing: 0.05em; }
.greeting { font-family: 'Space Grotesk', sans-serif; font-size: 24px; font-weight: 600; color: %s; margin-bottom: 8px; }
.message { font-size: 17px; color: %s; margin-bottom: 32px; }
.details { background: %s; border-radius: 12px; padding: 28px; margin: 32px 0; }
.detail-row { display: flex; padding: 14px 0; border-bottom: 1px solid %s; }
.detail-row:last-child { border-bottom: none; }
.detail-label { width: 120px; font-size: 12px; font-weight: 600; color: %s; text-transform: uppercase; letter-spacing: 0.05em; font-family: 'Space Grotesk', sans-serif; }
.detail-value { flex: 1; font-size: 16px; color: %s; font-weight: 500; }
.help { font-size: 15px; color: %s; margin-top: 32px; padding-top: 32px; border-top: 1px solid %s; font-style: italic; }
.footer { background: %s; padding: 32px 40px; text-align: center; border-top: 1px solid %s; }
.footer-text { font-size: 14px; color: %s; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="logo">B</div>
<div class="brand">Bookra</div>
</div>
<div class="content">
<div class="badge">Potvrzeno</div>
<div class="greeting">Dobrý den %s,</div>
<div class="message">
Vaše rezervace v <strong>%s</strong> je potvrzena.
</div>
<div class="details">
<div class="detail-row">
<div class="detail-label">Služba</div>
<div class="detail-value">%s</div>
</div>
<div class="detail-row">
<div class="detail-label">Termín</div>
<div class="detail-value">%s</div>
</div>
<div class="detail-row">
<div class="detail-label">Místo</div>
<div class="detail-value">%s</div>
</div>
</div>
<div class="help">
Potřebujete přeobjednat? Kontaktujte přímo %s.
</div>
</div>
<div class="footer">
<div class="footer-text">© 2024 Bookra</div>
</div>
</div>
</body>
</html>`,
subject, canvas, white, canvas, border,
logoBg, logoText, ink,
accentSubtle, accent, ink, inkMuted,
canvasSubtle, border, inkSubtle, ink,
inkSubtle, border,
canvas, border, inkMuted,
customerName, businessName, serviceName, dateTime, location, businessName)
text := fmt.Sprintf(`Rezervace potvrzena
Dobrý den %s,
Vaše rezervace v %s je potvrzena.
Služba: %s
Termín: %s
Místo: %s
Potřebujete přeobjednat? Kontaktujte %s.
© 2024 Bookra`,
customerName, businessName, serviceName, dateTime, location, businessName)
return EmailTemplate{Subject: subject, HTML: html, Text: text}
}
func passwordResetEN(name, resetURL string) EmailTemplate {
subject := "Reset your Bookra password"
html := fmt.Sprintf(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>%s</title>
<style>
body { margin: 0; padding: 0; font-family: 'Newsreader', Georgia, serif; background: %s; -webkit-font-smoothing: antialiased; }
.container { max-width: 600px; margin: 0 auto; background: %s; }
.header { background: %s; padding: 48px 40px; text-align: center; border-bottom: 1px solid %s; }
.logo { width: 56px; height: 56px; background: %s; border-radius: 14px; display: inline-flex; align-items: center; justify-content: center; font-family: 'Space Grotesk', sans-serif; font-size: 28px; font-weight: 700; color: %s; }
.brand { color: %s; font-family: 'Space Grotesk', sans-serif; font-size: 24px; font-weight: 600; margin-top: 16px; }
.content { padding: 48px 40px; }
.greeting { font-family: 'Space Grotesk', sans-serif; font-size: 20px; font-weight: 600; color: %s; margin-bottom: 16px; }
.message { font-size: 17px; line-height: 1.6; color: %s; margin-bottom: 32px; }
.button-wrap { margin: 40px 0; }
.button { display: inline-block; background: %s; color: %s; padding: 16px 40px; text-decoration: none; border-radius: 8px; font-family: 'Space Grotesk', sans-serif; font-weight: 500; font-size: 15px; }
.expiry { background: %s; border-left: 3px solid %s; padding: 16px 20px; margin: 32px 0; font-size: 15px; color: %s; }
.help { font-size: 15px; color: %s; margin-top: 40px; padding-top: 32px; border-top: 1px solid %s; font-style: italic; }
.footer { background: %s; padding: 32px 40px; text-align: center; border-top: 1px solid %s; }
.footer-text { font-size: 14px; color: %s; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="logo">B</div>
<div class="brand">Bookra</div>
</div>
<div class="content">
<div class="greeting">Hi %s,</div>
<div class="message">
We received a request to reset your password. Click below to choose a new one.
</div>
<div class="button-wrap">
<a href="%s" class="button">Reset Password</a>
</div>
<div class="expiry">
This link expires in <strong>1 hour</strong>.
</div>
<div class="help">
Didn't request this? You can safely ignore it.
</div>
</div>
<div class="footer">
<div class="footer-text">© 2024 Bookra</div>
</div>
</div>
</body>
</html>`,
subject, canvas, white, canvas, border,
logoBg, logoText, ink,
ink, inkMuted,
accent, white,
accentSubtle, accent, accent,
inkSubtle, border,
canvas, border, inkMuted,
name, resetURL)
text := fmt.Sprintf(`Reset Password — Bookra
Hi %s,
Reset your password (expires in 1 hour):
%s
Didn't request this? You can safely ignore it.
© 2024 Bookra`, name, resetURL)
return EmailTemplate{Subject: subject, HTML: html, Text: text}
}
func passwordResetCS(name, resetURL string) EmailTemplate {
subject := "Reset hesla pro Bookra"
html := fmt.Sprintf(`<!DOCTYPE html>
<html lang="cs">
<head>
<meta charset="UTF-8">
<title>%s</title>
<style>
body { margin: 0; padding: 0; font-family: 'Newsreader', Georgia, serif; background: %s; -webkit-font-smoothing: antialiased; }
.container { max-width: 600px; margin: 0 auto; background: %s; }
.header { background: %s; padding: 48px 40px; text-align: center; border-bottom: 1px solid %s; }
.logo { width: 56px; height: 56px; background: %s; border-radius: 14px; display: inline-flex; align-items: center; justify-content: center; font-family: 'Space Grotesk', sans-serif; font-size: 28px; font-weight: 700; color: %s; }
.brand { color: %s; font-family: 'Space Grotesk', sans-serif; font-size: 24px; font-weight: 600; margin-top: 16px; }
.content { padding: 48px 40px; }
.greeting { font-family: 'Space Grotesk', sans-serif; font-size: 20px; font-weight: 600; color: %s; margin-bottom: 16px; }
.message { font-size: 17px; line-height: 1.6; color: %s; margin-bottom: 32px; }
.button-wrap { margin: 40px 0; }
.button { display: inline-block; background: %s; color: %s; padding: 16px 40px; text-decoration: none; border-radius: 8px; font-family: 'Space Grotesk', sans-serif; font-weight: 500; font-size: 15px; }
.expiry { background: %s; border-left: 3px solid %s; padding: 16px 20px; margin: 32px 0; font-size: 15px; color: %s; }
.help { font-size: 15px; color: %s; margin-top: 40px; padding-top: 32px; border-top: 1px solid %s; font-style: italic; }
.footer { background: %s; padding: 32px 40px; text-align: center; border-top: 1px solid %s; }
.footer-text { font-size: 14px; color: %s; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="logo">B</div>
<div class="brand">Bookra</div>
</div>
<div class="content">
<div class="greeting">Dobrý den %s,</div>
<div class="message">
Obdrželi jsme žádost o reset hesla. Klikněte níže pro nastavení nového.
</div>
<div class="button-wrap">
<a href="%s" class="button">Resetovat heslo</a>
</div>
<div class="expiry">
Tento odkaz vyprší za <strong>1 hodinu</strong>.
</div>
<div class="help">
Nepožádali jste o tento email? Můžete ho bezpečně ignorovat.
</div>
</div>
<div class="footer">
<div class="footer-text">© 2024 Bookra</div>
</div>
</div>
</body>
</html>`,
subject, canvas, white, canvas, border,
logoBg, logoText, ink,
ink, inkMuted,
accent, white,
accentSubtle, accent, accent,
inkSubtle, border,
canvas, border, inkMuted,
name, resetURL)
text := fmt.Sprintf(`Reset hesla — Bookra
Dobrý den %s,
Reset hesla (vyprší za 1 hodinu):
%s
Nepožádali jste o tento email? Můžete ho ignorovat.
© 2024 Bookra`, name, resetURL)
return EmailTemplate{Subject: subject, HTML: html, Text: text}
}
@@ -0,0 +1,680 @@
package handlers
import (
"bookra/apps/auth-service/internal/config"
"bookra/apps/auth-service/internal/db"
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
// AdminDashboard provides a visual management interface for the auth service
type AdminDashboard struct {
cfg *config.Config
db *db.DB
}
func NewAdminDashboard(cfg *config.Config, database *db.DB) *AdminDashboard {
return &AdminDashboard{cfg: cfg, db: database}
}
// RegisterRoutes registers admin routes
func (a *AdminDashboard) RegisterRoutes(r *gin.Engine) {
admin := r.Group("/admin")
{
admin.GET("", a.RenderDashboard)
admin.GET("/api/config", a.GetConfig)
admin.GET("/api/prices", a.GetPrices)
admin.GET("/api/stats", a.GetStats)
}
}
// GetConfig returns current configuration (sanitized)
func (a *AdminDashboard) GetConfig(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"appEnv": a.cfg.AppEnv,
"port": a.cfg.Port,
"frontendURL": a.cfg.FrontendURL,
"neonAuthURL": a.cfg.NeonAuthURL,
"smtpConfigured": gin.H{
"host": a.cfg.SMTPHost,
"port": a.cfg.SMTPPort,
"from": a.cfg.EmailFrom,
},
"googleOAuthConfigured": a.cfg.GoogleClientID != "",
"stripeConfigured": a.cfg.StripeCheckoutReady(),
"stripeSecretConfigured": a.cfg.StripeSecretConfigured(),
"stripeWebhookConfigured": a.cfg.StripeWebhookConfigured(),
"stripePricesConfigured": a.cfg.StripeHasAnyPriceConfigured(),
})
}
// GetPrices returns configured Stripe prices
func (a *AdminDashboard) GetPrices(c *gin.Context) {
prices := []gin.H{}
planNames := map[string]string{
"starter": "Starter Plan",
"pro": "Pro Plan",
"business": "Business Plan",
"monthly": "Monthly Plan",
"growth": "Growth Plan (Pro alias)",
"multi-location": "Multi-Location (Business alias)",
}
currencies := []string{"czk", "usd"}
for planCode, priceID := range a.cfg.StripePriceIDs {
if strings.TrimSpace(priceID) == "" {
continue
}
// Parse plan:currency format
parts := strings.Split(planCode, ":")
displayName := planNames[planCode]
currency := ""
if len(parts) == 2 {
planCode = parts[0]
currency = parts[1]
displayName = planNames[planCode] + " (" + strings.ToUpper(currency) + ")"
}
if displayName == "" {
displayName = planCode
}
prices = append(prices, gin.H{
"planCode": planCode,
"currency": currency,
"priceID": priceID,
"displayName": displayName,
"configured": true,
})
}
c.JSON(http.StatusOK, gin.H{
"prices": prices,
"currencies": currencies,
"stripeConfigured": a.cfg.StripeCheckoutReady(),
"secretConfigured": a.cfg.StripeSecretConfigured(),
"webhookConfigured": a.cfg.StripeWebhookConfigured(),
"pricesConfigured": len(prices) > 0,
})
}
// GetStats returns database statistics
func (a *AdminDashboard) GetStats(c *gin.Context) {
if a.db == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Database not available"})
return
}
stats, err := a.db.GetStats(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load stats: " + err.Error()})
return
}
c.JSON(http.StatusOK, stats)
}
// RenderDashboard renders the HTML admin dashboard
func (a *AdminDashboard) RenderDashboard(c *gin.Context) {
c.Header("Content-Type", "text/html; charset=utf-8")
c.String(http.StatusOK, adminHTML)
}
const adminHTML = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Bookra Auth Service Admin</title>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,500;1,6..72,400&display=swap" rel="stylesheet">
<style>
:root {
--canvas: 40 25% 97%;
--canvas-subtle: 40 20% 94%;
--canvas-muted: 40 15% 89%;
--ink: 25 15% 12%;
--ink-muted: 25 10% 42%;
--ink-subtle: 25 8% 58%;
--accent: 17 55% 42%;
--accent-hover: 17 60% 37%;
--accent-subtle: 17 45% 94%;
--success: 145 45% 38%;
--success-subtle: 145 35% 94%;
--error: 0 60% 52%;
--error-subtle: 0 50% 96%;
--border: 30 12% 86%;
--shadow-sm: 0 1px 3px 0 rgb(0 0 0 / 0.04), 0 1px 2px -1px rgb(0 0 0 / 0.04);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.05), 0 2px 4px -2px rgb(0 0 0 / 0.05);
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.06), 0 4px 6px -4px rgb(0 0 0 / 0.04);
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: "Newsreader", Georgia, ui-serif, serif;
background: linear-gradient(180deg, hsl(var(--canvas)) 0%, hsl(var(--canvas-subtle)) 100%);
color: hsl(var(--ink));
line-height: 1.6;
min-height: 100vh;
}
.container {
max-width: 1280px;
margin: 0 auto;
padding: 2rem 1.5rem;
}
@media (min-width: 640px) {
.container { padding: 2rem; }
}
@media (min-width: 1024px) {
.container { padding: 3rem; }
}
header {
margin-bottom: 3rem;
}
.logo {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1rem;
}
.logo svg {
width: 32px;
height: 32px;
color: hsl(var(--accent));
}
header h1 {
font-family: "Space Grotesk", ui-sans-serif, system-ui, sans-serif;
font-size: clamp(1.75rem, 3vw + 0.5rem, 2.5rem);
font-weight: 600;
letter-spacing: -0.02em;
color: hsl(var(--ink));
}
header p {
font-size: 1.125rem;
color: hsl(var(--ink-muted));
max-width: 600px;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}
.stat-card {
background: linear-gradient(145deg, hsl(40 25% 98%) 0%, hsl(40 20% 96%) 100%);
border: 1px solid hsl(var(--border));
border-radius: 1rem;
padding: 1.5rem;
box-shadow: var(--shadow-sm);
transition: box-shadow 0.3s ease, transform 0.3s ease;
}
.stat-card:hover {
box-shadow: var(--shadow-md);
transform: translateY(-2px);
}
.stat-card .icon {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
background: hsl(var(--accent-subtle));
border-radius: 0.75rem;
margin-bottom: 1rem;
}
.stat-card .icon svg {
width: 20px;
height: 20px;
color: hsl(var(--accent));
}
.stat-card.success .icon { background: hsl(var(--success-subtle)); }
.stat-card.success .icon svg { color: hsl(var(--success)); }
.stat-value {
font-family: "Space Grotesk", ui-sans-serif, sans-serif;
font-size: 1.875rem;
font-weight: 600;
color: hsl(var(--ink));
line-height: 1;
margin-bottom: 0.375rem;
}
.stat-label {
font-size: 0.875rem;
color: hsl(var(--ink-muted));
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(380px, 1fr));
gap: 1.5rem;
}
.card {
background: linear-gradient(145deg, hsl(40 25% 98%) 0%, hsl(40 20% 96%) 100%);
border: 1px solid hsl(var(--border));
border-radius: 1rem;
overflow: hidden;
box-shadow: var(--shadow-sm);
}
.card-header {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1.25rem 1.5rem;
border-bottom: 1px solid hsl(var(--border));
}
.card-header svg {
width: 20px;
height: 20px;
color: hsl(var(--accent));
}
.card-header h2 {
font-family: "Space Grotesk", ui-sans-serif, sans-serif;
font-size: 1rem;
font-weight: 600;
color: hsl(var(--ink));
}
.card-body {
padding: 1.5rem;
}
.status {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.8125rem;
font-weight: 500;
font-family: "Space Grotesk", ui-sans-serif, sans-serif;
}
.status.active {
background: hsl(var(--success-subtle));
color: hsl(var(--success));
}
.status.inactive {
background: hsl(var(--error-subtle));
color: hsl(var(--error));
}
.status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
}
th, td {
text-align: left;
padding: 0.875rem 0;
border-bottom: 1px solid hsl(var(--border));
}
th {
font-family: "Space Grotesk", ui-sans-serif, sans-serif;
font-weight: 500;
color: hsl(var(--ink-muted));
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
tr:last-child td { border-bottom: none; }
.env-value {
font-family: "JetBrains Mono", ui-monospace, monospace;
background: hsl(var(--canvas-muted));
padding: 0.25rem 0.5rem;
border-radius: 0.375rem;
font-size: 0.8125rem;
color: hsl(var(--ink));
}
.badge {
display: inline-flex;
align-items: center;
padding: 0.25rem 0.625rem;
border-radius: 0.375rem;
font-size: 0.75rem;
font-weight: 500;
font-family: "Space Grotesk", ui-sans-serif, sans-serif;
background: hsl(var(--accent-subtle));
color: hsl(var(--accent));
}
.info-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.875rem 0;
border-bottom: 1px solid hsl(var(--border));
}
.info-row:last-child { border-bottom: none; }
.info-label {
color: hsl(var(--ink-muted));
font-size: 0.9375rem;
}
.loading {
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
padding: 3rem;
color: hsl(var(--ink-subtle));
}
.loading svg {
width: 20px;
height: 20px;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.error {
background: hsl(var(--error-subtle));
color: hsl(var(--error));
padding: 1rem;
border-radius: 0.75rem;
font-size: 0.875rem;
}
.empty-state {
text-align: center;
padding: 2rem;
color: hsl(var(--ink-muted));
}
.empty-state svg {
width: 48px;
height: 48px;
margin-bottom: 0.75rem;
color: hsl(var(--ink-subtle));
}
</style>
</head>
<body>
<div class="container">
<header>
<div class="logo">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
<path d="M2 17l10 5 10-5"/>
<path d="M2 12l10 5 10-5"/>
</svg>
<h1>Auth Service Admin</h1>
</div>
<p>Monitor users, configure billing plans, and manage service health.</p>
</header>
<div class="stats-grid" id="stats-grid">
<div class="stat-card">
<div class="icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
</div>
<div class="stat-value">-</div>
<div class="stat-label">Total Users</div>
</div>
<div class="stat-card">
<div class="icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<polyline points="12 6 12 12 16 14"/>
</svg>
</div>
<div class="stat-value">-</div>
<div class="stat-label">Active (7d)</div>
</div>
<div class="stat-card">
<div class="icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
</svg>
</div>
<div class="stat-value">-</div>
<div class="stat-label">Magic Links Sent</div>
</div>
<div class="stat-card success">
<div class="icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
</svg>
</div>
<div class="stat-value">-</div>
<div class="stat-label">New This Week</div>
</div>
</div>
<div class="grid">
<div class="card">
<div class="card-header">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>
</svg>
<h2>Service Configuration</h2>
</div>
<div class="card-body" id="config-content">
<div class="loading">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2v4m0 12v4M4.93 4.93l2.83 2.83m8.48 8.48l2.83 2.83M2 12h4m12 0h4M4.93 19.07l2.83-2.83m8.48-8.48l2.83-2.83"/>
</svg>
Loading configuration...
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="5" width="20" height="14" rx="2"/>
<line x1="2" y1="10" x2="22" y2="10"/>
</svg>
<h2>Billing Plans</h2>
</div>
<div class="card-body" id="prices-content">
<div class="loading">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2v4m0 12v4M4.93 4.93l2.83 2.83m8.48 8.48l2.83 2.83M2 12h4m12 0h4M4.93 19.07l2.83-2.83m8.48-8.48l2.83-2.83"/>
</svg>
Loading plans...
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
</svg>
<h2>API Endpoints</h2>
</div>
<div class="card-body">
<table>
<thead>
<tr>
<th>Method</th>
<th>Endpoint</th>
</tr>
</thead>
<tbody>
<tr><td><span class="badge">POST</span></td><td>/api/auth/magic-link</td></tr>
<tr><td><span class="badge">POST</span></td><td>/api/auth/verify</td></tr>
<tr><td><span class="badge">POST</span></td><td>/api/auth/register</td></tr>
<tr><td><span class="badge">POST</span></td><td>/api/auth/login</td></tr>
<tr><td><span class="badge">GET</span></td><td>/api/auth/me</td></tr>
<tr><td><span class="badge">GET</span></td><td>/api/billing/subscription</td></tr>
<tr><td><span class="badge">POST</span></td><td>/api/billing/checkout</td></tr>
</tbody>
</table>
</div>
</div>
<div class="card">
<div class="card-header">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
<h2>Service Overview</h2>
</div>
<div class="card-body">
<div class="info-row">
<span class="info-label">Authentication</span>
<span>Magic links, JWT, OAuth</span>
</div>
<div class="info-row">
<span class="info-label">Billing</span>
<span>Stripe subscriptions</span>
</div>
<div class="info-row">
<span class="info-label">Database</span>
<span>Neon PostgreSQL</span>
</div>
<div class="info-row">
<span class="info-label">Email</span>
<span>SMTP transactional</span>
</div>
</div>
</div>
</div>
</div>
<script>
// Load stats
fetch('/admin/api/stats')
.then(r => r.json())
.then(data => {
const cards = document.querySelectorAll('.stat-card');
cards[0].querySelector('.stat-value').textContent = data.totalUsers.toLocaleString();
cards[1].querySelector('.stat-value').textContent = data.activeUsers7Days.toLocaleString();
cards[2].querySelector('.stat-value').textContent = data.magicLinksSent.toLocaleString();
cards[3].querySelector('.stat-value').textContent = data.usersThisWeek.toLocaleString();
})
.catch(err => {
document.getElementById('stats-grid').innerHTML =
'<div class="error" style="grid-column: 1/-1;">Failed to load statistics</div>';
});
// Load configuration
fetch('/admin/api/config')
.then(r => r.json())
.then(data => {
let html = '<div class="info-row">' +
'<span class="info-label">Environment</span>' +
'<span class="env-value">' + data.appEnv + '</span>' +
'</div>' +
'<div class="info-row">' +
'<span class="info-label">Port</span>' +
'<span class="env-value">' + data.port + '</span>' +
'</div>' +
'<div class="info-row">' +
'<span class="info-label">Neon Auth</span>' +
'<span class="status ' + (data.neonAuthURL ? 'active' : 'inactive') + '">' +
'<span class="status-dot"></span>' +
(data.neonAuthURL ? 'Configured' : 'Not Configured') +
'</span>' +
'</div>' +
'<div class="info-row">' +
'<span class="info-label">SMTP</span>' +
'<span class="status ' + (data.smtpConfigured.host ? 'active' : 'inactive') + '">' +
'<span class="status-dot"></span>' +
(data.smtpConfigured.host ? data.smtpConfigured.host : 'Not Configured') +
'</span>' +
'</div>' +
'<div class="info-row">' +
'<span class="info-label">Google OAuth</span>' +
'<span class="status ' + (data.googleOAuthConfigured ? 'active' : 'inactive') + '">' +
'<span class="status-dot"></span>' +
(data.googleOAuthConfigured ? 'Enabled' : 'Disabled') +
'</span>' +
'</div>' +
'<div class="info-row">' +
'<span class="info-label">Stripe</span>' +
'<span class="status ' + (data.stripeConfigured ? 'active' : 'inactive') + '">' +
'<span class="status-dot"></span>' +
(data.stripeConfigured ? 'Configured' : 'Not Configured') +
'</span>' +
'</div>';
document.getElementById('config-content').innerHTML = html;
})
.catch(err => {
document.getElementById('config-content').innerHTML =
'<div class="error">Failed to load configuration</div>';
});
// Load prices
fetch('/admin/api/prices')
.then(r => r.json())
.then(data => {
if (!data.prices || data.prices.length === 0) {
document.getElementById('prices-content').innerHTML =
'<div class="empty-state">' +
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2" y="5" width="20" height="14" rx="2"/><line x1="2" y1="10" x2="22" y2="10"/></svg>' +
'<p>No Stripe prices configured</p>' +
'</div>';
return;
}
let html = '<table><thead><tr><th>Plan</th><th>Currency</th><th>Status</th></tr></thead><tbody>';
data.prices.forEach(p => {
html += '<tr>' +
'<td>' + p.displayName + '</td>' +
'<td>' + (p.currency ? p.currency.toUpperCase() : 'Default') + '</td>' +
'<td><span class="badge">' + p.priceID.substring(0, 12) + '...</span></td>' +
'</tr>';
});
html += '</tbody></table>';
document.getElementById('prices-content').innerHTML = html;
})
.catch(err => {
document.getElementById('prices-content').innerHTML =
'<div class="error">Failed to load prices</div>';
});
</script>
</body>
</html>`
@@ -0,0 +1,513 @@
package handlers
import (
"bookra/apps/auth-service/internal/auth"
"bookra/apps/auth-service/internal/billing"
"bookra/apps/auth-service/internal/config"
"bookra/apps/auth-service/internal/db"
"bookra/apps/auth-service/internal/email"
"bookra/apps/auth-service/internal/oauth"
"context"
"crypto/rand"
"encoding/base64"
"errors"
"io"
"log"
"net/http"
"net/url"
"strings"
"time"
"github.com/gin-gonic/gin"
)
type Handler struct {
authSvc *auth.Service
neon *auth.NeonVerifier
billingSvc *billing.Service
google *oauth.GoogleProvider
cfg *config.Config
db *db.DB
}
type LoginRequest struct {
Email string `json:"email" binding:"required,email"`
Locale string `json:"locale,omitempty"`
}
type VerifyRequest struct {
Token string `json:"token" binding:"required"`
}
type RefreshRequest struct {
RefreshToken string `json:"refresh_token"`
}
type PasswordRegisterRequest struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=8"`
Name string `json:"name" binding:"required"`
}
type PasswordLoginRequest struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required"`
}
type CheckoutRequest struct {
PlanCode string `json:"planCode,omitempty"`
Currency string `json:"currency,omitempty"`
}
func New(db *db.DB, emailSvc *email.Service, cfg *config.Config) (*Handler, error) {
neonVerifier, err := auth.NewNeonVerifier(cfg.NeonAuthURL)
if err != nil {
return nil, err
}
return &Handler{
authSvc: auth.NewService(db, emailSvc, cfg.JWTSecret, cfg.FrontendURL),
neon: neonVerifier,
billingSvc: billing.NewService(cfg, db),
google: oauth.NewGoogleProvider(cfg),
cfg: cfg,
db: db,
}, nil
}
func (h *Handler) RegisterRoutes(r *gin.Engine) {
// Auth API
api := r.Group("/api/auth")
{
api.POST("/magic-link", h.SendMagicLink)
api.POST("/verify", h.VerifyMagicLink)
api.POST("/register", h.RegisterWithPassword)
api.POST("/login", h.LoginWithPassword)
api.POST("/refresh", h.RefreshToken)
api.GET("/me", h.RequireAuth(), h.GetMe)
api.POST("/logout", h.RequireAuth(), h.Logout)
api.GET("/providers", h.ListProviders)
api.GET("/oauth/google", h.GoogleAuth)
api.GET("/oauth/google/callback", h.GoogleCallback)
}
// Billing API
billingAPI := r.Group("/api/billing")
{
billingAPI.POST("/webhook", h.StripeWebhook)
billingAPI.GET("/subscription", h.RequireAuth(), h.GetSubscription)
billingAPI.POST("/checkout", h.RequireAuth(), h.CreateCheckoutSession)
billingAPI.POST("/refresh", h.RequireAuth(), h.RefreshSubscription)
billingAPI.GET("/plans", h.ListPlans)
}
// Admin Dashboard (Visual Management)
admin := NewAdminDashboard(h.cfg, h.db)
admin.RegisterRoutes(r)
}
func (h *Handler) SendMagicLink(c *gin.Context) {
var req LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Detect locale from request: JSON body > Accept-Language header > default "en"
locale := req.Locale
if locale == "" {
locale = detectLocale(c)
}
if err := h.authSvc.GenerateMagicLink(c.Request.Context(), req.Email, locale); err != nil {
log.Printf("magic link failed for %s: %v", req.Email, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to send magic link"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Magic link sent to your email"})
}
// detectLocale extracts locale from Accept-Language header
func detectLocale(c *gin.Context) string {
acceptLang := c.GetHeader("Accept-Language")
if strings.HasPrefix(acceptLang, "cs") || strings.Contains(acceptLang, "cs-") {
return "cs"
}
// Default to English
return "en"
}
func (h *Handler) VerifyMagicLink(c *gin.Context) {
var req VerifyRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
tokens, err := h.authSvc.VerifyMagicLink(c.Request.Context(), req.Token)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or expired token"})
return
}
c.JSON(http.StatusOK, tokens)
}
func (h *Handler) RegisterWithPassword(c *gin.Context) {
var req PasswordRegisterRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
tokens, err := h.authSvc.RegisterWithPassword(c.Request.Context(), req.Email, req.Password, req.Name)
if err != nil {
if strings.Contains(err.Error(), "already registered") {
c.JSON(http.StatusConflict, gin.H{"error": "Email already registered"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Registration failed"})
return
}
c.JSON(http.StatusCreated, tokens)
}
func (h *Handler) LoginWithPassword(c *gin.Context) {
var req PasswordLoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
tokens, err := h.authSvc.LoginWithPassword(c.Request.Context(), req.Email, req.Password)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"})
return
}
c.JSON(http.StatusOK, tokens)
}
func (h *Handler) RefreshToken(c *gin.Context) {
var req RefreshRequest
if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
refreshToken := strings.TrimSpace(req.RefreshToken)
if refreshToken == "" {
authHeader := c.GetHeader("Authorization")
if strings.HasPrefix(authHeader, "Bearer ") {
refreshToken = strings.TrimSpace(strings.TrimPrefix(authHeader, "Bearer "))
}
}
if refreshToken == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Missing refresh token"})
return
}
tokens, err := h.authSvc.RefreshTokens(c.Request.Context(), refreshToken)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid refresh token"})
return
}
c.JSON(http.StatusOK, tokens)
}
func (h *Handler) GetMe(c *gin.Context) {
claims, exists := c.Get("claims")
if !exists {
c.JSON(http.StatusInternalServerError, gin.H{"error": "claims not found"})
return
}
userClaims := claims.(*auth.Claims)
c.JSON(http.StatusOK, gin.H{
"id": userClaims.UserID,
"email": userClaims.Email,
"name": userClaims.Name,
"role": userClaims.Role,
})
}
func (h *Handler) Logout(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "Logged out"})
}
func (h *Handler) ListProviders(c *gin.Context) {
providers := []gin.H{}
if h.google.Enabled() {
providers = append(providers, gin.H{
"id": "google",
"name": "Google",
"url": "/api/auth/oauth/google",
})
}
providers = append(providers, gin.H{
"id": "email",
"name": "Email Magic Link",
})
c.JSON(http.StatusOK, gin.H{"providers": providers})
}
func (h *Handler) GoogleAuth(c *gin.Context) {
if !h.google.Enabled() {
c.JSON(http.StatusNotFound, gin.H{"error": "Google OAuth not configured"})
return
}
state := generateState()
url := h.google.GetAuthURL(state)
c.SetCookie("oauth_state", state, 600, "/", "", oauthCookieSecure(c, h.cfg), true)
c.Redirect(http.StatusTemporaryRedirect, url)
}
func (h *Handler) GoogleCallback(c *gin.Context) {
if !h.google.Enabled() {
c.JSON(http.StatusNotFound, gin.H{"error": "Google OAuth not configured"})
return
}
state := c.Query("state")
expectedState, err := c.Cookie("oauth_state")
if err != nil || state == "" || state != expectedState {
c.SetCookie("oauth_state", "", -1, "/", "", oauthCookieSecure(c, h.cfg), true)
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid OAuth state"})
return
}
c.SetCookie("oauth_state", "", -1, "/", "", oauthCookieSecure(c, h.cfg), true)
code := c.Query("code")
if code == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Missing code"})
return
}
user, err := h.google.ExchangeCode(c.Request.Context(), code)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "OAuth failed"})
return
}
providerID, email, name := h.google.ParseUser(user)
tokens, err := h.authSvc.OAuthLoginOrCreate(c.Request.Context(), "google", providerID, email, name)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to process login"})
return
}
redirectURL := h.cfg.FrontendURL + "/auth/callback?token=" + url.QueryEscape(tokens.AccessToken)
if tokens.RefreshToken != "" {
redirectURL += "&refresh_token=" + url.QueryEscape(tokens.RefreshToken)
}
c.Redirect(http.StatusTemporaryRedirect, redirectURL)
}
func (h *Handler) GetSubscription(c *gin.Context) {
claims, ok := h.claimsFromContext(c)
if !ok {
return
}
snapshot, err := h.billingSvc.GetSubscription(c.Request.Context(), claims.UserID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load subscription"})
return
}
c.JSON(http.StatusOK, snapshot)
}
func (h *Handler) CreateCheckoutSession(c *gin.Context) {
claims, ok := h.claimsFromContext(c)
if !ok {
return
}
var req CheckoutRequest
if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
response, err := h.billingSvc.CreateCheckoutSession(c.Request.Context(), billing.UserIdentity{
ID: claims.UserID,
Email: claims.Email,
Name: claims.Name,
}, req.PlanCode, req.Currency)
if err != nil {
switch {
case errors.Is(err, billing.ErrPlanNotConfigured):
c.JSON(http.StatusBadRequest, gin.H{"error": "Billing plan is not configured"})
case errors.Is(err, billing.ErrStripeNotConfigured):
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Stripe is not configured"})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create checkout session"})
}
return
}
c.JSON(http.StatusOK, response)
}
func (h *Handler) RefreshSubscription(c *gin.Context) {
claims, ok := h.claimsFromContext(c)
if !ok {
return
}
snapshot, err := h.billingSvc.Refresh(c.Request.Context(), claims.UserID)
if err != nil {
if errors.Is(err, billing.ErrStripeNotConfigured) {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Stripe is not configured"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to refresh subscription"})
return
}
c.JSON(http.StatusOK, snapshot)
}
// ListPlans returns available billing plans and their configuration status
func (h *Handler) ListPlans(c *gin.Context) {
plans := []gin.H{
{"code": "starter", "name": "Starter", "description": "For individuals and small teams"},
{"code": "pro", "name": "Pro", "description": "For growing businesses"},
{"code": "business", "name": "Business", "description": "For multi-location operations"},
}
// Check which plans are configured
configured := make(map[string]bool)
for planCode, priceID := range h.cfg.StripePriceIDs {
if priceID != "" {
configured[planCode] = true
}
}
for i, plan := range plans {
code := plan["code"].(string)
plan["czkConfigured"] = configured[code+":czk"] || configured[code]
plan["usdConfigured"] = configured[code+":usd"] || configured[code]
plans[i] = plan
}
c.JSON(http.StatusOK, gin.H{
"plans": plans,
"stripeConfigured": h.cfg.StripeCheckoutReady(),
"secretConfigured": h.cfg.StripeSecretConfigured(),
"webhookConfigured": h.cfg.StripeWebhookConfigured(),
"pricesConfigured": h.cfg.StripeHasAnyPriceConfigured(),
"checkoutReady": h.cfg.StripeCheckoutReady(),
"currencies": []string{"czk", "usd"},
})
}
func (h *Handler) StripeWebhook(c *gin.Context) {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 1<<20)
payload, err := io.ReadAll(c.Request.Body)
if err != nil {
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "Webhook payload is too large"})
return
}
if err := h.billingSvc.HandleWebhook(c.Request.Context(), c.GetHeader("Stripe-Signature"), payload); err != nil {
switch {
case errors.Is(err, billing.ErrStripeWebhookMissing), errors.Is(err, billing.ErrStripeSignatureMissing):
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid Stripe webhook"})
}
return
}
c.JSON(http.StatusOK, gin.H{"received": true})
}
func (h *Handler) RequireAuth() gin.HandlerFunc {
return func(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
tokenString := ""
if strings.HasPrefix(authHeader, "Bearer ") {
tokenString = strings.TrimPrefix(authHeader, "Bearer ")
}
if tokenString == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Missing token"})
c.Abort()
return
}
claims, err := h.verifyBearerToken(tokenString)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"})
c.Abort()
return
}
c.Set("claims", claims)
c.Next()
}
}
func (h *Handler) verifyBearerToken(tokenString string) (*auth.Claims, error) {
if h.neon != nil && h.neon.Enabled() {
return h.neon.Verify(tokenString)
}
if h.cfg.AppEnv == "development" {
return h.authSvc.VerifyToken(tokenString)
}
return nil, errors.New("neon auth is not configured")
}
func (h *Handler) claimsFromContext(c *gin.Context) (*auth.Claims, bool) {
claims, exists := c.Get("claims")
if !exists {
c.JSON(http.StatusInternalServerError, gin.H{"error": "claims not found"})
return nil, false
}
userClaims, ok := claims.(*auth.Claims)
if !ok {
c.JSON(http.StatusInternalServerError, gin.H{"error": "invalid claims"})
return nil, false
}
return userClaims, true
}
func generateState() string {
buffer := make([]byte, 24)
if _, err := rand.Read(buffer); err != nil {
return "state_" + time.Now().Format("20060102150405")
}
return "state_" + strings.TrimRight(base64.URLEncoding.EncodeToString(buffer), "=")
}
func oauthCookieSecure(c *gin.Context, cfg *config.Config) bool {
if c.Request.TLS != nil {
return true
}
if strings.EqualFold(c.GetHeader("X-Forwarded-Proto"), "https") {
return true
}
return strings.HasPrefix(strings.ToLower(strings.TrimSpace(cfg.FrontendURL)), "https://")
}
func timeoutMiddleware(duration time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
ctx, cancel := context.WithTimeout(c.Request.Context(), duration)
defer cancel()
c.Request = c.Request.WithContext(ctx)
c.Next()
}
}
@@ -0,0 +1,88 @@
package oauth
import (
"bookra/apps/auth-service/internal/config"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
)
type GoogleUser struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
Picture string `json:"picture"`
VerifiedEmail bool `json:"verified_email"`
}
type GoogleProvider struct {
config *oauth2.Config
}
func NewGoogleProvider(cfg *config.Config) *GoogleProvider {
if cfg.GoogleClientID == "" || cfg.GoogleClientSecret == "" {
return nil
}
redirectURL := cfg.GoogleRedirectURL
if redirectURL == "" {
redirectURL = cfg.FrontendURL + "/auth/oauth/google/callback"
}
return &GoogleProvider{
config: &oauth2.Config{
ClientID: cfg.GoogleClientID,
ClientSecret: cfg.GoogleClientSecret,
RedirectURL: redirectURL,
Scopes: []string{
"openid",
"https://www.googleapis.com/auth/userinfo.email",
"https://www.googleapis.com/auth/userinfo.profile",
},
Endpoint: google.Endpoint,
},
}
}
func (p *GoogleProvider) Enabled() bool {
return p != nil && p.config != nil
}
func (p *GoogleProvider) GetAuthURL(state string) string {
return p.config.AuthCodeURL(state, oauth2.AccessTypeOnline)
}
func (p *GoogleProvider) ExchangeCode(ctx context.Context, code string) (*GoogleUser, error) {
token, err := p.config.Exchange(ctx, code)
if err != nil {
return nil, fmt.Errorf("exchange code: %w", err)
}
client := p.config.Client(ctx, token)
resp, err := client.Get("https://www.googleapis.com/oauth2/v2/userinfo")
if err != nil {
return nil, fmt.Errorf("fetch userinfo: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("userinfo returned %d: %s", resp.StatusCode, string(body))
}
var user GoogleUser
if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
return nil, fmt.Errorf("decode userinfo: %w", err)
}
return &user, nil
}
func (p *GoogleProvider) ParseUser(user *GoogleUser) (providerID, email, name string) {
return user.ID, user.Email, user.Name
}
@@ -0,0 +1,40 @@
-- +goose Up
-- +goose StatementBegin
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) NOT NULL UNIQUE,
name VARCHAR(255),
password_hash VARCHAR(255),
email_verified BOOLEAN DEFAULT FALSE,
provider VARCHAR(50) NOT NULL DEFAULT 'email',
provider_id VARCHAR(255),
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
last_login_at TIMESTAMP WITH TIME ZONE
);
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
CREATE INDEX IF NOT EXISTS idx_users_provider ON users(provider, provider_id);
CREATE TABLE IF NOT EXISTS magic_links (
token VARCHAR(255) PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
email VARCHAR(255) NOT NULL,
used BOOLEAN DEFAULT FALSE,
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_magic_links_user_id ON magic_links(user_id);
CREATE INDEX IF NOT EXISTS idx_magic_links_expires ON magic_links(expires_at) WHERE used = FALSE;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP TABLE IF EXISTS magic_links;
DROP TABLE IF EXISTS users;
-- +goose StatementEnd
@@ -0,0 +1,21 @@
-- +goose Up
-- +goose StatementBegin
CREATE TABLE IF NOT EXISTS stripe_kv (
key TEXT PRIMARY KEY,
value JSONB NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_stripe_kv_updated_at ON stripe_kv(updated_at);
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP INDEX IF EXISTS idx_stripe_kv_updated_at;
DROP TABLE IF EXISTS stripe_kv;
-- +goose StatementEnd
+14
View File
@@ -0,0 +1,14 @@
{
"$schema": "https://railway.app/railway.schema.json",
"build": {
"builder": "DOCKERFILE",
"dockerfilePath": "Dockerfile"
},
"deploy": {
"restartPolicyType": "ON_FAILURE",
"restartPolicyMaxRetries": 10,
"healthcheckPath": "/health",
"healthcheckTimeout": 30,
"numReplicas": 1
}
}
+407
View File
@@ -0,0 +1,407 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Bookra Email Templates Preview</title>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,500;1,6..72,400&display=swap" rel="stylesheet">
<style>
* { box-sizing: border-box; }
body {
font-family: 'Newsreader', Georgia, serif;
background: #fbf9f6;
margin: 0;
padding: 40px 20px;
color: #2a221e;
}
.container { max-width: 1400px; margin: 0 auto; }
h1 {
font-family: 'Space Grotesk', sans-serif;
text-align: center;
color: #2a221e;
margin-bottom: 8px;
font-size: 32px;
font-weight: 600;
letter-spacing: -0.02em;
}
.subtitle {
text-align: center;
color: #5c514a;
margin-bottom: 48px;
font-size: 17px;
font-style: italic;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(600px, 1fr));
gap: 32px;
}
.card {
background: white;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 4px 6px -1px rgba(42, 34, 30, 0.05);
border: 1px solid #e8e2da;
}
.card-header {
background: #fbf9f6;
padding: 24px 28px;
border-bottom: 1px solid #e8e2da;
}
.card-header h2 {
margin: 0;
font-family: 'Space Grotesk', sans-serif;
font-size: 16px;
font-weight: 600;
color: #2a221e;
}
.card-header p {
margin: 4px 0 0;
color: #5c514a;
font-size: 14px;
font-style: italic;
}
.card-body { padding: 0; }
iframe {
width: 100%;
height: 500px;
border: none;
background: white;
}
.toggle {
display: flex;
justify-content: center;
gap: 12px;
margin-bottom: 40px;
}
.toggle button {
padding: 12px 28px;
border-radius: 8px;
border: 1px solid #e8e2da;
cursor: pointer;
font-family: 'Space Grotesk', sans-serif;
font-weight: 500;
font-size: 14px;
transition: all 0.2s;
background: white;
color: #5c514a;
}
.toggle button.active {
background: #a65c3e;
color: white;
border-color: #a65c3e;
}
.toggle button:not(.active):hover {
background: #f5f2ed;
}
</style>
</head>
<body>
<div class="container">
<h1>Bookra Email Templates</h1>
<p class="subtitle">Warm editorial aesthetic with terracotta accents</p>
<div class="toggle">
<button class="active" onclick="showLang('en')">English</button>
<button onclick="showLang('cs')">Čeština</button>
</div>
<div class="grid" id="emailGrid">
<!-- Magic Link EN -->
<div class="card" data-lang="en">
<div class="card-header">
<h2>Magic Link</h2>
<p>Passwordless authentication</p>
</div>
<div class="card-body">
<iframe srcdoc='
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Newsreader:ital,opsz,wght@0,6..72,400&display=swap" rel="stylesheet">
<style>
body { margin: 0; padding: 0; font-family: "Newsreader", Georgia, serif; background: #fbf9f6; }
.container { max-width: 600px; margin: 0 auto; background: white; }
.header { background: #fbf9f6; padding: 48px 40px; text-align: center; border-bottom: 1px solid #e8e2da; }
.logo { width: 56px; height: 56px; background: #24201d; border-radius: 14px; display: inline-flex; align-items: center; justify-content: center; font-family: "Space Grotesk", sans-serif; font-size: 28px; font-weight: 700; color: #f7f2e8; }
.brand { color: #2a221e; font-family: "Space Grotesk", sans-serif; font-size: 24px; font-weight: 600; margin-top: 16px; letter-spacing: -0.02em; }
.tagline { color: #8b7f76; font-size: 15px; margin-top: 6px; font-style: italic; }
.content { padding: 48px 40px; }
.greeting { font-family: "Space Grotesk", sans-serif; font-size: 20px; font-weight: 600; color: #2a221e; margin-bottom: 16px; }
.message { font-size: 17px; line-height: 1.6; color: #5c514a; margin-bottom: 32px; }
.button-wrap { margin: 40px 0; }
.button { display: inline-block; background: #a65c3e; color: white; padding: 16px 40px; text-decoration: none; border-radius: 8px; font-family: "Space Grotesk", sans-serif; font-weight: 500; font-size: 15px; }
.link-box { background: #f5f2ed; border: 1px solid #e8e2da; border-radius: 8px; padding: 20px; margin: 32px 0; }
.link-label { font-size: 12px; font-weight: 600; color: #8b7f76; text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 8px; font-family: "Space Grotesk", sans-serif; }
.link-url { font-size: 14px; color: #5c514a; word-break: break-all; font-family: monospace; }
.expiry { background: #f5ebe7; border-left: 3px solid #a65c3e; padding: 16px 20px; margin: 32px 0; font-size: 15px; color: #a65c3e; }
.help { font-size: 15px; color: #8b7f76; margin-top: 40px; padding-top: 32px; border-top: 1px solid #e8e2da; font-style: italic; }
.footer { background: #fbf9f6; padding: 32px 40px; text-align: center; border-top: 1px solid #e8e2da; }
.footer-text { font-size: 14px; color: #8b7f76; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="logo">B</div>
<div class="brand">Bookra</div>
<div class="tagline">Calm booking software</div>
</div>
<div class="content">
<div class="greeting">Hi Sarah,</div>
<div class="message">
You requested a sign-in link for your Bookra account. Click below to access your account securely — no password needed.
</div>
<div class="button-wrap">
<a href="#" class="button">Sign In to Bookra</a>
</div>
<div class="link-box">
<div class="link-label">Or copy this link</div>
<div class="link-url">https://bookra.tdvorak.dev/auth/callback?token=xyz123...</div>
</div>
<div class="expiry">
This link expires in <strong>15 minutes</strong> for security.
</div>
<div class="help">
Didn&apos;t request this? You can safely ignore it.
</div>
</div>
<div class="footer">
<div class="footer-text">© 2024 Bookra</div>
</div>
</div>
</body>
</html>'>
</iframe>
</div>
</div>
<!-- Welcome EN -->
<div class="card" data-lang="en">
<div class="card-header">
<h2>Welcome Email</h2>
<p>New user onboarding</p>
</div>
<div class="card-body">
<iframe srcdoc='
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Newsreader:ital,opsz,wght@0,6..72,400&display=swap" rel="stylesheet">
<style>
body { margin: 0; padding: 0; font-family: "Newsreader", Georgia, serif; background: #fbf9f6; }
.container { max-width: 600px; margin: 0 auto; background: white; }
.header { background: #fbf9f6; padding: 48px 40px; text-align: center; border-bottom: 1px solid #e8e2da; }
.logo { width: 56px; height: 56px; background: #24201d; border-radius: 14px; display: inline-flex; align-items: center; justify-content: center; font-family: "Space Grotesk", sans-serif; font-size: 28px; font-weight: 700; color: #f7f2e8; }
.brand { color: #2a221e; font-family: "Space Grotesk", sans-serif; font-size: 24px; font-weight: 600; margin-top: 16px; }
.content { padding: 48px 40px; }
.greeting { font-family: "Space Grotesk", sans-serif; font-size: 28px; font-weight: 600; color: #2a221e; margin-bottom: 16px; }
.message { font-size: 18px; line-height: 1.6; color: #5c514a; margin-bottom: 32px; }
.features { background: #f5f2ed; border-radius: 12px; padding: 32px; margin: 32px 0; }
.feature { display: flex; align-items: flex-start; gap: 16px; margin-bottom: 20px; }
.feature:last-child { margin-bottom: 0; }
.feature-icon { width: 24px; height: 24px; background: #a65c3e; border-radius: 6px; display: flex; align-items: center; justify-content: center; color: white; font-size: 14px; flex-shrink: 0; }
.feature-text { font-size: 16px; color: #5c514a; line-height: 1.5; }
.button-wrap { margin: 40px 0; text-align: center; }
.button { display: inline-block; background: #a65c3e; color: white; padding: 16px 40px; text-decoration: none; border-radius: 8px; font-family: "Space Grotesk", sans-serif; font-weight: 500; font-size: 15px; }
.footer { background: #fbf9f6; padding: 32px 40px; text-align: center; border-top: 1px solid #e8e2da; }
.footer-text { font-size: 14px; color: #8b7f76; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="logo">B</div>
<div class="brand">Bookra</div>
</div>
<div class="content">
<div class="greeting">Welcome, Sarah</div>
<div class="message">
Thanks for joining Bookra. We&apos;re here to help you manage bookings with calm and clarity.
</div>
<div class="features">
<div class="feature">
<div class="feature-icon">✓</div>
<div class="feature-text"><strong>Smart scheduling</strong> — Automatic conflict detection</div>
</div>
<div class="feature">
<div class="feature-icon">✓</div>
<div class="feature-text"><strong>Customer insights</strong> — History and preferences</div>
</div>
<div class="feature">
<div class="feature-icon">✓</div>
<div class="feature-text"><strong>Reminders</strong> — Reduce no-shows</div>
</div>
</div>
<div class="button-wrap">
<a href="#" class="button">Open Dashboard</a>
</div>
</div>
<div class="footer">
<div class="footer-text">© 2024 Bookra</div>
</div>
</div>
</body>
</html>'>
</iframe>
</div>
</div>
<!-- Booking Confirmation EN -->
<div class="card" data-lang="en">
<div class="card-header">
<h2>Booking Confirmation</h2>
<p>Customer confirmation email</p>
</div>
<div class="card-body">
<iframe srcdoc='
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Newsreader:ital,opsz,wght@0,6..72,400&display=swap" rel="stylesheet">
<style>
body { margin: 0; padding: 0; font-family: "Newsreader", Georgia, serif; background: #fbf9f6; }
.container { max-width: 600px; margin: 0 auto; background: white; }
.header { background: #fbf9f6; padding: 48px 40px; text-align: center; border-bottom: 1px solid #e8e2da; }
.logo { width: 56px; height: 56px; background: #24201d; border-radius: 14px; display: inline-flex; align-items: center; justify-content: center; font-family: "Space Grotesk", sans-serif; font-size: 28px; font-weight: 700; color: #f7f2e8; }
.brand { color: #2a221e; font-family: "Space Grotesk", sans-serif; font-size: 24px; font-weight: 600; margin-top: 16px; }
.content { padding: 48px 40px; }
.badge { display: inline-block; background: #f5ebe7; color: #a65c3e; padding: 8px 16px; border-radius: 20px; font-size: 13px; font-weight: 600; margin-bottom: 24px; font-family: "Space Grotesk", sans-serif; text-transform: uppercase; letter-spacing: 0.05em; }
.greeting { font-family: "Space Grotesk", sans-serif; font-size: 24px; font-weight: 600; color: #2a221e; margin-bottom: 8px; }
.message { font-size: 17px; color: #5c514a; margin-bottom: 32px; }
.details { background: #f5f2ed; border-radius: 12px; padding: 28px; margin: 32px 0; }
.detail-row { display: flex; padding: 14px 0; border-bottom: 1px solid #e8e2da; }
.detail-row:last-child { border-bottom: none; }
.detail-label { width: 120px; font-size: 12px; font-weight: 600; color: #8b7f76; text-transform: uppercase; letter-spacing: 0.05em; font-family: "Space Grotesk", sans-serif; }
.detail-value { flex: 1; font-size: 16px; color: #2a221e; font-weight: 500; }
.help { font-size: 15px; color: #8b7f76; margin-top: 32px; padding-top: 32px; border-top: 1px solid #e8e2da; font-style: italic; }
.footer { background: #fbf9f6; padding: 32px 40px; text-align: center; border-top: 1px solid #e8e2da; }
.footer-text { font-size: 14px; color: #8b7f76; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="logo">B</div>
<div class="brand">Bookra</div>
</div>
<div class="content">
<div class="badge">Confirmed</div>
<div class="greeting">Hello Sarah,</div>
<div class="message">
Your booking with <strong>Studio Ella</strong> is confirmed.
</div>
<div class="details">
<div class="detail-row">
<div class="detail-label">Service</div>
<div class="detail-value">Haircut &amp; Styling</div>
</div>
<div class="detail-row">
<div class="detail-label">When</div>
<div class="detail-value">Monday, April 22 at 2:00 PM</div>
</div>
<div class="detail-row">
<div class="detail-label">Where</div>
<div class="detail-value">123 Main Street, Prague 1</div>
</div>
</div>
<div class="help">
Need to reschedule? Contact Studio Ella directly.
</div>
</div>
<div class="footer">
<div class="footer-text">© 2024 Bookra</div>
</div>
</div>
</body>
</html>'>
</iframe>
</div>
</div>
<!-- Magic Link CS -->
<div class="card" data-lang="cs" style="display:none">
<div class="card-header">
<h2>Magický Odkaz (CZ)</h2>
<p>Přihlášení bez hesla</p>
</div>
<div class="card-body">
<iframe srcdoc='
<!DOCTYPE html>
<html lang="cs">
<head>
<meta charset="UTF-8">
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Newsreader:ital,opsz,wght@0,6..72,400&display=swap" rel="stylesheet">
<style>
body { margin: 0; padding: 0; font-family: "Newsreader", Georgia, serif; background: #fbf9f6; }
.container { max-width: 600px; margin: 0 auto; background: white; }
.header { background: #fbf9f6; padding: 48px 40px; text-align: center; border-bottom: 1px solid #e8e2da; }
.logo { width: 56px; height: 56px; background: #24201d; border-radius: 14px; display: inline-flex; align-items: center; justify-content: center; font-family: "Space Grotesk", sans-serif; font-size: 28px; font-weight: 700; color: #f7f2e8; }
.brand { color: #2a221e; font-family: "Space Grotesk", sans-serif; font-size: 24px; font-weight: 600; margin-top: 16px; letter-spacing: -0.02em; }
.tagline { color: #8b7f76; font-size: 15px; margin-top: 6px; font-style: italic; }
.content { padding: 48px 40px; }
.greeting { font-family: "Space Grotesk", sans-serif; font-size: 20px; font-weight: 600; color: #2a221e; margin-bottom: 16px; }
.message { font-size: 17px; line-height: 1.6; color: #5c514a; margin-bottom: 32px; }
.button-wrap { margin: 40px 0; }
.button { display: inline-block; background: #a65c3e; color: white; padding: 16px 40px; text-decoration: none; border-radius: 8px; font-family: "Space Grotesk", sans-serif; font-weight: 500; font-size: 15px; }
.link-box { background: #f5f2ed; border: 1px solid #e8e2da; border-radius: 8px; padding: 20px; margin: 32px 0; }
.link-label { font-size: 12px; font-weight: 600; color: #8b7f76; text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 8px; font-family: "Space Grotesk", sans-serif; }
.link-url { font-size: 14px; color: #5c514a; word-break: break-all; font-family: monospace; }
.expiry { background: #f5ebe7; border-left: 3px solid #a65c3e; padding: 16px 20px; margin: 32px 0; font-size: 15px; color: #a65c3e; }
.help { font-size: 15px; color: #8b7f76; margin-top: 40px; padding-top: 32px; border-top: 1px solid #e8e2da; font-style: italic; }
.footer { background: #fbf9f6; padding: 32px 40px; text-align: center; border-top: 1px solid #e8e2da; }
.footer-text { font-size: 14px; color: #8b7f76; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="logo">B</div>
<div class="brand">Bookra</div>
<div class="tagline">Klidný rezervační software</div>
</div>
<div class="content">
<div class="greeting">Dobrý den Martino,</div>
<div class="message">
Požádali jste o přihlašovací odkaz k účtu Bookra. Klikněte níže pro bezpečný přístup — heslo není potřeba.
</div>
<div class="button-wrap">
<a href="#" class="button">Přihlásit se do Bookra</a>
</div>
<div class="link-box">
<div class="link-label">Nebo zkopírujte tento odkaz</div>
<div class="link-url">https://bookra.tdvorak.dev/auth/callback?token=xyz123...</div>
</div>
<div class="expiry">
Tento odkaz vyprší za <strong>15 minut</strong> z bezpečnostních důvodů.
</div>
<div class="help">
Nepožádali jste o tento email? Můžete ho bezpečně ignorovat.
</div>
</div>
<div class="footer">
<div class="footer-text">© 2024 Bookra</div>
</div>
</div>
</body>
</html>'>
</iframe>
</div>
</div>
</div>
</div>
<script>
function showLang(lang) {
document.querySelectorAll(".toggle button").forEach(btn => btn.classList.remove("active"));
event.target.classList.add("active");
document.querySelectorAll("[data-lang]").forEach(card => {
card.style.display = card.dataset.lang === lang ? "block" : "none";
});
}
</script>
</body>
</html>