Browse Source
feat(api)!: add account exchange-token endpoint
feat(api)!: add account exchange-token endpoint
Introduce ExchangeTokenAPIView at /api/account/exchange-token/ to exchange temporary tokens for a DRF auth token and minimal user profile (id, fullname, email, avatar). Tokens are one-time use and validated via OnlineClassTokenManager. Added serializer, view, URL, and Swagger examples. Update CourseOnlineClassTokenAPIView to return a fixed join URL: https://imamjavad.newhorizonco.uk/join-class?token={TOKEN}&slug={SLUG}. Store course_slug in token payload and remove redirect_path and the _base_components helper. Examples and docs updated. Docs: add CHANGELOG_EXCHANGE_TOKEN.md and exchange_token_api.md; update online_class_entry_flow.md. BREAKING CHANGE: exchange-token endpoint moved from /api/courses/auth/exchange-token/ to /api/account/exchange-token/. Response shape changed: adds token, user.id is numeric, user.name renamed to user.fullname, user.role and user.is_admin removed. Online token response now returns a fixed URL and includes course slug. redirect_path support removed; clients must use the returned token for subsequent requests.master
9 changed files with 669 additions and 21 deletions
-
1apps/account/serializers/__init__.py
-
11apps/account/serializers/auth.py
-
1apps/account/urls.py
-
1apps/account/views/__init__.py
-
126apps/account/views/auth.py
-
23apps/course/views/course.py
-
140docs/CHANGELOG_EXCHANGE_TOKEN.md
-
379docs/exchange_token_api.md
-
4docs/online_class_entry_flow.md
@ -1,3 +1,4 @@ |
|||
from .user import * |
|||
from .notification import * |
|||
from .location_history import * |
|||
from .auth import * |
|||
@ -0,0 +1,11 @@ |
|||
from rest_framework import serializers |
|||
|
|||
|
|||
class ExchangeTokenSerializer(serializers.Serializer): |
|||
temp_token = serializers.CharField(max_length=128) |
|||
|
|||
def validate_temp_token(self, value: str) -> str: |
|||
value = value.strip() |
|||
if not value: |
|||
raise serializers.ValidationError("temp_token is required.") |
|||
return value |
|||
@ -1,5 +1,6 @@ |
|||
from .user import * |
|||
from .notification import * |
|||
from .location_history import * |
|||
from .auth import * |
|||
|
|||
|
|||
@ -0,0 +1,126 @@ |
|||
import logging |
|||
|
|||
from django.contrib.auth import get_user_model |
|||
|
|||
from drf_yasg import openapi |
|||
from drf_yasg.utils import swagger_auto_schema |
|||
from rest_framework import status |
|||
from rest_framework.authtoken.models import Token |
|||
from rest_framework.generics import GenericAPIView |
|||
from rest_framework.permissions import AllowAny |
|||
from rest_framework.response import Response |
|||
|
|||
from apps.account.serializers import ExchangeTokenSerializer |
|||
from utils import absolute_url |
|||
from utils.redis import OnlineClassTokenManager |
|||
|
|||
|
|||
logger = logging.getLogger(__name__) |
|||
UserModel = get_user_model() |
|||
|
|||
|
|||
class ExchangeTokenAPIView(GenericAPIView): |
|||
""" |
|||
تبدیل temporary token به اطلاعات کاربر برای ورود از اپ موبایل |
|||
""" |
|||
permission_classes = [AllowAny] |
|||
serializer_class = ExchangeTokenSerializer |
|||
|
|||
@swagger_auto_schema( |
|||
operation_description="Exchange temporary token for user information and authentication token.", |
|||
request_body=ExchangeTokenSerializer, |
|||
responses={ |
|||
status.HTTP_200_OK: openapi.Response( |
|||
description="Token exchanged successfully.", |
|||
examples={ |
|||
"application/json": { |
|||
"success": True, |
|||
"message": "ورود موفق", |
|||
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", |
|||
"user": { |
|||
"id": 123, |
|||
"fullname": "علی احمدی", |
|||
"email": "user@example.com", |
|||
"avatar": "https://cdn.example.com/avatar.jpg" |
|||
} |
|||
} |
|||
} |
|||
), |
|||
status.HTTP_400_BAD_REQUEST: openapi.Response( |
|||
description="Invalid request.", |
|||
examples={ |
|||
"application/json": { |
|||
"success": False, |
|||
"message": "توکن ارسال نشده است" |
|||
} |
|||
} |
|||
), |
|||
status.HTTP_404_NOT_FOUND: openapi.Response( |
|||
description="Token not found or expired.", |
|||
examples={ |
|||
"application/json": { |
|||
"success": False, |
|||
"message": "توکن نامعتبر یا منقضی شده است" |
|||
} |
|||
} |
|||
), |
|||
} |
|||
) |
|||
def post(self, request, *args, **kwargs): |
|||
serializer = self.get_serializer(data=request.data) |
|||
serializer.is_valid(raise_exception=True) |
|||
|
|||
temp_token = serializer.validated_data['temp_token'] |
|||
|
|||
# دریافت اطلاعات از Redis/Cache |
|||
manager = OnlineClassTokenManager() |
|||
try: |
|||
token_data = manager.get_payload(temp_token) |
|||
except Exception: |
|||
return Response({ |
|||
'success': False, |
|||
'message': 'توکن نامعتبر یا منقضی شده است' |
|||
}, status=status.HTTP_404_NOT_FOUND) |
|||
|
|||
user_id = token_data.get('user_id') |
|||
if not user_id: |
|||
return Response({ |
|||
'success': False, |
|||
'message': 'توکن نامعتبر است' |
|||
}, status=status.HTTP_400_BAD_REQUEST) |
|||
|
|||
# دریافت کاربر |
|||
try: |
|||
user = UserModel.objects.get(id=user_id) |
|||
except UserModel.DoesNotExist: |
|||
return Response({ |
|||
'success': False, |
|||
'message': 'کاربر یافت نشد' |
|||
}, status=status.HTTP_404_NOT_FOUND) |
|||
|
|||
# حذف توکن موقت (one-time use) |
|||
manager.delete_token(temp_token) |
|||
|
|||
# دریافت یا تولید Token واقعی کاربر |
|||
auth_token, _ = Token.objects.get_or_create(user=user) |
|||
|
|||
# دریافت avatar URL |
|||
avatar_url = None |
|||
if hasattr(user, 'avatar') and user.avatar: |
|||
try: |
|||
avatar_url = absolute_url(user.avatar.url) |
|||
except Exception: |
|||
avatar_url = None |
|||
|
|||
# برگرداندن اطلاعات کاربر با token واقعی |
|||
return Response({ |
|||
'success': True, |
|||
'message': 'ورود موفق', |
|||
'token': auth_token.key, |
|||
'user': { |
|||
'id': user.id, |
|||
'fullname': user.get_full_name() or user.username or '', |
|||
'email': user.email or '', |
|||
'avatar': avatar_url |
|||
} |
|||
}, status=status.HTTP_200_OK) |
|||
@ -0,0 +1,140 @@ |
|||
# تغییرات API Exchange Token |
|||
|
|||
## نسخه جدید (اکتبر 2024) |
|||
|
|||
### تغییرات اساسی |
|||
|
|||
#### 1. انتقال API از `courses` به `account` |
|||
- **قدیمی**: `/api/courses/auth/exchange-token/` |
|||
- **جدید**: `/api/account/exchange-token/` |
|||
|
|||
#### 2. تغییر ساختار Response |
|||
**قدیمی:** |
|||
```json |
|||
{ |
|||
"success": true, |
|||
"message": "ورود موفق", |
|||
"user": { |
|||
"id": "123", |
|||
"email": "user@example.com", |
|||
"name": "علی احمدی", |
|||
"role": "admin", |
|||
"is_admin": true |
|||
} |
|||
} |
|||
``` |
|||
|
|||
**جدید:** |
|||
```json |
|||
{ |
|||
"success": true, |
|||
"message": "ورود موفق", |
|||
"token": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0", |
|||
"user": { |
|||
"id": 123, |
|||
"fullname": "علی احمدی", |
|||
"email": "user@example.com", |
|||
"avatar": "https://cdn.example.com/avatar.jpg" |
|||
} |
|||
} |
|||
``` |
|||
|
|||
#### 3. تغییر URL تولید Token موقت |
|||
**Response قبلی:** |
|||
```json |
|||
{ |
|||
"token": "abc123", |
|||
"url": "https://frontend.example.com/join-class?token=abc123", |
|||
"expires_in": 300 |
|||
} |
|||
``` |
|||
|
|||
**Response جدید:** |
|||
```json |
|||
{ |
|||
"token": "abc123xyz789", |
|||
"url": "https://imamjavad.newhorizonco.uk/join-class?token=abc123xyz789&slug=python-basics", |
|||
"expires_in": 300 |
|||
} |
|||
``` |
|||
|
|||
### تغییرات در فیلدها |
|||
|
|||
| فیلد قدیمی | فیلد جدید | توضیحات | |
|||
|-----------|----------|---------| |
|||
| `user.name` | `user.fullname` | تغییر نام فیلد | |
|||
| `user.id` (string) | `user.id` (number) | تغییر نوع داده | |
|||
| `user.role` | حذف شد | دیگر ارسال نمیشود | |
|||
| `user.is_admin` | حذف شد | دیگر ارسال نمیشود | |
|||
| - | `token` | **اضافه شد**: توکن احراز هویت DRF | |
|||
| - | `user.avatar` | **اضافه شد**: URL تصویر پروفایل | |
|||
|
|||
### URL ثابت |
|||
|
|||
دیگر از تنظیمات یا environment variable استفاده نمیشود. URL ثابت است: |
|||
|
|||
``` |
|||
https://imamjavad.newhorizonco.uk/join-class?token={TEMP_TOKEN}&slug={COURSE_SLUG} |
|||
``` |
|||
|
|||
### مزایای تغییرات |
|||
|
|||
✅ **Token واقعی**: دیگر نیازی به login مجدد نیست |
|||
✅ **Avatar**: نمایش تصویر پروفایل کاربر |
|||
✅ **Slug دوره**: دسترسی مستقیم به اطلاعات دوره |
|||
✅ **URL ثابت**: عدم وابستگی به تنظیمات |
|||
✅ **سادهتر**: حذف فیلدهای اضافی (role, is_admin) |
|||
|
|||
### Migration Guide |
|||
|
|||
#### Frontend (Next.js) |
|||
|
|||
**قدیمی:** |
|||
```typescript |
|||
const user = await api.exchangeToken(tempToken); |
|||
localStorage.setItem('user', JSON.stringify(user)); |
|||
``` |
|||
|
|||
**جدید:** |
|||
```typescript |
|||
const data = await exchangeToken(tempToken); |
|||
localStorage.setItem('authToken', data.token); // ⭐ جدید |
|||
localStorage.setItem('user', JSON.stringify(data.user)); |
|||
``` |
|||
|
|||
#### Flutter |
|||
|
|||
**قدیمی:** |
|||
```dart |
|||
final user = response['user']; |
|||
// نیاز به login مجدد داشت |
|||
``` |
|||
|
|||
**جدید:** |
|||
```dart |
|||
final authToken = response['token']; // ⭐ جدید |
|||
final user = response['user']; |
|||
// ذخیره token برای درخواستهای بعدی |
|||
await storage.write(key: 'authToken', value: authToken); |
|||
``` |
|||
|
|||
### Breaking Changes ⚠️ |
|||
|
|||
1. **URL تغییر کرد**: باید از `/api/account/exchange-token/` استفاده شود |
|||
2. **فیلد `token` اجباری است**: باید در frontend ذخیره و استفاده شود |
|||
3. **فیلد `name` به `fullname` تغییر کرد** |
|||
4. **فیلدهای `role` و `is_admin` حذف شدند** |
|||
5. **URL موقت شامل `slug` میشود** |
|||
|
|||
### تاریخ اعمال تغییرات |
|||
|
|||
تاریخ: **14 اکتبر 2024** |
|||
|
|||
### فایلهای تغییر یافته |
|||
|
|||
1. `apps/account/serializers/auth.py` (جدید) |
|||
2. `apps/account/views/auth.py` (جدید) |
|||
3. `apps/account/urls.py` (آپدیت) |
|||
4. `apps/course/views/course.py` (آپدیت: CourseOnlineClassTokenAPIView) |
|||
5. `docs/exchange_token_api.md` (آپدیت) |
|||
6. `docs/online_class_entry_flow.md` (آپدیت) |
|||
@ -0,0 +1,379 @@ |
|||
# API تبدیل توکن موقت (Exchange Token) |
|||
|
|||
## 📝 توضیحات |
|||
|
|||
این API برای تبدیل توکن موقت (temporary token) به اطلاعات کاربر واقعی استفاده میشود. |
|||
|
|||
--- |
|||
|
|||
## 🔗 Endpoint |
|||
|
|||
``` |
|||
POST /api/account/exchange-token/ |
|||
``` |
|||
|
|||
**دسترسی:** عمومی (AllowAny) |
|||
|
|||
--- |
|||
|
|||
## 📥 Request Body |
|||
|
|||
```json |
|||
{ |
|||
"temp_token": "abc123xyz" |
|||
} |
|||
``` |
|||
|
|||
--- |
|||
|
|||
## 📤 Response |
|||
|
|||
### ✅ موفق (200 OK) |
|||
|
|||
```json |
|||
{ |
|||
"success": true, |
|||
"message": "ورود موفق", |
|||
"token": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0", |
|||
"user": { |
|||
"id": 123, |
|||
"fullname": "علی احمدی", |
|||
"email": "user@example.com", |
|||
"avatar": "https://cdn.example.com/avatar.jpg" |
|||
} |
|||
} |
|||
``` |
|||
|
|||
**نکته:** |
|||
- `token`: توکن احراز هویت واقعی کاربر (DRF Token) که برای درخواستهای بعدی استفاده میشود |
|||
- `avatar`: در صورت عدم وجود، مقدار `null` برگردانده میشود |
|||
|
|||
### ❌ خطا - توکن نامعتبر یا منقضی (404 NOT FOUND) |
|||
|
|||
```json |
|||
{ |
|||
"success": false, |
|||
"message": "توکن نامعتبر یا منقضی شده است" |
|||
} |
|||
``` |
|||
|
|||
### ❌ خطا - کاربر یافت نشد (404 NOT FOUND) |
|||
|
|||
```json |
|||
{ |
|||
"success": false, |
|||
"message": "کاربر یافت نشد" |
|||
} |
|||
``` |
|||
|
|||
### ❌ خطا - توکن ارسال نشده (400 BAD REQUEST) |
|||
|
|||
```json |
|||
{ |
|||
"success": false, |
|||
"message": "توکن نامعتبر است" |
|||
} |
|||
``` |
|||
|
|||
--- |
|||
|
|||
## 🧪 تست با curl |
|||
|
|||
### مرحله 1: تولید توکن موقت |
|||
|
|||
```bash |
|||
curl -X POST http://localhost:8000/api/courses/1/online/token/ \ |
|||
-H "Authorization: Token <USER_BACKEND_TOKEN>" \ |
|||
-H "Content-Type: application/json" |
|||
``` |
|||
|
|||
**Response:** |
|||
```json |
|||
{ |
|||
"token": "abc123xyz789...", |
|||
"url": "https://imamjavad.newhorizonco.uk/join-class?token=abc123xyz789...&slug=python-basics", |
|||
"expires_in": 300 |
|||
} |
|||
``` |
|||
|
|||
**نکته:** URL ثابت است و شامل `token` و `slug` دوره میباشد. |
|||
|
|||
### مرحله 2: تبدیل توکن موقت |
|||
|
|||
```bash |
|||
curl -X POST http://localhost:8000/api/account/exchange-token/ \ |
|||
-H "Content-Type: application/json" \ |
|||
-d '{"temp_token": "abc123xyz"}' |
|||
``` |
|||
|
|||
**Response:** |
|||
```json |
|||
{ |
|||
"success": true, |
|||
"message": "ورود موفق", |
|||
"token": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0", |
|||
"user": { |
|||
"id": 123, |
|||
"fullname": "علی احمدی", |
|||
"email": "user@example.com", |
|||
"avatar": "https://cdn.example.com/avatar.jpg" |
|||
} |
|||
} |
|||
``` |
|||
|
|||
--- |
|||
|
|||
## 🔐 امنیت |
|||
|
|||
1. **One-Time Use**: توکن بعد از استفاده حذف میشود |
|||
2. **TTL**: توکن پس از 300 ثانیه (5 دقیقه) منقضی میشود |
|||
3. **Role Detection**: نقش کاربر (admin/user) بر اساس course و permissions تشخیص داده میشود |
|||
|
|||
--- |
|||
|
|||
## 📋 فایلهای پیادهسازی شده |
|||
|
|||
1. **Serializer**: `/apps/account/serializers/auth.py` |
|||
- اضافه شد: `ExchangeTokenSerializer` |
|||
|
|||
2. **View**: `/apps/account/views/auth.py` |
|||
- اضافه شد: `ExchangeTokenAPIView` |
|||
|
|||
3. **URL**: `/apps/account/urls.py` |
|||
- اضافه شد: `path('exchange-token/', ...)` |
|||
|
|||
--- |
|||
|
|||
## 🔄 فلوی کامل |
|||
|
|||
``` |
|||
📱 Mobile App |
|||
↓ |
|||
POST /api/courses/1/online/token/ |
|||
Headers: Authorization: Token <USER_TOKEN> |
|||
↓ |
|||
Response: { |
|||
"token": "abc123", |
|||
"url": "https://imamjavad.newhorizonco.uk/join-class?token=abc123&slug=python-basics" |
|||
} |
|||
↓ |
|||
📱 Open URL in Browser |
|||
↓ |
|||
🌐 Frontend: /join-class?token=abc123&slug=python-basics |
|||
↓ |
|||
POST /api/account/exchange-token/ |
|||
Body: {"temp_token": "abc123"} |
|||
↓ |
|||
Response: { |
|||
"token": "a1b2c3...", |
|||
"user": {...} |
|||
} |
|||
↓ |
|||
Save token + user in localStorage |
|||
↓ |
|||
Redirect to /online-classroom (with slug if needed) |
|||
``` |
|||
|
|||
--- |
|||
|
|||
## ✨ ویژگیها |
|||
|
|||
✅ **Token Management**: استفاده از Redis برای ذخیرهسازی موقت |
|||
✅ **Auth Token**: برگرداندن DRF Token واقعی برای احراز هویت |
|||
✅ **Security**: One-time use و TTL محدود |
|||
✅ **Error Handling**: مدیریت کامل خطاها |
|||
✅ **Avatar Support**: برگرداندن URL avatar کاربر |
|||
✅ **Documentation**: Swagger/OpenAPI documentation |
|||
|
|||
--- |
|||
|
|||
## 🛠️ نکات پیادهسازی |
|||
|
|||
1. از `OnlineClassTokenManager` استفاده میشود که در `utils/redis.py` تعریف شده |
|||
2. توکن موقت پس از استفاده با `manager.delete_token()` حذف میشود |
|||
3. نقش کاربر با بررسی `user.can_manage_course(course)` تشخیص داده میشود |
|||
4. در صورت عدم وجود course_id، فقط `user.is_staff` بررسی میشود |
|||
|
|||
--- |
|||
|
|||
## 📱 مثال Flutter |
|||
|
|||
```dart |
|||
import 'package:http/http.dart' as http; |
|||
import 'dart:convert'; |
|||
import 'package:url_launcher/url_launcher.dart'; |
|||
|
|||
class OnlineClassService { |
|||
final String baseUrl = 'https://your-backend.com'; |
|||
|
|||
/// مرحله 1: دریافت توکن موقت از بکند |
|||
Future<Map<String, dynamic>> getTemporaryToken({ |
|||
required int courseId, |
|||
required String userToken, |
|||
}) async { |
|||
final response = await http.post( |
|||
Uri.parse('$baseUrl/courses/$courseId/online/token/'), |
|||
headers: { |
|||
'Authorization': 'Token $userToken', |
|||
'Content-Type': 'application/json', |
|||
}, |
|||
); |
|||
|
|||
if (response.statusCode == 201) { |
|||
return jsonDecode(response.body); |
|||
} else { |
|||
throw Exception('Failed to get temporary token'); |
|||
} |
|||
} |
|||
|
|||
/// مرحله 2: باز کردن لینک در مرورگر |
|||
Future<void> openOnlineClass({ |
|||
required int courseId, |
|||
required String userToken, |
|||
}) async { |
|||
try { |
|||
// دریافت توکن موقت |
|||
final tokenData = await getTemporaryToken( |
|||
courseId: courseId, |
|||
userToken: userToken, |
|||
); |
|||
|
|||
final joinUrl = tokenData['url'] as String; |
|||
|
|||
// باز کردن در مرورگر |
|||
final uri = Uri.parse(joinUrl); |
|||
if (await canLaunchUrl(uri)) { |
|||
await launchUrl( |
|||
uri, |
|||
mode: LaunchMode.externalApplication, |
|||
); |
|||
} else { |
|||
throw Exception('Could not launch $joinUrl'); |
|||
} |
|||
} catch (e) { |
|||
print('Error opening online class: $e'); |
|||
rethrow; |
|||
} |
|||
} |
|||
} |
|||
|
|||
// استفاده در UI |
|||
class CourseDetailScreen extends StatelessWidget { |
|||
final int courseId; |
|||
final String userToken; |
|||
final OnlineClassService _service = OnlineClassService(); |
|||
|
|||
@override |
|||
Widget build(BuildContext context) { |
|||
return Scaffold( |
|||
body: Center( |
|||
child: ElevatedButton( |
|||
onPressed: () async { |
|||
try { |
|||
await _service.openOnlineClass( |
|||
courseId: courseId, |
|||
userToken: userToken, |
|||
); |
|||
} catch (e) { |
|||
ScaffoldMessenger.of(context).showSnackBar( |
|||
SnackBar(content: Text('خطا در ورود به کلاس: $e')), |
|||
); |
|||
} |
|||
}, |
|||
child: Text('ورود به کلاس آنلاین'), |
|||
), |
|||
), |
|||
); |
|||
} |
|||
} |
|||
``` |
|||
|
|||
### نصب dependencies برای Flutter |
|||
|
|||
در `pubspec.yaml`: |
|||
|
|||
```yaml |
|||
dependencies: |
|||
http: ^1.1.0 |
|||
url_launcher: ^6.2.1 |
|||
``` |
|||
|
|||
--- |
|||
|
|||
## 🌐 مثال Frontend (Next.js) |
|||
|
|||
صفحه `/app/join-class/page.tsx`: |
|||
|
|||
```typescript |
|||
'use client'; |
|||
|
|||
import { useEffect, useState } from 'react'; |
|||
import { useRouter, useSearchParams } from 'next/navigation'; |
|||
import { api } from '@/lib/api'; |
|||
|
|||
export default function JoinClassPage() { |
|||
const router = useRouter(); |
|||
const searchParams = useSearchParams(); |
|||
const [error, setError] = useState<string | null>(null); |
|||
|
|||
useEffect(() => { |
|||
const token = searchParams.get('token'); |
|||
|
|||
if (!token) { |
|||
setError('توکن یافت نشد'); |
|||
return; |
|||
} |
|||
|
|||
// تبدیل توکن به اطلاعات کاربر |
|||
exchangeToken(token) |
|||
.then((data) => { |
|||
// ذخیره token و user در localStorage |
|||
localStorage.setItem('authToken', data.token); |
|||
localStorage.setItem('user', JSON.stringify(data.user)); |
|||
|
|||
// هدایت به صفحه کلاس |
|||
router.push('/online-classroom'); |
|||
}) |
|||
.catch((err) => { |
|||
setError(err.message || 'خطا در ورود'); |
|||
}); |
|||
}, [searchParams, router]); |
|||
|
|||
async function exchangeToken(tempToken: string) { |
|||
const response = await fetch( |
|||
`${process.env.NEXT_PUBLIC_API_URL}/api/account/exchange-token/`, |
|||
{ |
|||
method: 'POST', |
|||
headers: { |
|||
'Content-Type': 'application/json', |
|||
}, |
|||
body: JSON.stringify({ temp_token: tempToken }), |
|||
} |
|||
); |
|||
|
|||
if (!response.ok) { |
|||
const error = await response.json(); |
|||
throw new Error(error.message || 'Failed to exchange token'); |
|||
} |
|||
|
|||
const data = await response.json(); |
|||
return data; // Returns { success, message, token, user } |
|||
} |
|||
|
|||
if (error) { |
|||
return ( |
|||
<div className="flex items-center justify-center min-h-screen"> |
|||
<div className="text-red-500">{error}</div> |
|||
</div> |
|||
); |
|||
} |
|||
|
|||
return ( |
|||
<div className="flex items-center justify-center min-h-screen"> |
|||
<div>در حال ورود...</div> |
|||
</div> |
|||
); |
|||
} |
|||
``` |
|||
|
|||
|
|||
Write
Preview
Loading…
Cancel
Save
Reference in new issue