diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 94a7b531..e170f51e 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -44,7 +44,6 @@ jobs: - name: Write .env.production working-directory: frontend run: | - echo "VITE_APP_GOOGLE_CLIENT_ID=${{ secrets.GOOGLE_CLIENT_ID }}" >> .env.production echo "VITE_APP_BACKEND_URL=${{ secrets.BACKEND_URL }}" >> .env.production - name: Build frontend diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9b56349d..3344e0dd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -123,18 +123,15 @@ export SMTP_SENDER_EMAIL='' export SMTP_PASSWORD='' export SMTP_SENDER_NAME='' export SMTP_USERNAME='' +``` -# For Github OAuth -export GITHUB_CLIENT_ID='' -export GITHUB_CLIENT_SECRET='' +### Google OAuth Configuration -# For Google OAuth -export VITE_GOOGLE_CLIENT_ID='' -``` +The repository's local configuration comes with Google OAuth credentials for a test application. Alternatively, you can set up your own Google OAuth application to test the application locally, by following the instructions [here](https://blog.logrocket.com/guide-adding-google-login-react-app/). ### Github OAuth Configuration -To run Github OAuth locally, you must follow these steps: +The repository's local configuration comes with Github OAuth credentials for a test application. Alternatively, you can set up your own Github OAuth application to test the application locally: 1. Create an OAuth App on [Github Developer Settings](https://github.com/settings/developers) 2. Set both Homepage URL and Authorization callback URL to `http://127.0.0.1:3000/login` before you `Update application` on Github Oauth App configuration @@ -168,16 +165,6 @@ To run code formatting: npm run format ``` -### Google Client ID - -You will need to set `VITE_APP_GOOGLE_CLIENT_ID`. To do this, first create a Google client id (see [this LogRocket post](https://blog.logrocket.com/guide-adding-google-login-react-app/)). Then create a `.env.local` file in the `frontend` directory and add the following line: - -``` -VITE_APP_GOOGLE_CLIENT_ID=your-client-id -``` - -Additionally, you should set `VITE_APP_BACKEND_URL` to the URL of the FastAPI backend. This should be `http://127.0.0.1:8080` when developing locally. - ## Testing To run the tests, you can use the following commands: diff --git a/Makefile b/Makefile index 7491c4b6..ea24e439 100644 --- a/Makefile +++ b/Makefile @@ -32,6 +32,9 @@ start-backend: start-frontend: @cd frontend && npm run dev +update-api: + @cd frontend && openapi-typescript http://localhost:8080/openapi.json --output src/gen/api.ts + start-docker-dynamodb: @docker kill store-db || true @docker rm store-db || true diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 846edf2c..51bedb3a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -37,7 +37,7 @@ const App = () => { } /> } /> } /> - } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/auth/AuthBlock.tsx b/frontend/src/components/auth/AuthBlock.tsx index 84b0951f..d1fb0a53 100644 --- a/frontend/src/components/auth/AuthBlock.tsx +++ b/frontend/src/components/auth/AuthBlock.tsx @@ -3,15 +3,14 @@ import { useEffect, useState } from "react"; import { useAlertQueue } from "hooks/useAlertQueue"; import { useAuthentication } from "hooks/useAuth"; +import AuthProvider from "components/auth/AuthProvider"; +import LoginForm from "components/auth/LoginForm"; +import SignupWithEmail from "components/auth/SignupWithEmail"; import BackButton from "components/ui/Button/BackButton"; import { Card, CardContent, CardFooter, CardHeader } from "components/ui/Card"; import Header from "components/ui/Header"; import Spinner from "components/ui/Spinner"; -import AuthProvider from "./AuthProvider"; -import LoginForm from "./LoginForm"; -import SignupWithEmail from "./SignupWithEmail"; - export const AuthBlockInner = () => { const auth = useAuthentication(); const { addErrorAlert } = useAlertQueue(); @@ -19,19 +18,6 @@ export const AuthBlockInner = () => { const [isSignup, setIsSignup] = useState(false); const [useSpinner, setUseSpinner] = useState(false); - const handleGithubSubmit = async ( - event: React.MouseEvent, - ) => { - event.preventDefault(); - - const { data, error } = await auth.client.GET("/users/github/login"); - if (error) { - addErrorAlert(error); - } else { - window.open(data, "_self"); - } - }; - useEffect(() => { (async () => { // Get the code from the query string to carry out OAuth login. @@ -70,7 +56,7 @@ export const AuthBlockInner = () => { {isSignup ? : } - + , - ) => Promise; - handleGithubSubmit?: ( - event: React.MouseEvent, - ) => Promise; -} - -const GoogleAuthComponentInner = () => { +const GoogleAuthButton = () => { const [credential, setCredential] = useState(null); const auth = useAuthentication(); const { addErrorAlert } = useAlertQueue(); @@ -31,6 +22,7 @@ const GoogleAuthComponentInner = () => { token: credential, }, }); + if (error) { addErrorAlert(error); } else { @@ -40,7 +32,7 @@ const GoogleAuthComponentInner = () => { })(); }, [credential]); - const login = useGoogleLogin({ + const handleGoogleLogin = useGoogleLogin({ onSuccess: (tokenResponse) => { const returnedCredential = tokenResponse.access_token; if (returnedCredential === undefined) { @@ -62,14 +54,78 @@ const GoogleAuthComponentInner = () => { variant={"outline"} size={"lg"} className="w-full hover:bg-gray-100 dark:hover:bg-gray-600" - onClick={() => login()} + onClick={() => handleGoogleLogin()} + disabled={credential !== null} > ); }; -const AuthProvider = ({ handleGithubSubmit }: AuthProvider) => { +const GoogleAuthButtonWrapper = () => { + const [googleClientId, setGoogleClientId] = useState(null); + const auth = useAuthentication(); + const { addErrorAlert } = useAlertQueue(); + + useEffect(() => { + (async () => { + if (googleClientId !== null) return; + + const { data, error } = await auth.client.GET("/users/google/client-id"); + if (error) { + addErrorAlert(error); + } else { + setGoogleClientId(data.client_id); + } + })(); + }, [googleClientId]); + + return googleClientId === null ? ( + + ) : ( + + + + ); +}; + +const GithubAuthButton = () => { + const auth = useAuthentication(); + const { addErrorAlert } = useAlertQueue(); + + const handleGithubSubmit = async ( + event: React.MouseEvent, + ) => { + event.preventDefault(); + + const { data, error } = await auth.client.GET("/users/github/login"); + if (error) { + addErrorAlert(error); + } else { + window.open(data, "_self"); + } + }; + + return ( + + ); +}; + +const AuthProvider = () => { return (
@@ -79,19 +135,10 @@ const AuthProvider = ({ handleGithubSubmit }: AuthProvider) => {
{/* Google */} - - - + {/* Github */} - +
); diff --git a/frontend/src/constants/env.ts b/frontend/src/constants/env.ts index 957495af..16f7c6ab 100644 --- a/frontend/src/constants/env.ts +++ b/frontend/src/constants/env.ts @@ -1,3 +1,2 @@ export const BACKEND_URL = import.meta.env.VITE_APP_BACKEND_URL || "http://127.0.0.1:8080"; -export const GOOGLE_CLIENT_ID = import.meta.env.VITE_GOOGLE_CLIENT_ID || ""; diff --git a/frontend/src/gen/api.ts b/frontend/src/gen/api.ts index e88f4f11..3eb37db4 100644 --- a/frontend/src/gen/api.ts +++ b/frontend/src/gen/api.ts @@ -114,8 +114,8 @@ export interface paths { path?: never; cookie?: never; }; - /** Get Users Batch Endpoint */ - get: operations["get_users_batch_endpoint_users_public_batch_get"]; + /** Get Users Public Batch Endpoint */ + get: operations["get_users_public_batch_endpoint_users_public_batch_get"]; put?: never; post?: never; delete?: never; @@ -141,6 +141,23 @@ export interface paths { patch?: never; trace?: never; }; + "/users/public/me": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get My Public User Info Endpoint */ + get: operations["get_my_public_user_info_endpoint_users_public_me_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/users/public/{id}": { parameters: { query?: never; @@ -209,6 +226,23 @@ export interface paths { patch?: never; trace?: never; }; + "/users/google/client-id": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Google Client Id Endpoint */ + get: operations["google_client_id_endpoint_users_google_client_id_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/users/google/login": { parameters: { query?: never; @@ -526,6 +560,11 @@ export interface components { /** Metadata */ metadata: string; }; + /** ClientIdResponse */ + ClientIdResponse: { + /** Client Id */ + client_id: string; + }; /** DeleteTokenResponse */ DeleteTokenResponse: { /** Message */ @@ -678,6 +717,19 @@ export interface components { /** Token */ token: string; }; + /** MyUserInfoResponse */ + MyUserInfoResponse: { + /** User Id */ + user_id: string; + /** Email */ + email: string; + /** Github Id */ + github_id: string | null; + /** Google Id */ + google_id: string | null; + /** Permissions */ + permissions: "is_admin"[] | null; + }; /** NewListingRequest */ NewListingRequest: { /** Name */ @@ -692,13 +744,8 @@ export interface components { /** Listing Id */ listing_id: string; }; - /** PublicUserInfoResponse */ - PublicUserInfoResponse: { - /** Users */ - users: components["schemas"]["SinglePublicUserInfoResponseItem"][]; - }; - /** SinglePublicUserInfoResponseItem */ - SinglePublicUserInfoResponseItem: { + /** PublicUserInfoResponseItem */ + PublicUserInfoResponseItem: { /** Id */ id: string; /** Email */ @@ -718,12 +765,10 @@ export interface components { /** Bio */ bio?: string | null; }; - /** SingleUserInfoResponseItem */ - SingleUserInfoResponseItem: { - /** Id */ - id: string; - /** Email */ - email: string; + /** PublicUsersInfoResponse */ + PublicUsersInfoResponse: { + /** Users */ + users: components["schemas"]["PublicUserInfoResponseItem"][]; }; /** UpdateArtifactRequest */ UpdateArtifactRequest: { @@ -747,18 +792,12 @@ export interface components { UploadArtifactResponse: { artifact: components["schemas"]["ListArtifactsItem"]; }; - /** UserInfoResponse */ - UserInfoResponse: { - /** User Id */ - user_id: string; + /** UserInfoResponseItem */ + UserInfoResponseItem: { + /** Id */ + id: string; /** Email */ email: string; - /** Github Id */ - github_id: string | null; - /** Google Id */ - google_id: string | null; - /** Permissions */ - permissions: "is_admin"[] | null; }; /** * UserPublic @@ -849,7 +888,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["UserInfoResponse"]; + "application/json": components["schemas"]["MyUserInfoResponse"]; }; }; }; @@ -913,7 +952,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SingleUserInfoResponseItem"]; + "application/json": components["schemas"]["UserInfoResponseItem"]; }; }; /** @description Validation Error */ @@ -977,7 +1016,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["PublicUserInfoResponse"]; + "application/json": components["schemas"]["PublicUsersInfoResponse"]; }; }; /** @description Validation Error */ @@ -991,7 +1030,7 @@ export interface operations { }; }; }; - get_users_batch_endpoint_users_public_batch_get: { + get_users_public_batch_endpoint_users_public_batch_get: { parameters: { query: { ids: string[]; @@ -1008,7 +1047,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["PublicUserInfoResponse"]; + "application/json": components["schemas"]["PublicUsersInfoResponse"]; }; }; /** @description Validation Error */ @@ -1039,7 +1078,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SingleUserInfoResponseItem"]; + "application/json": components["schemas"]["UserInfoResponseItem"]; }; }; /** @description Validation Error */ @@ -1053,6 +1092,26 @@ export interface operations { }; }; }; + get_my_public_user_info_endpoint_users_public_me_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UserPublic"]; + }; + }; + }; + }; get_public_user_info_by_id_endpoint_users_public__id__get: { parameters: { query?: never; @@ -1137,6 +1196,26 @@ export interface operations { }; }; }; + google_client_id_endpoint_users_google_client_id_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ClientIdResponse"]; + }; + }; + }; + }; google_login_endpoint_users_google_login_post: { parameters: { query?: never; diff --git a/frontend/src/pages/Profile.tsx b/frontend/src/pages/Profile.tsx index fb8517d1..a0de72b2 100644 --- a/frontend/src/pages/Profile.tsx +++ b/frontend/src/pages/Profile.tsx @@ -42,29 +42,35 @@ const ProfileDetails = () => { useEffect(() => { const fetchUser = async () => { if (id === undefined) { - return; - } - - try { - const { data, error } = await auth.client.GET("/users/public/{id}", { - params: { - path: { id }, - }, - }); + const { data, error } = await auth.client.GET("/users/public/me"); if (error) { addErrorAlert(error); } else { - setUser(data as UserResponse); // Ensure correct typing + setUser(data); + } + } else { + try { + const { data, error } = await auth.client.GET("/users/public/{id}", { + params: { + path: { id }, + }, + }); + + if (error) { + addErrorAlert(error); + } else { + setUser(data); + } + } catch (err) { + addErrorAlert(err); } - } catch (err) { - addErrorAlert(err); } }; fetchUser(); }, [id]); - return user && id ? ( + return user ? ( ) : (
diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts index c3241695..3eaa257a 100644 --- a/frontend/src/vite-env.d.ts +++ b/frontend/src/vite-env.d.ts @@ -1,6 +1,5 @@ interface ImportMetaEnv { readonly VITE_APP_BACKEND_URL: string; - readonly VITE_GOOGLE_CLIENT_ID: string; } interface ImportMeta { diff --git a/store/app/routers/auth/google.py b/store/app/routers/auth/google.py index 8cb3bdf5..91848a78 100644 --- a/store/app/routers/auth/google.py +++ b/store/app/routers/auth/google.py @@ -8,6 +8,7 @@ from pydantic.main import BaseModel from store.app.db import Crud +from store.settings import settings logger = logging.getLogger(__name__) @@ -29,11 +30,20 @@ async def get_google_user_email(token: str) -> str: return (await response.json())["email"] +class ClientIdResponse(BaseModel): + client_id: str + + +@google_auth_router.get("/client-id", response_model=ClientIdResponse) +async def google_client_id_endpoint() -> ClientIdResponse: + return ClientIdResponse(client_id=settings.oauth.google_client_id) + + class AuthResponse(BaseModel): api_key: str -@google_auth_router.post("/login") +@google_auth_router.post("/login", response_model=AuthResponse) async def google_login_endpoint( data: GoogleLogin, crud: Annotated[Crud, Depends(Crud.get)], diff --git a/store/app/routers/users.py b/store/app/routers/users.py index 1f1f1a34..d75d71f7 100644 --- a/store/app/routers/users.py +++ b/store/app/routers/users.py @@ -2,7 +2,7 @@ import logging from email.utils import parseaddr as parse_email_address -from typing import Annotated, Literal, overload +from typing import Annotated, Literal, Self, overload from fastapi import APIRouter, Depends, HTTPException, Query, Request, status from fastapi.security.utils import get_authorization_scheme_param @@ -137,7 +137,7 @@ class UserSignup(BaseModel): password: str -class UserInfoResponse(BaseModel): +class MyUserInfoResponse(BaseModel): user_id: str email: str github_id: str | None @@ -145,12 +145,12 @@ class UserInfoResponse(BaseModel): permissions: set[UserPermission] | None -@users_router.get("/me", response_model=UserInfoResponse) +@users_router.get("/me", response_model=MyUserInfoResponse) async def get_user_info_endpoint( user: Annotated[User, Depends(get_session_user_with_read_permission)], -) -> UserInfoResponse | None: +) -> MyUserInfoResponse | None: try: - return UserInfoResponse( + return MyUserInfoResponse( user_id=user.id, email=user.email, google_id=user.google_id, @@ -180,16 +180,23 @@ async def logout_user_endpoint( return True -class SingleUserInfoResponseItem(BaseModel): +class UserInfoResponseItem(BaseModel): id: str email: str + @classmethod + def from_user(cls, user: User) -> Self: + return cls( + id=user.id, + email=user.email, + ) + -class UserInfoResponse(BaseModel): - users: list[SingleUserInfoResponseItem] +class UsersInfoResponse(BaseModel): + users: list[UserInfoResponseItem] -class SinglePublicUserInfoResponseItem(BaseModel): +class PublicUserInfoResponseItem(BaseModel): id: str email: str permissions: set[UserPermission] | None = None @@ -200,15 +207,29 @@ class SinglePublicUserInfoResponseItem(BaseModel): name: str | None = None bio: str | None = None + @classmethod + def from_user(cls, user: User | UserPublic) -> Self: + return cls( + id=user.id, + email=user.email, + permissions=user.permissions, + created_at=user.created_at, + updated_at=user.updated_at, + first_name=user.first_name, + last_name=user.last_name, + name=user.name, + bio=user.bio, + ) + -class PublicUserInfoResponse(BaseModel): - users: list[SinglePublicUserInfoResponseItem] +class PublicUsersInfoResponse(BaseModel): + users: list[PublicUserInfoResponseItem] -@users_router.post("/signup", response_model=SingleUserInfoResponseItem) +@users_router.post("/signup", response_model=UserInfoResponseItem) async def register_user( data: UserSignup, email_signup_crud: EmailSignUpCrud = Depends(), user_crud: UserCrud = Depends() -) -> SingleUserInfoResponseItem: +) -> UserInfoResponseItem: async with email_signup_crud, user_crud: signup_token = await email_signup_crud.get_email_signup_token(data.signup_token_id) if not signup_token: @@ -221,7 +242,7 @@ async def register_user( user = await user_crud._create_user_from_email(email=signup_token.email, password=data.password) # Delete the signup token await email_signup_crud.delete_email_signup_token(data.signup_token_id) - return SingleUserInfoResponseItem(id=user.id, email=user.email) + return UserInfoResponseItem(id=user.id, email=user.email) class LoginRequest(BaseModel): @@ -255,40 +276,48 @@ async def login_user(data: LoginRequest, user_crud: UserCrud = Depends()) -> Log return LoginResponse(user_id=user.id, token=api_key.id) -@users_router.get("/batch", response_model=PublicUserInfoResponse) +@users_router.get("/batch", response_model=PublicUsersInfoResponse) async def get_users_batch_endpoint( crud: Annotated[Crud, Depends(Crud.get)], ids: list[str] = Query(...), -) -> PublicUserInfoResponse: +) -> PublicUsersInfoResponse: users = await crud.get_user_batch(ids) - return UserInfoResponse(users=[SingleUserInfoResponseItem(user) for user in users]) + return PublicUsersInfoResponse(users=[PublicUserInfoResponseItem.from_user(user) for user in users]) -@users_router.get("/public/batch", response_model=PublicUserInfoResponse) +@users_router.get("/public/batch", response_model=PublicUsersInfoResponse) async def get_users_public_batch_endpoint( crud: Annotated[Crud, Depends(Crud.get)], ids: list[str] = Query(...), -) -> PublicUserInfoResponse: +) -> PublicUsersInfoResponse: users = await crud.get_user_batch(ids) - return PublicUserInfoResponse(users=[SinglePublicUserInfoResponseItem(user) for user in users]) + return PublicUsersInfoResponse(users=[PublicUserInfoResponseItem.from_user(user) for user in users]) -@users_router.get("/{id}", response_model=SingleUserInfoResponseItem) -async def get_user_info_by_id_endpoint(id: str, crud: Annotated[Crud, Depends(Crud.get)]) -> SingleUserInfoResponseItem: +@users_router.get("/{id}", response_model=UserInfoResponseItem) +async def get_user_info_by_id_endpoint(id: str, crud: Annotated[Crud, Depends(Crud.get)]) -> UserInfoResponseItem: user = await crud.get_user(id) if user is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") - return SingleUserInfoResponseItem(user) + return UserInfoResponseItem.from_user(user) + + +@users_router.get("/public/me", response_model=UserPublic) +async def get_my_public_user_info_endpoint( + user: Annotated[User, Depends(get_session_user_with_read_permission)], +) -> PublicUserInfoResponseItem: + return PublicUserInfoResponseItem.from_user(user) @users_router.get("/public/{id}", response_model=UserPublic) async def get_public_user_info_by_id_endpoint( - id: str, user_crud: UserCrud = Depends() -) -> SinglePublicUserInfoResponseItem: + id: str, + user_crud: Annotated[Crud, Depends(Crud.get)], +) -> PublicUserInfoResponseItem: user = await user_crud.get_user_public(id) if user is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") - return SinglePublicUserInfoResponseItem(user) + return PublicUserInfoResponseItem.from_user(user) users_router.include_router(github_auth_router, prefix="/github")