mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-06-05 04:52:59 +00:00
cleanup
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user