auth + groups invite

This commit is contained in:
Виталий Лавшонок
2025-11-15 17:37:47 +03:00
parent ded41ba7f0
commit dfc2985209
16 changed files with 673 additions and 225 deletions

View File

@@ -9,22 +9,27 @@ import Mission from './pages/Mission';
import ArticleEditor from './pages/ArticleEditor'; import ArticleEditor from './pages/ArticleEditor';
import Article from './pages/Article'; import Article from './pages/Article';
import ContestEditor from './pages/ContestEditor'; import ContestEditor from './pages/ContestEditor';
import ProtectedRoute from './components/router/ProtectedRoute';
function App() { function App() {
return ( return (
<div className="w-full h-full bg-liquid-background flex justify-center"> <div className="w-full h-full bg-liquid-background flex justify-center">
<div className="relative w-full max-w-[1600px] h-full "> <div className="relative w-full max-w-[1600px] h-full ">
<Routes> <Routes>
<Route element={<ProtectedRoute />}>
<Route
path="/article/create/*"
element={<ArticleEditor />}
/>
<Route
path="/contest/create/*"
element={<ContestEditor />}
/>
</Route>
<Route path="/home/*" element={<Home />} /> <Route path="/home/*" element={<Home />} />
<Route path="/mission/:missionId" element={<Mission />} /> <Route path="/mission/:missionId" element={<Mission />} />
<Route
path="/article/create/*"
element={<ArticleEditor />}
/>
<Route
path="/contest/create/*"
element={<ContestEditor />}
/>
<Route path="/article/:articleId" element={<Article />} /> <Route path="/article/:articleId" element={<Article />} />
<Route path="*" element={<Home />} /> <Route path="*" element={<Home />} />
</Routes> </Routes>

View File

@@ -1,11 +1,15 @@
// src/routes/ProtectedRoute.tsx // src/routes/ProtectedRoute.tsx
import { Navigate, Outlet } from 'react-router-dom'; import { Navigate, Outlet, useLocation } from 'react-router-dom';
import { useAppSelector } from '../../redux/hooks'; import { useAppSelector } from '../../redux/hooks';
export default function ProtectedRoute() { export default function ProtectedRoute() {
const isAuthenticated = useAppSelector((state) => !!state.auth.jwt); const isAuthenticated = useAppSelector((state) => !!state.auth.jwt);
const location = useLocation();
console.log('location', location);
if (!isAuthenticated) { if (!isAuthenticated) {
return <Navigate to="/home/login" replace />; return <Navigate to="/home/login" replace state={{ from: location }} />;
} }
return <Outlet />; return <Outlet />;

View File

@@ -1,4 +1,4 @@
// import React from "react"; // src/pages/Home.tsx
import { Route, Routes } from 'react-router-dom'; import { Route, Routes } from 'react-router-dom';
import Login from '../views/home/auth/Login'; import Login from '../views/home/auth/Login';
import Register from '../views/home/auth/Register'; 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 { MissionsRightPanel } from '../views/home/rightpanel/Missions';
import { ArticlesRightPanel } from '../views/home/rightpanel/Articles'; import { ArticlesRightPanel } from '../views/home/rightpanel/Articles';
import { GroupRightPanel } from '../views/home/rightpanel/Group'; import { GroupRightPanel } from '../views/home/rightpanel/Group';
import GroupInvite from '../views/home/groupinviter/GroupInvite';
const Home = () => { const Home = () => {
const name = useAppSelector((state) => state.auth.username); const name = useAppSelector((state) => state.auth.username);
@@ -37,14 +38,18 @@ const Home = () => {
<Routes> <Routes>
<Route element={<ProtectedRoute />}> <Route element={<ProtectedRoute />}>
<Route path="account/*" element={<Account />} /> <Route path="account/*" element={<Account />} />
<Route
path="group-invite/*"
element={<GroupInvite />}
/>
<Route path="group/:groupId" element={<Group />} />
<Route path="groups/*" element={<Groups />} />
</Route> </Route>
<Route path="login" element={<Login />} /> <Route path="login" element={<Login />} />
<Route path="register" element={<Register />} /> <Route path="register" element={<Register />} />
<Route path="missions/*" element={<Missions />} /> <Route path="missions/*" element={<Missions />} />
<Route path="articles/*" element={<Articles />} /> <Route path="articles/*" element={<Articles />} />
<Route path="group/:groupId" element={<Group />} />
<Route path="groups/*" element={<Groups />} />
<Route path="contests/*" element={<Contests />} /> <Route path="contests/*" element={<Contests />} />
<Route path="contest/:contestId/*" element={<Contest />} /> <Route path="contest/:contestId/*" element={<Contest />} />
<Route <Route

View File

@@ -121,11 +121,26 @@ export const refreshToken = createAsyncThunk(
export const fetchWhoAmI = createAsyncThunk( export const fetchWhoAmI = createAsyncThunk(
'auth/whoami', 'auth/whoami',
async (_, { rejectWithValue }) => { async (_, { dispatch, getState, rejectWithValue }) => {
try { try {
const response = await axios.get('/authentication/whoami'); const response = await axios.get('/authentication/whoami');
return response.data; return response.data;
} catch (err: any) { } 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( return rejectWithValue(
err.response?.data?.message || 'Failed to fetch user info', err.response?.data?.message || 'Failed to fetch user info',
); );
@@ -269,6 +284,23 @@ const authSlice = createSlice({
builder.addCase(fetchWhoAmI.rejected, (state, action) => { builder.addCase(fetchWhoAmI.rejected, (state, action) => {
state.status = 'failed'; state.status = 'failed';
state.error = action.payload as string; 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'];
}
}); });
}, },
}); });

View File

@@ -33,8 +33,6 @@ export interface Submission {
sourceType: string; sourceType: string;
} }
export interface Mission { export interface Mission {
id: number; id: number;
authorId: number; authorId: number;
@@ -124,8 +122,6 @@ interface ContestsState {
status: Status; status: Status;
error?: string; error?: string;
}; };
// 🆕 Добавляем updateContest и deleteContest
updateContest: { updateContest: {
contest: Contest; contest: Contest;
status: Status; status: Status;
@@ -176,7 +172,7 @@ const initialState: ContestsState = {
status: 'idle', status: 'idle',
error: undefined, error: undefined,
}, },
fetchMySubmissions: { fetchMySubmissions: {
submissions: [], submissions: [],
status: 'idle', status: 'idle',
error: undefined, error: undefined,
@@ -262,7 +258,6 @@ export const fetchMySubmissions = createAsyncThunk(
}, },
); );
// Все контесты // Все контесты
export const fetchContests = createAsyncThunk( export const fetchContests = createAsyncThunk(
'contests/fetchAll', 'contests/fetchAll',
@@ -435,8 +430,6 @@ const contestsSlice = createSlice({
state.fetchMySubmissions.error = action.payload; state.fetchMySubmissions.error = action.payload;
}); });
// fetchContests // fetchContests
builder.addCase(fetchContests.pending, (state) => { builder.addCase(fetchContests.pending, (state) => {
state.fetchContests.status = 'loading'; state.fetchContests.status = 'loading';

View File

@@ -1,7 +1,9 @@
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit'; import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
import axios from '../../axios'; import axios from '../../axios';
// ─── Типы ──────────────────────────────────────────── // =====================
// Типы
// =====================
type Status = 'idle' | 'loading' | 'successful' | 'failed'; type Status = 'idle' | 'loading' | 'successful' | 'failed';
@@ -19,39 +21,106 @@ export interface Group {
contests: any[]; contests: any[];
} }
// =====================
// Состояние
// =====================
interface GroupsState { interface GroupsState {
groups: Group[]; fetchMyGroups: {
currentGroup: Group | null; groups: Group[];
statuses: { status: Status;
create: Status; error?: string;
update: Status; };
delete: Status; fetchGroupById: {
fetchMy: Status; group?: Group;
fetchById: Status; status: Status;
addMember: Status; error?: string;
removeMember: Status; };
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 = { const initialState: GroupsState = {
groups: [], fetchMyGroups: {
currentGroup: null, groups: [],
statuses: { status: 'idle',
create: 'idle', error: undefined,
update: 'idle', },
delete: 'idle', fetchGroupById: {
fetchMy: 'idle', group: undefined,
fetchById: 'idle', status: 'idle',
addMember: 'idle', error: undefined,
removeMember: 'idle', },
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( export const createGroup = createAsyncThunk(
'groups/createGroup', 'groups/createGroup',
async ( async (
@@ -69,7 +138,6 @@ export const createGroup = createAsyncThunk(
}, },
); );
// PUT /groups/{groupId}
export const updateGroup = createAsyncThunk( export const updateGroup = createAsyncThunk(
'groups/updateGroup', 'groups/updateGroup',
async ( async (
@@ -94,7 +162,6 @@ export const updateGroup = createAsyncThunk(
}, },
); );
// DELETE /groups/{groupId}
export const deleteGroup = createAsyncThunk( export const deleteGroup = createAsyncThunk(
'groups/deleteGroup', 'groups/deleteGroup',
async (groupId: number, { rejectWithValue }) => { async (groupId: number, { rejectWithValue }) => {
@@ -109,7 +176,6 @@ export const deleteGroup = createAsyncThunk(
}, },
); );
// GET /groups/my
export const fetchMyGroups = createAsyncThunk( export const fetchMyGroups = createAsyncThunk(
'groups/fetchMyGroups', 'groups/fetchMyGroups',
async (_, { rejectWithValue }) => { async (_, { rejectWithValue }) => {
@@ -124,7 +190,6 @@ export const fetchMyGroups = createAsyncThunk(
}, },
); );
// GET /groups/{groupId}
export const fetchGroupById = createAsyncThunk( export const fetchGroupById = createAsyncThunk(
'groups/fetchGroupById', 'groups/fetchGroupById',
async (groupId: number, { rejectWithValue }) => { async (groupId: number, { rejectWithValue }) => {
@@ -139,16 +204,22 @@ export const fetchGroupById = createAsyncThunk(
}, },
); );
// POST /groups/members
export const addGroupMember = createAsyncThunk( export const addGroupMember = createAsyncThunk(
'groups/addGroupMember', 'groups/addGroupMember',
async ( async (
{ userId, role }: { userId: number; role: string }, {
groupId,
userId,
role,
}: { groupId: number; userId: number; role: string },
{ rejectWithValue }, { rejectWithValue },
) => { ) => {
try { try {
await axios.post('/groups/members', { userId, role }); const response = await axios.post(`/groups/${groupId}/members`, {
return { userId, role }; userId,
role,
});
return response.data;
} catch (err: any) { } catch (err: any) {
return rejectWithValue( return rejectWithValue(
err.response?.data?.message || err.response?.data?.message ||
@@ -158,7 +229,6 @@ export const addGroupMember = createAsyncThunk(
}, },
); );
// DELETE /groups/{groupId}/members/{memberId}
export const removeGroupMember = createAsyncThunk( export const removeGroupMember = createAsyncThunk(
'groups/removeGroupMember', 'groups/removeGroupMember',
async ( 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({ const groupsSlice = createSlice({
name: 'groups', name: 'groups',
initialState, initialState,
reducers: { reducers: {
clearCurrentGroup: (state) => { setGroupsStatus: (
state.currentGroup = null; state,
action: PayloadAction<{ key: keyof GroupsState; status: Status }>,
) => {
const { key, status } = action.payload;
if (state[key]) {
(state[key] as any).status = status;
}
}, },
}, },
extraReducers: (builder) => { extraReducers: (builder) => {
// ─── CREATE GROUP ─── // fetchMyGroups
builder.addCase(createGroup.pending, (state) => {
state.statuses.create = 'loading';
state.error = null;
});
builder.addCase(
createGroup.fulfilled,
(state, action: PayloadAction<Group>) => {
state.statuses.create = 'successful';
state.groups.push(action.payload);
},
);
builder.addCase(
createGroup.rejected,
(state, action: PayloadAction<any>) => {
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<Group>) => {
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<any>) => {
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<number>) => {
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<any>) => {
state.statuses.delete = 'failed';
state.error = action.payload;
},
);
// ─── FETCH MY GROUPS ───
builder.addCase(fetchMyGroups.pending, (state) => { builder.addCase(fetchMyGroups.pending, (state) => {
state.statuses.fetchMy = 'loading'; state.fetchMyGroups.status = 'loading';
state.error = null;
}); });
builder.addCase( builder.addCase(
fetchMyGroups.fulfilled, fetchMyGroups.fulfilled,
(state, action: PayloadAction<Group[]>) => { (state, action: PayloadAction<Group[]>) => {
state.statuses.fetchMy = 'successful'; state.fetchMyGroups.status = 'successful';
state.groups = action.payload; state.fetchMyGroups.groups = action.payload;
},
);
builder.addCase(
fetchMyGroups.rejected,
(state, action: PayloadAction<any>) => {
state.statuses.fetchMy = 'failed';
state.error = 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) => { builder.addCase(fetchGroupById.pending, (state) => {
state.statuses.fetchById = 'loading'; state.fetchGroupById.status = 'loading';
state.error = null;
}); });
builder.addCase( builder.addCase(
fetchGroupById.fulfilled, fetchGroupById.fulfilled,
(state, action: PayloadAction<Group>) => { (state, action: PayloadAction<Group>) => {
state.statuses.fetchById = 'successful'; state.fetchGroupById.status = 'successful';
state.currentGroup = action.payload; state.fetchGroupById.group = action.payload;
},
);
builder.addCase(
fetchGroupById.rejected,
(state, action: PayloadAction<any>) => {
state.statuses.fetchById = 'failed';
state.error = 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<Group>) => {
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<Group>) => {
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<number>) => {
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) => { builder.addCase(addGroupMember.pending, (state) => {
state.statuses.addMember = 'loading'; state.addGroupMember.status = 'loading';
state.error = null;
}); });
builder.addCase(addGroupMember.fulfilled, (state) => { 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<any>) => {
state.statuses.addMember = 'failed';
state.error = action.payload;
},
);
// ─── REMOVE MEMBER ─── // removeGroupMember
builder.addCase(removeGroupMember.pending, (state) => { builder.addCase(removeGroupMember.pending, (state) => {
state.statuses.removeMember = 'loading'; state.removeGroupMember.status = 'loading';
state.error = null;
}); });
builder.addCase( builder.addCase(
removeGroupMember.fulfilled, removeGroupMember.fulfilled,
@@ -324,27 +416,60 @@ const groupsSlice = createSlice({
state, state,
action: PayloadAction<{ groupId: number; memberId: number }>, action: PayloadAction<{ groupId: number; memberId: number }>,
) => { ) => {
state.statuses.removeMember = 'successful'; state.removeGroupMember.status = 'successful';
if ( if (
state.currentGroup && state.fetchGroupById.group &&
state.currentGroup.id === action.payload.groupId state.fetchGroupById.group.id === action.payload.groupId
) { ) {
state.currentGroup.members = state.fetchGroupById.group.members =
state.currentGroup.members.filter( state.fetchGroupById.group.members.filter(
(m) => m.userId !== action.payload.memberId, (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( builder.addCase(
removeGroupMember.rejected, fetchGroupJoinLink.fulfilled,
(state, action: PayloadAction<any>) => { (
state.statuses.removeMember = 'failed'; state,
state.error = action.payload; 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<Group>) => {
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; export const groupsReducer = groupsSlice.reducer;

View File

@@ -147,9 +147,6 @@ const missionsSlice = createSlice({
name: 'missions', name: 'missions',
initialState, initialState,
reducers: { reducers: {
clearCurrentMission: (state) => {
state.currentMission = null;
},
setMissionsStatus: ( setMissionsStatus: (
state, state,
action: PayloadAction<{ 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; export const missionsReducer = missionsSlice.reducer;

View File

@@ -1,8 +1,9 @@
// src/views/home/auth/Login.tsx
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { PrimaryButton } from '../../../components/button/PrimaryButton'; import { PrimaryButton } from '../../../components/button/PrimaryButton';
import { Input } from '../../../components/input/Input'; import { Input } from '../../../components/input/Input';
import { useAppDispatch, useAppSelector } from '../../../redux/hooks'; 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 { loginUser } from '../../../redux/slices/auth';
// import { cn } from "../../../lib/cn"; // import { cn } from "../../../lib/cn";
import { setMenuActivePage } from '../../../redux/slices/store'; import { setMenuActivePage } from '../../../redux/slices/store';
@@ -13,6 +14,7 @@ import { googleLogo } from '../../../assets/icons/input';
const Login = () => { const Login = () => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation();
const [username, setUsername] = useState<string>(''); const [username, setUsername] = useState<string>('');
const [password, setPassword] = useState<string>(''); const [password, setPassword] = useState<string>('');
@@ -30,7 +32,9 @@ const Login = () => {
useEffect(() => { useEffect(() => {
if (jwt) { if (jwt) {
navigate('/home/account'); // или другая страница после входа const from = location.state?.from;
const path = from ? from.pathname + from.search : '/home/account';
navigate(path, { replace: true });
} }
}, [jwt]); }, [jwt]);

View File

@@ -1,8 +1,9 @@
// src/views/home/auth/Register.tsx
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { PrimaryButton } from '../../../components/button/PrimaryButton'; import { PrimaryButton } from '../../../components/button/PrimaryButton';
import { Input } from '../../../components/input/Input'; import { Input } from '../../../components/input/Input';
import { useAppDispatch, useAppSelector } from '../../../redux/hooks'; 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 { registerUser } from '../../../redux/slices/auth';
// import { cn } from "../../../lib/cn"; // import { cn } from "../../../lib/cn";
import { setMenuActivePage } from '../../../redux/slices/store'; import { setMenuActivePage } from '../../../redux/slices/store';
@@ -15,6 +16,7 @@ import { googleLogo } from '../../../assets/icons/input';
const Register = () => { const Register = () => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation();
const [username, setUsername] = useState<string>(''); const [username, setUsername] = useState<string>('');
const [email, setEmail] = useState<string>(''); const [email, setEmail] = useState<string>('');
@@ -32,7 +34,9 @@ const Register = () => {
useEffect(() => { useEffect(() => {
if (jwt) { 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); console.log(submitClicked);
}, [jwt]); }, [jwt]);

View File

@@ -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 (
<div className="h-full w-full box-border p-[20px] pt-[20p] flex items-center justify-center text-bold text-[36px]">
Приглашение признано недействительным.
</div>
);
}
const isExpired = new Date(expiresAt) < new Date();
if (isExpired) {
return (
<div className="h-full w-full box-border p-[20px] pt-[20px] flex items-center justify-center text-bold text-[36px]">
Период действия приглашения истек.
</div>
);
}
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 (
<div className="h-full w-full box-border flex">
<div className="p-[25px] text-liquid-white w-full">
<div className="font-bold text-[30px] mb-2">
Привет, {username}!
</div>
<div className="font-bold text-[25px]">
Вы действительно хотите присоединиться к группе:
</div>
<div className="font-bold text-[25px] mb-[20px]">
"{groupName}"
</div>
{joinError && (
<div className="text-red-500 mb-[10px]">
Ошибка присоединения: {joinError}
</div>
)}
<div className="flex flex-row w-full items-center justify-center mt-[30px] gap-[20px]">
<PrimaryButton
onClick={handleJoin}
text={
joinStatus === 'loading'
? 'Присоединяемся...'
: 'Присоединиться'
}
disabled={joinStatus === 'loading'}
/>
<SecondaryButton onClick={handleCancel} text="Отмена" />
</div>
</div>
</div>
);
};
export default GroupInvite;

View File

@@ -7,7 +7,7 @@ import {
EyeOpen, EyeOpen,
} from '../../../assets/icons/groups'; } from '../../../assets/icons/groups';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { GroupUpdate } from './Groups'; import { GroupInvite, GroupUpdate } from './Groups';
export interface GroupItemProps { export interface GroupItemProps {
id: number; id: number;
@@ -17,6 +17,9 @@ export interface GroupItemProps {
description: string; description: string;
setUpdateActive: (value: any) => void; setUpdateActive: (value: any) => void;
setUpdateGroup: (value: GroupUpdate) => void; setUpdateGroup: (value: GroupUpdate) => void;
setInviteActive: (value: any) => void;
setInviteGroup: (value: GroupInvite) => void;
type: 'manage' | 'member';
} }
interface IconComponentProps { interface IconComponentProps {
@@ -45,6 +48,9 @@ const GroupItem: React.FC<GroupItemProps> = ({
description, description,
setUpdateGroup, setUpdateGroup,
setUpdateActive, setUpdateActive,
setInviteActive,
setInviteGroup,
type,
}) => { }) => {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -63,10 +69,16 @@ const GroupItem: React.FC<GroupItemProps> = ({
<div className="grid grid-flow-row grid-rows-[1fr,24px]"> <div className="grid grid-flow-row grid-rows-[1fr,24px]">
<div className="text-[18px] font-bold">{name}</div> <div className="text-[18px] font-bold">{name}</div>
<div className=" flex gap-[10px]"> <div className=" flex gap-[10px]">
{(role == 'menager' || role == 'owner') && ( {type == 'manage' && (
<IconComponent src={UserAdd} /> <IconComponent
src={UserAdd}
onClick={() => {
setInviteActive(true);
setInviteGroup({ id, name });
}}
/>
)} )}
{(role == 'menager' || role == 'owner') && ( {type == 'manage' && (
<IconComponent <IconComponent
src={Edit} src={Edit}
onClick={() => { onClick={() => {

View File

@@ -8,6 +8,7 @@ import { fetchMyGroups } from '../../../redux/slices/groups';
import ModalCreate from './ModalCreate'; import ModalCreate from './ModalCreate';
import ModalUpdate from './ModalUpdate'; import ModalUpdate from './ModalUpdate';
import Filters from './Filter'; import Filters from './Filter';
import ModalInvite from './ModalInvite';
export interface GroupUpdate { export interface GroupUpdate {
id: number; id: number;
@@ -15,19 +16,35 @@ export interface GroupUpdate {
description: string; description: string;
} }
export interface GroupInvite {
id: number;
name: string;
}
const Groups = () => { const Groups = () => {
const [modalActive, setModalActive] = useState<boolean>(false); const [modalActive, setModalActive] = useState(false);
const [modelUpdateActive, setModalUpdateActive] = useState<boolean>(false); const [modalUpdateActive, setModalUpdateActive] = useState(false);
const [updateGroup, setUpdateGroup] = useState<GroupUpdate>({ const [updateGroup, setUpdateGroup] = useState<GroupUpdate>({
id: 0, id: 0,
name: '', name: '',
description: '', description: '',
}); });
const [modalInviteActive, setModalInviteActive] = useState(false);
const [inviteGroup, setInviteGroup] = useState<GroupInvite>({
id: 0,
name: '',
});
const dispatch = useAppDispatch(); 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); const currentUserName = useAppSelector((store) => store.auth.username);
@@ -52,8 +69,8 @@ const Groups = () => {
(m) => m.username === currentUserName, (m) => m.username === currentUserName,
); );
if (!me) return; if (!me) return;
const roles = me.role.split(',').map((r) => r.trim());
if (me.role === 'Administrator') { if (roles.includes('Administrator')) {
managed.push(group); managed.push(group);
} else { } else {
current.push(group); current.push(group);
@@ -68,7 +85,7 @@ const Groups = () => {
}, [groups, currentUserName]); }, [groups, currentUserName]);
return ( return (
<div className="h-full w-[calc(100%+250px)] box-border p-[20px] pt-[20p]"> <div className="h-full w-[calc(100%+250px)] box-border p-[20px] pt-[20px]">
<div className="h-full box-border"> <div className="h-full box-border">
<div className="relative flex items-center mb-[20px]"> <div className="relative flex items-center mb-[20px]">
<div <div
@@ -79,9 +96,7 @@ const Groups = () => {
Группы Группы
</div> </div>
<SecondaryButton <SecondaryButton
onClick={() => { onClick={() => setModalActive(true)}
setModalActive(true);
}}
text="Создать группу" text="Создать группу"
className="absolute right-0" className="absolute right-0"
/> />
@@ -89,37 +104,67 @@ const Groups = () => {
<Filters /> <Filters />
<GroupsBlock {groupsStatus === 'loading' && (
className="mb-[20px]" <div className="text-liquid-white mt-4">
title="Управляемые" Загрузка групп...
groups={managedGroups} </div>
setUpdateActive={setModalUpdateActive} )}
setUpdateGroup={setUpdateGroup} {groupsStatus === 'failed' && (
/> <div className="text-red-400 mt-4">
<GroupsBlock Ошибка: {groupsError || 'Не удалось загрузить группы'}
className="mb-[20px]" </div>
title="Текущие" )}
groups={currentGroups}
setUpdateActive={setModalUpdateActive} {groupsStatus === 'successful' && (
setUpdateGroup={setUpdateGroup} <>
/> <GroupsBlock
<GroupsBlock className="mb-[20px]"
className="mb-[20px]" title="Управляемые"
title="Скрытые" groups={managedGroups}
groups={hiddenGroups} // пока пусто setUpdateActive={setModalUpdateActive}
setUpdateActive={setModalUpdateActive} setUpdateGroup={setUpdateGroup}
setUpdateGroup={setUpdateGroup} setInviteActive={setModalInviteActive}
/> setInviteGroup={setInviteGroup}
type="manage"
/>
<GroupsBlock
className="mb-[20px]"
title="Текущие"
groups={currentGroups}
setUpdateActive={setModalUpdateActive}
setUpdateGroup={setUpdateGroup}
setInviteActive={setModalInviteActive}
setInviteGroup={setInviteGroup}
type="member"
/>
<GroupsBlock
className="mb-[20px]"
title="Скрытые"
groups={hiddenGroups} // пока пусто
setUpdateActive={setModalUpdateActive}
setUpdateGroup={setUpdateGroup}
setInviteActive={setModalInviteActive}
setInviteGroup={setInviteGroup}
type="member"
/>
</>
)}
</div> </div>
<ModalCreate setActive={setModalActive} active={modalActive} /> <ModalCreate setActive={setModalActive} active={modalActive} />
<ModalUpdate <ModalUpdate
setActive={setModalUpdateActive} setActive={setModalUpdateActive}
active={modelUpdateActive} active={modalUpdateActive}
groupId={updateGroup.id} groupId={updateGroup.id}
groupName={updateGroup.name} groupName={updateGroup.name}
groupDescription={updateGroup.description} groupDescription={updateGroup.description}
/> />
<ModalInvite
setActive={setModalInviteActive}
active={modalInviteActive}
groupId={inviteGroup.id}
groupName={inviteGroup.name}
/>
</div> </div>
); );
}; };

View File

@@ -3,7 +3,7 @@ import GroupItem from './GroupItem';
import { cn } from '../../../lib/cn'; import { cn } from '../../../lib/cn';
import { ChevroneDown } from '../../../assets/icons/groups'; import { ChevroneDown } from '../../../assets/icons/groups';
import { Group } from '../../../redux/slices/groups'; import { Group } from '../../../redux/slices/groups';
import { GroupUpdate } from './Groups'; import { GroupInvite, GroupUpdate } from './Groups';
interface GroupsBlockProps { interface GroupsBlockProps {
groups: Group[]; groups: Group[];
@@ -11,6 +11,9 @@ interface GroupsBlockProps {
className?: string; className?: string;
setUpdateActive: (value: any) => void; setUpdateActive: (value: any) => void;
setUpdateGroup: (value: GroupUpdate) => void; setUpdateGroup: (value: GroupUpdate) => void;
setInviteActive: (value: any) => void;
setInviteGroup: (value: GroupInvite) => void;
type: 'manage' | 'member';
} }
const GroupsBlock: FC<GroupsBlockProps> = ({ const GroupsBlock: FC<GroupsBlockProps> = ({
@@ -19,6 +22,9 @@ const GroupsBlock: FC<GroupsBlockProps> = ({
className, className,
setUpdateActive, setUpdateActive,
setUpdateGroup, setUpdateGroup,
setInviteActive,
setInviteGroup,
type,
}) => { }) => {
const [active, setActive] = useState<boolean>(title != 'Скрытые'); const [active, setActive] = useState<boolean>(title != 'Скрытые');
@@ -63,8 +69,11 @@ const GroupsBlock: FC<GroupsBlockProps> = ({
description={v.description} description={v.description}
setUpdateActive={setUpdateActive} setUpdateActive={setUpdateActive}
setUpdateGroup={setUpdateGroup} setUpdateGroup={setUpdateGroup}
setInviteActive={setInviteActive}
setInviteGroup={setInviteGroup}
role={'owner'} role={'owner'}
name={v.name} name={v.name}
type={type}
/> />
))} ))}
</div> </div>

View File

@@ -14,7 +14,7 @@ interface ModalCreateProps {
const ModalCreate: FC<ModalCreateProps> = ({ active, setActive }) => { const ModalCreate: FC<ModalCreateProps> = ({ active, setActive }) => {
const [name, setName] = useState<string>(''); const [name, setName] = useState<string>('');
const [description, setDescription] = useState<string>(''); const [description, setDescription] = useState<string>('');
const status = useAppSelector((state) => state.groups.statuses.create); const status = useAppSelector((state) => state.groups.createGroup.status);
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
useEffect(() => { useEffect(() => {

View File

@@ -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<ModalInviteProps> = ({
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 (
<Modal
className="bg-liquid-background border-liquid-lighter border-[2px] p-[25px] rounded-[20px] text-liquid-white"
onOpenChange={setActive}
open={active}
backdrop="blur"
>
<div className="w-[500px]">
<div className="font-bold text-[30px] mb-[20px]">
Приглашение в группу "{groupName}"
</div>
<div className="">
<div className="font-bold text-[18px] mb-[5px]">
Ссылка для приглашения
</div>
<div
className=" break-all break-words text-[#5d96ff] hover:underline cursor-pointer"
onClick={handleCopy}
>
{inviteLink}
</div>
</div>
<div className="flex flex-row w-full items-center justify-end mt-[30px] gap-[20px]">
<PrimaryButton
onClick={handleCopy}
text={
status === 'loading' ? 'Загрузка...' : 'Скопировать'
}
disabled={status === 'loading' || !inviteLink}
/>
<SecondaryButton
onClick={() => setActive(false)}
text="Отмена"
/>
</div>
</div>
</Modal>
);
};
export default ModalInvite;

View File

@@ -24,10 +24,10 @@ const ModalUpdate: FC<ModalUpdateProps> = ({
const [name, setName] = useState<string>(''); const [name, setName] = useState<string>('');
const [description, setDescription] = useState<string>(''); const [description, setDescription] = useState<string>('');
const statusUpdate = useAppSelector( const statusUpdate = useAppSelector(
(state) => state.groups.statuses.update, (state) => state.groups.updateGroup.status,
); );
const statusDelete = useAppSelector( const statusDelete = useAppSelector(
(state) => state.groups.statuses.delete, (state) => state.groups.deleteGroup.status,
); );
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();