From dfc298520931543796de43f3557df5582ec2a134 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=92=D0=B8=D1=82=D0=B0=D0=BB=D0=B8=D0=B9=20=D0=9B=D0=B0?= =?UTF-8?q?=D0=B2=D1=88=D0=BE=D0=BD=D0=BE=D0=BA?= <114582703+valavshonok@users.noreply.github.com> Date: Sat, 15 Nov 2025 17:37:47 +0300 Subject: [PATCH] auth + groups invite --- src/App.tsx | 21 +- src/components/router/ProtectedRoute.tsx | 8 +- src/pages/Home.tsx | 11 +- src/redux/slices/auth.ts | 34 +- src/redux/slices/contests.ts | 9 +- src/redux/slices/groups.ts | 435 +++++++++++++------- src/redux/slices/missions.ts | 5 +- src/views/home/auth/Login.tsx | 8 +- src/views/home/auth/Register.tsx | 8 +- src/views/home/groupinviter/GroupInvite.tsx | 111 +++++ src/views/home/groups/GroupItem.tsx | 20 +- src/views/home/groups/Groups.tsx | 109 +++-- src/views/home/groups/GroupsBlock.tsx | 11 +- src/views/home/groups/ModalCreate.tsx | 2 +- src/views/home/groups/ModalInvite.tsx | 102 +++++ src/views/home/groups/ModalUpdate.tsx | 4 +- 16 files changed, 673 insertions(+), 225 deletions(-) create mode 100644 src/views/home/groupinviter/GroupInvite.tsx create mode 100644 src/views/home/groups/ModalInvite.tsx diff --git a/src/App.tsx b/src/App.tsx index 609203e..009f229 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,22 +9,27 @@ import Mission from './pages/Mission'; import ArticleEditor from './pages/ArticleEditor'; import Article from './pages/Article'; import ContestEditor from './pages/ContestEditor'; +import ProtectedRoute from './components/router/ProtectedRoute'; function App() { return (
+ }> + } + /> + } + /> + + } /> } /> - } - /> - } - /> + } /> } /> diff --git a/src/components/router/ProtectedRoute.tsx b/src/components/router/ProtectedRoute.tsx index 775a461..c78e309 100644 --- a/src/components/router/ProtectedRoute.tsx +++ b/src/components/router/ProtectedRoute.tsx @@ -1,11 +1,15 @@ // src/routes/ProtectedRoute.tsx -import { Navigate, Outlet } from 'react-router-dom'; +import { Navigate, Outlet, useLocation } from 'react-router-dom'; import { useAppSelector } from '../../redux/hooks'; export default function ProtectedRoute() { const isAuthenticated = useAppSelector((state) => !!state.auth.jwt); + const location = useLocation(); + + console.log('location', location); + if (!isAuthenticated) { - return ; + return ; } return ; diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index 5b3114c..b133b62 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -1,4 +1,4 @@ -// import React from "react"; +// src/pages/Home.tsx import { Route, Routes } from 'react-router-dom'; import Login from '../views/home/auth/Login'; import Register from '../views/home/auth/Register'; @@ -18,6 +18,7 @@ import ProtectedRoute from '../components/router/ProtectedRoute'; import { MissionsRightPanel } from '../views/home/rightpanel/Missions'; import { ArticlesRightPanel } from '../views/home/rightpanel/Articles'; import { GroupRightPanel } from '../views/home/rightpanel/Group'; +import GroupInvite from '../views/home/groupinviter/GroupInvite'; const Home = () => { const name = useAppSelector((state) => state.auth.username); @@ -37,14 +38,18 @@ const Home = () => { }> } /> + } + /> + } /> + } /> } /> } /> } /> } /> - } /> - } /> } /> } /> { + async (_, { dispatch, getState, rejectWithValue }) => { try { const response = await axios.get('/authentication/whoami'); return response.data; } catch (err: any) { + const state: any = getState(); + const refresh = state.auth.refreshToken; + + if (refresh) { + // пробуем refresh + const result = await dispatch( + refreshToken({ refreshToken: refresh }), + ); + + // если успешный, повторить whoami + if (refreshToken.fulfilled.match(result)) { + const retry = await axios.get('/authentication/whoami'); + return retry.data; + } + } return rejectWithValue( err.response?.data?.message || 'Failed to fetch user info', ); @@ -269,6 +284,23 @@ const authSlice = createSlice({ builder.addCase(fetchWhoAmI.rejected, (state, action) => { state.status = 'failed'; state.error = action.payload as string; + + // Если пользователь не авторизован (401), делаем logout и пытаемся refresh + console.log(action); + if ( + action.payload === 'Unauthorized' || + action.payload === 'Failed to fetch user info' + ) { + // Вызов logout + state.jwt = null; + state.refreshToken = null; + state.username = null; + state.email = null; + state.id = null; + localStorage.removeItem('jwt'); + localStorage.removeItem('refreshToken'); + delete axios.defaults.headers.common['Authorization']; + } }); }, }); diff --git a/src/redux/slices/contests.ts b/src/redux/slices/contests.ts index 1e2970c..5ddff69 100644 --- a/src/redux/slices/contests.ts +++ b/src/redux/slices/contests.ts @@ -33,8 +33,6 @@ export interface Submission { sourceType: string; } - - export interface Mission { id: number; authorId: number; @@ -124,8 +122,6 @@ interface ContestsState { status: Status; error?: string; }; - - // 🆕 Добавляем updateContest и deleteContest updateContest: { contest: Contest; status: Status; @@ -176,7 +172,7 @@ const initialState: ContestsState = { status: 'idle', error: undefined, }, - fetchMySubmissions: { + fetchMySubmissions: { submissions: [], status: 'idle', error: undefined, @@ -262,7 +258,6 @@ export const fetchMySubmissions = createAsyncThunk( }, ); - // Все контесты export const fetchContests = createAsyncThunk( 'contests/fetchAll', @@ -435,8 +430,6 @@ const contestsSlice = createSlice({ state.fetchMySubmissions.error = action.payload; }); - - // fetchContests builder.addCase(fetchContests.pending, (state) => { state.fetchContests.status = 'loading'; diff --git a/src/redux/slices/groups.ts b/src/redux/slices/groups.ts index e9c586d..38350bb 100644 --- a/src/redux/slices/groups.ts +++ b/src/redux/slices/groups.ts @@ -1,7 +1,9 @@ import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit'; import axios from '../../axios'; -// ─── Типы ──────────────────────────────────────────── +// ===================== +// Типы +// ===================== type Status = 'idle' | 'loading' | 'successful' | 'failed'; @@ -19,39 +21,106 @@ export interface Group { contests: any[]; } +// ===================== +// Состояние +// ===================== + interface GroupsState { - groups: Group[]; - currentGroup: Group | null; - statuses: { - create: Status; - update: Status; - delete: Status; - fetchMy: Status; - fetchById: Status; - addMember: Status; - removeMember: Status; + fetchMyGroups: { + groups: Group[]; + status: Status; + error?: string; + }; + fetchGroupById: { + group?: Group; + status: Status; + error?: string; + }; + createGroup: { + group?: Group; + status: Status; + error?: string; + }; + updateGroup: { + group?: Group; + status: Status; + error?: string; + }; + deleteGroup: { + deletedId?: number; + status: Status; + error?: string; + }; + addGroupMember: { + status: Status; + error?: string; + }; + removeGroupMember: { + status: Status; + error?: string; + }; + fetchGroupJoinLink: { + joinLink?: { token: string; expiresAt: string }; + status: Status; + error?: string; + }; + joinGroupByToken: { + group?: Group; + status: Status; + error?: string; }; - error: string | null; } const initialState: GroupsState = { - groups: [], - currentGroup: null, - statuses: { - create: 'idle', - update: 'idle', - delete: 'idle', - fetchMy: 'idle', - fetchById: 'idle', - addMember: 'idle', - removeMember: 'idle', + fetchMyGroups: { + groups: [], + status: 'idle', + error: undefined, + }, + fetchGroupById: { + group: undefined, + status: 'idle', + error: undefined, + }, + createGroup: { + group: undefined, + status: 'idle', + error: undefined, + }, + updateGroup: { + group: undefined, + status: 'idle', + error: undefined, + }, + deleteGroup: { + deletedId: undefined, + status: 'idle', + error: undefined, + }, + addGroupMember: { + status: 'idle', + error: undefined, + }, + removeGroupMember: { + status: 'idle', + error: undefined, + }, + fetchGroupJoinLink: { + joinLink: undefined, + status: 'idle', + error: undefined, + }, + joinGroupByToken: { + group: undefined, + status: 'idle', + error: undefined, }, - error: null, }; -// ─── Async Thunks ───────────────────────────────────── +// ===================== +// Async Thunks +// ===================== -// POST /groups export const createGroup = createAsyncThunk( 'groups/createGroup', async ( @@ -69,7 +138,6 @@ export const createGroup = createAsyncThunk( }, ); -// PUT /groups/{groupId} export const updateGroup = createAsyncThunk( 'groups/updateGroup', async ( @@ -94,7 +162,6 @@ export const updateGroup = createAsyncThunk( }, ); -// DELETE /groups/{groupId} export const deleteGroup = createAsyncThunk( 'groups/deleteGroup', async (groupId: number, { rejectWithValue }) => { @@ -109,7 +176,6 @@ export const deleteGroup = createAsyncThunk( }, ); -// GET /groups/my export const fetchMyGroups = createAsyncThunk( 'groups/fetchMyGroups', async (_, { rejectWithValue }) => { @@ -124,7 +190,6 @@ export const fetchMyGroups = createAsyncThunk( }, ); -// GET /groups/{groupId} export const fetchGroupById = createAsyncThunk( 'groups/fetchGroupById', async (groupId: number, { rejectWithValue }) => { @@ -139,16 +204,22 @@ export const fetchGroupById = createAsyncThunk( }, ); -// POST /groups/members export const addGroupMember = createAsyncThunk( 'groups/addGroupMember', async ( - { userId, role }: { userId: number; role: string }, + { + groupId, + userId, + role, + }: { groupId: number; userId: number; role: string }, { rejectWithValue }, ) => { try { - await axios.post('/groups/members', { userId, role }); - return { userId, role }; + const response = await axios.post(`/groups/${groupId}/members`, { + userId, + role, + }); + return response.data; } catch (err: any) { return rejectWithValue( err.response?.data?.message || @@ -158,7 +229,6 @@ export const addGroupMember = createAsyncThunk( }, ); -// DELETE /groups/{groupId}/members/{memberId} export const removeGroupMember = createAsyncThunk( 'groups/removeGroupMember', async ( @@ -176,147 +246,169 @@ export const removeGroupMember = createAsyncThunk( }, ); -// ─── Slice ──────────────────────────────────────────── +// ===================== +// Новые Async Thunks +// ===================== + +// Получение актуальной ссылки для присоединения к группе +export const fetchGroupJoinLink = createAsyncThunk( + 'groups/fetchGroupJoinLink', + async (groupId: number, { rejectWithValue }) => { + try { + const response = await axios.get(`/groups/${groupId}/join-link`); + return response.data as { token: string; expiresAt: string }; + } catch (err: any) { + return rejectWithValue( + err.response?.data?.message || + 'Ошибка при получении ссылки для присоединения', + ); + } + }, +); + +// Присоединение к группе по токену приглашения +export const joinGroupByToken = createAsyncThunk( + 'groups/joinGroupByToken', + async (token: string, { rejectWithValue }) => { + try { + const response = await axios.post(`/groups/join/${token}`); + return response.data as Group; + } catch (err: any) { + return rejectWithValue( + err.response?.data?.message || + 'Ошибка при присоединении к группе по ссылке', + ); + } + }, +); + +// ===================== +// Slice +// ===================== const groupsSlice = createSlice({ name: 'groups', initialState, reducers: { - clearCurrentGroup: (state) => { - state.currentGroup = null; + setGroupsStatus: ( + state, + action: PayloadAction<{ key: keyof GroupsState; status: Status }>, + ) => { + const { key, status } = action.payload; + if (state[key]) { + (state[key] as any).status = status; + } }, }, extraReducers: (builder) => { - // ─── CREATE GROUP ─── - builder.addCase(createGroup.pending, (state) => { - state.statuses.create = 'loading'; - state.error = null; - }); - builder.addCase( - createGroup.fulfilled, - (state, action: PayloadAction) => { - state.statuses.create = 'successful'; - state.groups.push(action.payload); - }, - ); - builder.addCase( - createGroup.rejected, - (state, action: PayloadAction) => { - state.statuses.create = 'failed'; - state.error = action.payload; - }, - ); - - // ─── UPDATE GROUP ─── - builder.addCase(updateGroup.pending, (state) => { - state.statuses.update = 'loading'; - state.error = null; - }); - builder.addCase( - updateGroup.fulfilled, - (state, action: PayloadAction) => { - state.statuses.update = 'successful'; - const index = state.groups.findIndex( - (g) => g.id === action.payload.id, - ); - if (index !== -1) state.groups[index] = action.payload; - if (state.currentGroup?.id === action.payload.id) { - state.currentGroup = action.payload; - } - }, - ); - builder.addCase( - updateGroup.rejected, - (state, action: PayloadAction) => { - state.statuses.update = 'failed'; - state.error = action.payload; - }, - ); - - // ─── DELETE GROUP ─── - builder.addCase(deleteGroup.pending, (state) => { - state.statuses.delete = 'loading'; - state.error = null; - }); - builder.addCase( - deleteGroup.fulfilled, - (state, action: PayloadAction) => { - state.statuses.delete = 'successful'; - state.groups = state.groups.filter( - (g) => g.id !== action.payload, - ); - if (state.currentGroup?.id === action.payload) - state.currentGroup = null; - }, - ); - builder.addCase( - deleteGroup.rejected, - (state, action: PayloadAction) => { - state.statuses.delete = 'failed'; - state.error = action.payload; - }, - ); - - // ─── FETCH MY GROUPS ─── + // fetchMyGroups builder.addCase(fetchMyGroups.pending, (state) => { - state.statuses.fetchMy = 'loading'; - state.error = null; + state.fetchMyGroups.status = 'loading'; }); builder.addCase( fetchMyGroups.fulfilled, (state, action: PayloadAction) => { - state.statuses.fetchMy = 'successful'; - state.groups = action.payload; - }, - ); - builder.addCase( - fetchMyGroups.rejected, - (state, action: PayloadAction) => { - state.statuses.fetchMy = 'failed'; - state.error = action.payload; + state.fetchMyGroups.status = 'successful'; + state.fetchMyGroups.groups = action.payload; }, ); + builder.addCase(fetchMyGroups.rejected, (state, action: any) => { + state.fetchMyGroups.status = 'failed'; + state.fetchMyGroups.error = action.payload; + }); - // ─── FETCH GROUP BY ID ─── + // fetchGroupById builder.addCase(fetchGroupById.pending, (state) => { - state.statuses.fetchById = 'loading'; - state.error = null; + state.fetchGroupById.status = 'loading'; }); builder.addCase( fetchGroupById.fulfilled, (state, action: PayloadAction) => { - state.statuses.fetchById = 'successful'; - state.currentGroup = action.payload; - }, - ); - builder.addCase( - fetchGroupById.rejected, - (state, action: PayloadAction) => { - state.statuses.fetchById = 'failed'; - state.error = action.payload; + state.fetchGroupById.status = 'successful'; + state.fetchGroupById.group = action.payload; }, ); + builder.addCase(fetchGroupById.rejected, (state, action: any) => { + state.fetchGroupById.status = 'failed'; + state.fetchGroupById.error = action.payload; + }); - // ─── ADD MEMBER ─── + // createGroup + builder.addCase(createGroup.pending, (state) => { + state.createGroup.status = 'loading'; + }); + builder.addCase( + createGroup.fulfilled, + (state, action: PayloadAction) => { + state.createGroup.status = 'successful'; + state.createGroup.group = action.payload; + state.fetchMyGroups.groups.push(action.payload); + }, + ); + builder.addCase(createGroup.rejected, (state, action: any) => { + state.createGroup.status = 'failed'; + state.createGroup.error = action.payload; + }); + + // updateGroup + builder.addCase(updateGroup.pending, (state) => { + state.updateGroup.status = 'loading'; + }); + builder.addCase( + updateGroup.fulfilled, + (state, action: PayloadAction) => { + state.updateGroup.status = 'successful'; + state.updateGroup.group = action.payload; + const index = state.fetchMyGroups.groups.findIndex( + (g) => g.id === action.payload.id, + ); + if (index !== -1) + state.fetchMyGroups.groups[index] = action.payload; + if (state.fetchGroupById.group?.id === action.payload.id) + state.fetchGroupById.group = action.payload; + }, + ); + builder.addCase(updateGroup.rejected, (state, action: any) => { + state.updateGroup.status = 'failed'; + state.updateGroup.error = action.payload; + }); + + // deleteGroup + builder.addCase(deleteGroup.pending, (state) => { + state.deleteGroup.status = 'loading'; + }); + builder.addCase( + deleteGroup.fulfilled, + (state, action: PayloadAction) => { + state.deleteGroup.status = 'successful'; + state.deleteGroup.deletedId = action.payload; + state.fetchMyGroups.groups = state.fetchMyGroups.groups.filter( + (g) => g.id !== action.payload, + ); + if (state.fetchGroupById.group?.id === action.payload) + state.fetchGroupById.group = undefined; + }, + ); + builder.addCase(deleteGroup.rejected, (state, action: any) => { + state.deleteGroup.status = 'failed'; + state.deleteGroup.error = action.payload; + }); + + // addGroupMember builder.addCase(addGroupMember.pending, (state) => { - state.statuses.addMember = 'loading'; - state.error = null; + state.addGroupMember.status = 'loading'; }); builder.addCase(addGroupMember.fulfilled, (state) => { - state.statuses.addMember = 'successful'; + state.addGroupMember.status = 'successful'; + }); + builder.addCase(addGroupMember.rejected, (state, action: any) => { + state.addGroupMember.status = 'failed'; + state.addGroupMember.error = action.payload; }); - builder.addCase( - addGroupMember.rejected, - (state, action: PayloadAction) => { - state.statuses.addMember = 'failed'; - state.error = action.payload; - }, - ); - // ─── REMOVE MEMBER ─── + // removeGroupMember builder.addCase(removeGroupMember.pending, (state) => { - state.statuses.removeMember = 'loading'; - state.error = null; + state.removeGroupMember.status = 'loading'; }); builder.addCase( removeGroupMember.fulfilled, @@ -324,27 +416,60 @@ const groupsSlice = createSlice({ state, action: PayloadAction<{ groupId: number; memberId: number }>, ) => { - state.statuses.removeMember = 'successful'; + state.removeGroupMember.status = 'successful'; if ( - state.currentGroup && - state.currentGroup.id === action.payload.groupId + state.fetchGroupById.group && + state.fetchGroupById.group.id === action.payload.groupId ) { - state.currentGroup.members = - state.currentGroup.members.filter( + state.fetchGroupById.group.members = + state.fetchGroupById.group.members.filter( (m) => m.userId !== action.payload.memberId, ); } }, ); + builder.addCase(removeGroupMember.rejected, (state, action: any) => { + state.removeGroupMember.status = 'failed'; + state.removeGroupMember.error = action.payload; + }); + + // fetchGroupJoinLink + builder.addCase(fetchGroupJoinLink.pending, (state) => { + state.fetchGroupJoinLink.status = 'loading'; + }); builder.addCase( - removeGroupMember.rejected, - (state, action: PayloadAction) => { - state.statuses.removeMember = 'failed'; - state.error = action.payload; + fetchGroupJoinLink.fulfilled, + ( + state, + action: PayloadAction<{ token: string; expiresAt: string }>, + ) => { + state.fetchGroupJoinLink.status = 'successful'; + state.fetchGroupJoinLink.joinLink = action.payload; }, ); + builder.addCase(fetchGroupJoinLink.rejected, (state, action: any) => { + state.fetchGroupJoinLink.status = 'failed'; + state.fetchGroupJoinLink.error = action.payload; + }); + + // joinGroupByToken + builder.addCase(joinGroupByToken.pending, (state) => { + state.joinGroupByToken.status = 'loading'; + }); + builder.addCase( + joinGroupByToken.fulfilled, + (state, action: PayloadAction) => { + state.joinGroupByToken.status = 'successful'; + state.joinGroupByToken.group = action.payload; + state.fetchMyGroups.groups.push(action.payload); // добавим новую группу в список + }, + ); + builder.addCase(joinGroupByToken.rejected, (state, action: any) => { + state.joinGroupByToken.status = 'failed'; + state.joinGroupByToken.error = action.payload; + }); }, }); -export const { clearCurrentGroup } = groupsSlice.actions; +export const { setGroupsStatus } = groupsSlice.actions; export const groupsReducer = groupsSlice.reducer; diff --git a/src/redux/slices/missions.ts b/src/redux/slices/missions.ts index 9125402..f3c84ee 100644 --- a/src/redux/slices/missions.ts +++ b/src/redux/slices/missions.ts @@ -147,9 +147,6 @@ const missionsSlice = createSlice({ name: 'missions', initialState, reducers: { - clearCurrentMission: (state) => { - state.currentMission = null; - }, setMissionsStatus: ( state, action: PayloadAction<{ @@ -251,5 +248,5 @@ const missionsSlice = createSlice({ }, }); -export const { clearCurrentMission, setMissionsStatus } = missionsSlice.actions; +export const { setMissionsStatus } = missionsSlice.actions; export const missionsReducer = missionsSlice.reducer; diff --git a/src/views/home/auth/Login.tsx b/src/views/home/auth/Login.tsx index 548be70..d83cea5 100644 --- a/src/views/home/auth/Login.tsx +++ b/src/views/home/auth/Login.tsx @@ -1,8 +1,9 @@ +// src/views/home/auth/Login.tsx import { useState, useEffect } from 'react'; import { PrimaryButton } from '../../../components/button/PrimaryButton'; import { Input } from '../../../components/input/Input'; import { useAppDispatch, useAppSelector } from '../../../redux/hooks'; -import { Link, useNavigate } from 'react-router-dom'; +import { Link, useLocation, useNavigate } from 'react-router-dom'; import { loginUser } from '../../../redux/slices/auth'; // import { cn } from "../../../lib/cn"; import { setMenuActivePage } from '../../../redux/slices/store'; @@ -13,6 +14,7 @@ import { googleLogo } from '../../../assets/icons/input'; const Login = () => { const dispatch = useAppDispatch(); const navigate = useNavigate(); + const location = useLocation(); const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); @@ -30,7 +32,9 @@ const Login = () => { useEffect(() => { if (jwt) { - navigate('/home/account'); // или другая страница после входа + const from = location.state?.from; + const path = from ? from.pathname + from.search : '/home/account'; + navigate(path, { replace: true }); } }, [jwt]); diff --git a/src/views/home/auth/Register.tsx b/src/views/home/auth/Register.tsx index d341b39..d096422 100644 --- a/src/views/home/auth/Register.tsx +++ b/src/views/home/auth/Register.tsx @@ -1,8 +1,9 @@ +// src/views/home/auth/Register.tsx import { useState, useEffect } from 'react'; import { PrimaryButton } from '../../../components/button/PrimaryButton'; import { Input } from '../../../components/input/Input'; import { useAppDispatch, useAppSelector } from '../../../redux/hooks'; -import { useNavigate } from 'react-router-dom'; +import { useLocation, useNavigate } from 'react-router-dom'; import { registerUser } from '../../../redux/slices/auth'; // import { cn } from "../../../lib/cn"; import { setMenuActivePage } from '../../../redux/slices/store'; @@ -15,6 +16,7 @@ import { googleLogo } from '../../../assets/icons/input'; const Register = () => { const dispatch = useAppDispatch(); const navigate = useNavigate(); + const location = useLocation(); const [username, setUsername] = useState(''); const [email, setEmail] = useState(''); @@ -32,7 +34,9 @@ const Register = () => { useEffect(() => { if (jwt) { - navigate('/home/account'); + const from = location.state?.from; + const path = from ? from.pathname + from.search : '/home/account'; + navigate(path, { replace: true }); } console.log(submitClicked); }, [jwt]); diff --git a/src/views/home/groupinviter/GroupInvite.tsx b/src/views/home/groupinviter/GroupInvite.tsx new file mode 100644 index 0000000..893d817 --- /dev/null +++ b/src/views/home/groupinviter/GroupInvite.tsx @@ -0,0 +1,111 @@ +import { useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useAppDispatch, useAppSelector } from '../../../redux/hooks'; +import { setMenuActivePage } from '../../../redux/slices/store'; +import { useQuery } from '../../../hooks/useQuery'; +import { PrimaryButton } from '../../../components/button/PrimaryButton'; +import { SecondaryButton } from '../../../components/button/SecondaryButton'; +import { + joinGroupByToken, + setGroupsStatus, +} from '../../../redux/slices/groups'; + +const GroupInvite = () => { + const dispatch = useAppDispatch(); + const navigate = useNavigate(); + + const query = useQuery(); + const token = query.get('token') ?? undefined; + const expiresAt = query.get('expiresAt') ?? undefined; + const groupName = query.get('groupName') ?? undefined; + const groupId = Number(query.get('groupId') ?? undefined); + + const username = useAppSelector((state) => state.auth.username); + const joinStatus = useAppSelector( + (state) => state.groups.joinGroupByToken.status, + ); + const joinError = useAppSelector( + (state) => state.groups.joinGroupByToken.error, + ); + + useEffect(() => { + dispatch(setMenuActivePage('groups')); + }, []); + + useEffect(() => { + if (joinStatus == 'successful') { + dispatch( + setGroupsStatus({ key: 'joinGroupByToken', status: 'idle' }), + ); + navigate(`/group/${groupId}`); + } + }, [joinStatus]); + + if (!token || !expiresAt || !groupName || !groupId) { + return ( +
+ Приглашение признано недействительным. +
+ ); + } + + const isExpired = new Date(expiresAt) < new Date(); + + if (isExpired) { + return ( +
+ Период действия приглашения истек. +
+ ); + } + + const handleJoin = async () => { + if (!token) return; + try { + await dispatch(joinGroupByToken(token)).unwrap(); + } catch (err) { + console.error('Failed to join group', err); + } + }; + + const handleCancel = () => { + navigate('/home/account'); + }; + + return ( +
+
+
+ Привет, {username}! +
+
+ Вы действительно хотите присоединиться к группе: +
+
+ "{groupName}" +
+ + {joinError && ( +
+ Ошибка присоединения: {joinError} +
+ )} + +
+ + +
+
+
+ ); +}; + +export default GroupInvite; diff --git a/src/views/home/groups/GroupItem.tsx b/src/views/home/groups/GroupItem.tsx index d7f579f..4a979b0 100644 --- a/src/views/home/groups/GroupItem.tsx +++ b/src/views/home/groups/GroupItem.tsx @@ -7,7 +7,7 @@ import { EyeOpen, } from '../../../assets/icons/groups'; import { useNavigate } from 'react-router-dom'; -import { GroupUpdate } from './Groups'; +import { GroupInvite, GroupUpdate } from './Groups'; export interface GroupItemProps { id: number; @@ -17,6 +17,9 @@ export interface GroupItemProps { description: string; setUpdateActive: (value: any) => void; setUpdateGroup: (value: GroupUpdate) => void; + setInviteActive: (value: any) => void; + setInviteGroup: (value: GroupInvite) => void; + type: 'manage' | 'member'; } interface IconComponentProps { @@ -45,6 +48,9 @@ const GroupItem: React.FC = ({ description, setUpdateGroup, setUpdateActive, + setInviteActive, + setInviteGroup, + type, }) => { const navigate = useNavigate(); @@ -63,10 +69,16 @@ const GroupItem: React.FC = ({
{name}
- {(role == 'menager' || role == 'owner') && ( - + {type == 'manage' && ( + { + setInviteActive(true); + setInviteGroup({ id, name }); + }} + /> )} - {(role == 'menager' || role == 'owner') && ( + {type == 'manage' && ( { diff --git a/src/views/home/groups/Groups.tsx b/src/views/home/groups/Groups.tsx index 51650e6..2752d3c 100644 --- a/src/views/home/groups/Groups.tsx +++ b/src/views/home/groups/Groups.tsx @@ -8,6 +8,7 @@ import { fetchMyGroups } from '../../../redux/slices/groups'; import ModalCreate from './ModalCreate'; import ModalUpdate from './ModalUpdate'; import Filters from './Filter'; +import ModalInvite from './ModalInvite'; export interface GroupUpdate { id: number; @@ -15,19 +16,35 @@ export interface GroupUpdate { description: string; } +export interface GroupInvite { + id: number; + name: string; +} + const Groups = () => { - const [modalActive, setModalActive] = useState(false); - const [modelUpdateActive, setModalUpdateActive] = useState(false); + const [modalActive, setModalActive] = useState(false); + const [modalUpdateActive, setModalUpdateActive] = useState(false); const [updateGroup, setUpdateGroup] = useState({ id: 0, name: '', description: '', }); + const [modalInviteActive, setModalInviteActive] = useState(false); + const [inviteGroup, setInviteGroup] = useState({ + id: 0, + name: '', + }); const dispatch = useAppDispatch(); - // Берём группы из стора - const groups = useAppSelector((store) => store.groups.groups); + // ✅ Берём группы и статус из нового слайса + const groups = useAppSelector((store) => store.groups.fetchMyGroups.groups); + const groupsStatus = useAppSelector( + (store) => store.groups.fetchMyGroups.status, + ); + const groupsError = useAppSelector( + (store) => store.groups.fetchMyGroups.error, + ); // Берём текущего пользователя const currentUserName = useAppSelector((store) => store.auth.username); @@ -52,8 +69,8 @@ const Groups = () => { (m) => m.username === currentUserName, ); if (!me) return; - - if (me.role === 'Administrator') { + const roles = me.role.split(',').map((r) => r.trim()); + if (roles.includes('Administrator')) { managed.push(group); } else { current.push(group); @@ -68,7 +85,7 @@ const Groups = () => { }, [groups, currentUserName]); return ( -
+
{ Группы
{ - setModalActive(true); - }} + onClick={() => setModalActive(true)} text="Создать группу" className="absolute right-0" /> @@ -89,37 +104,67 @@ const Groups = () => { - - - + {groupsStatus === 'loading' && ( +
+ Загрузка групп... +
+ )} + {groupsStatus === 'failed' && ( +
+ Ошибка: {groupsError || 'Не удалось загрузить группы'} +
+ )} + + {groupsStatus === 'successful' && ( + <> + + + + + )}
+
); }; diff --git a/src/views/home/groups/GroupsBlock.tsx b/src/views/home/groups/GroupsBlock.tsx index e66d713..2f64f17 100644 --- a/src/views/home/groups/GroupsBlock.tsx +++ b/src/views/home/groups/GroupsBlock.tsx @@ -3,7 +3,7 @@ import GroupItem from './GroupItem'; import { cn } from '../../../lib/cn'; import { ChevroneDown } from '../../../assets/icons/groups'; import { Group } from '../../../redux/slices/groups'; -import { GroupUpdate } from './Groups'; +import { GroupInvite, GroupUpdate } from './Groups'; interface GroupsBlockProps { groups: Group[]; @@ -11,6 +11,9 @@ interface GroupsBlockProps { className?: string; setUpdateActive: (value: any) => void; setUpdateGroup: (value: GroupUpdate) => void; + setInviteActive: (value: any) => void; + setInviteGroup: (value: GroupInvite) => void; + type: 'manage' | 'member'; } const GroupsBlock: FC = ({ @@ -19,6 +22,9 @@ const GroupsBlock: FC = ({ className, setUpdateActive, setUpdateGroup, + setInviteActive, + setInviteGroup, + type, }) => { const [active, setActive] = useState(title != 'Скрытые'); @@ -63,8 +69,11 @@ const GroupsBlock: FC = ({ description={v.description} setUpdateActive={setUpdateActive} setUpdateGroup={setUpdateGroup} + setInviteActive={setInviteActive} + setInviteGroup={setInviteGroup} role={'owner'} name={v.name} + type={type} /> ))}
diff --git a/src/views/home/groups/ModalCreate.tsx b/src/views/home/groups/ModalCreate.tsx index 458c491..ecda215 100644 --- a/src/views/home/groups/ModalCreate.tsx +++ b/src/views/home/groups/ModalCreate.tsx @@ -14,7 +14,7 @@ interface ModalCreateProps { const ModalCreate: FC = ({ active, setActive }) => { const [name, setName] = useState(''); const [description, setDescription] = useState(''); - const status = useAppSelector((state) => state.groups.statuses.create); + const status = useAppSelector((state) => state.groups.createGroup.status); const dispatch = useAppDispatch(); useEffect(() => { diff --git a/src/views/home/groups/ModalInvite.tsx b/src/views/home/groups/ModalInvite.tsx new file mode 100644 index 0000000..a00cfec --- /dev/null +++ b/src/views/home/groups/ModalInvite.tsx @@ -0,0 +1,102 @@ +import { FC, useEffect, useMemo } from 'react'; +import { useAppDispatch, useAppSelector } from '../../../redux/hooks'; +import { fetchGroupJoinLink } from '../../../redux/slices/groups'; +import { Modal } from '../../../components/modal/Modal'; +import { PrimaryButton } from '../../../components/button/PrimaryButton'; +import { SecondaryButton } from '../../../components/button/SecondaryButton'; +import { Input } from '../../../components/input/Input'; + +interface ModalInviteProps { + active: boolean; + setActive: (value: boolean) => void; + groupId: number; + groupName: string; +} + +const ModalInvite: FC = ({ + active, + setActive, + groupId, + groupName, +}) => { + const dispatch = useAppDispatch(); + const baseUrl = window.location.origin; + + // Получаем токен и дату из Redux + const { joinLink, status } = useAppSelector( + (state) => state.groups.fetchGroupJoinLink, + ); + + // При открытии модалки запрашиваем join link + useEffect(() => { + if (active) { + dispatch(fetchGroupJoinLink(groupId)); + } + }, [active, groupId, dispatch]); + + // Генерация полной ссылки с query параметрами + const inviteLink = useMemo(() => { + if (!joinLink) return ''; + const params = new URLSearchParams({ + token: joinLink.token, + expiresAt: joinLink.expiresAt, + groupName, + groupId: `${groupId}`, + }); + return `${baseUrl}/home/group-invite?${params.toString()}`; + }, [joinLink, groupName, baseUrl, groupId]); + + // Копирование и закрытие модалки + const handleCopy = async () => { + if (!inviteLink) return; + try { + await navigator.clipboard.writeText(inviteLink); + setActive(false); + } catch (err) { + console.error('Не удалось скопировать ссылку:', err); + } + }; + + return ( + +
+
+ Приглашение в группу "{groupName}" +
+ +
+
+ Ссылка для приглашения +
+
+ {inviteLink} +
+
+ +
+ + setActive(false)} + text="Отмена" + /> +
+
+
+ ); +}; + +export default ModalInvite; diff --git a/src/views/home/groups/ModalUpdate.tsx b/src/views/home/groups/ModalUpdate.tsx index 9233c9f..5d4ec0a 100644 --- a/src/views/home/groups/ModalUpdate.tsx +++ b/src/views/home/groups/ModalUpdate.tsx @@ -24,10 +24,10 @@ const ModalUpdate: FC = ({ const [name, setName] = useState(''); const [description, setDescription] = useState(''); const statusUpdate = useAppSelector( - (state) => state.groups.statuses.update, + (state) => state.groups.updateGroup.status, ); const statusDelete = useAppSelector( - (state) => state.groups.statuses.delete, + (state) => state.groups.deleteGroup.status, ); const dispatch = useAppDispatch();