From c0998bb782a8e13e09450622e62295ad8637b626 Mon Sep 17 00:00:00 2001 From: Serhii Date: Fri, 6 Sep 2024 17:58:55 +0300 Subject: [PATCH] feature-user-jwt-auth --- frontend/src/App.tsx | 20 +++++---- frontend/src/api/auth.ts | 30 ++++--------- frontend/src/components/LoadingMask.tsx | 19 ++++++++ frontend/src/components/nav/Sidebar.tsx | 52 ++++++++++++---------- frontend/src/contexts/AuthContext.tsx | 55 ++++++------------------ frontend/src/contexts/LoadingContext.tsx | 35 +++++++++++++++ frontend/src/pages/Login.tsx | 23 ++++++++-- frontend/src/types/auth.ts | 22 ++-------- linguaphoto/api/user.py | 18 ++++---- linguaphoto/crud/base.py | 2 +- linguaphoto/crud/user.py | 11 +++-- linguaphoto/schemas/user.py | 1 - linguaphoto/settings.py | 2 +- 13 files changed, 158 insertions(+), 132 deletions(-) create mode 100644 frontend/src/components/LoadingMask.tsx create mode 100644 frontend/src/contexts/LoadingContext.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 957ac8e..56539f6 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,6 +4,9 @@ import NotFoundRedirect from "components/NotFoundRedirect"; import { AlertQueue, AlertQueueProvider } from "hooks/alerts"; import { AuthenticationProvider, OneTimePasswordWrapper } from "hooks/auth"; import { ThemeProvider } from "hooks/theme"; +import { LoadingProvider } from "contexts/LoadingContext"; +import { AuthProvider } from "contexts/AuthContext"; +import LoadingMask from "components/LoadingMask"; import CollectionPage from "pages/Collection"; import Collections from "pages/Collections"; import Home from "pages/Home"; @@ -18,10 +21,10 @@ const App = () => { return ( - - - - + + + + } /> @@ -42,10 +45,11 @@ const App = () => { - - - - + + + + + ); diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index 579037d..65670f9 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -3,45 +3,31 @@ import axios from "axios"; import { SigninData, SignupData, - SignupResponse, - SignupResponseBusiness, + Response, } from "types/auth"; const API_URL = process.env.REACT_APP_BACKEND_URL || "https://localhost:8080"; -export const signup = async (data: SignupData): Promise => { +export const signup = async (data: SignupData): Promise => { const response = await axios.post(`${API_URL}/signup`, data); return response.data; }; -export const read_me = async (token: string): Promise => { - const response = await axios.get(`${API_URL}/api/user/me`, { +export const read_me = async (token: string): Promise => { + const response = await axios.get(`${API_URL}/me`, { headers: { Authorization: `Bearer ${token}`, }, }); return response.data; }; -export const read_me_business = async ( - token: string, -): Promise => { - const response = await axios.get(`${API_URL}/api/business/me`, { - headers: { - Authorization: `Bearer ${token}`, - }, - }); - return response.data; -}; -export const signin = async (data: SigninData): Promise => { - const params = new URLSearchParams(); - params.append("username", data.email); - params.append("password", data.password); - const response = await axios.post(`${API_URL}/api/user/token`, params); +export const signin = async (data: SigninData): Promise => { + const response = await axios.post(`${API_URL}/signin`, data); console.log(response); return response.data; }; export const social_facebook_login = async ( token: string, -): Promise => { +): Promise => { console.log(token); const response = await axios.post( `${API_URL}/api/facebook/callback`, @@ -58,7 +44,7 @@ export const social_facebook_login = async ( }; export const social_Instagram_login = async ( token: string, -): Promise => { +): Promise => { const response = await axios.post( `${API_URL}/api/instagram/callback`, { diff --git a/frontend/src/components/LoadingMask.tsx b/frontend/src/components/LoadingMask.tsx new file mode 100644 index 0000000..4ca9dc9 --- /dev/null +++ b/frontend/src/components/LoadingMask.tsx @@ -0,0 +1,19 @@ +// src/components/LoadingMask.tsx +import React from 'react'; +import { useLoading } from 'contexts/LoadingContext'; + +const LoadingMask: React.FC = () => { + const { loading } = useLoading(); + + if (!loading) return null; + + return ( +
+
+ Loading... +
+
+ ); +}; + +export default LoadingMask; diff --git a/frontend/src/components/nav/Sidebar.tsx b/frontend/src/components/nav/Sidebar.tsx index 9dc035b..f5d1294 100644 --- a/frontend/src/components/nav/Sidebar.tsx +++ b/frontend/src/components/nav/Sidebar.tsx @@ -7,7 +7,7 @@ import { FaTimes, } from "react-icons/fa"; import { useNavigate } from "react-router-dom"; - +import { useAuth } from "contexts/AuthContext"; import clsx from "clsx"; interface SidebarItemProps { @@ -70,7 +70,7 @@ interface SidebarProps { const Sidebar = ({ show, onClose }: SidebarProps) => { const navigate = useNavigate(); - + const { is_auth, signout } = useAuth(); return (
{show ? ( @@ -106,7 +106,8 @@ const Sidebar = ({ show, onClose }: SidebarProps) => { }} size="md" /> - } onClick={() => { @@ -114,7 +115,7 @@ const Sidebar = ({ show, onClose }: SidebarProps) => { onClose(); }} size="md" - /> + />:<>} } @@ -130,25 +131,30 @@ const Sidebar = ({ show, onClose }: SidebarProps) => {
    - } - onClick={() => { - navigate("/login"); - onClose(); - }} - size="md" - /> - } - onClick={() => { - // Handle logout logic here - navigate("/login"); - onClose(); - }} - size="md" - /> + { + is_auth ? + } + onClick={() => { + // Handle logout logic here + signout(); + navigate("/login"); + onClose(); + }} + size="md" + /> : + } + onClick={() => { + navigate("/login"); + onClose(); + }} + size="md" + /> + } +
diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx index b49c06d..77e2f61 100644 --- a/frontend/src/contexts/AuthContext.tsx +++ b/frontend/src/contexts/AuthContext.tsx @@ -1,5 +1,5 @@ // src/context/AuthContext.tsx -import { read_me, read_me_business } from "api/auth"; +import { read_me } from "api/auth"; import React, { createContext, ReactNode, @@ -7,78 +7,49 @@ import React, { useEffect, useState, } from "react"; -import { SignupResponse, SignupResponseBusiness } from "types/auth"; +import { Response} from "types/auth"; interface AuthContextType { - auth: SignupResponse | null; - auth_business: SignupResponseBusiness | null; - auth_type: "user" | "business"; - setAuth: React.Dispatch>; - setAuthBusiness: React.Dispatch< - React.SetStateAction - >; - setAuthType: React.Dispatch>; + is_auth: boolean; + auth: Response | null; + setAuth: React.Dispatch>; signout: () => void; } const AuthContext = createContext(undefined); const AuthProvider = ({ children }: { children: ReactNode }) => { - const [auth, setAuth] = useState(null); - const [auth_business, setAuthBusiness] = - useState(null); - const [auth_type, setAuthType] = useState<"user" | "business">("user"); + const [auth, setAuth] = useState(null); + const [is_auth, setFlag] = useState(false); const signout = () => { localStorage.removeItem("token"); - localStorage.removeItem("type"); setAuth({}); - setAuthBusiness({}); + setFlag(false); }; useEffect(() => { - console.log("1"); const token = localStorage.getItem("token"); - const auth_type = localStorage.getItem("type"); if (token) { - if (auth_type == "user") { const fetch_data = async (token: string) => { const response = await read_me(token); console.log(response); if (response) setAuth(response); }; fetch_data(token); - } else { - const fetch_data = async (token: string) => { - const response = await read_me_business(token); - console.log(response); - if (response) setAuthBusiness(response); - }; - fetch_data(token); } - } else signout(); + else signout(); }, []); useEffect(() => { - console.log("2"); - if (auth?.access_token) { - localStorage.setItem("token", auth.access_token); - localStorage.setItem("type", "user"); + if (auth?.token) { + localStorage.setItem("token", auth.token); + setFlag(true); } }, [auth]); - useEffect(() => { - console.log("3"); - if (auth_business?.access_token) { - localStorage.setItem("token", auth_business.access_token); - localStorage.setItem("type", "business"); - } - }, [auth_business]); return ( diff --git a/frontend/src/contexts/LoadingContext.tsx b/frontend/src/contexts/LoadingContext.tsx new file mode 100644 index 0000000..daec71f --- /dev/null +++ b/frontend/src/contexts/LoadingContext.tsx @@ -0,0 +1,35 @@ +// src/context/LoadingContext.tsx +import React, { createContext, useState, useContext, ReactNode } from 'react'; + +interface LoadingContextType { + loading: boolean; + startLoading: () => void; + stopLoading: () => void; +} + +const LoadingContext = createContext(undefined); + +export const useLoading = (): LoadingContextType => { + const context = useContext(LoadingContext); + if (!context) { + throw new Error('useLoading must be used within a LoadingProvider'); + } + return context; +}; + +interface LoadingProviderProps { + children: ReactNode; +} + +export const LoadingProvider: React.FC = ({ children }) => { + const [loading, setLoading] = useState(false); + + const startLoading = () => setLoading(true); + const stopLoading = () => setLoading(false); + + return ( + + {children} + + ); +}; diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index a4bf4bf..1221b3b 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -1,15 +1,25 @@ -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; import { Google } from "react-bootstrap-icons"; +import { useLoading } from "contexts/LoadingContext"; +import { signup, signin } from "api/auth"; +import { useAuth } from "contexts/AuthContext"; +import { useNavigate } from "react-router-dom" const LoginPage: React.FC = () => { const [isSignup, setIsSignup] = useState(false); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [username, setName] = useState(""); + const {startLoading, stopLoading} = useLoading(); + const {is_auth, setAuth} = useAuth() + const navigate = useNavigate(); + useEffect(()=>{ + if(is_auth) + navigate('/collections'); + },[is_auth]) // Toggle between login and signup forms const handleSwitch = () => { setIsSignup(!isSignup); }; - // Handle form submission const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -17,9 +27,16 @@ const LoginPage: React.FC = () => { // Add your logic for login/signup here if (isSignup) { // You can call your API for sign-up - // const user = await signup({email, password, username}) + startLoading(); + const user = await signup({email, password, username}) + setAuth(user) + stopLoading(); } else { // You can call your API for login + startLoading(); + const user = await signin({email, password}) + setAuth(user) + stopLoading(); } }; diff --git a/frontend/src/types/auth.ts b/frontend/src/types/auth.ts index eac0a5e..d361f2f 100644 --- a/frontend/src/types/auth.ts +++ b/frontend/src/types/auth.ts @@ -8,24 +8,8 @@ export interface SigninData { email: string; password: string; } -export interface SignupResponse { +export interface Response { username?: string; email?: string; - phone?: string; - bio?: string; - error_message?: string; - access_token?: string | null; - link1?: string; - link2?: string; - link3?: string; - link4?: string; - avatar_url?: string; -} -export interface SignupResponseBusiness { - name?: string; - email?: string; - bio?: string; - error_message?: string; - access_token?: string | null; - avatar_url?: string; -} + token?: string | null; +} \ No newline at end of file diff --git a/linguaphoto/api/user.py b/linguaphoto/api/user.py index 12ae4ae..56725a6 100644 --- a/linguaphoto/api/user.py +++ b/linguaphoto/api/user.py @@ -14,8 +14,8 @@ router = APIRouter() -@router.post("/signup", response_model=UserSigninRespondFragment) -async def signup(user: UserSignupFragment, user_crud: UserCrud = Depends()) -> dict: +@router.post("/signup", response_model=UserSigninRespondFragment | None) +async def signup(user: UserSignupFragment, user_crud: UserCrud = Depends()) -> dict | None: """User registration endpoint. This endpoint allows a new user to sign up by providing the necessary user details. @@ -23,13 +23,15 @@ async def signup(user: UserSignupFragment, user_crud: UserCrud = Depends()) -> d """ async with user_crud: new_user = await user_crud.create_user_from_email(user) - token = create_access_token({"id": user.id}, timedelta(hours=24)) - res_user = UserSigninRespondFragment(id=new_user.id, token=token, username=user.username, email=user.email) + if new_user is None: + return None + token = create_access_token({"id": new_user.id}, timedelta(hours=24)) + res_user = UserSigninRespondFragment(token=token, username=user.username, email=user.email) return res_user.model_dump() -@router.post("/signin", response_model=UserSigninRespondFragment) -async def signin(user: UserSigninFragment, user_crud: UserCrud = Depends()) -> dict: +@router.post("/signin", response_model=UserSigninRespondFragment | None) +async def signin(user: UserSigninFragment, user_crud: UserCrud = Depends()) -> dict | None: """User login endpoint. This endpoint allows an existing user to sign in by verifying their credentials. @@ -46,8 +48,8 @@ async def signin(user: UserSigninFragment, user_crud: UserCrud = Depends()) -> d raise HTTPException(status_code=422, detail="Could not validate credentials") -@router.get("/me", response_model=UserSigninRespondFragment) -async def get_me(token: str = Depends(oauth2_schema), user_crud: UserCrud = Depends()) -> dict: +@router.get("/me", response_model=UserSigninRespondFragment | None) +async def get_me(token: str = Depends(oauth2_schema), user_crud: UserCrud = Depends()) -> dict | None: """Retrieve the currently authenticated user's information. This endpoint uses the provided token to decode and identify the user. diff --git a/linguaphoto/crud/base.py b/linguaphoto/crud/base.py index 83f51fa..0b02030 100644 --- a/linguaphoto/crud/base.py +++ b/linguaphoto/crud/base.py @@ -98,7 +98,7 @@ async def _add_item(self, item: LinguaBaseModel, unique_fields: list[str] | None # Log the item data before insertion for debugging purposes logger.info("Inserting item into DynamoDB: %s", item_data) - + print(condition) try: await table.put_item( Item=item_data, diff --git a/linguaphoto/crud/user.py b/linguaphoto/crud/user.py index b6b3455..d9869b3 100644 --- a/linguaphoto/crud/user.py +++ b/linguaphoto/crud/user.py @@ -8,10 +8,13 @@ class UserCrud(BaseCrud): - async def create_user_from_email(self, user: UserSignupFragment) -> User: - user = User.create(user) - await self._add_item(user, unique_fields=["email"]) - return user + async def create_user_from_email(self, user: UserSignupFragment) -> User | None: + duplicated_user = await self._get_items_from_secondary_index("email", user.email, User) + if duplicated_user: + return None + new_user = User.create(user) + await self._add_item(new_user, unique_fields=["email"]) + return new_user async def get_user(self, id: str, throw_if_missing: bool = False) -> User | None: return await self._get_item(id, User, throw_if_missing=throw_if_missing) diff --git a/linguaphoto/schemas/user.py b/linguaphoto/schemas/user.py index 28d6288..a869398 100644 --- a/linguaphoto/schemas/user.py +++ b/linguaphoto/schemas/user.py @@ -23,7 +23,6 @@ class UserSigninFragment(BaseModel): class UserSigninRespondFragment(BaseModel): - id: str token: str username: str email: EmailStr diff --git a/linguaphoto/settings.py b/linguaphoto/settings.py index 7c0e596..89a4621 100644 --- a/linguaphoto/settings.py +++ b/linguaphoto/settings.py @@ -19,7 +19,7 @@ class Settings: bucket_name = os.getenv("S3_BUCKET_NAME") - dynamodb_table_name = os.getenv("DYNAMODB_TABLE_NAME") + dynamodb_table_name = os.getenv("DYNAMODB_TABLE_NAME", "linguaphoto") media_hosting_server = os.getenv("MEDIA_HOSTING_SERVER") key_pair_id = os.getenv("KEY_PAIR_ID") aws_region_name = os.getenv("AWS_REGION")