From 328105b57691917bcbf9270ed626837a8e41c95f Mon Sep 17 00:00:00 2001 From: Winston Hsiao <96440583+Winston-Hsiao@users.noreply.github.com> Date: Mon, 12 Aug 2024 19:40:14 -0400 Subject: [PATCH] Email sign up flow (#282) * base for email sign up flow * CRUD and routers for EmailSignUpToken, two part register flow almost done * EmailSignUp token creation done, refined routes * fix email-signup-get and improved register UI * User creation from email sign up * redirect to login after User account creation * partial email/pass login logic * fix edge cases and type issues in API routes * Log in completed * Fix subject of registration email * fix type of /delete route for signup token * Delete signup token on registration * Actually add API key to email /login route * apparently I have to await a result I don't even care about thanks mypy * Use Google-style comments Co-authored-by: Ben Bolte * email_router and google style comments * changed register to be signup * Fixed styling/linting error --------- Co-authored-by: Dennis Chen Co-authored-by: Dennis Chen <41879777+chennisden@users.noreply.github.com> Co-authored-by: Ben Bolte --- frontend/src/App.tsx | 2 + frontend/src/components/auth/AuthBlock.tsx | 6 +- frontend/src/components/auth/LoginForm.tsx | 20 +- frontend/src/components/auth/SignupForm.tsx | 37 ++- .../src/components/auth/SignupWithEmail.tsx | 67 ++++ frontend/src/components/footer/Footer.tsx | 3 +- .../src/components/ui/Input/PasswordInput.tsx | 23 +- frontend/src/gen/api.ts | 306 ++++++++++++++++++ frontend/src/pages/ListingDetails.tsx | 2 +- frontend/src/pages/Signup.tsx | 82 +++++ frontend/src/types/index.ts | 11 + store/app/crud/email_signup.py | 25 ++ store/app/main.py | 9 +- store/app/model.py | 14 +- store/app/routers/email_signup.py | 63 ++++ store/app/routers/users.py | 66 +++- store/app/utils/email.py | 6 +- 17 files changed, 710 insertions(+), 32 deletions(-) create mode 100644 frontend/src/components/auth/SignupWithEmail.tsx create mode 100644 frontend/src/pages/Signup.tsx create mode 100644 store/app/crud/email_signup.py create mode 100644 store/app/routers/email_signup.py diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0c41861c..8e9cd9d3 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -15,6 +15,7 @@ import Login from "pages/Login"; import Logout from "pages/Logout"; import NotFound from "pages/NotFound"; import Profile from "pages/Profile"; +import Signup from "pages/Signup"; import Container from "components/Container"; import NotFoundRedirect from "components/NotFoundRedirect"; @@ -39,6 +40,7 @@ const App = () => { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/auth/AuthBlock.tsx b/frontend/src/components/auth/AuthBlock.tsx index dd9999c7..84b0951f 100644 --- a/frontend/src/components/auth/AuthBlock.tsx +++ b/frontend/src/components/auth/AuthBlock.tsx @@ -10,7 +10,7 @@ import Spinner from "components/ui/Spinner"; import AuthProvider from "./AuthProvider"; import LoginForm from "./LoginForm"; -import SignupForm from "./SignupForm"; +import SignupWithEmail from "./SignupWithEmail"; export const AuthBlockInner = () => { const auth = useAuthentication(); @@ -66,7 +66,9 @@ export const AuthBlockInner = () => { return ( <> - {isSignup ? : } + + {isSignup ? : } + diff --git a/frontend/src/components/auth/LoginForm.tsx b/frontend/src/components/auth/LoginForm.tsx index d97c756a..0127bddf 100644 --- a/frontend/src/components/auth/LoginForm.tsx +++ b/frontend/src/components/auth/LoginForm.tsx @@ -2,6 +2,7 @@ import { SubmitHandler, useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { useAlertQueue } from "hooks/useAlertQueue"; +import { useAuthentication } from "hooks/useAuth"; import { LoginSchema, LoginType } from "types"; import { Button } from "components/ui/Button/Button"; @@ -18,11 +19,24 @@ const LoginForm = () => { resolver: zodResolver(LoginSchema), }); - const { addAlert } = useAlertQueue(); + const { addAlert, addErrorAlert } = useAlertQueue(); + const auth = useAuthentication(); const onSubmit: SubmitHandler = async (data: LoginType) => { - // TODO: Add an api endpoint to send the credentials details to backend and email verification. - addAlert(`Not yet implemented: ${data.email}`, "success"); + try { + const { data: response, error } = await auth.client.POST("/users/login", { + body: data, + }); + + if (error) { + addErrorAlert(error); + } else { + addAlert(`Logged in! Welcome back!`, "success"); + auth.login(response.token); + } + } catch { + addErrorAlert("An unexpected error occurred during login."); + } }; return ( diff --git a/frontend/src/components/auth/SignupForm.tsx b/frontend/src/components/auth/SignupForm.tsx index d301023a..7eee4fe2 100644 --- a/frontend/src/components/auth/SignupForm.tsx +++ b/frontend/src/components/auth/SignupForm.tsx @@ -1,8 +1,9 @@ import { SubmitHandler, useForm } from "react-hook-form"; -import { Link } from "react-router-dom"; +import { Link, useNavigate } from "react-router-dom"; import { zodResolver } from "@hookform/resolvers/zod"; import { useAlertQueue } from "hooks/useAlertQueue"; +import { useAuthentication } from "hooks/useAuth"; import { SignUpSchema, SignupType } from "types"; import zxcvbn from "zxcvbn"; @@ -11,7 +12,15 @@ import ErrorMessage from "components/ui/ErrorMessage"; import { Input } from "components/ui/Input/Input"; import PasswordInput from "components/ui/Input/PasswordInput"; -const SignupForm = () => { +interface SignupFormProps { + signupTokenId: string; +} + +const SignupForm: React.FC = ({ signupTokenId }) => { + const auth = useAuthentication(); + const { addAlert, addErrorAlert } = useAlertQueue(); + const navigate = useNavigate(); + const { register, handleSubmit, @@ -25,8 +34,6 @@ const SignupForm = () => { const confirmPassword = watch("confirmPassword") || ""; const passwordStrength = password.length > 0 ? zxcvbn(password).score : 0; - const { addAlert, addErrorAlert } = useAlertQueue(); - const onSubmit: SubmitHandler = async (data: SignupType) => { // Exit account creation early if password too weak or not matching if (passwordStrength < 2) { @@ -37,8 +44,26 @@ const SignupForm = () => { return; } - // TODO: Add an api endpoint to send the credentials details to backend and email verification. - addAlert(`Not yet implemented: ${data.email}`, "success"); + try { + const { error } = await auth.client.POST("/users/signup", { + body: { + signup_token_id: signupTokenId, + email: data.email, + password: data.password, + }, + }); + + if (error) { + addErrorAlert(error); + } else { + addAlert("Registration successful! You can now log in.", "success"); + navigate("/login"); + // Sign user in automatically? + } + } catch (err) { + addErrorAlert(err); + } + console.log(data); }; return ( diff --git a/frontend/src/components/auth/SignupWithEmail.tsx b/frontend/src/components/auth/SignupWithEmail.tsx new file mode 100644 index 00000000..d689d860 --- /dev/null +++ b/frontend/src/components/auth/SignupWithEmail.tsx @@ -0,0 +1,67 @@ +import { useForm } from "react-hook-form"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { useAlertQueue } from "hooks/useAlertQueue"; +import { useAuthentication } from "hooks/useAuth"; +import { EmailSignupSchema, EmailSignupType } from "types"; + +import { Button } from "components/ui/Button/Button"; +import ErrorMessage from "components/ui/ErrorMessage"; +import { Input } from "components/ui/Input/Input"; + +interface EmailSignUpResponse { + message: string; +} + +const SignupWithEmail = () => { + const auth = useAuthentication(); + const { addAlert, addErrorAlert } = useAlertQueue(); + + const { + register, + handleSubmit, + formState: { errors }, + } = useForm({ + resolver: zodResolver(EmailSignupSchema), + }); + + const onSubmit = async ({ email }: EmailSignupType) => { + const { data, error } = await auth.client.POST("/email/signup/create/", { + body: { + email, + }, + }); + + if (error) { + addErrorAlert(error); + } else { + const responseData = data as EmailSignUpResponse; + const successMessage = + responseData?.message || + "Sign up email sent! Follow the link sent to you to continue registration."; + addAlert(successMessage, "success"); + } + }; + + return ( +
+ {/* Email Input */} +
+ + {errors?.email && {errors?.email?.message}} +
+ {/* Signup Button */} + +
+ ); +}; + +export default SignupWithEmail; diff --git a/frontend/src/components/footer/Footer.tsx b/frontend/src/components/footer/Footer.tsx index 0a7722db..44bff0e2 100644 --- a/frontend/src/components/footer/Footer.tsx +++ b/frontend/src/components/footer/Footer.tsx @@ -17,7 +17,8 @@ const Footer = () => { // - to hide footer on a page add path to this const showFooter = pathname?.startsWith("/browse") === false && - pathname?.startsWith("/login") === false; + pathname?.startsWith("/login") === false && + pathname?.startsWith("/signup") === false; if (!showFooter) { return null; diff --git a/frontend/src/components/ui/Input/PasswordInput.tsx b/frontend/src/components/ui/Input/PasswordInput.tsx index 3f882696..c99327c3 100644 --- a/frontend/src/components/ui/Input/PasswordInput.tsx +++ b/frontend/src/components/ui/Input/PasswordInput.tsx @@ -43,17 +43,17 @@ const PasswordInput = ({ const getStrengthColor = (score: number) => { switch (score) { case 0: - return "bg-red-500"; + return "red-500"; case 1: - return "bg-orange-500"; + return "orange-500"; case 2: - return "bg-yellow-500"; + return "yellow-500"; case 3: - return "bg-blue-500"; + return "blue-500"; case 4: - return "bg-green-500"; + return "green-500"; default: - return "bg-gray-300"; + return "gray-300"; } }; @@ -103,12 +103,17 @@ const PasswordInput = ({ <>
-
- Password Strength: {getStrengthLabel(passwordStrength)} +
+ Password Strength:{" "} + + {getStrengthLabel(passwordStrength)} +
{passwordStrength < 2 ? (
diff --git a/frontend/src/gen/api.ts b/frontend/src/gen/api.ts index bc810f92..195fbfe0 100644 --- a/frontend/src/gen/api.ts +++ b/frontend/src/gen/api.ts @@ -56,6 +56,40 @@ export interface paths { patch?: never; trace?: never; }; + "/users/signup": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Register User */ + post: operations["register_user_users_signup_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/users/login": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Login User */ + post: operations["login_user_users_login_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/users/batch": { parameters: { query?: never; @@ -362,6 +396,66 @@ export interface paths { patch?: never; trace?: never; }; + "/email/signup/create/": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Create Signup Token + * @description Creates a signup token and emails it to the user. + */ + post: operations["create_signup_token_email_signup_create__post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/email/signup/get/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Signup Token + * @description Attempts to get a email sign up token given an id. + */ + get: operations["get_signup_token_email_signup_get__id__get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/email/signup/delete/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** + * Delete Signup Token + * @description Deletes email signup token given an id. + */ + delete: operations["delete_signup_token_email_signup_delete__id__delete"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; } export type webhooks = Record; export interface components { @@ -376,11 +470,29 @@ export interface components { /** Metadata */ metadata: string; }; + /** DeleteTokenResponse */ + DeleteTokenResponse: { + /** Message */ + message: string; + }; /** DumpListingsResponse */ DumpListingsResponse: { /** Listings */ listings: components["schemas"]["Listing"][]; }; + /** EmailSignUpRequest */ + EmailSignUpRequest: { + /** + * Email + * Format: email + */ + email: string; + }; + /** EmailSignUpResponse */ + EmailSignUpResponse: { + /** Message */ + message: string; + }; /** GetBatchListingsResponse */ GetBatchListingsResponse: { /** Listings */ @@ -401,6 +513,13 @@ export interface components { /** Owner Is User */ owner_is_user: boolean; }; + /** GetTokenResponse */ + GetTokenResponse: { + /** Id */ + id: string; + /** Email */ + email: string; + }; /** GithubAuthRequest */ GithubAuthRequest: { /** Code */ @@ -481,6 +600,23 @@ export interface components { /** Image Url */ image_url: string | null; }; + /** LoginRequest */ + LoginRequest: { + /** + * Email + * Format: email + */ + email: string; + /** Password */ + password: string; + }; + /** LoginResponse */ + LoginResponse: { + /** User Id */ + user_id: string; + /** Token */ + token: string; + }; /** NewListingRequest */ NewListingRequest: { /** Name */ @@ -536,6 +672,15 @@ export interface components { /** Permissions */ permissions: "is_admin"[] | null; }; + /** UserRegister */ + UserRegister: { + /** Signup Token Id */ + signup_token_id: string; + /** Email */ + email: string; + /** Password */ + password: string; + }; /** ValidationError */ ValidationError: { /** Location */ @@ -634,6 +779,72 @@ export interface operations { }; }; }; + register_user_users_signup_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UserRegister"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SinglePublicUserInfoResponseItem"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + login_user_users_login_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["LoginRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["LoginResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; get_users_batch_endpoint_users_batch_get: { parameters: { query: { @@ -1163,4 +1374,99 @@ export interface operations { }; }; }; + create_signup_token_email_signup_create__post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["EmailSignUpRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["EmailSignUpResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_signup_token_email_signup_get__id__get: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GetTokenResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + delete_signup_token_email_signup_delete__id__delete: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DeleteTokenResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; } diff --git a/frontend/src/pages/ListingDetails.tsx b/frontend/src/pages/ListingDetails.tsx index cbf127cd..869bce4c 100644 --- a/frontend/src/pages/ListingDetails.tsx +++ b/frontend/src/pages/ListingDetails.tsx @@ -21,7 +21,7 @@ const RenderListing = (props: RenderListingProps) => { const { listing } = props; return ( -
+
{ + const navigate = useNavigate(); + const auth = useAuthentication(); + const { addErrorAlert } = useAlertQueue(); + const { id } = useParams(); + const [signupToken, setSignupToken] = + useState(null); + + useEffect(() => { + const fetchSignUpToken = async () => { + if (id === undefined) { + return; + } + + try { + const { data, error } = await auth.client.GET( + "/email/signup/get/{id}", + { + params: { + path: { id }, + }, + }, + ); + if (error) { + addErrorAlert(error); + } else { + setSignupToken(data); + } + } catch (err) { + addErrorAlert(err); + } + }; + fetchSignUpToken(); + }, [id]); + + return ( +
+ + +
+ + {signupToken ? ( + + + + ) : ( + +
+

Invalid Sign Up Link

+ +
+
+ )} + +
+ ); +}; + +export default Signup; diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index e9723ebb..03549741 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -18,6 +18,17 @@ export const LoginSchema = z.object({ export type LoginType = z.infer; +export const EmailSignupSchema = z.object({ + email: z + .string({ + required_error: "Email required.", + }) + .min(3, { message: "Email required." }) + .email("Invalid email."), +}); + +export type EmailSignupType = z.infer; + export const SignUpSchema = z .object({ email: z diff --git a/store/app/crud/email_signup.py b/store/app/crud/email_signup.py new file mode 100644 index 00000000..27b28bb5 --- /dev/null +++ b/store/app/crud/email_signup.py @@ -0,0 +1,25 @@ +"""This module provides CRUD operations for email sign-up tokens.""" + +from store.app.crud.base import BaseCrud +from store.app.model import EmailSignUpToken + + +class EmailSignUpCrud(BaseCrud): + async def create_email_signup_token(self, email: str) -> EmailSignUpToken: + signup_token = EmailSignUpToken.create(email=email) + await self._add_item(signup_token) + return signup_token + + async def get_email_signup_token(self, id: str) -> EmailSignUpToken | None: + + return await self._get_item(id, EmailSignUpToken, throw_if_missing=False) + + async def delete_email_signup_token(self, id: str) -> None: + await self._delete_item(id) + + +async def test_adhoc() -> None: + async with EmailSignUpCrud() as crud: + signup_token = await crud.create_email_signup_token(email="test@example.com") + await crud.get_email_signup_token(signup_token.id) + await crud.delete_email_signup_token(signup_token.id) diff --git a/store/app/main.py b/store/app/main.py index 8a25d0a7..5e7b3c82 100644 --- a/store/app/main.py +++ b/store/app/main.py @@ -10,8 +10,14 @@ from fastapi.responses import JSONResponse from store.app.db import create_tables -from store.app.errors import InternalError, ItemNotFoundError, NotAuthenticatedError, NotAuthorizedError +from store.app.errors import ( + InternalError, + ItemNotFoundError, + NotAuthenticatedError, + NotAuthorizedError, +) from store.app.routers.artifacts import artifacts_router +from store.app.routers.email_signup import email_router from store.app.routers.listings import listings_router from store.app.routers.users import users_router from store.settings import settings @@ -93,6 +99,7 @@ async def read_root() -> bool: app.include_router(users_router, prefix="/users", tags=["users"]) app.include_router(listings_router, prefix="/listings", tags=["listings"]) app.include_router(artifacts_router, prefix="/artifacts", tags=["artifacts"]) +app.include_router(email_router, prefix="/email", tags=["email"]) # For running with debugger if __name__ == "__main__": diff --git a/store/app/model.py b/store/app/model.py index 7a56e3ca..8b646794 100644 --- a/store/app/model.py +++ b/store/app/model.py @@ -43,7 +43,6 @@ class User(RobolistBaseModel): permissions: set[UserPermission] | None = None created_at: int updated_at: int - email_verified_at: int | None = None github_id: str | None = None google_id: str | None = None @@ -74,6 +73,19 @@ def verify_email(self) -> None: self.email_verified_at = int(time.time()) +class EmailSignUpToken(RobolistBaseModel): + """Object created when user attempts to sign up with email. + + Will be checked by signup dynamic route to render SignupForm if authorized. + """ + + email: EmailStr + + @classmethod + def create(cls, email: str) -> Self: + return cls(id=new_uuid(), email=email) + + class OAuthKey(RobolistBaseModel): """Keys for OAuth providers which identify users.""" diff --git a/store/app/routers/email_signup.py b/store/app/routers/email_signup.py new file mode 100644 index 00000000..1c1850fc --- /dev/null +++ b/store/app/routers/email_signup.py @@ -0,0 +1,63 @@ +"""This module defines the FastAPI routes for managing email related API routes.""" + +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel, EmailStr + +from store.app.crud.email_signup import EmailSignUpCrud +from store.app.utils.email import send_signup_email + +email_router = APIRouter() + + +# Request Model +class EmailSignUpRequest(BaseModel): + email: EmailStr + + +# Response Models +class EmailSignUpResponse(BaseModel): + message: str + + +class GetTokenResponse(BaseModel): + id: str + email: str + + +class DeleteTokenResponse(BaseModel): + message: str + + +@email_router.post("/signup/create/", response_model=EmailSignUpResponse) +async def create_signup_token(data: EmailSignUpRequest) -> EmailSignUpResponse: + """Creates a signup token and emails it to the user.""" + async with EmailSignUpCrud() as crud: + try: + signup_token = await crud.create_email_signup_token(data.email) + await send_signup_email(email=data.email, token=signup_token.id) + + return EmailSignUpResponse( + message="Sign up email sent! Follow the link sent to you to continue registration." + ) + except Exception as e: + print(f"Error creating signup token: {e}") + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + + +@email_router.get("/signup/get/{id}", response_model=GetTokenResponse) +async def get_signup_token(id: str) -> GetTokenResponse: + """Attempts to get a email sign up token given an id.""" + async with EmailSignUpCrud() as crud: + signup_token = await crud.get_email_signup_token(id) + if not signup_token: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Token not found.") + + # Map the EmailSignUpToken to GetTokenResponse + return GetTokenResponse(id=signup_token.id, email=signup_token.email) + + +@email_router.delete("/signup/delete/{id}", response_model=DeleteTokenResponse) +async def delete_signup_token(id: str, crud: EmailSignUpCrud = Depends()) -> DeleteTokenResponse: + """Deletes email signup token given an id.""" + await crud.delete_email_signup_token(id) + return DeleteTokenResponse(message="Token deleted successfully.") diff --git a/store/app/routers/users.py b/store/app/routers/users.py index 5212941a..db00a697 100644 --- a/store/app/routers/users.py +++ b/store/app/routers/users.py @@ -6,14 +6,18 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Request, status from fastapi.security.utils import get_authorization_scheme_param +from pydantic import EmailStr from pydantic.main import BaseModel as PydanticBaseModel from store.app.crud.base import ItemNotFoundError +from store.app.crud.email_signup import EmailSignUpCrud +from store.app.crud.users import UserCrud from store.app.db import Crud from store.app.errors import NotAuthenticatedError from store.app.model import User, UserPermission from store.app.routers.auth.github import github_auth_router from store.app.utils.email import send_delete_email +from store.app.utils.password import verify_password logger = logging.getLogger(__name__) @@ -126,12 +130,10 @@ def validate_email(email: str) -> str: return email -class SendRegister(BaseModel): - email: str - - class UserRegister(BaseModel): - token: str + signup_token_id: str + email: str + password: str class UserInfoResponse(BaseModel): @@ -180,6 +182,60 @@ class PublicUserInfoResponse(BaseModel): users: list[SinglePublicUserInfoResponseItem] +@users_router.post("/signup", response_model=SinglePublicUserInfoResponseItem) +async def register_user( + data: UserRegister, email_signup_crud: EmailSignUpCrud = Depends(), user_crud: UserCrud = Depends() +) -> SinglePublicUserInfoResponseItem: # Added return type annotation + 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: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Invalid or expired registration token") + + # Check if a user with this email already exists + existing_user = await user_crud.get_user_from_email(signup_token.email) + if existing_user: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="User already exists") + + # Create the 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 SinglePublicUserInfoResponseItem(id=user.id, email=user.email) + + +class LoginRequest(BaseModel): + email: EmailStr + password: str + + +class LoginResponse(BaseModel): + user_id: str + token: str + + +@users_router.post("/login", response_model=LoginResponse) +async def login_user(data: LoginRequest, user_crud: UserCrud = Depends()) -> LoginResponse: + async with user_crud: + # Fetch user by email + user = await user_crud.get_user_from_email(data.email) + if not user: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or password") + + # Ensure `hashed_password` is not None before verifying + if user.hashed_password is None or not verify_password(data.password, user.hashed_password): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or password") + + api_key = await user_crud.add_api_key( + user.id, + source="oauth", + permissions="full", + ) + + return LoginResponse(user_id=user.id, token=api_key.id) + + @users_router.get("/batch", response_model=PublicUserInfoResponse) async def get_users_batch_endpoint( crud: Annotated[Crud, Depends(Crud.get)], diff --git a/store/app/utils/email.py b/store/app/utils/email.py index e06bab56..57e589cc 100644 --- a/store/app/utils/email.py +++ b/store/app/utils/email.py @@ -30,16 +30,16 @@ async def send_email(subject: str, body: str, to: str) -> None: await smtp_client.quit() -async def send_register_email(email: str, token: str) -> None: +async def send_signup_email(email: str, token: str) -> None: body = textwrap.dedent( f"""

K-Scale Labs

register

-

Click here to register.

+

Click here to continue registration.

""" ) - await send_email(subject="Verify Email", body=body, to=email) + await send_email(subject="Register", body=body, to=email) async def send_reset_password_email(email: str, token: str) -> None: