mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 07:51:09 -05:00
Refactor user account system and remove moderation integration
- Remove first_name and last_name fields from User model - Add user deletion and social provider services - Restructure auth serializers into separate directory - Update avatar upload functionality and API endpoints - Remove django-moderation integration documentation - Add mandatory compliance enforcement rules - Update frontend documentation with API usage examples
This commit is contained in:
@@ -33,6 +33,19 @@
|
|||||||
### CRITICAL DOCUMENTATION RULE
|
### CRITICAL DOCUMENTATION RULE
|
||||||
- CRITICAL: After every change, it is MANDATORY to update docs/frontend.md with ALL documentation on how to use the updated API endpoints and features. Your edits to that file must be comprehensive and include all relevant details. If the file does not exist, you must create it and assume it is for a NextJS frontend.
|
- CRITICAL: After every change, it is MANDATORY to update docs/frontend.md with ALL documentation on how to use the updated API endpoints and features. Your edits to that file must be comprehensive and include all relevant details. If the file does not exist, you must create it and assume it is for a NextJS frontend.
|
||||||
- CRITICAL: It is MANDATORY to include any types that need to be added to the frontend in docs/types-api.ts for NextJS as the file would appear in `src/types/api.ts` in the NextJS project exactly. Again, create it if it does not exist. Make sure it is in sync with docs/api.ts. Full type safety.
|
- CRITICAL: It is MANDATORY to include any types that need to be added to the frontend in docs/types-api.ts for NextJS as the file would appear in `src/types/api.ts` in the NextJS project exactly. Again, create it if it does not exist. Make sure it is in sync with docs/api.ts. Full type safety.
|
||||||
|
- IT IS MANDATORY that api calls include a trailing forward slash. See example below. The forward slash may only be omitted if the end of the endpoint is a query parameter such as ``/companies/${query ? `?${query}` : ''}` or `/map/locations/${createQuery(params)}`.
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```
|
||||||
|
async updateAvatar(formData: FormData): Promise<User> {
|
||||||
|
return makeRequest('/auth/user/avatar/', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
headers: {}, // Let browser set Content-Type for FormData
|
||||||
|
});
|
||||||
|
},`
|
||||||
|
```
|
||||||
|
- The types-api.ts file should import the types file as such `@/types/api` and not from any other location.
|
||||||
- CRITICAL: It is MANDATORY to include any new API endpoints in docs/lib-api.ts for NextJS as the file would appear in `/src/lib/api.ts` in the NextJS project exactly. Again, create it if it does not exist. Make sure it is in sync with docs/types.ts. Full type safety.
|
- CRITICAL: It is MANDATORY to include any new API endpoints in docs/lib-api.ts for NextJS as the file would appear in `/src/lib/api.ts` in the NextJS project exactly. Again, create it if it does not exist. Make sure it is in sync with docs/types.ts. Full type safety.
|
||||||
|
|
||||||
### CRITICAL DATA RULE
|
### CRITICAL DATA RULE
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
## Brief overview
|
|
||||||
Guidelines for integrating new Django moderation systems while preserving existing functionality, based on ThrillWiki project patterns. These rules ensure backward compatibility and proper integration when extending moderation capabilities.
|
|
||||||
|
|
||||||
## Integration approach
|
|
||||||
- Always preserve existing models and functionality when adding new moderation features
|
|
||||||
- Use comprehensive model integration rather than replacement - combine original and new models in single files
|
|
||||||
- Maintain existing method signatures and property aliases for backward compatibility
|
|
||||||
- Fix field name mismatches systematically across services, selectors, and related components
|
|
||||||
|
|
||||||
## Django model patterns
|
|
||||||
- All models must inherit from TrackedModel base class and use pghistory for change tracking
|
|
||||||
- Use proper Meta class inheritance: `class Meta(TrackedModel.Meta):`
|
|
||||||
- Maintain comprehensive business logic methods on models (approve, reject, escalate)
|
|
||||||
- Include property aliases for backward compatibility when field names change
|
|
||||||
|
|
||||||
## Service and selector updates
|
|
||||||
- Update services and selectors to match restored model field names systematically
|
|
||||||
- Use proper field references: `user` instead of `submitted_by`, `created_at` instead of `submitted_at`
|
|
||||||
- Maintain transaction safety with `select_for_update()` in services
|
|
||||||
- Keep comprehensive error handling and validation in service methods
|
|
||||||
|
|
||||||
## Migration strategy
|
|
||||||
- Create migrations incrementally and handle field changes with proper defaults
|
|
||||||
- Test Django checks after each major integration step
|
|
||||||
- Apply migrations immediately after creation to verify database compatibility
|
|
||||||
- Handle nullable field changes with appropriate migration prompts
|
|
||||||
|
|
||||||
## Documentation requirements
|
|
||||||
- Update docs/types-api.ts with complete TypeScript interface definitions
|
|
||||||
- Update docs/lib-api.ts with comprehensive API client implementation
|
|
||||||
- Ensure full type safety and proper error handling in TypeScript clients
|
|
||||||
- Document all new API endpoints and integration patterns
|
|
||||||
|
|
||||||
## Testing and validation
|
|
||||||
- Run Django system checks after each integration step
|
|
||||||
- Verify existing functionality still works after adding new features
|
|
||||||
- Test both original workflow and new moderation system functionality
|
|
||||||
- Confirm database migrations apply successfully without errors
|
|
||||||
32
.clinerules/mandatory-compliance-enforcement.md
Normal file
32
.clinerules/mandatory-compliance-enforcement.md
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
## Brief overview
|
||||||
|
This rule file establishes strict compliance enforcement protocols for mandatory development standards. These guidelines are project-specific and stem from critical rule violations that compromised system integrity. All rules marked as "MANDATORY" in project documentation must be followed without exception.
|
||||||
|
|
||||||
|
## Rule compliance verification
|
||||||
|
- Always read and review ALL .clinerules files before making any code changes
|
||||||
|
- Verify compliance with mandatory formatting requirements before committing
|
||||||
|
- Double-check work against explicitly stated project standards
|
||||||
|
- Never assume exceptions to rules marked as "MANDATORY"
|
||||||
|
|
||||||
|
## API documentation standards
|
||||||
|
- All API endpoints MUST include trailing forward slashes unless ending with query parameters
|
||||||
|
- Follow the exact format specified in .clinerules for API endpoint documentation
|
||||||
|
- Validate all endpoint URLs against the mandatory trailing slash rule
|
||||||
|
- Ensure consistency across all API documentation files
|
||||||
|
|
||||||
|
## Quality assurance protocols
|
||||||
|
- Perform systematic review of all changes against project rules
|
||||||
|
- Validate that modifications comply with architectural standards
|
||||||
|
- Check for systematic patterns that might indicate rule violations
|
||||||
|
- Implement self-review processes before submitting any work
|
||||||
|
|
||||||
|
## Accountability measures
|
||||||
|
- Take full responsibility for rule violations without excuses
|
||||||
|
- Acknowledge when mandatory standards have been compromised
|
||||||
|
- Accept consequences for systematic non-compliance
|
||||||
|
- Demonstrate commitment to following established project standards
|
||||||
|
|
||||||
|
## Documentation integrity
|
||||||
|
- Maintain accuracy and compliance in all technical documentation
|
||||||
|
- Ensure API documentation matches backend URL routing expectations
|
||||||
|
- Preserve system architecture integrity through compliant documentation
|
||||||
|
- Follow project-specific formatting requirements exactly as specified
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# Generated by Django 5.2.5 on 2025-08-29 21:32
|
||||||
|
|
||||||
|
import pgtrigger.compiler
|
||||||
|
import pgtrigger.migrations
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("accounts", "0007_add_display_name_to_user"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
pgtrigger.migrations.RemoveTrigger(
|
||||||
|
model_name="user",
|
||||||
|
name="insert_insert",
|
||||||
|
),
|
||||||
|
pgtrigger.migrations.RemoveTrigger(
|
||||||
|
model_name="user",
|
||||||
|
name="update_update",
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="user",
|
||||||
|
name="first_name",
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="user",
|
||||||
|
name="last_name",
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="userevent",
|
||||||
|
name="first_name",
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="userevent",
|
||||||
|
name="last_name",
|
||||||
|
),
|
||||||
|
pgtrigger.migrations.AddTrigger(
|
||||||
|
model_name="user",
|
||||||
|
trigger=pgtrigger.compiler.Trigger(
|
||||||
|
name="insert_insert",
|
||||||
|
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||||
|
func='INSERT INTO "accounts_userevent" ("activity_visibility", "allow_friend_requests", "allow_messages", "allow_profile_comments", "ban_date", "ban_reason", "date_joined", "display_name", "email", "email_notifications", "id", "is_active", "is_banned", "is_staff", "is_superuser", "last_login", "last_password_change", "login_history_retention", "login_notifications", "notification_preferences", "password", "pending_email", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "privacy_level", "push_notifications", "role", "search_visibility", "session_timeout", "show_email", "show_join_date", "show_photos", "show_real_name", "show_reviews", "show_statistics", "show_top_lists", "theme_preference", "two_factor_enabled", "user_id", "username") VALUES (NEW."activity_visibility", NEW."allow_friend_requests", NEW."allow_messages", NEW."allow_profile_comments", NEW."ban_date", NEW."ban_reason", NEW."date_joined", NEW."display_name", NEW."email", NEW."email_notifications", NEW."id", NEW."is_active", NEW."is_banned", NEW."is_staff", NEW."is_superuser", NEW."last_login", NEW."last_password_change", NEW."login_history_retention", NEW."login_notifications", NEW."notification_preferences", NEW."password", NEW."pending_email", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."privacy_level", NEW."push_notifications", NEW."role", NEW."search_visibility", NEW."session_timeout", NEW."show_email", NEW."show_join_date", NEW."show_photos", NEW."show_real_name", NEW."show_reviews", NEW."show_statistics", NEW."show_top_lists", NEW."theme_preference", NEW."two_factor_enabled", NEW."user_id", NEW."username"); RETURN NULL;',
|
||||||
|
hash="1ffd9209b0e1949c05de2548585cda9179288b68",
|
||||||
|
operation="INSERT",
|
||||||
|
pgid="pgtrigger_insert_insert_3867c",
|
||||||
|
table="accounts_user",
|
||||||
|
when="AFTER",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
pgtrigger.migrations.AddTrigger(
|
||||||
|
model_name="user",
|
||||||
|
trigger=pgtrigger.compiler.Trigger(
|
||||||
|
name="update_update",
|
||||||
|
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||||
|
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
|
||||||
|
func='INSERT INTO "accounts_userevent" ("activity_visibility", "allow_friend_requests", "allow_messages", "allow_profile_comments", "ban_date", "ban_reason", "date_joined", "display_name", "email", "email_notifications", "id", "is_active", "is_banned", "is_staff", "is_superuser", "last_login", "last_password_change", "login_history_retention", "login_notifications", "notification_preferences", "password", "pending_email", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "privacy_level", "push_notifications", "role", "search_visibility", "session_timeout", "show_email", "show_join_date", "show_photos", "show_real_name", "show_reviews", "show_statistics", "show_top_lists", "theme_preference", "two_factor_enabled", "user_id", "username") VALUES (NEW."activity_visibility", NEW."allow_friend_requests", NEW."allow_messages", NEW."allow_profile_comments", NEW."ban_date", NEW."ban_reason", NEW."date_joined", NEW."display_name", NEW."email", NEW."email_notifications", NEW."id", NEW."is_active", NEW."is_banned", NEW."is_staff", NEW."is_superuser", NEW."last_login", NEW."last_password_change", NEW."login_history_retention", NEW."login_notifications", NEW."notification_preferences", NEW."password", NEW."pending_email", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."privacy_level", NEW."push_notifications", NEW."role", NEW."search_visibility", NEW."session_timeout", NEW."show_email", NEW."show_join_date", NEW."show_photos", NEW."show_real_name", NEW."show_reviews", NEW."show_statistics", NEW."show_top_lists", NEW."theme_preference", NEW."two_factor_enabled", NEW."user_id", NEW."username"); RETURN NULL;',
|
||||||
|
hash="e5f0a1acc20a9aad226004bc93ca8dbc3511052f",
|
||||||
|
operation="UPDATE",
|
||||||
|
pgid="pgtrigger_update_update_0e890",
|
||||||
|
table="accounts_user",
|
||||||
|
when="AFTER",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -44,6 +44,10 @@ class User(AbstractUser):
|
|||||||
FRIENDS = "friends", _("Friends Only")
|
FRIENDS = "friends", _("Friends Only")
|
||||||
PRIVATE = "private", _("Private")
|
PRIVATE = "private", _("Private")
|
||||||
|
|
||||||
|
# Override inherited fields to remove them
|
||||||
|
first_name = None
|
||||||
|
last_name = None
|
||||||
|
|
||||||
# Read-only ID
|
# Read-only ID
|
||||||
user_id = models.CharField(
|
user_id = models.CharField(
|
||||||
max_length=10,
|
max_length=10,
|
||||||
@@ -179,7 +183,10 @@ class UserProfile(models.Model):
|
|||||||
"""
|
"""
|
||||||
if self.avatar:
|
if self.avatar:
|
||||||
# Return Cloudflare Images URL with avatar variant
|
# Return Cloudflare Images URL with avatar variant
|
||||||
return self.avatar.url_variant("avatar")
|
base_url = self.avatar.url
|
||||||
|
if '/public' in base_url:
|
||||||
|
return base_url.replace('/public', '/avatar')
|
||||||
|
return base_url
|
||||||
|
|
||||||
# Generate default letter-based avatar using first letter of username
|
# Generate default letter-based avatar using first letter of username
|
||||||
first_letter = self.user.username[0].upper() if self.user.username else "U"
|
first_letter = self.user.username[0].upper() if self.user.username else "U"
|
||||||
@@ -191,11 +198,20 @@ class UserProfile(models.Model):
|
|||||||
Return avatar variants for different use cases
|
Return avatar variants for different use cases
|
||||||
"""
|
"""
|
||||||
if self.avatar:
|
if self.avatar:
|
||||||
return {
|
base_url = self.avatar.url
|
||||||
"thumbnail": self.avatar.url_variant("thumbnail"),
|
if '/public' in base_url:
|
||||||
"avatar": self.avatar.url_variant("avatar"),
|
return {
|
||||||
"large": self.avatar.url_variant("large"),
|
"thumbnail": base_url.replace('/public', '/thumbnail'),
|
||||||
}
|
"avatar": base_url.replace('/public', '/avatar'),
|
||||||
|
"large": base_url.replace('/public', '/large'),
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
# If no variant in URL, return the same URL for all variants
|
||||||
|
return {
|
||||||
|
"thumbnail": base_url,
|
||||||
|
"avatar": base_url,
|
||||||
|
"large": base_url,
|
||||||
|
}
|
||||||
|
|
||||||
# For default avatars, return the same URL for all variants
|
# For default avatars, return the same URL for all variants
|
||||||
default_url = self.get_avatar_url()
|
default_url = self.get_avatar_url()
|
||||||
|
|||||||
@@ -176,8 +176,7 @@ def user_search_autocomplete(*, query: str, limit: int = 10) -> QuerySet:
|
|||||||
"""
|
"""
|
||||||
return User.objects.filter(
|
return User.objects.filter(
|
||||||
Q(username__icontains=query)
|
Q(username__icontains=query)
|
||||||
| Q(first_name__icontains=query)
|
| Q(display_name__icontains=query),
|
||||||
| Q(last_name__icontains=query),
|
|
||||||
is_active=True,
|
is_active=True,
|
||||||
).order_by("username")[:limit]
|
).order_by("username")[:limit]
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ class UserSerializer(serializers.ModelSerializer):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
avatar_url = serializers.SerializerMethodField()
|
avatar_url = serializers.SerializerMethodField()
|
||||||
|
display_name = serializers.SerializerMethodField()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = User
|
model = User
|
||||||
@@ -26,8 +27,7 @@ class UserSerializer(serializers.ModelSerializer):
|
|||||||
"id",
|
"id",
|
||||||
"username",
|
"username",
|
||||||
"email",
|
"email",
|
||||||
"first_name",
|
"display_name",
|
||||||
"last_name",
|
|
||||||
"date_joined",
|
"date_joined",
|
||||||
"is_active",
|
"is_active",
|
||||||
"avatar_url",
|
"avatar_url",
|
||||||
@@ -40,6 +40,10 @@ class UserSerializer(serializers.ModelSerializer):
|
|||||||
return obj.profile.avatar.url
|
return obj.profile.avatar.url
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def get_display_name(self, obj) -> str:
|
||||||
|
"""Get user display name"""
|
||||||
|
return obj.get_display_name()
|
||||||
|
|
||||||
|
|
||||||
class LoginSerializer(serializers.Serializer):
|
class LoginSerializer(serializers.Serializer):
|
||||||
"""
|
"""
|
||||||
@@ -82,14 +86,14 @@ class SignupSerializer(serializers.ModelSerializer):
|
|||||||
fields = [
|
fields = [
|
||||||
"username",
|
"username",
|
||||||
"email",
|
"email",
|
||||||
"first_name",
|
"display_name",
|
||||||
"last_name",
|
|
||||||
"password",
|
"password",
|
||||||
"password_confirm",
|
"password_confirm",
|
||||||
]
|
]
|
||||||
extra_kwargs = {
|
extra_kwargs = {
|
||||||
"password": {"write_only": True},
|
"password": {"write_only": True},
|
||||||
"email": {"required": True},
|
"email": {"required": True},
|
||||||
|
"display_name": {"required": True},
|
||||||
}
|
}
|
||||||
|
|
||||||
def validate_email(self, value):
|
def validate_email(self, value):
|
||||||
|
|||||||
@@ -28,8 +28,6 @@ class UserDeletionService:
|
|||||||
username=cls.DELETED_USER_USERNAME,
|
username=cls.DELETED_USER_USERNAME,
|
||||||
defaults={
|
defaults={
|
||||||
"email": cls.DELETED_USER_EMAIL,
|
"email": cls.DELETED_USER_EMAIL,
|
||||||
"first_name": "",
|
|
||||||
"last_name": "",
|
|
||||||
"is_active": False,
|
"is_active": False,
|
||||||
"is_staff": False,
|
"is_staff": False,
|
||||||
"is_superuser": False,
|
"is_superuser": False,
|
||||||
@@ -177,7 +175,11 @@ class UserDeletionService:
|
|||||||
return False, "Cannot delete the system deleted user placeholder"
|
return False, "Cannot delete the system deleted user placeholder"
|
||||||
|
|
||||||
if user.is_superuser:
|
if user.is_superuser:
|
||||||
return False, "Cannot delete superuser accounts"
|
return False, "Superuser accounts cannot be deleted for security reasons. Please contact system administrator or remove superuser privileges first."
|
||||||
|
|
||||||
|
# Check if user has critical admin role
|
||||||
|
if user.role == User.Roles.ADMIN and user.is_staff:
|
||||||
|
return False, "Admin accounts with staff privileges cannot be deleted. Please remove admin privileges first or contact system administrator."
|
||||||
|
|
||||||
# Add any other business rules here
|
# Add any other business rules here
|
||||||
|
|
||||||
|
|||||||
11
backend/apps/accounts/services/__init__.py
Normal file
11
backend/apps/accounts/services/__init__.py
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
"""
|
||||||
|
Accounts Services Package
|
||||||
|
|
||||||
|
This package contains business logic services for account management,
|
||||||
|
including social provider management, user authentication, and profile services.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .social_provider_service import SocialProviderService
|
||||||
|
from .user_deletion_service import UserDeletionService
|
||||||
|
|
||||||
|
__all__ = ['SocialProviderService', 'UserDeletionService']
|
||||||
258
backend/apps/accounts/services/social_provider_service.py
Normal file
258
backend/apps/accounts/services/social_provider_service.py
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
"""
|
||||||
|
Social Provider Management Service
|
||||||
|
|
||||||
|
This service handles the business logic for connecting and disconnecting
|
||||||
|
social authentication providers while ensuring users never lock themselves
|
||||||
|
out of their accounts.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Dict, List, Optional, Tuple, TYPE_CHECKING
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
from allauth.socialaccount.models import SocialAccount, SocialApp
|
||||||
|
from allauth.socialaccount.providers import registry
|
||||||
|
from django.contrib.sites.shortcuts import get_current_site
|
||||||
|
from django.http import HttpRequest
|
||||||
|
import logging
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from apps.accounts.models import User
|
||||||
|
else:
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class SocialProviderService:
|
||||||
|
"""Service for managing social provider connections."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def can_disconnect_provider(user: User, provider: str) -> Tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
Check if a user can safely disconnect a social provider.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: The user attempting to disconnect
|
||||||
|
provider: The provider to disconnect (e.g., 'google', 'discord')
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (can_disconnect: bool, reason: str)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Count remaining social accounts after disconnection
|
||||||
|
remaining_social_accounts = user.socialaccount_set.exclude(
|
||||||
|
provider=provider
|
||||||
|
).count()
|
||||||
|
|
||||||
|
# Check if user has email/password auth
|
||||||
|
has_password_auth = (
|
||||||
|
user.email and
|
||||||
|
user.has_usable_password() and
|
||||||
|
bool(user.password) # Not empty/unusable
|
||||||
|
)
|
||||||
|
|
||||||
|
# Allow disconnection only if alternative auth exists
|
||||||
|
can_disconnect = remaining_social_accounts > 0 or has_password_auth
|
||||||
|
|
||||||
|
if not can_disconnect:
|
||||||
|
if remaining_social_accounts == 0 and not has_password_auth:
|
||||||
|
return False, "Cannot disconnect your only authentication method. Please set up a password or connect another social provider first."
|
||||||
|
elif not has_password_auth:
|
||||||
|
return False, "Please set up email/password authentication before disconnecting this provider."
|
||||||
|
else:
|
||||||
|
return False, "Cannot disconnect this provider at this time."
|
||||||
|
|
||||||
|
return True, "Provider can be safely disconnected."
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"Error checking disconnect permission for user {user.id}, provider {provider}: {e}")
|
||||||
|
return False, "Unable to verify disconnection safety. Please try again."
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_connected_providers(user: "User") -> List[Dict]:
|
||||||
|
"""
|
||||||
|
Get all social providers connected to a user's account.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: The user to check
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of connected provider information
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
connected_providers = []
|
||||||
|
|
||||||
|
for social_account in user.socialaccount_set.all():
|
||||||
|
can_disconnect, reason = SocialProviderService.can_disconnect_provider(
|
||||||
|
user, social_account.provider
|
||||||
|
)
|
||||||
|
|
||||||
|
provider_info = {
|
||||||
|
'provider': social_account.provider,
|
||||||
|
'provider_name': social_account.get_provider().name,
|
||||||
|
'uid': social_account.uid,
|
||||||
|
'date_joined': social_account.date_joined,
|
||||||
|
'can_disconnect': can_disconnect,
|
||||||
|
'disconnect_reason': reason if not can_disconnect else None,
|
||||||
|
'extra_data': social_account.extra_data
|
||||||
|
}
|
||||||
|
|
||||||
|
connected_providers.append(provider_info)
|
||||||
|
|
||||||
|
return connected_providers
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting connected providers for user {user.id}: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_available_providers(request: HttpRequest) -> List[Dict]:
|
||||||
|
"""
|
||||||
|
Get all available social providers for the current site.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: The HTTP request
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of available provider information
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
site = get_current_site(request)
|
||||||
|
available_providers = []
|
||||||
|
|
||||||
|
# Get all social apps configured for this site
|
||||||
|
social_apps = SocialApp.objects.filter(sites=site).order_by('provider')
|
||||||
|
|
||||||
|
for social_app in social_apps:
|
||||||
|
try:
|
||||||
|
provider = registry.by_id(social_app.provider)
|
||||||
|
|
||||||
|
provider_info = {
|
||||||
|
'id': social_app.provider,
|
||||||
|
'name': provider.name,
|
||||||
|
'auth_url': request.build_absolute_uri(
|
||||||
|
f'/accounts/{social_app.provider}/login/'
|
||||||
|
),
|
||||||
|
'connect_url': request.build_absolute_uri(
|
||||||
|
f'/api/v1/auth/social/connect/{social_app.provider}/'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
available_providers.append(provider_info)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
f"Error processing provider {social_app.provider}: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
return available_providers
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting available providers: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def disconnect_provider(user: "User", provider: str) -> Tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
Disconnect a social provider from a user's account.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: The user to disconnect from
|
||||||
|
provider: The provider to disconnect
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (success: bool, message: str)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# First check if disconnection is allowed
|
||||||
|
can_disconnect, reason = SocialProviderService.can_disconnect_provider(
|
||||||
|
user, provider)
|
||||||
|
|
||||||
|
if not can_disconnect:
|
||||||
|
return False, reason
|
||||||
|
|
||||||
|
# Find and delete the social account
|
||||||
|
social_accounts = user.socialaccount_set.filter(provider=provider)
|
||||||
|
|
||||||
|
if not social_accounts.exists():
|
||||||
|
return False, f"No {provider} account found to disconnect."
|
||||||
|
|
||||||
|
# Delete all social accounts for this provider (in case of duplicates)
|
||||||
|
deleted_count = social_accounts.count()
|
||||||
|
social_accounts.delete()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"User {user.id} disconnected {deleted_count} {provider} account(s)")
|
||||||
|
|
||||||
|
return True, f"{provider.title()} account disconnected successfully."
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error disconnecting {provider} for user {user.id}: {e}")
|
||||||
|
return False, f"Failed to disconnect {provider} account. Please try again."
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_auth_status(user: "User") -> Dict:
|
||||||
|
"""
|
||||||
|
Get comprehensive authentication status for a user.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: The user to check
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with authentication status information
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
connected_providers = SocialProviderService.get_connected_providers(user)
|
||||||
|
|
||||||
|
has_password_auth = (
|
||||||
|
user.email and
|
||||||
|
user.has_usable_password() and
|
||||||
|
bool(user.password)
|
||||||
|
)
|
||||||
|
|
||||||
|
auth_methods_count = len(connected_providers) + \
|
||||||
|
(1 if has_password_auth else 0)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'user_id': user.id,
|
||||||
|
'username': user.username,
|
||||||
|
'email': user.email,
|
||||||
|
'has_password_auth': has_password_auth,
|
||||||
|
'connected_providers': connected_providers,
|
||||||
|
'total_auth_methods': auth_methods_count,
|
||||||
|
'can_disconnect_any': auth_methods_count > 1,
|
||||||
|
'requires_password_setup': not has_password_auth and len(connected_providers) == 1
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting auth status for user {user.id}: {e}")
|
||||||
|
return {
|
||||||
|
'error': 'Unable to retrieve authentication status'
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def validate_provider_exists(provider: str) -> Tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
Validate that a social provider is configured and available.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
provider: The provider ID to validate
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (is_valid: bool, message: str)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Check if provider is registered with allauth
|
||||||
|
if provider not in registry.provider_map:
|
||||||
|
return False, f"Provider '{provider}' is not supported."
|
||||||
|
|
||||||
|
# Check if provider has a social app configured
|
||||||
|
if not SocialApp.objects.filter(provider=provider).exists():
|
||||||
|
return False, f"Provider '{provider}' is not configured on this site."
|
||||||
|
|
||||||
|
return True, f"Provider '{provider}' is valid and available."
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error validating provider {provider}: {e}")
|
||||||
|
return False, "Unable to validate provider."
|
||||||
309
backend/apps/accounts/services/user_deletion_service.py
Normal file
309
backend/apps/accounts/services/user_deletion_service.py
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
"""
|
||||||
|
User Deletion Service
|
||||||
|
|
||||||
|
This service handles user account deletion while preserving submissions
|
||||||
|
and maintaining data integrity across the platform.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.db import transaction
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.core.mail import send_mail
|
||||||
|
from django.conf import settings
|
||||||
|
from django.template.loader import render_to_string
|
||||||
|
from typing import Dict, Any, Tuple, Optional
|
||||||
|
import logging
|
||||||
|
import secrets
|
||||||
|
import string
|
||||||
|
from datetime import timedelta, datetime
|
||||||
|
|
||||||
|
from apps.accounts.models import User
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
class UserDeletionRequest:
|
||||||
|
"""Model for tracking user deletion requests."""
|
||||||
|
|
||||||
|
def __init__(self, user: User, verification_code: str, expires_at: datetime):
|
||||||
|
self.user = user
|
||||||
|
self.verification_code = verification_code
|
||||||
|
self.expires_at = expires_at
|
||||||
|
self.created_at = timezone.now()
|
||||||
|
|
||||||
|
|
||||||
|
class UserDeletionService:
|
||||||
|
"""Service for handling user account deletion with submission preservation."""
|
||||||
|
|
||||||
|
# In-memory storage for deletion requests (in production, use Redis or database)
|
||||||
|
_deletion_requests = {}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def can_delete_user(user: User) -> Tuple[bool, Optional[str]]:
|
||||||
|
"""
|
||||||
|
Check if a user can be safely deleted.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: User to check for deletion eligibility
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple[bool, Optional[str]]: (can_delete, reason_if_not)
|
||||||
|
"""
|
||||||
|
# Prevent deletion of superusers
|
||||||
|
if user.is_superuser:
|
||||||
|
return False, "Cannot delete superuser accounts"
|
||||||
|
|
||||||
|
# Prevent deletion of staff/admin users
|
||||||
|
if user.is_staff:
|
||||||
|
return False, "Cannot delete staff accounts"
|
||||||
|
|
||||||
|
# Check for system users (if you have any special system accounts)
|
||||||
|
if hasattr(user, 'role') and user.role in ['ADMIN', 'MODERATOR']:
|
||||||
|
return False, "Cannot delete admin or moderator accounts"
|
||||||
|
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def request_user_deletion(user: User) -> UserDeletionRequest:
|
||||||
|
"""
|
||||||
|
Create a deletion request for a user and send verification email.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: User requesting deletion
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
UserDeletionRequest: The deletion request object
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If user cannot be deleted
|
||||||
|
"""
|
||||||
|
# Check if user can be deleted
|
||||||
|
can_delete, reason = UserDeletionService.can_delete_user(user)
|
||||||
|
if not can_delete:
|
||||||
|
raise ValueError(reason)
|
||||||
|
|
||||||
|
# Generate verification code
|
||||||
|
verification_code = ''.join(secrets.choice(
|
||||||
|
string.ascii_uppercase + string.digits) for _ in range(8))
|
||||||
|
|
||||||
|
# Set expiration (24 hours from now)
|
||||||
|
expires_at = timezone.now() + timezone.timedelta(hours=24)
|
||||||
|
|
||||||
|
# Create deletion request
|
||||||
|
deletion_request = UserDeletionRequest(user, verification_code, expires_at)
|
||||||
|
|
||||||
|
# Store request (in production, use Redis or database)
|
||||||
|
UserDeletionService._deletion_requests[verification_code] = deletion_request
|
||||||
|
|
||||||
|
# Send verification email
|
||||||
|
UserDeletionService._send_deletion_verification_email(
|
||||||
|
user, verification_code, expires_at)
|
||||||
|
|
||||||
|
return deletion_request
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def verify_and_delete_user(verification_code: str) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Verify deletion code and delete user account.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
verification_code: Verification code from email
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict[str, Any]: Deletion result information
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If verification code is invalid or expired
|
||||||
|
"""
|
||||||
|
# Find deletion request
|
||||||
|
deletion_request = UserDeletionService._deletion_requests.get(verification_code)
|
||||||
|
if not deletion_request:
|
||||||
|
raise ValueError("Invalid verification code")
|
||||||
|
|
||||||
|
# Check if expired
|
||||||
|
if timezone.now() > deletion_request.expires_at:
|
||||||
|
# Clean up expired request
|
||||||
|
del UserDeletionService._deletion_requests[verification_code]
|
||||||
|
raise ValueError("Verification code has expired")
|
||||||
|
|
||||||
|
user = deletion_request.user
|
||||||
|
|
||||||
|
# Perform deletion
|
||||||
|
result = UserDeletionService.delete_user_preserve_submissions(user)
|
||||||
|
|
||||||
|
# Clean up deletion request
|
||||||
|
del UserDeletionService._deletion_requests[verification_code]
|
||||||
|
|
||||||
|
# Add verification info to result
|
||||||
|
result['deletion_request'] = {
|
||||||
|
'verification_code': verification_code,
|
||||||
|
'created_at': deletion_request.created_at,
|
||||||
|
'verified_at': timezone.now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def cancel_deletion_request(user: User) -> bool:
|
||||||
|
"""
|
||||||
|
Cancel a pending deletion request for a user.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: User whose deletion request to cancel
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if request was found and cancelled, False if no request found
|
||||||
|
"""
|
||||||
|
# Find and remove any deletion requests for this user
|
||||||
|
to_remove = []
|
||||||
|
for code, request in UserDeletionService._deletion_requests.items():
|
||||||
|
if request.user.id == user.id:
|
||||||
|
to_remove.append(code)
|
||||||
|
|
||||||
|
for code in to_remove:
|
||||||
|
del UserDeletionService._deletion_requests[code]
|
||||||
|
|
||||||
|
return len(to_remove) > 0
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
@transaction.atomic
|
||||||
|
def delete_user_preserve_submissions(user: User) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Delete a user account while preserving all their submissions.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: User to delete
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict[str, Any]: Information about the deletion and preserved submissions
|
||||||
|
"""
|
||||||
|
# Get or create the "deleted_user" placeholder
|
||||||
|
deleted_user_placeholder, created = User.objects.get_or_create(
|
||||||
|
username='deleted_user',
|
||||||
|
defaults={
|
||||||
|
'email': 'deleted@thrillwiki.com',
|
||||||
|
'first_name': 'Deleted',
|
||||||
|
'last_name': 'User',
|
||||||
|
'is_active': False,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Count submissions before transfer
|
||||||
|
submission_counts = UserDeletionService._count_user_submissions(user)
|
||||||
|
|
||||||
|
# Transfer submissions to placeholder user
|
||||||
|
UserDeletionService._transfer_user_submissions(user, deleted_user_placeholder)
|
||||||
|
|
||||||
|
# Store user info before deletion
|
||||||
|
deleted_user_info = {
|
||||||
|
'username': user.username,
|
||||||
|
'user_id': getattr(user, 'user_id', user.id),
|
||||||
|
'email': user.email,
|
||||||
|
'date_joined': user.date_joined,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Delete the user account
|
||||||
|
user.delete()
|
||||||
|
|
||||||
|
return {
|
||||||
|
'deleted_user': deleted_user_info,
|
||||||
|
'preserved_submissions': submission_counts,
|
||||||
|
'transferred_to': {
|
||||||
|
'username': deleted_user_placeholder.username,
|
||||||
|
'user_id': getattr(deleted_user_placeholder, 'user_id', deleted_user_placeholder.id),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _count_user_submissions(user: User) -> Dict[str, int]:
|
||||||
|
"""Count all submissions for a user."""
|
||||||
|
counts = {}
|
||||||
|
|
||||||
|
# Count different types of submissions
|
||||||
|
# Note: These are placeholder counts - adjust based on your actual models
|
||||||
|
counts['park_reviews'] = getattr(
|
||||||
|
user, 'park_reviews', user.__class__.objects.none()).count()
|
||||||
|
counts['ride_reviews'] = getattr(
|
||||||
|
user, 'ride_reviews', user.__class__.objects.none()).count()
|
||||||
|
counts['uploaded_park_photos'] = getattr(
|
||||||
|
user, 'uploaded_park_photos', user.__class__.objects.none()).count()
|
||||||
|
counts['uploaded_ride_photos'] = getattr(
|
||||||
|
user, 'uploaded_ride_photos', user.__class__.objects.none()).count()
|
||||||
|
counts['top_lists'] = getattr(
|
||||||
|
user, 'top_lists', user.__class__.objects.none()).count()
|
||||||
|
counts['edit_submissions'] = getattr(
|
||||||
|
user, 'edit_submissions', user.__class__.objects.none()).count()
|
||||||
|
counts['photo_submissions'] = getattr(
|
||||||
|
user, 'photo_submissions', user.__class__.objects.none()).count()
|
||||||
|
|
||||||
|
return counts
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _transfer_user_submissions(user: User, placeholder_user: User) -> None:
|
||||||
|
"""Transfer all user submissions to placeholder user."""
|
||||||
|
|
||||||
|
# Transfer different types of submissions
|
||||||
|
# Note: Adjust these based on your actual model relationships
|
||||||
|
|
||||||
|
# Park reviews
|
||||||
|
if hasattr(user, 'park_reviews'):
|
||||||
|
user.park_reviews.all().update(user=placeholder_user)
|
||||||
|
|
||||||
|
# Ride reviews
|
||||||
|
if hasattr(user, 'ride_reviews'):
|
||||||
|
user.ride_reviews.all().update(user=placeholder_user)
|
||||||
|
|
||||||
|
# Uploaded photos
|
||||||
|
if hasattr(user, 'uploaded_park_photos'):
|
||||||
|
user.uploaded_park_photos.all().update(user=placeholder_user)
|
||||||
|
|
||||||
|
if hasattr(user, 'uploaded_ride_photos'):
|
||||||
|
user.uploaded_ride_photos.all().update(user=placeholder_user)
|
||||||
|
|
||||||
|
# Top lists
|
||||||
|
if hasattr(user, 'top_lists'):
|
||||||
|
user.top_lists.all().update(user=placeholder_user)
|
||||||
|
|
||||||
|
# Edit submissions
|
||||||
|
if hasattr(user, 'edit_submissions'):
|
||||||
|
user.edit_submissions.all().update(user=placeholder_user)
|
||||||
|
|
||||||
|
# Photo submissions
|
||||||
|
if hasattr(user, 'photo_submissions'):
|
||||||
|
user.photo_submissions.all().update(user=placeholder_user)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _send_deletion_verification_email(user: User, verification_code: str, expires_at: timezone.datetime) -> None:
|
||||||
|
"""Send verification email for account deletion."""
|
||||||
|
try:
|
||||||
|
context = {
|
||||||
|
'user': user,
|
||||||
|
'verification_code': verification_code,
|
||||||
|
'expires_at': expires_at,
|
||||||
|
'site_name': 'ThrillWiki',
|
||||||
|
'site_url': getattr(settings, 'SITE_URL', 'https://thrillwiki.com'),
|
||||||
|
}
|
||||||
|
|
||||||
|
subject = 'ThrillWiki: Confirm Account Deletion'
|
||||||
|
html_message = render_to_string(
|
||||||
|
'emails/account_deletion_verification.html', context)
|
||||||
|
plain_message = render_to_string(
|
||||||
|
'emails/account_deletion_verification.txt', context)
|
||||||
|
|
||||||
|
send_mail(
|
||||||
|
subject=subject,
|
||||||
|
message=plain_message,
|
||||||
|
html_message=html_message,
|
||||||
|
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||||
|
recipient_list=[user.email],
|
||||||
|
fail_silently=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Deletion verification email sent to {user.email}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"Failed to send deletion verification email to {user.email}: {str(e)}")
|
||||||
|
raise
|
||||||
@@ -6,23 +6,6 @@ user deletion while preserving submissions, profile management, settings,
|
|||||||
preferences, privacy, notifications, and security.
|
preferences, privacy, notifications, and security.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from rest_framework import status
|
|
||||||
from rest_framework.decorators import api_view, permission_classes
|
|
||||||
from rest_framework.permissions import IsAuthenticated, IsAdminUser
|
|
||||||
from rest_framework.response import Response
|
|
||||||
from drf_spectacular.utils import extend_schema, OpenApiParameter
|
|
||||||
from drf_spectacular.types import OpenApiTypes
|
|
||||||
from django.shortcuts import get_object_or_404
|
|
||||||
from rest_framework.permissions import AllowAny
|
|
||||||
from django.utils import timezone
|
|
||||||
from apps.accounts.models import (
|
|
||||||
User,
|
|
||||||
UserProfile,
|
|
||||||
TopList,
|
|
||||||
UserNotification,
|
|
||||||
NotificationPreference,
|
|
||||||
)
|
|
||||||
from apps.accounts.services import UserDeletionService
|
|
||||||
from apps.api.v1.serializers.accounts import (
|
from apps.api.v1.serializers.accounts import (
|
||||||
CompleteUserSerializer,
|
CompleteUserSerializer,
|
||||||
UserPreferencesSerializer,
|
UserPreferencesSerializer,
|
||||||
@@ -39,6 +22,27 @@ from apps.api.v1.serializers.accounts import (
|
|||||||
MarkNotificationsReadSerializer,
|
MarkNotificationsReadSerializer,
|
||||||
AvatarUploadSerializer,
|
AvatarUploadSerializer,
|
||||||
)
|
)
|
||||||
|
from apps.accounts.services import UserDeletionService
|
||||||
|
from apps.accounts.models import (
|
||||||
|
User,
|
||||||
|
UserProfile,
|
||||||
|
TopList,
|
||||||
|
UserNotification,
|
||||||
|
NotificationPreference,
|
||||||
|
)
|
||||||
|
import logging
|
||||||
|
from rest_framework import status
|
||||||
|
from rest_framework.decorators import api_view, permission_classes
|
||||||
|
from rest_framework.permissions import IsAuthenticated, IsAdminUser
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from drf_spectacular.utils import extend_schema, OpenApiParameter
|
||||||
|
from drf_spectacular.types import OpenApiTypes
|
||||||
|
from django.shortcuts import get_object_or_404
|
||||||
|
from rest_framework.permissions import AllowAny
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
# Set up logging
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@extend_schema(
|
@extend_schema(
|
||||||
@@ -106,7 +110,7 @@ def delete_user_preserve_submissions(request, user_id):
|
|||||||
Delete a user while preserving all their submissions.
|
Delete a user while preserving all their submissions.
|
||||||
|
|
||||||
This endpoint allows administrators to delete user accounts while
|
This endpoint allows administrators to delete user accounts while
|
||||||
preserving all user-generated content (reviews, photos, top lists, etc.).
|
preserving all user - generated content(reviews, photos, top lists, etc.).
|
||||||
All submissions are transferred to a system "deleted_user" placeholder.
|
All submissions are transferred to a system "deleted_user" placeholder.
|
||||||
|
|
||||||
**Admin Only**: This endpoint requires admin permissions.
|
**Admin Only**: This endpoint requires admin permissions.
|
||||||
@@ -119,14 +123,71 @@ def delete_user_preserve_submissions(request, user_id):
|
|||||||
# Check if user can be deleted
|
# Check if user can be deleted
|
||||||
can_delete, reason = UserDeletionService.can_delete_user(user)
|
can_delete, reason = UserDeletionService.can_delete_user(user)
|
||||||
if not can_delete:
|
if not can_delete:
|
||||||
|
# Log the attempt for security monitoring
|
||||||
|
logger.warning(
|
||||||
|
f"Admin user {request.user.username} attempted to delete protected user {user.username} (ID: {user_id}). Reason: {reason}",
|
||||||
|
extra={
|
||||||
|
"admin_user": request.user.username,
|
||||||
|
"target_user": user.username,
|
||||||
|
"target_user_id": user_id,
|
||||||
|
"is_superuser": user.is_superuser,
|
||||||
|
"user_role": user.role,
|
||||||
|
"rejection_reason": reason,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Determine error code based on reason
|
||||||
|
error_code = "DELETION_FORBIDDEN"
|
||||||
|
if "superuser" in reason.lower():
|
||||||
|
error_code = "SUPERUSER_DELETION_FORBIDDEN"
|
||||||
|
elif "admin" in reason.lower():
|
||||||
|
error_code = "ADMIN_DELETION_FORBIDDEN"
|
||||||
|
elif "system" in reason.lower():
|
||||||
|
error_code = "SYSTEM_USER_DELETION_FORBIDDEN"
|
||||||
|
|
||||||
return Response(
|
return Response(
|
||||||
{"success": False, "error": f"Cannot delete user: {reason}"},
|
{
|
||||||
|
"success": False,
|
||||||
|
"error": f"Cannot delete user: {reason}",
|
||||||
|
"error_code": error_code,
|
||||||
|
"user_info": {
|
||||||
|
"username": user.username,
|
||||||
|
"user_id": user.user_id,
|
||||||
|
"role": user.role,
|
||||||
|
"is_superuser": user.is_superuser,
|
||||||
|
"is_staff": user.is_staff,
|
||||||
|
},
|
||||||
|
"help_text": "Contact system administrator if you need to delete this account type.",
|
||||||
|
},
|
||||||
status=status.HTTP_400_BAD_REQUEST,
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Log the successful deletion attempt
|
||||||
|
logger.info(
|
||||||
|
f"Admin user {request.user.username} is deleting user {user.username} (ID: {user_id})",
|
||||||
|
extra={
|
||||||
|
"admin_user": request.user.username,
|
||||||
|
"target_user": user.username,
|
||||||
|
"target_user_id": user_id,
|
||||||
|
"action": "user_deletion",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
# Perform the deletion
|
# Perform the deletion
|
||||||
result = UserDeletionService.delete_user_preserve_submissions(user)
|
result = UserDeletionService.delete_user_preserve_submissions(user)
|
||||||
|
|
||||||
|
# Log successful deletion
|
||||||
|
logger.info(
|
||||||
|
f"Successfully deleted user {result['deleted_user']['username']} (ID: {user_id}) by admin {request.user.username}",
|
||||||
|
extra={
|
||||||
|
"admin_user": request.user.username,
|
||||||
|
"deleted_user": result['deleted_user']['username'],
|
||||||
|
"deleted_user_id": user_id,
|
||||||
|
"preserved_submissions": result['preserved_submissions'],
|
||||||
|
"action": "user_deletion_completed",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
return Response(
|
return Response(
|
||||||
{
|
{
|
||||||
"success": True,
|
"success": True,
|
||||||
@@ -137,8 +198,25 @@ def delete_user_preserve_submissions(request, user_id):
|
|||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
# Log the error for debugging
|
||||||
|
logger.error(
|
||||||
|
f"Error deleting user {user_id} by admin {request.user.username}: {str(e)}",
|
||||||
|
extra={
|
||||||
|
"admin_user": request.user.username,
|
||||||
|
"target_user_id": user_id,
|
||||||
|
"error": str(e),
|
||||||
|
"action": "user_deletion_error",
|
||||||
|
},
|
||||||
|
exc_info=True
|
||||||
|
)
|
||||||
|
|
||||||
return Response(
|
return Response(
|
||||||
{"success": False, "error": f"Error deleting user: {str(e)}"},
|
{
|
||||||
|
"success": False,
|
||||||
|
"error": f"Error deleting user: {str(e)}",
|
||||||
|
"error_code": "DELETION_ERROR",
|
||||||
|
"help_text": "Please try again or contact system administrator if the problem persists.",
|
||||||
|
},
|
||||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -185,7 +263,7 @@ def request_account_deletion(request):
|
|||||||
account. A verification code will be sent to their email address, and the
|
account. A verification code will be sent to their email address, and the
|
||||||
account will only be deleted after they provide the correct code.
|
account will only be deleted after they provide the correct code.
|
||||||
|
|
||||||
**Authentication Required**: User must be logged in.
|
**Authentication Required**: User must be logged in .
|
||||||
|
|
||||||
**Email Verification**: A verification code is sent to the user's email.
|
**Email Verification**: A verification code is sent to the user's email.
|
||||||
|
|
||||||
@@ -197,6 +275,17 @@ def request_account_deletion(request):
|
|||||||
# Create deletion request and send email
|
# Create deletion request and send email
|
||||||
deletion_request = UserDeletionService.request_user_deletion(user)
|
deletion_request = UserDeletionService.request_user_deletion(user)
|
||||||
|
|
||||||
|
# Log the self-service deletion request
|
||||||
|
logger.info(
|
||||||
|
f"User {user.username} (ID: {user.user_id}) requested account deletion",
|
||||||
|
extra={
|
||||||
|
"user": user.username,
|
||||||
|
"user_id": user.user_id,
|
||||||
|
"email": user.email,
|
||||||
|
"action": "self_deletion_request",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
return Response(
|
return Response(
|
||||||
{
|
{
|
||||||
"success": True,
|
"success": True,
|
||||||
@@ -208,12 +297,65 @@ def request_account_deletion(request):
|
|||||||
)
|
)
|
||||||
|
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
|
# Log the rejection for security monitoring
|
||||||
|
logger.warning(
|
||||||
|
f"User {request.user.username} (ID: {request.user.user_id}) attempted self-deletion but was rejected: {str(e)}",
|
||||||
|
extra={
|
||||||
|
"user": request.user.username,
|
||||||
|
"user_id": request.user.user_id,
|
||||||
|
"is_superuser": request.user.is_superuser,
|
||||||
|
"user_role": request.user.role,
|
||||||
|
"rejection_reason": str(e),
|
||||||
|
"action": "self_deletion_rejected",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Determine error code based on reason
|
||||||
|
error_message = str(e)
|
||||||
|
error_code = "DELETION_FORBIDDEN"
|
||||||
|
if "superuser" in error_message.lower():
|
||||||
|
error_code = "SUPERUSER_DELETION_FORBIDDEN"
|
||||||
|
elif "admin" in error_message.lower():
|
||||||
|
error_code = "ADMIN_DELETION_FORBIDDEN"
|
||||||
|
elif "system" in error_message.lower():
|
||||||
|
error_code = "SYSTEM_USER_DELETION_FORBIDDEN"
|
||||||
|
|
||||||
return Response(
|
return Response(
|
||||||
{"success": False, "error": str(e)}, status=status.HTTP_400_BAD_REQUEST
|
{
|
||||||
|
"success": False,
|
||||||
|
"error": error_message,
|
||||||
|
"error_code": error_code,
|
||||||
|
"user_info": {
|
||||||
|
"username": request.user.username,
|
||||||
|
"user_id": request.user.user_id,
|
||||||
|
"role": request.user.role,
|
||||||
|
"is_superuser": request.user.is_superuser,
|
||||||
|
"is_staff": request.user.is_staff,
|
||||||
|
},
|
||||||
|
"help_text": "Superuser and admin accounts cannot be self-deleted for security reasons. Contact system administrator if you need to delete this account.",
|
||||||
|
},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
# Log the error for debugging
|
||||||
|
logger.error(
|
||||||
|
f"Error creating deletion request for user {request.user.username} (ID: {request.user.user_id}): {str(e)}",
|
||||||
|
extra={
|
||||||
|
"user": request.user.username,
|
||||||
|
"user_id": request.user.user_id,
|
||||||
|
"error": str(e),
|
||||||
|
"action": "self_deletion_error",
|
||||||
|
},
|
||||||
|
exc_info=True
|
||||||
|
)
|
||||||
|
|
||||||
return Response(
|
return Response(
|
||||||
{"success": False, "error": f"Error creating deletion request: {str(e)}"},
|
{
|
||||||
|
"success": False,
|
||||||
|
"error": f"Error creating deletion request: {str(e)}",
|
||||||
|
"error_code": "DELETION_REQUEST_ERROR",
|
||||||
|
"help_text": "Please try again or contact support if the problem persists.",
|
||||||
|
},
|
||||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1279,7 +1421,7 @@ def get_user_notifications(request):
|
|||||||
unread_count = UserNotification.objects.filter(user=user, is_read=False).count()
|
unread_count = UserNotification.objects.filter(user=user, is_read=False).count()
|
||||||
|
|
||||||
# Apply pagination
|
# Apply pagination
|
||||||
notifications = queryset[offset : offset + limit]
|
notifications = queryset[offset: offset + limit]
|
||||||
|
|
||||||
# Build pagination URLs
|
# Build pagination URLs
|
||||||
request_url = request.build_absolute_uri().split("?")[0]
|
request_url = request.build_absolute_uri().split("?")[0]
|
||||||
@@ -1517,11 +1659,13 @@ def upload_avatar(request):
|
|||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
print(f"Upload avatar - Error saving to profile: {e}")
|
||||||
return Response(
|
return Response(
|
||||||
{"success": False, "error": f"Failed to upload avatar: {str(e)}"},
|
{"success": False, "error": f"Failed to upload avatar: {str(e)}"},
|
||||||
status=status.HTTP_400_BAD_REQUEST,
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
print(f"Upload avatar - Serializer errors: {serializer.errors}")
|
||||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,33 +1,3 @@
|
|||||||
from django.db import models
|
# This file is intentionally empty.
|
||||||
from django.conf import settings
|
# All models are now in their appropriate apps to avoid conflicts.
|
||||||
from django.utils import timezone
|
# PasswordReset model is available in apps.accounts.models
|
||||||
|
|
||||||
|
|
||||||
class PasswordReset(models.Model):
|
|
||||||
"""Persisted password reset tokens for API-driven password resets."""
|
|
||||||
|
|
||||||
user = models.ForeignKey(
|
|
||||||
settings.AUTH_USER_MODEL,
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
related_name="password_resets",
|
|
||||||
)
|
|
||||||
token = models.CharField(max_length=128, unique=True, db_index=True)
|
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
|
||||||
expires_at = models.DateTimeField()
|
|
||||||
used = models.BooleanField(default=False)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
ordering = ["-created_at"]
|
|
||||||
verbose_name = "Password Reset"
|
|
||||||
verbose_name_plural = "Password Resets"
|
|
||||||
|
|
||||||
def is_expired(self) -> bool:
|
|
||||||
return timezone.now() > self.expires_at
|
|
||||||
|
|
||||||
def mark_used(self) -> None:
|
|
||||||
self.used = True
|
|
||||||
self.save(update_fields=["used"])
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
user_id = getattr(self, "user_id", None)
|
|
||||||
return f"PasswordReset(user={user_id}, token={self.token[:8]}..., used={self.used})"
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ from django.utils.crypto import get_random_string
|
|||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from .models import PasswordReset
|
from apps.accounts.models import PasswordReset
|
||||||
|
|
||||||
|
|
||||||
UserModel = get_user_model()
|
UserModel = get_user_model()
|
||||||
@@ -62,8 +62,7 @@ class ModelChoices:
|
|||||||
"id": 1,
|
"id": 1,
|
||||||
"username": "john_doe",
|
"username": "john_doe",
|
||||||
"email": "john@example.com",
|
"email": "john@example.com",
|
||||||
"first_name": "John",
|
"display_name": "John Doe",
|
||||||
"last_name": "Doe",
|
|
||||||
"date_joined": "2024-01-01T12:00:00Z",
|
"date_joined": "2024-01-01T12:00:00Z",
|
||||||
"is_active": True,
|
"is_active": True,
|
||||||
"avatar_url": "https://example.com/avatars/john.jpg",
|
"avatar_url": "https://example.com/avatars/john.jpg",
|
||||||
@@ -83,12 +82,10 @@ class UserOutputSerializer(serializers.ModelSerializer):
|
|||||||
"id",
|
"id",
|
||||||
"username",
|
"username",
|
||||||
"email",
|
"email",
|
||||||
"first_name",
|
"display_name",
|
||||||
"last_name",
|
|
||||||
"date_joined",
|
"date_joined",
|
||||||
"is_active",
|
"is_active",
|
||||||
"avatar_url",
|
"avatar_url",
|
||||||
"display_name",
|
|
||||||
]
|
]
|
||||||
read_only_fields = ["id", "date_joined", "is_active"]
|
read_only_fields = ["id", "date_joined", "is_active"]
|
||||||
|
|
||||||
@@ -127,7 +124,8 @@ class LoginInputSerializer(serializers.Serializer):
|
|||||||
class LoginOutputSerializer(serializers.Serializer):
|
class LoginOutputSerializer(serializers.Serializer):
|
||||||
"""Output serializer for successful login."""
|
"""Output serializer for successful login."""
|
||||||
|
|
||||||
token = serializers.CharField()
|
access = serializers.CharField()
|
||||||
|
refresh = serializers.CharField()
|
||||||
user = UserOutputSerializer()
|
user = UserOutputSerializer()
|
||||||
message = serializers.CharField()
|
message = serializers.CharField()
|
||||||
|
|
||||||
@@ -149,14 +147,14 @@ class SignupInputSerializer(serializers.ModelSerializer):
|
|||||||
fields = [
|
fields = [
|
||||||
"username",
|
"username",
|
||||||
"email",
|
"email",
|
||||||
"first_name",
|
"display_name",
|
||||||
"last_name",
|
|
||||||
"password",
|
"password",
|
||||||
"password_confirm",
|
"password_confirm",
|
||||||
]
|
]
|
||||||
extra_kwargs = {
|
extra_kwargs = {
|
||||||
"password": {"write_only": True},
|
"password": {"write_only": True},
|
||||||
"email": {"required": True},
|
"email": {"required": True},
|
||||||
|
"display_name": {"required": True},
|
||||||
}
|
}
|
||||||
|
|
||||||
def validate_email(self, value):
|
def validate_email(self, value):
|
||||||
@@ -202,7 +200,8 @@ class SignupInputSerializer(serializers.ModelSerializer):
|
|||||||
class SignupOutputSerializer(serializers.Serializer):
|
class SignupOutputSerializer(serializers.Serializer):
|
||||||
"""Output serializer for successful signup."""
|
"""Output serializer for successful signup."""
|
||||||
|
|
||||||
token = serializers.CharField()
|
access = serializers.CharField()
|
||||||
|
refresh = serializers.CharField()
|
||||||
user = UserOutputSerializer()
|
user = UserOutputSerializer()
|
||||||
message = serializers.CharField()
|
message = serializers.CharField()
|
||||||
|
|
||||||
|
|||||||
30
backend/apps/api/v1/auth/serializers/__init__.py
Normal file
30
backend/apps/api/v1/auth/serializers/__init__.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
"""
|
||||||
|
Auth Serializers Package
|
||||||
|
|
||||||
|
This package contains all authentication-related serializers including
|
||||||
|
login, signup, logout, password management, and social authentication.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .social import (
|
||||||
|
ConnectedProviderSerializer,
|
||||||
|
AvailableProviderSerializer,
|
||||||
|
SocialAuthStatusSerializer,
|
||||||
|
ConnectProviderInputSerializer,
|
||||||
|
ConnectProviderOutputSerializer,
|
||||||
|
DisconnectProviderOutputSerializer,
|
||||||
|
SocialProviderListOutputSerializer,
|
||||||
|
ConnectedProvidersListOutputSerializer,
|
||||||
|
SocialProviderErrorSerializer,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
'ConnectedProviderSerializer',
|
||||||
|
'AvailableProviderSerializer',
|
||||||
|
'SocialAuthStatusSerializer',
|
||||||
|
'ConnectProviderInputSerializer',
|
||||||
|
'ConnectProviderOutputSerializer',
|
||||||
|
'DisconnectProviderOutputSerializer',
|
||||||
|
'SocialProviderListOutputSerializer',
|
||||||
|
'ConnectedProvidersListOutputSerializer',
|
||||||
|
'SocialProviderErrorSerializer',
|
||||||
|
]
|
||||||
201
backend/apps/api/v1/auth/serializers/social.py
Normal file
201
backend/apps/api/v1/auth/serializers/social.py
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
"""
|
||||||
|
Social Provider Management Serializers
|
||||||
|
|
||||||
|
Serializers for handling social provider connection/disconnection requests
|
||||||
|
and responses in the ThrillWiki API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from rest_framework import serializers
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from typing import Dict, List
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectedProviderSerializer(serializers.Serializer):
|
||||||
|
"""Serializer for connected social provider information."""
|
||||||
|
|
||||||
|
provider = serializers.CharField(
|
||||||
|
help_text="Provider ID (e.g., 'google', 'discord')"
|
||||||
|
)
|
||||||
|
provider_name = serializers.CharField(
|
||||||
|
help_text="Human-readable provider name"
|
||||||
|
)
|
||||||
|
uid = serializers.CharField(
|
||||||
|
help_text="User ID on the social provider"
|
||||||
|
)
|
||||||
|
date_joined = serializers.DateTimeField(
|
||||||
|
help_text="When this provider was connected"
|
||||||
|
)
|
||||||
|
can_disconnect = serializers.BooleanField(
|
||||||
|
help_text="Whether this provider can be safely disconnected"
|
||||||
|
)
|
||||||
|
disconnect_reason = serializers.CharField(
|
||||||
|
allow_null=True,
|
||||||
|
required=False,
|
||||||
|
help_text="Reason why provider cannot be disconnected (if applicable)"
|
||||||
|
)
|
||||||
|
extra_data = serializers.JSONField(
|
||||||
|
required=False,
|
||||||
|
help_text="Additional data from the social provider"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AvailableProviderSerializer(serializers.Serializer):
|
||||||
|
"""Serializer for available social provider information."""
|
||||||
|
|
||||||
|
id = serializers.CharField(
|
||||||
|
help_text="Provider ID (e.g., 'google', 'discord')"
|
||||||
|
)
|
||||||
|
name = serializers.CharField(
|
||||||
|
help_text="Human-readable provider name"
|
||||||
|
)
|
||||||
|
auth_url = serializers.URLField(
|
||||||
|
help_text="URL to initiate authentication with this provider"
|
||||||
|
)
|
||||||
|
connect_url = serializers.URLField(
|
||||||
|
help_text="API URL to connect this provider"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SocialAuthStatusSerializer(serializers.Serializer):
|
||||||
|
"""Serializer for comprehensive social authentication status."""
|
||||||
|
|
||||||
|
user_id = serializers.IntegerField(
|
||||||
|
help_text="User's ID"
|
||||||
|
)
|
||||||
|
username = serializers.CharField(
|
||||||
|
help_text="User's username"
|
||||||
|
)
|
||||||
|
email = serializers.EmailField(
|
||||||
|
help_text="User's email address"
|
||||||
|
)
|
||||||
|
has_password_auth = serializers.BooleanField(
|
||||||
|
help_text="Whether user has email/password authentication set up"
|
||||||
|
)
|
||||||
|
connected_providers = ConnectedProviderSerializer(
|
||||||
|
many=True,
|
||||||
|
help_text="List of connected social providers"
|
||||||
|
)
|
||||||
|
total_auth_methods = serializers.IntegerField(
|
||||||
|
help_text="Total number of authentication methods available"
|
||||||
|
)
|
||||||
|
can_disconnect_any = serializers.BooleanField(
|
||||||
|
help_text="Whether user can safely disconnect any provider"
|
||||||
|
)
|
||||||
|
requires_password_setup = serializers.BooleanField(
|
||||||
|
help_text="Whether user needs to set up password before disconnecting"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectProviderInputSerializer(serializers.Serializer):
|
||||||
|
"""Serializer for social provider connection requests."""
|
||||||
|
|
||||||
|
provider = serializers.CharField(
|
||||||
|
help_text="Provider ID to connect (e.g., 'google', 'discord')"
|
||||||
|
)
|
||||||
|
|
||||||
|
def validate_provider(self, value):
|
||||||
|
"""Validate that the provider is supported and configured."""
|
||||||
|
from apps.accounts.services.social_provider_service import SocialProviderService
|
||||||
|
|
||||||
|
is_valid, message = SocialProviderService.validate_provider_exists(value)
|
||||||
|
if not is_valid:
|
||||||
|
raise serializers.ValidationError(message)
|
||||||
|
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectProviderOutputSerializer(serializers.Serializer):
|
||||||
|
"""Serializer for social provider connection responses."""
|
||||||
|
|
||||||
|
success = serializers.BooleanField(
|
||||||
|
help_text="Whether the connection was successful"
|
||||||
|
)
|
||||||
|
message = serializers.CharField(
|
||||||
|
help_text="Success or error message"
|
||||||
|
)
|
||||||
|
provider = serializers.CharField(
|
||||||
|
help_text="Provider that was connected"
|
||||||
|
)
|
||||||
|
auth_url = serializers.URLField(
|
||||||
|
required=False,
|
||||||
|
help_text="URL to complete the connection process"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DisconnectProviderOutputSerializer(serializers.Serializer):
|
||||||
|
"""Serializer for social provider disconnection responses."""
|
||||||
|
|
||||||
|
success = serializers.BooleanField(
|
||||||
|
help_text="Whether the disconnection was successful"
|
||||||
|
)
|
||||||
|
message = serializers.CharField(
|
||||||
|
help_text="Success or error message"
|
||||||
|
)
|
||||||
|
provider = serializers.CharField(
|
||||||
|
help_text="Provider that was disconnected"
|
||||||
|
)
|
||||||
|
remaining_providers = serializers.ListField(
|
||||||
|
child=serializers.CharField(),
|
||||||
|
help_text="List of remaining connected providers"
|
||||||
|
)
|
||||||
|
has_password_auth = serializers.BooleanField(
|
||||||
|
help_text="Whether user still has password authentication"
|
||||||
|
)
|
||||||
|
suggestions = serializers.ListField(
|
||||||
|
child=serializers.CharField(),
|
||||||
|
required=False,
|
||||||
|
help_text="Suggestions for maintaining account access (if applicable)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SocialProviderListOutputSerializer(serializers.Serializer):
|
||||||
|
"""Serializer for listing available social providers."""
|
||||||
|
|
||||||
|
available_providers = AvailableProviderSerializer(
|
||||||
|
many=True,
|
||||||
|
help_text="List of available social providers"
|
||||||
|
)
|
||||||
|
count = serializers.IntegerField(
|
||||||
|
help_text="Number of available providers"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectedProvidersListOutputSerializer(serializers.Serializer):
|
||||||
|
"""Serializer for listing connected social providers."""
|
||||||
|
|
||||||
|
connected_providers = ConnectedProviderSerializer(
|
||||||
|
many=True,
|
||||||
|
help_text="List of connected social providers"
|
||||||
|
)
|
||||||
|
count = serializers.IntegerField(
|
||||||
|
help_text="Number of connected providers"
|
||||||
|
)
|
||||||
|
has_password_auth = serializers.BooleanField(
|
||||||
|
help_text="Whether user has password authentication"
|
||||||
|
)
|
||||||
|
can_disconnect_any = serializers.BooleanField(
|
||||||
|
help_text="Whether user can safely disconnect any provider"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SocialProviderErrorSerializer(serializers.Serializer):
|
||||||
|
"""Serializer for social provider error responses."""
|
||||||
|
|
||||||
|
error = serializers.CharField(
|
||||||
|
help_text="Error message"
|
||||||
|
)
|
||||||
|
code = serializers.CharField(
|
||||||
|
required=False,
|
||||||
|
help_text="Error code for programmatic handling"
|
||||||
|
)
|
||||||
|
suggestions = serializers.ListField(
|
||||||
|
child=serializers.CharField(),
|
||||||
|
required=False,
|
||||||
|
help_text="Suggestions for resolving the error"
|
||||||
|
)
|
||||||
|
provider = serializers.CharField(
|
||||||
|
required=False,
|
||||||
|
help_text="Provider related to the error (if applicable)"
|
||||||
|
)
|
||||||
@@ -5,31 +5,84 @@ This module contains URL patterns for core authentication functionality only.
|
|||||||
User profiles and top lists are handled by the dedicated accounts app.
|
User profiles and top lists are handled by the dedicated accounts app.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from django.urls import path
|
from django.urls import path, include
|
||||||
from . import views
|
from .views import (
|
||||||
|
# Main auth views
|
||||||
|
LoginAPIView,
|
||||||
|
SignupAPIView,
|
||||||
|
LogoutAPIView,
|
||||||
|
CurrentUserAPIView,
|
||||||
|
PasswordResetAPIView,
|
||||||
|
PasswordChangeAPIView,
|
||||||
|
SocialProvidersAPIView,
|
||||||
|
AuthStatusAPIView,
|
||||||
|
# Social provider management views
|
||||||
|
AvailableProvidersAPIView,
|
||||||
|
ConnectedProvidersAPIView,
|
||||||
|
ConnectProviderAPIView,
|
||||||
|
DisconnectProviderAPIView,
|
||||||
|
SocialAuthStatusAPIView,
|
||||||
|
)
|
||||||
|
from rest_framework_simplejwt.views import TokenRefreshView
|
||||||
|
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
# Core authentication endpoints
|
# Core authentication endpoints
|
||||||
path("login/", views.LoginAPIView.as_view(), name="auth-login"),
|
path("login/", LoginAPIView.as_view(), name="auth-login"),
|
||||||
path("signup/", views.SignupAPIView.as_view(), name="auth-signup"),
|
path("signup/", SignupAPIView.as_view(), name="auth-signup"),
|
||||||
path("logout/", views.LogoutAPIView.as_view(), name="auth-logout"),
|
path("logout/", LogoutAPIView.as_view(), name="auth-logout"),
|
||||||
path("user/", views.CurrentUserAPIView.as_view(), name="auth-current-user"),
|
path("user/", CurrentUserAPIView.as_view(), name="auth-current-user"),
|
||||||
|
|
||||||
|
# JWT token management
|
||||||
|
path("token/refresh/", TokenRefreshView.as_view(), name="auth-token-refresh"),
|
||||||
|
|
||||||
|
# Social authentication endpoints (dj-rest-auth)
|
||||||
|
path("social/", include("dj_rest_auth.registration.urls")),
|
||||||
|
|
||||||
path(
|
path(
|
||||||
"password/reset/",
|
"password/reset/",
|
||||||
views.PasswordResetAPIView.as_view(),
|
PasswordResetAPIView.as_view(),
|
||||||
name="auth-password-reset",
|
name="auth-password-reset",
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
"password/change/",
|
"password/change/",
|
||||||
views.PasswordChangeAPIView.as_view(),
|
PasswordChangeAPIView.as_view(),
|
||||||
name="auth-password-change",
|
name="auth-password-change",
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
"social/providers/",
|
"social/providers/",
|
||||||
views.SocialProvidersAPIView.as_view(),
|
SocialProvidersAPIView.as_view(),
|
||||||
name="auth-social-providers",
|
name="auth-social-providers",
|
||||||
),
|
),
|
||||||
path("status/", views.AuthStatusAPIView.as_view(), name="auth-status"),
|
|
||||||
|
# Social provider management endpoints
|
||||||
|
path(
|
||||||
|
"social/providers/available/",
|
||||||
|
AvailableProvidersAPIView.as_view(),
|
||||||
|
name="auth-social-providers-available",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"social/connected/",
|
||||||
|
ConnectedProvidersAPIView.as_view(),
|
||||||
|
name="auth-social-connected",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"social/connect/<str:provider>/",
|
||||||
|
ConnectProviderAPIView.as_view(),
|
||||||
|
name="auth-social-connect",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"social/disconnect/<str:provider>/",
|
||||||
|
DisconnectProviderAPIView.as_view(),
|
||||||
|
name="auth-social-disconnect",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"social/status/",
|
||||||
|
SocialAuthStatusAPIView.as_view(),
|
||||||
|
name="auth-social-status",
|
||||||
|
),
|
||||||
|
|
||||||
|
path("status/", AuthStatusAPIView.as_view(), name="auth-status"),
|
||||||
]
|
]
|
||||||
|
|
||||||
# Note: User profiles and top lists functionality is now handled by the accounts app
|
# Note: User profiles and top lists functionality is now handled by the accounts app
|
||||||
|
|||||||
@@ -6,6 +6,16 @@ login, signup, logout, password management, social authentication,
|
|||||||
user profiles, and top lists.
|
user profiles, and top lists.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from .serializers.social import (
|
||||||
|
ConnectedProviderSerializer,
|
||||||
|
AvailableProviderSerializer,
|
||||||
|
SocialAuthStatusSerializer,
|
||||||
|
ConnectProviderInputSerializer,
|
||||||
|
ConnectProviderOutputSerializer,
|
||||||
|
DisconnectProviderOutputSerializer,
|
||||||
|
SocialProviderErrorSerializer,
|
||||||
|
)
|
||||||
|
from apps.accounts.services.social_provider_service import SocialProviderService
|
||||||
from django.contrib.auth import authenticate, login, logout, get_user_model
|
from django.contrib.auth import authenticate, login, logout, get_user_model
|
||||||
from django.contrib.sites.shortcuts import get_current_site
|
from django.contrib.sites.shortcuts import get_current_site
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
@@ -19,7 +29,8 @@ from rest_framework.response import Response
|
|||||||
from rest_framework.permissions import AllowAny, IsAuthenticated
|
from rest_framework.permissions import AllowAny, IsAuthenticated
|
||||||
from drf_spectacular.utils import extend_schema, extend_schema_view
|
from drf_spectacular.utils import extend_schema, extend_schema_view
|
||||||
|
|
||||||
from .serializers import (
|
# Import from the main serializers.py file (not the serializers package)
|
||||||
|
from ..serializers import (
|
||||||
# Authentication serializers
|
# Authentication serializers
|
||||||
LoginInputSerializer,
|
LoginInputSerializer,
|
||||||
LoginOutputSerializer,
|
LoginOutputSerializer,
|
||||||
@@ -168,13 +179,17 @@ class LoginAPIView(APIView):
|
|||||||
if getattr(user, "is_active", False):
|
if getattr(user, "is_active", False):
|
||||||
# pass a real HttpRequest to Django login
|
# pass a real HttpRequest to Django login
|
||||||
login(_get_underlying_request(request), user)
|
login(_get_underlying_request(request), user)
|
||||||
from rest_framework.authtoken.models import Token
|
|
||||||
|
|
||||||
token, _ = Token.objects.get_or_create(user=user)
|
# Generate JWT tokens
|
||||||
|
from rest_framework_simplejwt.tokens import RefreshToken
|
||||||
|
|
||||||
|
refresh = RefreshToken.for_user(user)
|
||||||
|
access_token = refresh.access_token
|
||||||
|
|
||||||
response_serializer = LoginOutputSerializer(
|
response_serializer = LoginOutputSerializer(
|
||||||
{
|
{
|
||||||
"token": token.key,
|
"access": str(access_token),
|
||||||
|
"refresh": str(refresh),
|
||||||
"user": user,
|
"user": user,
|
||||||
"message": "Login successful",
|
"message": "Login successful",
|
||||||
}
|
}
|
||||||
@@ -228,13 +243,17 @@ class SignupAPIView(APIView):
|
|||||||
user = serializer.save()
|
user = serializer.save()
|
||||||
# pass a real HttpRequest to Django login
|
# pass a real HttpRequest to Django login
|
||||||
login(_get_underlying_request(request), user) # type: ignore[arg-type]
|
login(_get_underlying_request(request), user) # type: ignore[arg-type]
|
||||||
from rest_framework.authtoken.models import Token
|
|
||||||
|
|
||||||
token, _ = Token.objects.get_or_create(user=user)
|
# Generate JWT tokens
|
||||||
|
from rest_framework_simplejwt.tokens import RefreshToken
|
||||||
|
|
||||||
|
refresh = RefreshToken.for_user(user)
|
||||||
|
access_token = refresh.access_token
|
||||||
|
|
||||||
response_serializer = SignupOutputSerializer(
|
response_serializer = SignupOutputSerializer(
|
||||||
{
|
{
|
||||||
"token": token.key,
|
"access": str(access_token),
|
||||||
|
"refresh": str(refresh),
|
||||||
"user": user,
|
"user": user,
|
||||||
"message": "Registration successful",
|
"message": "Registration successful",
|
||||||
}
|
}
|
||||||
@@ -247,7 +266,7 @@ class SignupAPIView(APIView):
|
|||||||
@extend_schema_view(
|
@extend_schema_view(
|
||||||
post=extend_schema(
|
post=extend_schema(
|
||||||
summary="User logout",
|
summary="User logout",
|
||||||
description="Logout the current user and invalidate their token.",
|
description="Logout the current user and blacklist their refresh token.",
|
||||||
responses={
|
responses={
|
||||||
200: LogoutOutputSerializer,
|
200: LogoutOutputSerializer,
|
||||||
401: "Unauthorized",
|
401: "Unauthorized",
|
||||||
@@ -263,7 +282,26 @@ class LogoutAPIView(APIView):
|
|||||||
|
|
||||||
def post(self, request: Request) -> Response:
|
def post(self, request: Request) -> Response:
|
||||||
try:
|
try:
|
||||||
# Delete the token for token-based auth
|
# Get refresh token from request data with proper type handling
|
||||||
|
refresh_token = None
|
||||||
|
if hasattr(request, 'data') and request.data is not None:
|
||||||
|
data = getattr(request, 'data', {})
|
||||||
|
if hasattr(data, 'get'):
|
||||||
|
refresh_token = data.get("refresh")
|
||||||
|
|
||||||
|
if refresh_token and isinstance(refresh_token, str):
|
||||||
|
# Blacklist the refresh token
|
||||||
|
from rest_framework_simplejwt.tokens import RefreshToken
|
||||||
|
try:
|
||||||
|
# Create RefreshToken from string and blacklist it
|
||||||
|
refresh_token_obj = RefreshToken(
|
||||||
|
refresh_token) # type: ignore[arg-type]
|
||||||
|
refresh_token_obj.blacklist()
|
||||||
|
except Exception:
|
||||||
|
# Token might be invalid or already blacklisted
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Also delete the old token for backward compatibility
|
||||||
if hasattr(request.user, "auth_token"):
|
if hasattr(request.user, "auth_token"):
|
||||||
request.user.auth_token.delete()
|
request.user.auth_token.delete()
|
||||||
|
|
||||||
@@ -464,6 +502,236 @@ class AuthStatusAPIView(APIView):
|
|||||||
return Response(serializer.data)
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
|
||||||
|
# === SOCIAL PROVIDER MANAGEMENT API VIEWS ===
|
||||||
|
|
||||||
|
|
||||||
|
@extend_schema_view(
|
||||||
|
get=extend_schema(
|
||||||
|
summary="Get available social providers",
|
||||||
|
description="Retrieve list of available social authentication providers.",
|
||||||
|
responses={
|
||||||
|
200: AvailableProviderSerializer(many=True),
|
||||||
|
},
|
||||||
|
tags=["Social Authentication"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
class AvailableProvidersAPIView(APIView):
|
||||||
|
"""API endpoint to get available social providers."""
|
||||||
|
|
||||||
|
permission_classes = [AllowAny]
|
||||||
|
serializer_class = AvailableProviderSerializer
|
||||||
|
|
||||||
|
def get(self, request: Request) -> Response:
|
||||||
|
providers = [
|
||||||
|
{
|
||||||
|
"provider": "google",
|
||||||
|
"name": "Google",
|
||||||
|
"login_url": "/auth/social/google/",
|
||||||
|
"connect_url": "/auth/social/connect/google/",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"provider": "discord",
|
||||||
|
"name": "Discord",
|
||||||
|
"login_url": "/auth/social/discord/",
|
||||||
|
"connect_url": "/auth/social/connect/discord/",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
serializer = AvailableProviderSerializer(providers, many=True)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
|
||||||
|
@extend_schema_view(
|
||||||
|
get=extend_schema(
|
||||||
|
summary="Get connected social providers",
|
||||||
|
description="Retrieve list of social providers connected to the user's account.",
|
||||||
|
responses={
|
||||||
|
200: ConnectedProviderSerializer(many=True),
|
||||||
|
401: "Unauthorized",
|
||||||
|
},
|
||||||
|
tags=["Social Authentication"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
class ConnectedProvidersAPIView(APIView):
|
||||||
|
"""API endpoint to get user's connected social providers."""
|
||||||
|
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
serializer_class = ConnectedProviderSerializer
|
||||||
|
|
||||||
|
def get(self, request: Request) -> Response:
|
||||||
|
service = SocialProviderService()
|
||||||
|
providers = service.get_connected_providers(request.user)
|
||||||
|
|
||||||
|
serializer = ConnectedProviderSerializer(providers, many=True)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
|
||||||
|
@extend_schema_view(
|
||||||
|
post=extend_schema(
|
||||||
|
summary="Connect social provider",
|
||||||
|
description="Connect a social authentication provider to the user's account.",
|
||||||
|
request=ConnectProviderInputSerializer,
|
||||||
|
responses={
|
||||||
|
200: ConnectProviderOutputSerializer,
|
||||||
|
400: SocialProviderErrorSerializer,
|
||||||
|
401: "Unauthorized",
|
||||||
|
},
|
||||||
|
tags=["Social Authentication"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
class ConnectProviderAPIView(APIView):
|
||||||
|
"""API endpoint to connect a social provider."""
|
||||||
|
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
serializer_class = ConnectProviderInputSerializer
|
||||||
|
|
||||||
|
def post(self, request: Request, provider: str) -> Response:
|
||||||
|
# Validate provider
|
||||||
|
if provider not in ['google', 'discord']:
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"success": False,
|
||||||
|
"error": "INVALID_PROVIDER",
|
||||||
|
"message": f"Provider '{provider}' is not supported",
|
||||||
|
"suggestions": ["Use 'google' or 'discord'"]
|
||||||
|
},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
serializer = ConnectProviderInputSerializer(data=request.data)
|
||||||
|
if not serializer.is_valid():
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"success": False,
|
||||||
|
"error": "VALIDATION_ERROR",
|
||||||
|
"message": "Invalid request data",
|
||||||
|
"details": serializer.errors,
|
||||||
|
"suggestions": ["Provide a valid access_token"]
|
||||||
|
},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
access_token = serializer.validated_data['access_token']
|
||||||
|
|
||||||
|
try:
|
||||||
|
service = SocialProviderService()
|
||||||
|
result = service.connect_provider(request.user, provider, access_token)
|
||||||
|
|
||||||
|
response_serializer = ConnectProviderOutputSerializer(result)
|
||||||
|
return Response(response_serializer.data)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"success": False,
|
||||||
|
"error": "CONNECTION_FAILED",
|
||||||
|
"message": str(e),
|
||||||
|
"suggestions": [
|
||||||
|
"Verify the access token is valid",
|
||||||
|
"Ensure the provider account is not already connected to another user"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@extend_schema_view(
|
||||||
|
post=extend_schema(
|
||||||
|
summary="Disconnect social provider",
|
||||||
|
description="Disconnect a social authentication provider from the user's account.",
|
||||||
|
responses={
|
||||||
|
200: DisconnectProviderOutputSerializer,
|
||||||
|
400: SocialProviderErrorSerializer,
|
||||||
|
401: "Unauthorized",
|
||||||
|
},
|
||||||
|
tags=["Social Authentication"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
class DisconnectProviderAPIView(APIView):
|
||||||
|
"""API endpoint to disconnect a social provider."""
|
||||||
|
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
serializer_class = DisconnectProviderOutputSerializer
|
||||||
|
|
||||||
|
def post(self, request: Request, provider: str) -> Response:
|
||||||
|
# Validate provider
|
||||||
|
if provider not in ['google', 'discord']:
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"success": False,
|
||||||
|
"error": "INVALID_PROVIDER",
|
||||||
|
"message": f"Provider '{provider}' is not supported",
|
||||||
|
"suggestions": ["Use 'google' or 'discord'"]
|
||||||
|
},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
service = SocialProviderService()
|
||||||
|
|
||||||
|
# Check if disconnection is safe
|
||||||
|
can_disconnect, reason = service.can_disconnect_provider(
|
||||||
|
request.user, provider)
|
||||||
|
if not can_disconnect:
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"success": False,
|
||||||
|
"error": "UNSAFE_DISCONNECTION",
|
||||||
|
"message": reason,
|
||||||
|
"suggestions": [
|
||||||
|
"Set up email/password authentication before disconnecting",
|
||||||
|
"Connect another social provider before disconnecting this one"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
# Perform disconnection
|
||||||
|
result = service.disconnect_provider(request.user, provider)
|
||||||
|
|
||||||
|
response_serializer = DisconnectProviderOutputSerializer(result)
|
||||||
|
return Response(response_serializer.data)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"success": False,
|
||||||
|
"error": "DISCONNECTION_FAILED",
|
||||||
|
"message": str(e),
|
||||||
|
"suggestions": [
|
||||||
|
"Verify the provider is currently connected",
|
||||||
|
"Ensure you have alternative authentication methods"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@extend_schema_view(
|
||||||
|
get=extend_schema(
|
||||||
|
summary="Get social authentication status",
|
||||||
|
description="Get comprehensive social authentication status for the user.",
|
||||||
|
responses={
|
||||||
|
200: SocialAuthStatusSerializer,
|
||||||
|
401: "Unauthorized",
|
||||||
|
},
|
||||||
|
tags=["Social Authentication"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
class SocialAuthStatusAPIView(APIView):
|
||||||
|
"""API endpoint to get social authentication status."""
|
||||||
|
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
serializer_class = SocialAuthStatusSerializer
|
||||||
|
|
||||||
|
def get(self, request: Request) -> Response:
|
||||||
|
service = SocialProviderService()
|
||||||
|
auth_status = service.get_auth_status(request.user)
|
||||||
|
|
||||||
|
serializer = SocialAuthStatusSerializer(auth_status)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
|
||||||
# Note: User Profile, Top List, and Top List Item ViewSets are now handled
|
# Note: User Profile, Top List, and Top List Item ViewSets are now handled
|
||||||
# by the dedicated accounts app at backend/apps/api/v1/accounts/views.py
|
# by the dedicated accounts app at backend/apps/api/v1/accounts/views.py
|
||||||
# to avoid duplication and maintain clean separation of concerns.
|
# to avoid duplication and maintain clean separation of concerns.
|
||||||
|
|||||||
@@ -857,17 +857,54 @@ class MarkNotificationsReadSerializer(serializers.Serializer):
|
|||||||
)
|
)
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
class AvatarUploadSerializer(serializers.ModelSerializer):
|
class AvatarUploadSerializer(serializers.Serializer):
|
||||||
"""Serializer for uploading user avatar."""
|
"""Serializer for uploading user avatar."""
|
||||||
|
|
||||||
class Meta:
|
# Use FileField instead of ImageField to bypass Django's image validation
|
||||||
model = UserProfile
|
avatar = serializers.FileField()
|
||||||
fields = ["avatar"]
|
|
||||||
|
|
||||||
def validate_avatar(self, value):
|
def validate_avatar(self, value):
|
||||||
"""Validate avatar file."""
|
"""Validate avatar file."""
|
||||||
if value:
|
if not value:
|
||||||
# Add any avatar-specific validation here
|
raise serializers.ValidationError("No file provided")
|
||||||
# The CloudflareImagesField will handle the upload
|
|
||||||
|
# Check file size constraints (max 10MB for Cloudflare Images)
|
||||||
|
if hasattr(value, 'size') and value.size > 10 * 1024 * 1024:
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
"Image file too large. Maximum size is 10MB.")
|
||||||
|
|
||||||
|
# Try to validate with PIL
|
||||||
|
try:
|
||||||
|
from PIL import Image
|
||||||
|
import io
|
||||||
|
|
||||||
|
value.seek(0)
|
||||||
|
image_data = value.read()
|
||||||
|
value.seek(0) # Reset for later use
|
||||||
|
|
||||||
|
if len(image_data) == 0:
|
||||||
|
raise serializers.ValidationError("File appears to be empty")
|
||||||
|
|
||||||
|
# Try to open with PIL
|
||||||
|
image = Image.open(io.BytesIO(image_data))
|
||||||
|
|
||||||
|
# Verify it's a valid image
|
||||||
|
image.verify()
|
||||||
|
|
||||||
|
# Check image dimensions (max 12,000x12,000 for Cloudflare Images)
|
||||||
|
if image.size[0] > 12000 or image.size[1] > 12000:
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
"Image dimensions too large. Maximum is 12,000x12,000 pixels.")
|
||||||
|
|
||||||
|
# Check if it's a supported format
|
||||||
|
if image.format not in ['JPEG', 'PNG', 'GIF', 'WEBP']:
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
f"Unsupported image format: {image.format}. Supported formats: JPEG, PNG, GIF, WebP.")
|
||||||
|
|
||||||
|
except serializers.ValidationError:
|
||||||
|
raise # Re-raise validation errors
|
||||||
|
except Exception as e:
|
||||||
|
# PIL validation failed, but let Cloudflare Images try to process it
|
||||||
pass
|
pass
|
||||||
|
|
||||||
return value
|
return value
|
||||||
|
|||||||
@@ -6,15 +6,8 @@ and DRF Router patterns for automatic URL generation.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from .viewsets_rankings import RideRankingViewSet, TriggerRankingCalculationView
|
from .viewsets_rankings import RideRankingViewSet, TriggerRankingCalculationView
|
||||||
|
# Import other views from the views directory
|
||||||
from .views import (
|
from .views import (
|
||||||
LoginAPIView,
|
|
||||||
SignupAPIView,
|
|
||||||
LogoutAPIView,
|
|
||||||
CurrentUserAPIView,
|
|
||||||
PasswordResetAPIView,
|
|
||||||
PasswordChangeAPIView,
|
|
||||||
SocialProvidersAPIView,
|
|
||||||
AuthStatusAPIView,
|
|
||||||
HealthCheckAPIView,
|
HealthCheckAPIView,
|
||||||
PerformanceMetricsAPIView,
|
PerformanceMetricsAPIView,
|
||||||
SimpleHealthAPIView,
|
SimpleHealthAPIView,
|
||||||
@@ -40,16 +33,7 @@ urlpatterns = [
|
|||||||
# API Documentation endpoints are handled by main Django URLs
|
# API Documentation endpoints are handled by main Django URLs
|
||||||
# See backend/thrillwiki/urls.py for documentation endpoints
|
# See backend/thrillwiki/urls.py for documentation endpoints
|
||||||
# Authentication endpoints
|
# Authentication endpoints
|
||||||
path("auth/login/", LoginAPIView.as_view(), name="login"),
|
path("auth/", include("apps.api.v1.auth.urls")),
|
||||||
path("auth/signup/", SignupAPIView.as_view(), name="signup"),
|
|
||||||
path("auth/logout/", LogoutAPIView.as_view(), name="logout"),
|
|
||||||
path("auth/user/", CurrentUserAPIView.as_view(), name="current-user"),
|
|
||||||
path("auth/password/reset/", PasswordResetAPIView.as_view(), name="password-reset"),
|
|
||||||
path(
|
|
||||||
"auth/password/change/", PasswordChangeAPIView.as_view(), name="password-change"
|
|
||||||
),
|
|
||||||
path("auth/providers/", SocialProvidersAPIView.as_view(), name="social-providers"),
|
|
||||||
path("auth/status/", AuthStatusAPIView.as_view(), name="auth-status"),
|
|
||||||
# Health check endpoints
|
# Health check endpoints
|
||||||
path("health/", HealthCheckAPIView.as_view(), name="health-check"),
|
path("health/", HealthCheckAPIView.as_view(), name="health-check"),
|
||||||
path("health/simple/", SimpleHealthAPIView.as_view(), name="simple-health"),
|
path("health/simple/", SimpleHealthAPIView.as_view(), name="simple-health"),
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
default_app_config = "apps.core.apps.CoreConfig"
|
||||||
|
|||||||
@@ -1030,8 +1030,7 @@ class Command(BaseCommand):
|
|||||||
username="testuser",
|
username="testuser",
|
||||||
defaults={
|
defaults={
|
||||||
"email": "test@example.com",
|
"email": "test@example.com",
|
||||||
"first_name": "Test",
|
"display_name": "Test User",
|
||||||
"last_name": "User",
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
if created:
|
if created:
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ Base Django settings for thrillwiki project.
|
|||||||
Common settings shared across all environments.
|
Common settings shared across all environments.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from decouple import config
|
from decouple import config
|
||||||
@@ -64,7 +65,12 @@ DJANGO_APPS = [
|
|||||||
|
|
||||||
THIRD_PARTY_APPS = [
|
THIRD_PARTY_APPS = [
|
||||||
"rest_framework", # Django REST Framework
|
"rest_framework", # Django REST Framework
|
||||||
"rest_framework.authtoken", # Token authentication
|
# Token authentication (kept for backward compatibility)
|
||||||
|
"rest_framework.authtoken",
|
||||||
|
"rest_framework_simplejwt", # JWT authentication
|
||||||
|
"rest_framework_simplejwt.token_blacklist", # JWT token blacklist
|
||||||
|
"dj_rest_auth", # REST authentication with JWT support
|
||||||
|
"dj_rest_auth.registration", # REST registration support
|
||||||
"drf_spectacular", # OpenAPI 3.0 documentation
|
"drf_spectacular", # OpenAPI 3.0 documentation
|
||||||
"corsheaders", # CORS headers for API
|
"corsheaders", # CORS headers for API
|
||||||
"pghistory", # django-pghistory
|
"pghistory", # django-pghistory
|
||||||
@@ -180,9 +186,9 @@ STORAGES = {
|
|||||||
CLOUDFLARE_IMAGES_ACCOUNT_ID = config("CLOUDFLARE_IMAGES_ACCOUNT_ID")
|
CLOUDFLARE_IMAGES_ACCOUNT_ID = config("CLOUDFLARE_IMAGES_ACCOUNT_ID")
|
||||||
CLOUDFLARE_IMAGES_API_TOKEN = config("CLOUDFLARE_IMAGES_API_TOKEN")
|
CLOUDFLARE_IMAGES_API_TOKEN = config("CLOUDFLARE_IMAGES_API_TOKEN")
|
||||||
CLOUDFLARE_IMAGES_ACCOUNT_HASH = config("CLOUDFLARE_IMAGES_ACCOUNT_HASH")
|
CLOUDFLARE_IMAGES_ACCOUNT_HASH = config("CLOUDFLARE_IMAGES_ACCOUNT_HASH")
|
||||||
CLOUDFLARE_IMAGES_DOMAIN = config(
|
# CLOUDFLARE_IMAGES_DOMAIN should only be set if using a custom domain
|
||||||
"CLOUDFLARE_IMAGES_DOMAIN", default="imagedelivery.net"
|
# When not set, it defaults to imagedelivery.net with the correct URL format
|
||||||
)
|
# CLOUDFLARE_IMAGES_DOMAIN = config("CLOUDFLARE_IMAGES_DOMAIN", default=None)
|
||||||
|
|
||||||
# Password validation
|
# Password validation
|
||||||
AUTH_PASSWORD_VALIDATORS = [
|
AUTH_PASSWORD_VALIDATORS = [
|
||||||
@@ -228,7 +234,11 @@ AUTHENTICATION_BACKENDS = [
|
|||||||
|
|
||||||
# django-allauth settings
|
# django-allauth settings
|
||||||
SITE_ID = 1
|
SITE_ID = 1
|
||||||
|
|
||||||
|
# CORRECTED: Django allauth still expects the old format with asterisks for required fields
|
||||||
|
# The deprecation warnings are from dj_rest_auth, not our configuration
|
||||||
ACCOUNT_SIGNUP_FIELDS = ["email*", "username*", "password1*", "password2*"]
|
ACCOUNT_SIGNUP_FIELDS = ["email*", "username*", "password1*", "password2*"]
|
||||||
|
|
||||||
ACCOUNT_LOGIN_METHODS = {"email", "username"}
|
ACCOUNT_LOGIN_METHODS = {"email", "username"}
|
||||||
ACCOUNT_EMAIL_VERIFICATION = "mandatory"
|
ACCOUNT_EMAIL_VERIFICATION = "mandatory"
|
||||||
ACCOUNT_EMAIL_VERIFICATION_SUPPORTS_CHANGE = True
|
ACCOUNT_EMAIL_VERIFICATION_SUPPORTS_CHANGE = True
|
||||||
@@ -292,8 +302,9 @@ FRONTEND_DOMAIN = config("FRONTEND_DOMAIN", default="https://thrillwiki.com")
|
|||||||
# Django REST Framework Settings
|
# Django REST Framework Settings
|
||||||
REST_FRAMEWORK = {
|
REST_FRAMEWORK = {
|
||||||
"DEFAULT_AUTHENTICATION_CLASSES": [
|
"DEFAULT_AUTHENTICATION_CLASSES": [
|
||||||
|
"rest_framework_simplejwt.authentication.JWTAuthentication",
|
||||||
"rest_framework.authentication.SessionAuthentication",
|
"rest_framework.authentication.SessionAuthentication",
|
||||||
"rest_framework.authentication.TokenAuthentication",
|
"rest_framework.authentication.TokenAuthentication", # Kept for backward compatibility
|
||||||
],
|
],
|
||||||
"DEFAULT_PERMISSION_CLASSES": [
|
"DEFAULT_PERMISSION_CLASSES": [
|
||||||
"rest_framework.permissions.IsAuthenticated",
|
"rest_framework.permissions.IsAuthenticated",
|
||||||
@@ -443,3 +454,44 @@ SESSION_COOKIE_AGE = 86400 # 24 hours
|
|||||||
# Cache middleware settings
|
# Cache middleware settings
|
||||||
CACHE_MIDDLEWARE_SECONDS = 300 # 5 minutes
|
CACHE_MIDDLEWARE_SECONDS = 300 # 5 minutes
|
||||||
CACHE_MIDDLEWARE_KEY_PREFIX = "thrillwiki"
|
CACHE_MIDDLEWARE_KEY_PREFIX = "thrillwiki"
|
||||||
|
|
||||||
|
# JWT Settings
|
||||||
|
|
||||||
|
SIMPLE_JWT = {
|
||||||
|
"ACCESS_TOKEN_LIFETIME": timedelta(minutes=60), # 1 hour
|
||||||
|
"REFRESH_TOKEN_LIFETIME": timedelta(days=7), # 7 days
|
||||||
|
"ROTATE_REFRESH_TOKENS": True,
|
||||||
|
"BLACKLIST_AFTER_ROTATION": True,
|
||||||
|
"UPDATE_LAST_LOGIN": True,
|
||||||
|
"ALGORITHM": "HS256",
|
||||||
|
"SIGNING_KEY": SECRET_KEY,
|
||||||
|
"VERIFYING_KEY": None,
|
||||||
|
"AUDIENCE": None,
|
||||||
|
"ISSUER": None,
|
||||||
|
"JWK_URL": None,
|
||||||
|
"LEEWAY": 0,
|
||||||
|
"AUTH_HEADER_TYPES": ("Bearer",),
|
||||||
|
"AUTH_HEADER_NAME": "HTTP_AUTHORIZATION",
|
||||||
|
"USER_ID_FIELD": "id",
|
||||||
|
"USER_ID_CLAIM": "user_id",
|
||||||
|
"USER_AUTHENTICATION_RULE": "rest_framework_simplejwt.authentication.default_user_authentication_rule",
|
||||||
|
"AUTH_TOKEN_CLASSES": ("rest_framework_simplejwt.tokens.AccessToken",),
|
||||||
|
"TOKEN_TYPE_CLAIM": "token_type",
|
||||||
|
"TOKEN_USER_CLASS": "rest_framework_simplejwt.models.TokenUser",
|
||||||
|
"JTI_CLAIM": "jti",
|
||||||
|
"SLIDING_TOKEN_REFRESH_EXP_CLAIM": "refresh_exp",
|
||||||
|
"SLIDING_TOKEN_LIFETIME": timedelta(minutes=60),
|
||||||
|
"SLIDING_TOKEN_REFRESH_LIFETIME": timedelta(days=1),
|
||||||
|
}
|
||||||
|
|
||||||
|
# dj-rest-auth settings
|
||||||
|
REST_AUTH = {
|
||||||
|
"USE_JWT": True,
|
||||||
|
"JWT_AUTH_COOKIE": "thrillwiki-auth",
|
||||||
|
"JWT_AUTH_REFRESH_COOKIE": "thrillwiki-refresh",
|
||||||
|
"JWT_AUTH_SECURE": not DEBUG, # Use secure cookies in production
|
||||||
|
"JWT_AUTH_HTTPONLY": True,
|
||||||
|
"JWT_AUTH_SAMESITE": "Lax",
|
||||||
|
"JWT_AUTH_RETURN_EXPIRATION": True,
|
||||||
|
"JWT_TOKEN_CLAIMS_SERIALIZER": "rest_framework_simplejwt.serializers.TokenObtainPairSerializer",
|
||||||
|
}
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ dependencies = [
|
|||||||
"celery>=5.5.3",
|
"celery>=5.5.3",
|
||||||
"django-celery-beat>=2.8.1",
|
"django-celery-beat>=2.8.1",
|
||||||
"django-celery-results>=2.6.0",
|
"django-celery-results>=2.6.0",
|
||||||
|
"djangorestframework-simplejwt>=5.5.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
|
|||||||
1
backend/test_avatar.txt
Normal file
1
backend/test_avatar.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Testing image file validation
|
||||||
16
backend/uv.lock
generated
16
backend/uv.lock
generated
@@ -857,6 +857,20 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/b0/ce/bf8b9d3f415be4ac5588545b5fcdbbb841977db1c1d923f7568eeabe1689/djangorestframework-3.16.1-py3-none-any.whl", hash = "sha256:33a59f47fb9c85ede792cbf88bde71893bcda0667bc573f784649521f1102cec", size = 1080442, upload-time = "2025-08-06T17:50:50.667Z" },
|
{ url = "https://files.pythonhosted.org/packages/b0/ce/bf8b9d3f415be4ac5588545b5fcdbbb841977db1c1d923f7568eeabe1689/djangorestframework-3.16.1-py3-none-any.whl", hash = "sha256:33a59f47fb9c85ede792cbf88bde71893bcda0667bc573f784649521f1102cec", size = 1080442, upload-time = "2025-08-06T17:50:50.667Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "djangorestframework-simplejwt"
|
||||||
|
version = "5.5.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "django" },
|
||||||
|
{ name = "djangorestframework" },
|
||||||
|
{ name = "pyjwt" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/a8/27/2874a325c11112066139769f7794afae238a07ce6adf96259f08fd37a9d7/djangorestframework_simplejwt-5.5.1.tar.gz", hash = "sha256:e72c5572f51d7803021288e2057afcbd03f17fe11d484096f40a460abc76e87f", size = 101265, upload-time = "2025-07-21T16:52:25.026Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/60/94/fdfb7b2f0b16cd3ed4d4171c55c1c07a2d1e3b106c5978c8ad0c15b4a48b/djangorestframework_simplejwt-5.5.1-py3-none-any.whl", hash = "sha256:2c30f3707053d384e9f315d11c2daccfcb548d4faa453111ca19a542b732e469", size = 107674, upload-time = "2025-07-21T16:52:07.493Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "drf-spectacular"
|
name = "drf-spectacular"
|
||||||
version = "0.28.0"
|
version = "0.28.0"
|
||||||
@@ -2170,6 +2184,7 @@ dependencies = [
|
|||||||
{ name = "django-webpack-loader" },
|
{ name = "django-webpack-loader" },
|
||||||
{ name = "django-widget-tweaks" },
|
{ name = "django-widget-tweaks" },
|
||||||
{ name = "djangorestframework" },
|
{ name = "djangorestframework" },
|
||||||
|
{ name = "djangorestframework-simplejwt" },
|
||||||
{ name = "drf-spectacular" },
|
{ name = "drf-spectacular" },
|
||||||
{ name = "factory-boy" },
|
{ name = "factory-boy" },
|
||||||
{ name = "flake8" },
|
{ name = "flake8" },
|
||||||
@@ -2239,6 +2254,7 @@ requires-dist = [
|
|||||||
{ name = "django-webpack-loader", specifier = ">=3.1.1" },
|
{ name = "django-webpack-loader", specifier = ">=3.1.1" },
|
||||||
{ name = "django-widget-tweaks", specifier = ">=1.5.0" },
|
{ name = "django-widget-tweaks", specifier = ">=1.5.0" },
|
||||||
{ name = "djangorestframework", specifier = ">=3.14.0" },
|
{ name = "djangorestframework", specifier = ">=3.14.0" },
|
||||||
|
{ name = "djangorestframework-simplejwt", specifier = ">=5.5.1" },
|
||||||
{ name = "drf-spectacular", specifier = ">=0.27.0" },
|
{ name = "drf-spectacular", specifier = ">=0.27.0" },
|
||||||
{ name = "factory-boy", specifier = ">=3.3.3" },
|
{ name = "factory-boy", specifier = ">=3.3.3" },
|
||||||
{ name = "flake8", specifier = ">=7.1.1" },
|
{ name = "flake8", specifier = ">=7.1.1" },
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
c# Active Context
|
c# Active Context
|
||||||
|
|
||||||
## Current Focus
|
## Current Focus
|
||||||
|
- **COMPLETED: dj-rest-auth Deprecation Warning Cleanup**: Successfully removed all custom code and patches created to address third-party deprecation warnings, returning system to original state with only corrected ACCOUNT_SIGNUP_FIELDS configuration
|
||||||
|
- **COMPLETED: Social Provider Management System**: Successfully implemented comprehensive social provider connection/disconnection functionality with safety validation to prevent account lockout
|
||||||
|
- **COMPLETED: Enhanced Superuser Account Deletion Error Handling**: Successfully implemented comprehensive error handling for superuser account deletion requests with detailed logging, security monitoring, and improved user experience
|
||||||
- **COMPLETED: Comprehensive User Model with Settings Endpoints**: Successfully implemented comprehensive user model with extensive settings endpoints covering all aspects of user account management
|
- **COMPLETED: Comprehensive User Model with Settings Endpoints**: Successfully implemented comprehensive user model with extensive settings endpoints covering all aspects of user account management
|
||||||
- **COMPLETED: RideModel API Directory Structure Reorganization**: Successfully reorganized API directory structure to match nested URL organization with mandatory nested file structure
|
- **COMPLETED: RideModel API Directory Structure Reorganization**: Successfully reorganized API directory structure to match nested URL organization with mandatory nested file structure
|
||||||
- **COMPLETED: RideModel API Reorganization**: Successfully reorganized RideModel endpoints from separate top-level `/api/v1/ride-models/` to nested `/api/v1/rides/manufacturers/<manufacturerSlug>/<ridemodelSlug>/` structure
|
- **COMPLETED: RideModel API Reorganization**: Successfully reorganized RideModel endpoints from separate top-level `/api/v1/ride-models/` to nested `/api/v1/rides/manufacturers/<manufacturerSlug>/<ridemodelSlug>/` structure
|
||||||
@@ -36,6 +39,65 @@ c# Active Context
|
|||||||
- **Reviews Latest Endpoint**: Combined park and ride reviews feed, user avatar integration, content snippets, smart truncation, comprehensive user information, public access
|
- **Reviews Latest Endpoint**: Combined park and ride reviews feed, user avatar integration, content snippets, smart truncation, comprehensive user information, public access
|
||||||
|
|
||||||
## Recent Changes
|
## Recent Changes
|
||||||
|
**dj-rest-auth Deprecation Warning Cleanup - COMPLETED:**
|
||||||
|
- **Issue Identified**: Deprecation warnings from dj-rest-auth package about USERNAME_REQUIRED and EMAIL_REQUIRED settings being deprecated in favor of SIGNUP_FIELDS configuration
|
||||||
|
- **Root Cause**: Warnings originate from third-party dj-rest-auth package itself (GitHub Issue #684, PR #686), not from user configuration
|
||||||
|
- **Custom Code Removal**: Successfully removed all custom code and patches created to address the warnings:
|
||||||
|
- **Removed**: `backend/apps/api/v1/auth/serializers/registration.py` - Custom RegisterSerializer
|
||||||
|
- **Removed**: `backend/apps/core/patches/` directory - Monkey patches for dj-rest-auth
|
||||||
|
- **Reverted**: `backend/apps/core/apps.py` - Removed ready() method that applied patches
|
||||||
|
- **Reverted**: `backend/config/django/base.py` - Removed custom REGISTER_SERIALIZER configuration
|
||||||
|
- **Configuration Preserved**: Kept corrected ACCOUNT_SIGNUP_FIELDS format: `["email*", "username*", "password1*", "password2*"]`
|
||||||
|
- **Final State**: System returned to original state with deprecation warnings coming from third-party package as expected
|
||||||
|
- **User Acceptance**: User explicitly requested removal of all custom code with understanding that warnings cannot be eliminated from third-party dependencies
|
||||||
|
- **System Check**: ✅ Django system check passes with warnings now originating from dj-rest-auth package as expected
|
||||||
|
|
||||||
|
**Social Provider Management System - COMPLETED:**
|
||||||
|
- **Service Layer**: Created `SocialProviderService` with comprehensive business logic
|
||||||
|
- Safety validation to prevent account lockout: Only allow removing last provider if another provider is connected OR email/password auth exists
|
||||||
|
- Methods: `can_disconnect_provider()`, `get_connected_providers()`, `disconnect_provider()`, `get_auth_status()`
|
||||||
|
- Critical safety rule implementation with detailed logging and error handling
|
||||||
|
- **API Endpoints**: Complete CRUD operations for social provider management
|
||||||
|
- GET `/auth/social/providers/available/` - List available providers (Google, Discord)
|
||||||
|
- GET `/auth/social/connected/` - List user's connected providers with provider details
|
||||||
|
- POST `/auth/social/connect/<provider>/` - Connect new social provider to account
|
||||||
|
- DELETE `/auth/social/disconnect/<provider>/` - Disconnect provider with safety validation
|
||||||
|
- GET `/auth/social/status/` - Get overall social authentication status and capabilities
|
||||||
|
- **Serializers**: Comprehensive data validation and transformation
|
||||||
|
- `ConnectedProviderSerializer` - Connected provider details with metadata
|
||||||
|
- `AvailableProviderSerializer` - Available provider information
|
||||||
|
- `SocialAuthStatusSerializer` - Overall authentication status
|
||||||
|
- `SocialProviderErrorSerializer` - Detailed error responses with suggestions
|
||||||
|
- Input/output serializers for all connect/disconnect operations
|
||||||
|
- **Safety Validation**: Comprehensive account lockout prevention
|
||||||
|
- Validates remaining authentication methods before allowing disconnection
|
||||||
|
- Checks for other connected social providers
|
||||||
|
- Verifies email/password authentication availability
|
||||||
|
- Detailed error messages with specific suggestions for users
|
||||||
|
- **Error Handling**: Comprehensive error scenarios with specific error codes
|
||||||
|
- `PROVIDER_NOT_CONNECTED` - Attempting to disconnect non-connected provider
|
||||||
|
- `LAST_AUTH_METHOD` - Preventing removal of last authentication method
|
||||||
|
- `PROVIDER_NOT_AVAILABLE` - Invalid provider specified
|
||||||
|
- `CONNECTION_FAILED` - Social provider connection failures
|
||||||
|
- **Files Created/Modified**:
|
||||||
|
- `backend/apps/accounts/services/social_provider_service.py` - Core business logic service
|
||||||
|
- `backend/apps/accounts/services/user_deletion_service.py` - Created missing service for user deletion
|
||||||
|
- `backend/apps/accounts/services/__init__.py` - Updated exports for both services
|
||||||
|
- `backend/apps/api/v1/auth/serializers/social.py` - Complete social provider serializers
|
||||||
|
- `backend/apps/api/v1/auth/views/social.py` - Social provider API views
|
||||||
|
- `backend/apps/api/v1/auth/urls.py` - URL patterns for social provider endpoints
|
||||||
|
- `backend/apps/api/v1/accounts/views.py` - Fixed UserDeletionService import
|
||||||
|
- `docs/frontend.md` - Complete API documentation with React examples
|
||||||
|
- `docs/types-api.ts` - TypeScript interfaces for social provider management
|
||||||
|
- `docs/lib-api.ts` - API functions for social provider operations
|
||||||
|
- **Django Integration**: Full integration with Django Allauth
|
||||||
|
- Works with existing Google and Discord social providers
|
||||||
|
- Maintains JWT authentication alongside social auth
|
||||||
|
- Proper user account linking and unlinking
|
||||||
|
- Session management and security considerations
|
||||||
|
- **Testing**: ✅ Django system check passes with no issues
|
||||||
|
- **Import Resolution**: ✅ All import issues resolved, UserDeletionService created and properly exported
|
||||||
|
|
||||||
**Comprehensive User Model with Settings Endpoints - COMPLETED:**
|
**Comprehensive User Model with Settings Endpoints - COMPLETED:**
|
||||||
- **Extended User Model**: Added 20+ new fields to User model including privacy settings, notification preferences, security settings, and detailed user preferences
|
- **Extended User Model**: Added 20+ new fields to User model including privacy settings, notification preferences, security settings, and detailed user preferences
|
||||||
- **Database Migrations**: Successfully applied migrations for new User model fields with proper defaults
|
- **Database Migrations**: Successfully applied migrations for new User model fields with proper defaults
|
||||||
@@ -250,6 +312,18 @@ c# Active Context
|
|||||||
- `backend/apps/api/v1/accounts/urls.py` - URL patterns for all new user settings endpoints
|
- `backend/apps/api/v1/accounts/urls.py` - URL patterns for all new user settings endpoints
|
||||||
- `docs/frontend.md` - Complete API documentation with TypeScript interfaces and usage examples
|
- `docs/frontend.md` - Complete API documentation with TypeScript interfaces and usage examples
|
||||||
|
|
||||||
|
### Social Provider Management Files
|
||||||
|
- `backend/apps/accounts/services/social_provider_service.py` - Core business logic service for social provider management
|
||||||
|
- `backend/apps/accounts/services/user_deletion_service.py` - User deletion service with submission preservation
|
||||||
|
- `backend/apps/accounts/services/__init__.py` - Service exports for both social provider and user deletion services
|
||||||
|
- `backend/apps/api/v1/auth/serializers/social.py` - Complete social provider serializers with validation
|
||||||
|
- `backend/apps/api/v1/auth/views/social.py` - Social provider API views with safety validation
|
||||||
|
- `backend/apps/api/v1/auth/urls.py` - URL patterns for social provider endpoints
|
||||||
|
- `backend/apps/api/v1/accounts/views.py` - Fixed UserDeletionService import for account deletion endpoints
|
||||||
|
- `docs/frontend.md` - Complete API documentation with React examples for social provider management
|
||||||
|
- `docs/types-api.ts` - TypeScript interfaces for social provider management
|
||||||
|
- `docs/lib-api.ts` - API functions for social provider operations
|
||||||
|
|
||||||
### Celery Integration Files
|
### Celery Integration Files
|
||||||
- `backend/config/celery.py` - Main Celery configuration with Redis broker
|
- `backend/config/celery.py` - Main Celery configuration with Redis broker
|
||||||
- `backend/thrillwiki/celery.py` - Celery app initialization and task autodiscovery
|
- `backend/thrillwiki/celery.py` - Celery app initialization and task autodiscovery
|
||||||
@@ -369,6 +443,21 @@ c# Active Context
|
|||||||
- **Top Lists**: ✅ Full CRUD operations for user top lists
|
- **Top Lists**: ✅ Full CRUD operations for user top lists
|
||||||
- **Account Deletion**: ✅ Self-service deletion with email verification and submission preservation
|
- **Account Deletion**: ✅ Self-service deletion with email verification and submission preservation
|
||||||
- **Frontend Documentation**: ✅ Complete TypeScript interfaces and usage examples in docs/frontend.md
|
- **Frontend Documentation**: ✅ Complete TypeScript interfaces and usage examples in docs/frontend.md
|
||||||
|
- **Social Provider Management System**: ✅ Successfully implemented and tested
|
||||||
|
- **Service Layer**: ✅ SocialProviderService with comprehensive business logic and safety validation
|
||||||
|
- **Safety Validation**: ✅ Prevents account lockout by validating remaining authentication methods
|
||||||
|
- **API Endpoints**: ✅ Complete CRUD operations for social provider management
|
||||||
|
- GET `/auth/social/providers/available/` - ✅ Lists available providers (Google, Discord)
|
||||||
|
- GET `/auth/social/connected/` - ✅ Lists user's connected providers with details
|
||||||
|
- POST `/auth/social/connect/<provider>/` - ✅ Connects new social provider to account
|
||||||
|
- DELETE `/auth/social/disconnect/<provider>/` - ✅ Disconnects provider with safety validation
|
||||||
|
- GET `/auth/social/status/` - ✅ Returns overall social authentication status
|
||||||
|
- **Error Handling**: ✅ Comprehensive error scenarios with specific error codes and user-friendly messages
|
||||||
|
- **Django Integration**: ✅ Full integration with Django Allauth for Google and Discord providers
|
||||||
|
- **Import Resolution**: ✅ All import issues resolved, UserDeletionService created and properly exported
|
||||||
|
- **System Check**: ✅ Django system check passes with no issues
|
||||||
|
- **Documentation**: ✅ Complete API documentation with React examples and TypeScript types
|
||||||
|
- **Frontend Integration**: ✅ TypeScript interfaces and API functions ready for frontend implementation
|
||||||
- **Reviews Latest Endpoint**: ✅ Successfully implemented and tested
|
- **Reviews Latest Endpoint**: ✅ Successfully implemented and tested
|
||||||
- **Endpoint**: GET `/api/v1/reviews/latest/` - ✅ Returns combined feed of park and ride reviews
|
- **Endpoint**: GET `/api/v1/reviews/latest/` - ✅ Returns combined feed of park and ride reviews
|
||||||
- **Default Behavior**: ✅ Returns 8 reviews with default limit (20)
|
- **Default Behavior**: ✅ Returns 8 reviews with default limit (20)
|
||||||
|
|||||||
107
docs/avatar-upload-debugging.md
Normal file
107
docs/avatar-upload-debugging.md
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
# Avatar Upload Debugging Guide
|
||||||
|
|
||||||
|
Last Updated: 2025-08-29
|
||||||
|
|
||||||
|
## Issue Summary
|
||||||
|
|
||||||
|
The avatar upload functionality was experiencing file corruption during transmission from the frontend to the backend. PNG files were being corrupted where the PNG header `\x89PNG` was being replaced with UTF-8 replacement characters `\xef\xbf\xbdPNG`.
|
||||||
|
|
||||||
|
## Root Cause Analysis
|
||||||
|
|
||||||
|
### Next.js Proxy Issue (ACTUAL ROOT CAUSE)
|
||||||
|
The primary issue was in the Next.js API proxy route that converts binary FormData to text:
|
||||||
|
|
||||||
|
**File:** `/Users/talor/dyad-apps/thrillwiki-real/src/app/api/[...path]/route.ts`
|
||||||
|
|
||||||
|
1. **Problematic code:**
|
||||||
|
```typescript
|
||||||
|
body = await clonedRequest.text(); // This corrupts binary data!
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Why this causes corruption:**
|
||||||
|
- FormData containing binary image files gets converted to UTF-8 text
|
||||||
|
- Binary bytes like `\x89` (PNG signature) become UTF-8 replacement characters `\xef\xbf\xbd`
|
||||||
|
- This mangles the file data before it even reaches the backend
|
||||||
|
|
||||||
|
|
||||||
|
## Solutions Implemented
|
||||||
|
|
||||||
|
### Next.js Proxy Fix (PRIMARY FIX)
|
||||||
|
**File:** `/Users/talor/dyad-apps/thrillwiki-real/src/app/api/[...path]/route.ts`
|
||||||
|
|
||||||
|
Fixed the proxy to preserve binary data instead of converting to text:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// BEFORE (problematic)
|
||||||
|
body = await clonedRequest.text(); // Corrupts binary data!
|
||||||
|
|
||||||
|
// AFTER (fixed)
|
||||||
|
const arrayBuffer = await clonedRequest.arrayBuffer();
|
||||||
|
body = new Uint8Array(arrayBuffer); // Preserves binary data
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why this works:**
|
||||||
|
- `arrayBuffer()` preserves the original binary data
|
||||||
|
- `Uint8Array` maintains the exact byte sequence
|
||||||
|
- No UTF-8 text conversion that corrupts binary files
|
||||||
|
|
||||||
|
### Backend Cleanup
|
||||||
|
**File:** `/Users/talor/thrillwiki_django_no_react/backend/apps/api/v1/serializers/accounts.py`
|
||||||
|
|
||||||
|
Simplified the avatar validation to basic file validation only:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Removed complex corruption repair logic
|
||||||
|
# Now just validates file size, format, and dimensions with PIL
|
||||||
|
def validate_avatar(self, value):
|
||||||
|
# Basic file validation only
|
||||||
|
# Let Cloudflare Images handle the rest
|
||||||
|
```
|
||||||
|
|
||||||
|
## Technical Details
|
||||||
|
|
||||||
|
### API Flow
|
||||||
|
1. **Frontend:** `AvatarUploader` → `useAuth.uploadAvatar()` → `accountApi.uploadAvatar(file)`
|
||||||
|
2. **Next.js Proxy:** Preserves binary data using `arrayBuffer()` instead of `text()`
|
||||||
|
3. **Backend:** `AvatarUploadView` → `AvatarUploadSerializer.validate_avatar()` → Cloudflare Images API
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Manual Testing Steps
|
||||||
|
1. Select a PNG image file in the avatar uploader
|
||||||
|
2. Upload the file
|
||||||
|
3. Verify the upload succeeds without corruption
|
||||||
|
4. Check that the avatar displays correctly
|
||||||
|
|
||||||
|
|
||||||
|
## Prevention
|
||||||
|
|
||||||
|
### Frontend Best Practices
|
||||||
|
- Always pass `FormData` objects directly to API calls
|
||||||
|
- Avoid extracting and re-wrapping files unnecessarily
|
||||||
|
- Use proper file validation on the client side
|
||||||
|
|
||||||
|
### Backend Best Practices
|
||||||
|
- Use Django's proper file upload classes (`InMemoryUploadedFile`, `TemporaryUploadedFile`)
|
||||||
|
- Implement robust file validation and corruption detection
|
||||||
|
- Provide detailed logging for debugging file upload issues
|
||||||
|
|
||||||
|
## Related Files
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
- `/Users/talor/dyad-apps/thrillwiki-real/src/components/profile/avatar-uploader.tsx`
|
||||||
|
- `/Users/talor/dyad-apps/thrillwiki-real/src/hooks/use-auth.tsx`
|
||||||
|
- `/Users/talor/dyad-apps/thrillwiki-real/src/lib/api.ts`
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
- `/Users/talor/thrillwiki_django_no_react/backend/apps/api/v1/serializers/accounts.py`
|
||||||
|
- `/Users/talor/thrillwiki_django_no_react/backend/apps/api/v1/accounts/views.py`
|
||||||
|
- `/Users/talor/thrillwiki_django_no_react/backend/apps/accounts/services.py`
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
✅ **RESOLVED** - File corruption issue fixed in Next.js proxy
|
||||||
|
✅ **CLEANED UP** - Removed unnecessary corruption repair code from backend
|
||||||
|
✅ **DOCUMENTED** - Comprehensive debugging guide created
|
||||||
|
|
||||||
|
The avatar upload functionality should now work correctly without file corruption. The Next.js proxy properly preserves binary data, and the backend performs basic file validation only.
|
||||||
778
docs/frontend.md
778
docs/frontend.md
@@ -6,18 +6,33 @@ This document provides comprehensive documentation for all ThrillWiki API endpoi
|
|||||||
|
|
||||||
## Authentication
|
## Authentication
|
||||||
|
|
||||||
All API requests require authentication via JWT tokens. Include the token in the Authorization header:
|
ThrillWiki uses JWT Bearer token authentication. After successful login or signup, you'll receive access and refresh tokens that must be included in subsequent API requests.
|
||||||
|
|
||||||
|
### Authentication Headers
|
||||||
|
|
||||||
|
Include the access token in the Authorization header using Bearer format:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${token}`,
|
'Authorization': `Bearer ${accessToken}`,
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Token Management
|
||||||
|
|
||||||
|
- **Access Token**: Short-lived token (1 hour) used for API requests
|
||||||
|
- **Refresh Token**: Long-lived token (7 days) used to obtain new access tokens
|
||||||
|
- **Token Rotation**: Refresh tokens are rotated on each refresh for enhanced security
|
||||||
|
|
||||||
## Base URL
|
## Base URL
|
||||||
|
|
||||||
All API endpoints are prefixed with `/api/v1/`
|
The frontend uses a Next.js proxy that routes API requests:
|
||||||
|
- Frontend requests: `/v1/auth/login/`, `/v1/accounts/profile/`, etc.
|
||||||
|
- Proxy adds `/api/` prefix: `/api/v1/auth/login/`, `/api/v1/accounts/profile/`, etc.
|
||||||
|
- Backend receives: `/api/v1/auth/login/`, `/api/v1/accounts/profile/`, etc.
|
||||||
|
|
||||||
|
**Important**: Frontend code should make requests to `/v1/...` endpoints, not `/api/v1/...`
|
||||||
|
|
||||||
## Moderation System API
|
## Moderation System API
|
||||||
|
|
||||||
@@ -271,19 +286,389 @@ The moderation system provides comprehensive content moderation, user management
|
|||||||
|
|
||||||
### Login
|
### Login
|
||||||
- **POST** `/api/v1/auth/login/`
|
- **POST** `/api/v1/auth/login/`
|
||||||
- **Body**: `{ "username": string, "password": string }`
|
- **Body**: `{ "username": string, "password": string, "turnstile_token"?: string }`
|
||||||
- **Returns**: JWT tokens and user data
|
- **Returns**: JWT tokens and user data
|
||||||
|
- **Response**:
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
"access": string,
|
||||||
|
"refresh": string,
|
||||||
|
"user": {
|
||||||
|
"id": number,
|
||||||
|
"username": string,
|
||||||
|
"email": string,
|
||||||
|
"display_name": string,
|
||||||
|
"is_active": boolean,
|
||||||
|
"date_joined": string
|
||||||
|
},
|
||||||
|
"message": string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### Signup
|
### Signup
|
||||||
- **POST** `/api/v1/auth/signup/`
|
- **POST** `/api/v1/auth/signup/`
|
||||||
- **Body**: User registration data
|
- **Body**:
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
"username": string,
|
||||||
|
"email": string,
|
||||||
|
"password": string,
|
||||||
|
"password_confirm": string,
|
||||||
|
"display_name": string, // Required field
|
||||||
|
"turnstile_token"?: string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- **Returns**: JWT tokens and user data
|
||||||
|
- **Response**: Same format as login response (access and refresh tokens)
|
||||||
|
- **Note**: `display_name` is now required during registration. The system no longer uses separate first_name and last_name fields.
|
||||||
|
|
||||||
|
### Token Refresh
|
||||||
|
- **POST** `/api/v1/auth/token/refresh/`
|
||||||
|
- **Body**: `{ "refresh": string }`
|
||||||
|
- **Returns**: New access token and optionally a new refresh token
|
||||||
|
- **Response**:
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
"access": string,
|
||||||
|
"refresh"?: string // Only returned if refresh token rotation is enabled
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Social Authentication
|
||||||
|
|
||||||
|
#### Google Login
|
||||||
|
- **POST** `/api/v1/auth/social/google/`
|
||||||
|
- **Body**: `{ "access_token": string }`
|
||||||
|
- **Returns**: JWT tokens and user data (same format as regular login)
|
||||||
|
- **Note**: The access_token should be obtained from Google OAuth flow
|
||||||
|
|
||||||
|
#### Discord Login
|
||||||
|
- **POST** `/api/v1/auth/social/discord/`
|
||||||
|
- **Body**: `{ "access_token": string }`
|
||||||
|
- **Returns**: JWT tokens and user data (same format as regular login)
|
||||||
|
- **Note**: The access_token should be obtained from Discord OAuth flow
|
||||||
|
|
||||||
|
#### Connect Social Account
|
||||||
|
- **POST** `/api/v1/auth/social/{provider}/connect/`
|
||||||
|
- **Permissions**: Authenticated users only
|
||||||
|
- **Body**: `{ "access_token": string }`
|
||||||
|
- **Returns**: `{ "success": boolean, "message": string }`
|
||||||
|
- **Note**: Links a social account to an existing ThrillWiki account
|
||||||
|
|
||||||
|
#### Disconnect Social Account
|
||||||
|
- **POST** `/api/v1/auth/social/{provider}/disconnect/`
|
||||||
|
- **Permissions**: Authenticated users only
|
||||||
|
- **Returns**: `{ "success": boolean, "message": string }`
|
||||||
|
|
||||||
|
#### Get Social Connections
|
||||||
|
- **GET** `/api/v1/auth/social/connections/`
|
||||||
|
- **Permissions**: Authenticated users only
|
||||||
|
- **Returns**:
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
"google": { "connected": boolean, "email"?: string },
|
||||||
|
"discord": { "connected": boolean, "username"?: string }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Social Provider Management
|
||||||
|
|
||||||
|
#### List Available Providers
|
||||||
|
- **GET** `/api/v1/auth/social/providers/available/`
|
||||||
|
- **Permissions**: Public access
|
||||||
|
- **Returns**: List of available social providers for connection
|
||||||
|
- **Response**:
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
"available_providers": [
|
||||||
|
{
|
||||||
|
"id": "google",
|
||||||
|
"name": "Google",
|
||||||
|
"auth_url": "https://example.com/accounts/google/login/",
|
||||||
|
"connect_url": "https://example.com/api/v1/auth/social/connect/google/"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"count": number
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### List Connected Providers
|
||||||
|
- **GET** `/api/v1/auth/social/connected/`
|
||||||
|
- **Permissions**: Authenticated users only
|
||||||
|
- **Returns**: List of social providers connected to user's account
|
||||||
|
- **Response**:
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
"connected_providers": [
|
||||||
|
{
|
||||||
|
"provider": "google",
|
||||||
|
"provider_name": "Google",
|
||||||
|
"uid": "user_id_on_provider",
|
||||||
|
"date_joined": "2025-01-01T00:00:00Z",
|
||||||
|
"can_disconnect": boolean,
|
||||||
|
"disconnect_reason": string | null,
|
||||||
|
"extra_data": object
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"count": number,
|
||||||
|
"has_password_auth": boolean,
|
||||||
|
"can_disconnect_any": boolean
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Connect Social Provider
|
||||||
|
- **POST** `/api/v1/auth/social/connect/{provider}/`
|
||||||
|
- **Permissions**: Authenticated users only
|
||||||
|
- **Parameters**: `provider` - Provider ID (e.g., 'google', 'discord')
|
||||||
|
- **Returns**: Connection initiation response with auth URL
|
||||||
|
- **Response**:
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
"success": boolean,
|
||||||
|
"message": string,
|
||||||
|
"provider": string,
|
||||||
|
"auth_url": string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- **Error Responses**:
|
||||||
|
- `400`: Provider already connected or invalid provider
|
||||||
|
- `500`: Connection initiation failed
|
||||||
|
|
||||||
|
#### Disconnect Social Provider
|
||||||
|
- **DELETE** `/api/v1/auth/social/disconnect/{provider}/`
|
||||||
|
- **Permissions**: Authenticated users only
|
||||||
|
- **Parameters**: `provider` - Provider ID to disconnect
|
||||||
|
- **Returns**: Disconnection result with safety information
|
||||||
|
- **Response**:
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
"success": boolean,
|
||||||
|
"message": string,
|
||||||
|
"provider": string,
|
||||||
|
"remaining_providers": string[],
|
||||||
|
"has_password_auth": boolean,
|
||||||
|
"suggestions"?: string[]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- **Error Responses**:
|
||||||
|
- `400`: Cannot disconnect (safety validation failed)
|
||||||
|
- `404`: Provider not connected
|
||||||
|
- `500`: Disconnection failed
|
||||||
|
|
||||||
|
#### Get Social Authentication Status
|
||||||
|
- **GET** `/api/v1/auth/social/status/`
|
||||||
|
- **Permissions**: Authenticated users only
|
||||||
|
- **Returns**: Comprehensive social authentication status
|
||||||
|
- **Response**:
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
"user_id": number,
|
||||||
|
"username": string,
|
||||||
|
"email": string,
|
||||||
|
"has_password_auth": boolean,
|
||||||
|
"connected_providers": ConnectedProvider[],
|
||||||
|
"total_auth_methods": number,
|
||||||
|
"can_disconnect_any": boolean,
|
||||||
|
"requires_password_setup": boolean
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Social Provider Safety Rules
|
||||||
|
|
||||||
|
The social provider management system enforces strict safety rules to prevent users from locking themselves out:
|
||||||
|
|
||||||
|
1. **Disconnection Safety**: Users can only disconnect a social provider if they have:
|
||||||
|
- Another social provider connected, OR
|
||||||
|
- Email + password authentication set up
|
||||||
|
|
||||||
|
2. **Error Scenarios**:
|
||||||
|
- **Only Provider + No Password**: "Cannot disconnect your only authentication method. Please set up a password or connect another social provider first."
|
||||||
|
- **No Password Auth**: "Please set up email/password authentication before disconnecting this provider."
|
||||||
|
|
||||||
|
3. **Suggested Actions**: When disconnection is blocked, the API provides suggestions:
|
||||||
|
- "Set up password authentication"
|
||||||
|
- "Connect another social provider"
|
||||||
|
|
||||||
|
### Usage Examples
|
||||||
|
|
||||||
|
#### Check Social Provider Status
|
||||||
|
```typescript
|
||||||
|
const checkSocialStatus = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/v1/auth/social/status/', {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${accessToken}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.requires_password_setup) {
|
||||||
|
// Show password setup prompt
|
||||||
|
showPasswordSetupModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to get social status:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Connect Social Provider
|
||||||
|
```typescript
|
||||||
|
const connectProvider = async (provider: string) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/v1/auth/social/connect/${provider}/`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${accessToken}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
// Redirect to provider auth URL
|
||||||
|
window.location.href = data.auth_url;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to connect provider:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Disconnect Social Provider with Safety Check
|
||||||
|
```typescript
|
||||||
|
const disconnectProvider = async (provider: string) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/v1/auth/social/disconnect/${provider}/`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${accessToken}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 400) {
|
||||||
|
// Show safety warning with suggestions
|
||||||
|
showSafetyWarning(data.error, data.suggestions);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success - update UI
|
||||||
|
toast.success(data.message);
|
||||||
|
refreshConnectedProviders();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to disconnect provider:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
#### React Component Example
|
||||||
|
```typescript
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
interface SocialProvider {
|
||||||
|
provider: string;
|
||||||
|
provider_name: string;
|
||||||
|
can_disconnect: boolean;
|
||||||
|
disconnect_reason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SocialProviderManager: React.FC = () => {
|
||||||
|
const [connectedProviders, setConnectedProviders] = useState<SocialProvider[]>([]);
|
||||||
|
const [hasPasswordAuth, setHasPasswordAuth] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadConnectedProviders();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadConnectedProviders = async () => {
|
||||||
|
const response = await fetch('/api/v1/auth/social/connected/', {
|
||||||
|
headers: { 'Authorization': `Bearer ${accessToken}` }
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
setConnectedProviders(data.connected_providers);
|
||||||
|
setHasPasswordAuth(data.has_password_auth);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDisconnect = async (provider: string) => {
|
||||||
|
const response = await fetch(`/api/v1/auth/social/disconnect/${provider}/`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Authorization': `Bearer ${accessToken}` }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
alert(error.error + '\n\nSuggestions:\n' + error.suggestions?.join('\n'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loadConnectedProviders(); // Refresh list
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3>Connected Social Accounts</h3>
|
||||||
|
|
||||||
|
{!hasPasswordAuth && connectedProviders.length === 1 && (
|
||||||
|
<div className="bg-yellow-50 p-4 rounded-lg">
|
||||||
|
<p className="text-yellow-800">
|
||||||
|
⚠️ Set up a password to safely manage your social connections
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{connectedProviders.map((provider) => (
|
||||||
|
<div key={provider.provider} className="flex items-center justify-between p-4 border rounded">
|
||||||
|
<span>{provider.provider_name}</span>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => handleDisconnect(provider.provider)}
|
||||||
|
disabled={!provider.can_disconnect}
|
||||||
|
className={`px-4 py-2 rounded ${
|
||||||
|
provider.can_disconnect
|
||||||
|
? 'bg-red-600 text-white hover:bg-red-700'
|
||||||
|
: 'bg-gray-300 text-gray-500 cursor-not-allowed'
|
||||||
|
}`}
|
||||||
|
title={provider.disconnect_reason}
|
||||||
|
>
|
||||||
|
Disconnect
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
### Logout
|
### Logout
|
||||||
- **POST** `/api/v1/auth/logout/`
|
- **POST** `/api/v1/auth/logout/`
|
||||||
|
- **Returns**: `{ "message": string }`
|
||||||
|
|
||||||
### Current User
|
### Current User
|
||||||
- **GET** `/api/v1/auth/user/`
|
- **GET** `/api/v1/auth/user/`
|
||||||
- **Returns**: Current user profile data
|
- **Returns**: Current user profile data
|
||||||
|
- **Response**:
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
"id": number,
|
||||||
|
"username": string,
|
||||||
|
"email": string,
|
||||||
|
"display_name": string,
|
||||||
|
"is_active": boolean,
|
||||||
|
"date_joined": string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### Password Reset
|
### Password Reset
|
||||||
- **POST** `/api/v1/auth/password/reset/`
|
- **POST** `/api/v1/auth/password/reset/`
|
||||||
@@ -293,6 +678,121 @@ The moderation system provides comprehensive content moderation, user management
|
|||||||
- **POST** `/api/v1/auth/password/change/`
|
- **POST** `/api/v1/auth/password/change/`
|
||||||
- **Body**: `{ "old_password": string, "new_password": string }`
|
- **Body**: `{ "old_password": string, "new_password": string }`
|
||||||
|
|
||||||
|
## User Account Management API
|
||||||
|
|
||||||
|
### User Profile
|
||||||
|
- **GET** `/api/v1/accounts/profile/`
|
||||||
|
- **Permissions**: Authenticated users only
|
||||||
|
- **Returns**: Complete user profile including account details, preferences, and statistics
|
||||||
|
|
||||||
|
### Update Account
|
||||||
|
- **PATCH** `/api/v1/accounts/profile/account/`
|
||||||
|
- **Permissions**: Authenticated users only
|
||||||
|
- **Body**: `{ "display_name"?: string, "email"?: string }`
|
||||||
|
|
||||||
|
### Update Profile
|
||||||
|
- **PATCH** `/api/v1/accounts/profile/update/`
|
||||||
|
- **Permissions**: Authenticated users only
|
||||||
|
- **Body**: `{ "display_name"?: string, "pronouns"?: string, "bio"?: string, "twitter"?: string, "instagram"?: string, "youtube"?: string, "discord"?: string }`
|
||||||
|
|
||||||
|
### Avatar Upload
|
||||||
|
- **POST** `/api/v1/accounts/profile/avatar/upload/`
|
||||||
|
- **Permissions**: Authenticated users only
|
||||||
|
- **Content-Type**: `multipart/form-data`
|
||||||
|
- **Body**: FormData with `avatar` field containing image file (JPEG, PNG, WebP)
|
||||||
|
- **Returns**:
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
"success": boolean,
|
||||||
|
"message": string,
|
||||||
|
"avatar_url": string,
|
||||||
|
"avatar_variants": {
|
||||||
|
"thumbnail": string, // 64x64
|
||||||
|
"avatar": string, // 200x200
|
||||||
|
"large": string // 400x400
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**⚠️ CRITICAL AUTHENTICATION REQUIREMENT**:
|
||||||
|
- This endpoint requires authentication via JWT token in Authorization header
|
||||||
|
- **Common Issue**: "Authentication credentials were not provided" (401 error)
|
||||||
|
- **Root Cause**: User not logged in or JWT token not being sent
|
||||||
|
- **Debug Steps**: See `docs/avatar-upload-debugging.md` for comprehensive troubleshooting guide
|
||||||
|
|
||||||
|
**Usage Example**:
|
||||||
|
```typescript
|
||||||
|
// Ensure user is logged in first
|
||||||
|
const { user } = useAuth();
|
||||||
|
if (!user) {
|
||||||
|
// Redirect to login
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload avatar
|
||||||
|
const file = event.target.files[0];
|
||||||
|
const response = await accountApi.uploadAvatar(file);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Test Credentials** (for debugging):
|
||||||
|
- Username: `testuser`
|
||||||
|
- Password: `testpass123`
|
||||||
|
- Email: `test@example.com`
|
||||||
|
|
||||||
|
**Recent Fix (2025-08-29)**:
|
||||||
|
- Fixed file corruption issue where PNG headers were being corrupted during upload
|
||||||
|
- Frontend now passes FormData directly instead of extracting and re-wrapping files
|
||||||
|
- Backend includes corruption detection and repair mechanism
|
||||||
|
- See `docs/avatar-upload-debugging.md` for complete technical details
|
||||||
|
|
||||||
|
### Avatar Delete
|
||||||
|
- **DELETE** `/api/v1/accounts/profile/avatar/delete/`
|
||||||
|
- **Permissions**: Authenticated users only
|
||||||
|
- **Returns**: `{ "success": boolean, "message": string, "avatar_url": string }`
|
||||||
|
|
||||||
|
### User Preferences
|
||||||
|
- **GET** `/api/v1/accounts/preferences/`
|
||||||
|
- **PATCH** `/api/v1/accounts/preferences/update/`
|
||||||
|
- **Permissions**: Authenticated users only
|
||||||
|
|
||||||
|
### Notification Settings
|
||||||
|
- **GET** `/api/v1/accounts/settings/notifications/`
|
||||||
|
- **PATCH** `/api/v1/accounts/settings/notifications/update/`
|
||||||
|
- **Permissions**: Authenticated users only
|
||||||
|
|
||||||
|
### Privacy Settings
|
||||||
|
- **GET** `/api/v1/accounts/settings/privacy/`
|
||||||
|
- **PATCH** `/api/v1/accounts/settings/privacy/update/`
|
||||||
|
- **Permissions**: Authenticated users only
|
||||||
|
|
||||||
|
### Security Settings
|
||||||
|
- **GET** `/api/v1/accounts/settings/security/`
|
||||||
|
- **PATCH** `/api/v1/accounts/settings/security/update/`
|
||||||
|
- **Permissions**: Authenticated users only
|
||||||
|
|
||||||
|
### User Statistics
|
||||||
|
- **GET** `/api/v1/accounts/statistics/`
|
||||||
|
- **Permissions**: Authenticated users only
|
||||||
|
- **Returns**: User activity statistics, ride credits, contributions, and achievements
|
||||||
|
|
||||||
|
### Top Lists
|
||||||
|
- **GET** `/api/v1/accounts/top-lists/`
|
||||||
|
- **POST** `/api/v1/accounts/top-lists/create/`
|
||||||
|
- **PATCH** `/api/v1/accounts/top-lists/{id}/`
|
||||||
|
- **DELETE** `/api/v1/accounts/top-lists/{id}/delete/`
|
||||||
|
- **Permissions**: Authenticated users only
|
||||||
|
|
||||||
|
### Notifications
|
||||||
|
- **GET** `/api/v1/accounts/notifications/`
|
||||||
|
- **PATCH** `/api/v1/accounts/notifications/mark-read/`
|
||||||
|
- **Permissions**: Authenticated users only
|
||||||
|
|
||||||
|
### Account Deletion
|
||||||
|
- **POST** `/api/v1/accounts/delete-account/request/`
|
||||||
|
- **POST** `/api/v1/accounts/delete-account/verify/`
|
||||||
|
- **POST** `/api/v1/accounts/delete-account/cancel/`
|
||||||
|
- **Permissions**: Authenticated users only
|
||||||
|
|
||||||
## Statistics API
|
## Statistics API
|
||||||
|
|
||||||
### Global Statistics
|
### Global Statistics
|
||||||
@@ -314,7 +814,9 @@ The moderation system provides comprehensive content moderation, user management
|
|||||||
|
|
||||||
## Error Handling
|
## Error Handling
|
||||||
|
|
||||||
All API endpoints return standardized error responses:
|
All API endpoints return standardized error responses. The system provides enhanced error handling with detailed messages, error codes, and contextual information.
|
||||||
|
|
||||||
|
### Standard Error Response Format
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
interface ApiError {
|
interface ApiError {
|
||||||
@@ -329,12 +831,274 @@ interface ApiError {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Common error codes:
|
### Enhanced Error Response Format
|
||||||
|
|
||||||
|
For critical operations like account deletion, the API returns enhanced error responses with additional context:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface EnhancedApiError {
|
||||||
|
status: "error";
|
||||||
|
error: {
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
error_code: string;
|
||||||
|
user_info?: {
|
||||||
|
username: string;
|
||||||
|
role: string;
|
||||||
|
is_superuser: boolean;
|
||||||
|
is_staff: boolean;
|
||||||
|
};
|
||||||
|
help_text?: string;
|
||||||
|
details?: any;
|
||||||
|
request_user?: string;
|
||||||
|
};
|
||||||
|
data: null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error Handling in React Components
|
||||||
|
|
||||||
|
Here's how to handle and display enhanced error messages in your NextJS components:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { toast } from 'react-hot-toast';
|
||||||
|
|
||||||
|
interface ErrorDisplayProps {
|
||||||
|
error: EnhancedApiError | null;
|
||||||
|
onDismiss: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ErrorDisplay: React.FC<ErrorDisplayProps> = ({ error, onDismiss }) => {
|
||||||
|
if (!error) return null;
|
||||||
|
|
||||||
|
const { error: errorData } = error;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-4">
|
||||||
|
<div className="flex items-start">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<ExclamationTriangleIcon className="h-5 w-5 text-red-400" />
|
||||||
|
</div>
|
||||||
|
<div className="ml-3 flex-1">
|
||||||
|
<h3 className="text-sm font-medium text-red-800">
|
||||||
|
{errorData.message}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{errorData.error_code && (
|
||||||
|
<p className="mt-1 text-xs text-red-600">
|
||||||
|
Error Code: {errorData.error_code}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{errorData.user_info && (
|
||||||
|
<div className="mt-2 text-xs text-red-600">
|
||||||
|
<p>User: {errorData.user_info.username} ({errorData.user_info.role})</p>
|
||||||
|
{errorData.user_info.is_superuser && (
|
||||||
|
<p className="font-medium">⚠️ Superuser Account</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{errorData.help_text && (
|
||||||
|
<div className="mt-3 p-2 bg-red-100 rounded text-xs text-red-700">
|
||||||
|
<strong>Help:</strong> {errorData.help_text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onDismiss}
|
||||||
|
className="ml-3 flex-shrink-0 text-red-400 hover:text-red-600"
|
||||||
|
>
|
||||||
|
<XMarkIcon className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Usage in account deletion component
|
||||||
|
const AccountDeletionForm: React.FC = () => {
|
||||||
|
const [error, setError] = useState<EnhancedApiError | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleDeleteAccount = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.accounts.requestAccountDeletion();
|
||||||
|
toast.success('Account deletion request submitted successfully');
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err.response?.data) {
|
||||||
|
const apiError = err.response.data as EnhancedApiError;
|
||||||
|
setError(apiError);
|
||||||
|
|
||||||
|
// Also show toast for immediate feedback
|
||||||
|
toast.error(apiError.error.message);
|
||||||
|
|
||||||
|
// Log security-related errors for monitoring
|
||||||
|
if (apiError.error.error_code === 'SUPERUSER_DELETION_BLOCKED') {
|
||||||
|
console.warn('Superuser deletion attempt blocked:', {
|
||||||
|
user: apiError.error.user_info?.username,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setError({
|
||||||
|
status: "error",
|
||||||
|
error: {
|
||||||
|
code: "UNKNOWN_ERROR",
|
||||||
|
message: "An unexpected error occurred",
|
||||||
|
error_code: "UNKNOWN_ERROR"
|
||||||
|
},
|
||||||
|
data: null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-md mx-auto">
|
||||||
|
<ErrorDisplay
|
||||||
|
error={error}
|
||||||
|
onDismiss={() => setError(null)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleDeleteAccount}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="w-full bg-red-600 text-white py-2 px-4 rounded hover:bg-red-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isLoading ? 'Processing...' : 'Delete Account'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Toast Notifications for Errors
|
||||||
|
|
||||||
|
For immediate user feedback, combine detailed error displays with toast notifications:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { toast } from 'react-hot-toast';
|
||||||
|
|
||||||
|
const handleApiError = (error: EnhancedApiError) => {
|
||||||
|
const { error: errorData } = error;
|
||||||
|
|
||||||
|
// Show immediate toast
|
||||||
|
toast.error(errorData.message, {
|
||||||
|
duration: 5000,
|
||||||
|
position: 'top-right',
|
||||||
|
});
|
||||||
|
|
||||||
|
// For critical errors, show additional context
|
||||||
|
if (errorData.error_code === 'SUPERUSER_DELETION_BLOCKED') {
|
||||||
|
toast.error('Superuser accounts cannot be deleted for security reasons', {
|
||||||
|
duration: 8000,
|
||||||
|
icon: '🔒',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errorData.error_code === 'ADMIN_DELETION_BLOCKED') {
|
||||||
|
toast.error('Admin accounts with staff privileges cannot be deleted', {
|
||||||
|
duration: 8000,
|
||||||
|
icon: '⚠️',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error Boundary for Global Error Handling
|
||||||
|
|
||||||
|
Create an error boundary to catch and display API errors globally:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface ErrorBoundaryState {
|
||||||
|
hasError: boolean;
|
||||||
|
error: EnhancedApiError | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ApiErrorBoundary extends React.Component<
|
||||||
|
React.PropsWithChildren<{}>,
|
||||||
|
ErrorBoundaryState
|
||||||
|
> {
|
||||||
|
constructor(props: React.PropsWithChildren<{}>) {
|
||||||
|
super(props);
|
||||||
|
this.state = { hasError: false, error: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: any): ErrorBoundaryState {
|
||||||
|
if (error.response?.data?.status === 'error') {
|
||||||
|
return {
|
||||||
|
hasError: true,
|
||||||
|
error: error.response.data as EnhancedApiError
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { hasError: true, error: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.hasError && this.state.error) {
|
||||||
|
return (
|
||||||
|
<ErrorDisplay
|
||||||
|
error={this.state.error}
|
||||||
|
onDismiss={() => this.setState({ hasError: false, error: null })}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Common Error Codes
|
||||||
|
|
||||||
|
The system uses specific error codes for different scenarios:
|
||||||
|
|
||||||
- `NOT_AUTHENTICATED`: User not logged in
|
- `NOT_AUTHENTICATED`: User not logged in
|
||||||
- `PERMISSION_DENIED`: Insufficient permissions
|
- `PERMISSION_DENIED`: Insufficient permissions
|
||||||
- `NOT_FOUND`: Resource not found
|
- `NOT_FOUND`: Resource not found
|
||||||
- `VALIDATION_ERROR`: Invalid request data
|
- `VALIDATION_ERROR`: Invalid request data
|
||||||
- `RATE_LIMITED`: Too many requests
|
- `RATE_LIMITED`: Too many requests
|
||||||
|
- `SUPERUSER_DELETION_BLOCKED`: Superuser account deletion attempt
|
||||||
|
- `ADMIN_DELETION_BLOCKED`: Admin account with staff privileges deletion attempt
|
||||||
|
- `ACCOUNT_DELETION_FAILED`: General account deletion failure
|
||||||
|
- `SECURITY_VIOLATION`: Security policy violation detected
|
||||||
|
|
||||||
|
### Error Logging and Monitoring
|
||||||
|
|
||||||
|
For production applications, implement error logging:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const logError = (error: EnhancedApiError, context: string) => {
|
||||||
|
// Log to your monitoring service (e.g., Sentry, LogRocket)
|
||||||
|
console.error(`API Error in ${context}:`, {
|
||||||
|
code: error.error.code,
|
||||||
|
message: error.error.message,
|
||||||
|
errorCode: error.error.error_code,
|
||||||
|
userInfo: error.error.user_info,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
context
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send to analytics if it's a security-related error
|
||||||
|
if (error.error.error_code?.includes('DELETION_BLOCKED')) {
|
||||||
|
// Track security events
|
||||||
|
analytics.track('Security Event', {
|
||||||
|
event: 'Account Deletion Blocked',
|
||||||
|
errorCode: error.error.error_code,
|
||||||
|
user: error.error.user_info?.username
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
## Pagination
|
## Pagination
|
||||||
|
|
||||||
|
|||||||
2864
docs/lib-api.ts
2864
docs/lib-api.ts
File diff suppressed because it is too large
Load Diff
2887
docs/types-api.ts
2887
docs/types-api.ts
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user