feat: invite by link, 8-char code input, universal_code in profile

Backend:
- New InvitationLink model: token, mentor, created_at, used_by, is_banned
- Links expire after 12h, single-use, multiple active links allowed
- generate-invitation-link: creates InvitationLink (doesn't overwrite old)
- info-by-token: validates expiry/usage/ban before returning mentor info
- register-by-link: validates InvitationLink, marks used_by on registration
- Migration 0013_invitation_link_model

Frontend:
- Profile: show user's universal_code with copy button
- InviteDialog: 8 separate input boxes for code (auto-focus, paste support)
- InviteDialog: new "По ссылке" tab — generate link, copy, show expiry
- /invite/:token page: public registration page with mentor info preview
- Auto-login after invite link registration (setSession + checkUserSession)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Dev Server 2026-03-12 17:11:12 +03:00
parent 87f52da0eb
commit d5ebd2898a
11 changed files with 819 additions and 92 deletions

View File

@ -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"],
},
),
]

View File

@ -691,4 +691,50 @@ class Group(models.Model):
]
def __str__(self):
return f"{self.name} (ментор: {self.mentor.get_full_name()})"
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

View File

@ -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(

View File

@ -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 <InviteRegisterView token={params.token} />;
}

View File

@ -7,6 +7,7 @@ const ROOTS = {
export const paths = {
videoCall: '/video-call',
invite: (token) => `/invite/${token}`,
page404: '/404',
// Auth

View File

@ -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 <InviteRegisterView token={token} />;
}
function LessonDetailWrapper() {
const { id } = useParams();
return <LessonDetailView id={id} />;
@ -185,6 +194,9 @@ export function Router() {
// Video call fullscreen, no sidebar
{ path: 'video-call', element: <AuthGuard><S><VideoCallView /></S></AuthGuard> },
// Invite link public, no auth required
{ path: 'invite/:token', element: <S><InviteWrapper /></S> },
// Dashboard
{
path: 'dashboard',

View File

@ -858,6 +858,31 @@ export function AccountPlatformView() {
disabled
helperText="Изменить email нельзя"
/>
{user?.universal_code && (
<TextField
label="Мой код"
value={user.universal_code}
fullWidth
disabled
helperText="Передайте этот код ментору, чтобы он мог добавить вас"
inputProps={{ style: { letterSpacing: 4, fontWeight: 700, fontSize: 18 } }}
InputProps={{
endAdornment: (
<Tooltip title="Скопировать код">
<IconButton
edge="end"
onClick={() => {
navigator.clipboard.writeText(user.universal_code);
showSnack('Код скопирован');
}}
>
<Iconify icon="solar:copy-bold" width={18} />
</IconButton>
</Tooltip>
),
}}
/>
)}
</Stack>
</CardContent>
</Card>

View File

@ -0,0 +1 @@
export { InviteRegisterView } from './invite-register-view';

View File

@ -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 (
<Autocomplete
freeSolo
options={options}
loading={loading}
inputValue={inputValue}
onInputChange={(_, val) => { setInputValue(val); onChange(val); }}
onChange={(_, val) => { if (val) { setInputValue(val); onChange(val); } }}
noOptionsText="Города не найдены"
loadingText="Поиск..."
renderInput={(params) => (
<TextField
{...params}
label="Город"
error={error}
helperText={helperText}
InputLabelProps={{ shrink: true }}
InputProps={{
...params.InputProps,
endAdornment: (
<>
{loading ? <CircularProgress size={18} /> : 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 (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '100vh' }}>
<CircularProgress />
</Box>
);
}
if (infoError) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '100vh', p: 3 }}>
<Card sx={{ maxWidth: 400, width: 1 }}>
<CardContent>
<Stack spacing={2} alignItems="center">
<Iconify icon="solar:link-broken-bold" width={64} sx={{ color: 'error.main' }} />
<Typography variant="h6">Ссылка недействительна</Typography>
<Alert severity="error">{infoError}</Alert>
<Typography variant="body2" color="text.secondary" textAlign="center">
Ссылка истекла, уже была использована или заблокирована. Попросите ментора создать новую ссылку.
</Typography>
</Stack>
</CardContent>
</Card>
</Box>
);
}
return (
<Box
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'flex-start',
minHeight: '100vh',
bgcolor: 'background.default',
p: { xs: 2, sm: 4 },
}}
>
<Card sx={{ maxWidth: 480, width: 1 }}>
<CardContent>
<Stack spacing={3}>
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
<Logo disableLink />
</Box>
{/* Mentor info */}
<Stack direction="row" spacing={2} alignItems="center" sx={{ p: 2, bgcolor: 'background.neutral', borderRadius: 1.5 }}>
<Avatar
src={mentorInfo?.avatar_url}
sx={{ width: 48, height: 48, bgcolor: 'primary.main' }}
>
{mentorInfo?.mentor_name?.[0]}
</Avatar>
<Box>
<Typography variant="body2" color="text.secondary">Вас приглашает</Typography>
<Typography variant="subtitle1">{mentorInfo?.mentor_name}</Typography>
</Box>
</Stack>
<Typography variant="h5" textAlign="center">Создать аккаунт</Typography>
<Typography variant="body2" color="text.secondary" textAlign="center">
После регистрации вы сразу будете подключены к ментору
</Typography>
{submitError && <Alert severity="error">{submitError}</Alert>}
<Form methods={methods} onSubmit={onSubmit}>
<Stack spacing={2}>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
<Field.Text name="firstName" label="Имя" InputLabelProps={{ shrink: true }} />
<Field.Text name="lastName" label="Фамилия" InputLabelProps={{ shrink: true }} />
</Stack>
<Field.Text name="email" label="Email" type="email" InputLabelProps={{ shrink: true }} />
<Controller
name="city"
control={control}
render={({ field, fieldState }) => (
<CityAutocomplete
value={field.value}
onChange={field.onChange}
error={!!fieldState.error}
helperText={fieldState.error?.message}
/>
)}
/>
<Field.Text
name="password"
label="Пароль"
placeholder="Минимум 8 символов"
type={password.value ? 'text' : 'password'}
InputLabelProps={{ shrink: true }}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton onClick={password.onToggle} edge="end">
<Iconify icon={password.value ? 'solar:eye-bold' : 'solar:eye-closed-bold'} />
</IconButton>
</InputAdornment>
),
}}
/>
<Field.Text
name="passwordConfirm"
label="Подтвердите пароль"
placeholder="Минимум 8 символов"
type={passwordConfirm.value ? 'text' : 'password'}
InputLabelProps={{ shrink: true }}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton onClick={passwordConfirm.onToggle} edge="end">
<Iconify icon={passwordConfirm.value ? 'solar:eye-bold' : 'solar:eye-closed-bold'} />
</IconButton>
</InputAdornment>
),
}}
/>
<LoadingButton
fullWidth
color="inherit"
size="large"
type="submit"
variant="contained"
loading={isSubmitting}
loadingIndicator="Регистрация..."
>
Зарегистрироваться
</LoadingButton>
</Stack>
</Form>
</Stack>
</CardContent>
</Card>
</Box>
);
}

