Initial commit: SmoothSchedule multi-tenant scheduling platform
This commit includes: - Django backend with multi-tenancy (django-tenants) - React + TypeScript frontend with Vite - Platform administration API with role-based access control - Authentication system with token-based auth - Quick login dev tools for testing different user roles - CORS and CSRF configuration for local development - Docker development environment setup 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
180
PLATFORM_INTEGRATION.md
Normal file
@@ -0,0 +1,180 @@
|
||||
# Platform Integration Summary
|
||||
|
||||
## What We've Completed
|
||||
|
||||
### ✅ Backend (Django)
|
||||
|
||||
1. **Created Platform App** (`smoothschedule/platform/`)
|
||||
- `apps.py` - App configuration
|
||||
- `serializers.py` - TenantSerializer, PlatformUserSerializer, PlatformMetricsSerializer
|
||||
- `permissions.py` - IsPlatformAdmin, IsPlatformUser
|
||||
- `views.py` - TenantViewSet, PlatformUserViewSet
|
||||
- `urls.py` - URL routing
|
||||
|
||||
2. **API Endpoints Created**
|
||||
- `GET /api/platform/businesses/` - List all tenants
|
||||
- `GET /api/platform/businesses/{id}/` - Get specific tenant
|
||||
- `GET /api/platform/businesses/metrics/` - Get platform metrics
|
||||
- `GET /api/platform/users/` - List all users
|
||||
- `GET /api/platform/users/{id}/` - Get specific user
|
||||
|
||||
3. **Configuration Updates**
|
||||
- Added `platform` to `INSTALLED_APPS` in `config/settings/base.py`
|
||||
- Added platform URLs to `config/urls.py`
|
||||
|
||||
### ✅ Frontend (React)
|
||||
|
||||
The frontend already has complete implementation from the tarball:
|
||||
|
||||
1. **Platform Pages**
|
||||
- `pages/platform/PlatformDashboard.tsx` - Metrics and charts
|
||||
- `pages/platform/PlatformBusinesses.tsx` - Tenant list with masquerade
|
||||
- `pages/platform/PlatformUsers.tsx` - User management
|
||||
- `pages/platform/PlatformSupport.tsx` - Support tickets
|
||||
- `pages/platform/PlatformSettings.tsx` - Platform settings
|
||||
|
||||
2. **Routing**
|
||||
- Role-based access configured in `App.tsx`
|
||||
- Routes protected by user role (SUPERUSER, PLATFORM_MANAGER, etc.)
|
||||
|
||||
3. **API Integration**
|
||||
- `hooks/usePlatform.ts` - React Query hooks
|
||||
- `api/platform.ts` - API client functions
|
||||
|
||||
## Next Steps
|
||||
|
||||
### 1. Restart Django Server
|
||||
The Django server needs to be restarted to pick up the new `platform` app:
|
||||
|
||||
```bash
|
||||
# Stop the current server (Ctrl+C in the terminal)
|
||||
# Then restart it
|
||||
docker-compose -f docker-compose.local.yml restart django
|
||||
```
|
||||
|
||||
### 2. Test API Endpoints
|
||||
|
||||
Once the server is restarted, test the endpoints:
|
||||
|
||||
```bash
|
||||
# List all tenants (requires authentication)
|
||||
curl -H "Authorization: Bearer YOUR_TOKEN" http://lvh.me:8000/api/platform/businesses/
|
||||
|
||||
# List all users
|
||||
curl -H "Authorization: Bearer YOUR_TOKEN" http://lvh.me:8000/api/platform/users/
|
||||
```
|
||||
|
||||
### 3. Create Platform Admin User
|
||||
|
||||
You need a user with platform admin role to access these endpoints:
|
||||
|
||||
```python
|
||||
# In Django shell
|
||||
from smoothschedule.users.models import User
|
||||
|
||||
# Create or update a superuser
|
||||
user = User.objects.filter(username='admin').first()
|
||||
if user:
|
||||
user.role = User.Role.SUPERUSER
|
||||
user.is_staff = True
|
||||
user.is_superuser = True
|
||||
user.save()
|
||||
```
|
||||
|
||||
### 4. Test Frontend
|
||||
|
||||
1. Navigate to `http://platform.lvh.me:5173/`
|
||||
2. Login with platform admin credentials
|
||||
3. You should see:
|
||||
- Platform Dashboard with metrics
|
||||
- Businesses page showing all tenants
|
||||
- Users page showing all users
|
||||
- Support and Settings pages
|
||||
|
||||
## Known Limitations
|
||||
|
||||
### Need to Add Tenant Reference to User Model
|
||||
|
||||
Currently, the `User` model doesn't have a direct reference to which `Tenant` they belong to. This is needed for:
|
||||
- Showing business name in user list
|
||||
- Filtering users by business
|
||||
- Getting accurate owner information
|
||||
|
||||
**To fix:** Add a foreign key to `User` model:
|
||||
|
||||
```python
|
||||
# In smoothschedule/users/models.py
|
||||
from core.models import Tenant
|
||||
|
||||
class User(AbstractUser):
|
||||
# ... existing fields ...
|
||||
|
||||
tenant = models.ForeignKey(
|
||||
Tenant,
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='users',
|
||||
help_text="Tenant this user belongs to (null for platform users)"
|
||||
)
|
||||
```
|
||||
|
||||
Then run migrations:
|
||||
```bash
|
||||
python manage.py makemigrations
|
||||
python manage.py migrate
|
||||
```
|
||||
|
||||
## Architecture Notes
|
||||
|
||||
### Multi-Tenancy with django-tenants
|
||||
|
||||
- **Public Schema**: Stores `Tenant` and `Domain` models
|
||||
- **Tenant Schemas**: Each tenant gets isolated PostgreSQL schema
|
||||
- **Platform API**: Operates on public schema to list all tenants
|
||||
- **User Model**: Stored in public schema (shared across tenants)
|
||||
|
||||
### Permission Model
|
||||
|
||||
1. **Platform Roles** (access all tenants):
|
||||
- `SUPERUSER` - Full access
|
||||
- `PLATFORM_MANAGER` - Manage tenants and users
|
||||
- `PLATFORM_SUPPORT` - View-only access
|
||||
- `PLATFORM_SALES` - Sales operations
|
||||
|
||||
2. **Tenant Roles** (access single tenant):
|
||||
- `TENANT_OWNER` - Tenant administrator
|
||||
- `TENANT_MANAGER` - Manage tenant settings
|
||||
- `TENANT_STAFF` - Use tenant features
|
||||
|
||||
3. **Customer Role**:
|
||||
- `CUSTOMER` - End user of tenant
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
smoothschedule/
|
||||
├── platform/ # NEW - Platform management app
|
||||
│ ├── __init__.py
|
||||
│ ├── apps.py
|
||||
│ ├── serializers.py # API serializers
|
||||
│ ├── permissions.py # Custom permissions
|
||||
│ ├── views.py # ViewSets
|
||||
│ └── urls.py # URL routing
|
||||
├── core/
|
||||
│ └── models.py # Tenant, Domain models
|
||||
├── smoothschedule/users/
|
||||
│ └── models.py # User model
|
||||
└── config/
|
||||
├── settings/base.py # Updated INSTALLED_APPS
|
||||
└── urls.py # Added platform URLs
|
||||
```
|
||||
|
||||
## Testing the Complete Flow
|
||||
|
||||
1. **Backend**: Platform API serving tenant/user data
|
||||
2. **Frontend**: React app consuming API and displaying in UI
|
||||
3. **Authentication**: Role-based access control
|
||||
4. **Multi-tenancy**: Proper isolation between tenants
|
||||
|
||||
Once the server is restarted and a platform admin user is created, the entire platform section should be functional!
|
||||
294
README.md
Normal file
@@ -0,0 +1,294 @@
|
||||
# Smooth Schedule - Multi-Tenant SaaS Platform
|
||||
|
||||
A production-grade Django skeleton with **strict data isolation** and **high-trust security** for resource orchestration.
|
||||
|
||||
## 🎯 Features
|
||||
|
||||
- ✅ **Multi-Tenancy**: PostgreSQL schema-per-tenant using django-tenants
|
||||
- ✅ **8-Tier Role Hierarchy**: From SUPERUSER to CUSTOMER with strict permissions
|
||||
- ✅ **Secure Masquerading**: django-hijack with custom permission matrix
|
||||
- ✅ **Full Audit Trail**: Structured logging of all masquerade activity
|
||||
- ✅ **Headless API**: Django Rest Framework (no server-side HTML)
|
||||
- ✅ **Docker Ready**: Complete Docker Compose setup via cookiecutter-django
|
||||
- ✅ **AWS Integration**: S3 storage + Route53 DNS for custom domains
|
||||
|
||||
## 📋 Prerequisites
|
||||
|
||||
- Python 3.9+
|
||||
- PostgreSQL 14+
|
||||
- Docker & Docker Compose
|
||||
- Cookiecutter (`pip install cookiecutter`)
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### 1. Run Setup Script
|
||||
|
||||
```bash
|
||||
chmod +x setup_project.sh
|
||||
./setup_project.sh
|
||||
cd smoothschedule
|
||||
```
|
||||
|
||||
### 2. Configure Environment
|
||||
|
||||
Create `.env` file:
|
||||
|
||||
```env
|
||||
# Database
|
||||
POSTGRES_DB=smoothschedule_db
|
||||
POSTGRES_USER=smoothschedule_user
|
||||
POSTGRES_PASSWORD=your_secure_password
|
||||
|
||||
# Django
|
||||
DJANGO_SECRET_KEY=your_secret_key_here
|
||||
DJANGO_DEBUG=True
|
||||
DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1
|
||||
|
||||
# AWS
|
||||
AWS_ACCESS_KEY_ID=your_aws_key
|
||||
AWS_SECRET_ACCESS_KEY=your_aws_secret
|
||||
AWS_STORAGE_BUCKET_NAME=smoothschedule-media
|
||||
AWS_ROUTE53_HOSTED_ZONE_ID=your_zone_id
|
||||
```
|
||||
|
||||
### 3. Start Services
|
||||
|
||||
```bash
|
||||
docker-compose build
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
### 4. Run Migrations
|
||||
|
||||
```bash
|
||||
# Shared schema
|
||||
docker-compose run --rm django python manage.py migrate_schemas --shared
|
||||
|
||||
# Create superuser
|
||||
docker-compose run --rm django python manage.py createsuperuser
|
||||
```
|
||||
|
||||
### 5. Create First Tenant
|
||||
|
||||
```python
|
||||
docker-compose run --rm django python manage.py shell
|
||||
|
||||
from core.models import Tenant, Domain
|
||||
|
||||
tenant = Tenant.objects.create(
|
||||
name="Demo Company",
|
||||
schema_name="demo",
|
||||
subscription_tier="PROFESSIONAL",
|
||||
)
|
||||
|
||||
Domain.objects.create(
|
||||
domain="demo.smoothschedule.local",
|
||||
tenant=tenant,
|
||||
is_primary=True,
|
||||
)
|
||||
```
|
||||
|
||||
```bash
|
||||
# Run tenant migrations
|
||||
docker-compose run --rm django python manage.py migrate_schemas
|
||||
```
|
||||
|
||||
## 🏗️ Architecture
|
||||
|
||||
### Multi-Tenancy Model
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ PostgreSQL Database │
|
||||
├─────────────────────────────────────────┤
|
||||
│ public (shared schema) │
|
||||
│ ├─ Tenants │
|
||||
│ ├─ Domains │
|
||||
│ ├─ Users │
|
||||
│ └─ PermissionGrants │
|
||||
├─────────────────────────────────────────┤
|
||||
│ tenant_demo (schema for Demo Company) │
|
||||
│ ├─ Appointments │
|
||||
│ ├─ Resources │
|
||||
│ └─ Customers │
|
||||
├─────────────────────────────────────────┤
|
||||
│ tenant_acme (schema for Acme Corp) │
|
||||
│ ├─ Appointments │
|
||||
│ ├─ Resources │
|
||||
│ └─ Customers │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Role Hierarchy
|
||||
|
||||
| Role | Level | Access Scope |
|
||||
|---------------------|----------|---------------------------|
|
||||
| SUPERUSER | Platform | All tenants (god mode) |
|
||||
| PLATFORM_MANAGER | Platform | All tenants |
|
||||
| PLATFORM_SALES | Platform | Demo accounts only |
|
||||
| PLATFORM_SUPPORT | Platform | Tenant users |
|
||||
| TENANT_OWNER | Tenant | Own tenant (full access) |
|
||||
| TENANT_MANAGER | Tenant | Own tenant |
|
||||
| TENANT_STAFF | Tenant | Own tenant (limited) |
|
||||
| CUSTOMER | Tenant | Own data only |
|
||||
|
||||
### Masquerading Matrix
|
||||
|
||||
| Hijacker Role | Can Masquerade As |
|
||||
|--------------------|----------------------------------|
|
||||
| SUPERUSER | Anyone |
|
||||
| PLATFORM_SUPPORT | Tenant users |
|
||||
| PLATFORM_SALES | Demo accounts (`is_temporary=True`) |
|
||||
| TENANT_OWNER | Staff in same tenant |
|
||||
| Others | No one |
|
||||
|
||||
**Security Rules:**
|
||||
- Cannot hijack yourself
|
||||
- Cannot hijack SUPERUSERs (except by other SUPERUSERs)
|
||||
- Maximum depth: 1 (no hijack chains)
|
||||
- All attempts logged to `logs/masquerade.log`
|
||||
|
||||
## 📁 Project Structure
|
||||
|
||||
```
|
||||
smoothschedule/
|
||||
├── config/
|
||||
│ └── settings.py # Multi-tenancy & security config
|
||||
├── core/
|
||||
│ ├── models.py # Tenant, Domain, PermissionGrant
|
||||
│ ├── permissions.py # Hijack permission matrix
|
||||
│ ├── middleware.py # Masquerade audit logging
|
||||
│ └── admin.py # Django admin for core models
|
||||
├── users/
|
||||
│ ├── models.py # Custom User with 8-tier roles
|
||||
│ └── admin.py # User admin with hijack button
|
||||
├── logs/
|
||||
│ ├── security.log # General security events
|
||||
│ └── masquerade.log # Hijack activity (JSON)
|
||||
└── setup_project.sh # Automated setup script
|
||||
```
|
||||
|
||||
## 🔐 Security Features
|
||||
|
||||
### Audit Logging
|
||||
|
||||
All masquerade activity is logged in JSON format:
|
||||
|
||||
```json
|
||||
{
|
||||
"timestamp": "2024-01-15T10:30:00Z",
|
||||
"action": "HIJACK_START",
|
||||
"hijacker_email": "support@smoothschedule.com",
|
||||
"hijacked_email": "customer@demo.com",
|
||||
"ip_address": "192.168.1.1",
|
||||
"session_key": "abc123..."
|
||||
}
|
||||
```
|
||||
|
||||
### Permission Grants (30-Minute Window)
|
||||
|
||||
Time-limited elevated permissions:
|
||||
|
||||
```python
|
||||
from core.models import PermissionGrant
|
||||
|
||||
grant = PermissionGrant.create_grant(
|
||||
grantor=admin_user,
|
||||
grantee=support_user,
|
||||
action="view_billing",
|
||||
reason="Customer requested billing support",
|
||||
duration_minutes=30,
|
||||
)
|
||||
|
||||
# Check if active
|
||||
if grant.is_active():
|
||||
# Perform privileged action
|
||||
pass
|
||||
```
|
||||
|
||||
## 🧪 Testing Masquerading
|
||||
|
||||
1. Access Django Admin: `http://localhost:8000/admin/`
|
||||
2. Create test users with different roles
|
||||
3. Click "Hijack" button next to a user
|
||||
4. Verify audit logs: `docker-compose exec django cat logs/masquerade.log`
|
||||
|
||||
## 📊 Admin Interface
|
||||
|
||||
- **Tenant Management**: View tenants, domains, subscription tiers
|
||||
- **User Management**: Color-coded roles, masquerade buttons
|
||||
- **Permission Grants**: Active/expired/revoked status, bulk revoke
|
||||
- **Domain Verification**: AWS Route53 integration status
|
||||
|
||||
## 🛠️ Development
|
||||
|
||||
### Adding Tenant Apps
|
||||
|
||||
Edit `config/settings.py`:
|
||||
|
||||
```python
|
||||
TENANT_APPS = [
|
||||
'django.contrib.contenttypes',
|
||||
'appointments', # Your app
|
||||
'resources', # Your app
|
||||
'billing', # Your app
|
||||
]
|
||||
```
|
||||
|
||||
### Custom Domain Setup
|
||||
|
||||
```python
|
||||
domain = Domain.objects.create(
|
||||
domain="app.customdomain.com",
|
||||
tenant=tenant,
|
||||
is_custom_domain=True,
|
||||
route53_zone_id="Z1234567890ABC",
|
||||
)
|
||||
```
|
||||
|
||||
Then configure Route53 CNAME: `app.customdomain.com` → `smoothschedule.yourhost.com`
|
||||
|
||||
## 📖 Key Files Reference
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `setup_project.sh` | Automated project initialization |
|
||||
| `config/settings.py` | Multi-tenancy, middleware, security config |
|
||||
| `core/models.py` | Tenant, Domain, PermissionGrant models |
|
||||
| `core/permissions.py` | Masquerading permission matrix |
|
||||
| `core/middleware.py` | Audit logging for masquerading |
|
||||
| `users/models.py` | Custom User with 8-tier roles |
|
||||
|
||||
## 📝 Important Notes
|
||||
|
||||
- **Django Admin**: The ONLY HTML interface (everything else is API)
|
||||
- **Middleware Order**: `TenantMainMiddleware` must be first, `MasqueradeAuditMiddleware` after `HijackUserMiddleware`
|
||||
- **Tenant Isolation**: Each tenant's data is in a separate PostgreSQL schema
|
||||
- **Production**: Update `SECRET_KEY`, database credentials, and AWS keys via environment variables
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
**Cannot create tenant users:**
|
||||
- Error: "Users with role TENANT_STAFF must be assigned to a tenant"
|
||||
- Solution: Set `user.tenant = tenant_instance` before saving
|
||||
|
||||
**Hijack button doesn't appear:**
|
||||
- Check `HIJACK_AUTHORIZATION_CHECK` in settings
|
||||
- Verify `HijackUserAdminMixin` in `users/admin.py`
|
||||
- Ensure user has permission per matrix rules
|
||||
|
||||
**Migrations fail:**
|
||||
- Run shared migrations first: `migrate_schemas --shared`
|
||||
- Then run tenant migrations: `migrate_schemas`
|
||||
|
||||
## 📄 License
|
||||
|
||||
MIT
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
This is a production skeleton. Extend `TENANT_APPS` with your business logic.
|
||||
|
||||
---
|
||||
|
||||
**Built with ❤️ for multi-tenant SaaS perfection**
|
||||
302
config/settings.py
Normal file
@@ -0,0 +1,302 @@
|
||||
"""
|
||||
Smooth Schedule Settings - Multi-Tenancy & Security Configuration
|
||||
CRITICAL: This file contains essential settings for django-tenants and security middleware
|
||||
"""
|
||||
|
||||
# =============================================================================
|
||||
# MULTI-TENANCY CONFIGURATION (django-tenants)
|
||||
# =============================================================================
|
||||
|
||||
# Shared apps - Available to all tenants (stored in 'public' schema)
|
||||
SHARED_APPS = [
|
||||
'django_tenants', # Must be first
|
||||
'core', # Core models (Tenant, Domain, PermissionGrant)
|
||||
'users', # Custom User model - shared across all tenants
|
||||
|
||||
# Django built-ins
|
||||
'django.contrib.admin',
|
||||
'django.contrib.auth',
|
||||
'django.contrib.contenttypes',
|
||||
'django.contrib.sessions',
|
||||
'django.contrib.messages',
|
||||
'django.contrib.staticfiles',
|
||||
|
||||
# Third-party shared apps
|
||||
'rest_framework',
|
||||
'rest_framework.authtoken',
|
||||
'corsheaders',
|
||||
'django_filters',
|
||||
'hijack', # Masquerading
|
||||
'hijack.contrib.admin',
|
||||
|
||||
# Celery
|
||||
'django_celery_beat',
|
||||
'django_celery_results',
|
||||
]
|
||||
|
||||
# Tenant-specific apps - Each tenant gets isolated data in their own schema
|
||||
TENANT_APPS = [
|
||||
# Core tenant functionality
|
||||
'django.contrib.contenttypes', # Needed for tenant schemas
|
||||
|
||||
# Your tenant-scoped apps (add your business logic apps here)
|
||||
# Examples:
|
||||
# 'schedule', # Resource scheduling
|
||||
# 'customers', # Customer management
|
||||
# 'payments', # Billing & payments
|
||||
# 'appointments', # Appointment booking
|
||||
# 'analytics', # Tenant-specific analytics
|
||||
]
|
||||
|
||||
# Combined installed apps (django-tenants will handle schema routing)
|
||||
INSTALLED_APPS = list(SHARED_APPS) + [
|
||||
app for app in TENANT_APPS if app not in SHARED_APPS
|
||||
]
|
||||
|
||||
# Tenant model configuration
|
||||
TENANT_MODEL = "core.Tenant"
|
||||
TENANT_DOMAIN_MODEL = "core.Domain"
|
||||
|
||||
# Public schema name (for shared data)
|
||||
PUBLIC_SCHEMA_NAME = 'public'
|
||||
|
||||
# =============================================================================
|
||||
# DATABASE CONFIGURATION
|
||||
# =============================================================================
|
||||
|
||||
DATABASES = {
|
||||
'default': {
|
||||
'ENGINE': 'django_tenants.postgresql_backend', # Multi-schema engine
|
||||
'NAME': 'smoothschedule_db',
|
||||
'USER': 'smoothschedule_user',
|
||||
'PASSWORD': 'CHANGE_ME_IN_PRODUCTION', # Use env vars in production
|
||||
'HOST': 'localhost',
|
||||
'PORT': '5432',
|
||||
}
|
||||
}
|
||||
|
||||
# Database routers for tenant isolation
|
||||
DATABASE_ROUTERS = [
|
||||
'django_tenants.routers.TenantSyncRouter',
|
||||
]
|
||||
|
||||
# =============================================================================
|
||||
# MIDDLEWARE CONFIGURATION
|
||||
# =============================================================================
|
||||
# CRITICAL: Order matters! Read comments carefully.
|
||||
|
||||
MIDDLEWARE = [
|
||||
# 1. MUST BE FIRST: Tenant resolution
|
||||
'django_tenants.middleware.main.TenantMainMiddleware',
|
||||
|
||||
# 2. Security middleware
|
||||
'django.middleware.security.SecurityMiddleware',
|
||||
'whitenoise.middleware.WhiteNoiseMiddleware', # Static files
|
||||
|
||||
# 3. Session & CSRF
|
||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||
'corsheaders.middleware.CorsMiddleware',
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
'django.middleware.csrf.CsrfViewMiddleware',
|
||||
|
||||
# 4. Authentication
|
||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||
|
||||
# 5. Hijack (Masquerading) - MUST come before our audit middleware
|
||||
'hijack.middleware.HijackUserMiddleware',
|
||||
|
||||
# 6. MASQUERADE AUDIT - MUST come AFTER HijackUserMiddleware
|
||||
# This is our custom middleware that logs masquerading activity
|
||||
'core.middleware.MasqueradeAuditMiddleware',
|
||||
|
||||
# 7. Messages & Clickjacking
|
||||
'django.contrib.messages.middleware.MessageMiddleware',
|
||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||
]
|
||||
|
||||
# =============================================================================
|
||||
# AUTHENTICATION & USER MODEL
|
||||
# =============================================================================
|
||||
|
||||
AUTH_USER_MODEL = 'users.User' # Custom user model with roles
|
||||
|
||||
# Hijack (Masquerading) Configuration
|
||||
HIJACK_AUTHORIZATION_CHECK = 'core.permissions.can_hijack'
|
||||
HIJACK_DISPLAY_ADMIN_BUTTON = True # Show hijack button in admin
|
||||
HIJACK_USE_BOOTSTRAP = True
|
||||
HIJACK_ALLOW_GET_REQUESTS = False # Security: require POST for hijacking
|
||||
|
||||
# Track when hijack sessions start (for audit duration calculation)
|
||||
HIJACK_INSERT_BEFORE = True
|
||||
|
||||
# =============================================================================
|
||||
# REST FRAMEWORK CONFIGURATION
|
||||
# =============================================================================
|
||||
|
||||
REST_FRAMEWORK = {
|
||||
'DEFAULT_AUTHENTICATION_CLASSES': [
|
||||
'rest_framework.authentication.TokenAuthentication',
|
||||
'rest_framework.authentication.SessionAuthentication',
|
||||
],
|
||||
'DEFAULT_PERMISSION_CLASSES': [
|
||||
'rest_framework.permissions.IsAuthenticated',
|
||||
],
|
||||
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
|
||||
'PAGE_SIZE': 50,
|
||||
'DEFAULT_FILTER_BACKENDS': [
|
||||
'django_filters.rest_framework.DjangoFilterBackend',
|
||||
'rest_framework.filters.SearchFilter',
|
||||
'rest_framework.filters.OrderingFilter',
|
||||
],
|
||||
'DEFAULT_RENDERER_CLASSES': [
|
||||
'rest_framework.renderers.JSONRenderer',
|
||||
],
|
||||
# Add BrowsableAPIRenderer only in development
|
||||
# 'rest_framework.renderers.BrowsableAPIRenderer',
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# LOGGING CONFIGURATION
|
||||
# =============================================================================
|
||||
|
||||
LOGGING = {
|
||||
'version': 1,
|
||||
'disable_existing_loggers': False,
|
||||
'formatters': {
|
||||
'verbose': {
|
||||
'format': '[{levelname}] {asctime} {name} {message}',
|
||||
'style': '{',
|
||||
},
|
||||
'json': {
|
||||
'()': 'pythonjsonlogger.jsonlogger.JsonFormatter',
|
||||
'format': '%(asctime)s %(name)s %(levelname)s %(message)s',
|
||||
},
|
||||
},
|
||||
'handlers': {
|
||||
'console': {
|
||||
'class': 'logging.StreamHandler',
|
||||
'formatter': 'verbose',
|
||||
},
|
||||
'security_file': {
|
||||
'class': 'logging.handlers.RotatingFileHandler',
|
||||
'filename': 'logs/security.log',
|
||||
'maxBytes': 1024 * 1024 * 10, # 10 MB
|
||||
'backupCount': 5,
|
||||
'formatter': 'json',
|
||||
},
|
||||
'masquerade_file': {
|
||||
'class': 'logging.handlers.RotatingFileHandler',
|
||||
'filename': 'logs/masquerade.log',
|
||||
'maxBytes': 1024 * 1024 * 10, # 10 MB
|
||||
'backupCount': 5,
|
||||
'formatter': 'json',
|
||||
},
|
||||
},
|
||||
'loggers': {
|
||||
# General security logger
|
||||
'smoothschedule.security': {
|
||||
'handlers': ['console', 'security_file'],
|
||||
'level': 'INFO',
|
||||
'propagate': False,
|
||||
},
|
||||
# Masquerade-specific logger
|
||||
'smoothschedule.security.masquerade': {
|
||||
'handlers': ['console', 'masquerade_file'],
|
||||
'level': 'INFO',
|
||||
'propagate': False,
|
||||
},
|
||||
# Django default
|
||||
'django': {
|
||||
'handlers': ['console'],
|
||||
'level': 'INFO',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# AWS CONFIGURATION (S3, Route53)
|
||||
# =============================================================================
|
||||
|
||||
AWS_ACCESS_KEY_ID = 'YOUR_AWS_ACCESS_KEY' # Use env vars in production
|
||||
AWS_SECRET_ACCESS_KEY = 'YOUR_AWS_SECRET_KEY' # Use env vars in production
|
||||
AWS_STORAGE_BUCKET_NAME = 'smoothschedule-media'
|
||||
AWS_S3_REGION_NAME = 'us-east-1'
|
||||
AWS_S3_CUSTOM_DOMAIN = f'{AWS_STORAGE_BUCKET_NAME}.s3.amazonaws.com'
|
||||
|
||||
# Route53 Configuration (for custom domains)
|
||||
AWS_ROUTE53_ENABLED = True
|
||||
AWS_ROUTE53_HOSTED_ZONE_ID = 'YOUR_ZONE_ID' # Main smoothschedule.com zone
|
||||
|
||||
# Static files (CSS, JavaScript, Images)
|
||||
STATIC_URL = '/static/'
|
||||
STATIC_ROOT = 'staticfiles/'
|
||||
|
||||
# Media files (User uploads)
|
||||
MEDIA_URL = f'https://{AWS_S3_CUSTOM_DOMAIN}/media/'
|
||||
DEFAULT_FILE_STORAGE = 'storages.backends.s3boto3.S3Boto3Storage'
|
||||
|
||||
# =============================================================================
|
||||
# CELERY CONFIGURATION
|
||||
# =============================================================================
|
||||
|
||||
CELERY_BROKER_URL = 'redis://localhost:6379/0'
|
||||
CELERY_RESULT_BACKEND = 'redis://localhost:6379/0'
|
||||
CELERY_ACCEPT_CONTENT = ['json']
|
||||
CELERY_TASK_SERIALIZER = 'json'
|
||||
CELERY_RESULT_SERIALIZER = 'json'
|
||||
CELERY_TIMEZONE = 'UTC'
|
||||
|
||||
# =============================================================================
|
||||
# CORS CONFIGURATION (for React frontend)
|
||||
# =============================================================================
|
||||
|
||||
CORS_ALLOWED_ORIGINS = [
|
||||
"http://localhost:3000", # React dev server
|
||||
"http://localhost:8000",
|
||||
]
|
||||
|
||||
CORS_ALLOW_CREDENTIALS = True
|
||||
|
||||
# =============================================================================
|
||||
# SECURITY SETTINGS
|
||||
# =============================================================================
|
||||
|
||||
# IMPORTANT: Update these for production
|
||||
SECRET_KEY = 'CHANGE_ME_IN_PRODUCTION' # Use env var
|
||||
DEBUG = True # Set to False in production
|
||||
ALLOWED_HOSTS = ['*'] # Restrict in production
|
||||
|
||||
# Session security
|
||||
SESSION_COOKIE_HTTPONLY = True
|
||||
SESSION_COOKIE_SECURE = False # Set True in production (HTTPS)
|
||||
SESSION_COOKIE_SAMESITE = 'Lax'
|
||||
|
||||
# CSRF security
|
||||
CSRF_COOKIE_HTTPONLY = False # Must be False for DRF
|
||||
CSRF_COOKIE_SECURE = False # Set True in production (HTTPS)
|
||||
CSRF_COOKIE_SAMESITE = 'Lax'
|
||||
|
||||
# Password validation
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
{'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator'},
|
||||
{'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 'OPTIONS': {'min_length': 10}},
|
||||
{'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator'},
|
||||
{'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'},
|
||||
]
|
||||
|
||||
# =============================================================================
|
||||
# CODE REVIEW CHECKLIST VERIFICATION
|
||||
# =============================================================================
|
||||
|
||||
"""
|
||||
✓ 1. Middleware Order: MasqueradeAuditMiddleware comes AFTER HijackUserMiddleware
|
||||
✓ 2. Tenant Model: Uses django_tenants.models.TenantMixin (see core/models.py)
|
||||
✓ 3. De-bloat Script: Removes templates/ folder (see setup_project.sh)
|
||||
|
||||
Additional Security Notes:
|
||||
- DATABASE_ROUTERS configured for schema isolation
|
||||
- HIJACK_AUTHORIZATION_CHECK points to our custom permission matrix
|
||||
- Structured logging configured for audit trail
|
||||
- AWS S3 configured for file storage
|
||||
- CORS configured for React frontend
|
||||
"""
|
||||
2
core/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# Core app initialization
|
||||
default_app_config = 'core.apps.CoreConfig'
|
||||
237
core/admin.py
Normal file
@@ -0,0 +1,237 @@
|
||||
"""
|
||||
Smooth Schedule Core App Admin Configuration
|
||||
"""
|
||||
from django.contrib import admin
|
||||
from django.utils.html import format_html
|
||||
from django.urls import reverse
|
||||
from django_tenants.admin import TenantAdminMixin
|
||||
from .models import Tenant, Domain, PermissionGrant
|
||||
|
||||
|
||||
@admin.register(Tenant)
|
||||
class TenantAdmin(TenantAdminMixin, admin.ModelAdmin):
|
||||
"""
|
||||
Admin interface for Tenant management.
|
||||
"""
|
||||
list_display = [
|
||||
'name',
|
||||
'schema_name',
|
||||
'subscription_tier',
|
||||
'is_active',
|
||||
'created_on',
|
||||
'user_count',
|
||||
'domain_list',
|
||||
]
|
||||
|
||||
list_filter = [
|
||||
'is_active',
|
||||
'subscription_tier',
|
||||
'created_on',
|
||||
]
|
||||
|
||||
search_fields = [
|
||||
'name',
|
||||
'schema_name',
|
||||
'contact_email',
|
||||
]
|
||||
|
||||
readonly_fields = [
|
||||
'schema_name',
|
||||
'created_on',
|
||||
]
|
||||
|
||||
fieldsets = (
|
||||
('Basic Information', {
|
||||
'fields': ('name', 'schema_name', 'created_on')
|
||||
}),
|
||||
('Subscription', {
|
||||
'fields': ('subscription_tier', 'is_active', 'max_users', 'max_resources')
|
||||
}),
|
||||
('Contact', {
|
||||
'fields': ('contact_email', 'phone')
|
||||
}),
|
||||
)
|
||||
|
||||
def user_count(self, obj):
|
||||
"""Display count of users in this tenant"""
|
||||
count = obj.users.count()
|
||||
return format_html(
|
||||
'<span style="color: {};">{}</span>',
|
||||
'green' if count < obj.max_users else 'red',
|
||||
count
|
||||
)
|
||||
user_count.short_description = 'Users'
|
||||
|
||||
def domain_list(self, obj):
|
||||
"""Display list of domains for this tenant"""
|
||||
domains = obj.domain_set.all()
|
||||
if not domains:
|
||||
return '-'
|
||||
|
||||
domain_links = []
|
||||
for domain in domains:
|
||||
url = reverse('admin:core_domain_change', args=[domain.pk])
|
||||
domain_links.append(f'<a href="{url}">{domain.domain}</a>')
|
||||
|
||||
return format_html(' | '.join(domain_links))
|
||||
domain_list.short_description = 'Domains'
|
||||
|
||||
|
||||
@admin.register(Domain)
|
||||
class DomainAdmin(admin.ModelAdmin):
|
||||
"""
|
||||
Admin interface for Domain management.
|
||||
"""
|
||||
list_display = [
|
||||
'domain',
|
||||
'tenant',
|
||||
'is_primary',
|
||||
'is_custom_domain',
|
||||
'verified_status',
|
||||
]
|
||||
|
||||
list_filter = [
|
||||
'is_primary',
|
||||
'is_custom_domain',
|
||||
]
|
||||
|
||||
search_fields = [
|
||||
'domain',
|
||||
'tenant__name',
|
||||
]
|
||||
|
||||
readonly_fields = [
|
||||
'verified_at',
|
||||
]
|
||||
|
||||
fieldsets = (
|
||||
('Domain Information', {
|
||||
'fields': ('domain', 'tenant', 'is_primary')
|
||||
}),
|
||||
('Custom Domain Settings', {
|
||||
'fields': (
|
||||
'is_custom_domain',
|
||||
'route53_zone_id',
|
||||
'route53_record_set_id',
|
||||
'ssl_certificate_arn',
|
||||
'verified_at',
|
||||
),
|
||||
'classes': ('collapse',),
|
||||
}),
|
||||
)
|
||||
|
||||
def verified_status(self, obj):
|
||||
"""Display verification status with color coding"""
|
||||
if obj.is_verified():
|
||||
return format_html(
|
||||
'<span style="color: green;">✓ Verified</span>'
|
||||
)
|
||||
else:
|
||||
return format_html(
|
||||
'<span style="color: orange;">⚠ Pending</span>'
|
||||
)
|
||||
verified_status.short_description = 'Status'
|
||||
|
||||
|
||||
@admin.register(PermissionGrant)
|
||||
class PermissionGrantAdmin(admin.ModelAdmin):
|
||||
"""
|
||||
Admin interface for Permission Grant management.
|
||||
"""
|
||||
list_display = [
|
||||
'id',
|
||||
'grantor',
|
||||
'grantee',
|
||||
'action',
|
||||
'granted_at',
|
||||
'expires_at',
|
||||
'status',
|
||||
'time_left',
|
||||
]
|
||||
|
||||
list_filter = [
|
||||
'action',
|
||||
'granted_at',
|
||||
'expires_at',
|
||||
]
|
||||
|
||||
search_fields = [
|
||||
'grantor__email',
|
||||
'grantee__email',
|
||||
'action',
|
||||
'reason',
|
||||
]
|
||||
|
||||
readonly_fields = [
|
||||
'granted_at',
|
||||
'grantor',
|
||||
'grantee',
|
||||
'ip_address',
|
||||
'user_agent',
|
||||
]
|
||||
|
||||
fieldsets = (
|
||||
('Grant Information', {
|
||||
'fields': ('grantor', 'grantee', 'action', 'reason')
|
||||
}),
|
||||
('Timing', {
|
||||
'fields': ('granted_at', 'expires_at', 'revoked_at')
|
||||
}),
|
||||
('Audit Trail', {
|
||||
'fields': ('ip_address', 'user_agent'),
|
||||
'classes': ('collapse',),
|
||||
}),
|
||||
)
|
||||
|
||||
def status(self, obj):
|
||||
"""Display status with color coding"""
|
||||
if obj.revoked_at:
|
||||
return format_html(
|
||||
'<span style="color: red;">✗ Revoked</span>'
|
||||
)
|
||||
elif obj.is_active():
|
||||
return format_html(
|
||||
'<span style="color: green;">✓ Active</span>'
|
||||
)
|
||||
else:
|
||||
return format_html(
|
||||
'<span style="color: gray;">⊘ Expired</span>'
|
||||
)
|
||||
status.short_description = 'Status'
|
||||
|
||||
def time_left(self, obj):
|
||||
"""Display remaining time"""
|
||||
remaining = obj.time_remaining()
|
||||
if remaining is None:
|
||||
return '-'
|
||||
|
||||
minutes = int(remaining.total_seconds() / 60)
|
||||
if minutes < 5:
|
||||
color = 'red'
|
||||
elif minutes < 15:
|
||||
color = 'orange'
|
||||
else:
|
||||
color = 'green'
|
||||
|
||||
return format_html(
|
||||
'<span style="color: {};">{} min</span>',
|
||||
color,
|
||||
minutes
|
||||
)
|
||||
time_left.short_description = 'Time Left'
|
||||
|
||||
actions = ['revoke_grants']
|
||||
|
||||
def revoke_grants(self, request, queryset):
|
||||
"""Admin action to revoke permission grants"""
|
||||
count = 0
|
||||
for grant in queryset:
|
||||
if grant.is_active():
|
||||
grant.revoke()
|
||||
count += 1
|
||||
|
||||
self.message_user(
|
||||
request,
|
||||
f'Successfully revoked {count} permission grant(s).'
|
||||
)
|
||||
revoke_grants.short_description = 'Revoke selected grants'
|
||||
18
core/apps.py
Normal file
@@ -0,0 +1,18 @@
|
||||
"""
|
||||
Smooth Schedule Core App Configuration
|
||||
"""
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class CoreConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'core'
|
||||
verbose_name = 'Smooth Schedule Core'
|
||||
|
||||
def ready(self):
|
||||
"""
|
||||
Import signals and perform app initialization.
|
||||
"""
|
||||
# Import signals here when needed
|
||||
# from . import signals
|
||||
pass
|
||||
241
core/middleware.py
Normal file
@@ -0,0 +1,241 @@
|
||||
"""
|
||||
Smooth Schedule Masquerade Audit Middleware
|
||||
Captures and logs masquerading activity for compliance and security auditing
|
||||
"""
|
||||
import logging
|
||||
import json
|
||||
from django.utils.deprecation import MiddlewareMixin
|
||||
from django.utils import timezone
|
||||
|
||||
logger = logging.getLogger('smoothschedule.security.masquerade')
|
||||
|
||||
|
||||
class MasqueradeAuditMiddleware(MiddlewareMixin):
|
||||
"""
|
||||
Audit middleware that tracks masquerading (hijack) activity.
|
||||
|
||||
CRITICAL: This middleware MUST be placed AFTER HijackUserMiddleware in settings.
|
||||
|
||||
Responsibilities:
|
||||
1. Detect when a user is being masqueraded (hijacked)
|
||||
2. Extract the original admin user from session
|
||||
3. Enrich request object with audit context
|
||||
4. Log structured audit events
|
||||
|
||||
The enriched request will have:
|
||||
- request.actual_user: The original admin (if masquerading)
|
||||
- request.is_masquerading: Boolean flag
|
||||
- request.masquerade_metadata: Dict with audit info
|
||||
|
||||
Example log output:
|
||||
{
|
||||
"timestamp": "2024-01-15T10:30:00Z",
|
||||
"action": "API_CALL",
|
||||
"endpoint": "/api/customers/",
|
||||
"method": "GET",
|
||||
"apparent_user": "customer@example.com",
|
||||
"actual_user": "support@chronoflow.com",
|
||||
"masquerading": true,
|
||||
"ip_address": "192.168.1.1",
|
||||
"user_agent": "Mozilla/5.0..."
|
||||
}
|
||||
"""
|
||||
|
||||
def process_request(self, request):
|
||||
"""
|
||||
Process incoming request to detect and log masquerading.
|
||||
"""
|
||||
# Initialize masquerade flags
|
||||
request.is_masquerading = False
|
||||
request.actual_user = None
|
||||
request.masquerade_metadata = {}
|
||||
|
||||
# Check if user is authenticated
|
||||
if not hasattr(request, 'user') or not request.user.is_authenticated:
|
||||
return None
|
||||
|
||||
# Check for hijack session data
|
||||
# django-hijack stores the original user ID in session['hijack_history']
|
||||
hijack_history = request.session.get('hijack_history', [])
|
||||
|
||||
if hijack_history and len(hijack_history) > 0:
|
||||
# User is being masqueraded
|
||||
request.is_masquerading = True
|
||||
|
||||
# Extract original admin user ID from hijack history
|
||||
# hijack_history is a list of user IDs: [original_user_id, ...]
|
||||
original_user_id = hijack_history[0]
|
||||
|
||||
# Load the actual admin user
|
||||
from users.models import User
|
||||
try:
|
||||
actual_user = User.objects.get(pk=original_user_id)
|
||||
request.actual_user = actual_user
|
||||
|
||||
# Build metadata for audit logging
|
||||
request.masquerade_metadata = {
|
||||
'apparent_user_id': request.user.id,
|
||||
'apparent_user_email': request.user.email,
|
||||
'apparent_user_role': request.user.role,
|
||||
'actual_user_id': actual_user.id,
|
||||
'actual_user_email': actual_user.email,
|
||||
'actual_user_role': actual_user.role,
|
||||
'hijack_started_at': request.session.get('hijack_started_at'),
|
||||
'session_key': request.session.session_key,
|
||||
}
|
||||
|
||||
except User.DoesNotExist:
|
||||
# Original user was deleted? This shouldn't happen but log it
|
||||
logger.error(
|
||||
f"Hijack session references non-existent user ID: {original_user_id}. "
|
||||
f"Current user: {request.user.email}"
|
||||
)
|
||||
# Clear the corrupted hijack session
|
||||
request.session.pop('hijack_history', None)
|
||||
request.is_masquerading = False
|
||||
|
||||
return None
|
||||
|
||||
def process_view(self, request, view_func, view_args, view_kwargs):
|
||||
"""
|
||||
Log audit event when masquerading user accesses a view.
|
||||
Only logs for authenticated, non-admin endpoints.
|
||||
"""
|
||||
if not request.is_masquerading:
|
||||
return None
|
||||
|
||||
# Skip logging for admin interface (too noisy)
|
||||
if request.path.startswith('/admin/'):
|
||||
return None
|
||||
|
||||
# Skip logging for static files and media
|
||||
if request.path.startswith('/static/') or request.path.startswith('/media/'):
|
||||
return None
|
||||
|
||||
# Build structured log entry
|
||||
log_entry = {
|
||||
'timestamp': timezone.now().isoformat(),
|
||||
'action': 'MASQUERADE_VIEW_ACCESS',
|
||||
'path': request.path,
|
||||
'method': request.method,
|
||||
'view_name': view_func.__name__ if view_func else 'Unknown',
|
||||
'apparent_user': request.user.email,
|
||||
'apparent_user_role': request.user.get_role_display(),
|
||||
'actual_user': request.actual_user.email if request.actual_user else 'Unknown',
|
||||
'actual_user_role': request.actual_user.get_role_display() if request.actual_user else 'Unknown',
|
||||
'ip_address': self._get_client_ip(request),
|
||||
'user_agent': request.META.get('HTTP_USER_AGENT', '')[:200],
|
||||
'tenant': request.user.tenant.name if request.user.tenant else 'Platform',
|
||||
}
|
||||
|
||||
# Log as structured JSON
|
||||
logger.info(
|
||||
f"Masquerade Access: {request.actual_user.email} as {request.user.email}",
|
||||
extra={'audit_data': log_entry}
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
def process_response(self, request, response):
|
||||
"""
|
||||
Add audit headers to response when masquerading (for debugging).
|
||||
"""
|
||||
if hasattr(request, 'is_masquerading') and request.is_masquerading:
|
||||
# Add custom headers (visible in browser dev tools)
|
||||
response['X-SmoothSchedule-Masquerading'] = 'true'
|
||||
if request.actual_user:
|
||||
response['X-SmoothSchedule-Actual-User'] = request.actual_user.email
|
||||
|
||||
return response
|
||||
|
||||
@staticmethod
|
||||
def _get_client_ip(request):
|
||||
"""
|
||||
Extract client IP address from request, handling proxies.
|
||||
"""
|
||||
# Check for X-Forwarded-For header (from load balancers/proxies)
|
||||
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
|
||||
if x_forwarded_for:
|
||||
# Take the first IP (client IP, before proxies)
|
||||
ip = x_forwarded_for.split(',')[0].strip()
|
||||
else:
|
||||
ip = request.META.get('REMOTE_ADDR')
|
||||
|
||||
return ip
|
||||
|
||||
|
||||
class MasqueradeEventLogger:
|
||||
"""
|
||||
Utility class for logging masquerade lifecycle events.
|
||||
Use this for logging hijack start/end events.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def log_hijack_start(hijacker, hijacked, request):
|
||||
"""
|
||||
Log when a hijack session starts.
|
||||
"""
|
||||
log_entry = {
|
||||
'timestamp': timezone.now().isoformat(),
|
||||
'action': 'HIJACK_START',
|
||||
'hijacker_id': hijacker.id,
|
||||
'hijacker_email': hijacker.email,
|
||||
'hijacker_role': hijacker.get_role_display(),
|
||||
'hijacked_id': hijacked.id,
|
||||
'hijacked_email': hijacked.email,
|
||||
'hijacked_role': hijacked.get_role_display(),
|
||||
'ip_address': request.META.get('REMOTE_ADDR'),
|
||||
'user_agent': request.META.get('HTTP_USER_AGENT', '')[:200],
|
||||
'session_key': request.session.session_key,
|
||||
}
|
||||
|
||||
logger.warning(
|
||||
f"HIJACK START: {hijacker.email} masquerading as {hijacked.email}",
|
||||
extra={'audit_data': log_entry}
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def log_hijack_end(hijacker, hijacked, request, duration_seconds=None):
|
||||
"""
|
||||
Log when a hijack session ends.
|
||||
"""
|
||||
log_entry = {
|
||||
'timestamp': timezone.now().isoformat(),
|
||||
'action': 'HIJACK_END',
|
||||
'hijacker_id': hijacker.id,
|
||||
'hijacker_email': hijacker.email,
|
||||
'hijacked_id': hijacked.id,
|
||||
'hijacked_email': hijacked.email,
|
||||
'duration_seconds': duration_seconds,
|
||||
'ip_address': request.META.get('REMOTE_ADDR'),
|
||||
'session_key': request.session.session_key,
|
||||
}
|
||||
|
||||
logger.warning(
|
||||
f"HIJACK END: {hijacker.email} stopped masquerading as {hijacked.email}",
|
||||
extra={'audit_data': log_entry}
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def log_hijack_denied(hijacker, hijacked, request, reason=''):
|
||||
"""
|
||||
Log when a hijack attempt is denied.
|
||||
"""
|
||||
log_entry = {
|
||||
'timestamp': timezone.now().isoformat(),
|
||||
'action': 'HIJACK_DENIED',
|
||||
'hijacker_id': hijacker.id,
|
||||
'hijacker_email': hijacker.email,
|
||||
'hijacker_role': hijacker.get_role_display(),
|
||||
'attempted_hijacked_id': hijacked.id,
|
||||
'attempted_hijacked_email': hijacked.email,
|
||||
'attempted_hijacked_role': hijacked.get_role_display(),
|
||||
'denial_reason': reason,
|
||||
'ip_address': request.META.get('REMOTE_ADDR'),
|
||||
'user_agent': request.META.get('HTTP_USER_AGENT', '')[:200],
|
||||
}
|
||||
|
||||
logger.error(
|
||||
f"HIJACK DENIED: {hijacker.email} attempted to masquerade as {hijacked.email} - {reason}",
|
||||
extra={'audit_data': log_entry}
|
||||
)
|
||||
271
core/models.py
Normal file
@@ -0,0 +1,271 @@
|
||||
"""
|
||||
Smooth Schedule Core Models
|
||||
Multi-tenancy, Domain management, and Permission Grant system
|
||||
"""
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
from django_tenants.models import TenantMixin, DomainMixin
|
||||
from datetime import timedelta
|
||||
|
||||
|
||||
class Tenant(TenantMixin):
|
||||
"""
|
||||
Tenant model for multi-schema architecture.
|
||||
Each tenant gets their own PostgreSQL schema for complete data isolation.
|
||||
"""
|
||||
name = models.CharField(max_length=100)
|
||||
created_on = models.DateField(auto_now_add=True)
|
||||
|
||||
# Subscription & billing
|
||||
is_active = models.BooleanField(default=True)
|
||||
subscription_tier = models.CharField(
|
||||
max_length=50,
|
||||
choices=[
|
||||
('FREE', 'Free Trial'),
|
||||
('STARTER', 'Starter'),
|
||||
('PROFESSIONAL', 'Professional'),
|
||||
('ENTERPRISE', 'Enterprise'),
|
||||
],
|
||||
default='FREE'
|
||||
)
|
||||
|
||||
# Feature flags
|
||||
max_users = models.IntegerField(default=5)
|
||||
max_resources = models.IntegerField(default=10)
|
||||
|
||||
# Metadata
|
||||
contact_email = models.EmailField(blank=True)
|
||||
phone = models.CharField(max_length=20, blank=True)
|
||||
|
||||
# Auto-created fields from TenantMixin:
|
||||
# - schema_name (unique, indexed)
|
||||
# - auto_create_schema
|
||||
# - auto_drop_schema
|
||||
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class Domain(DomainMixin):
|
||||
"""
|
||||
Domain model for tenant routing.
|
||||
Supports both subdomain (tenant.chronoflow.com) and custom domains.
|
||||
"""
|
||||
# Inherited from DomainMixin:
|
||||
# - domain (unique, primary key)
|
||||
# - tenant (ForeignKey to Tenant)
|
||||
# - is_primary (boolean)
|
||||
|
||||
# Route53 integration fields for custom domains
|
||||
is_custom_domain = models.BooleanField(
|
||||
default=False,
|
||||
help_text="True if this is a custom domain (not a subdomain of smoothschedule.com)"
|
||||
)
|
||||
|
||||
route53_zone_id = models.CharField(
|
||||
max_length=100,
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text="AWS Route53 Hosted Zone ID for this custom domain"
|
||||
)
|
||||
|
||||
route53_record_set_id = models.CharField(
|
||||
max_length=100,
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text="Route53 Record Set ID for DNS verification"
|
||||
)
|
||||
|
||||
# SSL certificate management (for future AWS ACM integration)
|
||||
ssl_certificate_arn = models.CharField(
|
||||
max_length=200,
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text="AWS ACM Certificate ARN for HTTPS"
|
||||
)
|
||||
|
||||
verified_at = models.DateTimeField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="When the custom domain was verified via DNS"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ['domain']
|
||||
|
||||
def __str__(self):
|
||||
domain_type = "Custom" if self.is_custom_domain else "Subdomain"
|
||||
return f"{self.domain} ({domain_type})"
|
||||
|
||||
def is_verified(self):
|
||||
"""Check if custom domain is verified"""
|
||||
if not self.is_custom_domain:
|
||||
return True # Subdomains are always verified
|
||||
return self.verified_at is not None
|
||||
|
||||
|
||||
class PermissionGrant(models.Model):
|
||||
"""
|
||||
Time-limited permission grants (30-minute window).
|
||||
Used for temporary elevated access without permanently changing user permissions.
|
||||
|
||||
Example use cases:
|
||||
- Support agent needs temporary access to tenant data
|
||||
- Sales demo requiring elevated permissions
|
||||
- Cross-tenant operations during migrations
|
||||
"""
|
||||
grantor = models.ForeignKey(
|
||||
'users.User',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='granted_permissions',
|
||||
help_text="User who granted the permission"
|
||||
)
|
||||
|
||||
grantee = models.ForeignKey(
|
||||
'users.User',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='received_permissions',
|
||||
help_text="User who received the permission"
|
||||
)
|
||||
|
||||
action = models.CharField(
|
||||
max_length=100,
|
||||
help_text="Specific action or permission being granted (e.g., 'view_billing', 'edit_settings')"
|
||||
)
|
||||
|
||||
reason = models.TextField(
|
||||
blank=True,
|
||||
help_text="Justification for granting this permission (audit trail)"
|
||||
)
|
||||
|
||||
granted_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
expires_at = models.DateTimeField(
|
||||
help_text="When this permission grant expires"
|
||||
)
|
||||
|
||||
revoked_at = models.DateTimeField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="If manually revoked before expiration"
|
||||
)
|
||||
|
||||
# Audit metadata
|
||||
ip_address = models.GenericIPAddressField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="IP address where grant was created"
|
||||
)
|
||||
|
||||
user_agent = models.TextField(
|
||||
blank=True,
|
||||
help_text="Browser/client user agent"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-granted_at']
|
||||
indexes = [
|
||||
models.Index(fields=['grantee', 'expires_at']),
|
||||
models.Index(fields=['grantor', 'granted_at']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.grantor} → {self.grantee}: {self.action} (expires {self.expires_at})"
|
||||
|
||||
def is_active(self):
|
||||
"""
|
||||
Check if this permission grant is currently active.
|
||||
Returns False if expired or revoked.
|
||||
"""
|
||||
now = timezone.now()
|
||||
|
||||
# Check if revoked
|
||||
if self.revoked_at is not None:
|
||||
return False
|
||||
|
||||
# Check if expired
|
||||
if now >= self.expires_at:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def revoke(self):
|
||||
"""Manually revoke this permission grant"""
|
||||
if self.revoked_at is None:
|
||||
self.revoked_at = timezone.now()
|
||||
self.save(update_fields=['revoked_at'])
|
||||
|
||||
def time_remaining(self):
|
||||
"""Get timedelta of remaining active time (or None if expired/revoked)"""
|
||||
if not self.is_active():
|
||||
return None
|
||||
|
||||
now = timezone.now()
|
||||
return self.expires_at - now
|
||||
|
||||
@classmethod
|
||||
def create_grant(cls, grantor, grantee, action, reason="", duration_minutes=30, ip_address=None, user_agent=""):
|
||||
"""
|
||||
Factory method to create a time-limited permission grant.
|
||||
|
||||
Args:
|
||||
grantor: User granting the permission
|
||||
grantee: User receiving the permission
|
||||
action: Permission action string
|
||||
reason: Justification for audit trail
|
||||
duration_minutes: How long the grant is valid (default 30)
|
||||
ip_address: IP address of the request
|
||||
user_agent: Browser user agent
|
||||
|
||||
Returns:
|
||||
PermissionGrant instance
|
||||
"""
|
||||
now = timezone.now()
|
||||
expires_at = now + timedelta(minutes=duration_minutes)
|
||||
|
||||
return cls.objects.create(
|
||||
grantor=grantor,
|
||||
grantee=grantee,
|
||||
action=action,
|
||||
reason=reason,
|
||||
expires_at=expires_at,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent
|
||||
)
|
||||
|
||||
|
||||
class TierLimit(models.Model):
|
||||
"""
|
||||
Defines resource limits for each subscription tier.
|
||||
Used by HasQuota permission to enforce hard blocks.
|
||||
"""
|
||||
tier = models.CharField(
|
||||
max_length=50,
|
||||
choices=[
|
||||
('FREE', 'Free Trial'),
|
||||
('STARTER', 'Starter'),
|
||||
('PROFESSIONAL', 'Professional'),
|
||||
('ENTERPRISE', 'Enterprise'),
|
||||
]
|
||||
)
|
||||
|
||||
feature_code = models.CharField(
|
||||
max_length=100,
|
||||
help_text="Feature code (e.g., 'MAX_RESOURCES', 'MAX_USERS')"
|
||||
)
|
||||
|
||||
limit = models.IntegerField(
|
||||
default=0,
|
||||
help_text="Maximum allowed count for this feature"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
unique_together = ['tier', 'feature_code']
|
||||
ordering = ['tier', 'feature_code']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.tier} - {self.feature_code}: {self.limit}"
|
||||
|
||||
285
core/permissions.py
Normal file
@@ -0,0 +1,285 @@
|
||||
"""
|
||||
Smooth Schedule Hijack Permissions
|
||||
Implements the masquerading "Matrix" - strict rules for who can impersonate whom
|
||||
"""
|
||||
from django.core.exceptions import PermissionDenied
|
||||
|
||||
|
||||
def can_hijack(hijacker, hijacked):
|
||||
"""
|
||||
Determine if hijacker (the admin) can masquerade as hijacked (target user).
|
||||
|
||||
The Matrix:
|
||||
┌──────────────────────┬─────────────────────────────────────────────────┐
|
||||
│ Hijacker Role │ Can Hijack │
|
||||
├──────────────────────┼─────────────────────────────────────────────────┤
|
||||
│ SUPERUSER │ Anyone (full god mode) │
|
||||
│ PLATFORM_SUPPORT │ TENANT_OWNER, TENANT_MANAGER, TENANT_STAFF │
|
||||
│ PLATFORM_SALES │ Only users with is_temporary=True │
|
||||
│ TENANT_OWNER │ TENANT_STAFF in same tenant only │
|
||||
│ Others │ Nobody │
|
||||
└──────────────────────┴─────────────────────────────────────────────────┘
|
||||
|
||||
Args:
|
||||
hijacker: User attempting to impersonate (the admin)
|
||||
hijacked: User being impersonated (the target)
|
||||
|
||||
Returns:
|
||||
bool: True if hijack is allowed, False otherwise
|
||||
|
||||
Security Notes:
|
||||
- Never allow self-hijacking
|
||||
- Never allow hijacking of superusers (except by other superusers)
|
||||
- Always validate tenant boundaries for tenant-scoped roles
|
||||
- Log all hijack attempts (success and failure) for audit
|
||||
"""
|
||||
from users.models import User
|
||||
|
||||
# Safety check: can't hijack yourself
|
||||
if hijacker.id == hijacked.id:
|
||||
return False
|
||||
|
||||
# Safety check: only superusers can hijack other superusers
|
||||
if hijacked.role == User.Role.SUPERUSER and hijacker.role != User.Role.SUPERUSER:
|
||||
return False
|
||||
|
||||
# Rule 1: SUPERUSER can hijack anyone
|
||||
if hijacker.role == User.Role.SUPERUSER:
|
||||
return True
|
||||
|
||||
# Rule 2: PLATFORM_SUPPORT can hijack tenant users
|
||||
if hijacker.role == User.Role.PLATFORM_SUPPORT:
|
||||
return hijacked.role in [
|
||||
User.Role.TENANT_OWNER,
|
||||
User.Role.TENANT_MANAGER,
|
||||
User.Role.TENANT_STAFF,
|
||||
User.Role.CUSTOMER,
|
||||
]
|
||||
|
||||
# Rule 3: PLATFORM_SALES can only hijack temporary demo accounts
|
||||
if hijacker.role == User.Role.PLATFORM_SALES:
|
||||
return hijacked.is_temporary
|
||||
|
||||
# Rule 4: TENANT_OWNER can hijack staff within their own tenant
|
||||
if hijacker.role == User.Role.TENANT_OWNER:
|
||||
# Must be in same tenant
|
||||
if not hijacker.tenant or not hijacked.tenant:
|
||||
return False
|
||||
if hijacker.tenant.id != hijacked.tenant.id:
|
||||
return False
|
||||
|
||||
# Can only hijack staff and customers, not other owners/managers
|
||||
return hijacked.role in [
|
||||
User.Role.TENANT_STAFF,
|
||||
User.Role.CUSTOMER,
|
||||
]
|
||||
|
||||
# Default: deny
|
||||
return False
|
||||
|
||||
|
||||
def can_hijack_or_403(hijacker, hijacked):
|
||||
"""
|
||||
Same as can_hijack but raises PermissionDenied instead of returning False.
|
||||
Useful for views that want to use exception handling.
|
||||
|
||||
Args:
|
||||
hijacker: User attempting to impersonate
|
||||
hijacked: User being impersonated
|
||||
|
||||
Raises:
|
||||
PermissionDenied: If hijack is not allowed
|
||||
|
||||
Returns:
|
||||
bool: True if allowed (never returns False, raises instead)
|
||||
"""
|
||||
if not can_hijack(hijacker, hijacked):
|
||||
raise PermissionDenied(
|
||||
f"User {hijacker.email} ({hijacker.get_role_display()}) "
|
||||
f"is not authorized to masquerade as {hijacked.email} ({hijacked.get_role_display()})"
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def get_hijackable_users(hijacker):
|
||||
"""
|
||||
Get queryset of all users that the hijacker can masquerade as.
|
||||
Useful for building "Masquerade as..." dropdowns in the UI.
|
||||
|
||||
Args:
|
||||
hijacker: User who wants to hijack
|
||||
|
||||
Returns:
|
||||
QuerySet: Users that can be hijacked by this user
|
||||
"""
|
||||
from users.models import User
|
||||
|
||||
# Start with all users except self
|
||||
qs = User.objects.exclude(id=hijacker.id)
|
||||
|
||||
# Apply filters based on hijacker role
|
||||
if hijacker.role == User.Role.SUPERUSER:
|
||||
# Superuser can hijack anyone
|
||||
return qs
|
||||
|
||||
elif hijacker.role == User.Role.PLATFORM_SUPPORT:
|
||||
# Can hijack all tenant-level users
|
||||
return qs.filter(role__in=[
|
||||
User.Role.TENANT_OWNER,
|
||||
User.Role.TENANT_MANAGER,
|
||||
User.Role.TENANT_STAFF,
|
||||
User.Role.CUSTOMER,
|
||||
])
|
||||
|
||||
elif hijacker.role == User.Role.PLATFORM_SALES:
|
||||
# Only temporary demo accounts
|
||||
return qs.filter(is_temporary=True)
|
||||
|
||||
elif hijacker.role == User.Role.TENANT_OWNER:
|
||||
# Only staff in same tenant
|
||||
if not hijacker.tenant:
|
||||
return qs.none()
|
||||
|
||||
return qs.filter(
|
||||
tenant=hijacker.tenant,
|
||||
role__in=[User.Role.TENANT_STAFF, User.Role.CUSTOMER]
|
||||
)
|
||||
|
||||
else:
|
||||
# No one else can hijack
|
||||
return qs.none()
|
||||
|
||||
|
||||
def validate_hijack_chain(request):
|
||||
"""
|
||||
Validate that hijack chains are not too deep.
|
||||
Prevents: Admin1 -> Admin2 -> Admin3 -> User scenarios.
|
||||
|
||||
Smooth Schedule Security Policy: Maximum hijack depth is 1.
|
||||
You cannot hijack while already hijacked.
|
||||
|
||||
Args:
|
||||
request: Django request object
|
||||
|
||||
Raises:
|
||||
PermissionDenied: If already in a hijack session
|
||||
|
||||
Returns:
|
||||
bool: True if allowed to start new hijack
|
||||
"""
|
||||
hijack_history = request.session.get('hijack_history', [])
|
||||
|
||||
if len(hijack_history) > 0:
|
||||
raise PermissionDenied(
|
||||
"Cannot start a new masquerade session while already masquerading. "
|
||||
"Please exit your current session first."
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# SaaS Quota Enforcement ("Hard Block" Logic)
|
||||
# ==============================================================================
|
||||
|
||||
def HasQuota(feature_code):
|
||||
"""
|
||||
Permission factory for SaaS tier limit enforcement.
|
||||
|
||||
Returns a DRF permission class that blocks write operations when
|
||||
tenant has exceeded their quota for a specific feature.
|
||||
|
||||
The "Hard Block": Prevents resource creation when tenant hits limit.
|
||||
|
||||
Usage:
|
||||
class ResourceViewSet(ModelViewSet):
|
||||
permission_classes = [IsAuthenticated, HasQuota('MAX_RESOURCES')]
|
||||
|
||||
Args:
|
||||
feature_code: TierLimit feature code (e.g., 'MAX_RESOURCES', 'MAX_USERS')
|
||||
|
||||
Returns:
|
||||
QuotaPermission class configured for the feature
|
||||
|
||||
How it Works:
|
||||
1. Read operations (GET/HEAD/OPTIONS) always allowed
|
||||
2. Write operations check current usage vs tier limit
|
||||
3. If usage >= limit, raises PermissionDenied (403)
|
||||
"""
|
||||
from rest_framework.permissions import BasePermission
|
||||
from django.apps import apps
|
||||
|
||||
class QuotaPermission(BasePermission):
|
||||
"""
|
||||
Dynamically generated permission class for quota checking.
|
||||
"""
|
||||
|
||||
# Map feature codes to model paths for usage counting
|
||||
# CRITICAL: This map must be populated for the permission to work
|
||||
USAGE_MAP = {
|
||||
'MAX_RESOURCES': 'schedule.Resource',
|
||||
'MAX_USERS': 'users.User',
|
||||
'MAX_EVENTS_PER_MONTH': 'schedule.Event',
|
||||
# Add more mappings as needed
|
||||
}
|
||||
|
||||
def has_permission(self, request, view):
|
||||
"""
|
||||
Check if tenant has quota for this operation.
|
||||
|
||||
Returns True for read operations, checks quota for writes.
|
||||
"""
|
||||
# Allow all read-only operations (GET, HEAD, OPTIONS)
|
||||
if request.method in ['GET', 'HEAD', 'OPTIONS']:
|
||||
return True
|
||||
|
||||
# Get tenant from request
|
||||
tenant = getattr(request, 'tenant', None)
|
||||
if not tenant:
|
||||
# No tenant in request - allow for public schema operations
|
||||
return True
|
||||
|
||||
# Get the model to check usage against
|
||||
model_path = self.USAGE_MAP.get(feature_code)
|
||||
if not model_path:
|
||||
# Feature not mapped - fail safe by allowing
|
||||
# (Production: you'd want to log this as a configuration error)
|
||||
return True
|
||||
|
||||
# Get the model class
|
||||
try:
|
||||
app_label, model_name = model_path.split('.')
|
||||
Model = apps.get_model(app_label, model_name)
|
||||
except (ValueError, LookupError):
|
||||
# Invalid model path - fail safe
|
||||
return True
|
||||
|
||||
# Get the tier limit for this tenant
|
||||
try:
|
||||
from core.models import TierLimit
|
||||
limit = TierLimit.objects.get(
|
||||
tier=tenant.subscription_tier,
|
||||
feature_code=feature_code
|
||||
).limit
|
||||
except TierLimit.DoesNotExist:
|
||||
# No limit defined - allow (unlimited)
|
||||
return True
|
||||
|
||||
# Count current usage
|
||||
# NOTE: django-tenants automatically scopes this query to tenant schema
|
||||
current_count = Model.objects.count()
|
||||
|
||||
# The "Hard Block": Enforce the limit
|
||||
if current_count >= limit:
|
||||
# Quota exceeded - deny the operation
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
raise PermissionDenied(
|
||||
f"Quota exceeded: You have reached your plan limit of {limit} "
|
||||
f"{feature_code.replace('MAX_', '').lower().replace('_', ' ')}. "
|
||||
f"Please upgrade your subscription to add more."
|
||||
)
|
||||
|
||||
# Quota available - allow the operation
|
||||
return True
|
||||
|
||||
return QuotaPermission
|
||||
BIN
frontend.tar.xz
Normal file
2
frontend/.env.development
Normal file
@@ -0,0 +1,2 @@
|
||||
VITE_DEV_MODE=true
|
||||
VITE_API_URL=http://lvh.me:8000
|
||||
10
frontend/.env.example
Normal file
@@ -0,0 +1,10 @@
|
||||
# Environment Variables Template
|
||||
# Copy this file to .env.development or .env.production and update the values
|
||||
|
||||
# API Base URL - Backend server URL
|
||||
# Development: http://lvh.me:8000
|
||||
# Production: https://api.yourdomain.com
|
||||
VITE_API_URL=http://lvh.me:8000
|
||||
|
||||
# Development mode flag (optional)
|
||||
VITE_DEV_MODE=true
|
||||
3
frontend/.env.production
Normal file
@@ -0,0 +1,3 @@
|
||||
# Production environment variables
|
||||
# Set VITE_API_URL to your production API URL
|
||||
VITE_API_URL=https://api.yourdomain.com
|
||||
24
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
159
frontend/CLAUDE.md
Normal file
@@ -0,0 +1,159 @@
|
||||
# SmoothSchedule Frontend Development Guide
|
||||
|
||||
## Local Development Domain Setup
|
||||
|
||||
### Why lvh.me instead of localhost?
|
||||
|
||||
This project uses **lvh.me** for local development instead of `localhost` due to cookie domain restrictions in RFC 6265.
|
||||
|
||||
**The Problem with localhost:**
|
||||
- Browsers reject cookies with `domain=.localhost` for security reasons
|
||||
- `localhost` is treated as a special-use domain where domain cookies don't work
|
||||
- Cannot share cookies across subdomains like `platform.localhost` and `business.localhost`
|
||||
|
||||
**The Solution - lvh.me:**
|
||||
- `lvh.me` is a public DNS service that resolves all `*.lvh.me` domains to `127.0.0.1`
|
||||
- Browsers allow setting cookies with `domain=.lvh.me`
|
||||
- Cookies are accessible across all subdomains (platform.lvh.me, business1.lvh.me, etc.)
|
||||
- No /etc/hosts configuration needed - it just works!
|
||||
|
||||
### Development URLs
|
||||
|
||||
Use these URLs for local development:
|
||||
|
||||
- **Base domain:** `http://lvh.me:5173`
|
||||
- **Platform dashboard:** `http://platform.lvh.me:5173`
|
||||
- **Business subdomains:** `http://{subdomain}.lvh.me:5173`
|
||||
|
||||
### Multi-Tenant Architecture
|
||||
|
||||
The application uses subdomain-based multi-tenancy:
|
||||
|
||||
1. **Platform Users** (superuser, platform_manager, platform_support)
|
||||
- Access the app at `http://platform.lvh.me:5173`
|
||||
- See platform dashboard and administrative features
|
||||
|
||||
2. **Business Users** (owner, manager, staff, resource)
|
||||
- Access the app at `http://{business_subdomain}.lvh.me:5173`
|
||||
- See business-specific dashboard and features
|
||||
|
||||
### Cookie-Based Authentication
|
||||
|
||||
Tokens are stored in cookies with `domain=.lvh.me` to enable cross-subdomain access:
|
||||
|
||||
```typescript
|
||||
// Set cookie accessible across all *.lvh.me subdomains
|
||||
setCookie('access_token', token, 7); // domain=.lvh.me
|
||||
|
||||
// Cookie is accessible on:
|
||||
// - platform.lvh.me:5173
|
||||
// - business1.lvh.me:5173
|
||||
// - business2.lvh.me:5173
|
||||
// etc.
|
||||
```
|
||||
|
||||
**Key Files:**
|
||||
- `/src/utils/cookies.ts` - Cookie utilities with cross-subdomain support
|
||||
- `/src/hooks/useAuth.ts` - Authentication hooks using cookies
|
||||
- `/src/api/client.ts` - API client with cookie-based auth
|
||||
|
||||
### Login Flow
|
||||
|
||||
1. User navigates to `http://platform.lvh.me:5173`
|
||||
2. If not authenticated, shows login page
|
||||
3. User enters credentials and submits
|
||||
4. Backend validates and returns JWT tokens + user data
|
||||
5. Tokens stored in cookies with `domain=.lvh.me`
|
||||
6. User data stored in React Query cache
|
||||
7. App checks user role:
|
||||
- Platform users: Stay on platform.lvh.me
|
||||
- Business users: Redirect to {business_subdomain}.lvh.me
|
||||
8. Cookies accessible on target subdomain - user sees dashboard
|
||||
|
||||
### Testing with Playwright
|
||||
|
||||
Tests use lvh.me for proper subdomain testing:
|
||||
|
||||
```typescript
|
||||
// Start on platform subdomain
|
||||
await page.goto('http://platform.lvh.me:5173');
|
||||
|
||||
// Login sets cookies with domain=.lvh.me
|
||||
await page.getByPlaceholder(/username/i).fill('poduck');
|
||||
await page.getByPlaceholder(/password/i).fill('starry12');
|
||||
await page.getByRole('button', { name: /sign in/i }).click();
|
||||
|
||||
// Cookies accessible, dashboard loads
|
||||
await expect(page.getByRole('heading', { name: /platform dashboard/i })).toBeVisible();
|
||||
```
|
||||
|
||||
### Running the Development Server
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Start dev server
|
||||
npm run dev
|
||||
|
||||
# Access at http://platform.lvh.me:5173
|
||||
```
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
npm test
|
||||
|
||||
# Run E2E tests
|
||||
npx playwright test
|
||||
|
||||
# Run specific test file
|
||||
npx playwright test login-flow.spec.ts
|
||||
|
||||
# Run with UI
|
||||
npx playwright test --ui
|
||||
```
|
||||
|
||||
## Production Deployment
|
||||
|
||||
In production, replace `lvh.me` with your actual domain:
|
||||
|
||||
1. Update `src/utils/cookies.ts`:
|
||||
```typescript
|
||||
const domain = window.location.hostname.includes('yourdomain.com')
|
||||
? '.yourdomain.com'
|
||||
: window.location.hostname;
|
||||
```
|
||||
|
||||
2. Configure DNS:
|
||||
- `platform.yourdomain.com` → Your server IP
|
||||
- `*.yourdomain.com` → Your server IP (wildcard for business subdomains)
|
||||
|
||||
3. SSL certificates:
|
||||
- Get wildcard certificate for `*.yourdomain.com`
|
||||
- Or use Let's Encrypt with wildcard support
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Cookies not working?
|
||||
- Make sure you're using `lvh.me`, not `localhost`
|
||||
- Check browser DevTools → Application → Cookies
|
||||
- Verify `domain=.lvh.me` is set on cookies
|
||||
- Clear cookies and try again
|
||||
|
||||
### Redirect issues?
|
||||
- Check `/src/pages/LoginPage.tsx` redirect logic
|
||||
- Verify user role and business_subdomain in response
|
||||
- Check browser console for navigation errors
|
||||
|
||||
### Can't access lvh.me?
|
||||
- Verify internet connection (lvh.me requires DNS lookup)
|
||||
- Try `ping lvh.me` - should resolve to `127.0.0.1`
|
||||
- Alternative: Use `127.0.0.1.nip.io` (similar service)
|
||||
|
||||
## References
|
||||
|
||||
- [RFC 6265 - HTTP State Management Mechanism](https://datatracker.ietf.org/doc/html/rfc6265)
|
||||
- [lvh.me DNS service](http://lvh.me)
|
||||
- [Cookie Domain Attribute Rules](https://stackoverflow.com/questions/1062963/how-do-browser-cookie-domains-work)
|
||||
29
frontend/CSP-PRODUCTION.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# Content Security Policy for Production
|
||||
|
||||
During development, CSP is disabled in `index.html` to avoid conflicts with browser extensions.
|
||||
|
||||
For production, configure CSP via server headers (nginx/CloudFlare):
|
||||
|
||||
```nginx
|
||||
# nginx configuration
|
||||
add_header Content-Security-Policy "
|
||||
default-src 'self';
|
||||
script-src 'self' 'unsafe-inline' 'unsafe-eval' https://js.stripe.com https://connect-js.stripe.com blob:;
|
||||
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;
|
||||
font-src 'self' https://fonts.gstatic.com;
|
||||
img-src 'self' data: https:;
|
||||
connect-src 'self' https://api.stripe.com https://connect-js.stripe.com https://yourdomain.com;
|
||||
frame-src 'self' https://js.stripe.com https://connect-js.stripe.com;
|
||||
" always;
|
||||
```
|
||||
|
||||
## Why not in HTML meta tag?
|
||||
|
||||
1. **Browser extensions interfere**: Extensions inject their own CSP rules causing false errors
|
||||
2. **Dynamic configuration**: Production domains differ from development (lvh.me vs yourdomain.com)
|
||||
3. **Better control**: Server headers can vary by environment without changing source code
|
||||
4. **Standard practice**: Industry best practice is CSP via headers, not meta tags
|
||||
|
||||
## Testing CSP
|
||||
|
||||
Test your production CSP at: https://csp-evaluator.withgoogle.com/
|
||||
16
frontend/Dockerfile
Normal file
@@ -0,0 +1,16 @@
|
||||
FROM node:22-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm install --network-timeout=600000 --retry 3
|
||||
|
||||
COPY . .
|
||||
|
||||
# Startup script that ensures node_modules are in sync with package.json
|
||||
COPY docker-entrypoint.sh /docker-entrypoint.sh
|
||||
RUN chmod +x /docker-entrypoint.sh
|
||||
|
||||
EXPOSE 5173
|
||||
|
||||
ENTRYPOINT ["/docker-entrypoint.sh"]
|
||||
42
frontend/Dockerfile.prod
Normal file
@@ -0,0 +1,42 @@
|
||||
# Production Dockerfile for SmoothSchedule Frontend
|
||||
# Multi-stage build: Build the app, then serve with nginx
|
||||
|
||||
# Build stage
|
||||
FROM node:22-alpine as builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package.json package-lock.json* ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci --only=production=false --network-timeout=600000
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build arguments for environment variables
|
||||
ARG VITE_API_URL
|
||||
ENV VITE_API_URL=${VITE_API_URL}
|
||||
|
||||
# Build the application
|
||||
RUN npm run build
|
||||
|
||||
|
||||
# Production stage - serve with nginx
|
||||
FROM nginx:alpine
|
||||
|
||||
# Copy custom nginx config
|
||||
COPY nginx.conf /etc/nginx/nginx.conf
|
||||
|
||||
# Copy built assets from builder stage
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
|
||||
# Expose port
|
||||
EXPOSE 80
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost/ || exit 1
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
16
frontend/README.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# React + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
|
||||
423
frontend/README_INTEGRATION.md
Normal file
@@ -0,0 +1,423 @@
|
||||
# Frontend Integration - Step by Step
|
||||
|
||||
## Current Status
|
||||
|
||||
✅ **API Infrastructure Complete**
|
||||
- API client with axios interceptors
|
||||
- Authentication hooks
|
||||
- All feature hooks (customers, services, resources, appointments, business)
|
||||
- Playwright testing setup
|
||||
- Login page component
|
||||
- Example App.tsx with real auth
|
||||
|
||||
✅ **Components Extracted**
|
||||
- All components from zip copied to `src/*-extracted/` directories
|
||||
- TypeScript types copied to `src/types.ts`
|
||||
|
||||
## Quick Start (5 Minutes)
|
||||
|
||||
### 1. Install Dependencies
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npx playwright install
|
||||
```
|
||||
|
||||
### 2. Start Backend
|
||||
```bash
|
||||
# In another terminal
|
||||
cd backend
|
||||
docker-compose up
|
||||
docker-compose exec backend python manage.py migrate
|
||||
docker-compose exec backend python manage.py createsuperuser
|
||||
```
|
||||
|
||||
### 3. Start Frontend
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### 4. Test Login
|
||||
- Go to `http://lvh.me:5173`
|
||||
- Login with your superuser credentials
|
||||
- You should see a welcome screen with your user info!
|
||||
|
||||
## Integration Steps
|
||||
|
||||
### Step 1: Replace App.tsx
|
||||
|
||||
```bash
|
||||
# Backup current App.tsx
|
||||
mv src/App.tsx src/App.tsx.backup
|
||||
|
||||
# Use the integrated version
|
||||
mv src/App-integrated.tsx src/App.tsx
|
||||
```
|
||||
|
||||
### Step 2: Copy Components
|
||||
|
||||
```bash
|
||||
# Copy all components at once
|
||||
cp -r src/components-extracted/* src/components/ 2>/dev/null || true
|
||||
cp -r src/layouts-extracted/* src/layouts/ 2>/dev/null || true
|
||||
cp -r src/pages-extracted/* src/pages/ 2>/dev/null || true
|
||||
|
||||
# Or copy selectively, one feature at a time (recommended)
|
||||
```
|
||||
|
||||
### Step 3: Update Component Imports
|
||||
|
||||
For each component you copy, replace mock data with API hooks.
|
||||
|
||||
#### Example: Dashboard.tsx
|
||||
|
||||
**Before:**
|
||||
```typescript
|
||||
import { APPOINTMENTS, SERVICES } from '../mockData';
|
||||
|
||||
const Dashboard = () => {
|
||||
const [appointments] = useState(APPOINTMENTS);
|
||||
const [services] = useState(SERVICES);
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**After:**
|
||||
```typescript
|
||||
import { useAppointments } from '../hooks/useAppointments';
|
||||
import { useServices } from '../hooks/useServices';
|
||||
|
||||
const Dashboard = () => {
|
||||
const { data: appointments, isLoading: apptLoading } = useAppointments();
|
||||
const { data: services, isLoading: servicesLoading } = useServices();
|
||||
|
||||
if (apptLoading || servicesLoading) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
#### Example: Customers.tsx
|
||||
|
||||
**Before:**
|
||||
```typescript
|
||||
import { CUSTOMERS } from '../mockData';
|
||||
|
||||
const Customers = () => {
|
||||
const [customers] = useState(CUSTOMERS);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**After:**
|
||||
```typescript
|
||||
import { useCustomers } from '../hooks/useCustomers';
|
||||
|
||||
const Customers = () => {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const { data: customers, isLoading } = useCustomers({ search: searchTerm });
|
||||
|
||||
if (isLoading) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
#### Example: Scheduler.tsx
|
||||
|
||||
**Before:**
|
||||
```typescript
|
||||
import { APPOINTMENTS, RESOURCES, SERVICES } from '../mockData';
|
||||
|
||||
const Scheduler = () => {
|
||||
const [appointments] = useState(APPOINTMENTS);
|
||||
const [resources] = useState(RESOURCES);
|
||||
const [services] = useState(SERVICES);
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**After:**
|
||||
```typescript
|
||||
import { useAppointments } from '../hooks/useAppointments';
|
||||
import { useResources } from '../hooks/useResources';
|
||||
import { useServices } from '../hooks/useServices';
|
||||
|
||||
const Scheduler = () => {
|
||||
const { data: appointments, isLoading: apptLoading } = useAppointments();
|
||||
const { data: resources, isLoading: resLoading } = useResources();
|
||||
const { data: services, isLoading: servicesLoading } = useServices();
|
||||
|
||||
if (apptLoading || resLoading || servicesLoading) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### Step 4: Handle Create/Update/Delete Operations
|
||||
|
||||
For mutations, use the mutation hooks:
|
||||
|
||||
```typescript
|
||||
import { useCreateCustomer, useUpdateCustomer, useDeleteCustomer } from '../hooks/useCustomers';
|
||||
|
||||
const Customers = () => {
|
||||
const { data: customers } = useCustomers();
|
||||
const createMutation = useCreateCustomer();
|
||||
const updateMutation = useUpdateCustomer();
|
||||
const deleteMutation = useDeleteCustomer();
|
||||
|
||||
const handleCreateCustomer = (data: any) => {
|
||||
createMutation.mutate(data, {
|
||||
onSuccess: () => {
|
||||
console.log('Customer created!');
|
||||
// Maybe close a modal or reset a form
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Failed to create customer:', error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleUpdateCustomer = (id: string, updates: any) => {
|
||||
updateMutation.mutate({ id, updates }, {
|
||||
onSuccess: () => {
|
||||
console.log('Customer updated!');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteCustomer = (id: string) => {
|
||||
deleteMutation.mutate(id, {
|
||||
onSuccess: () => {
|
||||
console.log('Customer deleted!');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
// Your JSX with handlers
|
||||
<button onClick={() => handleCreateCustomer(newCustomerData)}>
|
||||
Create Customer
|
||||
</button>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### Step 5: Update App.tsx with Full Routing
|
||||
|
||||
Once components are copied and updated, implement role-based routing in App.tsx:
|
||||
|
||||
```typescript
|
||||
// Import all your pages
|
||||
import Dashboard from './pages/Dashboard';
|
||||
import Scheduler from './pages/Scheduler';
|
||||
import Customers from './pages/Customers';
|
||||
// ... etc
|
||||
|
||||
const AppContent = () => {
|
||||
const { data: user } = useCurrentUser();
|
||||
const { data: business } = useCurrentBusiness();
|
||||
|
||||
if (!user) return <LoginPage />;
|
||||
|
||||
// Platform users
|
||||
if (['superuser', 'platform_manager', 'platform_support'].includes(user.role)) {
|
||||
return (
|
||||
<Routes>
|
||||
<Route element={<PlatformLayout user={user} />}>
|
||||
<Route path="/platform/dashboard" element={<PlatformDashboard />} />
|
||||
<Route path="/platform/businesses" element={<PlatformBusinesses />} />
|
||||
{/* ... more platform routes */}
|
||||
</Route>
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
// Customer users
|
||||
if (user.role === 'customer') {
|
||||
return (
|
||||
<Routes>
|
||||
<Route element={<CustomerLayout business={business} user={user} />}>
|
||||
<Route path="/" element={<CustomerDashboard />} />
|
||||
<Route path="/book" element={<BookingPage />} />
|
||||
{/* ... more customer routes */}
|
||||
</Route>
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
// Business users (owner, manager, staff, resource)
|
||||
return (
|
||||
<Routes>
|
||||
<Route element={<BusinessLayout business={business} user={user} />}>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/scheduler" element={<Scheduler />} />
|
||||
<Route path="/customers" element={<Customers />} />
|
||||
<Route path="/resources" element={<Resources />} />
|
||||
{/* ... more business routes */}
|
||||
</Route>
|
||||
</Routes>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
## Testing with Playwright
|
||||
|
||||
### Run Visual Tests
|
||||
|
||||
```bash
|
||||
# Run tests and compare with extracted frontend
|
||||
npm run test:headed
|
||||
|
||||
# Or use UI mode for debugging
|
||||
npm run test:ui
|
||||
```
|
||||
|
||||
### Create New Tests
|
||||
|
||||
Create test files in `tests/e2e/`:
|
||||
|
||||
```typescript
|
||||
// tests/e2e/customers.spec.ts
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Customers Page', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Login
|
||||
await page.goto('/');
|
||||
await page.getByLabel(/username/i).fill('testowner');
|
||||
await page.getByLabel(/password/i).fill('testpass123');
|
||||
await page.getByRole('button', { name: /sign in/i }).click();
|
||||
await page.waitForURL(/\//);
|
||||
});
|
||||
|
||||
test('should display customers list', async ({ page }) => {
|
||||
await page.goto('/customers');
|
||||
|
||||
// Wait for customers to load
|
||||
await page.waitForSelector('table tbody tr, [data-testid="customer-list"]');
|
||||
|
||||
// Take screenshot for comparison
|
||||
await expect(page).toHaveScreenshot('customers-list.png');
|
||||
});
|
||||
|
||||
test('should filter customers by status', async ({ page }) => {
|
||||
await page.goto('/customers');
|
||||
|
||||
// Click status filter
|
||||
await page.getByRole('button', { name: /filter/i }).click();
|
||||
await page.getByRole('option', { name: /active/i }).click();
|
||||
|
||||
// Verify filtered results
|
||||
const rows = await page.locator('table tbody tr').count();
|
||||
expect(rows).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Issue: Types don't match
|
||||
**Symptom**: TypeScript errors about id being string vs number
|
||||
|
||||
**Solution**: The hooks already handle transformation. Make sure you're using the hooks, not calling apiClient directly.
|
||||
|
||||
### Issue: Components look different from extracted version
|
||||
**Symptom**: Visual differences in Playwright screenshots
|
||||
|
||||
**Solution**:
|
||||
1. Check Tailwind classes are identical
|
||||
2. Verify dark mode is applied correctly
|
||||
3. Compare CSS files from extracted version
|
||||
4. Use Playwright UI mode to inspect elements
|
||||
|
||||
### Issue: CORS errors in console
|
||||
**Symptom**: "Access-Control-Allow-Origin" errors
|
||||
|
||||
**Solution**:
|
||||
1. Verify backend is running
|
||||
2. Check CORS settings in `backend/config/settings/base.py`
|
||||
3. Ensure `X-Business-Subdomain` header is in CORS_ALLOW_HEADERS
|
||||
|
||||
### Issue: 404 on API calls
|
||||
**Symptom**: API calls return 404
|
||||
|
||||
**Solution**:
|
||||
1. Check you're using correct subdomain: `acme.lvh.me:5173`
|
||||
2. Verify backend middleware is processing `X-Business-Subdomain` header
|
||||
3. Check business exists in database with correct subdomain
|
||||
|
||||
### Issue: Data not loading
|
||||
**Symptom**: Components show loading forever
|
||||
|
||||
**Solution**:
|
||||
1. Check browser console for errors
|
||||
2. Verify backend migrations are applied
|
||||
3. Create test data in backend
|
||||
4. Check React Query DevTools (install `@tanstack/react-query-devtools`)
|
||||
|
||||
## Adding React Query DevTools
|
||||
|
||||
For debugging:
|
||||
|
||||
```bash
|
||||
npm install @tanstack/react-query-devtools
|
||||
```
|
||||
|
||||
In App.tsx:
|
||||
```typescript
|
||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
||||
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Router>
|
||||
<AppContent />
|
||||
</Router>
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
</QueryClientProvider>
|
||||
```
|
||||
|
||||
## Component Integration Checklist
|
||||
|
||||
- [ ] Login page works
|
||||
- [ ] Dashboard displays
|
||||
- [ ] Sidebar navigation works
|
||||
- [ ] Dark mode toggle works
|
||||
- [ ] Customers page loads data from API
|
||||
- [ ] Can create new customer
|
||||
- [ ] Can edit customer
|
||||
- [ ] Can delete customer
|
||||
- [ ] Services page loads data
|
||||
- [ ] Resources page loads data
|
||||
- [ ] Scheduler displays appointments
|
||||
- [ ] Can create appointment
|
||||
- [ ] Can drag-and-drop appointments
|
||||
- [ ] Settings page loads business data
|
||||
- [ ] Can update business settings
|
||||
- [ ] Visual tests pass
|
||||
- [ ] No console errors
|
||||
|
||||
## Next Steps After Basic Integration
|
||||
|
||||
1. **Error Boundaries**: Add error boundaries for better error handling
|
||||
2. **Loading States**: Improve loading UI with skeletons
|
||||
3. **Optimistic Updates**: Add optimistic updates for better UX
|
||||
4. **Form Validation**: Add form validation with react-hook-form or similar
|
||||
5. **Toast Notifications**: Add toast library for success/error messages
|
||||
6. **Accessibility**: Add ARIA labels and keyboard navigation
|
||||
7. **Performance**: Optimize re-renders with React.memo
|
||||
8. **Tests**: Add more comprehensive Playwright tests
|
||||
|
||||
## Resources
|
||||
|
||||
- **Main Guide**: `../FRONTEND_INTEGRATION_GUIDE.md`
|
||||
- **Backend API**: `../BACKEND_IMPLEMENTATION_SUMMARY.md`
|
||||
- **All Hooks**: Check `src/hooks/` directory
|
||||
- **Type Definitions**: `src/types.ts`
|
||||
- **Example Components**: `src/*-extracted/` directories
|
||||
|
||||
You're ready to integrate! Start with the Dashboard, then move to individual features one by one.
|
||||
17
frontend/capture-original.js
Normal file
@@ -0,0 +1,17 @@
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch();
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
|
||||
// Capture original frontend
|
||||
await page.goto('http://localhost:3000/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(2000);
|
||||
await page.screenshot({ path: 'tests/e2e/original-main.png', fullPage: true });
|
||||
|
||||
console.log('Screenshot saved to tests/e2e/original-main.png');
|
||||
|
||||
await browser.close();
|
||||
})();
|
||||
12
frontend/docker-entrypoint.sh
Normal file
@@ -0,0 +1,12 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# Ensure node_modules are in sync with package.json
|
||||
# This handles the case where the anonymous volume has stale dependencies
|
||||
if [ ! -d "node_modules" ] || [ "package.json" -nt "node_modules" ]; then
|
||||
echo "Installing npm packages..."
|
||||
npm install --network-timeout=600000 --retry 3
|
||||
fi
|
||||
|
||||
# Execute the CMD
|
||||
exec "$@"
|
||||
29
frontend/eslint.config.js
Normal file
@@ -0,0 +1,29 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{js,jsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
ecmaFeatures: { jsx: true },
|
||||
sourceType: 'module',
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
|
||||
},
|
||||
},
|
||||
])
|
||||
48
frontend/index.html
Normal file
@@ -0,0 +1,48 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<!-- CSP: Disabled in development due to browser extension conflicts. Enable in production via server headers. -->
|
||||
<title>Smooth Schedule - Multi-Tenant Scheduling</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
/* Ensure full height for the app */
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Custom scrollbar for the timeline */
|
||||
.timeline-scroll::-webkit-scrollbar {
|
||||
height: 8px;
|
||||
width: 8px;
|
||||
}
|
||||
.timeline-scroll::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
}
|
||||
.dark .timeline-scroll::-webkit-scrollbar-track {
|
||||
background: #1e293b;
|
||||
}
|
||||
.timeline-scroll::-webkit-scrollbar-thumb {
|
||||
background: #cbd5e1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.dark .timeline-scroll::-webkit-scrollbar-thumb {
|
||||
background: #475569;
|
||||
}
|
||||
.timeline-scroll::-webkit-scrollbar-thumb:hover {
|
||||
background: #94a3b8;
|
||||
}
|
||||
.dark .timeline-scroll::-webkit-scrollbar-thumb:hover {
|
||||
background: #64748b;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-white antialiased transition-colors duration-200">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
60
frontend/nginx.conf
Normal file
@@ -0,0 +1,60 @@
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
# Logging
|
||||
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
||||
'$status $body_bytes_sent "$http_referer" '
|
||||
'"$http_user_agent" "$http_x_forwarded_for"';
|
||||
access_log /var/log/nginx/access.log main;
|
||||
error_log /var/log/nginx/error.log warn;
|
||||
|
||||
# Performance
|
||||
sendfile on;
|
||||
tcp_nopush on;
|
||||
tcp_nodelay on;
|
||||
keepalive_timeout 65;
|
||||
|
||||
# Compression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_proxied any;
|
||||
gzip_comp_level 6;
|
||||
gzip_types text/plain text/css text/xml application/json application/javascript
|
||||
application/rss+xml application/atom+xml image/svg+xml;
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
|
||||
# Cache static assets
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# Handle SPA routing - serve index.html for all routes
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Health check endpoint
|
||||
location /health {
|
||||
access_log off;
|
||||
return 200 "healthy\n";
|
||||
add_header Content-Type text/plain;
|
||||
}
|
||||
}
|
||||
}
|
||||
4647
frontend/package-lock.json
generated
Normal file
50
frontend/package.json
Normal file
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@stripe/connect-js": "^3.3.31",
|
||||
"@stripe/react-connect-js": "^3.3.31",
|
||||
"@tanstack/react-query": "^5.90.10",
|
||||
"axios": "^1.13.2",
|
||||
"date-fns": "^4.1.0",
|
||||
"i18next": "^25.6.3",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"i18next-http-backend": "^3.0.2",
|
||||
"lucide-react": "^0.554.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-i18next": "^16.3.5",
|
||||
"react-phone-number-input": "^3.4.14",
|
||||
"react-router-dom": "^7.9.6",
|
||||
"recharts": "^3.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@playwright/test": "^1.48.0",
|
||||
"@tailwindcss/postcss": "^4.1.17",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.6",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"autoprefixer": "^10.4.22",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^4.1.17",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.2.4"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"test": "playwright test",
|
||||
"test:ui": "playwright test --ui",
|
||||
"test:headed": "playwright test --headed"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 224 KiB |
|
After Width: | Height: | Size: 231 KiB |
@@ -0,0 +1,343 @@
|
||||
# Page snapshot
|
||||
|
||||
```yaml
|
||||
- generic [ref=e2]:
|
||||
- generic [ref=e3]:
|
||||
- navigation [ref=e4]:
|
||||
- generic [ref=e6]:
|
||||
- link "Smooth Schedule" [ref=e7] [cursor=pointer]:
|
||||
- /url: "#/"
|
||||
- img [ref=e8]
|
||||
- generic [ref=e14]: Smooth Schedule
|
||||
- generic [ref=e15]:
|
||||
- link "Features" [ref=e16] [cursor=pointer]:
|
||||
- /url: "#/features"
|
||||
- link "Pricing" [ref=e17] [cursor=pointer]:
|
||||
- /url: "#/pricing"
|
||||
- link "About" [ref=e18] [cursor=pointer]:
|
||||
- /url: "#/about"
|
||||
- link "Contact" [ref=e19] [cursor=pointer]:
|
||||
- /url: "#/contact"
|
||||
- generic [ref=e20]:
|
||||
- button "🇺🇸 English" [ref=e23]:
|
||||
- img [ref=e24]
|
||||
- generic [ref=e27]: 🇺🇸
|
||||
- generic [ref=e28]: English
|
||||
- img [ref=e29]
|
||||
- button "Switch to dark mode" [ref=e31]:
|
||||
- img [ref=e32]
|
||||
- link "Login" [ref=e34] [cursor=pointer]:
|
||||
- /url: "#/login"
|
||||
- link "Get Started" [ref=e35] [cursor=pointer]:
|
||||
- /url: "#/signup"
|
||||
- main [ref=e36]:
|
||||
- generic [ref=e37]:
|
||||
- generic [ref=e42]:
|
||||
- generic [ref=e43]:
|
||||
- generic [ref=e44]:
|
||||
- generic [ref=e47]: Get started today
|
||||
- heading "Scheduling Made Simple" [level=1] [ref=e48]
|
||||
- paragraph [ref=e49]: The all-in-one platform for managing appointments, resources, and customers. Start free, scale as you grow.
|
||||
- generic [ref=e50]:
|
||||
- link "Get Started Free" [ref=e51] [cursor=pointer]:
|
||||
- /url: "#/signup"
|
||||
- text: Get Started Free
|
||||
- img [ref=e52]
|
||||
- button "Watch Demo" [ref=e54]:
|
||||
- img [ref=e55]
|
||||
- text: Watch Demo
|
||||
- generic [ref=e57]:
|
||||
- generic [ref=e58]:
|
||||
- img [ref=e59]
|
||||
- generic [ref=e62]: No credit card required
|
||||
- generic [ref=e64]:
|
||||
- img [ref=e65]
|
||||
- generic [ref=e68]: Get started today
|
||||
- generic [ref=e69]:
|
||||
- generic [ref=e71]:
|
||||
- generic [ref=e78]: dashboard.smoothschedule.com
|
||||
- generic [ref=e79]:
|
||||
- generic [ref=e80]:
|
||||
- generic [ref=e81]:
|
||||
- generic [ref=e82]: Today
|
||||
- generic [ref=e83]: "12"
|
||||
- generic [ref=e84]:
|
||||
- generic [ref=e85]: This Week
|
||||
- generic [ref=e86]: "48"
|
||||
- generic [ref=e87]:
|
||||
- generic [ref=e88]: Revenue
|
||||
- generic [ref=e89]: $2.4k
|
||||
- generic [ref=e90]:
|
||||
- generic [ref=e91]: Today's Schedule
|
||||
- generic [ref=e92]:
|
||||
- generic [ref=e95]:
|
||||
- generic [ref=e96]: 9:00 AM
|
||||
- generic [ref=e97]: Sarah J. - Haircut
|
||||
- generic [ref=e100]:
|
||||
- generic [ref=e101]: 10:30 AM
|
||||
- generic [ref=e102]: Mike T. - Consultation
|
||||
- generic [ref=e105]:
|
||||
- generic [ref=e106]: 2:00 PM
|
||||
- generic [ref=e107]: Emma W. - Color
|
||||
- generic [ref=e109]:
|
||||
- img [ref=e111]
|
||||
- generic [ref=e114]:
|
||||
- generic [ref=e115]: New Booking!
|
||||
- generic [ref=e116]: Just now
|
||||
- generic [ref=e117]:
|
||||
- paragraph [ref=e118]: Trusted by 1,000+ businesses worldwide
|
||||
- generic [ref=e119]:
|
||||
- generic [ref=e120]: TechCorp
|
||||
- generic [ref=e121]: Innovate
|
||||
- generic [ref=e122]: StartupX
|
||||
- generic [ref=e123]: GrowthCo
|
||||
- generic [ref=e124]: ScaleUp
|
||||
- generic [ref=e126]:
|
||||
- generic [ref=e127]:
|
||||
- heading "Everything You Need" [level=2] [ref=e128]
|
||||
- paragraph [ref=e129]: Powerful features to run your service business
|
||||
- generic [ref=e130]:
|
||||
- generic [ref=e131]:
|
||||
- img [ref=e133]
|
||||
- heading "Smart Scheduling" [level=3] [ref=e135]
|
||||
- paragraph [ref=e136]: Drag-and-drop calendar with real-time availability, automated reminders, and conflict detection.
|
||||
- generic [ref=e137]:
|
||||
- img [ref=e139]
|
||||
- heading "Resource Management" [level=3] [ref=e144]
|
||||
- paragraph [ref=e145]: Manage staff, rooms, and equipment. Set availability, skills, and booking rules.
|
||||
- generic [ref=e146]:
|
||||
- img [ref=e148]
|
||||
- heading "Customer Portal" [level=3] [ref=e152]
|
||||
- paragraph [ref=e153]: Self-service booking portal for customers. View history, manage appointments, and save payment methods.
|
||||
- generic [ref=e154]:
|
||||
- img [ref=e156]
|
||||
- heading "Integrated Payments" [level=3] [ref=e158]
|
||||
- paragraph [ref=e159]: Accept payments online with Stripe. Deposits, full payments, and automatic invoicing.
|
||||
- generic [ref=e160]:
|
||||
- img [ref=e162]
|
||||
- heading "Multi-Location Support" [level=3] [ref=e166]
|
||||
- paragraph [ref=e167]: Manage multiple locations or brands from a single dashboard with isolated data.
|
||||
- generic [ref=e168]:
|
||||
- img [ref=e170]
|
||||
- heading "White-Label Ready" [level=3] [ref=e176]
|
||||
- paragraph [ref=e177]: Custom domain, branding, and remove SmoothSchedule branding for a seamless experience.
|
||||
- link "View All features" [ref=e179] [cursor=pointer]:
|
||||
- /url: "#/features"
|
||||
- text: View All features
|
||||
- img [ref=e180]
|
||||
- generic [ref=e183]:
|
||||
- generic [ref=e184]:
|
||||
- heading "Get Started in Minutes" [level=2] [ref=e185]
|
||||
- paragraph [ref=e186]: Three simple steps to transform your scheduling
|
||||
- generic [ref=e187]:
|
||||
- generic [ref=e190]:
|
||||
- generic [ref=e191]: "01"
|
||||
- img [ref=e193]
|
||||
- heading "Create Your Account" [level=3] [ref=e196]
|
||||
- paragraph [ref=e197]: Sign up for free and set up your business profile in minutes.
|
||||
- generic [ref=e200]:
|
||||
- generic [ref=e201]: "02"
|
||||
- img [ref=e203]
|
||||
- heading "Add Your Services" [level=3] [ref=e206]
|
||||
- paragraph [ref=e207]: Configure your services, pricing, and available resources.
|
||||
- generic [ref=e209]:
|
||||
- generic [ref=e210]: "03"
|
||||
- img [ref=e212]
|
||||
- heading "Start Booking" [level=3] [ref=e217]
|
||||
- paragraph [ref=e218]: Share your booking link and let customers schedule instantly.
|
||||
- generic [ref=e221]:
|
||||
- generic [ref=e222]:
|
||||
- img [ref=e224]
|
||||
- generic [ref=e226]: 1M+
|
||||
- generic [ref=e227]: Appointments Scheduled
|
||||
- generic [ref=e228]:
|
||||
- img [ref=e230]
|
||||
- generic [ref=e234]: 5,000+
|
||||
- generic [ref=e235]: Businesses
|
||||
- generic [ref=e236]:
|
||||
- img [ref=e238]
|
||||
- generic [ref=e241]: 50+
|
||||
- generic [ref=e242]: Countries
|
||||
- generic [ref=e243]:
|
||||
- img [ref=e245]
|
||||
- generic [ref=e248]: 99.9%
|
||||
- generic [ref=e249]: Uptime
|
||||
- generic [ref=e251]:
|
||||
- generic [ref=e252]:
|
||||
- heading "Loved by Businesses Everywhere" [level=2] [ref=e253]
|
||||
- paragraph [ref=e254]: See what our customers have to say
|
||||
- generic [ref=e255]:
|
||||
- generic [ref=e256]:
|
||||
- generic [ref=e257]:
|
||||
- img [ref=e258]
|
||||
- img [ref=e260]
|
||||
- img [ref=e262]
|
||||
- img [ref=e264]
|
||||
- img [ref=e266]
|
||||
- blockquote [ref=e268]: "\"SmoothSchedule transformed how we manage appointments. Our no-show rate dropped by 40% with automated reminders.\""
|
||||
- generic [ref=e269]:
|
||||
- generic [ref=e271]: S
|
||||
- generic [ref=e272]:
|
||||
- generic [ref=e273]: Sarah Johnson
|
||||
- generic [ref=e274]: Owner at Luxe Salon
|
||||
- generic [ref=e275]:
|
||||
- generic [ref=e276]:
|
||||
- img [ref=e277]
|
||||
- img [ref=e279]
|
||||
- img [ref=e281]
|
||||
- img [ref=e283]
|
||||
- img [ref=e285]
|
||||
- blockquote [ref=e287]: "\"The white-label feature is perfect for our multi-location business. Each location has its own branded booking experience.\""
|
||||
- generic [ref=e288]:
|
||||
- generic [ref=e290]: M
|
||||
- generic [ref=e291]:
|
||||
- generic [ref=e292]: Michael Chen
|
||||
- generic [ref=e293]: CEO at FitLife Studios
|
||||
- generic [ref=e294]:
|
||||
- generic [ref=e295]:
|
||||
- img [ref=e296]
|
||||
- img [ref=e298]
|
||||
- img [ref=e300]
|
||||
- img [ref=e302]
|
||||
- img [ref=e304]
|
||||
- blockquote [ref=e306]: "\"Setup was incredibly easy. We were up and running in under an hour, and our clients love the self-service booking.\""
|
||||
- generic [ref=e307]:
|
||||
- generic [ref=e309]: E
|
||||
- generic [ref=e310]:
|
||||
- generic [ref=e311]: Emily Rodriguez
|
||||
- generic [ref=e312]: Manager at Peak Performance Therapy
|
||||
- generic [ref=e314]:
|
||||
- generic [ref=e315]:
|
||||
- heading "Simple, Transparent Pricing" [level=2] [ref=e316]
|
||||
- paragraph [ref=e317]: Start free, upgrade as you grow. No hidden fees.
|
||||
- generic [ref=e318]:
|
||||
- generic [ref=e319]:
|
||||
- heading "Free" [level=3] [ref=e320]
|
||||
- paragraph [ref=e321]: Perfect for getting started
|
||||
- generic [ref=e322]: $0/month
|
||||
- link "Get Started" [ref=e323] [cursor=pointer]:
|
||||
- /url: "#/signup"
|
||||
- generic [ref=e324]:
|
||||
- generic [ref=e325]: Most Popular
|
||||
- heading "Professional" [level=3] [ref=e326]
|
||||
- paragraph [ref=e327]: For growing businesses
|
||||
- generic [ref=e328]: $29/month
|
||||
- link "Get Started" [ref=e329] [cursor=pointer]:
|
||||
- /url: "#/signup"
|
||||
- generic [ref=e330]:
|
||||
- heading "Business" [level=3] [ref=e331]
|
||||
- paragraph [ref=e332]: For established teams
|
||||
- generic [ref=e333]: $79/month
|
||||
- link "Get Started" [ref=e334] [cursor=pointer]:
|
||||
- /url: "#/signup"
|
||||
- link "View full pricing details" [ref=e336] [cursor=pointer]:
|
||||
- /url: "#/pricing"
|
||||
- text: View full pricing details
|
||||
- img [ref=e337]
|
||||
- generic [ref=e343]:
|
||||
- heading "Ready to get started?" [level=2] [ref=e344]
|
||||
- paragraph [ref=e345]: Join thousands of businesses already using SmoothSchedule.
|
||||
- generic [ref=e346]:
|
||||
- link "Get Started Free" [ref=e347] [cursor=pointer]:
|
||||
- /url: "#/signup"
|
||||
- text: Get Started Free
|
||||
- img [ref=e348]
|
||||
- link "Talk to Sales" [ref=e350] [cursor=pointer]:
|
||||
- /url: "#/contact"
|
||||
- paragraph [ref=e351]: No credit card required
|
||||
- contentinfo [ref=e352]:
|
||||
- generic [ref=e353]:
|
||||
- generic [ref=e354]:
|
||||
- generic [ref=e355]:
|
||||
- link "Smooth Schedule" [ref=e356] [cursor=pointer]:
|
||||
- /url: "#/"
|
||||
- img [ref=e357]
|
||||
- generic [ref=e363]: Smooth Schedule
|
||||
- paragraph [ref=e364]: The all-in-one scheduling platform for businesses of all sizes. Manage resources, staff, and bookings effortlessly.
|
||||
- generic [ref=e365]:
|
||||
- link "Twitter" [ref=e366] [cursor=pointer]:
|
||||
- /url: https://twitter.com/smoothschedule
|
||||
- img [ref=e367]
|
||||
- link "LinkedIn" [ref=e369] [cursor=pointer]:
|
||||
- /url: https://linkedin.com/company/smoothschedule
|
||||
- img [ref=e370]
|
||||
- link "GitHub" [ref=e374] [cursor=pointer]:
|
||||
- /url: https://github.com/smoothschedule
|
||||
- img [ref=e375]
|
||||
- link "YouTube" [ref=e378] [cursor=pointer]:
|
||||
- /url: https://youtube.com/@smoothschedule
|
||||
- img [ref=e379]
|
||||
- generic [ref=e382]:
|
||||
- heading "Product" [level=3] [ref=e383]
|
||||
- list [ref=e384]:
|
||||
- listitem [ref=e385]:
|
||||
- link "Features" [ref=e386] [cursor=pointer]:
|
||||
- /url: "#/features"
|
||||
- listitem [ref=e387]:
|
||||
- link "Pricing" [ref=e388] [cursor=pointer]:
|
||||
- /url: "#/pricing"
|
||||
- listitem [ref=e389]:
|
||||
- link "Get Started" [ref=e390] [cursor=pointer]:
|
||||
- /url: "#/signup"
|
||||
- generic [ref=e391]:
|
||||
- heading "Company" [level=3] [ref=e392]
|
||||
- list [ref=e393]:
|
||||
- listitem [ref=e394]:
|
||||
- link "About" [ref=e395] [cursor=pointer]:
|
||||
- /url: "#/about"
|
||||
- listitem [ref=e396]:
|
||||
- link "Contact" [ref=e397] [cursor=pointer]:
|
||||
- /url: "#/contact"
|
||||
- generic [ref=e398]:
|
||||
- heading "Legal" [level=3] [ref=e399]
|
||||
- list [ref=e400]:
|
||||
- listitem [ref=e401]:
|
||||
- link "Privacy Policy" [ref=e402] [cursor=pointer]:
|
||||
- /url: "#/privacy"
|
||||
- listitem [ref=e403]:
|
||||
- link "Terms of Service" [ref=e404] [cursor=pointer]:
|
||||
- /url: "#/terms"
|
||||
- paragraph [ref=e406]: © 2025 Smooth Schedule Inc. All rights reserved.
|
||||
- generic [ref=e407]:
|
||||
- generic [ref=e408]:
|
||||
- heading "🔓 Quick Login (Dev Only)" [level=3] [ref=e409]:
|
||||
- generic [ref=e410]: 🔓
|
||||
- generic [ref=e411]: Quick Login (Dev Only)
|
||||
- button "×" [ref=e412]
|
||||
- generic [ref=e413]:
|
||||
- button "Logging in..." [disabled] [ref=e414]:
|
||||
- generic [ref=e415]:
|
||||
- img [ref=e416]
|
||||
- text: Logging in...
|
||||
- button "Platform Manager PLATFORM_MANAGER" [disabled] [ref=e419]:
|
||||
- generic [ref=e420]:
|
||||
- generic [ref=e421]: Platform Manager
|
||||
- generic [ref=e422]: PLATFORM_MANAGER
|
||||
- button "Platform Sales PLATFORM_SALES" [disabled] [ref=e423]:
|
||||
- generic [ref=e424]:
|
||||
- generic [ref=e425]: Platform Sales
|
||||
- generic [ref=e426]: PLATFORM_SALES
|
||||
- button "Platform Support PLATFORM_SUPPORT" [disabled] [ref=e427]:
|
||||
- generic [ref=e428]:
|
||||
- generic [ref=e429]: Platform Support
|
||||
- generic [ref=e430]: PLATFORM_SUPPORT
|
||||
- button "Business Owner TENANT_OWNER" [disabled] [ref=e431]:
|
||||
- generic [ref=e432]:
|
||||
- generic [ref=e433]: Business Owner
|
||||
- generic [ref=e434]: TENANT_OWNER
|
||||
- button "Business Manager TENANT_MANAGER" [disabled] [ref=e435]:
|
||||
- generic [ref=e436]:
|
||||
- generic [ref=e437]: Business Manager
|
||||
- generic [ref=e438]: TENANT_MANAGER
|
||||
- button "Staff Member TENANT_STAFF" [disabled] [ref=e439]:
|
||||
- generic [ref=e440]:
|
||||
- generic [ref=e441]: Staff Member
|
||||
- generic [ref=e442]: TENANT_STAFF
|
||||
- button "Customer CUSTOMER" [disabled] [ref=e443]:
|
||||
- generic [ref=e444]:
|
||||
- generic [ref=e445]: Customer
|
||||
- generic [ref=e446]: CUSTOMER
|
||||
- generic [ref=e447]:
|
||||
- text: "Password for all:"
|
||||
- code [ref=e448]: test123
|
||||
```
|
||||
@@ -0,0 +1,343 @@
|
||||
# Page snapshot
|
||||
|
||||
```yaml
|
||||
- generic [ref=e2]:
|
||||
- generic [ref=e3]:
|
||||
- navigation [ref=e4]:
|
||||
- generic [ref=e6]:
|
||||
- link "Smooth Schedule" [ref=e7]:
|
||||
- /url: "#/"
|
||||
- img [ref=e8]
|
||||
- generic [ref=e14]: Smooth Schedule
|
||||
- generic [ref=e15]:
|
||||
- link "Features" [ref=e16]:
|
||||
- /url: "#/features"
|
||||
- link "Pricing" [ref=e17]:
|
||||
- /url: "#/pricing"
|
||||
- link "About" [ref=e18]:
|
||||
- /url: "#/about"
|
||||
- link "Contact" [ref=e19]:
|
||||
- /url: "#/contact"
|
||||
- generic [ref=e20]:
|
||||
- button "🇺🇸 English" [ref=e23]:
|
||||
- img [ref=e24]
|
||||
- generic [ref=e27]: 🇺🇸
|
||||
- generic [ref=e28]: English
|
||||
- img [ref=e29]
|
||||
- button "Switch to dark mode" [ref=e31]:
|
||||
- img [ref=e32]
|
||||
- link "Login" [ref=e34]:
|
||||
- /url: "#/login"
|
||||
- link "Get Started" [ref=e35]:
|
||||
- /url: "#/signup"
|
||||
- main [ref=e36]:
|
||||
- generic [ref=e37]:
|
||||
- generic [ref=e42]:
|
||||
- generic [ref=e43]:
|
||||
- generic [ref=e44]:
|
||||
- generic [ref=e47]: Get started today
|
||||
- heading "Scheduling Made Simple" [level=1] [ref=e48]
|
||||
- paragraph [ref=e49]: The all-in-one platform for managing appointments, resources, and customers. Start free, scale as you grow.
|
||||
- generic [ref=e50]:
|
||||
- link "Get Started Free" [ref=e51]:
|
||||
- /url: "#/signup"
|
||||
- text: Get Started Free
|
||||
- img [ref=e52]
|
||||
- button "Watch Demo" [ref=e54]:
|
||||
- img [ref=e55]
|
||||
- text: Watch Demo
|
||||
- generic [ref=e57]:
|
||||
- generic [ref=e58]:
|
||||
- img [ref=e59]
|
||||
- generic [ref=e62]: No credit card required
|
||||
- generic [ref=e64]:
|
||||
- img [ref=e65]
|
||||
- generic [ref=e68]: Get started today
|
||||
- generic [ref=e69]:
|
||||
- generic [ref=e71]:
|
||||
- generic [ref=e78]: dashboard.smoothschedule.com
|
||||
- generic [ref=e79]:
|
||||
- generic [ref=e80]:
|
||||
- generic [ref=e81]:
|
||||
- generic [ref=e82]: Today
|
||||
- generic [ref=e83]: "12"
|
||||
- generic [ref=e84]:
|
||||
- generic [ref=e85]: This Week
|
||||
- generic [ref=e86]: "48"
|
||||
- generic [ref=e87]:
|
||||
- generic [ref=e88]: Revenue
|
||||
- generic [ref=e89]: $2.4k
|
||||
- generic [ref=e90]:
|
||||
- generic [ref=e91]: Today's Schedule
|
||||
- generic [ref=e92]:
|
||||
- generic [ref=e95]:
|
||||
- generic [ref=e96]: 9:00 AM
|
||||
- generic [ref=e97]: Sarah J. - Haircut
|
||||
- generic [ref=e100]:
|
||||
- generic [ref=e101]: 10:30 AM
|
||||
- generic [ref=e102]: Mike T. - Consultation
|
||||
- generic [ref=e105]:
|
||||
- generic [ref=e106]: 2:00 PM
|
||||
- generic [ref=e107]: Emma W. - Color
|
||||
- generic [ref=e109]:
|
||||
- img [ref=e111]
|
||||
- generic [ref=e114]:
|
||||
- generic [ref=e115]: New Booking!
|
||||
- generic [ref=e116]: Just now
|
||||
- generic [ref=e117]:
|
||||
- paragraph [ref=e118]: Trusted by 1,000+ businesses worldwide
|
||||
- generic [ref=e119]:
|
||||
- generic [ref=e120]: TechCorp
|
||||
- generic [ref=e121]: Innovate
|
||||
- generic [ref=e122]: StartupX
|
||||
- generic [ref=e123]: GrowthCo
|
||||
- generic [ref=e124]: ScaleUp
|
||||
- generic [ref=e126]:
|
||||
- generic [ref=e127]:
|
||||
- heading "Everything You Need" [level=2] [ref=e128]
|
||||
- paragraph [ref=e129]: Powerful features to run your service business
|
||||
- generic [ref=e130]:
|
||||
- generic [ref=e131]:
|
||||
- img [ref=e133]
|
||||
- heading "Smart Scheduling" [level=3] [ref=e135]
|
||||
- paragraph [ref=e136]: Drag-and-drop calendar with real-time availability, automated reminders, and conflict detection.
|
||||
- generic [ref=e137]:
|
||||
- img [ref=e139]
|
||||
- heading "Resource Management" [level=3] [ref=e144]
|
||||
- paragraph [ref=e145]: Manage staff, rooms, and equipment. Set availability, skills, and booking rules.
|
||||
- generic [ref=e146]:
|
||||
- img [ref=e148]
|
||||
- heading "Customer Portal" [level=3] [ref=e152]
|
||||
- paragraph [ref=e153]: Self-service booking portal for customers. View history, manage appointments, and save payment methods.
|
||||
- generic [ref=e154]:
|
||||
- img [ref=e156]
|
||||
- heading "Integrated Payments" [level=3] [ref=e158]
|
||||
- paragraph [ref=e159]: Accept payments online with Stripe. Deposits, full payments, and automatic invoicing.
|
||||
- generic [ref=e160]:
|
||||
- img [ref=e162]
|
||||
- heading "Multi-Location Support" [level=3] [ref=e166]
|
||||
- paragraph [ref=e167]: Manage multiple locations or brands from a single dashboard with isolated data.
|
||||
- generic [ref=e168]:
|
||||
- img [ref=e170]
|
||||
- heading "White-Label Ready" [level=3] [ref=e176]
|
||||
- paragraph [ref=e177]: Custom domain, branding, and remove SmoothSchedule branding for a seamless experience.
|
||||
- link "View All features" [ref=e179]:
|
||||
- /url: "#/features"
|
||||
- text: View All features
|
||||
- img [ref=e180]
|
||||
- generic [ref=e183]:
|
||||
- generic [ref=e184]:
|
||||
- heading "Get Started in Minutes" [level=2] [ref=e185]
|
||||
- paragraph [ref=e186]: Three simple steps to transform your scheduling
|
||||
- generic [ref=e187]:
|
||||
- generic [ref=e190]:
|
||||
- generic [ref=e191]: "01"
|
||||
- img [ref=e193]
|
||||
- heading "Create Your Account" [level=3] [ref=e196]
|
||||
- paragraph [ref=e197]: Sign up for free and set up your business profile in minutes.
|
||||
- generic [ref=e200]:
|
||||
- generic [ref=e201]: "02"
|
||||
- img [ref=e203]
|
||||
- heading "Add Your Services" [level=3] [ref=e206]
|
||||
- paragraph [ref=e207]: Configure your services, pricing, and available resources.
|
||||
- generic [ref=e209]:
|
||||
- generic [ref=e210]: "03"
|
||||
- img [ref=e212]
|
||||
- heading "Start Booking" [level=3] [ref=e217]
|
||||
- paragraph [ref=e218]: Share your booking link and let customers schedule instantly.
|
||||
- generic [ref=e221]:
|
||||
- generic [ref=e222]:
|
||||
- img [ref=e224]
|
||||
- generic [ref=e226]: 1M+
|
||||
- generic [ref=e227]: Appointments Scheduled
|
||||
- generic [ref=e228]:
|
||||
- img [ref=e230]
|
||||
- generic [ref=e234]: 5,000+
|
||||
- generic [ref=e235]: Businesses
|
||||
- generic [ref=e236]:
|
||||
- img [ref=e238]
|
||||
- generic [ref=e241]: 50+
|
||||
- generic [ref=e242]: Countries
|
||||
- generic [ref=e243]:
|
||||
- img [ref=e245]
|
||||
- generic [ref=e248]: 99.9%
|
||||
- generic [ref=e249]: Uptime
|
||||
- generic [ref=e251]:
|
||||
- generic [ref=e252]:
|
||||
- heading "Loved by Businesses Everywhere" [level=2] [ref=e253]
|
||||
- paragraph [ref=e254]: See what our customers have to say
|
||||
- generic [ref=e255]:
|
||||
- generic [ref=e256]:
|
||||
- generic [ref=e257]:
|
||||
- img [ref=e258]
|
||||
- img [ref=e260]
|
||||
- img [ref=e262]
|
||||
- img [ref=e264]
|
||||
- img [ref=e266]
|
||||
- blockquote [ref=e268]: "\"SmoothSchedule transformed how we manage appointments. Our no-show rate dropped by 40% with automated reminders.\""
|
||||
- generic [ref=e269]:
|
||||
- generic [ref=e271]: S
|
||||
- generic [ref=e272]:
|
||||
- generic [ref=e273]: Sarah Johnson
|
||||
- generic [ref=e274]: Owner at Luxe Salon
|
||||
- generic [ref=e275]:
|
||||
- generic [ref=e276]:
|
||||
- img [ref=e277]
|
||||
- img [ref=e279]
|
||||
- img [ref=e281]
|
||||
- img [ref=e283]
|
||||
- img [ref=e285]
|
||||
- blockquote [ref=e287]: "\"The white-label feature is perfect for our multi-location business. Each location has its own branded booking experience.\""
|
||||
- generic [ref=e288]:
|
||||
- generic [ref=e290]: M
|
||||
- generic [ref=e291]:
|
||||
- generic [ref=e292]: Michael Chen
|
||||
- generic [ref=e293]: CEO at FitLife Studios
|
||||
- generic [ref=e294]:
|
||||
- generic [ref=e295]:
|
||||
- img [ref=e296]
|
||||
- img [ref=e298]
|
||||
- img [ref=e300]
|
||||
- img [ref=e302]
|
||||
- img [ref=e304]
|
||||
- blockquote [ref=e306]: "\"Setup was incredibly easy. We were up and running in under an hour, and our clients love the self-service booking.\""
|
||||
- generic [ref=e307]:
|
||||
- generic [ref=e309]: E
|
||||
- generic [ref=e310]:
|
||||
- generic [ref=e311]: Emily Rodriguez
|
||||
- generic [ref=e312]: Manager at Peak Performance Therapy
|
||||
- generic [ref=e314]:
|
||||
- generic [ref=e315]:
|
||||
- heading "Simple, Transparent Pricing" [level=2] [ref=e316]
|
||||
- paragraph [ref=e317]: Start free, upgrade as you grow. No hidden fees.
|
||||
- generic [ref=e318]:
|
||||
- generic [ref=e319]:
|
||||
- heading "Free" [level=3] [ref=e320]
|
||||
- paragraph [ref=e321]: Perfect for getting started
|
||||
- generic [ref=e322]: $0/month
|
||||
- link "Get Started" [ref=e323]:
|
||||
- /url: "#/signup"
|
||||
- generic [ref=e324]:
|
||||
- generic [ref=e325]: Most Popular
|
||||
- heading "Professional" [level=3] [ref=e326]
|
||||
- paragraph [ref=e327]: For growing businesses
|
||||
- generic [ref=e328]: $29/month
|
||||
- link "Get Started" [ref=e329]:
|
||||
- /url: "#/signup"
|
||||
- generic [ref=e330]:
|
||||
- heading "Business" [level=3] [ref=e331]
|
||||
- paragraph [ref=e332]: For established teams
|
||||
- generic [ref=e333]: $79/month
|
||||
- link "Get Started" [ref=e334]:
|
||||
- /url: "#/signup"
|
||||
- link "View full pricing details" [ref=e336]:
|
||||
- /url: "#/pricing"
|
||||
- text: View full pricing details
|
||||
- img [ref=e337]
|
||||
- generic [ref=e343]:
|
||||
- heading "Ready to get started?" [level=2] [ref=e344]
|
||||
- paragraph [ref=e345]: Join thousands of businesses already using SmoothSchedule.
|
||||
- generic [ref=e346]:
|
||||
- link "Get Started Free" [ref=e347]:
|
||||
- /url: "#/signup"
|
||||
- text: Get Started Free
|
||||
- img [ref=e348]
|
||||
- link "Talk to Sales" [ref=e350]:
|
||||
- /url: "#/contact"
|
||||
- paragraph [ref=e351]: No credit card required
|
||||
- contentinfo [ref=e352]:
|
||||
- generic [ref=e353]:
|
||||
- generic [ref=e354]:
|
||||
- generic [ref=e355]:
|
||||
- link "Smooth Schedule" [ref=e356]:
|
||||
- /url: "#/"
|
||||
- img [ref=e357]
|
||||
- generic [ref=e363]: Smooth Schedule
|
||||
- paragraph [ref=e364]: The all-in-one scheduling platform for businesses of all sizes. Manage resources, staff, and bookings effortlessly.
|
||||
- generic [ref=e365]:
|
||||
- link "Twitter" [ref=e366]:
|
||||
- /url: https://twitter.com/smoothschedule
|
||||
- img [ref=e367]
|
||||
- link "LinkedIn" [ref=e369]:
|
||||
- /url: https://linkedin.com/company/smoothschedule
|
||||
- img [ref=e370]
|
||||
- link "GitHub" [ref=e374]:
|
||||
- /url: https://github.com/smoothschedule
|
||||
- img [ref=e375]
|
||||
- link "YouTube" [ref=e378]:
|
||||
- /url: https://youtube.com/@smoothschedule
|
||||
- img [ref=e379]
|
||||
- generic [ref=e382]:
|
||||
- heading "Product" [level=3] [ref=e383]
|
||||
- list [ref=e384]:
|
||||
- listitem [ref=e385]:
|
||||
- link "Features" [ref=e386]:
|
||||
- /url: "#/features"
|
||||
- listitem [ref=e387]:
|
||||
- link "Pricing" [ref=e388]:
|
||||
- /url: "#/pricing"
|
||||
- listitem [ref=e389]:
|
||||
- link "Get Started" [ref=e390]:
|
||||
- /url: "#/signup"
|
||||
- generic [ref=e391]:
|
||||
- heading "Company" [level=3] [ref=e392]
|
||||
- list [ref=e393]:
|
||||
- listitem [ref=e394]:
|
||||
- link "About" [ref=e395]:
|
||||
- /url: "#/about"
|
||||
- listitem [ref=e396]:
|
||||
- link "Contact" [ref=e397]:
|
||||
- /url: "#/contact"
|
||||
- generic [ref=e398]:
|
||||
- heading "Legal" [level=3] [ref=e399]
|
||||
- list [ref=e400]:
|
||||
- listitem [ref=e401]:
|
||||
- link "Privacy Policy" [ref=e402]:
|
||||
- /url: "#/privacy"
|
||||
- listitem [ref=e403]:
|
||||
- link "Terms of Service" [ref=e404]:
|
||||
- /url: "#/terms"
|
||||
- paragraph [ref=e406]: © 2025 Smooth Schedule Inc. All rights reserved.
|
||||
- generic [ref=e407]:
|
||||
- generic [ref=e408]:
|
||||
- heading "🔓 Quick Login (Dev Only)" [level=3] [ref=e409]:
|
||||
- generic [ref=e410]: 🔓
|
||||
- generic [ref=e411]: Quick Login (Dev Only)
|
||||
- button "×" [ref=e412]
|
||||
- generic [ref=e413]:
|
||||
- button "Logging in..." [disabled] [ref=e414]:
|
||||
- generic [ref=e415]:
|
||||
- img [ref=e416]
|
||||
- text: Logging in...
|
||||
- button "Platform Manager PLATFORM_MANAGER" [disabled] [ref=e419]:
|
||||
- generic [ref=e420]:
|
||||
- generic [ref=e421]: Platform Manager
|
||||
- generic [ref=e422]: PLATFORM_MANAGER
|
||||
- button "Platform Sales PLATFORM_SALES" [disabled] [ref=e423]:
|
||||
- generic [ref=e424]:
|
||||
- generic [ref=e425]: Platform Sales
|
||||
- generic [ref=e426]: PLATFORM_SALES
|
||||
- button "Platform Support PLATFORM_SUPPORT" [disabled] [ref=e427]:
|
||||
- generic [ref=e428]:
|
||||
- generic [ref=e429]: Platform Support
|
||||
- generic [ref=e430]: PLATFORM_SUPPORT
|
||||
- button "Business Owner TENANT_OWNER" [disabled] [ref=e431]:
|
||||
- generic [ref=e432]:
|
||||
- generic [ref=e433]: Business Owner
|
||||
- generic [ref=e434]: TENANT_OWNER
|
||||
- button "Business Manager TENANT_MANAGER" [disabled] [ref=e435]:
|
||||
- generic [ref=e436]:
|
||||
- generic [ref=e437]: Business Manager
|
||||
- generic [ref=e438]: TENANT_MANAGER
|
||||
- button "Staff Member TENANT_STAFF" [disabled] [ref=e439]:
|
||||
- generic [ref=e440]:
|
||||
- generic [ref=e441]: Staff Member
|
||||
- generic [ref=e442]: TENANT_STAFF
|
||||
- button "Customer CUSTOMER" [disabled] [ref=e443]:
|
||||
- generic [ref=e444]:
|
||||
- generic [ref=e445]: Customer
|
||||
- generic [ref=e446]: CUSTOMER
|
||||
- generic [ref=e447]:
|
||||
- text: "Password for all:"
|
||||
- code [ref=e448]: test123
|
||||
```
|
||||
@@ -0,0 +1,343 @@
|
||||
# Page snapshot
|
||||
|
||||
```yaml
|
||||
- generic [ref=e2]:
|
||||
- generic [ref=e3]:
|
||||
- navigation [ref=e4]:
|
||||
- generic [ref=e6]:
|
||||
- link "Smooth Schedule" [ref=e7] [cursor=pointer]:
|
||||
- /url: "#/"
|
||||
- img [ref=e8]
|
||||
- generic [ref=e14]: Smooth Schedule
|
||||
- generic [ref=e15]:
|
||||
- link "Features" [ref=e16] [cursor=pointer]:
|
||||
- /url: "#/features"
|
||||
- link "Pricing" [ref=e17] [cursor=pointer]:
|
||||
- /url: "#/pricing"
|
||||
- link "About" [ref=e18] [cursor=pointer]:
|
||||
- /url: "#/about"
|
||||
- link "Contact" [ref=e19] [cursor=pointer]:
|
||||
- /url: "#/contact"
|
||||
- generic [ref=e20]:
|
||||
- button "🇺🇸 English" [ref=e23]:
|
||||
- img [ref=e24]
|
||||
- generic [ref=e28]: 🇺🇸
|
||||
- generic [ref=e29]: English
|
||||
- img [ref=e30]
|
||||
- button "Switch to dark mode" [ref=e32]:
|
||||
- img [ref=e33]
|
||||
- link "Login" [ref=e35] [cursor=pointer]:
|
||||
- /url: "#/login"
|
||||
- link "Get Started" [ref=e36] [cursor=pointer]:
|
||||
- /url: "#/signup"
|
||||
- main [ref=e37]:
|
||||
- generic [ref=e38]:
|
||||
- generic [ref=e43]:
|
||||
- generic [ref=e44]:
|
||||
- generic [ref=e45]:
|
||||
- generic [ref=e48]: Get started today
|
||||
- heading "Scheduling Made Simple" [level=1] [ref=e49]
|
||||
- paragraph [ref=e50]: The all-in-one platform for managing appointments, resources, and customers. Start free, scale as you grow.
|
||||
- generic [ref=e51]:
|
||||
- link "Get Started Free" [ref=e52] [cursor=pointer]:
|
||||
- /url: "#/signup"
|
||||
- text: Get Started Free
|
||||
- img [ref=e53]
|
||||
- button "Watch Demo" [ref=e56]:
|
||||
- img [ref=e57]
|
||||
- text: Watch Demo
|
||||
- generic [ref=e59]:
|
||||
- generic [ref=e60]:
|
||||
- img [ref=e61]
|
||||
- generic [ref=e64]: No credit card required
|
||||
- generic [ref=e66]:
|
||||
- img [ref=e67]
|
||||
- generic [ref=e70]: Get started today
|
||||
- generic [ref=e71]:
|
||||
- generic [ref=e73]:
|
||||
- generic [ref=e80]: dashboard.smoothschedule.com
|
||||
- generic [ref=e81]:
|
||||
- generic [ref=e82]:
|
||||
- generic [ref=e83]:
|
||||
- generic [ref=e84]: Today
|
||||
- generic [ref=e85]: "12"
|
||||
- generic [ref=e86]:
|
||||
- generic [ref=e87]: This Week
|
||||
- generic [ref=e88]: "48"
|
||||
- generic [ref=e89]:
|
||||
- generic [ref=e90]: Revenue
|
||||
- generic [ref=e91]: $2.4k
|
||||
- generic [ref=e92]:
|
||||
- generic [ref=e93]: Today's Schedule
|
||||
- generic [ref=e94]:
|
||||
- generic [ref=e97]:
|
||||
- generic [ref=e98]: 9:00 AM
|
||||
- generic [ref=e99]: Sarah J. - Haircut
|
||||
- generic [ref=e102]:
|
||||
- generic [ref=e103]: 10:30 AM
|
||||
- generic [ref=e104]: Mike T. - Consultation
|
||||
- generic [ref=e107]:
|
||||
- generic [ref=e108]: 2:00 PM
|
||||
- generic [ref=e109]: Emma W. - Color
|
||||
- generic [ref=e111]:
|
||||
- img [ref=e113]
|
||||
- generic [ref=e116]:
|
||||
- generic [ref=e117]: New Booking!
|
||||
- generic [ref=e118]: Just now
|
||||
- generic [ref=e119]:
|
||||
- paragraph [ref=e120]: Trusted by 1,000+ businesses worldwide
|
||||
- generic [ref=e121]:
|
||||
- generic [ref=e122]: TechCorp
|
||||
- generic [ref=e123]: Innovate
|
||||
- generic [ref=e124]: StartupX
|
||||
- generic [ref=e125]: GrowthCo
|
||||
- generic [ref=e126]: ScaleUp
|
||||
- generic [ref=e128]:
|
||||
- generic [ref=e129]:
|
||||
- heading "Everything You Need" [level=2] [ref=e130]
|
||||
- paragraph [ref=e131]: Powerful features to run your service business
|
||||
- generic [ref=e132]:
|
||||
- generic [ref=e133]:
|
||||
- img [ref=e135]
|
||||
- heading "Smart Scheduling" [level=3] [ref=e140]
|
||||
- paragraph [ref=e141]: Drag-and-drop calendar with real-time availability, automated reminders, and conflict detection.
|
||||
- generic [ref=e142]:
|
||||
- img [ref=e144]
|
||||
- heading "Resource Management" [level=3] [ref=e149]
|
||||
- paragraph [ref=e150]: Manage staff, rooms, and equipment. Set availability, skills, and booking rules.
|
||||
- generic [ref=e151]:
|
||||
- img [ref=e153]
|
||||
- heading "Customer Portal" [level=3] [ref=e157]
|
||||
- paragraph [ref=e158]: Self-service booking portal for customers. View history, manage appointments, and save payment methods.
|
||||
- generic [ref=e159]:
|
||||
- img [ref=e161]
|
||||
- heading "Integrated Payments" [level=3] [ref=e164]
|
||||
- paragraph [ref=e165]: Accept payments online with Stripe. Deposits, full payments, and automatic invoicing.
|
||||
- generic [ref=e166]:
|
||||
- img [ref=e168]
|
||||
- heading "Multi-Location Support" [level=3] [ref=e174]
|
||||
- paragraph [ref=e175]: Manage multiple locations or brands from a single dashboard with isolated data.
|
||||
- generic [ref=e176]:
|
||||
- img [ref=e178]
|
||||
- heading "White-Label Ready" [level=3] [ref=e184]
|
||||
- paragraph [ref=e185]: Custom domain, branding, and remove SmoothSchedule branding for a seamless experience.
|
||||
- link "View All features" [ref=e187] [cursor=pointer]:
|
||||
- /url: "#/features"
|
||||
- text: View All features
|
||||
- img [ref=e188]
|
||||
- generic [ref=e192]:
|
||||
- generic [ref=e193]:
|
||||
- heading "Get Started in Minutes" [level=2] [ref=e194]
|
||||
- paragraph [ref=e195]: Three simple steps to transform your scheduling
|
||||
- generic [ref=e196]:
|
||||
- generic [ref=e199]:
|
||||
- generic [ref=e200]: "01"
|
||||
- img [ref=e202]
|
||||
- heading "Create Your Account" [level=3] [ref=e207]
|
||||
- paragraph [ref=e208]: Sign up for free and set up your business profile in minutes.
|
||||
- generic [ref=e211]:
|
||||
- generic [ref=e212]: "02"
|
||||
- img [ref=e214]
|
||||
- heading "Add Your Services" [level=3] [ref=e217]
|
||||
- paragraph [ref=e218]: Configure your services, pricing, and available resources.
|
||||
- generic [ref=e220]:
|
||||
- generic [ref=e221]: "03"
|
||||
- img [ref=e223]
|
||||
- heading "Start Booking" [level=3] [ref=e228]
|
||||
- paragraph [ref=e229]: Share your booking link and let customers schedule instantly.
|
||||
- generic [ref=e232]:
|
||||
- generic [ref=e233]:
|
||||
- img [ref=e235]
|
||||
- generic [ref=e240]: 1M+
|
||||
- generic [ref=e241]: Appointments Scheduled
|
||||
- generic [ref=e242]:
|
||||
- img [ref=e244]
|
||||
- generic [ref=e250]: 5,000+
|
||||
- generic [ref=e251]: Businesses
|
||||
- generic [ref=e252]:
|
||||
- img [ref=e254]
|
||||
- generic [ref=e258]: 50+
|
||||
- generic [ref=e259]: Countries
|
||||
- generic [ref=e260]:
|
||||
- img [ref=e262]
|
||||
- generic [ref=e265]: 99.9%
|
||||
- generic [ref=e266]: Uptime
|
||||
- generic [ref=e268]:
|
||||
- generic [ref=e269]:
|
||||
- heading "Loved by Businesses Everywhere" [level=2] [ref=e270]
|
||||
- paragraph [ref=e271]: See what our customers have to say
|
||||
- generic [ref=e272]:
|
||||
- generic [ref=e273]:
|
||||
- generic [ref=e274]:
|
||||
- img [ref=e275]
|
||||
- img [ref=e277]
|
||||
- img [ref=e279]
|
||||
- img [ref=e281]
|
||||
- img [ref=e283]
|
||||
- blockquote [ref=e285]: "\"SmoothSchedule transformed how we manage appointments. Our no-show rate dropped by 40% with automated reminders.\""
|
||||
- generic [ref=e286]:
|
||||
- generic [ref=e288]: S
|
||||
- generic [ref=e289]:
|
||||
- generic [ref=e290]: Sarah Johnson
|
||||
- generic [ref=e291]: Owner at Luxe Salon
|
||||
- generic [ref=e292]:
|
||||
- generic [ref=e293]:
|
||||
- img [ref=e294]
|
||||
- img [ref=e296]
|
||||
- img [ref=e298]
|
||||
- img [ref=e300]
|
||||
- img [ref=e302]
|
||||
- blockquote [ref=e304]: "\"The white-label feature is perfect for our multi-location business. Each location has its own branded booking experience.\""
|
||||
- generic [ref=e305]:
|
||||
- generic [ref=e307]: M
|
||||
- generic [ref=e308]:
|
||||
- generic [ref=e309]: Michael Chen
|
||||
- generic [ref=e310]: CEO at FitLife Studios
|
||||
- generic [ref=e311]:
|
||||
- generic [ref=e312]:
|
||||
- img [ref=e313]
|
||||
- img [ref=e315]
|
||||
- img [ref=e317]
|
||||
- img [ref=e319]
|
||||
- img [ref=e321]
|
||||
- blockquote [ref=e323]: "\"Setup was incredibly easy. We were up and running in under an hour, and our clients love the self-service booking.\""
|
||||
- generic [ref=e324]:
|
||||
- generic [ref=e326]: E
|
||||
- generic [ref=e327]:
|
||||
- generic [ref=e328]: Emily Rodriguez
|
||||
- generic [ref=e329]: Manager at Peak Performance Therapy
|
||||
- generic [ref=e331]:
|
||||
- generic [ref=e332]:
|
||||
- heading "Simple, Transparent Pricing" [level=2] [ref=e333]
|
||||
- paragraph [ref=e334]: Start free, upgrade as you grow. No hidden fees.
|
||||
- generic [ref=e335]:
|
||||
- generic [ref=e336]:
|
||||
- heading "Free" [level=3] [ref=e337]
|
||||
- paragraph [ref=e338]: Perfect for getting started
|
||||
- generic [ref=e339]: $0/month
|
||||
- link "Get Started" [ref=e340] [cursor=pointer]:
|
||||
- /url: "#/signup"
|
||||
- generic [ref=e341]:
|
||||
- generic [ref=e342]: Most Popular
|
||||
- heading "Professional" [level=3] [ref=e343]
|
||||
- paragraph [ref=e344]: For growing businesses
|
||||
- generic [ref=e345]: $29/month
|
||||
- link "Get Started" [ref=e346] [cursor=pointer]:
|
||||
- /url: "#/signup"
|
||||
- generic [ref=e347]:
|
||||
- heading "Business" [level=3] [ref=e348]
|
||||
- paragraph [ref=e349]: For established teams
|
||||
- generic [ref=e350]: $79/month
|
||||
- link "Get Started" [ref=e351] [cursor=pointer]:
|
||||
- /url: "#/signup"
|
||||
- link "View full pricing details" [ref=e353] [cursor=pointer]:
|
||||
- /url: "#/pricing"
|
||||
- text: View full pricing details
|
||||
- img [ref=e354]
|
||||
- generic [ref=e361]:
|
||||
- heading "Ready to get started?" [level=2] [ref=e362]
|
||||
- paragraph [ref=e363]: Join thousands of businesses already using SmoothSchedule.
|
||||
- generic [ref=e364]:
|
||||
- link "Get Started Free" [ref=e365] [cursor=pointer]:
|
||||
- /url: "#/signup"
|
||||
- text: Get Started Free
|
||||
- img [ref=e366]
|
||||
- link "Talk to Sales" [ref=e369] [cursor=pointer]:
|
||||
- /url: "#/contact"
|
||||
- paragraph [ref=e370]: No credit card required
|
||||
- contentinfo [ref=e371]:
|
||||
- generic [ref=e372]:
|
||||
- generic [ref=e373]:
|
||||
- generic [ref=e374]:
|
||||
- link "Smooth Schedule" [ref=e375] [cursor=pointer]:
|
||||
- /url: "#/"
|
||||
- img [ref=e376]
|
||||
- generic [ref=e382]: Smooth Schedule
|
||||
- paragraph [ref=e383]: The all-in-one scheduling platform for businesses of all sizes. Manage resources, staff, and bookings effortlessly.
|
||||
- generic [ref=e384]:
|
||||
- link "Twitter" [ref=e385] [cursor=pointer]:
|
||||
- /url: https://twitter.com/smoothschedule
|
||||
- img [ref=e386]
|
||||
- link "LinkedIn" [ref=e388] [cursor=pointer]:
|
||||
- /url: https://linkedin.com/company/smoothschedule
|
||||
- img [ref=e389]
|
||||
- link "GitHub" [ref=e393] [cursor=pointer]:
|
||||
- /url: https://github.com/smoothschedule
|
||||
- img [ref=e394]
|
||||
- link "YouTube" [ref=e397] [cursor=pointer]:
|
||||
- /url: https://youtube.com/@smoothschedule
|
||||
- img [ref=e398]
|
||||
- generic [ref=e401]:
|
||||
- heading "Product" [level=3] [ref=e402]
|
||||
- list [ref=e403]:
|
||||
- listitem [ref=e404]:
|
||||
- link "Features" [ref=e405] [cursor=pointer]:
|
||||
- /url: "#/features"
|
||||
- listitem [ref=e406]:
|
||||
- link "Pricing" [ref=e407] [cursor=pointer]:
|
||||
- /url: "#/pricing"
|
||||
- listitem [ref=e408]:
|
||||
- link "Get Started" [ref=e409] [cursor=pointer]:
|
||||
- /url: "#/signup"
|
||||
- generic [ref=e410]:
|
||||
- heading "Company" [level=3] [ref=e411]
|
||||
- list [ref=e412]:
|
||||
- listitem [ref=e413]:
|
||||
- link "About" [ref=e414] [cursor=pointer]:
|
||||
- /url: "#/about"
|
||||
- listitem [ref=e415]:
|
||||
- link "Contact" [ref=e416] [cursor=pointer]:
|
||||
- /url: "#/contact"
|
||||
- generic [ref=e417]:
|
||||
- heading "Legal" [level=3] [ref=e418]
|
||||
- list [ref=e419]:
|
||||
- listitem [ref=e420]:
|
||||
- link "Privacy Policy" [ref=e421] [cursor=pointer]:
|
||||
- /url: "#/privacy"
|
||||
- listitem [ref=e422]:
|
||||
- link "Terms of Service" [ref=e423] [cursor=pointer]:
|
||||
- /url: "#/terms"
|
||||
- paragraph [ref=e425]: © 2025 Smooth Schedule Inc. All rights reserved.
|
||||
- generic [ref=e426]:
|
||||
- generic [ref=e427]:
|
||||
- heading "🔓 Quick Login (Dev Only)" [level=3] [ref=e428]:
|
||||
- generic [ref=e429]: 🔓
|
||||
- generic [ref=e430]: Quick Login (Dev Only)
|
||||
- button "×" [ref=e431]
|
||||
- generic [ref=e432]:
|
||||
- button "Logging in..." [disabled] [ref=e433]:
|
||||
- generic [ref=e434]:
|
||||
- img [ref=e435]
|
||||
- text: Logging in...
|
||||
- button "Platform Manager PLATFORM_MANAGER" [disabled] [ref=e438]:
|
||||
- generic [ref=e439]:
|
||||
- generic [ref=e440]: Platform Manager
|
||||
- generic [ref=e441]: PLATFORM_MANAGER
|
||||
- button "Platform Sales PLATFORM_SALES" [disabled] [ref=e442]:
|
||||
- generic [ref=e443]:
|
||||
- generic [ref=e444]: Platform Sales
|
||||
- generic [ref=e445]: PLATFORM_SALES
|
||||
- button "Platform Support PLATFORM_SUPPORT" [disabled] [ref=e446]:
|
||||
- generic [ref=e447]:
|
||||
- generic [ref=e448]: Platform Support
|
||||
- generic [ref=e449]: PLATFORM_SUPPORT
|
||||
- button "Business Owner TENANT_OWNER" [disabled] [ref=e450]:
|
||||
- generic [ref=e451]:
|
||||
- generic [ref=e452]: Business Owner
|
||||
- generic [ref=e453]: TENANT_OWNER
|
||||
- button "Business Manager TENANT_MANAGER" [disabled] [ref=e454]:
|
||||
- generic [ref=e455]:
|
||||
- generic [ref=e456]: Business Manager
|
||||
- generic [ref=e457]: TENANT_MANAGER
|
||||
- button "Staff Member TENANT_STAFF" [disabled] [ref=e458]:
|
||||
- generic [ref=e459]:
|
||||
- generic [ref=e460]: Staff Member
|
||||
- generic [ref=e461]: TENANT_STAFF
|
||||
- button "Customer CUSTOMER" [disabled] [ref=e462]:
|
||||
- generic [ref=e463]:
|
||||
- generic [ref=e464]: Customer
|
||||
- generic [ref=e465]: CUSTOMER
|
||||
- generic [ref=e466]:
|
||||
- text: "Password for all:"
|
||||
- code [ref=e467]: test123
|
||||
```
|
||||
|
After Width: | Height: | Size: 410 KiB |
85
frontend/playwright-report/index.html
Normal file
58
frontend/playwright.config.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Playwright configuration for SmoothSchedule
|
||||
* Tests the React frontend with proper subdomain support
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: './tests/e2e',
|
||||
|
||||
/* Run tests in files in parallel */
|
||||
fullyParallel: true,
|
||||
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
forbidOnly: !!process.env.CI,
|
||||
|
||||
/* Retry on CI only */
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
|
||||
/* Reporter to use */
|
||||
reporter: 'html',
|
||||
|
||||
/* Shared settings for all the projects below */
|
||||
use: {
|
||||
/* Base URL for all tests */
|
||||
baseURL: 'http://lvh.me:5174',
|
||||
|
||||
/* Collect trace when retrying the failed test */
|
||||
trace: 'on-first-retry',
|
||||
|
||||
/* Screenshot on failure */
|
||||
screenshot: 'only-on-failure',
|
||||
},
|
||||
|
||||
/* Configure projects for major browsers */
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
|
||||
{
|
||||
name: 'firefox',
|
||||
use: { ...devices['Desktop Firefox'] },
|
||||
},
|
||||
|
||||
{
|
||||
name: 'webkit',
|
||||
use: { ...devices['Desktop Safari'] },
|
||||
},
|
||||
],
|
||||
|
||||
/* Run your local dev server before starting the tests */
|
||||
webServer: {
|
||||
command: 'npm run dev',
|
||||
url: 'http://lvh.me:5174',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
});
|
||||
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
7
frontend/public/favicon.svg
Normal file
|
After Width: | Height: | Size: 15 KiB |
1
frontend/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
36
frontend/src/App.css
Normal file
@@ -0,0 +1,36 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* Custom scrollbar for timeline */
|
||||
.timeline-scroll::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.timeline-scroll::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.timeline-scroll::-webkit-scrollbar-thumb {
|
||||
background: #cbd5e0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.timeline-scroll::-webkit-scrollbar-thumb:hover {
|
||||
background: #a0aec0;
|
||||
}
|
||||
|
||||
/* Dark mode scrollbar */
|
||||
.dark .timeline-scroll::-webkit-scrollbar-thumb {
|
||||
background: #4a5568;
|
||||
}
|
||||
|
||||
.dark .timeline-scroll::-webkit-scrollbar-thumb:hover {
|
||||
background: #68768a;
|
||||
}
|
||||
|
||||
/* Preserve existing app styles */
|
||||
.app {
|
||||
min-height: 100vh;
|
||||
}
|
||||
557
frontend/src/App.tsx
Normal file
@@ -0,0 +1,557 @@
|
||||
/**
|
||||
* Main App Component - Integrated with Real API
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { HashRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { useCurrentUser, useMasquerade, useLogout } from './hooks/useAuth';
|
||||
import { useCurrentBusiness } from './hooks/useBusiness';
|
||||
import { useUpdateBusiness } from './hooks/useBusiness';
|
||||
import { setCookie } from './utils/cookies';
|
||||
import { DevQuickLogin } from './components/DevQuickLogin';
|
||||
|
||||
// Import Login Page
|
||||
import LoginPage from './pages/LoginPage';
|
||||
import OAuthCallback from './pages/OAuthCallback';
|
||||
|
||||
// Import layouts
|
||||
import BusinessLayout from './layouts/BusinessLayout';
|
||||
import PlatformLayout from './layouts/PlatformLayout';
|
||||
import CustomerLayout from './layouts/CustomerLayout';
|
||||
import MarketingLayout from './layouts/MarketingLayout';
|
||||
|
||||
// Import marketing pages
|
||||
import HomePage from './pages/marketing/HomePage';
|
||||
import FeaturesPage from './pages/marketing/FeaturesPage';
|
||||
import PricingPage from './pages/marketing/PricingPage';
|
||||
import AboutPage from './pages/marketing/AboutPage';
|
||||
import ContactPage from './pages/marketing/ContactPage';
|
||||
import SignupPage from './pages/marketing/SignupPage';
|
||||
|
||||
// Import pages
|
||||
import Dashboard from './pages/Dashboard';
|
||||
import Scheduler from './pages/Scheduler';
|
||||
import Customers from './pages/Customers';
|
||||
import Settings from './pages/Settings';
|
||||
import Payments from './pages/Payments';
|
||||
import Resources from './pages/Resources';
|
||||
import Services from './pages/Services';
|
||||
import Staff from './pages/Staff';
|
||||
import CustomerDashboard from './pages/customer/CustomerDashboard';
|
||||
import ResourceDashboard from './pages/resource/ResourceDashboard';
|
||||
import BookingPage from './pages/customer/BookingPage';
|
||||
import TrialExpired from './pages/TrialExpired';
|
||||
import Upgrade from './pages/Upgrade';
|
||||
|
||||
// Import platform pages
|
||||
import PlatformDashboard from './pages/platform/PlatformDashboard';
|
||||
import PlatformBusinesses from './pages/platform/PlatformBusinesses';
|
||||
import PlatformSupport from './pages/platform/PlatformSupport';
|
||||
import PlatformUsers from './pages/platform/PlatformUsers';
|
||||
import PlatformSettings from './pages/platform/PlatformSettings';
|
||||
import ProfileSettings from './pages/ProfileSettings';
|
||||
import VerifyEmail from './pages/VerifyEmail';
|
||||
import EmailVerificationRequired from './pages/EmailVerificationRequired';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
refetchOnWindowFocus: false,
|
||||
retry: 1,
|
||||
staleTime: 30000, // 30 seconds
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Loading Component
|
||||
*/
|
||||
const LoadingScreen: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||||
<p className="text-gray-600 dark:text-gray-400">{t('common.loading')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Error Component
|
||||
*/
|
||||
const ErrorScreen: React.FC<{ error: Error }> = ({ error }) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
|
||||
<div className="text-center max-w-md">
|
||||
<h2 className="text-2xl font-bold text-red-600 dark:text-red-400 mb-4">{t('common.error')}</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-4">{error.message}</p>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
|
||||
>
|
||||
{t('common.reload')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* App Content - Handles routing based on auth state
|
||||
*/
|
||||
const AppContent: React.FC = () => {
|
||||
// Check for tokens in URL FIRST - before any queries execute
|
||||
// This handles login/masquerade redirects that pass tokens in the URL
|
||||
const [processingUrlTokens] = useState(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
return !!(params.get('access_token') && params.get('refresh_token'));
|
||||
});
|
||||
|
||||
const { data: user, isLoading: userLoading, error: userError } = useCurrentUser();
|
||||
const { data: business, isLoading: businessLoading, error: businessError } = useCurrentBusiness();
|
||||
const [darkMode, setDarkMode] = useState(false);
|
||||
const updateBusinessMutation = useUpdateBusiness();
|
||||
const masqueradeMutation = useMasquerade();
|
||||
const logoutMutation = useLogout();
|
||||
|
||||
// Apply dark mode class
|
||||
React.useEffect(() => {
|
||||
document.documentElement.classList.toggle('dark', darkMode);
|
||||
}, [darkMode]);
|
||||
|
||||
// Handle tokens in URL (from login or masquerade redirect)
|
||||
React.useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const accessToken = params.get('access_token');
|
||||
const refreshToken = params.get('refresh_token');
|
||||
|
||||
if (accessToken && refreshToken) {
|
||||
// Extract masquerade stack if present (for masquerade banner)
|
||||
const masqueradeStackParam = params.get('masquerade_stack');
|
||||
if (masqueradeStackParam) {
|
||||
try {
|
||||
const masqueradeStack = JSON.parse(decodeURIComponent(masqueradeStackParam));
|
||||
localStorage.setItem('masquerade_stack', JSON.stringify(masqueradeStack));
|
||||
} catch (e) {
|
||||
console.error('Failed to parse masquerade stack', e);
|
||||
}
|
||||
}
|
||||
|
||||
// For backward compatibility, also check for original_user parameter
|
||||
const originalUserParam = params.get('original_user');
|
||||
if (originalUserParam && !masqueradeStackParam) {
|
||||
try {
|
||||
const originalUser = JSON.parse(decodeURIComponent(originalUserParam));
|
||||
// Convert old format to new stack format (single entry)
|
||||
const stack = [{
|
||||
user_id: originalUser.id,
|
||||
username: originalUser.username,
|
||||
role: originalUser.role,
|
||||
business_id: originalUser.business,
|
||||
business_subdomain: originalUser.business_subdomain,
|
||||
}];
|
||||
localStorage.setItem('masquerade_stack', JSON.stringify(stack));
|
||||
} catch (e) {
|
||||
console.error('Failed to parse original user', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Set cookies using helper (handles domain correctly)
|
||||
setCookie('access_token', accessToken, 7);
|
||||
setCookie('refresh_token', refreshToken, 7);
|
||||
|
||||
// Clear session cookie to prevent interference with JWT
|
||||
// (Django session cookie might take precedence over JWT)
|
||||
document.cookie = 'sessionid=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; domain=.lvh.me';
|
||||
document.cookie = 'sessionid=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
|
||||
|
||||
// Clean URL
|
||||
const newUrl = window.location.pathname + window.location.hash;
|
||||
window.history.replaceState({}, '', newUrl);
|
||||
|
||||
// Force reload to ensure auth state is picked up
|
||||
window.location.reload();
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Show loading while processing URL tokens (before reload happens)
|
||||
if (processingUrlTokens) {
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
|
||||
// Loading state
|
||||
if (userLoading) {
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
|
||||
// Helper to detect root domain (for marketing site)
|
||||
const isRootDomain = (): boolean => {
|
||||
const hostname = window.location.hostname;
|
||||
return hostname === 'lvh.me' || hostname === 'localhost' || hostname === '127.0.0.1';
|
||||
};
|
||||
|
||||
// Not authenticated - show public routes
|
||||
if (!user) {
|
||||
// On root domain, show marketing site
|
||||
if (isRootDomain()) {
|
||||
return (
|
||||
<Routes>
|
||||
<Route element={<MarketingLayout />}>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/features" element={<FeaturesPage />} />
|
||||
<Route path="/pricing" element={<PricingPage />} />
|
||||
<Route path="/about" element={<AboutPage />} />
|
||||
<Route path="/contact" element={<ContactPage />} />
|
||||
<Route path="/signup" element={<SignupPage />} />
|
||||
</Route>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/oauth/callback/:provider" element={<OAuthCallback />} />
|
||||
<Route path="/verify-email" element={<VerifyEmail />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
// On business subdomain, show login
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/oauth/callback/:provider" element={<OAuthCallback />} />
|
||||
<Route path="/verify-email" element={<VerifyEmail />} />
|
||||
<Route path="*" element={<Navigate to="/login" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (userError) {
|
||||
return <ErrorScreen error={userError as Error} />;
|
||||
}
|
||||
|
||||
// Handlers
|
||||
const toggleTheme = () => setDarkMode((prev) => !prev);
|
||||
const handleSignOut = () => {
|
||||
logoutMutation.mutate();
|
||||
};
|
||||
const handleUpdateBusiness = (updates: Partial<any>) => {
|
||||
updateBusinessMutation.mutate(updates);
|
||||
};
|
||||
|
||||
const handleMasquerade = (targetUser: any) => {
|
||||
// Call the masquerade API with the target user's username
|
||||
// Fallback to email prefix if username is not available
|
||||
const username = targetUser.username || targetUser.email?.split('@')[0];
|
||||
if (!username) {
|
||||
console.error('Cannot masquerade: no username or email available', targetUser);
|
||||
return;
|
||||
}
|
||||
masqueradeMutation.mutate(username);
|
||||
};
|
||||
|
||||
// Helper to check access based on roles
|
||||
const hasAccess = (allowedRoles: string[]) => allowedRoles.includes(user.role);
|
||||
|
||||
// Platform users (superuser, platform_manager, platform_support)
|
||||
const isPlatformUser = ['superuser', 'platform_manager', 'platform_support'].includes(user.role);
|
||||
|
||||
if (isPlatformUser) {
|
||||
return (
|
||||
<Routes>
|
||||
<Route
|
||||
element={
|
||||
<PlatformLayout
|
||||
user={user}
|
||||
darkMode={darkMode}
|
||||
toggleTheme={toggleTheme}
|
||||
onSignOut={handleSignOut}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{(user.role === 'superuser' || user.role === 'platform_manager') && (
|
||||
<>
|
||||
<Route path="/platform/dashboard" element={<PlatformDashboard />} />
|
||||
<Route path="/platform/businesses" element={<PlatformBusinesses onMasquerade={handleMasquerade} />} />
|
||||
<Route path="/platform/users" element={<PlatformUsers onMasquerade={handleMasquerade} />} />
|
||||
</>
|
||||
)}
|
||||
<Route path="/platform/support" element={<PlatformSupport />} />
|
||||
{user.role === 'superuser' && (
|
||||
<Route path="/platform/settings" element={<PlatformSettings />} />
|
||||
)}
|
||||
<Route path="/platform/profile" element={<ProfileSettings />} />
|
||||
<Route path="/verify-email" element={<VerifyEmail />} />
|
||||
<Route
|
||||
path="*"
|
||||
element={
|
||||
<Navigate
|
||||
to={
|
||||
user.role === 'superuser' || user.role === 'platform_manager'
|
||||
? '/platform/dashboard'
|
||||
: '/platform/support'
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
// Customer users
|
||||
if (user.role === 'customer') {
|
||||
return (
|
||||
<Routes>
|
||||
<Route
|
||||
element={
|
||||
<CustomerLayout
|
||||
business={business || ({} as any)}
|
||||
user={user}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Route path="/" element={<CustomerDashboard />} />
|
||||
<Route path="/book" element={<BookingPage />} />
|
||||
<Route path="/payments" element={<Payments />} />
|
||||
<Route path="/profile" element={<ProfileSettings />} />
|
||||
<Route path="/verify-email" element={<VerifyEmail />} />
|
||||
<Route path="*" element={<Navigate to="/" />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
// Business loading - show loading with user info
|
||||
if (businessLoading) {
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
|
||||
// Check if we're on root/platform domain without proper business context
|
||||
const currentHostname = window.location.hostname;
|
||||
const isRootOrPlatform = currentHostname === 'lvh.me' || currentHostname === 'localhost' || currentHostname === 'platform.lvh.me';
|
||||
|
||||
// Business error or no business found
|
||||
if (businessError || !business) {
|
||||
// If user is a business owner on root domain, redirect to their business
|
||||
if (isRootOrPlatform && user.role === 'owner' && user.business_subdomain) {
|
||||
const port = window.location.port ? `:${window.location.port}` : '';
|
||||
window.location.href = `http://${user.business_subdomain}.lvh.me${port}/`;
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
|
||||
// If on root/platform and shouldn't be here, show appropriate message
|
||||
if (isRootOrPlatform) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
|
||||
<div className="text-center max-w-md p-6">
|
||||
<h2 className="text-2xl font-bold text-amber-600 dark:text-amber-400 mb-4">Wrong Location</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
||||
{user.business_subdomain
|
||||
? `Please access the app at your business subdomain: ${user.business_subdomain}.lvh.me`
|
||||
: 'Your account is not associated with a business. Please contact support.'}
|
||||
</p>
|
||||
<div className="flex gap-4 justify-center">
|
||||
{user.business_subdomain && (
|
||||
<button
|
||||
onClick={() => {
|
||||
const port = window.location.port ? `:${window.location.port}` : '';
|
||||
window.location.href = `http://${user.business_subdomain}.lvh.me${port}/`;
|
||||
}}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
|
||||
>
|
||||
Go to Business
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleSignOut}
|
||||
className="px-4 py-2 bg-gray-600 text-white rounded hover:bg-gray-700"
|
||||
>
|
||||
Sign Out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
|
||||
<div className="text-center max-w-md p-6">
|
||||
<h2 className="text-2xl font-bold text-red-600 dark:text-red-400 mb-4">Business Not Found</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
||||
{businessError instanceof Error ? businessError.message : 'Unable to load business data. Please check your subdomain or try again.'}
|
||||
</p>
|
||||
<div className="flex gap-4 justify-center">
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
|
||||
>
|
||||
Reload
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSignOut}
|
||||
className="px-4 py-2 bg-gray-600 text-white rounded hover:bg-gray-700"
|
||||
>
|
||||
Sign Out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Business users (owner, manager, staff, resource)
|
||||
if (['owner', 'manager', 'staff', 'resource'].includes(user.role)) {
|
||||
// Check if email verification is required
|
||||
if (!user.email_verified) {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/email-verification-required" element={<EmailVerificationRequired />} />
|
||||
<Route path="/verify-email" element={<VerifyEmail />} />
|
||||
<Route path="*" element={<Navigate to="/email-verification-required" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
// Check if trial has expired
|
||||
const isTrialExpired = business.isTrialExpired || (business.status === 'Trial' && business.trialEnd && new Date(business.trialEnd) < new Date());
|
||||
|
||||
// Allowed routes when trial is expired
|
||||
const allowedWhenExpired = ['/trial-expired', '/upgrade', '/settings', '/profile'];
|
||||
const currentPath = window.location.pathname;
|
||||
const isOnAllowedRoute = allowedWhenExpired.some(route => currentPath.startsWith(route));
|
||||
|
||||
// If trial expired and not on allowed route, redirect to trial-expired
|
||||
if (isTrialExpired && !isOnAllowedRoute) {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/trial-expired" element={<TrialExpired />} />
|
||||
<Route path="/upgrade" element={<Upgrade />} />
|
||||
<Route path="/profile" element={<ProfileSettings />} />
|
||||
<Route
|
||||
path="/settings"
|
||||
element={hasAccess(['owner']) ? <Settings /> : <Navigate to="/trial-expired" />}
|
||||
/>
|
||||
<Route path="*" element={<Navigate to="/trial-expired" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route
|
||||
element={
|
||||
<BusinessLayout
|
||||
business={business}
|
||||
user={user}
|
||||
darkMode={darkMode}
|
||||
toggleTheme={toggleTheme}
|
||||
onSignOut={handleSignOut}
|
||||
updateBusiness={handleUpdateBusiness}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{/* Trial and Upgrade Routes */}
|
||||
<Route path="/trial-expired" element={<TrialExpired />} />
|
||||
<Route path="/upgrade" element={<Upgrade />} />
|
||||
|
||||
{/* Regular Routes */}
|
||||
<Route
|
||||
path="/"
|
||||
element={user.role === 'resource' ? <ResourceDashboard /> : <Dashboard />}
|
||||
/>
|
||||
<Route path="/scheduler" element={<Scheduler />} />
|
||||
<Route
|
||||
path="/customers"
|
||||
element={
|
||||
hasAccess(['owner', 'manager', 'staff']) ? (
|
||||
<Customers onMasquerade={handleMasquerade} effectiveUser={user} />
|
||||
) : (
|
||||
<Navigate to="/" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/services"
|
||||
element={
|
||||
hasAccess(['owner', 'manager', 'staff']) ? (
|
||||
<Services />
|
||||
) : (
|
||||
<Navigate to="/" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/resources"
|
||||
element={
|
||||
hasAccess(['owner', 'manager', 'staff']) ? (
|
||||
<Resources onMasquerade={handleMasquerade} effectiveUser={user} />
|
||||
) : (
|
||||
<Navigate to="/" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/staff"
|
||||
element={
|
||||
hasAccess(['owner', 'manager']) ? (
|
||||
<Staff onMasquerade={handleMasquerade} effectiveUser={user} />
|
||||
) : (
|
||||
<Navigate to="/" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/payments"
|
||||
element={
|
||||
hasAccess(['owner', 'manager']) ? <Payments /> : <Navigate to="/" />
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/messages"
|
||||
element={
|
||||
hasAccess(['owner', 'manager']) ? (
|
||||
<div className="p-8">
|
||||
<h1 className="text-2xl font-bold mb-4">Messages</h1>
|
||||
<p className="text-gray-600">Messages feature coming soon...</p>
|
||||
</div>
|
||||
) : (
|
||||
<Navigate to="/" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/settings"
|
||||
element={hasAccess(['owner']) ? <Settings /> : <Navigate to="/" />}
|
||||
/>
|
||||
<Route path="/profile" element={<ProfileSettings />} />
|
||||
<Route path="/verify-email" element={<VerifyEmail />} />
|
||||
<Route path="*" element={<Navigate to="/" />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback
|
||||
return <Navigate to="/" />;
|
||||
};
|
||||
|
||||
/**
|
||||
* Main App Component
|
||||
*/
|
||||
const App: React.FC = () => {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Router>
|
||||
<AppContent />
|
||||
</Router>
|
||||
<DevQuickLogin />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
113
frontend/src/api/auth.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Authentication API
|
||||
*/
|
||||
|
||||
import apiClient from './client';
|
||||
|
||||
export interface LoginCredentials {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
import { UserRole } from '../types';
|
||||
|
||||
export interface MasqueradeStackEntry {
|
||||
user_id: number;
|
||||
username: string;
|
||||
role: UserRole;
|
||||
business_id?: number;
|
||||
business_subdomain?: string;
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
access: string;
|
||||
refresh: string;
|
||||
user: {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
name: string;
|
||||
role: UserRole;
|
||||
avatar_url?: string;
|
||||
email_verified?: boolean;
|
||||
is_staff: boolean;
|
||||
is_superuser: boolean;
|
||||
business?: number;
|
||||
business_name?: string;
|
||||
business_subdomain?: string;
|
||||
};
|
||||
masquerade_stack?: MasqueradeStackEntry[];
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
name: string;
|
||||
role: UserRole;
|
||||
avatar_url?: string;
|
||||
email_verified?: boolean;
|
||||
is_staff: boolean;
|
||||
is_superuser: boolean;
|
||||
business?: number;
|
||||
business_name?: string;
|
||||
business_subdomain?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Login user
|
||||
*/
|
||||
export const login = async (credentials: LoginCredentials): Promise<LoginResponse> => {
|
||||
const response = await apiClient.post<LoginResponse>('/api/auth/login/', credentials);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Logout user
|
||||
*/
|
||||
export const logout = async (): Promise<void> => {
|
||||
await apiClient.post('/api/auth/logout/');
|
||||
};
|
||||
|
||||
/**
|
||||
* Get current user
|
||||
*/
|
||||
export const getCurrentUser = async (): Promise<User> => {
|
||||
const response = await apiClient.get<User>('/api/auth/me/');
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Refresh access token
|
||||
*/
|
||||
export const refreshToken = async (refresh: string): Promise<{ access: string }> => {
|
||||
const response = await apiClient.post('/api/auth/refresh/', { refresh });
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Masquerade as another user
|
||||
*/
|
||||
export const masquerade = async (
|
||||
username: string,
|
||||
masquerade_stack?: MasqueradeStackEntry[]
|
||||
): Promise<LoginResponse> => {
|
||||
const response = await apiClient.post<LoginResponse>(
|
||||
`/api/users/${username}/masquerade/`,
|
||||
{ masquerade_stack }
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Stop masquerading and return to previous user
|
||||
*/
|
||||
export const stopMasquerade = async (
|
||||
masquerade_stack: MasqueradeStackEntry[]
|
||||
): Promise<LoginResponse> => {
|
||||
const response = await apiClient.post<LoginResponse>(
|
||||
'/api/users/stop_masquerade/',
|
||||
{ masquerade_stack }
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
106
frontend/src/api/business.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Business API - Resources and Users
|
||||
*/
|
||||
|
||||
import apiClient from './client';
|
||||
import { User, Resource, BusinessOAuthSettings, BusinessOAuthSettingsResponse, BusinessOAuthCredentials } from '../types';
|
||||
|
||||
/**
|
||||
* Get all resources for the current business
|
||||
*/
|
||||
export const getResources = async (): Promise<Resource[]> => {
|
||||
const response = await apiClient.get<Resource[]>('/api/resources/');
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all users for the current business
|
||||
*/
|
||||
export const getBusinessUsers = async (): Promise<User[]> => {
|
||||
const response = await apiClient.get<User[]>('/api/business/users/');
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get business OAuth settings and available platform providers
|
||||
*/
|
||||
export const getBusinessOAuthSettings = async (): Promise<BusinessOAuthSettingsResponse> => {
|
||||
const response = await apiClient.get<{
|
||||
business_settings: {
|
||||
oauth_enabled_providers: string[];
|
||||
oauth_allow_registration: boolean;
|
||||
oauth_auto_link_by_email: boolean;
|
||||
};
|
||||
available_providers: string[];
|
||||
}>('/api/business/oauth-settings/');
|
||||
|
||||
// Transform snake_case to camelCase
|
||||
return {
|
||||
businessSettings: {
|
||||
enabledProviders: response.data.business_settings.oauth_enabled_providers || [],
|
||||
allowRegistration: response.data.business_settings.oauth_allow_registration,
|
||||
autoLinkByEmail: response.data.business_settings.oauth_auto_link_by_email,
|
||||
},
|
||||
availableProviders: response.data.available_providers || [],
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Update business OAuth settings
|
||||
*/
|
||||
export const updateBusinessOAuthSettings = async (
|
||||
settings: Partial<BusinessOAuthSettings>
|
||||
): Promise<BusinessOAuthSettingsResponse> => {
|
||||
// Transform camelCase to snake_case for backend
|
||||
const backendData: Record<string, any> = {};
|
||||
|
||||
if (settings.enabledProviders !== undefined) {
|
||||
backendData.oauth_enabled_providers = settings.enabledProviders;
|
||||
}
|
||||
if (settings.allowRegistration !== undefined) {
|
||||
backendData.oauth_allow_registration = settings.allowRegistration;
|
||||
}
|
||||
if (settings.autoLinkByEmail !== undefined) {
|
||||
backendData.oauth_auto_link_by_email = settings.autoLinkByEmail;
|
||||
}
|
||||
|
||||
const response = await apiClient.patch<{
|
||||
business_settings: {
|
||||
oauth_enabled_providers: string[];
|
||||
oauth_allow_registration: boolean;
|
||||
oauth_auto_link_by_email: boolean;
|
||||
};
|
||||
available_providers: string[];
|
||||
}>('/api/business/oauth-settings/update/', backendData);
|
||||
|
||||
// Transform snake_case to camelCase
|
||||
return {
|
||||
businessSettings: {
|
||||
enabledProviders: response.data.business_settings.oauth_enabled_providers || [],
|
||||
allowRegistration: response.data.business_settings.oauth_allow_registration,
|
||||
autoLinkByEmail: response.data.business_settings.oauth_auto_link_by_email,
|
||||
},
|
||||
availableProviders: response.data.available_providers || [],
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Get business OAuth credentials (custom credentials for paid tiers)
|
||||
*/
|
||||
export const getBusinessOAuthCredentials = async (): Promise<BusinessOAuthCredentials> => {
|
||||
const response = await apiClient.get<BusinessOAuthCredentials>('/api/business/oauth-credentials/');
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Update business OAuth credentials (custom credentials for paid tiers)
|
||||
*/
|
||||
export const updateBusinessOAuthCredentials = async (
|
||||
credentials: Partial<BusinessOAuthCredentials>
|
||||
): Promise<BusinessOAuthCredentials> => {
|
||||
const response = await apiClient.patch<BusinessOAuthCredentials>(
|
||||
'/api/business/oauth-credentials/update/',
|
||||
credentials
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
86
frontend/src/api/client.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* API Client
|
||||
* Axios instance configured for SmoothSchedule API
|
||||
*/
|
||||
|
||||
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios';
|
||||
import { API_BASE_URL, getSubdomain } from './config';
|
||||
import { getCookie } from '../utils/cookies';
|
||||
|
||||
// Create axios instance
|
||||
const apiClient = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
withCredentials: true, // For CORS with credentials
|
||||
});
|
||||
|
||||
// Request interceptor - add auth token and business subdomain
|
||||
apiClient.interceptors.request.use(
|
||||
(config: InternalAxiosRequestConfig) => {
|
||||
// Add business subdomain header if on business site
|
||||
const subdomain = getSubdomain();
|
||||
if (subdomain && subdomain !== 'platform') {
|
||||
config.headers['X-Business-Subdomain'] = subdomain;
|
||||
}
|
||||
|
||||
// Add auth token if available (from cookie)
|
||||
const token = getCookie('access_token');
|
||||
if (token) {
|
||||
// Use 'Token' prefix for Django REST Framework Token Authentication
|
||||
config.headers['Authorization'] = `Token ${token}`;
|
||||
}
|
||||
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Response interceptor - handle errors and token refresh
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error: AxiosError) => {
|
||||
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
|
||||
|
||||
// Handle 401 Unauthorized - token expired
|
||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||
originalRequest._retry = true;
|
||||
|
||||
try {
|
||||
// Try to refresh token (from cookie)
|
||||
const refreshToken = getCookie('refresh_token');
|
||||
if (refreshToken) {
|
||||
const response = await axios.post(`${API_BASE_URL}/api/auth/refresh/`, {
|
||||
refresh: refreshToken,
|
||||
});
|
||||
|
||||
const { access } = response.data;
|
||||
|
||||
// Import setCookie dynamically to avoid circular dependency
|
||||
const { setCookie } = await import('../utils/cookies');
|
||||
setCookie('access_token', access, 7);
|
||||
|
||||
// Retry original request with new token
|
||||
if (originalRequest.headers) {
|
||||
originalRequest.headers['Authorization'] = `Bearer ${access}`;
|
||||
}
|
||||
return apiClient(originalRequest);
|
||||
}
|
||||
} catch (refreshError) {
|
||||
// Refresh failed - clear tokens and redirect to login
|
||||
const { deleteCookie } = await import('../utils/cookies');
|
||||
deleteCookie('access_token');
|
||||
deleteCookie('refresh_token');
|
||||
window.location.href = '/login';
|
||||
return Promise.reject(refreshError);
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
export default apiClient;
|
||||
59
frontend/src/api/config.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* API Configuration
|
||||
* Centralized configuration for API endpoints and settings
|
||||
*/
|
||||
|
||||
// Determine API base URL based on environment
|
||||
const getApiBaseUrl = (): string => {
|
||||
// In production, this would be set via environment variable
|
||||
if (import.meta.env.VITE_API_URL) {
|
||||
return import.meta.env.VITE_API_URL;
|
||||
}
|
||||
|
||||
// Development: use api subdomain
|
||||
return 'http://api.lvh.me:8000';
|
||||
};
|
||||
|
||||
export const API_BASE_URL = getApiBaseUrl();
|
||||
|
||||
/**
|
||||
* Extract subdomain from current hostname
|
||||
* Returns null if on root domain or invalid subdomain
|
||||
*/
|
||||
export const getSubdomain = (): string | null => {
|
||||
const hostname = window.location.hostname;
|
||||
const parts = hostname.split('.');
|
||||
|
||||
// lvh.me without subdomain (root domain) - no business context
|
||||
if (hostname === 'lvh.me') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Has subdomain
|
||||
if (parts.length > 1) {
|
||||
const subdomain = parts[0];
|
||||
// Exclude special subdomains
|
||||
if (['www', 'api', 'platform'].includes(subdomain)) {
|
||||
return subdomain === 'platform' ? null : subdomain;
|
||||
}
|
||||
return subdomain;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if current page is platform site
|
||||
*/
|
||||
export const isPlatformSite = (): boolean => {
|
||||
const hostname = window.location.hostname;
|
||||
return hostname.startsWith('platform.');
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if current page is business site
|
||||
*/
|
||||
export const isBusinessSite = (): boolean => {
|
||||
const subdomain = getSubdomain();
|
||||
return subdomain !== null && subdomain !== 'platform';
|
||||
};
|
||||
51
frontend/src/api/customDomains.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Custom Domains API - Manage custom domains for businesses
|
||||
*/
|
||||
|
||||
import apiClient from './client';
|
||||
import { CustomDomain } from '../types';
|
||||
|
||||
/**
|
||||
* Get all custom domains for the current business
|
||||
*/
|
||||
export const getCustomDomains = async (): Promise<CustomDomain[]> => {
|
||||
const response = await apiClient.get<CustomDomain[]>('/api/business/domains/');
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Add a new custom domain
|
||||
*/
|
||||
export const addCustomDomain = async (domain: string): Promise<CustomDomain> => {
|
||||
const response = await apiClient.post<CustomDomain>('/api/business/domains/', {
|
||||
domain: domain.toLowerCase().trim(),
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete a custom domain
|
||||
*/
|
||||
export const deleteCustomDomain = async (domainId: number): Promise<void> => {
|
||||
await apiClient.delete(`/api/business/domains/${domainId}/`);
|
||||
};
|
||||
|
||||
/**
|
||||
* Verify a custom domain by checking DNS
|
||||
*/
|
||||
export const verifyCustomDomain = async (domainId: number): Promise<{ verified: boolean; message: string }> => {
|
||||
const response = await apiClient.post<{ verified: boolean; message: string }>(
|
||||
`/api/business/domains/${domainId}/verify/`
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Set a custom domain as the primary domain
|
||||
*/
|
||||
export const setPrimaryDomain = async (domainId: number): Promise<CustomDomain> => {
|
||||
const response = await apiClient.post<CustomDomain>(
|
||||
`/api/business/domains/${domainId}/set-primary/`
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
181
frontend/src/api/domains.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* Domains API - NameSilo Integration for Domain Registration
|
||||
*/
|
||||
|
||||
import apiClient from './client';
|
||||
|
||||
// Types
|
||||
export interface DomainAvailability {
|
||||
domain: string;
|
||||
available: boolean;
|
||||
price: number | null;
|
||||
premium: boolean;
|
||||
premium_price: number | null;
|
||||
}
|
||||
|
||||
export interface DomainPrice {
|
||||
tld: string;
|
||||
registration: number;
|
||||
renewal: number;
|
||||
transfer: number;
|
||||
}
|
||||
|
||||
export interface RegistrantContact {
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
address: string;
|
||||
city: string;
|
||||
state: string;
|
||||
zip_code: string;
|
||||
country: string;
|
||||
}
|
||||
|
||||
export interface DomainRegisterRequest {
|
||||
domain: string;
|
||||
years: number;
|
||||
whois_privacy: boolean;
|
||||
auto_renew: boolean;
|
||||
nameservers?: string[];
|
||||
contact: RegistrantContact;
|
||||
auto_configure: boolean;
|
||||
}
|
||||
|
||||
export interface DomainRegistration {
|
||||
id: number;
|
||||
domain: string;
|
||||
status: 'pending' | 'active' | 'expired' | 'transferred' | 'failed';
|
||||
registered_at: string | null;
|
||||
expires_at: string | null;
|
||||
auto_renew: boolean;
|
||||
whois_privacy: boolean;
|
||||
purchase_price: number | null;
|
||||
renewal_price: number | null;
|
||||
nameservers: string[];
|
||||
days_until_expiry: number | null;
|
||||
is_expiring_soon: boolean;
|
||||
created_at: string;
|
||||
// Detail fields
|
||||
registrant_first_name?: string;
|
||||
registrant_last_name?: string;
|
||||
registrant_email?: string;
|
||||
}
|
||||
|
||||
export interface DomainSearchHistory {
|
||||
id: number;
|
||||
searched_domain: string;
|
||||
was_available: boolean;
|
||||
price: number | null;
|
||||
searched_at: string;
|
||||
}
|
||||
|
||||
// API Functions
|
||||
|
||||
/**
|
||||
* Search for domain availability
|
||||
*/
|
||||
export const searchDomains = async (
|
||||
query: string,
|
||||
tlds: string[] = ['.com', '.net', '.org']
|
||||
): Promise<DomainAvailability[]> => {
|
||||
const response = await apiClient.post<DomainAvailability[]>('/api/domains/search/search/', {
|
||||
query,
|
||||
tlds,
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get TLD pricing
|
||||
*/
|
||||
export const getDomainPrices = async (): Promise<DomainPrice[]> => {
|
||||
const response = await apiClient.get<DomainPrice[]>('/api/domains/search/prices/');
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Register a new domain
|
||||
*/
|
||||
export const registerDomain = async (
|
||||
data: DomainRegisterRequest
|
||||
): Promise<DomainRegistration> => {
|
||||
const response = await apiClient.post<DomainRegistration>('/api/domains/search/register/', data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all registered domains for current business
|
||||
*/
|
||||
export const getRegisteredDomains = async (): Promise<DomainRegistration[]> => {
|
||||
const response = await apiClient.get<DomainRegistration[]>('/api/domains/registrations/');
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a single domain registration
|
||||
*/
|
||||
export const getDomainRegistration = async (id: number): Promise<DomainRegistration> => {
|
||||
const response = await apiClient.get<DomainRegistration>(`/api/domains/registrations/${id}/`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Update nameservers for a domain
|
||||
*/
|
||||
export const updateNameservers = async (
|
||||
id: number,
|
||||
nameservers: string[]
|
||||
): Promise<DomainRegistration> => {
|
||||
const response = await apiClient.post<DomainRegistration>(
|
||||
`/api/domains/registrations/${id}/update_nameservers/`,
|
||||
{ nameservers }
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Toggle auto-renewal for a domain
|
||||
*/
|
||||
export const toggleAutoRenew = async (
|
||||
id: number,
|
||||
autoRenew: boolean
|
||||
): Promise<DomainRegistration> => {
|
||||
const response = await apiClient.post<DomainRegistration>(
|
||||
`/api/domains/registrations/${id}/toggle_auto_renew/`,
|
||||
{ auto_renew: autoRenew }
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Renew a domain
|
||||
*/
|
||||
export const renewDomain = async (
|
||||
id: number,
|
||||
years: number = 1
|
||||
): Promise<DomainRegistration> => {
|
||||
const response = await apiClient.post<DomainRegistration>(
|
||||
`/api/domains/registrations/${id}/renew/`,
|
||||
{ years }
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Sync domain info from NameSilo
|
||||
*/
|
||||
export const syncDomain = async (id: number): Promise<DomainRegistration> => {
|
||||
const response = await apiClient.post<DomainRegistration>(
|
||||
`/api/domains/registrations/${id}/sync/`
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get domain search history
|
||||
*/
|
||||
export const getSearchHistory = async (): Promise<DomainSearchHistory[]> => {
|
||||
const response = await apiClient.get<DomainSearchHistory[]>('/api/domains/history/');
|
||||
return response.data;
|
||||
};
|
||||
93
frontend/src/api/oauth.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* OAuth API
|
||||
* Handles OAuth authentication flows with various providers
|
||||
*/
|
||||
|
||||
import apiClient from './client';
|
||||
|
||||
export interface OAuthProvider {
|
||||
name: string;
|
||||
display_name: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export interface OAuthAuthorizationResponse {
|
||||
authorization_url: string;
|
||||
}
|
||||
|
||||
export interface OAuthTokenResponse {
|
||||
access: string;
|
||||
refresh: string;
|
||||
user: {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
name: string;
|
||||
role: string;
|
||||
avatar_url?: string;
|
||||
is_staff: boolean;
|
||||
is_superuser: boolean;
|
||||
business?: number;
|
||||
business_name?: string;
|
||||
business_subdomain?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface OAuthConnection {
|
||||
id: string;
|
||||
provider: string;
|
||||
provider_user_id: string;
|
||||
email?: string;
|
||||
connected_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of enabled OAuth providers
|
||||
*/
|
||||
export const getOAuthProviders = async (): Promise<OAuthProvider[]> => {
|
||||
const response = await apiClient.get<{ providers: OAuthProvider[] }>('/api/auth/oauth/providers/');
|
||||
return response.data.providers;
|
||||
};
|
||||
|
||||
/**
|
||||
* Initiate OAuth flow - get authorization URL
|
||||
*/
|
||||
export const initiateOAuth = async (provider: string): Promise<OAuthAuthorizationResponse> => {
|
||||
const response = await apiClient.get<OAuthAuthorizationResponse>(
|
||||
`/api/auth/oauth/${provider}/authorize/`
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle OAuth callback - exchange code for tokens
|
||||
*/
|
||||
export const handleOAuthCallback = async (
|
||||
provider: string,
|
||||
code: string,
|
||||
state: string
|
||||
): Promise<OAuthTokenResponse> => {
|
||||
const response = await apiClient.post<OAuthTokenResponse>(
|
||||
`/api/auth/oauth/${provider}/callback/`,
|
||||
{
|
||||
code,
|
||||
state,
|
||||
}
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get user's connected OAuth accounts
|
||||
*/
|
||||
export const getOAuthConnections = async (): Promise<OAuthConnection[]> => {
|
||||
const response = await apiClient.get<{ connections: OAuthConnection[] }>('/api/auth/oauth/connections/');
|
||||
return response.data.connections;
|
||||
};
|
||||
|
||||
/**
|
||||
* Disconnect an OAuth account
|
||||
*/
|
||||
export const disconnectOAuth = async (provider: string): Promise<void> => {
|
||||
await apiClient.delete(`/api/auth/oauth/connections/${provider}/`);
|
||||
};
|
||||
433
frontend/src/api/payments.ts
Normal file
@@ -0,0 +1,433 @@
|
||||
/**
|
||||
* Payments API
|
||||
* Functions for managing payment configuration (API keys and Connect)
|
||||
*/
|
||||
|
||||
import apiClient from './client';
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
export type PaymentMode = 'direct_api' | 'connect' | 'none';
|
||||
export type KeyStatus = 'active' | 'invalid' | 'deprecated';
|
||||
export type AccountStatus = 'pending' | 'onboarding' | 'active' | 'restricted' | 'rejected';
|
||||
|
||||
export interface ApiKeysInfo {
|
||||
id: number;
|
||||
status: KeyStatus;
|
||||
secret_key_masked: string;
|
||||
publishable_key_masked: string;
|
||||
last_validated_at: string | null;
|
||||
stripe_account_id: string;
|
||||
stripe_account_name: string;
|
||||
validation_error: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ConnectAccountInfo {
|
||||
id: number;
|
||||
business: number;
|
||||
business_name: string;
|
||||
business_subdomain: string;
|
||||
stripe_account_id: string;
|
||||
account_type: 'standard' | 'express' | 'custom';
|
||||
status: AccountStatus;
|
||||
charges_enabled: boolean;
|
||||
payouts_enabled: boolean;
|
||||
details_submitted: boolean;
|
||||
onboarding_complete: boolean;
|
||||
onboarding_link: string | null;
|
||||
onboarding_link_expires_at: string | null;
|
||||
is_onboarding_link_valid: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface PaymentConfig {
|
||||
payment_mode: PaymentMode;
|
||||
tier: string;
|
||||
can_accept_payments: boolean;
|
||||
api_keys: ApiKeysInfo | null;
|
||||
connect_account: ConnectAccountInfo | null;
|
||||
}
|
||||
|
||||
export interface ApiKeysValidationResult {
|
||||
valid: boolean;
|
||||
account_id?: string;
|
||||
account_name?: string;
|
||||
environment?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface ApiKeysCurrentResponse {
|
||||
configured: boolean;
|
||||
id?: number;
|
||||
status?: KeyStatus;
|
||||
secret_key_masked?: string;
|
||||
publishable_key_masked?: string;
|
||||
last_validated_at?: string | null;
|
||||
stripe_account_id?: string;
|
||||
stripe_account_name?: string;
|
||||
validation_error?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface ConnectOnboardingResponse {
|
||||
account_type: 'standard' | 'custom';
|
||||
url: string;
|
||||
stripe_account_id?: string;
|
||||
}
|
||||
|
||||
export interface AccountSessionResponse {
|
||||
client_secret: string;
|
||||
stripe_account_id: string;
|
||||
publishable_key: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Unified Configuration
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get unified payment configuration status.
|
||||
* Returns the complete payment setup for the business.
|
||||
*/
|
||||
export const getPaymentConfig = () =>
|
||||
apiClient.get<PaymentConfig>('/api/payments/config/status/');
|
||||
|
||||
// ============================================================================
|
||||
// API Keys (Free Tier)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get current API key configuration (masked keys).
|
||||
*/
|
||||
export const getApiKeys = () =>
|
||||
apiClient.get<ApiKeysCurrentResponse>('/api/payments/api-keys/');
|
||||
|
||||
/**
|
||||
* Save API keys.
|
||||
* Validates and stores the provided Stripe API keys.
|
||||
*/
|
||||
export const saveApiKeys = (secretKey: string, publishableKey: string) =>
|
||||
apiClient.post<ApiKeysInfo>('/api/payments/api-keys/', {
|
||||
secret_key: secretKey,
|
||||
publishable_key: publishableKey,
|
||||
});
|
||||
|
||||
/**
|
||||
* Validate API keys without saving.
|
||||
* Tests the keys against Stripe API.
|
||||
*/
|
||||
export const validateApiKeys = (secretKey: string, publishableKey: string) =>
|
||||
apiClient.post<ApiKeysValidationResult>('/api/payments/api-keys/validate/', {
|
||||
secret_key: secretKey,
|
||||
publishable_key: publishableKey,
|
||||
});
|
||||
|
||||
/**
|
||||
* Re-validate stored API keys.
|
||||
* Tests stored keys and updates their status.
|
||||
*/
|
||||
export const revalidateApiKeys = () =>
|
||||
apiClient.post<ApiKeysValidationResult>('/api/payments/api-keys/revalidate/');
|
||||
|
||||
/**
|
||||
* Delete stored API keys.
|
||||
*/
|
||||
export const deleteApiKeys = () =>
|
||||
apiClient.delete<{ success: boolean; message: string }>('/api/payments/api-keys/delete/');
|
||||
|
||||
// ============================================================================
|
||||
// Stripe Connect (Paid Tiers)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get current Connect account status.
|
||||
*/
|
||||
export const getConnectStatus = () =>
|
||||
apiClient.get<ConnectAccountInfo>('/api/payments/connect/status/');
|
||||
|
||||
/**
|
||||
* Initiate Connect account onboarding.
|
||||
* Returns a URL to redirect the user for Stripe onboarding.
|
||||
*/
|
||||
export const initiateConnectOnboarding = (refreshUrl: string, returnUrl: string) =>
|
||||
apiClient.post<ConnectOnboardingResponse>('/api/payments/connect/onboard/', {
|
||||
refresh_url: refreshUrl,
|
||||
return_url: returnUrl,
|
||||
});
|
||||
|
||||
/**
|
||||
* Refresh Connect onboarding link.
|
||||
* For custom Connect accounts that need a new onboarding link.
|
||||
*/
|
||||
export const refreshConnectOnboardingLink = (refreshUrl: string, returnUrl: string) =>
|
||||
apiClient.post<{ url: string }>('/api/payments/connect/refresh-link/', {
|
||||
refresh_url: refreshUrl,
|
||||
return_url: returnUrl,
|
||||
});
|
||||
|
||||
/**
|
||||
* Create an Account Session for embedded Connect onboarding.
|
||||
* Returns a client_secret for initializing Stripe's embedded Connect components.
|
||||
*/
|
||||
export const createAccountSession = () =>
|
||||
apiClient.post<AccountSessionResponse>('/api/payments/connect/account-session/');
|
||||
|
||||
/**
|
||||
* Refresh Connect account status from Stripe.
|
||||
* Syncs the local account record with the current state in Stripe.
|
||||
*/
|
||||
export const refreshConnectStatus = () =>
|
||||
apiClient.post<ConnectAccountInfo>('/api/payments/connect/refresh-status/');
|
||||
|
||||
// ============================================================================
|
||||
// Transaction Analytics
|
||||
// ============================================================================
|
||||
|
||||
export interface Transaction {
|
||||
id: number;
|
||||
business: number;
|
||||
business_name: string;
|
||||
stripe_payment_intent_id: string;
|
||||
stripe_charge_id: string;
|
||||
transaction_type: 'payment' | 'refund' | 'application_fee';
|
||||
status: 'pending' | 'succeeded' | 'failed' | 'refunded' | 'partially_refunded';
|
||||
amount: number;
|
||||
amount_display: string;
|
||||
application_fee_amount: number;
|
||||
fee_display: string;
|
||||
net_amount: number;
|
||||
currency: string;
|
||||
customer_email: string;
|
||||
customer_name: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
stripe_data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface TransactionListResponse {
|
||||
results: Transaction[];
|
||||
count: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
total_pages: number;
|
||||
}
|
||||
|
||||
export interface TransactionSummary {
|
||||
total_transactions: number;
|
||||
total_volume: number;
|
||||
total_volume_display: string;
|
||||
total_fees: number;
|
||||
total_fees_display: string;
|
||||
net_revenue: number;
|
||||
net_revenue_display: string;
|
||||
successful_transactions: number;
|
||||
failed_transactions: number;
|
||||
refunded_transactions: number;
|
||||
average_transaction: number;
|
||||
average_transaction_display: string;
|
||||
}
|
||||
|
||||
export interface TransactionFilters {
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
status?: 'all' | 'succeeded' | 'pending' | 'failed' | 'refunded';
|
||||
transaction_type?: 'all' | 'payment' | 'refund' | 'application_fee';
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
}
|
||||
|
||||
export interface StripeCharge {
|
||||
id: string;
|
||||
amount: number;
|
||||
amount_display: string;
|
||||
amount_refunded: number;
|
||||
currency: string;
|
||||
status: string;
|
||||
paid: boolean;
|
||||
refunded: boolean;
|
||||
description: string | null;
|
||||
receipt_email: string | null;
|
||||
receipt_url: string | null;
|
||||
created: number;
|
||||
payment_method_details: Record<string, unknown> | null;
|
||||
billing_details: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface ChargesResponse {
|
||||
charges: StripeCharge[];
|
||||
has_more: boolean;
|
||||
}
|
||||
|
||||
export interface StripePayout {
|
||||
id: string;
|
||||
amount: number;
|
||||
amount_display: string;
|
||||
currency: string;
|
||||
status: string;
|
||||
arrival_date: number | null;
|
||||
created: number;
|
||||
description: string | null;
|
||||
destination: string | null;
|
||||
failure_message: string | null;
|
||||
method: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface PayoutsResponse {
|
||||
payouts: StripePayout[];
|
||||
has_more: boolean;
|
||||
}
|
||||
|
||||
export interface BalanceItem {
|
||||
amount: number;
|
||||
currency: string;
|
||||
amount_display: string;
|
||||
}
|
||||
|
||||
export interface BalanceResponse {
|
||||
available: BalanceItem[];
|
||||
pending: BalanceItem[];
|
||||
available_total: number;
|
||||
pending_total: number;
|
||||
}
|
||||
|
||||
export interface ExportRequest {
|
||||
format: 'csv' | 'xlsx' | 'pdf' | 'quickbooks';
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
include_details?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of transactions with optional filtering.
|
||||
*/
|
||||
export const getTransactions = (filters?: TransactionFilters) => {
|
||||
const params = new URLSearchParams();
|
||||
if (filters?.start_date) params.append('start_date', filters.start_date);
|
||||
if (filters?.end_date) params.append('end_date', filters.end_date);
|
||||
if (filters?.status && filters.status !== 'all') params.append('status', filters.status);
|
||||
if (filters?.transaction_type && filters.transaction_type !== 'all') {
|
||||
params.append('transaction_type', filters.transaction_type);
|
||||
}
|
||||
if (filters?.page) params.append('page', String(filters.page));
|
||||
if (filters?.page_size) params.append('page_size', String(filters.page_size));
|
||||
|
||||
const queryString = params.toString();
|
||||
return apiClient.get<TransactionListResponse>(
|
||||
`/api/payments/transactions/${queryString ? `?${queryString}` : ''}`
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a single transaction by ID.
|
||||
*/
|
||||
export const getTransaction = (id: number) =>
|
||||
apiClient.get<Transaction>(`/api/payments/transactions/${id}/`);
|
||||
|
||||
/**
|
||||
* Get transaction summary/analytics.
|
||||
*/
|
||||
export const getTransactionSummary = (filters?: Pick<TransactionFilters, 'start_date' | 'end_date'>) => {
|
||||
const params = new URLSearchParams();
|
||||
if (filters?.start_date) params.append('start_date', filters.start_date);
|
||||
if (filters?.end_date) params.append('end_date', filters.end_date);
|
||||
|
||||
const queryString = params.toString();
|
||||
return apiClient.get<TransactionSummary>(
|
||||
`/api/payments/transactions/summary/${queryString ? `?${queryString}` : ''}`
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get charges from Stripe API.
|
||||
*/
|
||||
export const getStripeCharges = (limit: number = 20) =>
|
||||
apiClient.get<ChargesResponse>(`/api/payments/transactions/charges/?limit=${limit}`);
|
||||
|
||||
/**
|
||||
* Get payouts from Stripe API.
|
||||
*/
|
||||
export const getStripePayouts = (limit: number = 20) =>
|
||||
apiClient.get<PayoutsResponse>(`/api/payments/transactions/payouts/?limit=${limit}`);
|
||||
|
||||
/**
|
||||
* Get current balance from Stripe API.
|
||||
*/
|
||||
export const getStripeBalance = () =>
|
||||
apiClient.get<BalanceResponse>('/api/payments/transactions/balance/');
|
||||
|
||||
/**
|
||||
* Export transaction data.
|
||||
* Returns the file data directly for download.
|
||||
*/
|
||||
export const exportTransactions = (request: ExportRequest) =>
|
||||
apiClient.post('/api/payments/transactions/export/', request, {
|
||||
responseType: 'blob',
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Transaction Details & Refunds
|
||||
// ============================================================================
|
||||
|
||||
export interface RefundInfo {
|
||||
id: string;
|
||||
amount: number;
|
||||
amount_display: string;
|
||||
status: string;
|
||||
reason: string | null;
|
||||
created: number;
|
||||
}
|
||||
|
||||
export interface PaymentMethodInfo {
|
||||
type: string;
|
||||
brand?: string;
|
||||
last4?: string;
|
||||
exp_month?: number;
|
||||
exp_year?: number;
|
||||
funding?: string;
|
||||
bank_name?: string;
|
||||
}
|
||||
|
||||
export interface TransactionDetail extends Transaction {
|
||||
refunds: RefundInfo[];
|
||||
refundable_amount: number;
|
||||
total_refunded: number;
|
||||
can_refund: boolean;
|
||||
payment_method_info: PaymentMethodInfo | null;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface RefundRequest {
|
||||
amount?: number;
|
||||
reason?: 'duplicate' | 'fraudulent' | 'requested_by_customer';
|
||||
metadata?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface RefundResponse {
|
||||
success: boolean;
|
||||
refund_id: string;
|
||||
amount: number;
|
||||
amount_display: string;
|
||||
status: string;
|
||||
reason: string | null;
|
||||
transaction_status: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get detailed transaction information including refund data.
|
||||
*/
|
||||
export const getTransactionDetail = (id: number) =>
|
||||
apiClient.get<TransactionDetail>(`/api/payments/transactions/${id}/`);
|
||||
|
||||
/**
|
||||
* Issue a refund for a transaction.
|
||||
* @param transactionId - The ID of the transaction to refund
|
||||
* @param request - Optional refund request with amount and reason
|
||||
*/
|
||||
export const refundTransaction = (transactionId: number, request?: RefundRequest) =>
|
||||
apiClient.post<RefundResponse>(`/api/payments/transactions/${transactionId}/refund/`, request || {});
|
||||
56
frontend/src/api/platform.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Platform API
|
||||
* API functions for platform-level operations (businesses, users, etc.)
|
||||
*/
|
||||
|
||||
import apiClient from './client';
|
||||
|
||||
export interface PlatformBusiness {
|
||||
id: number;
|
||||
name: string;
|
||||
subdomain: string;
|
||||
tier: string;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
user_count: number;
|
||||
}
|
||||
|
||||
export interface PlatformUser {
|
||||
id: number;
|
||||
email: string;
|
||||
username: string;
|
||||
name?: string;
|
||||
role?: string;
|
||||
is_active: boolean;
|
||||
is_staff: boolean;
|
||||
is_superuser: boolean;
|
||||
business: number | null;
|
||||
business_name?: string;
|
||||
business_subdomain?: string;
|
||||
date_joined: string;
|
||||
last_login?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all businesses (platform admin only)
|
||||
*/
|
||||
export const getBusinesses = async (): Promise<PlatformBusiness[]> => {
|
||||
const response = await apiClient.get<PlatformBusiness[]>('/api/platform/businesses/');
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all users (platform admin only)
|
||||
*/
|
||||
export const getUsers = async (): Promise<PlatformUser[]> => {
|
||||
const response = await apiClient.get<PlatformUser[]>('/api/platform/users/');
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get users for a specific business
|
||||
*/
|
||||
export const getBusinessUsers = async (businessId: number): Promise<PlatformUser[]> => {
|
||||
const response = await apiClient.get<PlatformUser[]>(`/api/platform/users/?business=${businessId}`);
|
||||
return response.data;
|
||||
};
|
||||
90
frontend/src/api/platformOAuth.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Platform OAuth Settings API
|
||||
*/
|
||||
|
||||
import apiClient from './client';
|
||||
|
||||
export interface OAuthProviderConfig {
|
||||
enabled: boolean;
|
||||
client_id: string;
|
||||
client_secret: string;
|
||||
// Apple-specific fields
|
||||
team_id?: string;
|
||||
key_id?: string;
|
||||
// Microsoft-specific field
|
||||
tenant_id?: string;
|
||||
}
|
||||
|
||||
export interface PlatformOAuthSettings {
|
||||
// Global setting
|
||||
oauth_allow_registration: boolean;
|
||||
|
||||
// Provider configurations
|
||||
google: OAuthProviderConfig;
|
||||
apple: OAuthProviderConfig;
|
||||
facebook: OAuthProviderConfig;
|
||||
linkedin: OAuthProviderConfig;
|
||||
microsoft: OAuthProviderConfig;
|
||||
twitter: OAuthProviderConfig;
|
||||
twitch: OAuthProviderConfig;
|
||||
}
|
||||
|
||||
export interface PlatformOAuthSettingsUpdate {
|
||||
oauth_allow_registration?: boolean;
|
||||
|
||||
// Google
|
||||
oauth_google_enabled?: boolean;
|
||||
oauth_google_client_id?: string;
|
||||
oauth_google_client_secret?: string;
|
||||
|
||||
// Apple
|
||||
oauth_apple_enabled?: boolean;
|
||||
oauth_apple_client_id?: string;
|
||||
oauth_apple_client_secret?: string;
|
||||
oauth_apple_team_id?: string;
|
||||
oauth_apple_key_id?: string;
|
||||
|
||||
// Facebook
|
||||
oauth_facebook_enabled?: boolean;
|
||||
oauth_facebook_client_id?: string;
|
||||
oauth_facebook_client_secret?: string;
|
||||
|
||||
// LinkedIn
|
||||
oauth_linkedin_enabled?: boolean;
|
||||
oauth_linkedin_client_id?: string;
|
||||
oauth_linkedin_client_secret?: string;
|
||||
|
||||
// Microsoft
|
||||
oauth_microsoft_enabled?: boolean;
|
||||
oauth_microsoft_client_id?: string;
|
||||
oauth_microsoft_client_secret?: string;
|
||||
oauth_microsoft_tenant_id?: string;
|
||||
|
||||
// Twitter (X)
|
||||
oauth_twitter_enabled?: boolean;
|
||||
oauth_twitter_client_id?: string;
|
||||
oauth_twitter_client_secret?: string;
|
||||
|
||||
// Twitch
|
||||
oauth_twitch_enabled?: boolean;
|
||||
oauth_twitch_client_id?: string;
|
||||
oauth_twitch_client_secret?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get platform OAuth settings
|
||||
*/
|
||||
export const getPlatformOAuthSettings = async (): Promise<PlatformOAuthSettings> => {
|
||||
const { data } = await apiClient.get('/api/platform/settings/oauth/');
|
||||
return data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Update platform OAuth settings
|
||||
*/
|
||||
export const updatePlatformOAuthSettings = async (
|
||||
settings: PlatformOAuthSettingsUpdate
|
||||
): Promise<PlatformOAuthSettings> => {
|
||||
const { data } = await apiClient.post('/api/platform/settings/oauth/', settings);
|
||||
return data;
|
||||
};
|
||||
210
frontend/src/api/profile.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import apiClient from './client';
|
||||
|
||||
// Types
|
||||
export interface UserProfile {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
name: string;
|
||||
phone?: string;
|
||||
phone_verified: boolean;
|
||||
avatar_url?: string;
|
||||
email_verified: boolean;
|
||||
two_factor_enabled: boolean;
|
||||
totp_confirmed: boolean;
|
||||
sms_2fa_enabled: boolean;
|
||||
timezone: string;
|
||||
locale: string;
|
||||
notification_preferences: NotificationPreferences;
|
||||
role: string;
|
||||
business?: number;
|
||||
business_name?: string;
|
||||
business_subdomain?: string;
|
||||
// Address fields
|
||||
address_line1?: string;
|
||||
address_line2?: string;
|
||||
city?: string;
|
||||
state?: string;
|
||||
postal_code?: string;
|
||||
country?: string;
|
||||
}
|
||||
|
||||
export interface NotificationPreferences {
|
||||
email: boolean;
|
||||
sms: boolean;
|
||||
in_app: boolean;
|
||||
appointment_reminders: boolean;
|
||||
marketing: boolean;
|
||||
}
|
||||
|
||||
export interface TOTPSetupResponse {
|
||||
secret: string;
|
||||
qr_code: string; // Base64 encoded PNG
|
||||
provisioning_uri: string;
|
||||
}
|
||||
|
||||
export interface TOTPVerifyResponse {
|
||||
success: boolean;
|
||||
recovery_codes: string[];
|
||||
}
|
||||
|
||||
export interface Session {
|
||||
id: string;
|
||||
device_info: string;
|
||||
ip_address: string;
|
||||
location: string;
|
||||
created_at: string;
|
||||
last_activity: string;
|
||||
is_current: boolean;
|
||||
}
|
||||
|
||||
export interface LoginHistoryEntry {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
ip_address: string;
|
||||
device_info: string;
|
||||
location: string;
|
||||
success: boolean;
|
||||
failure_reason?: string;
|
||||
two_factor_method?: string;
|
||||
}
|
||||
|
||||
// Profile API
|
||||
export const getProfile = async (): Promise<UserProfile> => {
|
||||
const response = await apiClient.get('/api/auth/profile/');
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const updateProfile = async (data: Partial<UserProfile>): Promise<UserProfile> => {
|
||||
const response = await apiClient.patch('/api/auth/profile/', data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const uploadAvatar = async (file: File): Promise<{ avatar_url: string }> => {
|
||||
const formData = new FormData();
|
||||
formData.append('avatar', file);
|
||||
const response = await apiClient.post('/api/auth/profile/avatar/', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const deleteAvatar = async (): Promise<void> => {
|
||||
await apiClient.delete('/api/auth/profile/avatar/');
|
||||
};
|
||||
|
||||
// Email API
|
||||
export const sendVerificationEmail = async (): Promise<void> => {
|
||||
await apiClient.post('/api/auth/email/verify/send/');
|
||||
};
|
||||
|
||||
export const verifyEmail = async (token: string): Promise<void> => {
|
||||
await apiClient.post('/api/auth/email/verify/confirm/', { token });
|
||||
};
|
||||
|
||||
export const requestEmailChange = async (newEmail: string): Promise<void> => {
|
||||
await apiClient.post('/api/auth/email/change/', { new_email: newEmail });
|
||||
};
|
||||
|
||||
export const confirmEmailChange = async (token: string): Promise<void> => {
|
||||
await apiClient.post('/api/auth/email/change/confirm/', { token });
|
||||
};
|
||||
|
||||
// Password API
|
||||
export const changePassword = async (
|
||||
currentPassword: string,
|
||||
newPassword: string
|
||||
): Promise<void> => {
|
||||
await apiClient.post('/api/auth/password/change/', {
|
||||
current_password: currentPassword,
|
||||
new_password: newPassword,
|
||||
});
|
||||
};
|
||||
|
||||
// 2FA API
|
||||
export const setupTOTP = async (): Promise<TOTPSetupResponse> => {
|
||||
const response = await apiClient.post('/api/auth/2fa/totp/setup/');
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const verifyTOTP = async (code: string): Promise<TOTPVerifyResponse> => {
|
||||
const response = await apiClient.post('/api/auth/2fa/totp/verify/', { code });
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const disableTOTP = async (code: string): Promise<void> => {
|
||||
await apiClient.post('/api/auth/2fa/totp/disable/', { code });
|
||||
};
|
||||
|
||||
export const getRecoveryCodes = async (): Promise<string[]> => {
|
||||
const response = await apiClient.get('/api/auth/2fa/recovery-codes/');
|
||||
return response.data.codes;
|
||||
};
|
||||
|
||||
export const regenerateRecoveryCodes = async (): Promise<string[]> => {
|
||||
const response = await apiClient.post('/api/auth/2fa/recovery-codes/regenerate/');
|
||||
return response.data.codes;
|
||||
};
|
||||
|
||||
// Sessions API
|
||||
export const getSessions = async (): Promise<Session[]> => {
|
||||
const response = await apiClient.get('/api/auth/sessions/');
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const revokeSession = async (sessionId: string): Promise<void> => {
|
||||
await apiClient.delete(`/api/auth/sessions/${sessionId}/`);
|
||||
};
|
||||
|
||||
export const revokeOtherSessions = async (): Promise<void> => {
|
||||
await apiClient.post('/api/auth/sessions/revoke-others/');
|
||||
};
|
||||
|
||||
export const getLoginHistory = async (): Promise<LoginHistoryEntry[]> => {
|
||||
const response = await apiClient.get('/api/auth/login-history/');
|
||||
return response.data;
|
||||
};
|
||||
|
||||
// Phone Verification API
|
||||
export const sendPhoneVerification = async (phone: string): Promise<void> => {
|
||||
await apiClient.post('/api/auth/phone/verify/send/', { phone });
|
||||
};
|
||||
|
||||
export const verifyPhoneCode = async (code: string): Promise<void> => {
|
||||
await apiClient.post('/api/auth/phone/verify/confirm/', { code });
|
||||
};
|
||||
|
||||
// Multiple Email Management API
|
||||
export interface UserEmail {
|
||||
id: number;
|
||||
email: string;
|
||||
is_primary: boolean;
|
||||
verified: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export const getUserEmails = async (): Promise<UserEmail[]> => {
|
||||
const response = await apiClient.get('/api/auth/emails/');
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const addUserEmail = async (email: string): Promise<UserEmail> => {
|
||||
const response = await apiClient.post('/api/auth/emails/', { email });
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const deleteUserEmail = async (emailId: number): Promise<void> => {
|
||||
await apiClient.delete(`/api/auth/emails/${emailId}/`);
|
||||
};
|
||||
|
||||
export const sendUserEmailVerification = async (emailId: number): Promise<void> => {
|
||||
await apiClient.post(`/api/auth/emails/${emailId}/send-verification/`);
|
||||
};
|
||||
|
||||
export const verifyUserEmail = async (emailId: number, token: string): Promise<void> => {
|
||||
await apiClient.post(`/api/auth/emails/${emailId}/verify/`, { token });
|
||||
};
|
||||
|
||||
export const setPrimaryEmail = async (emailId: number): Promise<void> => {
|
||||
await apiClient.post(`/api/auth/emails/${emailId}/set-primary/`);
|
||||
};
|
||||
1
frontend/src/assets/react.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
BIN
frontend/src/assets/smooth_schedule_icon.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
247
frontend/src/assets/smooth_schedule_icon.svg
Normal file
@@ -0,0 +1,247 @@
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
width="100%" viewBox="0 0 1730 1100" enable-background="new 0 0 1730 1100" xml:space="preserve">
|
||||
<path fill="#2B5B5F" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M969.645508,771.667175
|
||||
C983.734009,760.932678 998.024170,750.981995 1011.135925,739.665955
|
||||
C1020.239868,731.809021 1027.811401,722.153137 1035.890625,713.142212
|
||||
C1039.635864,708.964966 1042.988037,704.431946 1046.455933,700.011230
|
||||
C1047.427979,698.771973 1048.177979,697.358459 1048.318481,695.266968
|
||||
C1043.233154,698.355286 1038.068848,701.321533 1033.076172,704.552979
|
||||
C1011.285095,718.656555 987.633118,729.002747 963.865662,739.154541
|
||||
C926.816467,754.979309 888.330383,766.524841 849.335266,776.096252
|
||||
C816.661194,784.116150 783.571899,790.540527 750.510559,796.854858
|
||||
C725.879822,801.559082 701.040466,805.235657 676.219849,808.862976
|
||||
C650.730042,812.588013 625.020874,814.936829 599.640015,819.253784
|
||||
C561.088013,825.810913 522.543823,832.670288 484.360413,841.058594
|
||||
C453.594025,847.817566 423.045654,856.010376 393.000458,865.471924
|
||||
C368.607147,873.153687 344.985138,883.370483 321.251038,893.028137
|
||||
C306.543671,899.012756 291.840790,905.266846 277.895966,912.805786
|
||||
C257.393433,923.890198 237.239243,935.709290 217.557373,948.193909
|
||||
C200.561569,958.974670 183.671112,970.190491 168.118774,982.907349
|
||||
C150.190521,997.567078 133.454575,1013.749817 116.860298,1029.946777
|
||||
C109.343819,1037.283325 103.407921,1046.241699 96.785301,1054.488647
|
||||
C95.963615,1055.511963 95.329086,1056.685547 94.607811,1057.789551
|
||||
C94.087418,1057.428833 93.567024,1057.068237 93.046631,1056.707520
|
||||
C95.143036,1051.902710 97.038155,1046.997681 99.369362,1042.309692
|
||||
C112.070229,1016.768616 126.605263,992.265686 143.560577,969.362183
|
||||
C154.371017,954.759155 166.268524,940.905212 178.350021,927.312134
|
||||
C195.337433,908.199402 214.588501,891.460449 234.166809,874.999146
|
||||
C257.180664,855.649292 281.649719,838.427429 307.573792,823.472717
|
||||
C336.247131,806.932129 365.478760,791.285645 396.623840,779.614136
|
||||
C412.184509,773.782776 427.375671,766.898743 443.113495,761.622986
|
||||
C464.384369,754.492371 485.846008,747.786011 507.540192,742.096313
|
||||
C540.973694,733.327393 574.554077,725.033386 608.300049,717.568359
|
||||
C634.070862,711.867554 660.204224,707.813232 686.158875,702.934082
|
||||
C711.461548,698.177490 736.731262,693.245178 762.037231,688.506470
|
||||
C773.996765,686.266907 786.117065,684.782654 797.967041,682.087830
|
||||
C813.228760,678.617249 828.417358,674.693970 843.413147,670.214722
|
||||
C868.431335,662.742126 893.816467,656.055481 918.046326,646.500244
|
||||
C947.249329,634.983948 975.782898,621.687195 1001.597900,603.148926
|
||||
C1019.638672,590.193542 1038.112427,577.639526 1057.397705,566.673218
|
||||
C1078.458008,554.697449 1100.290771,544.266785 1123.296509,535.835693
|
||||
C1153.968750,524.595032 1185.606567,517.348511 1216.895020,508.623566
|
||||
C1228.170898,505.479218 1239.672241,503.140717 1251.080444,500.476807
|
||||
C1252.144653,500.228271 1253.280396,500.285614 1255.739990,500.096741
|
||||
C1253.853149,502.287323 1252.808350,503.648651 1251.613892,504.862793
|
||||
C1244.387573,512.208191 1237.151611,519.453979 1230.673462,527.585815
|
||||
C1218.089600,543.381836 1207.873535,560.547852 1199.297607,578.655396
|
||||
C1191.381104,595.370850 1184.464722,612.563843 1177.247925,629.605774
|
||||
C1168.326660,650.672302 1158.144165,671.075928 1146.017334,690.496033
|
||||
C1135.214478,707.795898 1123.201904,724.184570 1109.329102,739.153809
|
||||
C1098.717407,750.604187 1088.405151,762.391296 1077.100098,773.122986
|
||||
C1066.321655,783.354675 1054.340088,792.309082 1043.048340,802.012085
|
||||
C1022.812439,819.400757 999.674561,832.426270 976.316162,844.731873
|
||||
C956.019775,855.424316 934.888245,864.927551 913.308838,872.683411
|
||||
C889.113220,881.379639 864.222961,888.452698 839.208069,894.452332
|
||||
C822.112122,898.552673 804.305725,899.769409 786.777283,901.957336
|
||||
C776.820679,903.200073 766.784119,903.802368 755.687805,904.790649
|
||||
C757.714539,906.218933 759.003662,907.369080 760.489807,908.138672
|
||||
C783.668091,920.140076 807.284790,931.020691 832.462219,938.359375
|
||||
C855.906860,945.193054 879.583191,951.030334 903.823364,953.736511
|
||||
C919.614380,955.499451 935.650452,956.242859 951.530090,955.794312
|
||||
C985.213318,954.842834 1018.249756,949.116272 1050.425049,938.980957
|
||||
C1090.859131,926.244141 1128.350220,907.577209 1162.281494,882.076538
|
||||
C1172.054565,874.731628 1181.528320,866.922607 1190.604370,858.733459
|
||||
C1201.177246,849.194031 1211.503418,839.328491 1221.327759,829.023743
|
||||
C1238.017578,811.517944 1253.516968,792.980530 1265.936401,772.111816
|
||||
C1274.501709,757.719238 1283.092041,743.320740 1291.001709,728.565918
|
||||
C1296.228638,718.815796 1300.504639,708.528442 1304.793457,698.308411
|
||||
C1315.707275,672.301758 1322.893799,645.154175 1327.839600,617.478088
|
||||
C1330.420410,603.036621 1331.911011,588.336731 1332.869995,573.686584
|
||||
C1333.878906,558.275757 1334.407471,542.754089 1333.765503,527.338318
|
||||
C1333.190186,513.526611 1330.652344,499.801727 1329.257446,486.010529
|
||||
C1329.129883,484.748444 1330.735107,482.487030 1332.013306,482.032196
|
||||
C1347.430786,476.546417 1363.083862,471.698395 1378.384033,465.913452
|
||||
C1395.856812,459.307068 1413.124390,452.132050 1430.313843,444.811279
|
||||
C1442.720703,439.527374 1455.204834,434.247284 1467.033081,427.821472
|
||||
C1488.682861,416.060059 1510.133179,403.880432 1531.183105,391.080017
|
||||
C1553.192505,377.696136 1573.413086,361.740723 1592.717285,344.700775
|
||||
C1602.850830,335.755951 1612.603027,326.373413 1622.384766,317.039001
|
||||
C1625.733643,313.843140 1628.616577,310.162659 1631.782593,306.769073
|
||||
C1632.601929,305.891022 1633.686157,305.260193 1634.648682,304.515747
|
||||
C1634.988770,304.771484 1635.328979,305.027191 1635.669067,305.282928
|
||||
C1633.291504,309.465271 1631.207642,313.850372 1628.485229,317.794739
|
||||
C1616.850464,334.652039 1605.817017,352.011719 1593.041870,367.969421
|
||||
C1581.144165,382.831451 1568.030884,396.904633 1554.180420,409.977081
|
||||
C1532.040161,430.873718 1508.570923,450.362701 1482.932861,466.917206
|
||||
C1461.684692,480.637024 1440.099609,493.861084 1418.288452,506.665344
|
||||
C1412.599854,510.004883 1412.874390,514.199585 1413.025269,519.129028
|
||||
C1413.335327,529.252197 1413.837646,539.372375 1413.970093,549.497498
|
||||
C1414.420044,583.932129 1409.491089,617.730225 1402.481934,651.371643
|
||||
C1396.489746,680.130859 1388.232544,708.150024 1376.739746,735.113281
|
||||
C1365.900146,760.543701 1354.243652,785.647095 1338.789673,808.736450
|
||||
C1329.595947,822.472168 1320.811768,836.521423 1310.929565,849.746643
|
||||
C1299.263916,865.358582 1287.147461,880.676086 1272.680908,893.951477
|
||||
C1267.312744,898.877502 1262.994141,904.960815 1257.552490,909.790405
|
||||
C1244.686401,921.209106 1231.741821,932.582581 1218.245483,943.234863
|
||||
C1206.325317,952.643250 1194.096924,961.842163 1181.146973,969.724365
|
||||
C1158.948486,983.235718 1135.947021,995.433838 1111.749023,1005.062805
|
||||
C1097.940796,1010.557495 1084.021851,1016.069458 1069.709839,1019.932617
|
||||
C1049.345581,1025.429443 1028.718628,1030.276367 1007.929993,1033.773071
|
||||
C991.841858,1036.479248 975.354248,1037.157715 959.006348,1037.869873
|
||||
C944.148682,1038.517090 929.177429,1038.851318 914.369873,1037.769165
|
||||
C895.950500,1036.422974 877.505005,1034.281860 859.326843,1031.045898
|
||||
C829.348206,1025.709351 800.144714,1017.064575 771.925598,1005.670044
|
||||
C756.608765,999.485352 741.639099,992.105408 727.324646,983.855835
|
||||
C708.068115,972.758301 689.407349,960.583984 670.880676,948.284729
|
||||
C663.679993,943.504333 657.602966,937.049927 650.901428,931.493958
|
||||
C644.098328,925.853760 636.800903,920.762085 630.356140,914.748413
|
||||
C619.933044,905.022461 609.957886,894.810120 599.945679,884.653320
|
||||
C596.250183,880.904480 592.985229,876.731201 588.732971,871.842102
|
||||
C593.234985,871.136841 596.812500,870.434021 600.423706,870.036133
|
||||
C613.563843,868.588135 626.724976,867.327454 639.859131,865.829712
|
||||
C649.863892,864.688843 659.866699,863.473877 669.822815,861.976013
|
||||
C687.452637,859.323730 705.044434,856.420166 722.655457,853.642822
|
||||
C738.960144,851.071533 755.332520,848.871826 771.558411,845.876282
|
||||
C788.103882,842.821716 804.617798,839.442139 820.942810,835.388611
|
||||
C838.621033,830.999084 856.189697,826.061890 873.562744,820.590759
|
||||
C883.364563,817.504089 892.799072,813.130615 902.178589,808.853394
|
||||
C914.899170,803.052734 927.670898,797.269348 939.927246,790.575073
|
||||
C950.102966,785.017090 959.565613,778.153442 969.645508,771.667175
|
||||
M1050.581421,694.050720
|
||||
C1050.730957,693.806946 1050.880493,693.563171 1051.029907,693.319275
|
||||
C1050.812500,693.437988 1050.595093,693.556763 1050.581421,694.050720
|
||||
z"/>
|
||||
<path fill="#2B5B5F" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M556.660950,457.603760
|
||||
C541.109375,531.266846 547.394165,603.414612 568.399292,675.217285
|
||||
C432.503021,704.469421 306.741730,754.212341 199.911194,846.845520
|
||||
C200.479172,845.300049 200.602173,844.107422 201.242157,843.353882
|
||||
C209.385620,833.765381 217.337875,823.994263 225.877579,814.768311
|
||||
C234.207504,805.768921 242.989990,797.166687 251.896179,788.730408
|
||||
C257.379120,783.536743 263.590637,779.120728 269.241333,774.092896
|
||||
C273.459808,770.339478 276.960907,765.728882 281.376740,762.257324
|
||||
C297.837646,749.316223 314.230652,736.249023 331.255981,724.078125
|
||||
C345.231110,714.087769 359.912170,705.048035 374.584686,696.085144
|
||||
C382.450134,691.280396 391.044617,687.685791 399.150726,683.253601
|
||||
C407.072968,678.921997 414.597321,673.833801 422.642975,669.762817
|
||||
C438.151398,661.916077 453.798492,654.321594 469.600006,647.085205
|
||||
C477.539642,643.449280 478.113831,642.479065 476.519012,633.766968
|
||||
C474.589203,623.224731 473.630249,612.508850 471.947601,601.916382
|
||||
C467.749847,575.490784 468.654633,548.856323 469.237122,522.316833
|
||||
C469.602295,505.676849 471.616699,488.988892 474.083252,472.500793
|
||||
C477.059357,452.606354 480.060059,432.564514 485.320496,413.203339
|
||||
C491.148651,391.752808 499.099060,370.831879 506.971741,350.000183
|
||||
C512.325867,335.832855 518.620361,321.957916 525.397888,308.404053
|
||||
C541.421509,276.359467 560.144958,245.828873 582.862244,218.156967
|
||||
C598.004089,199.712769 614.822388,182.523621 631.949951,165.861969
|
||||
C652.972046,145.411667 676.340942,127.695229 701.137573,111.953148
|
||||
C726.902954,95.596024 753.783325,81.411240 782.541138,71.040688
|
||||
C797.603638,65.608902 812.617126,59.820137 828.057861,55.716591
|
||||
C845.892639,50.976776 864.121277,47.634624 882.284790,44.254494
|
||||
C890.218506,42.778072 898.403564,42.346916 906.495300,42.086170
|
||||
C924.443237,41.507816 942.445129,40.435017 960.349243,41.242741
|
||||
C979.963135,42.127602 999.561890,44.377670 1019.039673,46.986061
|
||||
C1043.176270,50.218334 1066.758545,56.365486 1089.506470,64.964005
|
||||
C1106.661865,71.448593 1123.305542,79.342972 1139.969482,87.057976
|
||||
C1162.813843,97.634354 1183.941406,111.123840 1204.113037,126.138229
|
||||
C1207.003540,128.289703 1209.946899,130.370087 1213.763916,133.133530
|
||||
C1216.783447,129.931229 1220.327026,126.716408 1223.223755,122.997650
|
||||
C1231.400269,112.500671 1239.273560,101.768028 1247.343994,91.187546
|
||||
C1251.051270,86.327263 1254.881470,81.556633 1258.788696,76.855797
|
||||
C1259.760620,75.686508 1261.248413,74.945900 1262.499023,74.008209
|
||||
C1263.292480,75.345688 1264.457031,76.590347 1264.818726,78.035873
|
||||
C1267.046143,86.937248 1268.891724,95.937119 1271.242432,104.803978
|
||||
C1275.496948,120.851143 1280.156372,136.791153 1284.390381,152.843521
|
||||
C1289.730957,173.090820 1294.707275,193.434189 1300.038086,213.684174
|
||||
C1305.998291,236.325089 1312.179443,258.907806 1318.265015,281.515717
|
||||
C1318.472290,282.285461 1318.685059,283.053680 1319.249390,285.117157
|
||||
C1249.010864,270.419495 1179.575439,255.889877 1109.182129,241.159790
|
||||
C1125.300659,224.247345 1141.057739,207.714233 1157.271729,190.701782
|
||||
C1151.530518,186.784927 1146.192871,182.681778 1140.429565,179.305191
|
||||
C1127.437134,171.693329 1114.523315,163.859375 1101.056763,157.163300
|
||||
C1072.803589,143.114868 1043.187866,132.633057 1012.025146,127.306679
|
||||
C996.903809,124.722130 981.545776,123.292236 966.235352,122.115311
|
||||
C953.661621,121.148743 940.985535,120.787796 928.380005,121.118324
|
||||
C905.687134,121.713341 883.266846,125.033134 861.164490,130.156235
|
||||
C827.750183,137.901321 796.099426,150.481354 765.943542,166.659683
|
||||
C744.045410,178.407761 723.100586,191.717087 704.741150,208.715820
|
||||
C692.812561,219.760330 680.168945,230.111618 668.980225,241.854492
|
||||
C657.360962,254.049179 646.193909,266.898956 636.478516,280.629303
|
||||
C622.844910,299.897369 609.775757,319.708069 598.278931,340.299286
|
||||
C589.203308,356.553925 582.410522,374.153534 575.426636,391.487335
|
||||
C567.199646,411.906586 561.340576,433.110779 557.061401,454.725311
|
||||
C556.900940,455.536224 556.898621,456.378479 556.660950,457.603760
|
||||
z"/>
|
||||
<path fill="#2B5B5F" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M1706.087402,107.067314
|
||||
C1703.089111,115.619484 1700.499512,124.342247 1697.015747,132.691925
|
||||
C1686.536865,157.806900 1674.552490,182.225861 1658.662109,204.387024
|
||||
C1646.541138,221.290833 1633.860840,237.859802 1620.447754,253.749130
|
||||
C1610.171387,265.922516 1598.887085,277.376678 1587.116699,288.127747
|
||||
C1567.458008,306.083740 1546.417847,322.320587 1524.483398,337.552246
|
||||
C1495.366455,357.771515 1464.521729,374.787689 1432.470215,389.522156
|
||||
C1408.761597,400.421356 1384.338989,409.873322 1359.856445,418.950348
|
||||
C1338.651123,426.812286 1317.005859,433.538574 1295.377563,440.189545
|
||||
C1282.541626,444.136749 1269.303589,446.756866 1256.353271,450.357635
|
||||
C1243.725464,453.868683 1231.256226,457.945312 1218.677490,461.637756
|
||||
C1192.216675,469.405334 1165.581299,476.616241 1139.306396,484.964813
|
||||
C1122.046509,490.448944 1105.143555,497.158905 1088.355957,503.995453
|
||||
C1073.956177,509.859589 1059.653931,516.113403 1045.836670,523.221436
|
||||
C1027.095337,532.862488 1009.846802,544.765564 994.656799,559.637390
|
||||
C986.521912,567.601807 977.590271,574.817322 968.586731,581.815613
|
||||
C950.906799,595.557678 919.261353,591.257507 902.949524,575.751221
|
||||
C890.393311,563.815002 883.972961,548.799927 878.270020,533.265137
|
||||
C872.118042,516.506958 862.364990,502.109009 851.068176,488.567474
|
||||
C837.824646,472.692474 824.675781,456.737396 811.315308,440.961517
|
||||
C803.714661,431.986664 795.703918,423.360413 788.002930,414.468903
|
||||
C778.470581,403.462769 769.106140,392.311340 759.596680,381.285126
|
||||
C752.240295,372.755371 744.606201,364.460114 737.400879,355.806427
|
||||
C730.120544,347.062592 727.078613,337.212921 730.571777,325.824554
|
||||
C736.598145,306.177765 760.405457,299.432281 775.159790,313.874237
|
||||
C789.284302,327.699738 802.566406,342.385803 816.219543,356.692902
|
||||
C816.564697,357.054535 816.916931,357.409424 817.268250,357.765015
|
||||
C845.125427,385.956726 872.707642,414.429291 901.026123,442.149719
|
||||
C908.467834,449.434296 918.068054,454.575775 926.906189,460.350739
|
||||
C933.051758,464.366333 939.634460,467.707123 945.928894,471.503540
|
||||
C948.467102,473.034454 950.404358,472.612885 952.644043,470.884766
|
||||
C972.771118,455.355255 994.347229,442.156677 1017.344299,431.336121
|
||||
C1045.954834,417.874298 1075.032959,405.539398 1105.177612,395.868073
|
||||
C1127.357422,388.752136 1149.351074,380.949371 1171.792480,374.778931
|
||||
C1209.532104,364.402008 1247.646118,355.393494 1285.443359,345.217163
|
||||
C1317.973999,336.458740 1350.391968,327.239716 1382.656372,317.547119
|
||||
C1412.278198,308.648407 1441.114014,297.585785 1469.434570,285.016663
|
||||
C1511.778687,266.223450 1552.020386,243.841415 1590.082031,217.493744
|
||||
C1608.183228,204.963440 1625.881104,191.914856 1641.874268,176.685043
|
||||
C1649.680786,169.250977 1658.483398,162.733627 1665.537964,154.666931
|
||||
C1679.129517,139.125244 1691.799438,122.777374 1705.282837,106.722069
|
||||
C1705.838623,106.809341 1705.963135,106.938339 1706.087402,107.067314
|
||||
z"/>
|
||||
<path fill="#2B5B5F" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M1705.527710,106.486694
|
||||
C1705.630859,105.936501 1705.907837,105.568420 1706.218628,105.231407
|
||||
C1706.297485,105.145935 1706.483765,105.159477 1706.930664,105.055130
|
||||
C1706.771118,105.730949 1706.643921,106.269272 1706.302246,106.937462
|
||||
C1705.963135,106.938339 1705.838623,106.809341 1705.527710,106.486694
|
||||
z"/>
|
||||
<path fill="#2B5B5F" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M196.850372,849.515076
|
||||
C196.898865,849.852539 196.767776,850.076172 196.636688,850.299805
|
||||
C196.648056,850.000305 196.659409,849.700745 196.850372,849.515076
|
||||
z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 16 KiB |
89
frontend/src/components/AppointmentConfirmation.css
Normal file
@@ -0,0 +1,89 @@
|
||||
.confirmation-container {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 3rem 2rem;
|
||||
max-width: 500px;
|
||||
margin: 2rem auto;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.confirmation-icon {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
background: #48bb78;
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 3rem;
|
||||
margin: 0 auto 1.5rem;
|
||||
}
|
||||
|
||||
.confirmation-container h2 {
|
||||
font-size: 2rem;
|
||||
color: #1a202c;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.confirmation-details {
|
||||
background: #f7fafc;
|
||||
border-radius: 6px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 0;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.detail-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
font-weight: 600;
|
||||
color: #4a5568;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
color: #2d3748;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
background: #48bb78;
|
||||
color: white;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 12px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.confirmation-message {
|
||||
color: #718096;
|
||||
margin-bottom: 2rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.btn-done {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
background: #3182ce;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.btn-done:hover {
|
||||
background: #2c5282;
|
||||
}
|
||||
39
frontend/src/components/AppointmentConfirmation.jsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import React from 'react';
|
||||
import { format } from 'date-fns';
|
||||
import './AppointmentConfirmation.css';
|
||||
|
||||
const AppointmentConfirmation = ({ appointment, onClose }) => {
|
||||
const startTime = new Date(appointment.start_time);
|
||||
|
||||
return (
|
||||
<div className="confirmation-container">
|
||||
<div className="confirmation-icon">✓</div>
|
||||
<h2>Booking Confirmed!</h2>
|
||||
|
||||
<div className="confirmation-details">
|
||||
<div className="detail-row">
|
||||
<span className="detail-label">Date:</span>
|
||||
<span className="detail-value">{format(startTime, 'MMMM d, yyyy')}</span>
|
||||
</div>
|
||||
<div className="detail-row">
|
||||
<span className="detail-label">Time:</span>
|
||||
<span className="detail-value">{format(startTime, 'h:mm a')}</span>
|
||||
</div>
|
||||
<div className="detail-row">
|
||||
<span className="detail-label">Status:</span>
|
||||
<span className="detail-value status-badge">{appointment.status}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="confirmation-message">
|
||||
You will receive a confirmation email shortly with all the details.
|
||||
</p>
|
||||
|
||||
<button onClick={onClose} className="btn-done">
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppointmentConfirmation;
|
||||
137
frontend/src/components/BookingForm.css
Normal file
@@ -0,0 +1,137 @@
|
||||
.booking-form-container {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 2rem;
|
||||
max-width: 500px;
|
||||
margin: 2rem auto;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.booking-form-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.booking-form-header h2 {
|
||||
font-size: 1.75rem;
|
||||
color: #1a202c;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 2rem;
|
||||
color: #718096;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
background: #edf2f7;
|
||||
}
|
||||
|
||||
.service-summary {
|
||||
background: #f7fafc;
|
||||
padding: 1rem;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.service-summary p {
|
||||
margin: 0.5rem 0;
|
||||
color: #4a5568;
|
||||
}
|
||||
|
||||
.booking-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #2d3748;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select {
|
||||
padding: 0.75rem;
|
||||
border: 1px solid #cbd5e0;
|
||||
border-radius: 6px;
|
||||
font-size: 1rem;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group select:focus {
|
||||
outline: none;
|
||||
border-color: #3182ce;
|
||||
}
|
||||
|
||||
.form-group input.error,
|
||||
.form-group select.error {
|
||||
border-color: #e53e3e;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: #e53e3e;
|
||||
font-size: 0.875rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.btn-cancel,
|
||||
.btn-submit {
|
||||
flex: 1;
|
||||
padding: 0.75rem;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.btn-cancel {
|
||||
background: #edf2f7;
|
||||
color: #4a5568;
|
||||
}
|
||||
|
||||
.btn-cancel:hover {
|
||||
background: #e2e8f0;
|
||||
}
|
||||
|
||||
.btn-submit {
|
||||
background: #3182ce;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-submit:hover:not(:disabled) {
|
||||
background: #2c5282;
|
||||
}
|
||||
|
||||
.btn-submit:disabled {
|
||||
background: #a0aec0;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
133
frontend/src/components/BookingForm.jsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import React, { useState } from 'react';
|
||||
import { format } from 'date-fns';
|
||||
import './BookingForm.css';
|
||||
|
||||
const BookingForm = ({ service, resources, onSubmit, onCancel, loading }) => {
|
||||
const [formData, setFormData] = useState({
|
||||
resource: resources?.[0]?.id || '',
|
||||
date: '',
|
||||
time: '',
|
||||
});
|
||||
|
||||
const [errors, setErrors] = useState({});
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({ ...prev, [name]: value }));
|
||||
// Clear error for this field
|
||||
if (errors[name]) {
|
||||
setErrors(prev => ({ ...prev, [name]: '' }));
|
||||
}
|
||||
};
|
||||
|
||||
const validate = () => {
|
||||
const newErrors = {};
|
||||
|
||||
if (!formData.resource) {
|
||||
newErrors.resource = 'Please select a resource';
|
||||
}
|
||||
if (!formData.date) {
|
||||
newErrors.date = 'Please select a date';
|
||||
}
|
||||
if (!formData.time) {
|
||||
newErrors.time = 'Please select a time';
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validate()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Combine date and time into ISO format
|
||||
const startDateTime = new Date(`${formData.date}T${formData.time}`);
|
||||
const endDateTime = new Date(startDateTime.getTime() + service.duration * 60000);
|
||||
|
||||
const appointmentData = {
|
||||
service: service.id,
|
||||
resource: parseInt(formData.resource),
|
||||
start_time: startDateTime.toISOString(),
|
||||
end_time: endDateTime.toISOString(),
|
||||
};
|
||||
|
||||
onSubmit(appointmentData);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="booking-form-container">
|
||||
<div className="booking-form-header">
|
||||
<h2>Book: {service.name}</h2>
|
||||
<button onClick={onCancel} className="close-btn">×</button>
|
||||
</div>
|
||||
|
||||
<div className="service-summary">
|
||||
<p><strong>Duration:</strong> {service.duration} minutes</p>
|
||||
<p><strong>Price:</strong> ${service.price}</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="booking-form">
|
||||
<div className="form-group">
|
||||
<label htmlFor="resource">Select Provider</label>
|
||||
<select
|
||||
id="resource"
|
||||
name="resource"
|
||||
value={formData.resource}
|
||||
onChange={handleChange}
|
||||
className={errors.resource ? 'error' : ''}
|
||||
>
|
||||
<option value="">Choose a provider...</option>
|
||||
{resources?.map((resource) => (
|
||||
<option key={resource.id} value={resource.id}>
|
||||
{resource.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{errors.resource && <span className="error-message">{errors.resource}</span>}
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="date">Date</label>
|
||||
<input
|
||||
type="date"
|
||||
id="date"
|
||||
name="date"
|
||||
value={formData.date}
|
||||
onChange={handleChange}
|
||||
min={format(new Date(), 'yyyy-MM-dd')}
|
||||
className={errors.date ? 'error' : ''}
|
||||
/>
|
||||
{errors.date && <span className="error-message">{errors.date}</span>}
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="time">Time</label>
|
||||
<input
|
||||
type="time"
|
||||
id="time"
|
||||
name="time"
|
||||
value={formData.time}
|
||||
onChange={handleChange}
|
||||
className={errors.time ? 'error' : ''}
|
||||
/>
|
||||
{errors.time && <span className="error-message">{errors.time}</span>}
|
||||
</div>
|
||||
|
||||
<div className="form-actions">
|
||||
<button type="button" onClick={onCancel} className="btn-cancel">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" className="btn-submit" disabled={loading}>
|
||||
{loading ? 'Booking...' : 'Confirm Booking'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BookingForm;
|
||||
269
frontend/src/components/ConnectOnboarding.tsx
Normal file
@@ -0,0 +1,269 @@
|
||||
/**
|
||||
* Stripe Connect Onboarding Component
|
||||
* For paid-tier businesses to connect their Stripe account via Connect
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
ExternalLink,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
CreditCard,
|
||||
Wallet,
|
||||
} from 'lucide-react';
|
||||
import { ConnectAccountInfo } from '../api/payments';
|
||||
import { useConnectOnboarding, useRefreshConnectLink } from '../hooks/usePayments';
|
||||
|
||||
interface ConnectOnboardingProps {
|
||||
connectAccount: ConnectAccountInfo | null;
|
||||
tier: string;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
const ConnectOnboarding: React.FC<ConnectOnboardingProps> = ({
|
||||
connectAccount,
|
||||
tier,
|
||||
onSuccess,
|
||||
}) => {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const onboardingMutation = useConnectOnboarding();
|
||||
const refreshLinkMutation = useRefreshConnectLink();
|
||||
|
||||
const isActive = connectAccount?.status === 'active' && connectAccount?.charges_enabled;
|
||||
const isOnboarding = connectAccount?.status === 'onboarding' ||
|
||||
(connectAccount && !connectAccount.onboarding_complete);
|
||||
const needsOnboarding = !connectAccount;
|
||||
|
||||
const getReturnUrls = () => {
|
||||
const baseUrl = window.location.origin;
|
||||
return {
|
||||
refreshUrl: `${baseUrl}/payments?connect=refresh`,
|
||||
returnUrl: `${baseUrl}/payments?connect=complete`,
|
||||
};
|
||||
};
|
||||
|
||||
const handleStartOnboarding = async () => {
|
||||
setError(null);
|
||||
try {
|
||||
const { refreshUrl, returnUrl } = getReturnUrls();
|
||||
const result = await onboardingMutation.mutateAsync({ refreshUrl, returnUrl });
|
||||
// Redirect to Stripe onboarding
|
||||
window.location.href = result.url;
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error || 'Failed to start onboarding');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefreshLink = async () => {
|
||||
setError(null);
|
||||
try {
|
||||
const { refreshUrl, returnUrl } = getReturnUrls();
|
||||
const result = await refreshLinkMutation.mutateAsync({ refreshUrl, returnUrl });
|
||||
// Redirect to continue onboarding
|
||||
window.location.href = result.url;
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error || 'Failed to refresh onboarding link');
|
||||
}
|
||||
};
|
||||
|
||||
// Account type display
|
||||
const getAccountTypeLabel = () => {
|
||||
switch (connectAccount?.account_type) {
|
||||
case 'standard':
|
||||
return 'Standard Connect';
|
||||
case 'express':
|
||||
return 'Express Connect';
|
||||
case 'custom':
|
||||
return 'Custom Connect';
|
||||
default:
|
||||
return 'Connect';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Active Account Status */}
|
||||
{isActive && (
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<CheckCircle className="text-green-600 shrink-0 mt-0.5" size={20} />
|
||||
<div className="flex-1">
|
||||
<h4 className="font-medium text-green-800">Stripe Connected</h4>
|
||||
<p className="text-sm text-green-700 mt-1">
|
||||
Your Stripe account is connected and ready to accept payments.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Account Details */}
|
||||
{connectAccount && (
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<h4 className="font-medium text-gray-900 mb-3">Account Details</h4>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Account Type:</span>
|
||||
<span className="text-gray-900">{getAccountTypeLabel()}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Status:</span>
|
||||
<span
|
||||
className={`px-2 py-0.5 text-xs font-medium rounded-full ${
|
||||
connectAccount.status === 'active'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: connectAccount.status === 'onboarding'
|
||||
? 'bg-yellow-100 text-yellow-800'
|
||||
: connectAccount.status === 'restricted'
|
||||
? 'bg-red-100 text-red-800'
|
||||
: 'bg-gray-100 text-gray-800'
|
||||
}`}
|
||||
>
|
||||
{connectAccount.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-gray-600">Charges:</span>
|
||||
<span className="flex items-center gap-1">
|
||||
{connectAccount.charges_enabled ? (
|
||||
<>
|
||||
<CreditCard size={14} className="text-green-600" />
|
||||
<span className="text-green-600">Enabled</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CreditCard size={14} className="text-gray-400" />
|
||||
<span className="text-gray-500">Disabled</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-gray-600">Payouts:</span>
|
||||
<span className="flex items-center gap-1">
|
||||
{connectAccount.payouts_enabled ? (
|
||||
<>
|
||||
<Wallet size={14} className="text-green-600" />
|
||||
<span className="text-green-600">Enabled</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Wallet size={14} className="text-gray-400" />
|
||||
<span className="text-gray-500">Disabled</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{connectAccount.stripe_account_id && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Account ID:</span>
|
||||
<code className="font-mono text-gray-900 text-xs">
|
||||
{connectAccount.stripe_account_id}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Onboarding in Progress */}
|
||||
{isOnboarding && (
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertCircle className="text-yellow-600 shrink-0 mt-0.5" size={20} />
|
||||
<div className="flex-1">
|
||||
<h4 className="font-medium text-yellow-800">Complete Onboarding</h4>
|
||||
<p className="text-sm text-yellow-700 mt-1">
|
||||
Your Stripe Connect account setup is incomplete.
|
||||
Click below to continue the onboarding process.
|
||||
</p>
|
||||
<button
|
||||
onClick={handleRefreshLink}
|
||||
disabled={refreshLinkMutation.isPending}
|
||||
className="mt-3 flex items-center gap-2 px-4 py-2 text-sm font-medium text-yellow-800 bg-yellow-100 rounded-lg hover:bg-yellow-200 disabled:opacity-50"
|
||||
>
|
||||
{refreshLinkMutation.isPending ? (
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
) : (
|
||||
<RefreshCw size={16} />
|
||||
)}
|
||||
Continue Onboarding
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Start Onboarding */}
|
||||
{needsOnboarding && (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<h4 className="font-medium text-blue-800 mb-2">Connect with Stripe</h4>
|
||||
<p className="text-sm text-blue-700">
|
||||
As a {tier} tier business, you'll use Stripe Connect to accept payments.
|
||||
This provides a seamless payment experience for your customers while
|
||||
the platform handles payment processing.
|
||||
</p>
|
||||
<ul className="mt-3 space-y-1 text-sm text-blue-700">
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle size={14} />
|
||||
Secure payment processing
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle size={14} />
|
||||
Automatic payouts to your bank account
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle size={14} />
|
||||
PCI compliance handled for you
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleStartOnboarding}
|
||||
disabled={onboardingMutation.isPending}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-3 text-sm font-medium text-white bg-[#635BFF] rounded-lg hover:bg-[#5851ea] disabled:opacity-50"
|
||||
>
|
||||
{onboardingMutation.isPending ? (
|
||||
<Loader2 size={18} className="animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<ExternalLink size={18} />
|
||||
Connect with Stripe
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<div className="flex items-start gap-2 text-red-800">
|
||||
<AlertCircle size={18} className="shrink-0 mt-0.5" />
|
||||
<span className="text-sm">{error}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* External Stripe Dashboard Link */}
|
||||
{isActive && (
|
||||
<a
|
||||
href="https://dashboard.stripe.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 text-sm text-gray-600 hover:text-gray-900"
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
Open Stripe Dashboard
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConnectOnboarding;
|
||||
290
frontend/src/components/ConnectOnboardingEmbed.tsx
Normal file
@@ -0,0 +1,290 @@
|
||||
/**
|
||||
* Embedded Stripe Connect Onboarding Component
|
||||
*
|
||||
* Uses Stripe's Connect embedded components to provide a seamless
|
||||
* onboarding experience without redirecting users away from the app.
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import {
|
||||
ConnectComponentsProvider,
|
||||
ConnectAccountOnboarding,
|
||||
} from '@stripe/react-connect-js';
|
||||
import { loadConnectAndInitialize } from '@stripe/connect-js';
|
||||
import type { StripeConnectInstance } from '@stripe/connect-js';
|
||||
import {
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
Loader2,
|
||||
CreditCard,
|
||||
Wallet,
|
||||
Building2,
|
||||
} from 'lucide-react';
|
||||
import { createAccountSession, refreshConnectStatus, ConnectAccountInfo } from '../api/payments';
|
||||
|
||||
interface ConnectOnboardingEmbedProps {
|
||||
connectAccount: ConnectAccountInfo | null;
|
||||
tier: string;
|
||||
onComplete?: () => void;
|
||||
onError?: (error: string) => void;
|
||||
}
|
||||
|
||||
type LoadingState = 'idle' | 'loading' | 'ready' | 'error' | 'complete';
|
||||
|
||||
const ConnectOnboardingEmbed: React.FC<ConnectOnboardingEmbedProps> = ({
|
||||
connectAccount,
|
||||
tier,
|
||||
onComplete,
|
||||
onError,
|
||||
}) => {
|
||||
const [stripeConnectInstance, setStripeConnectInstance] = useState<StripeConnectInstance | null>(null);
|
||||
const [loadingState, setLoadingState] = useState<LoadingState>('idle');
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
const isActive = connectAccount?.status === 'active' && connectAccount?.charges_enabled;
|
||||
|
||||
// Initialize Stripe Connect
|
||||
const initializeStripeConnect = useCallback(async () => {
|
||||
if (loadingState === 'loading' || loadingState === 'ready') return;
|
||||
|
||||
setLoadingState('loading');
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
// Fetch account session from our backend
|
||||
const response = await createAccountSession();
|
||||
const { client_secret, publishable_key } = response.data;
|
||||
|
||||
// Initialize the Connect instance
|
||||
const instance = await loadConnectAndInitialize({
|
||||
publishableKey: publishable_key,
|
||||
fetchClientSecret: async () => client_secret,
|
||||
appearance: {
|
||||
overlays: 'drawer',
|
||||
variables: {
|
||||
colorPrimary: '#635BFF',
|
||||
colorBackground: '#ffffff',
|
||||
colorText: '#1a1a1a',
|
||||
colorDanger: '#df1b41',
|
||||
fontFamily: 'system-ui, -apple-system, sans-serif',
|
||||
fontSizeBase: '14px',
|
||||
spacingUnit: '4px',
|
||||
borderRadius: '8px',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
setStripeConnectInstance(instance);
|
||||
setLoadingState('ready');
|
||||
} catch (err: any) {
|
||||
console.error('Failed to initialize Stripe Connect:', err);
|
||||
const message = err.response?.data?.error || err.message || 'Failed to initialize payment setup';
|
||||
setErrorMessage(message);
|
||||
setLoadingState('error');
|
||||
onError?.(message);
|
||||
}
|
||||
}, [loadingState, onError]);
|
||||
|
||||
// Handle onboarding completion
|
||||
const handleOnboardingExit = useCallback(async () => {
|
||||
// Refresh status from Stripe to sync the local database
|
||||
try {
|
||||
await refreshConnectStatus();
|
||||
} catch (err) {
|
||||
console.error('Failed to refresh Connect status:', err);
|
||||
}
|
||||
setLoadingState('complete');
|
||||
onComplete?.();
|
||||
}, [onComplete]);
|
||||
|
||||
// Handle errors from the Connect component
|
||||
const handleLoadError = useCallback((loadError: { error: { message?: string }; elementTagName: string }) => {
|
||||
console.error('Connect component load error:', loadError);
|
||||
const message = loadError.error.message || 'Failed to load payment component';
|
||||
setErrorMessage(message);
|
||||
setLoadingState('error');
|
||||
onError?.(message);
|
||||
}, [onError]);
|
||||
|
||||
// Account type display
|
||||
const getAccountTypeLabel = () => {
|
||||
switch (connectAccount?.account_type) {
|
||||
case 'standard':
|
||||
return 'Standard Connect';
|
||||
case 'express':
|
||||
return 'Express Connect';
|
||||
case 'custom':
|
||||
return 'Custom Connect';
|
||||
default:
|
||||
return 'Connect';
|
||||
}
|
||||
};
|
||||
|
||||
// If account is already active, show status
|
||||
if (isActive) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<CheckCircle className="text-green-600 shrink-0 mt-0.5" size={20} />
|
||||
<div className="flex-1">
|
||||
<h4 className="font-medium text-green-800">Stripe Connected</h4>
|
||||
<p className="text-sm text-green-700 mt-1">
|
||||
Your Stripe account is connected and ready to accept payments.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<h4 className="font-medium text-gray-900 mb-3">Account Details</h4>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Account Type:</span>
|
||||
<span className="text-gray-900">{getAccountTypeLabel()}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Status:</span>
|
||||
<span className="px-2 py-0.5 text-xs font-medium rounded-full bg-green-100 text-green-800">
|
||||
{connectAccount.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-gray-600">Charges:</span>
|
||||
<span className="flex items-center gap-1 text-green-600">
|
||||
<CreditCard size={14} />
|
||||
Enabled
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-gray-600">Payouts:</span>
|
||||
<span className="flex items-center gap-1 text-green-600">
|
||||
<Wallet size={14} />
|
||||
{connectAccount.payouts_enabled ? 'Enabled' : 'Pending'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Completion state
|
||||
if (loadingState === 'complete') {
|
||||
return (
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-6 text-center">
|
||||
<CheckCircle className="mx-auto text-green-600 mb-3" size={48} />
|
||||
<h4 className="font-medium text-green-800 text-lg">Onboarding Complete!</h4>
|
||||
<p className="text-sm text-green-700 mt-2">
|
||||
Your Stripe account has been set up. You can now accept payments.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (loadingState === 'error') {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertCircle className="text-red-600 shrink-0 mt-0.5" size={20} />
|
||||
<div className="flex-1">
|
||||
<h4 className="font-medium text-red-800">Setup Failed</h4>
|
||||
<p className="text-sm text-red-700 mt-1">{errorMessage}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setLoadingState('idle');
|
||||
setErrorMessage(null);
|
||||
}}
|
||||
className="w-full px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Idle state - show start button
|
||||
if (loadingState === 'idle') {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Building2 className="text-blue-600 shrink-0 mt-0.5" size={20} />
|
||||
<div className="flex-1">
|
||||
<h4 className="font-medium text-blue-800">Set Up Payments</h4>
|
||||
<p className="text-sm text-blue-700 mt-1">
|
||||
As a {tier} tier business, you'll use Stripe Connect to accept payments.
|
||||
Complete the onboarding process to start accepting payments from your customers.
|
||||
</p>
|
||||
<ul className="mt-3 space-y-1 text-sm text-blue-700">
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle size={14} />
|
||||
Secure payment processing
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle size={14} />
|
||||
Automatic payouts to your bank account
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle size={14} />
|
||||
PCI compliance handled for you
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={initializeStripeConnect}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-3 text-sm font-medium text-white bg-[#635BFF] rounded-lg hover:bg-[#5851ea] transition-colors"
|
||||
>
|
||||
<CreditCard size={18} />
|
||||
Start Payment Setup
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Loading state
|
||||
if (loadingState === 'loading') {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12">
|
||||
<Loader2 className="animate-spin text-[#635BFF] mb-4" size={40} />
|
||||
<p className="text-gray-600">Initializing payment setup...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Ready state - show embedded onboarding
|
||||
if (loadingState === 'ready' && stripeConnectInstance) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4">
|
||||
<h4 className="font-medium text-gray-900 mb-2">Complete Your Account Setup</h4>
|
||||
<p className="text-sm text-gray-600">
|
||||
Fill out the information below to finish setting up your payment account.
|
||||
Your information is securely handled by Stripe.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="border border-gray-200 rounded-lg overflow-hidden bg-white p-4">
|
||||
<ConnectComponentsProvider connectInstance={stripeConnectInstance}>
|
||||
<ConnectAccountOnboarding
|
||||
onExit={handleOnboardingExit}
|
||||
onLoadError={handleLoadError}
|
||||
/>
|
||||
</ConnectComponentsProvider>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default ConnectOnboardingEmbed;
|
||||
180
frontend/src/components/DevQuickLogin.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import { useState } from 'react';
|
||||
import apiClient from '../api/client';
|
||||
import { setCookie } from '../utils/cookies';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
interface TestUser {
|
||||
username: string;
|
||||
password: string;
|
||||
role: string;
|
||||
label: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
const testUsers: TestUser[] = [
|
||||
{
|
||||
username: 'superuser',
|
||||
password: 'test123',
|
||||
role: 'SUPERUSER',
|
||||
label: 'Platform Superuser',
|
||||
color: 'bg-purple-600 hover:bg-purple-700',
|
||||
},
|
||||
{
|
||||
username: 'platform_manager',
|
||||
password: 'test123',
|
||||
role: 'PLATFORM_MANAGER',
|
||||
label: 'Platform Manager',
|
||||
color: 'bg-blue-600 hover:bg-blue-700',
|
||||
},
|
||||
{
|
||||
username: 'platform_sales',
|
||||
password: 'test123',
|
||||
role: 'PLATFORM_SALES',
|
||||
label: 'Platform Sales',
|
||||
color: 'bg-green-600 hover:bg-green-700',
|
||||
},
|
||||
{
|
||||
username: 'platform_support',
|
||||
password: 'test123',
|
||||
role: 'PLATFORM_SUPPORT',
|
||||
label: 'Platform Support',
|
||||
color: 'bg-yellow-600 hover:bg-yellow-700',
|
||||
},
|
||||
{
|
||||
username: 'tenant_owner',
|
||||
password: 'test123',
|
||||
role: 'TENANT_OWNER',
|
||||
label: 'Business Owner',
|
||||
color: 'bg-indigo-600 hover:bg-indigo-700',
|
||||
},
|
||||
{
|
||||
username: 'tenant_manager',
|
||||
password: 'test123',
|
||||
role: 'TENANT_MANAGER',
|
||||
label: 'Business Manager',
|
||||
color: 'bg-pink-600 hover:bg-pink-700',
|
||||
},
|
||||
{
|
||||
username: 'tenant_staff',
|
||||
password: 'test123',
|
||||
role: 'TENANT_STAFF',
|
||||
label: 'Staff Member',
|
||||
color: 'bg-teal-600 hover:bg-teal-700',
|
||||
},
|
||||
{
|
||||
username: 'customer',
|
||||
password: 'test123',
|
||||
role: 'CUSTOMER',
|
||||
label: 'Customer',
|
||||
color: 'bg-orange-600 hover:bg-orange-700',
|
||||
},
|
||||
];
|
||||
|
||||
export function DevQuickLogin() {
|
||||
const queryClient = useQueryClient();
|
||||
const [loading, setLoading] = useState<string | null>(null);
|
||||
const [isMinimized, setIsMinimized] = useState(false);
|
||||
|
||||
// Only show in development
|
||||
if (import.meta.env.PROD) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleQuickLogin = async (user: TestUser) => {
|
||||
setLoading(user.username);
|
||||
try {
|
||||
// Call token auth API
|
||||
const response = await apiClient.post('/api/auth-token/', {
|
||||
username: user.username,
|
||||
password: user.password,
|
||||
});
|
||||
|
||||
// Store token in cookie (use 'access_token' to match what client.ts expects)
|
||||
setCookie('access_token', response.data.token, 7);
|
||||
|
||||
// Invalidate queries to refetch user data
|
||||
await queryClient.invalidateQueries({ queryKey: ['currentUser'] });
|
||||
await queryClient.invalidateQueries({ queryKey: ['currentBusiness'] });
|
||||
|
||||
// Reload page to trigger auth flow
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
console.error('Quick login failed:', error);
|
||||
alert(`Failed to login as ${user.label}: ${error.message}`);
|
||||
} finally {
|
||||
setLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (isMinimized) {
|
||||
return (
|
||||