Оценить качество материала и подачу материала автором видео:
Front-end
Трудоустройтесь middle front-end разработчиком на React JS (TypeScript) за 12-16 месяцев обучения с ежедневной менторской поддержкой в формате видео 1 на 1 и коммерческими проектами в портфолио
Трудоустройтесь middle back-end разработчиком за 12-16 месяцев обучения с ежедневной менторской поддержкой в формате видео 1 на 1 и коммерческими проектами в портфолио
Получите коммерческий опыт на реальных стартапах, прокачайте tech & soft навыки, научитесь работать в команде, проходить собеседования и получите первую работу в IT!
На данный момент мы не авторизовались, но можем делать CRUD-операции, потому что временно мы прописали
VITE_ACCESS_TOKEN в .local.env и прикрепили в prepareHeaders
Теперь нам нужно понять откуда взять code и redirectUri.
Пошаговый план, что мы будем делать:
Когда пользователь нажимает на кнопку "login", открывается popup-окно для OAuth-авторизации в которое передаем
url
После авторизации провайдер OAuth редиректит на /oauth/callback?code=....
На этой странице компонент OAuthCallback достаёт code и с помощью postMessage отправляет его в основное окно
приложения, а затем закрывает popup-окно.
🔗
window.postMessage — это
специальный механизм в браузерах для безопасного обмена сообщениями между окнами, вкладками
или фреймами, даже если они с разных доменов (cross-origin). Это один из немногих стандартных способов
передачи данных между, например, popup-окном и основным приложением.
Основное окно ловит это сообщение, вызывает login mutation (обычно это запрос на бэкенд, где по code выдают токен).
После успешного логина пользователь считается авторизованным.
// Компонент, срабатывающий после успешной OAuth авторизации,// его цель - отправить код обратно в главное окно приложения и закрыть popupexportconstOAuthCallback= () => {useEffect(() => {// Получаем текущий URLconsturl=newURL(window.location.href)// Извлекаем code из параметров запросаconstcode=url.searchParams.get('code')if (code &&window.opener) {window.opener.postMessage({ code },'*') }window.close() }, [])return <p>Logging you in...</p>}
exportconstLogin= () => {const [login] =useLoginMutation()constloginHandler= () => {// Создаем URI для перенаправления после авторизацииconstredirectUri=import.meta.env.VITE_DOMAIN_ADDRESS+Path.OAuthRedirect// Создаем URL endpoint OAuth авторизации, добавляя callbackUrl как параметр запросаconsturl=`${import.meta.env.VITE_BASE_URL}/auth/oauth-redirect?callbackUrl=${redirectUri}`// Открываем всплывающее окно для OAuth авторизацииwindow.open(url,'oauthPopup','width=500, height=600')// Функция-обработчик для получения сообщений из всплывающего окнаconstreceiveMessage=async (event:MessageEvent) => {if (event.origin !==import.meta.env.VITE_DOMAIN_ADDRESS) returnconst { code } =event.dataif (!code) return// Отписываемся от события, чтобы избежать обработки дублирующихся сообщенийwindow.removeEventListener('message', receiveMessage)login({ code, redirectUri, rememberMe:false }) }// Подписываемся на сообщения из всплывающего окнаwindow.addEventListener('message', receiveMessage) }return ( <buttontype={'button'} onClick={loginHandler}> login </button> )}
Результат:login-запрос уходит и в ответе мы получаем 2 jwt токена: (refreshToken и accessToken) 🚀
AccessToken и refreshToken
Теория
Зачем backend возвращает accessToken и refreshToken?
accessToken (токен доступа)
Основной токен для авторизации на клиенте.
Используется для запросов к защищённым эндпоинтам (например, «получить профиль пользователя», «создать заказ» и т.д.).
Короткоживущий (обычно 5–30 минут): если его украдут, злоумышленник не сможет долго им пользоваться.
refreshToken (токен обновления)
Хранится отдельно (чаще всего — в httpOnly cookie).
Не используется напрямую для запросов к API!
Его задача — обновлять accessToken, когда тот истёк.
Долго живёт (например, несколько дней, недель или даже месяцев).
Механизм работы (упрощённо)
Пользователь логинится → backend возвращает оба токена.
Клиент использует accessToken для запросов.
Когда accessToken истекает:
3.1. Клиент делает запрос на обновление (refresh) с помощью refreshToken.
3.2. Backend проверяет refreshToken, и если всё ок — выдаёт новый accessToken (и может обновить refreshToken).
3.3. Пользователь продолжает работать без разлогина.
Зачем это нужно? (основные плюсы)
Безопасность.
Если accessToken украдут — его действие быстро закончится, а refreshToken хранится максимально безопасно
(например, не доступен из JS).
Удобство для пользователя.
Пользователь не замечает, что accessToken обновляется — сессия «вечная», пока жив refreshToken
(и пользователь не вышел из аккаунта).
Контроль сессий.
Можно отозвать refreshToken на сервере, чтобы разлогинить пользователя на всех устройствах.
Теперь разберемся, зачем нам нужен refreshToken и как с ним работать
Чтобы увидеть проблему, при логинизации укажите accessTokenTTL равный например 10s. Залогиньтесь и попробуйте
обновить плейлист, у вас все должно работать. Потом подождите 10 секунд и опять попробуйте обновить, должна упасть
ошибка, т.к. время жизни accessToken истекло и он стал невалидным.
Библиотека async-mutex нужна для того, чтобы управлять доступом к общим данным в асинхронном коде. Она помогает
избежать ситуаций, когда несколько асинхронных операций одновременно пытаются изменить одни и те же данные
(например, переменную, объект или массив), что может привести к ошибкам или некорректному поведению.
Простыми словами:async-mutex позволяет «заблокировать» ресурс для одной операции, чтобы другие ждали своей очереди. Это как очередь
в магазин — пока один покупатель на кассе, остальные ждут, чтобы не было путаницы.
import { baseApi } from'@/app/api/baseApi.ts'import { baseQuery } from'@/app/api/baseQuery.ts'import { AUTH_KEYS } from'@/common/constants'import { handleErrors, isTokens } from'@/common/utils'importtype { BaseQueryFn, FetchArgs, FetchBaseQueryError } from'@reduxjs/toolkit/query/react'import { Mutex } from'async-mutex'// Создаём новый мьютекс для управления параллельными запросами на обновление токенаconstmutex=newMutex()exportconstbaseQueryWithReauth:BaseQueryFn<string|FetchArgs,unknown,FetchBaseQueryError> =async (args, api, extraOptions) => {// Ждём завершения любого текущего процесса обновления токена (если мьютекс заблокирован)awaitmutex.waitForUnlock()// Выполняем исходный запрос к APIlet result =awaitbaseQuery(args, api, extraOptions)// Если запрос завершился ошибкой 401 Unauthorizedif (result.error &&result.error.status ===401) {// Проверяем, что мьютекс ещё не заблокирован (то есть другой процесс обновления токена не идёт)if (!mutex.isLocked()) {// Блокируем мьютекс, чтобы только один процесс обновления токена выполнялся в данный моментconstrelease=awaitmutex.acquire()try {constrefreshToken=localStorage.getItem(AUTH_KEYS.refreshToken)constrefreshResult=awaitbaseQuery( { url:'/auth/refresh', method:'post', body: { refreshToken } }, api, extraOptions )if (refreshResult.data &&isTokens(refreshResult.data)) {localStorage.setItem(AUTH_KEYS.accessToken,refreshResult.data.accessToken)localStorage.setItem(AUTH_KEYS.refreshToken,refreshResult.data.refreshToken)// Повторяем исходный запрос с новым access token result =awaitbaseQuery(args, api, extraOptions) } else {// Если обновление токена не удалось — выполняем выход из системы// @ts-expect-errorapi.dispatch(baseApi.endpoints.logout.initiate()) } } finally {// Всегда освобождаем мьютекс после завершения обновления токенаrelease() } } else {// Если процесс обновления токена уже идёт — ждём его завершенияawaitmutex.waitForUnlock()// После обновления токенов повторяем исходный запрос result =awaitbaseQuery(args, api, extraOptions) } }// Обрабатываем все ошибки, кроме 401 Unauthorized (они обрабатываются выше)if (result.error &&result.error.status !==401) {handleErrors(result.error) }return result}
⚠️
Из handleErrors.ts удалите case 401, т.к. ошибку авторизации мы обрабатываем по-другому
commin/utils/isTokens.ts
isTokens.ts
exportconstisTokens= (data:unknown): data is { accessToken: string; refreshToken: string } => {return (typeof data ==='object'&& data !==null&&'accessToken'in data &&'refreshToken'in data )}
Результат: авторизация реализована 🚀
Refresh errors
Если мы вылогинимся мы увидим ошибку, т.к. идет refresh запрос с невалидним refresh-токеном.
console
{status:"400",code:5,title:"Validation failed",detail:"refreshToken must be a string; Received value: null",source: {pointer:"/data/attributes/refreshToken", },meta:{timestamp:"2025-07-22T13:12:47.617Z",path:"/api/1.0/auth/refresh", },}
Но мы не можем не показывать ошибки со статусом 400. Нам не нужно это только при ошибке с токеном. Поэтому
немного доработаем 400 case
Сейчас все плейлисты отрисовываются на странице плейлистов ('/playlists').
Но хотелось бы видеть не абсолютно все плейлисты, а только те, которые мы создали сами. Для этого у нас есть
страница профиля.
Чтобы попасть на страницу профиля мы переходим по ссылке, которая находится в Header.tsx.
Давайте этот момент тоже сразу переделаем, и на страницу профиля будем переходить по клику на имя пользователя
Удалите CreatePlaylistForm со страницы PlaylistsPage.tsx
Результат: теперь создавать плейлисты можем только на странице профиля 🚀
Skip
Если на странице профиля открыть developer tools и посмотреть, какие запросы уходят, то вы увидите что за плейлистами
уходит 2 запроса, вместо одного:
первый запрос без query параметров playlists
второй запрос с userIdplaylists?userId=1
Для того чтобы решить этот вопрос и не отправлять лишний запрос на бэкенд воспользуемся
skip
Запросы с использованием хуков автоматически начинают загружать данные, как только компонент монтируется.
Однако бывают случаи, когда вы хотите отложить загрузку данных до тех пор, пока не выполнится определенное условие.
RTK Query поддерживает условную загрузку данных для таких сценариев.
Для предотвращения автоматического выполнения запроса используется булево свойство skip в настройках хука.
Результат: теперь уходит только 1 запрос за плейлистами 🚀
Redirect
Если вы вылогинитесь и обновите страницу профиля, то вы не увидите своих плейлистов, т.к. me-запрос будет падать с
401 ошибкой.
Поэтому логично если мы вылогинены, не пускать пользователя на страницу профиля, редиректить его на главную
(в нашем случае будем редиректить на страницу публичных плейлистов)
Zod — это TypeScript-библиотека для валидации данных и статического вывода типов. Она позволяет безопасно
работать с данными, проверяя их структуру и типы на этапе выполнения (runtime), а также автоматически генерирует
TypeScript-типы на основе схем валидации.
✅ Расширяемость (можно создавать кастомные валидаторы).
Валидация форм
Давайте провалидируем форму для создания нового плейлиста. Если сейчас мы не введем никакие значения в форму и нажмем
кнопку create playlist, то увидим серверную ошибку "title must be longer than or equal to 1 characters; Received value:". Это означает, что бэкенд разработчик предусмотрел такой кейс и не разрешает нам создать плейлист с
пустым title. Но зачем нам для этого вообще делать запрос, давайте реализуем эту задачу на стороне фронта и таким
образом пользователь не будет беспокоить сервер, а сразу увидит ошибку
Нативная валидация через React Hook Form подходит для форм с несложной валидацией и небольшим количеством полей.
Если форма имеет много полей со сложными валидациями, то код становится нечитаемым и сложным для поддержки.
Упростить валидацию форм помогают специальные библиотеки, одной из которых является Zod.
Она поддерживается React Hook Form с помощью @hookform/resolvers.
exportconstcreatePlaylistSchema=z.object({ title: z.string().min(1,'The title length must be more than 1 character').max(100,'The title length must be less than 100 characters'), description:z.string().max(1000,'The description length must be less than 1000 characters.'),})
Результат: теперь выводится кастомное сообщение об ошибке 🚀
exportconstcreatePlaylistSchema=z.object({ title: z.string().min(1,'The title length must be more than 1 character').max(100,'The title length must be less than 100 characters'), description:z.string().max(1000,'The description length must be less than 1000 characters.'),})exportconstplaylistMetaSchema=z.object({ page:z.int().positive(), pageSize:z.int().positive(), totalCount:z.int().positive(), pagesCount:z.int().positive(),})exportconstplaylistAttributesSchema=z.object({ title:z.string(), description:z.string(), addedAt:z.iso.datetime(), updatedAt:z.iso.datetime(), order:z.int(), dislikesCount:z.int().nonnegative(), likesCount:z.int().nonnegative(), tags:z.array(tagSchema), images: imagesSchema, user: userSchema, currentUserReaction: currentUserReactionSchema,})exportconstplaylistDataSchema=z.object({ id:z.string(), type:z.literal('playlists'), attributes: playlistAttributesSchema,})exportconstplaylistsResponseSchema=z.object({ data:z.array(playlistDataSchema), meta: playlistMetaSchema,})
Если бэкенд ничего не возвращает, значит и валидировать ничего не нужно. Соответственно эндпоинты
deletePlaylist, updatePlaylist и deletePlaylistCover оставляем как есть.
Результат: покрыли zod валидацией все ответы от сервера для плейлистов 🚀
withZodCatch
Обратите внимание, что теперь нам в каждом эндпоинте где мы хотим сделать валидацию придется дублировать
catchSchemaFailure
Вебсокеты — это технология, которая позволяет устанавливать постоянное двустороннее соединение между клиентом
(обычно браузером) и сервером.
В отличие от HTTP‑запросов, где клиент инициирует каждый запрос, при WebSocket соединении обе стороны могут в
реальном времени отправлять и получать данные, без необходимости заново открывать соединение.
Простой пример:
HTTP: клиент → сервер → ответ → соединение закрывается.
WebSocket: клиент ↔ сервер (открыто постоянное соединение, и обе стороны могут обмениваться сообщениями, когда
угодно).
Когда мы находимся на странице публичных плейлистов, то любой пользователь может создать новый плейлист.
И хотелось бы об этом знать сразу же и без перезагрузки увидеть новый плейлист. Для этого будем использовать библиотеку
socket.io
Socket.IO — это библиотека для реального времени, которая использует WebSocket и fallback‑технологии, предоставляя
удобный API для двусторонней связи между клиентом и сервером.
exportconstplaylistsApi=baseApi.injectEndpoints({endpoints: build => ({ fetchPlaylists:build.query({query: (params:FetchPlaylistsArgs) => ({ url:`playlists`, params }),...withZodCatch(playlistsResponseSchema), keepUnusedDataFor:0,// 👈 очистка сразу после размонтированияasynconCacheEntryAdded(_arg, { updateCachedData, cacheDataLoaded, cacheEntryRemoved }) {// Ждем разрешения начального запроса перед продолжениемawait cacheDataLoaded// Создаем Socket.IO соединение с серверомconstsocket:Socket=io('https://musicfun.it-incubator.app', { path:'/api/1.0/ws',// пользовательский путь для Socket.IO сервера (по умолчанию '/socket.io/') transports: ['websocket'], })socket.on('connect', () =>console.log('✅ Подключен к серверу'))socket.on('tracks.playlist-created', (msg:PlaylistCreatedEvent) => {// 1 вариантconstnewPlaylist=msg.payload.dataupdateCachedData(state => {state.data.pop()state.data.unshift(newPlaylist)state.meta.totalCount =state.meta.totalCount +1state.meta.pagesCount =Math.ceil(state.meta.totalCount /state.meta.pageSize) })// 2 вариант// dispatch(playlistsApi.util.invalidateTags(['Playlist'])) })// CacheEntryRemoved разрешится, когда подписка на кеш больше не активнаawait cacheEntryRemoved// Выполняем шаги очистки после разрешения промиса `cacheEntryRemoved`socket.on('disconnect', () =>console.log('❌ Соединение разорвано')) }, providesTags: ['Playlist'], }),/*...*/ }),})
Результат: при создании нового плейлиста у вас автоматически добавится новый плейлист 🚀
Вынесение логики
Если нам будет необходимо внедрить вебсокеты для другого события, то нам придется дублировать код и открывать несколько
соединений, поэтому сразу зарефакторим и декомпозируем код