mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-06-03 20:13:00 +00:00
164a37e997
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.
244 lines
6.2 KiB
Go
244 lines
6.2 KiB
Go
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()
|
|
}
|