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>
This commit is contained in:
152
users/admin.py
Normal file
152
users/admin.py
Normal file
@@ -0,0 +1,152 @@
|
||||
"""
|
||||
Smooth Schedule Users App Admin Configuration
|
||||
"""
|
||||
from django.contrib import admin
|
||||
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
|
||||
from django.utils.html import format_html
|
||||
from hijack.contrib.admin import HijackUserAdminMixin
|
||||
from .models import User
|
||||
|
||||
|
||||
@admin.register(User)
|
||||
class UserAdmin(HijackUserAdminMixin, BaseUserAdmin):
|
||||
"""
|
||||
Custom User admin with role-based filtering and masquerade button.
|
||||
"""
|
||||
list_display = [
|
||||
'email',
|
||||
'first_name',
|
||||
'last_name',
|
||||
'role_badge',
|
||||
'tenant',
|
||||
'is_active',
|
||||
'is_temporary',
|
||||
'created_at',
|
||||
'hijack_field', # Adds the masquerade button
|
||||
]
|
||||
|
||||
list_filter = [
|
||||
'role',
|
||||
'is_active',
|
||||
'is_staff',
|
||||
'is_superuser',
|
||||
'is_temporary',
|
||||
'created_at',
|
||||
]
|
||||
|
||||
search_fields = [
|
||||
'email',
|
||||
'first_name',
|
||||
'last_name',
|
||||
'tenant__name',
|
||||
]
|
||||
|
||||
ordering = ['-created_at']
|
||||
|
||||
fieldsets = (
|
||||
('Authentication', {
|
||||
'fields': ('email', 'password')
|
||||
}),
|
||||
('Personal Information', {
|
||||
'fields': ('first_name', 'last_name', 'phone', 'job_title')
|
||||
}),
|
||||
('Permissions & Role', {
|
||||
'fields': ('role', 'tenant', 'is_temporary', 'is_active', 'is_staff', 'is_superuser')
|
||||
}),
|
||||
('Groups & Permissions', {
|
||||
'fields': ('groups', 'user_permissions'),
|
||||
'classes': ('collapse',),
|
||||
}),
|
||||
('Important Dates', {
|
||||
'fields': ('last_login', 'date_joined', 'created_at', 'updated_at'),
|
||||
'classes': ('collapse',),
|
||||
}),
|
||||
('Security', {
|
||||
'fields': ('last_login_ip',),
|
||||
'classes': ('collapse',),
|
||||
}),
|
||||
)
|
||||
|
||||
readonly_fields = [
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'date_joined',
|
||||
'last_login',
|
||||
'last_login_ip',
|
||||
]
|
||||
|
||||
add_fieldsets = (
|
||||
(None, {
|
||||
'classes': ('wide',),
|
||||
'fields': (
|
||||
'email',
|
||||
'password1',
|
||||
'password2',
|
||||
'role',
|
||||
'tenant',
|
||||
'first_name',
|
||||
'last_name',
|
||||
'is_temporary',
|
||||
),
|
||||
}),
|
||||
)
|
||||
|
||||
def role_badge(self, obj):
|
||||
"""Display role with color-coded badge"""
|
||||
role_colors = {
|
||||
'SUPERUSER': '#d32f2f', # Dark red
|
||||
'PLATFORM_MANAGER': '#f57c00', # Orange
|
||||
'PLATFORM_SALES': '#fbc02d', # Yellow
|
||||
'PLATFORM_SUPPORT': '#7cb342', # Light green
|
||||
'TENANT_OWNER': '#1976d2', # Blue
|
||||
'TENANT_MANAGER': '#0288d1', # Light blue
|
||||
'TENANT_STAFF': '#0097a7', # Cyan
|
||||
'CUSTOMER': '#5e35b1', # Purple
|
||||
}
|
||||
|
||||
color = role_colors.get(obj.role, '#777')
|
||||
|
||||
badge_html = f'''
|
||||
<span style="
|
||||
background-color: {color};
|
||||
color: white;
|
||||
padding: 3px 8px;
|
||||
border-radius: 3px;
|
||||
font-size: 11px;
|
||||
font-weight: bold;
|
||||
white-space: nowrap;
|
||||
">{obj.get_role_display()}</span>
|
||||
'''
|
||||
|
||||
if obj.is_temporary:
|
||||
badge_html += ' <span style="color: orange; font-size: 11px;">🎭 DEMO</span>'
|
||||
|
||||
return format_html(badge_html)
|
||||
role_badge.short_description = 'Role'
|
||||
|
||||
def get_queryset(self, request):
|
||||
"""
|
||||
Filter users based on who is viewing.
|
||||
Platform users see all, tenant users only see their tenant.
|
||||
"""
|
||||
qs = super().get_queryset(request)
|
||||
|
||||
if request.user.is_superuser:
|
||||
return qs
|
||||
|
||||
# If user is a tenant-level admin, only show users from their tenant
|
||||
if request.user.tenant:
|
||||
return qs.filter(tenant=request.user.tenant)
|
||||
|
||||
return qs
|
||||
|
||||
def formfield_for_foreignkey(self, db_field, request, **kwargs):
|
||||
"""
|
||||
Limit tenant choices based on who is editing.
|
||||
"""
|
||||
if db_field.name == "tenant":
|
||||
# If editing user is tenant-scoped, they can only assign to their own tenant
|
||||
if request.user.tenant and not request.user.is_superuser:
|
||||
kwargs["queryset"] = request.user.get_accessible_tenants()
|
||||
|
||||
return super().formfield_for_foreignkey(db_field, request, **kwargs)
|
||||
143
users/models.py
Normal file
143
users/models.py
Normal file
@@ -0,0 +1,143 @@
|
||||
"""
|
||||
Smooth Schedule Custom User Model
|
||||
Implements strict role hierarchy and multi-tenant user management
|
||||
"""
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
class User(AbstractUser):
|
||||
"""
|
||||
Custom User model with strict role-based access control.
|
||||
Replaces Django's default User model.
|
||||
"""
|
||||
|
||||
# Role Choices - Strict Hierarchy
|
||||
class Role(models.TextChoices):
|
||||
# Platform-level roles (access across all tenants)
|
||||
SUPERUSER = 'SUPERUSER', _('Platform Superuser')
|
||||
PLATFORM_MANAGER = 'PLATFORM_MANAGER', _('Platform Manager')
|
||||
PLATFORM_SALES = 'PLATFORM_SALES', _('Platform Sales')
|
||||
PLATFORM_SUPPORT = 'PLATFORM_SUPPORT', _('Platform Support')
|
||||
|
||||
# Tenant-level roles (access within single tenant)
|
||||
TENANT_OWNER = 'TENANT_OWNER', _('Tenant Owner')
|
||||
TENANT_MANAGER = 'TENANT_MANAGER', _('Tenant Manager')
|
||||
TENANT_STAFF = 'TENANT_STAFF', _('Tenant Staff')
|
||||
|
||||
# Customer role (end users of the tenant)
|
||||
CUSTOMER = 'CUSTOMER', _('Customer')
|
||||
|
||||
# Core fields
|
||||
role = models.CharField(
|
||||
max_length=20,
|
||||
choices=Role.choices,
|
||||
default=Role.CUSTOMER,
|
||||
help_text="User's role in the system hierarchy"
|
||||
)
|
||||
|
||||
# For multi-tenancy: link users to their tenant
|
||||
# Note: This is only for tenant-level users. Platform users can access all tenants.
|
||||
tenant = models.ForeignKey(
|
||||
'core.Tenant',
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='users',
|
||||
help_text="Tenant this user belongs to (null for platform-level users)"
|
||||
)
|
||||
|
||||
# Special flags
|
||||
is_temporary = models.BooleanField(
|
||||
default=False,
|
||||
help_text="True for sales demo accounts - can be masqueraded by Platform Sales"
|
||||
)
|
||||
|
||||
# Additional profile fields
|
||||
phone = models.CharField(max_length=20, blank=True)
|
||||
job_title = models.CharField(max_length=100, blank=True)
|
||||
|
||||
# Metadata
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
last_login_ip = models.GenericIPAddressField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['email']
|
||||
indexes = [
|
||||
models.Index(fields=['role', 'tenant']),
|
||||
models.Index(fields=['email', 'is_active']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.email} ({self.get_role_display()})"
|
||||
|
||||
def is_platform_user(self):
|
||||
"""Check if user has platform-level access"""
|
||||
return self.role in [
|
||||
self.Role.SUPERUSER,
|
||||
self.Role.PLATFORM_MANAGER,
|
||||
self.Role.PLATFORM_SALES,
|
||||
self.Role.PLATFORM_SUPPORT,
|
||||
]
|
||||
|
||||
def is_tenant_user(self):
|
||||
"""Check if user is tenant-scoped"""
|
||||
return self.role in [
|
||||
self.Role.TENANT_OWNER,
|
||||
self.Role.TENANT_MANAGER,
|
||||
self.Role.TENANT_STAFF,
|
||||
self.Role.CUSTOMER,
|
||||
]
|
||||
|
||||
def can_manage_users(self):
|
||||
"""Check if user can manage other users"""
|
||||
return self.role in [
|
||||
self.Role.SUPERUSER,
|
||||
self.Role.PLATFORM_MANAGER,
|
||||
self.Role.TENANT_OWNER,
|
||||
self.Role.TENANT_MANAGER,
|
||||
]
|
||||
|
||||
def can_access_billing(self):
|
||||
"""Check if user can access billing information"""
|
||||
return self.role in [
|
||||
self.Role.SUPERUSER,
|
||||
self.Role.PLATFORM_MANAGER,
|
||||
self.Role.TENANT_OWNER,
|
||||
]
|
||||
|
||||
def get_accessible_tenants(self):
|
||||
"""
|
||||
Get list of tenants this user can access.
|
||||
Platform users can access all tenants.
|
||||
Tenant users can only access their own tenant.
|
||||
"""
|
||||
from core.models import Tenant
|
||||
|
||||
if self.is_platform_user():
|
||||
return Tenant.objects.all()
|
||||
elif self.tenant:
|
||||
return Tenant.objects.filter(pk=self.tenant.pk)
|
||||
else:
|
||||
return Tenant.objects.none()
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""
|
||||
Override save to enforce business rules
|
||||
"""
|
||||
# Superusers must be staff and have is_superuser flag
|
||||
if self.role == self.Role.SUPERUSER:
|
||||
self.is_staff = True
|
||||
self.is_superuser = True
|
||||
|
||||
# Platform users should not be tied to a tenant
|
||||
if self.is_platform_user():
|
||||
self.tenant = None
|
||||
|
||||
# Tenant users must have a tenant
|
||||
if self.is_tenant_user() and not self.tenant:
|
||||
raise ValueError(f"Users with role {self.role} must be assigned to a tenant")
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
Reference in New Issue
Block a user