- 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: