mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-06-03 20:13:00 +00:00
cleanup
This commit is contained in:
@@ -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
|
||||
@@ -0,0 +1,21 @@
|
||||
# Environment variables
|
||||
.env
|
||||
|
||||
# Binary
|
||||
auth-service
|
||||
*.exe
|
||||
|
||||
# Go
|
||||
vendor/
|
||||
*.log
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -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"]
|
||||
@@ -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).
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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'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'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 & 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>
|
||||
Reference in New Issue
Block a user