diff --git a/src/app/providers/helper/protected.route.tsx b/src/app/providers/helper/protected.route.tsx index 67fa1b3..f803ad1 100644 --- a/src/app/providers/helper/protected.route.tsx +++ b/src/app/providers/helper/protected.route.tsx @@ -1,4 +1,5 @@ import { Navigate } from "react-router-dom"; +import { authService } from "@/modules/auth/api/auth.service"; interface ProtectedRouteProps { children: React.ReactNode; @@ -9,7 +10,9 @@ export const ProtectedRoute: React.FC = ({ children, fallbackPath = "/", }) => { - if (false) { + const isAuthenticated = authService.isAuthenticated(); + + if (!isAuthenticated) { return ; } diff --git a/src/app/providers/routing.tsx b/src/app/providers/routing.tsx index d515bba..7b1691e 100644 --- a/src/app/providers/routing.tsx +++ b/src/app/providers/routing.tsx @@ -1,8 +1,10 @@ import { Suspense } from "react"; +import { Routes as ReactRoutes, Route } from "react-router-dom"; import { HomePage } from "@/pages/home.page"; import { SecondaryPage } from "@/pages/secondary.page"; +import { AuthPage } from "@/pages/AuthPage"; +import { CreateOrganizationPage } from "@/pages/CreateOrganizationPage"; import { ProtectedRoute } from "./helper/protected.route"; -import { Routes as ReactRoutes, Route } from "react-router-dom"; export const Routing = () => { return ( @@ -14,18 +16,27 @@ export const Routing = () => { } > - } /> - - - - - - } - /> - + } /> + } + /> + + + + } + /> + + + + } + /> ); diff --git a/src/modules/auth/api/auth.service.ts b/src/modules/auth/api/auth.service.ts new file mode 100644 index 0000000..18cb94c --- /dev/null +++ b/src/modules/auth/api/auth.service.ts @@ -0,0 +1,110 @@ +import { apiService } from "@/shared/api/api.service"; +import type { + LoginCredentials, + RegisterData, + AuthResponse, + OrganizationCreateData, + OrganizationResponse, +} from "../types/auth.types"; + +export const authService = { + async login(credentials: LoginCredentials): Promise { + const formData = new URLSearchParams(); + formData.append("username", credentials.username); + formData.append("password", credentials.password); + + return apiService.post("/auth/login", formData, { + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + }); + }, + + async register( + data: Omit, + ): Promise { + return apiService.post("/auth/register", { + username: data.username, + email: data.email, + password: data.password, + first_name: data.first_name, + last_name: data.last_name, + }); + }, + + async createOrganization( + data: OrganizationCreateData, + ): Promise { + const formData = new FormData(); + formData.append("name", data.name); + if (data.logo) { + formData.append("logo", data.logo); + } + + return apiService.post("/organizations", formData, { + headers: { + "Content-Type": "multipart/form-data", + }, + }); + }, + + async getCurrentUser(): Promise { + return apiService.get("/users/me"); + }, + + async getOrganizations(): Promise { + return apiService.get("/organizations"); + }, + + logout(): void { + localStorage.removeItem("auth-storage"); + localStorage.removeItem("organization-storage"); + }, + + saveAuthData(token: string, user: AuthResponse["user"]): void { + const authStorage = { + state: { + token, + user, + }, + version: 0, + }; + localStorage.setItem("auth-storage", JSON.stringify(authStorage)); + }, + + saveOrganization(organization: OrganizationResponse): void { + localStorage.setItem("organization-storage", JSON.stringify(organization)); + }, + + getToken(): string | null { + const authStorage = localStorage.getItem("auth-storage"); + if (!authStorage) return null; + + try { + const parsed = JSON.parse(authStorage); + return parsed.state?.token || null; + } catch { + return null; + } + }, + + getCurrentOrganization(): OrganizationResponse | null { + const orgStorage = localStorage.getItem("organization-storage"); + if (!orgStorage) return null; + + try { + return JSON.parse(orgStorage); + } catch { + return null; + } + }, + + isAuthenticated(): boolean { + const token = this.getToken(); + return !!token; + }, + + hasOrganization(): boolean { + return !!this.getCurrentOrganization(); + }, +}; diff --git a/src/modules/auth/components/CreateOrganizationForm.tsx b/src/modules/auth/components/CreateOrganizationForm.tsx new file mode 100644 index 0000000..4477f39 --- /dev/null +++ b/src/modules/auth/components/CreateOrganizationForm.tsx @@ -0,0 +1,138 @@ +import React, { useState, useRef } from "react"; +import { useAuth } from "../hooks/useAuth"; + +export const CreateOrganizationForm = () => { + const [organizationName, setOrganizationName] = useState(""); + const [logo, setLogo] = useState(null); + const [logoPreview, setLogoPreview] = useState(""); + const fileInputRef = useRef(null); + const { createOrganization, isLoading, error } = useAuth(); + + const handleLogoChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) { + setLogo(file); + const reader = new FileReader(); + reader.onloadend = () => { + setLogoPreview(reader.result as string); + }; + reader.readAsDataURL(file); + } + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (organizationName.trim()) { + await createOrganization({ + name: organizationName, + logo: logo || undefined, + }); + } + }; + + return ( +
+
+
+
fileInputRef.current?.click()} + > + {logoPreview ? ( + Organization logo + ) : ( +
+ + + + + Загрузить лого + +
+ )} +
+ +
+
+ +
+ + setOrganizationName(e.target.value)} + className="w-full px-4 py-2 rounded-lg border transition-all focus:outline-none focus:ring-2" + style={{ + backgroundColor: "var(--input-bg)", + color: "var(--text-primary)", + borderColor: "var(--border)", + "--tw-ring-color": "var(--accent)", + }} + required + disabled={isLoading} + placeholder="Введите название организации" + /> +
+ + {error && ( +
+ {error} +
+ )} + + +
+ ); +}; diff --git a/src/modules/auth/components/LoginForm.tsx b/src/modules/auth/components/LoginForm.tsx new file mode 100644 index 0000000..9a14f78 --- /dev/null +++ b/src/modules/auth/components/LoginForm.tsx @@ -0,0 +1,109 @@ +import React, { useState } from "react"; +import { useAuth } from "../hooks/useAuth"; + +interface LoginFormProps { + onSwitchToRegister: () => void; +} + +export const LoginForm: React.FC = ({ onSwitchToRegister }) => { + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const { login, isLoading, error } = useAuth(); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + await login({ username, password }); + }; + + return ( +
+
+ + setUsername(e.target.value)} + className="w-full px-4 py-2 rounded-lg border transition-all focus:outline-none focus:ring-2" + style={{ + backgroundColor: "var(--input-bg)", + color: "var(--text-primary)", + borderColor: "var(--border)", + "--tw-ring-color": "var(--accent)", + }} + required + disabled={isLoading} + placeholder="Введите имя пользователя" + /> +
+ +
+ + setPassword(e.target.value)} + className="w-full px-4 py-2 rounded-lg border transition-all focus:outline-none focus:ring-2" + style={{ + backgroundColor: "var(--input-bg)", + color: "var(--text-primary)", + borderColor: "var(--border)", + "--tw-ring-color": "var(--accent)", + }} + required + disabled={isLoading} + placeholder="Введите пароль" + /> +
+ + {error && ( +
+ {error} +
+ )} + + + +
+ +
+
+ ); +}; diff --git a/src/modules/auth/components/RegisterForm.tsx b/src/modules/auth/components/RegisterForm.tsx new file mode 100644 index 0000000..7033db6 --- /dev/null +++ b/src/modules/auth/components/RegisterForm.tsx @@ -0,0 +1,231 @@ +import React, { useState } from "react"; +import { useAuth } from "../hooks/useAuth"; + +interface RegisterFormProps { + onSwitchToLogin: () => void; +} + +export const RegisterForm: React.FC = ({ + onSwitchToLogin, +}) => { + const [formData, setFormData] = useState({ + first_name: "", + last_name: "", + username: "", + email: "", + password: "", + confirmPassword: "", + }); + + const { register, isLoading, error } = useAuth(); + + const handleChange = (e: React.ChangeEvent) => { + setFormData({ + ...formData, + [e.target.id]: e.target.value, + }); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + await register(formData); + }; + + return ( +
+
+
+ + +
+ +
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + {error && ( +
+ {error} +
+ )} + + + +
+ +
+
+ ); +}; diff --git a/src/modules/auth/hooks/useAuth.ts b/src/modules/auth/hooks/useAuth.ts new file mode 100644 index 0000000..3d72584 --- /dev/null +++ b/src/modules/auth/hooks/useAuth.ts @@ -0,0 +1,82 @@ +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { authService } from "../api/auth.service"; +import { useApi } from "@/shared/api/hooks/use.api"; +import type { + LoginCredentials, + RegisterData, + OrganizationCreateData, +} from "../types/auth.types"; + +export const useAuth = () => { + const navigate = useNavigate(); + const { isLoading, error, request } = useApi(); + const [authError, setAuthError] = useState(null); + + const login = async (credentials: LoginCredentials) => { + setAuthError(null); + const result = await request(() => authService.login(credentials)); + + if (result) { + authService.saveAuthData(result.access_token, result.user); + + // Проверяем, есть ли уже организация + const orgs = await request(() => authService.getOrganizations()); + if (orgs && orgs.length > 0) { + authService.saveOrganization(orgs[0]); + navigate("/home"); + } else { + navigate("/create-organization"); + } + } else if (error) { + setAuthError(error); + } + }; + + const register = async (data: RegisterData) => { + setAuthError(null); + + if (data.password !== data.confirmPassword) { + setAuthError("Пароли не совпадают"); + return; + } + + const { confirmPassword, ...registerData } = data; + const result = await request(() => authService.register(registerData)); + + if (result) { + authService.saveAuthData(result.access_token, result.user); + navigate("/create-organization"); + } else if (error) { + setAuthError(error); + } + }; + + const createOrganization = async (data: OrganizationCreateData) => { + setAuthError(null); + const result = await request(() => authService.createOrganization(data)); + + if (result) { + authService.saveOrganization(result); + navigate("/home"); + } else if (error) { + setAuthError(error); + } + }; + + const logout = () => { + authService.logout(); + navigate("/"); + }; + + return { + login, + register, + createOrganization, + logout, + isLoading, + error: authError, + isAuthenticated: authService.isAuthenticated(), + hasOrganization: authService.hasOrganization(), + }; +}; diff --git a/src/modules/auth/types/auth.types.ts b/src/modules/auth/types/auth.types.ts new file mode 100644 index 0000000..713bdc1 --- /dev/null +++ b/src/modules/auth/types/auth.types.ts @@ -0,0 +1,38 @@ +export interface LoginCredentials { + username: string; + password: string; +} + +export interface RegisterData { + username: string; + email: string; + password: string; + confirmPassword: string; + first_name: string; + last_name: string; +} + +export interface AuthResponse { + access_token: string; + token_type: string; + user: { + id: number; + username: string; + email: string; + first_name: string; + last_name: string; + }; +} + +export interface OrganizationCreateData { + name: string; + logo?: File; +} + +export interface OrganizationResponse { + id: number; + name: string; + logo_url?: string; + created_at: string; + owner_id: number; +} diff --git a/src/pages/AuthPage.tsx b/src/pages/AuthPage.tsx new file mode 100644 index 0000000..418b2b3 --- /dev/null +++ b/src/pages/AuthPage.tsx @@ -0,0 +1,50 @@ +import { useState } from "react"; +import { ThemeToggle } from "@/modules/theme-changer/ui/Theme.toggle"; +import { LoginForm } from "@/modules/auth/components/LoginForm"; +import { RegisterForm } from "@/modules/auth/components/RegisterForm"; + +export const AuthPage = () => { + const [isLogin, setIsLogin] = useState(true); + + return ( +
+
+ +
+ +
+
+

+ IPS Manager +

+

+ {isLogin + ? "Войдите в систему для управления IPS агентами" + : "Создайте аккаунт для начала работы"} +

+
+ + {isLogin ? ( + setIsLogin(false)} /> + ) : ( + setIsLogin(true)} /> + )} +
+
+ ); +}; diff --git a/src/pages/CreateOrganizationPage.tsx b/src/pages/CreateOrganizationPage.tsx new file mode 100644 index 0000000..e94af62 --- /dev/null +++ b/src/pages/CreateOrganizationPage.tsx @@ -0,0 +1,52 @@ +import { ThemeToggle } from "@/modules/theme-changer/ui/Theme.toggle"; +import { CreateOrganizationForm } from "@/modules/auth/components/CreateOrganizationForm"; +import { useAuth } from "@/modules/auth/hooks/useAuth"; +import { Navigate } from "react-router-dom"; + +export const CreateOrganizationPage = () => { + const { isAuthenticated, hasOrganization } = useAuth(); + + if (!isAuthenticated) { + return ; + } + + if (hasOrganization) { + return ; + } + + return ( +
+
+ +
+ +
+
+

+ Создание организации +

+

+ Создайте организацию для начала работы с IPS платформой +

+
+ + +
+
+ ); +};