feat(social): друзья, личные беседы, присутствие, аватары и часовой пояс

По запросу пользователя (вне очереди AGENT.md §13):

- главного сервера как точки входа больше нет: новичок начинает с пустым
  списком серверов, вход — только по приглашению или созданием своего;
- друзья: поиск по логину с экранированием LIKE, заявки (POST
  /users/@me/relationships), принятие, удаление/отклонение, списки friends/
  incoming/outgoing/blocked;
- личные беседы: POST /users/@me/channels (идемпотентно), GET
  /users/@me/channels со собеседником, статусом и последним сообщением;
  сообщения в DM работают через общие ручки комнат, доступ — только участникам
  (посторонний получает 404, события в Gateway тоже фильтруются);
- присутствие: last_seen_at обновляется при активности, «невидимка» и простой
  дольше двух минут выглядят как офлайн, смена статуса рассылает
  PRESENCE_UPDATE друзьям;
- READY отдаёт dm_channels (собеседник, аватар, статус, последнее сообщение);
- профиль: timezone (по умолчанию Europe/Moscow) в PATCH /users/@me,
  загрузка аватара POST /users/@me/avatar (проверка, что это изображение, в том
  числе по содержимому) и удаление DELETE /users/@me/avatar; старый файл
  удаляется с диска;
- тесты: заявки в друзья, личные беседы и их изоляция, «невидимка», часовой
  пояс и аватар, PRESENCE_UPDATE другу, отсутствие событий DM у постороннего.
This commit is contained in:
2026-09-20 01:11:04 +03:00
parent b01eb1f34c
commit 33dc1fec96
19 changed files with 1738 additions and 82 deletions
+27 -16
View File
@@ -15,7 +15,11 @@ interface LocationState {
from?: string;
}
/** Форма входа: поле кода 2FA появляется при ответе `auth.2fa_required`. */
/**
* Форма входа. Поле кода 2FA показывается всегда: сервер принимает и код из
* приложения (6 цифр), и резервный код вида `a8eh-pshp-t8st`, а при попытке
* входа без кода отвечает `auth.2fa_required` — тогда подсказываем про код.
*/
export default function LoginPage() {
const { t } = useTranslation();
const navigate = useNavigate();
@@ -30,8 +34,11 @@ export default function LoginPage() {
const from = (location.state as LocationState | null)?.from ?? '/app';
const submit = useMutation({
mutationFn: () =>
login(needsTotp ? { email, password, totp_code: totpCode } : { email, password }),
mutationFn: () => {
// Пустое поле — вход без кода: сервер сам скажет, нужен ли он.
const code = totpCode.trim();
return login(code === '' ? { email, password } : { email, password, totp_code: code });
},
onSuccess: async () => {
// Профиль перечитываем заново: cookie уже выставлена сервером.
await getQueryClient().invalidateQueries();
@@ -39,6 +46,7 @@ export default function LoginPage() {
},
onError: (error: unknown) => {
if (errorCode(error) === 'auth.2fa_required') {
// Логин, пароль и уже введённый код остаются в форме.
setNeedsTotp(true);
}
},
@@ -46,6 +54,7 @@ export default function LoginPage() {
const onSubmit = (event: FormEvent<HTMLFormElement>): void => {
event.preventDefault();
setNeedsTotp(false);
submit.mutate();
};
@@ -79,22 +88,24 @@ export default function LoginPage() {
onChange={(event) => setPassword(event.target.value)}
required
/>
<Field
label={t('auth.login.totp')}
hint={t('auth.login.totpHint')}
name="totp_code"
inputMode="text"
autoComplete="one-time-code"
maxLength={32}
value={totpCode}
onChange={(event) => setTotpCode(event.target.value)}
/>
{needsTotp ? (
<Field
label={t('auth.login.totp')}
hint={t('auth.login.totpHint')}
name="totp_code"
inputMode="numeric"
autoComplete="one-time-code"
pattern="[0-9]*"
maxLength={8}
value={totpCode}
onChange={(event) => setTotpCode(event.target.value)}
autoFocus
/>
<p role="alert" className="text-sm text-danger">
{t('auth.login.totpRequired')}
</p>
) : null}
{submit.isError ? <ErrorNotice error={submit.error} /> : null}
{/* При `auth.2fa_required` подсказка уже показана у поля — не дублируем. */}
{submit.isError && !needsTotp ? <ErrorNotice error={submit.error} /> : null}
<Button type="submit" disabled={submit.isPending}>
{t(submit.isPending ? 'auth.login.submitting' : 'auth.login.submit')}
+4 -1
View File
@@ -148,8 +148,11 @@ export function TwoFactorSection({
className="mt-2 grid grid-cols-2 gap-1 font-mono text-xs"
data-testid="recovery-codes"
>
{/* Коды вида `a8eh-pshp-t8st` не обрезаем: переносим по символам. */}
{recoveryCodes.map((recoveryCode) => (
<li key={recoveryCode}>{recoveryCode}</li>
<li key={recoveryCode} className="min-w-0 break-all">
{recoveryCode}
</li>
))}
</ul>
<div className="mt-3 flex items-center gap-3">