View File

@ -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 (
<Stack direction="row" spacing={0.5} justifyContent="center">
{chars.map((ch, i) => (
<TextField
key={i}
inputRef={(el) => { 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 }}
/>
))}
</Stack>
);
}
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 (
<Dialog open={open} onClose={() => { if (!loading) { reset(); onClose(); } }} maxWidth="xs" fullWidth>
<Dialog open={open} onClose={handleClose} maxWidth="xs" fullWidth>
<DialogTitle>Пригласить ученика</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}>
<Tabs value={mode} onChange={(_, v) => setMode(v)} size="small">
<Tabs value={mode} onChange={(_, v) => { setMode(v); setError(null); setSuccessMsg(null); }}>
<Tab value="email" label="По email" />
<Tab value="code" label="По коду" />
<Tab value="link" label="По ссылке" />
</Tabs>
<TextField
label={mode === 'email' ? 'Email ученика' : 'Код приглашения'}
value={value}
onChange={(e) => setValue(e.target.value)}
disabled={loading}
fullWidth
size="small"
/>
{mode === 'email' && (
<TextField
label="Email ученика"
value={emailValue}
onChange={(e) => setEmailValue(e.target.value)}
disabled={loading}
fullWidth
size="small"
autoFocus
/>
)}
{mode === 'code' && (
<Stack spacing={1}>
<Typography variant="body2" color="text.secondary" textAlign="center">
Введите 8-значный код ученика
</Typography>
<CodeInput value={codeValue} onChange={setCodeValue} disabled={loading} />
</Stack>
)}
{mode === 'link' && (
<Stack spacing={2}>
<Typography variant="body2" color="text.secondary">
Сгенерируйте ссылку для регистрации ученика. Каждая ссылка действует 12 часов и может быть использована только 1 раз.
</Typography>
{!linkData ? (
<Button
variant="outlined"
onClick={handleGenerateLink}
disabled={linkLoading}
startIcon={linkLoading ? <CircularProgress size={16} /> : <Iconify icon="solar:link-bold" width={18} />}
>
Сгенерировать ссылку
</Button>
) : (
<Stack spacing={1}>
<TextField
value={linkData.invitation_link}
fullWidth
size="small"
InputProps={{
readOnly: true,
endAdornment: (
<InputAdornment position="end">
<Tooltip title={copied ? 'Скопировано!' : 'Скопировать'}>
<IconButton edge="end" onClick={handleCopyLink} size="small">
<Iconify icon={copied ? 'solar:check-circle-bold' : 'solar:copy-bold'} width={18} />
</IconButton>
</Tooltip>
</InputAdornment>
),
}}
/>
{expiresText && (
<Typography variant="caption" color="text.secondary">
{expiresText} · только 1 ученик
</Typography>
)}
<Button
size="small"
variant="text"
onClick={handleGenerateLink}
disabled={linkLoading}
startIcon={<Iconify icon="solar:refresh-bold" width={16} />}
>
Новая ссылка
</Button>
</Stack>
)}
</Stack>
)}
{error && <Alert severity="error">{error}</Alert>}
{successMsg && <Alert severity="success">{successMsg}</Alert>}
</Stack>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<Button onClick={() => { reset(); onClose(); }} disabled={loading}>Закрыть</Button>
<Button variant="contained" onClick={handleSend} disabled={loading || !value.trim()}>
{loading ? 'Отправка...' : 'Пригласить'}
</Button>
<Button onClick={handleClose} disabled={loading || linkLoading}>Закрыть</Button>
{mode !== 'link' && (
<Button
variant="contained"
onClick={handleSend}
disabled={loading || !canSend}
startIcon={loading ? <CircularProgress size={16} /> : null}
>
{loading ? 'Отправка...' : 'Пригласить'}
</Button>
)}
</DialogActions>
</Dialog>
);

View File

@ -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;
}