diff --git a/frontend/src/components/auth/SignupWithEmail.tsx b/frontend/src/components/auth/SignupWithEmail.tsx index cf4a96e9..3b2f0156 100644 --- a/frontend/src/components/auth/SignupWithEmail.tsx +++ b/frontend/src/components/auth/SignupWithEmail.tsx @@ -1,35 +1,53 @@ -import { SubmitHandler, useForm } from "react-hook-form"; +import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; -import { EmailSignUpSchema, EmailSignUpType } from "types"; +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), + resolver: zodResolver(EmailSignupSchema), }); - const onSubmit: SubmitHandler = async ( - data: EmailSignupType, - ) => { - // TODO: Add an api endpoint to create EmailSignUpToken and send email to - // submitted email. User gets link in email to /register/{token} - // to finish sign up + const onSubmit = async ({ email }: EmailSignupType) => { + console.log(`email: ${email}`); + const { data, error } = await auth.client.POST("/email-signup/create/", { + body: { + email, + }, + }); - console.log(data); + if (error) { + addErrorAlert(error); + } else { + const responseData = data as EmailSignUpResponse; + const successMessage = + responseData?.message || "Sign-up email sent! Check your inbox."; + addAlert(successMessage, "success"); + } }; return (
+ className="grid grid-cols-1 space-y-6" + > {/* Email Input */}
@@ -38,7 +56,8 @@ const SignupWithEmail = () => { {/* Signup Button */} diff --git a/frontend/src/gen/api.ts b/frontend/src/gen/api.ts index 98ee41e6..10d0bc5d 100644 --- a/frontend/src/gen/api.ts +++ b/frontend/src/gen/api.ts @@ -362,6 +362,57 @@ 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 */ + post: operations["create_signup_token_email_signup_create__post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/email-signup/get/{token}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Signup Token */ + get: operations["get_signup_token_email_signup_get__token__get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/email-signup/delete/{token}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** Delete Signup Token */ + delete: operations["delete_signup_token_email_signup_delete__token__delete"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; } export type webhooks = Record; export interface components { @@ -376,11 +427,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 +470,13 @@ export interface components { /** Owner Is User */ owner_is_user: boolean; }; + /** GetTokenResponse */ + GetTokenResponse: { + /** Email */ + email: string; + /** Created At */ + created_at: string; + }; /** GithubAuthRequest */ GithubAuthRequest: { /** Code */ @@ -1163,4 +1239,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__token__get: { + parameters: { + query?: never; + header?: never; + path: { + token: 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__token__delete: { + parameters: { + query?: never; + header?: never; + path: { + token: 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/Register.tsx b/frontend/src/pages/Register.tsx index e831bd75..752c00d4 100644 --- a/frontend/src/pages/Register.tsx +++ b/frontend/src/pages/Register.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from "react"; -import { useNavigate, useParams } from "react-router-dom"; +import { useParams } from "react-router-dom"; import { paths } from "gen/api"; import { useAlertQueue } from "hooks/useAlertQueue"; @@ -9,7 +9,7 @@ import SignupForm from "components/auth/SignupForm"; import { Button } from "components/ui/Button/Button"; type EmailSignUpResponse = - paths["/emailSignUp/{token}"]["get"]["responses"][200]["content"]["application/json"]; + paths["/email-signup/get/{token}"]["get"]["responses"][200]["content"]["application/json"]; const Register = () => { const { addErrorAlert } = useAlertQueue(); @@ -26,11 +26,14 @@ const Register = () => { } try { - const { data, error } = await auth.client.GET("/emailSignUp/{token}", { - params: { - path: { token }, + const { data, error } = await auth.client.GET( + "/email-signup/get/{token}", + { + params: { + path: { token }, + }, }, - }); + ); if (error) { addErrorAlert(error); } else { @@ -49,7 +52,8 @@ const Register = () => {

Invalid Sign Up Link

diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 9f9f975b..03549741 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -18,7 +18,7 @@ export const LoginSchema = z.object({ export type LoginType = z.infer; -export const EmailSignUpSchema = z.object({ +export const EmailSignupSchema = z.object({ email: z .string({ required_error: "Email required.", @@ -27,7 +27,7 @@ export const EmailSignUpSchema = z.object({ .email("Invalid email."), }); -export type EmailSignUpType = z.infer; +export type EmailSignupType = z.infer; export const SignUpSchema = z .object({ diff --git a/store/app/crud/email_signup.py b/store/app/crud/email_signup.py new file mode 100644 index 00000000..91e73eeb --- /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: + token = EmailSignUpToken.create(email=email) + await self._add_item(token) + return token + + async def get_email_signup_token(self, token: str) -> EmailSignUpToken | None: + return await self._get_item(token, EmailSignUpToken, throw_if_missing=False) + + async def delete_email_signup_token(self, token: str) -> None: + await self._delete_item(token) + + +async def test_adhoc() -> None: + async with EmailSignUpCrud() as crud: + token = await crud.create_email_signup_token(email="test@example.com") + retrieved_token = await crud.get_email_signup_token(token.token) + print(f"Retrieved Token: {retrieved_token}") + await crud.delete_email_signup_token(token.token) diff --git a/store/app/main.py b/store/app/main.py index 8a25d0a7..5f5942f1 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_signup_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_signup_router, prefix="/email-signup", tags=["email-signup"]) # For running with debugger if __name__ == "__main__": diff --git a/store/app/model.py b/store/app/model.py index 581c3a25..e0634eff 100644 --- a/store/app/model.py +++ b/store/app/model.py @@ -74,17 +74,17 @@ def verify_email(self) -> None: class EmailSignUpToken(RobolistBaseModel): - """ - Object created when user attempts to sign up with email. + """Object created when user attempts to sign up with email. - Will be checked by register dynamic route + Will be checked by register dynamic route to render SignupForm is authorized. """ + email: EmailStr token: str @classmethod - def create(cls) -> Self: - return cls(token=new_uuid()) + def create(cls, email: str) -> Self: + return cls(email=email, token=new_uuid()) class OAuthKey(RobolistBaseModel): diff --git a/store/app/routers/email_signup.py b/store/app/routers/email_signup.py new file mode 100644 index 00000000..82aea647 --- /dev/null +++ b/store/app/routers/email_signup.py @@ -0,0 +1,55 @@ +"""This module defines the FastAPI routes for managing email sign-up tokens.""" + +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel, EmailStr + +from store.app.crud.users import UserCrud + +email_signup_router = APIRouter() + + +# Request Model +class EmailSignUpRequest(BaseModel): + email: EmailStr + + +# Response Models +class EmailSignUpResponse(BaseModel): + message: str + + +class GetTokenResponse(BaseModel): + email: str + created_at: str + + +class DeleteTokenResponse(BaseModel): + message: str + + +# POST: Create Signup Token +@email_signup_router.post("/create/", response_model=EmailSignUpResponse) +async def create_signup_token(data: EmailSignUpRequest, crud: UserCrud = Depends()) -> EmailSignUpResponse: + try: + await crud.create_email_signup_token(data.email) + return {"message": "Sign-up token created successfully."} + except Exception as e: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + + +# GET: Retrieve Signup Token +@email_signup_router.get("/get/{token}", response_model=GetTokenResponse) +async def get_signup_token(token: str, crud: UserCrud = Depends()) -> GetTokenResponse: + signup_token = await crud.get_email_signup_token(token) + if not signup_token: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Token not found.") + return signup_token + + +# DELETE: Delete Signup Token +@email_signup_router.delete("/delete/{token}", response_model=DeleteTokenResponse) +async def delete_signup_token(token: str, crud: UserCrud = Depends()) -> DeleteTokenResponse: + deleted = await crud.delete_email_signup_token(token) + if not deleted: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Token not found.") + return {"message": "Token deleted successfully."}