diff --git a/backend/apps/users/migrations/0013_invitation_link_model.py b/backend/apps/users/migrations/0013_invitation_link_model.py new file mode 100644 index 0000000..53ac7fb --- /dev/null +++ b/backend/apps/users/migrations/0013_invitation_link_model.py @@ -0,0 +1,68 @@ +# Generated by Django 4.2.7 on 2026-03-12 14:06 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ("users", "0012_add_group_to_board"), + ] + + operations = [ + migrations.CreateModel( + name="InvitationLink", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "token", + models.CharField( + db_index=True, max_length=64, unique=True, verbose_name="Токен" + ), + ), + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="Создана"), + ), + ( + "is_banned", + models.BooleanField(default=False, verbose_name="Забанена"), + ), + ( + "mentor", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="invitation_links", + to=settings.AUTH_USER_MODEL, + verbose_name="Ментор", + ), + ), + ( + "used_by", + models.OneToOneField( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="registered_via_link", + to=settings.AUTH_USER_MODEL, + verbose_name="Использована пользователем", + ), + ), + ], + options={ + "verbose_name": "Ссылка-приглашение", + "verbose_name_plural": "Ссылки-приглашения", + "db_table": "invitation_links", + "ordering": ["-created_at"], + }, + ), + ] diff --git a/backend/apps/users/models.py b/backend/apps/users/models.py index 27d9132..0d95754 100644 --- a/backend/apps/users/models.py +++ b/backend/apps/users/models.py @@ -691,4 +691,50 @@ class Group(models.Model): ] def __str__(self): - return f"{self.name} (ментор: {self.mentor.get_full_name()})" \ No newline at end of file + return f"{self.name} (ментор: {self.mentor.get_full_name()})" + + +class InvitationLink(models.Model): + """ + Ссылка-приглашение от ментора для регистрации ученика. + - Каждая ссылка действует 12 часов с момента создания + - Одна ссылка — один ученик (used_by) + - Несколько ссылок могут быть активны одновременно + - По истечении 12 часов ссылка помечается is_banned=True и не может быть использована + """ + token = models.CharField(max_length=64, unique=True, db_index=True, verbose_name='Токен') + mentor = models.ForeignKey( + 'User', + on_delete=models.CASCADE, + related_name='invitation_links', + verbose_name='Ментор', + ) + created_at = models.DateTimeField(auto_now_add=True, verbose_name='Создана') + used_by = models.OneToOneField( + 'User', + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='registered_via_link', + verbose_name='Использована пользователем', + ) + is_banned = models.BooleanField(default=False, verbose_name='Забанена') + + class Meta: + db_table = 'invitation_links' + verbose_name = 'Ссылка-приглашение' + verbose_name_plural = 'Ссылки-приглашения' + ordering = ['-created_at'] + + def __str__(self): + return f"InvitationLink({self.mentor.email}, {self.token[:8]}...)" + + @property + def is_expired(self): + from django.utils import timezone as tz + from datetime import timedelta + return tz.now() > self.created_at + timedelta(hours=12) + + @property + def is_valid(self): + return not self.is_banned and not self.is_expired and self.used_by_id is None diff --git a/backend/apps/users/profile_views.py b/backend/apps/users/profile_views.py index 4fbe82c..ee6fe5d 100644 --- a/backend/apps/users/profile_views.py +++ b/backend/apps/users/profile_views.py @@ -1253,23 +1253,38 @@ class ClientManagementViewSet(viewsets.ViewSet): @action(detail=False, methods=['post'], url_path='generate-invitation-link') def generate_invitation_link(self, request): """ - Сгенерировать или обновить токен ссылки-приглашения. - POST /api/users/manage/clients/generate-invitation-link/ + Создать новую ссылку-приглашение (12 часов, 1 использование). + Старые ссылки остаются действительными. + POST /api/manage/clients/generate-invitation-link/ """ + from django.utils import timezone as tz + from datetime import timedelta + from .models import InvitationLink + user = request.user if user.role != 'mentor': return Response({'error': 'Только для менторов'}, status=status.HTTP_403_FORBIDDEN) - - user.invitation_link_token = secrets.token_urlsafe(32) - user.save(update_fields=['invitation_link_token']) - + + # Истекаем просроченные ссылки этого ментора + expire_before = tz.now() - timedelta(hours=12) + InvitationLink.objects.filter( + mentor=user, + is_banned=False, + used_by__isnull=True, + created_at__lt=expire_before, + ).update(is_banned=True) + + token = secrets.token_urlsafe(32) + inv = InvitationLink.objects.create(mentor=user, token=token) + from django.conf import settings frontend_url = getattr(settings, 'FRONTEND_URL', '').rstrip('/') - link = f"{frontend_url}/invite/{user.invitation_link_token}" - + link = f"{frontend_url}/invite/{token}" + return Response({ - 'invitation_link_token': user.invitation_link_token, - 'invitation_link': link + 'invitation_link_token': token, + 'invitation_link': link, + 'expires_at': (inv.created_at + timedelta(hours=12)).isoformat(), }) @@ -1453,20 +1468,37 @@ class InvitationViewSet(viewsets.ViewSet): Получить информацию о менторе по токену ссылки-приглашения. GET /api/invitation/info-by-token/?token=... """ + from django.utils import timezone as tz + from datetime import timedelta + from .models import InvitationLink + token = request.query_params.get('token') if not token: return Response({'error': 'Токен не указан'}, status=status.HTTP_400_BAD_REQUEST) - + try: - mentor = User.objects.get(invitation_link_token=token, role='mentor') - return Response({ - 'mentor_name': mentor.get_full_name(), - 'mentor_id': mentor.id, - 'avatar_url': request.build_absolute_uri(mentor.avatar.url) if mentor.avatar else None, - }) - except User.DoesNotExist: + inv = InvitationLink.objects.select_related('mentor').get(token=token) + except InvitationLink.DoesNotExist: return Response({'error': 'Недействительная ссылка'}, status=status.HTTP_404_NOT_FOUND) + if inv.is_banned: + return Response({'error': 'Ссылка заблокирована'}, status=status.HTTP_410_GONE) + if inv.is_expired: + inv.is_banned = True + inv.save(update_fields=['is_banned']) + return Response({'error': 'Ссылка истекла'}, status=status.HTTP_410_GONE) + if inv.used_by_id is not None: + return Response({'error': 'Ссылка уже использована'}, status=status.HTTP_410_GONE) + + mentor = inv.mentor + expires_at = inv.created_at + timedelta(hours=12) + return Response({ + 'mentor_name': mentor.get_full_name(), + 'mentor_id': mentor.id, + 'avatar_url': request.build_absolute_uri(mentor.avatar.url) if mentor.avatar else None, + 'expires_at': expires_at.isoformat(), + }) + @action(detail=False, methods=['post'], url_path='register-by-link', permission_classes=[AllowAny]) def register_by_link(self, request): """ @@ -1482,6 +1514,8 @@ class InvitationViewSet(viewsets.ViewSet): "city": "..." } """ + from .models import InvitationLink + token = request.data.get('token') first_name = request.data.get('first_name') last_name = request.data.get('last_name') @@ -1494,10 +1528,21 @@ class InvitationViewSet(viewsets.ViewSet): return Response({'error': 'Имя, фамилия и токен обязательны'}, status=status.HTTP_400_BAD_REQUEST) try: - mentor = User.objects.get(invitation_link_token=token, role='mentor') - except User.DoesNotExist: + inv = InvitationLink.objects.select_related('mentor').get(token=token) + except InvitationLink.DoesNotExist: return Response({'error': 'Недействительная ссылка'}, status=status.HTTP_404_NOT_FOUND) + if inv.is_banned: + return Response({'error': 'Ссылка заблокирована'}, status=status.HTTP_410_GONE) + if inv.is_expired: + inv.is_banned = True + inv.save(update_fields=['is_banned']) + return Response({'error': 'Срок действия ссылки истёк'}, status=status.HTTP_410_GONE) + if inv.used_by_id is not None: + return Response({'error': 'Ссылка уже была использована'}, status=status.HTTP_410_GONE) + + mentor = inv.mentor + # Если email указан, проверяем его уникальность if email: if User.objects.filter(email=email).exists(): @@ -1522,7 +1567,11 @@ class InvitationViewSet(viewsets.ViewSet): # Генерируем персональный токен для входа student_user.login_token = secrets.token_urlsafe(32) student_user.save(update_fields=['login_token']) - + + # Помечаем ссылку как использованную + inv.used_by = student_user + inv.save(update_fields=['used_by']) + # Создаем профиль клиента client = Client.objects.create(user=student_user) @@ -1601,65 +1650,80 @@ class ParentManagementViewSet(viewsets.ViewSet): status=status.HTTP_403_FORBIDDEN ) - # Поддержка старого формата (child_email) и нового (email + данные) + # Поддержка: universal_code (8 симв.) / child_email / email + universal_code = (request.data.get('universal_code') or '').strip().upper() child_email = request.data.get('child_email') or request.data.get('email') - - if not child_email: + + if not universal_code and not child_email: return Response( - {'error': 'Необходимо указать email ребенка'}, + {'error': 'Необходимо указать 8-значный код ребенка или его email'}, status=status.HTTP_400_BAD_REQUEST ) - - # Нормализуем email - child_email = child_email.lower().strip() - + # Получаем или создаем профиль родителя - parent, created = Parent.objects.get_or_create(user=user) - - # Ищем пользователя + parent, _ = Parent.objects.get_or_create(user=user) + created = False - try: - child_user = User.objects.get(email=child_email) - # Если пользователь существует, проверяем что это клиент + + if universal_code: + # --- Поиск по universal_code --- + allowed = set('ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789') + valid_8 = len(universal_code) == 8 and all(c in allowed for c in universal_code) + valid_6_legacy = len(universal_code) == 6 and universal_code.isdigit() + if not (valid_8 or valid_6_legacy): + return Response( + {'error': 'Код должен содержать 8 символов (буквы и цифры)'}, + status=status.HTTP_400_BAD_REQUEST + ) + try: + child_user = User.objects.get(universal_code=universal_code) + except User.DoesNotExist: + return Response( + {'error': 'Пользователь с таким кодом не найден'}, + status=status.HTTP_404_NOT_FOUND + ) if child_user.role != 'client': return Response( - {'error': 'Пользователь с таким email уже существует, но не является клиентом'}, + {'error': 'Пользователь с этим кодом не является учеником (client)'}, status=status.HTTP_400_BAD_REQUEST ) - except User.DoesNotExist: - created = True - # Создаем нового пользователя-клиента - first_name = request.data.get('first_name', '').strip() - last_name = request.data.get('last_name', '').strip() - phone = request.data.get('phone', '').strip() - - if not first_name or not last_name: - return Response( - {'error': 'Для создания нового пользователя необходимо указать имя и фамилию'}, - status=status.HTTP_400_BAD_REQUEST + else: + # --- Поиск / создание по email --- + child_email = child_email.lower().strip() + try: + child_user = User.objects.get(email=child_email) + if child_user.role != 'client': + return Response( + {'error': 'Пользователь с таким email не является учеником (client)'}, + status=status.HTTP_400_BAD_REQUEST + ) + except User.DoesNotExist: + created = True + first_name = request.data.get('first_name', '').strip() + last_name = request.data.get('last_name', '').strip() + phone = request.data.get('phone', '').strip() + + if not first_name or not last_name: + return Response( + {'error': 'Для создания нового пользователя необходимо указать имя и фамилию'}, + status=status.HTTP_400_BAD_REQUEST + ) + + temp_password = secrets.token_urlsafe(12) + child_user = User.objects.create_user( + email=child_email, + password=temp_password, + first_name=first_name, + last_name=last_name, + phone=normalize_phone(phone) if phone else '', + role='client', + email_verified=True, ) - - # Создаем пользователя с временным паролем - temp_password = secrets.token_urlsafe(12) - - child_user = User.objects.create_user( - email=child_email, - password=temp_password, - first_name=first_name, - last_name=last_name, - phone=normalize_phone(phone) if phone else '', - role='client', - email_verified=True, # Email автоматически подтвержден при добавлении родителем - ) - - # Генерируем токен для установки пароля - reset_token = secrets.token_urlsafe(32) - child_user.email_verification_token = reset_token - child_user.save() - - # Отправляем приветственное письмо со ссылкой на установку пароля - send_student_welcome_email_task.delay(child_user.id, reset_token) - + reset_token = secrets.token_urlsafe(32) + child_user.email_verification_token = reset_token + child_user.save() + send_student_welcome_email_task.delay(child_user.id, reset_token) + # Получаем или создаем профиль клиента if created: # Если пользователь был только что создан child = Client.objects.create( diff --git a/front_minimal/src/app/invite/[token]/page.jsx b/front_minimal/src/app/invite/[token]/page.jsx new file mode 100644 index 0000000..d477599 --- /dev/null +++ b/front_minimal/src/app/invite/[token]/page.jsx @@ -0,0 +1,10 @@ +import { CONFIG } from 'src/config-global'; +import { InviteRegisterView } from 'src/sections/invite/view'; + +// ---------------------------------------------------------------------- + +export const metadata = { title: `Регистрация по приглашению | ${CONFIG.site.name}` }; + +export default function Page({ params }) { + return ; +} diff --git a/front_minimal/src/routes/paths.js b/front_minimal/src/routes/paths.js index d4ca7cd..01b3c19 100644 --- a/front_minimal/src/routes/paths.js +++ b/front_minimal/src/routes/paths.js @@ -7,6 +7,7 @@ const ROOTS = { export const paths = { videoCall: '/video-call', + invite: (token) => `/invite/${token}`, page404: '/404', // Auth diff --git a/front_minimal/src/routes/sections.jsx b/front_minimal/src/routes/sections.jsx index 6f77cc2..a0c1352 100644 --- a/front_minimal/src/routes/sections.jsx +++ b/front_minimal/src/routes/sections.jsx @@ -108,6 +108,10 @@ const VideoCallView = lazy(() => import('src/sections/video-call/view').then((m) => ({ default: m.VideoCallView })) ); +const InviteRegisterView = lazy(() => + import('src/sections/invite/view').then((m) => ({ default: m.InviteRegisterView })) +); + const PrejoinView = lazy(() => import('src/sections/prejoin/view/prejoin-view').then((m) => ({ default: m.PrejoinView })) ); @@ -145,6 +149,11 @@ function AuthLayoutWrapper() { ); } +function InviteWrapper() { + const { token } = useParams(); + return ; +} + function LessonDetailWrapper() { const { id } = useParams(); return ; @@ -185,6 +194,9 @@ export function Router() { // Video call — fullscreen, no sidebar { path: 'video-call', element: }, + // Invite link — public, no auth required + { path: 'invite/:token', element: }, + // Dashboard { path: 'dashboard', diff --git a/front_minimal/src/sections/account-platform/view/account-platform-view.jsx b/front_minimal/src/sections/account-platform/view/account-platform-view.jsx index badd797..1eeab6e 100644 --- a/front_minimal/src/sections/account-platform/view/account-platform-view.jsx +++ b/front_minimal/src/sections/account-platform/view/account-platform-view.jsx @@ -858,6 +858,31 @@ export function AccountPlatformView() { disabled helperText="Изменить email нельзя" /> + {user?.universal_code && ( + + { + navigator.clipboard.writeText(user.universal_code); + showSnack('Код скопирован'); + }} + > + + + + ), + }} + /> + )} diff --git a/front_minimal/src/sections/invite/view/index.js b/front_minimal/src/sections/invite/view/index.js new file mode 100644 index 0000000..8a22ac3 --- /dev/null +++ b/front_minimal/src/sections/invite/view/index.js @@ -0,0 +1 @@ +export { InviteRegisterView } from './invite-register-view'; diff --git a/front_minimal/src/sections/invite/view/invite-register-view.jsx b/front_minimal/src/sections/invite/view/invite-register-view.jsx new file mode 100644 index 0000000..a5a2ca2 --- /dev/null +++ b/front_minimal/src/sections/invite/view/invite-register-view.jsx @@ -0,0 +1,316 @@ +'use client'; + +import { z as zod } from 'zod'; +import { useState, useEffect, useCallback, useRef } from 'react'; +import { useForm, Controller } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; + +import Box from '@mui/material/Box'; +import Card from '@mui/material/Card'; +import Alert from '@mui/material/Alert'; +import Stack from '@mui/material/Stack'; +import Avatar from '@mui/material/Avatar'; +import IconButton from '@mui/material/IconButton'; +import Typography from '@mui/material/Typography'; +import CardContent from '@mui/material/CardContent'; +import LoadingButton from '@mui/lab/LoadingButton'; +import InputAdornment from '@mui/material/InputAdornment'; +import CircularProgress from '@mui/material/CircularProgress'; +import Autocomplete from '@mui/material/Autocomplete'; +import TextField from '@mui/material/TextField'; + +import { useRouter } from 'src/routes/hooks'; +import { paths } from 'src/routes/paths'; + +import { useBoolean } from 'src/hooks/use-boolean'; +import { useAuthContext } from 'src/auth/hooks'; +import { setSession } from 'src/auth/context/jwt/utils'; + +import { Logo } from 'src/components/logo'; +import { Iconify } from 'src/components/iconify'; +import { Form, Field } from 'src/components/hook-form'; + +import { searchCities } from 'src/utils/profile-api'; +import { getInviteLinkInfo, registerByInviteLink } from 'src/utils/students-api'; + +// ---------------------------------------------------------------------- + +const RegisterSchema = zod + .object({ + firstName: zod.string().min(1, { message: 'Введите имя!' }), + lastName: zod.string().min(1, { message: 'Введите фамилию!' }), + email: zod + .string() + .min(1, { message: 'Введите email!' }) + .email({ message: 'Введите корректный email!' }), + city: zod.string().min(1, { message: 'Введите город!' }), + password: zod + .string() + .min(1, { message: 'Введите пароль!' }) + .min(8, { message: 'Минимум 8 символов!' }), + passwordConfirm: zod.string().min(1, { message: 'Подтвердите пароль!' }), + }) + .refine((d) => d.password === d.passwordConfirm, { + message: 'Пароли не совпадают!', + path: ['passwordConfirm'], + }); + +// ---------------------------------------------------------------------- + +function CityAutocomplete({ value, onChange, error, helperText }) { + const [inputValue, setInputValue] = useState(value || ''); + const [options, setOptions] = useState([]); + const [loading, setLoading] = useState(false); + const timerRef = useRef(null); + + const fetch = useCallback(async (query) => { + if (!query || query.length < 2) { setOptions([]); return; } + setLoading(true); + try { + const res = await searchCities(query, 30); + setOptions(res.map((c) => (typeof c === 'string' ? c : c.name || c.city || String(c)))); + } finally { setLoading(false); } + }, []); + + useEffect(() => { + clearTimeout(timerRef.current); + timerRef.current = setTimeout(() => fetch(inputValue), 350); + return () => clearTimeout(timerRef.current); + }, [inputValue, fetch]); + + return ( + { setInputValue(val); onChange(val); }} + onChange={(_, val) => { if (val) { setInputValue(val); onChange(val); } }} + noOptionsText="Города не найдены" + loadingText="Поиск..." + renderInput={(params) => ( + + {loading ? : null} + {params.InputProps.endAdornment} + + ), + }} + /> + )} + /> + ); +} + +// ---------------------------------------------------------------------- + +export function InviteRegisterView({ token }) { + const router = useRouter(); + const { checkUserSession } = useAuthContext(); + + const password = useBoolean(); + const passwordConfirm = useBoolean(); + + const [mentorInfo, setMentorInfo] = useState(null); + const [infoLoading, setInfoLoading] = useState(true); + const [infoError, setInfoError] = useState(''); + const [submitError, setSubmitError] = useState(''); + + useEffect(() => { + if (!token) { setInfoError('Токен отсутствует'); setInfoLoading(false); return; } + getInviteLinkInfo(token) + .then((data) => setMentorInfo(data)) + .catch((e) => { + const msg = e?.response?.data?.error || e?.response?.data?.message || e?.message || 'Ссылка недействительна или истекла'; + setInfoError(msg); + }) + .finally(() => setInfoLoading(false)); + }, [token]); + + const methods = useForm({ + resolver: zodResolver(RegisterSchema), + defaultValues: { firstName: '', lastName: '', email: '', city: '', password: '', passwordConfirm: '' }, + }); + + const { control, handleSubmit, formState: { isSubmitting } } = methods; // eslint-disable-line + + const onSubmit = handleSubmit(async (data) => { + try { + setSubmitError(''); + const timezone = typeof Intl !== 'undefined' + ? Intl.DateTimeFormat().resolvedOptions().timeZone + : 'Europe/Moscow'; + + const res = await registerByInviteLink({ + token, + first_name: data.firstName, + last_name: data.lastName, + email: data.email, + password: data.password, + city: data.city, + timezone, + }); + + // Авто-логин + await setSession(res.access, res.refresh); + await checkUserSession?.(); + router.replace(paths.dashboard.root); + } catch (e) { + const msg = e?.response?.data?.error?.message + || e?.response?.data?.error + || e?.response?.data?.message + || e?.message + || 'Ошибка регистрации'; + setSubmitError(msg); + } + }); + + if (infoLoading) { + return ( + + + + ); + } + + if (infoError) { + return ( + + + + + + Ссылка недействительна + {infoError} + + Ссылка истекла, уже была использована или заблокирована. Попросите ментора создать новую ссылку. + + + + + + ); + } + + return ( + + + + + + + + + {/* Mentor info */} + + + {mentorInfo?.mentor_name?.[0]} + + + Вас приглашает + {mentorInfo?.mentor_name} + + + + Создать аккаунт + + После регистрации вы сразу будете подключены к ментору + + + {submitError && {submitError}} + +
+ + + + + + + + + ( + + )} + /> + + + + + + + ), + }} + /> + + + + + + + ), + }} + /> + + + Зарегистрироваться + + +
+
+
+
+
+ ); +} diff --git a/front_minimal/src/sections/students/view/students-view.jsx b/front_minimal/src/sections/students/view/students-view.jsx index 62bc8c0..9273d20 100644 --- a/front_minimal/src/sections/students/view/students-view.jsx +++ b/front_minimal/src/sections/students/view/students-view.jsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect, useCallback } from 'react'; +import { useRef, useState, useEffect, useCallback } from 'react'; import Tab from '@mui/material/Tab'; import Box from '@mui/material/Box'; @@ -15,10 +15,12 @@ import Avatar from '@mui/material/Avatar'; import Button from '@mui/material/Button'; import Dialog from '@mui/material/Dialog'; import Divider from '@mui/material/Divider'; +import Tooltip from '@mui/material/Tooltip'; import ListItem from '@mui/material/ListItem'; import TextField from '@mui/material/TextField'; import Typography from '@mui/material/Typography'; import CardHeader from '@mui/material/CardHeader'; +import IconButton from '@mui/material/IconButton'; import DialogTitle from '@mui/material/DialogTitle'; import CardContent from '@mui/material/CardContent'; import ListItemText from '@mui/material/ListItemText'; @@ -31,12 +33,13 @@ import CircularProgress from '@mui/material/CircularProgress'; import { paths } from 'src/routes/paths'; import { useRouter } from 'src/routes/hooks'; -import { resolveMediaUrl } from 'src/utils/axios'; +import { resolveMediaUrl } from 'src/utils/axios'; import { getStudents, getMyMentors, getMyInvitations, addStudentInvitation, + generateInvitationLink, sendMentorshipRequest, acceptMentorshipRequest, rejectMentorshipRequest, @@ -274,57 +277,226 @@ function MentorRequests({ onRefresh }) { ); } +// 8 отдельных полей для ввода кода +function CodeInput({ value, onChange, disabled }) { + const refs = useRef([]); + const chars = (value || '').padEnd(8, '').split('').slice(0, 8); + + const handleChange = (i, v) => { + const ch = v.toUpperCase().replace(/[^A-Z0-9]/g, ''); + const next = chars.slice(); + next[i] = ch[0] || ''; + onChange(next.join('').trimEnd()); + if (ch && i < 7) refs.current[i + 1]?.focus(); + }; + + const handleKeyDown = (i, e) => { + if (e.key === 'Backspace' && !chars[i] && i > 0) refs.current[i - 1]?.focus(); + if (e.key === 'ArrowLeft' && i > 0) refs.current[i - 1]?.focus(); + if (e.key === 'ArrowRight' && i < 7) refs.current[i + 1]?.focus(); + }; + + const handlePaste = (e) => { + e.preventDefault(); + const text = e.clipboardData.getData('text').toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, 8); + onChange(text); + refs.current[Math.min(text.length, 7)]?.focus(); + }; + + return ( + + {chars.map((ch, i) => ( + { refs.current[i] = el; }} + value={ch} + onChange={(e) => handleChange(i, e.target.value)} + onKeyDown={(e) => handleKeyDown(i, e)} + onPaste={i === 0 ? handlePaste : undefined} + disabled={disabled} + inputProps={{ + maxLength: 1, + style: { + textAlign: 'center', + fontWeight: 700, + fontSize: 18, + letterSpacing: 0, + padding: '10px 0', + width: 32, + }, + }} + sx={{ width: 40 }} + /> + ))} + + ); +} + function InviteDialog({ open, onClose, onSuccess }) { - const [mode, setMode] = useState('email'); // 'email' | 'code' - const [value, setValue] = useState(''); + const [mode, setMode] = useState('email'); // 'email' | 'code' | 'link' + const [emailValue, setEmailValue] = useState(''); + const [codeValue, setCodeValue] = useState(''); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [successMsg, setSuccessMsg] = useState(null); + const [linkData, setLinkData] = useState(null); // { invitation_link, expires_at } + const [linkLoading, setLinkLoading] = useState(false); + const [copied, setCopied] = useState(false); - const reset = () => { setValue(''); setError(null); setSuccessMsg(null); }; + const reset = () => { + setEmailValue(''); setCodeValue(''); setError(null); setSuccessMsg(null); + setLinkData(null); setCopied(false); + }; + + const handleClose = () => { if (!loading && !linkLoading) { reset(); onClose(); } }; const handleSend = async () => { - if (!value.trim()) return; + const val = mode === 'email' ? emailValue.trim() : codeValue.trim(); + if (!val) return; try { setLoading(true); setError(null); - const payload = mode === 'email' ? { email: value.trim() } : { universal_code: value.trim() }; + const payload = mode === 'email' ? { email: val } : { universal_code: val }; const res = await addStudentInvitation(payload); setSuccessMsg(res?.message || 'Приглашение отправлено'); onSuccess(); } catch (e) { - setError(e?.response?.data?.detail || e?.response?.data?.email?.[0] || e?.message || 'Ошибка'); + const msg = e?.response?.data?.error?.message + || e?.response?.data?.detail + || e?.response?.data?.email?.[0] + || e?.message || 'Ошибка'; + setError(msg); } finally { setLoading(false); } }; + const handleGenerateLink = async () => { + try { + setLinkLoading(true); + setError(null); + const res = await generateInvitationLink(); + setLinkData(res); + setCopied(false); + } catch (e) { + setError(e?.response?.data?.error?.message || e?.message || 'Ошибка генерации ссылки'); + } finally { + setLinkLoading(false); + } + }; + + const handleCopyLink = () => { + navigator.clipboard.writeText(linkData.invitation_link); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + const expiresText = linkData?.expires_at + ? `Действует до ${new Date(linkData.expires_at).toLocaleString('ru-RU', { hour: '2-digit', minute: '2-digit', day: '2-digit', month: '2-digit' })}` + : ''; + + const canSend = mode === 'email' ? !!emailValue.trim() : codeValue.length === 8; + return ( - { if (!loading) { reset(); onClose(); } }} maxWidth="xs" fullWidth> + Пригласить ученика - setMode(v)} size="small"> + { setMode(v); setError(null); setSuccessMsg(null); }}> + - setValue(e.target.value)} - disabled={loading} - fullWidth - size="small" - /> + + {mode === 'email' && ( + setEmailValue(e.target.value)} + disabled={loading} + fullWidth + size="small" + autoFocus + /> + )} + + {mode === 'code' && ( + + + Введите 8-значный код ученика + + + + )} + + {mode === 'link' && ( + + + Сгенерируйте ссылку для регистрации ученика. Каждая ссылка действует 12 часов и может быть использована только 1 раз. + + {!linkData ? ( + + ) : ( + + + + + + + + + ), + }} + /> + {expiresText && ( + + {expiresText} · только 1 ученик + + )} + + + )} + + )} + {error && {error}} {successMsg && {successMsg}} - - + + {mode !== 'link' && ( + + )} ); diff --git a/front_minimal/src/utils/students-api.js b/front_minimal/src/utils/students-api.js index 89277dd..039a84b 100644 --- a/front_minimal/src/utils/students-api.js +++ b/front_minimal/src/utils/students-api.js @@ -65,3 +65,15 @@ export async function rejectInvitationAsStudent(invitationId) { const res = await axios.post('/invitation/reject-as-student/', { invitation_id: invitationId }); return res.data; } + +// Public: get mentor info by invite link token (no auth required) +export async function getInviteLinkInfo(token) { + const res = await axios.get('/invitation/info-by-token/', { params: { token } }); + return res.data; +} + +// Public: register new student via invite link (no auth required) +export async function registerByInviteLink(payload) { + const res = await axios.post('/invitation/register-by-link/', payload); + return res.data; +}