mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-06-03 20:13:00 +00:00
feat(core): consolidate auth service into backend and implement stripe billing
This commit performs a major architectural refactor by migrating the standalone `auth-service` into the main `backend` application, enabling a unified codebase and simplified deployment. It also introduces comprehensive Stripe billing support and a new administrative dashboard.
Key changes:
- **Architecture**: Deleted `apps/auth-service` and integrated its functionality (JWT, magic links, OAuth, user management) into `apps/backend`.
- **Billing**: Added Stripe integration to `backend`, supporting both monthly and yearly subscription cycles with automatic plan entitlement enforcement (e.g., location limits).
- **Admin Dashboard**: Implemented a new administrative service and API endpoints to manage tenants, users, and view platform-wide statistics.
- **Frontend**:
- Added a new pricing page with monthly/yearly toggle and comparison table.
- Integrated Stripe and Sentry for payments and error tracking.
- Improved dashboard UX/UI and added i18n support for new features.
- Enhanced the public booking flow with better validation and contact form integration.
- **Database**: Added migrations for users, magic links, password resets, OAuth states, admin audit logs, and refresh tokens.
- **DevOps**: Updated environment configurations for Railway and Vercel, and streamlined the project's `package.json` scripts.
This commit is contained in:
@@ -0,0 +1,243 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"bookra/apps/backend/internal/db"
|
||||
"bookra/apps/backend/internal/domain"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrUnauthorized = errors.New("unauthorized")
|
||||
ErrForbidden = errors.New("forbidden: admin access required")
|
||||
ErrInvalidAdminCreds = errors.New("invalid admin credentials")
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
repo db.Repository
|
||||
adminEmail string
|
||||
adminKey string
|
||||
}
|
||||
|
||||
func NewService(repo db.Repository, adminEmail, adminKey string) *Service {
|
||||
return &Service{
|
||||
repo: repo,
|
||||
adminEmail: adminEmail,
|
||||
adminKey: adminKey,
|
||||
}
|
||||
}
|
||||
|
||||
// IsConfigured returns true if admin credentials are set
|
||||
func (s *Service) IsConfigured() bool {
|
||||
return s.adminEmail != "" && s.adminKey != ""
|
||||
}
|
||||
|
||||
// ValidateAdminLogin checks if the provided credentials match the admin credentials
|
||||
// Uses constant-time comparison to prevent timing attacks
|
||||
func (s *Service) ValidateAdminLogin(email, key string) bool {
|
||||
if !s.IsConfigured() {
|
||||
return false
|
||||
}
|
||||
|
||||
emailMatch := subtle.ConstantTimeCompare([]byte(email), []byte(s.adminEmail)) == 1
|
||||
keyMatch := subtle.ConstantTimeCompare([]byte(key), []byte(s.adminKey)) == 1
|
||||
|
||||
return emailMatch && keyMatch
|
||||
}
|
||||
|
||||
// RequireAdmin is middleware that checks for admin authentication
|
||||
// It supports two modes:
|
||||
// 1. Admin credentials via X-Admin-Email and X-Admin-Key headers (for API access)
|
||||
// 2. Session-based auth where the user has role "admin" or "superadmin"
|
||||
func RequireAdmin(adminSvc *Service, authSvc interface{ IsAdmin(ctx context.Context, userID string) (bool, error) }) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// Check for admin header credentials (direct admin login)
|
||||
adminEmail := c.GetHeader("X-Admin-Email")
|
||||
adminKey := c.GetHeader("X-Admin-Key")
|
||||
|
||||
if adminEmail != "" && adminKey != "" {
|
||||
if adminSvc.ValidateAdminLogin(adminEmail, adminKey) {
|
||||
c.Set("isAdmin", true)
|
||||
c.Set("adminMode", "credentials")
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid admin credentials"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check for Bearer token with admin role
|
||||
auth := c.GetHeader("Authorization")
|
||||
if auth != "" && strings.HasPrefix(auth, "Bearer ") {
|
||||
// The auth middleware should have already validated the token
|
||||
// and set the user info in context
|
||||
userID, exists := c.Get("userID")
|
||||
if !exists {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
isAdmin, err := authSvc.IsAdmin(c.Request.Context(), userID.(string))
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "failed to check admin status"})
|
||||
return
|
||||
}
|
||||
|
||||
if isAdmin {
|
||||
c.Set("isAdmin", true)
|
||||
c.Set("adminMode", "session")
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "admin access required"})
|
||||
}
|
||||
}
|
||||
|
||||
// GetDashboardStats returns platform-wide statistics for admin dashboard
|
||||
func (s *Service) GetDashboardStats(ctx context.Context) (domain.AdminDashboardStats, error) {
|
||||
stats, err := s.repo.GetPlatformStats(ctx)
|
||||
if err != nil {
|
||||
return domain.AdminDashboardStats{}, err
|
||||
}
|
||||
|
||||
return domain.AdminDashboardStats{
|
||||
TotalTenants: stats.TotalTenants,
|
||||
TotalUsers: stats.TotalUsers,
|
||||
ActiveSubscriptions: stats.ActiveSubscriptions,
|
||||
TrialSubscriptions: stats.TrialSubscriptions,
|
||||
BookingsThisMonth: stats.BookingsThisMonth,
|
||||
RevenueThisMonthCents: stats.RevenueThisMonth,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ListTenants returns paginated list of all tenants
|
||||
func (s *Service) ListTenants(ctx context.Context, page, pageSize int) (domain.AdminTenantList, error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 || pageSize > 100 {
|
||||
pageSize = 20
|
||||
}
|
||||
|
||||
offset := (page - 1) * pageSize
|
||||
|
||||
tenants, total, err := s.repo.ListAllTenants(ctx, pageSize, offset)
|
||||
if err != nil {
|
||||
return domain.AdminTenantList{}, err
|
||||
}
|
||||
|
||||
result := domain.AdminTenantList{
|
||||
Total: total,
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
Tenants: make([]domain.AdminTenant, len(tenants)),
|
||||
}
|
||||
|
||||
for i, t := range tenants {
|
||||
result.Tenants[i] = domain.AdminTenant{
|
||||
ID: t.ID,
|
||||
Slug: t.Slug,
|
||||
Name: t.Name,
|
||||
PlanCode: t.PlanCode,
|
||||
SubscriptionStatus: t.SubscriptionStatus,
|
||||
BillingProvider: t.BillingProvider,
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ListUsers returns paginated list of all users
|
||||
func (s *Service) ListUsers(ctx context.Context, page, pageSize int) (domain.AdminUserList, error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 || pageSize > 100 {
|
||||
pageSize = 20
|
||||
}
|
||||
|
||||
offset := (page - 1) * pageSize
|
||||
|
||||
users, total, err := s.repo.ListAllUsers(ctx, pageSize, offset)
|
||||
if err != nil {
|
||||
return domain.AdminUserList{}, err
|
||||
}
|
||||
|
||||
result := domain.AdminUserList{
|
||||
Total: total,
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
Users: make([]domain.AdminUser, len(users)),
|
||||
}
|
||||
|
||||
for i, u := range users {
|
||||
result.Users[i] = domain.AdminUser{
|
||||
ID: u.ID.String(),
|
||||
Email: u.Email,
|
||||
Name: stringPtrToStr(u.Name),
|
||||
EmailVerified: u.EmailVerified,
|
||||
Provider: u.Provider,
|
||||
Role: u.Role,
|
||||
CreatedAt: u.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// UpdateUserRole changes a user's role
|
||||
func (s *Service) UpdateUserRole(ctx context.Context, adminUserID, targetUserID, newRole string, ip, userAgent string) error {
|
||||
// Validate role
|
||||
validRoles := map[string]bool{
|
||||
"user": true,
|
||||
"admin": true,
|
||||
"superadmin": true,
|
||||
}
|
||||
if !validRoles[newRole] {
|
||||
return errors.New("invalid role")
|
||||
}
|
||||
|
||||
if err := s.repo.UpdateUserRole(ctx, targetUserID, newRole); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Log the action
|
||||
return s.repo.CreateAdminAuditLog(ctx, db.AdminAuditLogParams{
|
||||
AdminUserID: adminUserID,
|
||||
Action: "update_user_role",
|
||||
ResourceType: "user",
|
||||
ResourceID: targetUserID,
|
||||
Details: map[string]any{
|
||||
"newRole": newRole,
|
||||
},
|
||||
IPAddress: ip,
|
||||
UserAgent: userAgent,
|
||||
})
|
||||
}
|
||||
|
||||
// SyncTenantSubscription manually syncs a tenant's subscription from Stripe
|
||||
func (s *Service) SyncTenantSubscription(ctx context.Context, tenantID string) error {
|
||||
// This will be called from the billing service
|
||||
return nil
|
||||
}
|
||||
|
||||
func stringPtrToStr(s *string) string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
return *s
|
||||
}
|
||||
|
||||
func init() {
|
||||
// Ensure time package is imported
|
||||
_ = time.Now()
|
||||
}
|
||||
Reference in New Issue
Block a user