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

@@ -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<string>('');
const [password, setPassword] = useState<string>('');
@@ -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]);

View File

@@ -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<string>('');
const [email, setEmail] = useState<string>('');
@@ -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]);

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,
} 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<GroupItemProps> = ({
description,
setUpdateGroup,
setUpdateActive,
setInviteActive,
setInviteGroup,
type,
}) => {
const navigate = useNavigate();
@@ -63,10 +69,16 @@ const GroupItem: React.FC<GroupItemProps> = ({
<div className="grid grid-flow-row grid-rows-[1fr,24px]">
<div className="text-[18px] font-bold">{name}</div>
<div className=" flex gap-[10px]">
{(role == 'menager' || role == 'owner') && (
<IconComponent src={UserAdd} />
{type == 'manage' && (
<IconComponent
src={UserAdd}
onClick={() => {
setInviteActive(true);
setInviteGroup({ id, name });
}}
/>
)}
{(role == 'menager' || role == 'owner') && (
{type == 'manage' && (
<IconComponent
src={Edit}
onClick={() => {

View File

@@ -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<boolean>(false);
const [modelUpdateActive, setModalUpdateActive] = useState<boolean>(false);
const [modalActive, setModalActive] = useState(false);
const [modalUpdateActive, setModalUpdateActive] = useState(false);
const [updateGroup, setUpdateGroup] = useState<GroupUpdate>({
id: 0,
name: '',
description: '',
});
const [modalInviteActive, setModalInviteActive] = useState(false);
const [inviteGroup, setInviteGroup] = useState<GroupInvite>({
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 (
<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="relative flex items-center mb-[20px]">
<div
@@ -79,9 +96,7 @@ const Groups = () => {
Группы
</div>
<SecondaryButton
onClick={() => {
setModalActive(true);
}}
onClick={() => setModalActive(true)}
text="Создать группу"
className="absolute right-0"
/>
@@ -89,37 +104,67 @@ const Groups = () => {
<Filters />
<GroupsBlock
className="mb-[20px]"
title="Управляемые"
groups={managedGroups}
setUpdateActive={setModalUpdateActive}
setUpdateGroup={setUpdateGroup}
/>
<GroupsBlock
className="mb-[20px]"
title="Текущие"
groups={currentGroups}
setUpdateActive={setModalUpdateActive}
setUpdateGroup={setUpdateGroup}
/>
<GroupsBlock
className="mb-[20px]"
title="Скрытые"
groups={hiddenGroups} // пока пусто
setUpdateActive={setModalUpdateActive}
setUpdateGroup={setUpdateGroup}
/>
{groupsStatus === 'loading' && (
<div className="text-liquid-white mt-4">
Загрузка групп...
</div>
)}
{groupsStatus === 'failed' && (
<div className="text-red-400 mt-4">
Ошибка: {groupsError || 'Не удалось загрузить группы'}
</div>
)}
{groupsStatus === 'successful' && (
<>
<GroupsBlock
className="mb-[20px]"
title="Управляемые"
groups={managedGroups}
setUpdateActive={setModalUpdateActive}
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>
<ModalCreate setActive={setModalActive} active={modalActive} />
<ModalUpdate
setActive={setModalUpdateActive}
active={modelUpdateActive}
active={modalUpdateActive}
groupId={updateGroup.id}
groupName={updateGroup.name}
groupDescription={updateGroup.description}
/>
<ModalInvite
setActive={setModalInviteActive}
active={modalInviteActive}
groupId={inviteGroup.id}
groupName={inviteGroup.name}
/>
</div>
);
};

View File

@@ -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<GroupsBlockProps> = ({
@@ -19,6 +22,9 @@ const GroupsBlock: FC<GroupsBlockProps> = ({
className,
setUpdateActive,
setUpdateGroup,
setInviteActive,
setInviteGroup,
type,
}) => {
const [active, setActive] = useState<boolean>(title != 'Скрытые');
@@ -63,8 +69,11 @@ const GroupsBlock: FC<GroupsBlockProps> = ({
description={v.description}
setUpdateActive={setUpdateActive}
setUpdateGroup={setUpdateGroup}
setInviteActive={setInviteActive}
setInviteGroup={setInviteGroup}
role={'owner'}
name={v.name}
type={type}
/>
))}
</div>

View File

@@ -14,7 +14,7 @@ interface ModalCreateProps {
const ModalCreate: FC<ModalCreateProps> = ({ active, setActive }) => {
const [name, setName] = 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();
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 [description, setDescription] = useState<string>('');
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();