From 2e965fc0992914c3ceaf53c6707f1b3f66bee90e Mon Sep 17 00:00:00 2001 From: seeleng Date: Thu, 31 Oct 2024 14:02:57 +0800 Subject: [PATCH 01/64] feat: add models for email validation --- .../651ed2d244c5_add_email_verification.py | 54 +++++++++++++++++++ backend/src/auth/models.py | 20 ++++--- 2 files changed, 68 insertions(+), 6 deletions(-) create mode 100644 backend/alembic/versions/651ed2d244c5_add_email_verification.py diff --git a/backend/alembic/versions/651ed2d244c5_add_email_verification.py b/backend/alembic/versions/651ed2d244c5_add_email_verification.py new file mode 100644 index 00000000..94d7e495 --- /dev/null +++ b/backend/alembic/versions/651ed2d244c5_add_email_verification.py @@ -0,0 +1,54 @@ +"""Add email verification + +Revision ID: 651ed2d244c5 +Revises: 59cef91d2fa1 +Create Date: 2024-10-31 13:46:07.360330 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "651ed2d244c5" +down_revision: Union[str, None] = "59cef91d2fa1" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "email_verification", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("code", sa.String(), nullable=False), + sa.Column("used", sa.Boolean(), nullable=False), + sa.Column( + "created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False + ), + sa.Column( + "updated_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False + ), + sa.Column("deleted_at", sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint( + ["user_id"], + ["user.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.add_column( + "user", + sa.Column("verified", sa.Boolean(), server_default="true", nullable=False), + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("user", "verified") + op.drop_table("email_verification") + # ### end Alembic commands ### diff --git a/backend/src/auth/models.py b/backend/src/auth/models.py index 986d1e53..d2e6e7fd 100644 --- a/backend/src/auth/models.py +++ b/backend/src/auth/models.py @@ -35,18 +35,17 @@ class User(Base): hashed_password: Mapped[str] account_type: Mapped[AccountType] last_accessed: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + top_events_period: Mapped[int] = mapped_column(Integer, default=7) + tier_id: Mapped[int] = mapped_column( + ForeignKey("tier.id"), default=1, server_default="1" + ) + verified: Mapped[bool] = mapped_column(server_default="true") role: Mapped[Role] = mapped_column(server_default="NORMAL") - categories: Mapped[list[Category]] = relationship(secondary=user_category_table) notes: Mapped[list[Note]] = relationship("Note", backref="user") - top_events_period: Mapped[int] = mapped_column(Integer, default=7) - bookmarks: Mapped[list[Bookmark]] = relationship(backref="user") - tier_id: Mapped[int] = mapped_column( - ForeignKey("tier.id"), default=1, server_default="1" - ) subscription: Mapped[Subscription] = relationship( "Subscription", backref="user", lazy="selectin", uselist=False ) @@ -62,3 +61,12 @@ class PasswordReset(Base): user_id: Mapped[int] = mapped_column(ForeignKey("user.id")) code: Mapped[str] used: Mapped[bool] + + +class EmailVerification(Base): + __tablename__ = "email_verification" + + id: Mapped[int] = mapped_column(primary_key=True) + user_id: Mapped[int] = mapped_column(ForeignKey("user.id")) + code: Mapped[str] + used: Mapped[bool] = mapped_column(default=False) From 775e2e078492823ffe679261a21ac51faff22687 Mon Sep 17 00:00:00 2001 From: seeleng Date: Thu, 31 Oct 2024 14:04:27 +0800 Subject: [PATCH 02/64] feat: send verification email on signup --- backend/src/auth/router.py | 21 +++++++++++++++++--- backend/src/auth/utils.py | 39 ++++++++++++++++++++++++++++++++++++++ backend/src/utils/mail.py | 4 ++++ 3 files changed, 61 insertions(+), 3 deletions(-) diff --git a/backend/src/auth/router.py b/backend/src/auth/router.py index 2994137d..01b08b99 100644 --- a/backend/src/auth/router.py +++ b/backend/src/auth/router.py @@ -9,8 +9,13 @@ import httpx from sqlalchemy import select from sqlalchemy.orm import selectinload -from src.auth.utils import create_token, send_reset_password_email +from src.auth.utils import ( + create_token, + send_reset_password_email, + send_verification_email, +) from src.common.constants import ( + FRONTEND_URL, GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, GOOGLE_REDIRECT_URI, @@ -32,7 +37,7 @@ get_password_hash, verify_password, ) -from .models import AccountType, PasswordReset, User +from .models import AccountType, EmailVerification, PasswordReset, User router = APIRouter(prefix="/auth", tags=["auth"]) @@ -43,7 +48,10 @@ @router.post("/signup") def sign_up( - data: SignUpData, response: Response, session=Depends(get_session) + data: SignUpData, + response: Response, + background_task: BackgroundTasks, + session=Depends(get_session), ) -> Token: existing_user = session.scalars( select(User).where(User.email == data.email) @@ -70,6 +78,13 @@ def sign_up( ) ) + code = str(uuid4()) + email_validation = EmailVerification(user_id=new_user.id, code=code, used=False) + session.add(email_validation) + session.commit() + verification_link = f"{FRONTEND_URL}/verify-email?code={code}" + background_task.add_task(send_verification_email, data.email, verification_link) + return create_token(new_user, response) diff --git a/backend/src/auth/utils.py b/backend/src/auth/utils.py index 68d4ef89..8b09bcfe 100644 --- a/backend/src/auth/utils.py +++ b/backend/src/auth/utils.py @@ -28,3 +28,42 @@ def send_reset_password_email(email: str, code: str): "Reset your password", f"Here is the link to reset your password.\n{FRONTEND_URL}/reset-password?code={code}", ) + + +def send_verification_email(receiving_email_addr: str, verification_link: str): + subject = "Verify Your Email for Jippy ✨" + plain_message = ( + "Hi there,\n\n" + "Thank you for signing up for Jippy! Please verify your email by clicking the link below:\n" + f"{verification_link}\n\n" + "If you didn't sign up, please ignore this email.\n\n" + "Best,\nThe Jippy Team" + ) + + # HTML message with a button + html_message = f""" + + +

Welcome to Jippy!

+

Thanks for signing up. Click the button below to verify your email address:

+ + Verify My Email + +

If the button doesn't work, you can also copy and paste the following link into your browser:

+

{verification_link}

+

If you didn't sign up, please ignore this email.

+

Best,
The Jippy Team

+ + + """ + + send_email(receiving_email_addr, subject, plain_message, html_message) diff --git a/backend/src/utils/mail.py b/backend/src/utils/mail.py index e217c1e7..27d99d0f 100644 --- a/backend/src/utils/mail.py +++ b/backend/src/utils/mail.py @@ -11,6 +11,7 @@ def send_email( receiving_email_addr: str, subject: str, message: str, + html_message: str | None = None, ): context = ssl.create_default_context() with smtplib.SMTP_SSL("smtp.gmail.com", PORT, context=context) as server: @@ -22,6 +23,9 @@ def send_email( msg["To"] = receiving_email_addr msg.set_content(message) + if html_message: + msg.add_alternative(html_message, subtype="html") + server.sendmail(GOOGLE_EMAIL, receiving_email_addr, msg.as_string()) From 57d508b9e4f836b34823b45c2abd09d13b97c2ab Mon Sep 17 00:00:00 2001 From: seeleng Date: Thu, 31 Oct 2024 14:08:21 +0800 Subject: [PATCH 03/64] fix(signup): unverify normal user --- backend/src/auth/router.py | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/src/auth/router.py b/backend/src/auth/router.py index 01b08b99..3369f873 100644 --- a/backend/src/auth/router.py +++ b/backend/src/auth/router.py @@ -63,6 +63,7 @@ def sign_up( email=data.email, hashed_password=get_password_hash(data.password), account_type=AccountType.NORMAL, + verified=False, ) session.add(new_user) session.commit() From d24f119ac4ede0dde0fe99169d926b53154ec1e7 Mon Sep 17 00:00:00 2001 From: seeleng Date: Thu, 31 Oct 2024 14:08:38 +0800 Subject: [PATCH 04/64] feat(user): expose verified boolean --- backend/src/auth/schemas.py | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/src/auth/schemas.py b/backend/src/auth/schemas.py index c141a7ca..602621f9 100644 --- a/backend/src/auth/schemas.py +++ b/backend/src/auth/schemas.py @@ -20,6 +20,7 @@ class UserPublic(BaseModel): usage: UsageDTO | None = None tier: TierDTO + verified: bool class Token(BaseModel): From 4623146e17ff6199ab5139914beb69890b1a9fd0 Mon Sep 17 00:00:00 2001 From: Wang Haoyang Date: Thu, 31 Oct 2024 14:09:38 +0800 Subject: [PATCH 05/64] feat: email verification frontend --- .../(unauthenticated)/verify-email/page.tsx | 81 +++++++++++++++++++ frontend/package-lock.json | 12 +-- 2 files changed, 87 insertions(+), 6 deletions(-) create mode 100644 frontend/app/(unauthenticated)/verify-email/page.tsx diff --git a/frontend/app/(unauthenticated)/verify-email/page.tsx b/frontend/app/(unauthenticated)/verify-email/page.tsx new file mode 100644 index 00000000..1057ef07 --- /dev/null +++ b/frontend/app/(unauthenticated)/verify-email/page.tsx @@ -0,0 +1,81 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { CheckCircleIcon } from "lucide-react"; + +import { completeEmailVerificationAuthVerifyEmailPut } from "@/client"; +import Link from "@/components/navigation/link"; +import { Box } from "@/components/ui/box"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { LoadingSpinner } from "@/components/ui/loading-spinner"; +import { useUserStore } from "@/store/user/user-store-provider"; + +export default function VerifyEmail() { + const router = useRouter(); + const setLoggedIn = useUserStore((state) => state.setLoggedIn); + const sentAuthentication = useRef(false); + const [isLoading, setIsLoading] = useState(true); + const searchParams = useSearchParams(); + const code = searchParams.get("code"); + + useEffect(() => { + if (!sentAuthentication.current) { + sentAuthentication.current = true; + (async () => { + if (!code) { + router.push("/login"); + return; + } + const response = await completeEmailVerificationAuthVerifyEmailPut({ + query: { code }, + }); + // There is some problem where this function runs twice, causing an error + // on the second run since the email verification is used. + if (response.data) { + setIsLoading(false); + setLoggedIn(response.data!.user); + router.push("/"); + } + })(); + } + }, [code, router, setLoggedIn]); + + return ( + + + + Verifying your email + + + + + {isLoading ? ( + + ) : ( + + )} + + + + {isLoading + ? "Hang tight! We're logging you in. This shouldn't take too long." + : "All done! You should be redirected soon."} + + {!isLoading && ( + + Redirect now + + )} + + + + + ); +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 70e7e7c6..bb4304e6 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -3902,11 +3902,6 @@ "dev": true, "license": "ISC" }, - "node_modules/embla-carousel": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.3.0.tgz", - "integrity": "sha512-Ve8dhI4w28qBqR8J+aMtv7rLK89r1ZA5HocwFz6uMB/i5EiC7bGI7y+AM80yAVUJw3qqaZYK7clmZMUR8kM3UA==" - }, "node_modules/embla-carousel-react": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/embla-carousel-react/-/embla-carousel-react-8.3.0.tgz", @@ -3919,7 +3914,12 @@ "react": "^16.8.0 || ^17.0.1 || ^18.0.0" } }, - "node_modules/embla-carousel-reactive-utils": { + "node_modules/embla-carousel-react/node_modules/embla-carousel": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.3.0.tgz", + "integrity": "sha512-Ve8dhI4w28qBqR8J+aMtv7rLK89r1ZA5HocwFz6uMB/i5EiC7bGI7y+AM80yAVUJw3qqaZYK7clmZMUR8kM3UA==" + }, + "node_modules/embla-carousel-react/node_modules/embla-carousel-reactive-utils": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/embla-carousel-reactive-utils/-/embla-carousel-reactive-utils-8.3.0.tgz", "integrity": "sha512-EYdhhJ302SC4Lmkx8GRsp0sjUhEN4WyFXPOk0kGu9OXZSRMmcBlRgTvHcq8eKJE1bXWBsOi1T83B+BSSVZSmwQ==", From d6cb6b1d032a6f8ff0e298101cd0cfa3a263ecb2 Mon Sep 17 00:00:00 2001 From: Wang Haoyang Date: Thu, 31 Oct 2024 14:12:20 +0800 Subject: [PATCH 06/64] Require auth for verify email page -Move verify email page from unauthenticated to authenticated -This requires the user to login before verifying --- .../{(unauthenticated) => (authenticated)}/verify-email/page.tsx | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename frontend/app/{(unauthenticated) => (authenticated)}/verify-email/page.tsx (100%) diff --git a/frontend/app/(unauthenticated)/verify-email/page.tsx b/frontend/app/(authenticated)/verify-email/page.tsx similarity index 100% rename from frontend/app/(unauthenticated)/verify-email/page.tsx rename to frontend/app/(authenticated)/verify-email/page.tsx From 522c86622841f90693a97f03d2ddeebd56d41af2 Mon Sep 17 00:00:00 2001 From: seeleng Date: Thu, 31 Oct 2024 14:14:22 +0800 Subject: [PATCH 07/64] fix(password-reset): stop requiring auth --- backend/src/auth/router.py | 57 ++++++++++++++++++++------------------ 1 file changed, 30 insertions(+), 27 deletions(-) diff --git a/backend/src/auth/router.py b/backend/src/auth/router.py index 3369f873..531c92af 100644 --- a/backend/src/auth/router.py +++ b/backend/src/auth/router.py @@ -162,32 +162,10 @@ def auth_google( return token -routerWithAuth = APIRouter( - prefix="/auth", tags=["auth"], dependencies=[Depends(add_current_user)] -) - - -@routerWithAuth.get("/session") -def get_user( - current_user: Annotated[User, Depends(get_current_user)], - session=Depends(get_session), -) -> UserPublic: - user = session.get(User, current_user.id) - if user: - user.last_accessed = datetime.now() - session.add(user) - session.commit() - - return current_user - - -@routerWithAuth.get("/logout") -def logout(response: Response): - response.delete_cookie(key="session") - return "" - - -@routerWithAuth.post("/password-reset") +####################### +# password reset # +####################### +@router.post("/password-reset") def request_password_reset( data: PasswordResetRequestData, background_task: BackgroundTasks, @@ -209,7 +187,7 @@ def request_password_reset( background_task.add_task(send_reset_password_email, email, code) -@routerWithAuth.put("/password-reset") +@router.put("/password-reset") def complete_password_reset( code: str, data: PasswordResetCompleteData, @@ -230,6 +208,31 @@ def complete_password_reset( session.commit() +routerWithAuth = APIRouter( + prefix="/auth", tags=["auth"], dependencies=[Depends(add_current_user)] +) + + +@routerWithAuth.get("/session") +def get_user( + current_user: Annotated[User, Depends(get_current_user)], + session=Depends(get_session), +) -> UserPublic: + user = session.get(User, current_user.id) + if user: + user.last_accessed = datetime.now() + session.add(user) + session.commit() + + return current_user + + +@routerWithAuth.get("/logout") +def logout(response: Response): + response.delete_cookie(key="session") + return "" + + @routerWithAuth.put("/change-password") def change_password( user: Annotated[User, Depends(get_current_user)], From 231d3d99dfbd78c46a5bb30bee4099ed6a54ccf2 Mon Sep 17 00:00:00 2001 From: seeleng Date: Thu, 31 Oct 2024 14:18:10 +0800 Subject: [PATCH 08/64] feat: add /verify-email endpoint --- backend/src/auth/router.py | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/backend/src/auth/router.py b/backend/src/auth/router.py index 531c92af..b478532b 100644 --- a/backend/src/auth/router.py +++ b/backend/src/auth/router.py @@ -103,6 +103,39 @@ def log_in( return create_token(user, response) +@router.put("/verify-email") +def complete_email_verification( + code: str, + response: Response, + session=Depends(get_session), +) -> Token: + email_verification = session.scalars( + select(EmailVerification).where(EmailVerification.code == code) + ).first() + if not email_verification or email_verification.used: + raise HTTPException(HTTPStatus.NOT_FOUND) + + user = session.scalar( + select(User) + .where(User.id == email_verification.user_id) + .options( + selectinload(User.categories), + selectinload(User.tier), + selectinload(User.usage), + ) + ) + user.verified = True + email_verification.used = True + session.add(user) + session.add(email_verification) + session.commit() + session.refresh(user) + + token = create_token(user, response) + + return token + + ####################### # google auth # ####################### From 425db62493684ed12d76ef89c4021c79b9dcda6b Mon Sep 17 00:00:00 2001 From: seeleng Date: Thu, 31 Oct 2024 14:24:33 +0800 Subject: [PATCH 09/64] feat: add endpoint for resend verification --- backend/src/auth/router.py | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/backend/src/auth/router.py b/backend/src/auth/router.py index b478532b..fd68714b 100644 --- a/backend/src/auth/router.py +++ b/backend/src/auth/router.py @@ -40,6 +40,9 @@ from .models import AccountType, EmailVerification, PasswordReset, User router = APIRouter(prefix="/auth", tags=["auth"]) +routerWithAuth = APIRouter( + prefix="/auth", tags=["auth"], dependencies=[Depends(add_current_user)] +) ####################### # username & password # @@ -103,7 +106,7 @@ def log_in( return create_token(user, response) -@router.put("/verify-email") +@router.put("/email-verification") def complete_email_verification( code: str, response: Response, @@ -136,6 +139,22 @@ def complete_email_verification( return token +@routerWithAuth.post("/email-verification") +def resend_verification_email( + user: Annotated[User, Depends(get_current_user)], + background_task: BackgroundTasks, + session=Depends(get_session), +): + code = str(uuid4()) + email_validation = EmailVerification(user_id=user.id, code=code, used=False) + session.add(email_validation) + session.commit() + verification_link = f"{FRONTEND_URL}/verify-email?code={code}" + background_task.add_task(send_verification_email, user.email, verification_link) + + return + + ####################### # google auth # ####################### @@ -241,11 +260,6 @@ def complete_password_reset( session.commit() -routerWithAuth = APIRouter( - prefix="/auth", tags=["auth"], dependencies=[Depends(add_current_user)] -) - - @routerWithAuth.get("/session") def get_user( current_user: Annotated[User, Depends(get_current_user)], From 330307f4d6a6dca52b2bf1a646b810634d4f7299 Mon Sep 17 00:00:00 2001 From: seeleng Date: Thu, 31 Oct 2024 14:42:22 +0800 Subject: [PATCH 10/64] feat: add unverified tier --- .../4f9ec96fc98e_add_unverified_tier.py | 62 +++++++++++++++++++ backend/src/auth/models.py | 4 ++ backend/src/auth/router.py | 9 ++- backend/src/limits/models.py | 1 + 4 files changed, 75 insertions(+), 1 deletion(-) create mode 100644 backend/alembic/versions/4f9ec96fc98e_add_unverified_tier.py diff --git a/backend/alembic/versions/4f9ec96fc98e_add_unverified_tier.py b/backend/alembic/versions/4f9ec96fc98e_add_unverified_tier.py new file mode 100644 index 00000000..316e4a59 --- /dev/null +++ b/backend/alembic/versions/4f9ec96fc98e_add_unverified_tier.py @@ -0,0 +1,62 @@ +"""Add unverified tier + +Revision ID: 4f9ec96fc98e +Revises: 651ed2d244c5 +Create Date: 2024-10-31 14:30:56.099043 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlalchemy.orm as orm +from alembic_postgresql_enum import TableReference +from src.limits.models import Tier + +# revision identifiers, used by Alembic. +revision: str = "4f9ec96fc98e" +down_revision: Union[str, None] = "651ed2d244c5" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.sync_enum_values( + "public", + "tiernames", + ["FREE", "ADMIN", "PREMIUM", "UNVERIFIED"], + [ + TableReference( + table_schema="public", table_name="tier", column_name="tier_name" + ) + ], + enum_values_to_rename=[], + ) + session = orm.Session(bind=op.get_bind()) + session.add(Tier(tier_name="UNVERIFIED", label="Unverified", gp_question_limit=0)) + session.commit() + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + session = orm.Session(bind=op.get_bind()) + unverified = session.scalar(sa.select(Tier).where(Tier.label == "Unverified")) + session.delete(unverified) + session.commit() + + op.sync_enum_values( + "public", + "tiernames", + ["FREE", "ADMIN", "PREMIUM"], + [ + TableReference( + table_schema="public", table_name="tier", column_name="tier_name" + ) + ], + enum_values_to_rename=[], + ) + + # ### end Alembic commands ### diff --git a/backend/src/auth/models.py b/backend/src/auth/models.py index d2e6e7fd..9d831e14 100644 --- a/backend/src/auth/models.py +++ b/backend/src/auth/models.py @@ -27,6 +27,10 @@ class Role(str, Enum): ADMIN = "admin" +# TODO: it's probably safer to check with the db but it'll do for now +UNVERIFIED_TIER_ID = 4 + + class User(Base): __tablename__ = "user" diff --git a/backend/src/auth/router.py b/backend/src/auth/router.py index fd68714b..a4e24d35 100644 --- a/backend/src/auth/router.py +++ b/backend/src/auth/router.py @@ -37,7 +37,13 @@ get_password_hash, verify_password, ) -from .models import AccountType, EmailVerification, PasswordReset, User +from .models import ( + UNVERIFIED_TIER_ID, + AccountType, + EmailVerification, + PasswordReset, + User, +) router = APIRouter(prefix="/auth", tags=["auth"]) routerWithAuth = APIRouter( @@ -67,6 +73,7 @@ def sign_up( hashed_password=get_password_hash(data.password), account_type=AccountType.NORMAL, verified=False, + tier_id=UNVERIFIED_TIER_ID, ) session.add(new_user) session.commit() diff --git a/backend/src/limits/models.py b/backend/src/limits/models.py index fd99eb60..f9e2b8d2 100644 --- a/backend/src/limits/models.py +++ b/backend/src/limits/models.py @@ -8,6 +8,7 @@ class TierNames(str, Enum): FREE = "FREE" ADMIN = "ADMIN" PREMIUM = "PREMIUM" + UNVERIFIED = "UNVERIFIED" class Usage(Base): From 3ecffb2eed34a4a521fee20e89e5a0a45359aee1 Mon Sep 17 00:00:00 2001 From: seeleng Date: Thu, 31 Oct 2024 15:01:38 +0800 Subject: [PATCH 11/64] feat(essay): add columns for rate limit --- backend/src/limits/models.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/backend/src/limits/models.py b/backend/src/limits/models.py index f9e2b8d2..3f9df7df 100644 --- a/backend/src/limits/models.py +++ b/backend/src/limits/models.py @@ -15,7 +15,8 @@ class Usage(Base): __tablename__ = "usage" user_id: Mapped[int] = mapped_column(ForeignKey("user.id"), primary_key=True) - gp_question_asked: Mapped[int] + gp_question_asked: Mapped[int] = mapped_column(server_default="0") + essays: Mapped[int] = mapped_column(server_default="0") class Tier(Base): @@ -25,3 +26,4 @@ class Tier(Base): tier_name: Mapped[TierNames] label: Mapped[str] gp_question_limit: Mapped[int] + essay_limit: Mapped[int] = mapped_column(server_default="0") From dddb05848353f52482b6b94ecc5d2a7d2c5b9872 Mon Sep 17 00:00:00 2001 From: Wang Haoyang Date: Thu, 31 Oct 2024 15:10:03 +0800 Subject: [PATCH 12/64] Improve redirect post-login -Conditionally redirect user after login success -If user has browser history, redirect user to previous page from browser history after login -If user has no browser history, redirect user to Jippy home page after login -This is slightly more convenient for users --- frontend/app/(unauthenticated)/login/page.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/frontend/app/(unauthenticated)/login/page.tsx b/frontend/app/(unauthenticated)/login/page.tsx index b33bed32..490ffef5 100644 --- a/frontend/app/(unauthenticated)/login/page.tsx +++ b/frontend/app/(unauthenticated)/login/page.tsx @@ -58,7 +58,11 @@ function LoginPage() { } else { setIsError(false); setLoggedIn(response.data.user); - router.push("/"); + if (window.history?.length && window.history.length > 1) { + router.back(); + } else { + router.replace("/", { scroll: false }); + } } }; From 06b81fb879a02eba21012bd3ed0d98c9412ec39a Mon Sep 17 00:00:00 2001 From: seeleng Date: Thu, 31 Oct 2024 15:19:03 +0800 Subject: [PATCH 13/64] fix: rate limit essays --- .../63af7264fba3_add_essay_rate_limit.py | 48 +++++++++++++++++++ backend/src/essays/dependencies.py | 29 +++++++++++ backend/src/essays/router.py | 2 + backend/src/limits/models.py | 4 +- 4 files changed, 81 insertions(+), 2 deletions(-) create mode 100644 backend/alembic/versions/63af7264fba3_add_essay_rate_limit.py create mode 100644 backend/src/essays/dependencies.py diff --git a/backend/alembic/versions/63af7264fba3_add_essay_rate_limit.py b/backend/alembic/versions/63af7264fba3_add_essay_rate_limit.py new file mode 100644 index 00000000..10a7577c --- /dev/null +++ b/backend/alembic/versions/63af7264fba3_add_essay_rate_limit.py @@ -0,0 +1,48 @@ +"""Add essay rate limit + +Revision ID: 63af7264fba3 +Revises: 4f9ec96fc98e +Create Date: 2024-10-31 14:54:32.307467 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlalchemy.orm as orm +from src.limits.models import Tier + + +# revision identifiers, used by Alembic. +revision: str = "63af7264fba3" +down_revision: Union[str, None] = "4f9ec96fc98e" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +ESSAY_LIMITS = {"Free": 3, "Premium": 10, "Unverified": 0, "Admin": 1000} + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column( + "tier", + sa.Column("essay_limit", sa.Integer(), server_default="0", nullable=False), + ) + op.add_column( + "usage", sa.Column("essays", sa.Integer(), server_default="0", nullable=False) + ) + session = orm.Session(bind=op.get_bind()) + for tier_type, essay_limit in ESSAY_LIMITS.items(): + tier = session.scalar(sa.select(Tier).where(Tier.label == tier_type)) + tier.essay_limit = essay_limit + session.add(tier) + session.commit() + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("usage", "essays") + op.drop_column("tier", "essay_limit") + # ### end Alembic commands ### diff --git a/backend/src/essays/dependencies.py b/backend/src/essays/dependencies.py new file mode 100644 index 00000000..c6fb1e52 --- /dev/null +++ b/backend/src/essays/dependencies.py @@ -0,0 +1,29 @@ +from http import HTTPStatus +from typing import Annotated + +from fastapi import Depends, HTTPException +from src.auth.dependencies import get_current_user +from src.auth.models import User +from src.common.dependencies import get_session +from src.limits.models import Usage + + +def has_essay_tries_left( + user: Annotated[User, Depends(get_current_user)], + session=Depends(get_session), +): + usage = session.get(Usage, user.id) + if not usage: + usage = Usage(user_id=user.id) + # This is inefficient, refactor in the future. + session.add(usage) + session.commit() + + user_tier_limit = user.tier.essay_limit + user_essay_usage = usage.essays + if user_tier_limit - user_essay_usage <= 0: + raise HTTPException(HTTPStatus.TOO_MANY_REQUESTS) + + usage.essays += 1 + session.add(usage) + session.commit() diff --git a/backend/src/essays/router.py b/backend/src/essays/router.py index 07810f0d..0f392027 100644 --- a/backend/src/essays/router.py +++ b/backend/src/essays/router.py @@ -6,6 +6,7 @@ from src.auth.dependencies import get_current_user from src.auth.models import User from src.common.dependencies import get_session +from src.essays.dependencies import has_essay_tries_left from src.essays.models import ( Comment, CommentAnalysis, @@ -31,6 +32,7 @@ def create_essay( data: EssayCreate, user: Annotated[User, Depends(get_current_user)], session: Annotated[Session, Depends(get_session)], + _=Depends(has_essay_tries_left), ) -> EssayCreateDTO: essay = Essay(question=data.question, user_id=user.id) diff --git a/backend/src/limits/models.py b/backend/src/limits/models.py index 3f9df7df..e9be11a4 100644 --- a/backend/src/limits/models.py +++ b/backend/src/limits/models.py @@ -15,8 +15,8 @@ class Usage(Base): __tablename__ = "usage" user_id: Mapped[int] = mapped_column(ForeignKey("user.id"), primary_key=True) - gp_question_asked: Mapped[int] = mapped_column(server_default="0") - essays: Mapped[int] = mapped_column(server_default="0") + gp_question_asked: Mapped[int] = mapped_column(default=0, server_default="0") + essays: Mapped[int] = mapped_column(default=0, server_default="0") class Tier(Base): From 8ecc9376bed3460834ff35ba425f8c933f8fcf4c Mon Sep 17 00:00:00 2001 From: seeleng Date: Thu, 31 Oct 2024 15:43:46 +0800 Subject: [PATCH 14/64] fix: hacky alembic fix --- .../versions/4f9ec96fc98e_add_unverified_tier.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/backend/alembic/versions/4f9ec96fc98e_add_unverified_tier.py b/backend/alembic/versions/4f9ec96fc98e_add_unverified_tier.py index 316e4a59..fa8da5e2 100644 --- a/backend/alembic/versions/4f9ec96fc98e_add_unverified_tier.py +++ b/backend/alembic/versions/4f9ec96fc98e_add_unverified_tier.py @@ -12,7 +12,8 @@ import sqlalchemy as sa import sqlalchemy.orm as orm from alembic_postgresql_enum import TableReference -from src.limits.models import Tier +from src.common.base import Base +from src.limits.models import TierNames # revision identifiers, used by Alembic. revision: str = "4f9ec96fc98e" @@ -21,6 +22,15 @@ depends_on: Union[str, Sequence[str], None] = None +class Tier(Base): + __tablename__ = "tier" + + id: orm.Mapped[int] = orm.mapped_column(primary_key=True) + tier_name: orm.Mapped[TierNames] + label: orm.Mapped[str] + gp_question_limit: orm.Mapped[int] + + def upgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### op.sync_enum_values( From 6ea8e6b4f2309d0436a77da60ea441f0281f5e35 Mon Sep 17 00:00:00 2001 From: Wang Haoyang Date: Thu, 31 Oct 2024 15:46:32 +0800 Subject: [PATCH 15/64] Improve verify email page -Modify the frontend messages on the verify email to sound nice -Update redirect now component to be a span that performs an onClick() function instead of just redirecting to home -Improve redirect after successful verification to selectively redirect to previous or home page depending on user's browser history -Potentially fix multiple runs of the verify backend call from the frontend(pending testing) --- .../app/(authenticated)/verify-email/page.tsx | 57 ++++++++++++------- 1 file changed, 36 insertions(+), 21 deletions(-) diff --git a/frontend/app/(authenticated)/verify-email/page.tsx b/frontend/app/(authenticated)/verify-email/page.tsx index 1057ef07..c1f6352a 100644 --- a/frontend/app/(authenticated)/verify-email/page.tsx +++ b/frontend/app/(authenticated)/verify-email/page.tsx @@ -4,7 +4,6 @@ import { useEffect, useRef, useState } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { CheckCircleIcon } from "lucide-react"; -import { completeEmailVerificationAuthVerifyEmailPut } from "@/client"; import Link from "@/components/navigation/link"; import { Box } from "@/components/ui/box"; import { @@ -17,41 +16,54 @@ import { import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { useUserStore } from "@/store/user/user-store-provider"; +export const UNVERIFIED_TIER_ID = 4; +export const VERIFY_SUCCESS_DELAY = 1; + export default function VerifyEmail() { + const user = useUserStore((store) => store.user); const router = useRouter(); - const setLoggedIn = useUserStore((state) => state.setLoggedIn); - const sentAuthentication = useRef(false); const [isLoading, setIsLoading] = useState(true); const searchParams = useSearchParams(); const code = searchParams.get("code"); + const redirectAfterVerify = () => { + if (window.history?.length && window.history.length > 1) { + // Redirect the user to their previously accessed page after successful verification + router.back(); + } else { + // User has no previous page in browser history, redirect user to Jippy home page + router.replace("/", { scroll: false }); + } + }; + useEffect(() => { - if (!sentAuthentication.current) { - sentAuthentication.current = true; + const timeout = null; + if (code && user?.tier_id === UNVERIFIED_TIER_ID) { (async () => { - if (!code) { - router.push("/login"); - return; - } - const response = await completeEmailVerificationAuthVerifyEmailPut({ + /* const response = await completeEmailVerificationAuthVerifyEmailPut({ query: { code }, }); // There is some problem where this function runs twice, causing an error // on the second run since the email verification is used. if (response.data) { setIsLoading(false); - setLoggedIn(response.data!.user); - router.push("/"); - } + const timeout = setTimeout(redirectAfterVerify, VERIFY_SUCCESS_DELAY * 1000); + } */ })(); } - }, [code, router, setLoggedIn]); + if (timeout) { + // Cleanup redirect timeout on unmount of the page + return () => clearTimeout(timeout); + } + }, [code, user]); return ( - - + + - Verifying your email + + {isLoading ? "Verifying your email" : "Verified! Logging you in"} + @@ -66,12 +78,15 @@ export default function VerifyEmail() { {isLoading ? "Hang tight! We're logging you in. This shouldn't take too long." - : "All done! You should be redirected soon."} + : "All done! You'll be redirected soon. "} {!isLoading && ( - - Redirect now - + + Redirect now + )} From 3b1dbb90339683c36eb20d80ebd37f0a034d6124 Mon Sep 17 00:00:00 2001 From: Wang Haoyang Date: Thu, 31 Oct 2024 15:49:37 +0800 Subject: [PATCH 16/64] Add new UnverifiedAlert component -Add new UnverifiedAlert component to be displayed when the user is logged in but has not verified their email --- .../navigation/unverified-alert.tsx | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 frontend/components/navigation/unverified-alert.tsx diff --git a/frontend/components/navigation/unverified-alert.tsx b/frontend/components/navigation/unverified-alert.tsx new file mode 100644 index 00000000..6f05c74f --- /dev/null +++ b/frontend/components/navigation/unverified-alert.tsx @@ -0,0 +1,23 @@ +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { CircleAlert } from "lucide-react"; + +const UnverifiedAlert = () => { + const onClickResendVerification = () => { + + }; + + return ( + +
+ +
+ Email verification + + Please verify your email address by clicking the verification link sent to your email.
+ Resend verification link? +
+
+ ); +}; + +export default UnverifiedAlert; From aaaf60b961cdd06dc9ef66f4ab324ece42f995f2 Mon Sep 17 00:00:00 2001 From: seeleng Date: Thu, 31 Oct 2024 15:49:41 +0800 Subject: [PATCH 17/64] Revert "fix: hacky alembic fix" This reverts commit 8ecc9376bed3460834ff35ba425f8c933f8fcf4c. --- .../versions/4f9ec96fc98e_add_unverified_tier.py | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/backend/alembic/versions/4f9ec96fc98e_add_unverified_tier.py b/backend/alembic/versions/4f9ec96fc98e_add_unverified_tier.py index fa8da5e2..316e4a59 100644 --- a/backend/alembic/versions/4f9ec96fc98e_add_unverified_tier.py +++ b/backend/alembic/versions/4f9ec96fc98e_add_unverified_tier.py @@ -12,8 +12,7 @@ import sqlalchemy as sa import sqlalchemy.orm as orm from alembic_postgresql_enum import TableReference -from src.common.base import Base -from src.limits.models import TierNames +from src.limits.models import Tier # revision identifiers, used by Alembic. revision: str = "4f9ec96fc98e" @@ -22,15 +21,6 @@ depends_on: Union[str, Sequence[str], None] = None -class Tier(Base): - __tablename__ = "tier" - - id: orm.Mapped[int] = orm.mapped_column(primary_key=True) - tier_name: orm.Mapped[TierNames] - label: orm.Mapped[str] - gp_question_limit: orm.Mapped[int] - - def upgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### op.sync_enum_values( From 41bc15384a5ad1716a57ffdca2cb0b6d0883e363 Mon Sep 17 00:00:00 2001 From: Wang Haoyang Date: Thu, 31 Oct 2024 15:51:29 +0800 Subject: [PATCH 18/64] Display new UnverifiedAlert on navbar -Selectively display new UnverifiedAlert component below navbar when user logs in but haven't verified their email(tier_idcorresponds to that of unverified) --- frontend/components/navigation/mobile/mobile-navbar.tsx | 9 ++++++++- frontend/components/navigation/navbar.tsx | 9 ++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/frontend/components/navigation/mobile/mobile-navbar.tsx b/frontend/components/navigation/mobile/mobile-navbar.tsx index 164b933b..124e388b 100644 --- a/frontend/components/navigation/mobile/mobile-navbar.tsx +++ b/frontend/components/navigation/mobile/mobile-navbar.tsx @@ -10,11 +10,13 @@ import { useUserStore } from "@/store/user/user-store-provider"; import { NavItem } from "@/types/navigation"; import MobileSidebar from "./mobile-sidebar"; +import { UNVERIFIED_TIER_ID } from "@/app/(authenticated)/verify-email/page"; +import UnverifiedAlert from "../unverified-alert"; export const NavItems: NavItem[] = []; function MobileNavbar() { - const isLoggedIn = useUserStore((state) => state.isLoggedIn); + const {isLoggedIn, user} = useUserStore((state) => state); return ( // min-h-[84px] max-h-[84px] @@ -54,6 +56,11 @@ function MobileNavbar() { ))} + { isLoggedIn && user?.tier_id === UNVERIFIED_TIER_ID && +
+ +
+ } ); } diff --git a/frontend/components/navigation/navbar.tsx b/frontend/components/navigation/navbar.tsx index cc18af64..7c3b2daa 100644 --- a/frontend/components/navigation/navbar.tsx +++ b/frontend/components/navigation/navbar.tsx @@ -15,11 +15,13 @@ import { navigationMenuTriggerStyle } from "@/components/ui/navigation-menu"; import JippyLogo from "@/public/jippy-logo/jippy-logo-sm"; import { useUserStore } from "@/store/user/user-store-provider"; import { NavItem } from "@/types/navigation"; +import UnverifiedAlert from "./unverified-alert"; +import { UNVERIFIED_TIER_ID } from "@/app/(authenticated)/verify-email/page"; export const NavItems: NavItem[] = []; function Navbar() { - const isLoggedIn = useUserStore((state) => state.isLoggedIn); + const {isLoggedIn, user} = useUserStore((state) => state); return ( // min-h-[84px] max-h-[84px] @@ -65,6 +67,11 @@ function Navbar() { ))} + { isLoggedIn && user?.tier_id === UNVERIFIED_TIER_ID && +
+ +
+ } ); } From baf15b5672f5e77cd18c7572dc9358fe6c5cca36 Mon Sep 17 00:00:00 2001 From: Wang Haoyang Date: Thu, 31 Oct 2024 16:05:51 +0800 Subject: [PATCH 19/64] Add verify status validation -Add validation for whether user is verified on the billing page -If user is unverified, disable all subscription buttons and also add the UnverifiedAlert banner to remind the user to verify their email -Only if the user is verified, then enable the subscription buttons and remove the UnverifiedAlert banner --- .../app/(authenticated)/user/billing/page.tsx | 51 +++++++++++-------- 1 file changed, 31 insertions(+), 20 deletions(-) diff --git a/frontend/app/(authenticated)/user/billing/page.tsx b/frontend/app/(authenticated)/user/billing/page.tsx index 439f69de..19ac5081 100644 --- a/frontend/app/(authenticated)/user/billing/page.tsx +++ b/frontend/app/(authenticated)/user/billing/page.tsx @@ -22,6 +22,8 @@ import { tierIDToTierName, TierPrice, } from "@/types/billing"; +import { UNVERIFIED_TIER_ID } from "../../verify-email/page"; +import UnverifiedAlert from "@/components/navigation/unverified-alert"; const FREE_TIER_ID = 1; const TIER_STATUS_ACTIVE = "active"; @@ -31,7 +33,9 @@ const getPriceButtonText = ( user: UserPublic | undefined, ) => { const userTierId = user?.tier_id || 1; - if (priceTierId == userTierId) { + if (userTierId === UNVERIFIED_TIER_ID) { + return "Not allowed"; + } else if (priceTierId == userTierId) { return "Current"; } else if (priceTierId > userTierId) { return "Upgrade"; @@ -56,6 +60,7 @@ const Page = () => { const billingPath = usePathname(); const searchParams = useSearchParams(); + const isUserUnverified = user?.tier_id === UNVERIFIED_TIER_ID; let isSuccess = searchParams.get("success") === "true"; let stripeSessionId = searchParams.get("session_id"); let isCancelled = searchParams.get("cancelled") === "true"; @@ -79,7 +84,7 @@ const Page = () => { const jippyTiers = [ { tierName: JippyTier.Free, - isButtonDisabled: user?.tier_id == JippyTierID.Free, + isButtonDisabled: isUserUnverified || user?.tier_id == JippyTierID.Free, buttonText: getPriceButtonText(JippyTierID.Free, user), onClickBuy: onClickDowngradeSubscription, price: TierPrice.Free, @@ -88,7 +93,7 @@ const Page = () => { }, { tierName: JippyTier.Premium, - isButtonDisabled: user?.tier_id == JippyTierID.Premium, + isButtonDisabled: isUserUnverified || user?.tier_id == JippyTierID.Premium, buttonText: getPriceButtonText(JippyTierID.Premium, user), onClickBuy: () => { stripeCheckoutMutation.mutate({ @@ -159,23 +164,29 @@ const Page = () => {

Your Tier

-
-

{userTier} Tier:

- -
- {user?.subscription && ( - + { isUserUnverified ? ( + + ) : ( + <> +
+

{userTier} Tier:

+ +
+ {user?.subscription && ( + + )} + )}
From 4102cbc981b57231421a41a99aa4b12f4d64c04e Mon Sep 17 00:00:00 2001 From: Wang Haoyang Date: Thu, 31 Oct 2024 16:06:31 +0800 Subject: [PATCH 20/64] Format frontend with eslint --- .../app/(authenticated)/user/billing/page.tsx | 9 ++-- .../app/(authenticated)/verify-email/page.tsx | 12 +++--- .../navigation/mobile/mobile-navbar.tsx | 10 ++--- frontend/components/navigation/navbar.tsx | 9 ++-- .../navigation/unverified-alert.tsx | 42 ++++++++++++------- 5 files changed, 47 insertions(+), 35 deletions(-) diff --git a/frontend/app/(authenticated)/user/billing/page.tsx b/frontend/app/(authenticated)/user/billing/page.tsx index 19ac5081..4d7f4fe3 100644 --- a/frontend/app/(authenticated)/user/billing/page.tsx +++ b/frontend/app/(authenticated)/user/billing/page.tsx @@ -3,9 +3,11 @@ import { useEffect, useState } from "react"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { UNVERIFIED_TIER_ID } from "@/app/(authenticated)/verify-email/page"; import { UserPublic } from "@/client"; import PricingTable from "@/components/billing/pricing-table"; import Chip from "@/components/display/chip"; +import UnverifiedAlert from "@/components/navigation/unverified-alert"; import { Button } from "@/components/ui/button"; import { useToast } from "@/hooks/use-toast"; import { @@ -22,8 +24,6 @@ import { tierIDToTierName, TierPrice, } from "@/types/billing"; -import { UNVERIFIED_TIER_ID } from "../../verify-email/page"; -import UnverifiedAlert from "@/components/navigation/unverified-alert"; const FREE_TIER_ID = 1; const TIER_STATUS_ACTIVE = "active"; @@ -93,7 +93,8 @@ const Page = () => { }, { tierName: JippyTier.Premium, - isButtonDisabled: isUserUnverified || user?.tier_id == JippyTierID.Premium, + isButtonDisabled: + isUserUnverified || user?.tier_id == JippyTierID.Premium, buttonText: getPriceButtonText(JippyTierID.Premium, user), onClickBuy: () => { stripeCheckoutMutation.mutate({ @@ -164,7 +165,7 @@ const Page = () => {

Your Tier

- { isUserUnverified ? ( + {isUserUnverified ? ( ) : ( <> diff --git a/frontend/app/(authenticated)/verify-email/page.tsx b/frontend/app/(authenticated)/verify-email/page.tsx index c1f6352a..a72eaff2 100644 --- a/frontend/app/(authenticated)/verify-email/page.tsx +++ b/frontend/app/(authenticated)/verify-email/page.tsx @@ -81,12 +81,12 @@ export default function VerifyEmail() { : "All done! You'll be redirected soon. "} {!isLoading && ( - - Redirect now - + + Redirect now + )} diff --git a/frontend/components/navigation/mobile/mobile-navbar.tsx b/frontend/components/navigation/mobile/mobile-navbar.tsx index 124e388b..b4377789 100644 --- a/frontend/components/navigation/mobile/mobile-navbar.tsx +++ b/frontend/components/navigation/mobile/mobile-navbar.tsx @@ -1,8 +1,10 @@ "use client"; +import { UNVERIFIED_TIER_ID } from "@/app/(authenticated)/verify-email/page"; import UserProfileButton from "@/components/auth/user-profile-button"; import { NAVBAR_HEIGHT } from "@/components/layout/app-layout"; import Link from "@/components/navigation/link"; +import UnverifiedAlert from "@/components/navigation/unverified-alert"; import { Button } from "@/components/ui/button"; import JippyIconSm from "@/public/jippy-icon/jippy-icon-sm"; import JippyLogo from "@/public/jippy-logo/jippy-logo-sm"; @@ -10,13 +12,11 @@ import { useUserStore } from "@/store/user/user-store-provider"; import { NavItem } from "@/types/navigation"; import MobileSidebar from "./mobile-sidebar"; -import { UNVERIFIED_TIER_ID } from "@/app/(authenticated)/verify-email/page"; -import UnverifiedAlert from "../unverified-alert"; export const NavItems: NavItem[] = []; function MobileNavbar() { - const {isLoggedIn, user} = useUserStore((state) => state); + const { isLoggedIn, user } = useUserStore((state) => state); return ( // min-h-[84px] max-h-[84px] @@ -56,11 +56,11 @@ function MobileNavbar() { ))}
- { isLoggedIn && user?.tier_id === UNVERIFIED_TIER_ID && + {isLoggedIn && user?.tier_id === UNVERIFIED_TIER_ID && (
- } + )} ); } diff --git a/frontend/components/navigation/navbar.tsx b/frontend/components/navigation/navbar.tsx index 7c3b2daa..bf74b8b2 100644 --- a/frontend/components/navigation/navbar.tsx +++ b/frontend/components/navigation/navbar.tsx @@ -7,6 +7,7 @@ import { NavigationMenuList, } from "@radix-ui/react-navigation-menu"; +import { UNVERIFIED_TIER_ID } from "@/app/(authenticated)/verify-email/page"; import UserProfileButton from "@/components/auth/user-profile-button"; import { NAVBAR_HEIGHT } from "@/components/layout/app-layout"; import Link from "@/components/navigation/link"; @@ -15,13 +16,13 @@ import { navigationMenuTriggerStyle } from "@/components/ui/navigation-menu"; import JippyLogo from "@/public/jippy-logo/jippy-logo-sm"; import { useUserStore } from "@/store/user/user-store-provider"; import { NavItem } from "@/types/navigation"; + import UnverifiedAlert from "./unverified-alert"; -import { UNVERIFIED_TIER_ID } from "@/app/(authenticated)/verify-email/page"; export const NavItems: NavItem[] = []; function Navbar() { - const {isLoggedIn, user} = useUserStore((state) => state); + const { isLoggedIn, user } = useUserStore((state) => state); return ( // min-h-[84px] max-h-[84px] @@ -67,11 +68,11 @@ function Navbar() { ))}
- { isLoggedIn && user?.tier_id === UNVERIFIED_TIER_ID && + {isLoggedIn && user?.tier_id === UNVERIFIED_TIER_ID && (
- } + )} ); } diff --git a/frontend/components/navigation/unverified-alert.tsx b/frontend/components/navigation/unverified-alert.tsx index 6f05c74f..c3f6da73 100644 --- a/frontend/components/navigation/unverified-alert.tsx +++ b/frontend/components/navigation/unverified-alert.tsx @@ -1,23 +1,33 @@ -import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { CircleAlert } from "lucide-react"; -const UnverifiedAlert = () => { - const onClickResendVerification = () => { +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; - }; +const UnverifiedAlert = () => { + const onClickResendVerification = () => {}; - return ( - -
- -
- Email verification - - Please verify your email address by clicking the verification link sent to your email.
- Resend verification link? -
-
- ); + return ( + +
+ +
+ Email verification + + Please verify your email address by clicking the verification link sent + to your email.
+ + Resend + {" "} + verification link? +
+
+ ); }; export default UnverifiedAlert; From 4e81b4c542242a65c9886cbb6fd067124a6ae222 Mon Sep 17 00:00:00 2001 From: Wang Haoyang Date: Thu, 31 Oct 2024 16:08:24 +0800 Subject: [PATCH 21/64] Update client openapi ts -Generate updated client ts with openapi-ts --- frontend/client/schemas.gen.ts | 8 +++-- frontend/client/services.gen.ts | 48 ++++++++++++++++++--------- frontend/client/types.gen.ts | 57 +++++++++++++++++++++------------ 3 files changed, 75 insertions(+), 38 deletions(-) diff --git a/frontend/client/schemas.gen.ts b/frontend/client/schemas.gen.ts index 8a0f9558..c34ffc76 100644 --- a/frontend/client/schemas.gen.ts +++ b/frontend/client/schemas.gen.ts @@ -1783,7 +1783,7 @@ export const TierDTOSchema = { export const TierNamesSchema = { type: 'string', - enum: ['FREE', 'ADMIN', 'PREMIUM'], + enum: ['FREE', 'ADMIN', 'PREMIUM', 'UNVERIFIED'], title: 'TierNames' } as const; @@ -1873,10 +1873,14 @@ export const UserPublicSchema = { }, tier: { '$ref': '#/components/schemas/TierDTO' + }, + verified: { + type: 'boolean', + title: 'Verified' } }, type: 'object', - required: ['id', 'email', 'last_accessed', 'categories', 'tier'], + required: ['id', 'email', 'last_accessed', 'categories', 'tier', 'verified'], title: 'UserPublic' } as const; diff --git a/frontend/client/services.gen.ts b/frontend/client/services.gen.ts index 20f41e2b..fb929936 100644 --- a/frontend/client/services.gen.ts +++ b/frontend/client/services.gen.ts @@ -1,7 +1,7 @@ // This file is auto-generated by @hey-api/openapi-ts import { createClient, createConfig, type Options, urlSearchParamsBodySerializer } from './client'; -import type { SignUpAuthSignupPostData, SignUpAuthSignupPostError, SignUpAuthSignupPostResponse, LogInAuthLoginPostData, LogInAuthLoginPostError, LogInAuthLoginPostResponse, LoginGoogleAuthLoginGoogleGetError, LoginGoogleAuthLoginGoogleGetResponse, AuthGoogleAuthGoogleGetData, AuthGoogleAuthGoogleGetError, AuthGoogleAuthGoogleGetResponse, StripeWebhookBillingWebhookPostData, StripeWebhookBillingWebhookPostError, StripeWebhookBillingWebhookPostResponse, GetUserAuthSessionGetData, GetUserAuthSessionGetError, GetUserAuthSessionGetResponse, LogoutAuthLogoutGetData, LogoutAuthLogoutGetError, LogoutAuthLogoutGetResponse, RequestPasswordResetAuthPasswordResetPostData, RequestPasswordResetAuthPasswordResetPostError, RequestPasswordResetAuthPasswordResetPostResponse, CompletePasswordResetAuthPasswordResetPutData, CompletePasswordResetAuthPasswordResetPutError, CompletePasswordResetAuthPasswordResetPutResponse, ChangePasswordAuthChangePasswordPutData, ChangePasswordAuthChangePasswordPutError, ChangePasswordAuthChangePasswordPutResponse, CreateCheckoutSessionBillingCreateCheckoutSessionPostData, CreateCheckoutSessionBillingCreateCheckoutSessionPostError, CreateCheckoutSessionBillingCreateCheckoutSessionPostResponse, CreateCustomerPortalSessionBillingCreateCustomerPortalSessionPostData, CreateCustomerPortalSessionBillingCreateCustomerPortalSessionPostError, CreateCustomerPortalSessionBillingCreateCustomerPortalSessionPostResponse, DowngradeSubscriptionBillingDowngradeSubscriptionPutData, DowngradeSubscriptionBillingDowngradeSubscriptionPutError, DowngradeSubscriptionBillingDowngradeSubscriptionPutResponse, GetCategoriesCategoriesGetData, GetCategoriesCategoriesGetError, GetCategoriesCategoriesGetResponse, UpdateProfileProfilePutData, UpdateProfileProfilePutError, UpdateProfileProfilePutResponse, GetEventsEventsGetData, GetEventsEventsGetError, GetEventsEventsGetResponse, GetEventEventsIdGetData, GetEventEventsIdGetError, GetEventEventsIdGetResponse, GetEventNotesEventsIdNotesGetData, GetEventNotesEventsIdNotesGetError, GetEventNotesEventsIdNotesGetResponse, ReadEventEventsIdReadPostData, ReadEventEventsIdReadPostError, ReadEventEventsIdReadPostResponse, SearchWhateverEventsSearchGetData, SearchWhateverEventsSearchGetError, SearchWhateverEventsSearchGetResponse, AddBookmarkEventsIdBookmarksPostData, AddBookmarkEventsIdBookmarksPostError, AddBookmarkEventsIdBookmarksPostResponse, DeleteBookmarkEventsIdBookmarksDeleteData, DeleteBookmarkEventsIdBookmarksDeleteError, DeleteBookmarkEventsIdBookmarksDeleteResponse, GetUserQuestionsUserQuestionsGetData, GetUserQuestionsUserQuestionsGetError, GetUserQuestionsUserQuestionsGetResponse, CreateUserQuestionUserQuestionsPostData, CreateUserQuestionUserQuestionsPostError, CreateUserQuestionUserQuestionsPostResponse, GetUserQuestionUserQuestionsIdGetData, GetUserQuestionUserQuestionsIdGetError, GetUserQuestionUserQuestionsIdGetResponse, ClassifyQuestionUserQuestionsClassifyPostData, ClassifyQuestionUserQuestionsClassifyPostError, ClassifyQuestionUserQuestionsClassifyPostResponse, GetAllNotesNotesGetData, GetAllNotesNotesGetError, GetAllNotesNotesGetResponse, CreateNoteNotesPostData, CreateNoteNotesPostError, CreateNoteNotesPostResponse, UpdateNoteNotesIdPutData, UpdateNoteNotesIdPutError, UpdateNoteNotesIdPutResponse, DeleteNoteNotesIdDeleteData, DeleteNoteNotesIdDeleteError, DeleteNoteNotesIdDeleteResponse, GetPointNotesPointsIdNotesGetData, GetPointNotesPointsIdNotesGetError, GetPointNotesPointsIdNotesGetResponse, UpsertLikeLikesPostData, UpsertLikeLikesPostError, UpsertLikeLikesPostResponse, CreateEssayEssaysPostData, CreateEssayEssaysPostError, CreateEssayEssaysPostResponse, GetEssaysEssaysGetData, GetEssaysEssaysGetError, GetEssaysEssaysGetResponse, GetEssayEssaysIdGetData, GetEssayEssaysIdGetError, GetEssayEssaysIdGetResponse, DeleteEssayEssaysIdDeleteData, DeleteEssayEssaysIdDeleteError, DeleteEssayEssaysIdDeleteResponse, GetArticlesArticlesGetData, GetArticlesArticlesGetError, GetArticlesArticlesGetResponse, GetTopArticlesArticlesTopGetData, GetTopArticlesArticlesTopGetError, GetTopArticlesArticlesTopGetResponse, GetArticleArticlesIdGetData, GetArticleArticlesIdGetError, GetArticleArticlesIdGetResponse, AddBookmarkArticlesIdBookmarksPostData, AddBookmarkArticlesIdBookmarksPostError, AddBookmarkArticlesIdBookmarksPostResponse, DeleteBookmarkArticlesIdBookmarksDeleteData, DeleteBookmarkArticlesIdBookmarksDeleteError, DeleteBookmarkArticlesIdBookmarksDeleteResponse, ReadArticleArticlesIdReadPostData, ReadArticleArticlesIdReadPostError, ReadArticleArticlesIdReadPostResponse, GetSubscriptionSubscriptionsIdGetData, GetSubscriptionSubscriptionsIdGetError, GetSubscriptionSubscriptionsIdGetResponse, GetSubscriptionStatusSubscriptionsIdStatusGetData, GetSubscriptionStatusSubscriptionsIdStatusGetError, GetSubscriptionStatusSubscriptionsIdStatusGetResponse } from './types.gen'; +import type { SignUpAuthSignupPostData, SignUpAuthSignupPostError, SignUpAuthSignupPostResponse, LogInAuthLoginPostData, LogInAuthLoginPostError, LogInAuthLoginPostResponse, CompleteEmailVerificationAuthEmailVerificationPutData, CompleteEmailVerificationAuthEmailVerificationPutError, CompleteEmailVerificationAuthEmailVerificationPutResponse, ResendVerificationEmailAuthEmailVerificationPostData, ResendVerificationEmailAuthEmailVerificationPostError, ResendVerificationEmailAuthEmailVerificationPostResponse, LoginGoogleAuthLoginGoogleGetError, LoginGoogleAuthLoginGoogleGetResponse, AuthGoogleAuthGoogleGetData, AuthGoogleAuthGoogleGetError, AuthGoogleAuthGoogleGetResponse, RequestPasswordResetAuthPasswordResetPostData, RequestPasswordResetAuthPasswordResetPostError, RequestPasswordResetAuthPasswordResetPostResponse, CompletePasswordResetAuthPasswordResetPutData, CompletePasswordResetAuthPasswordResetPutError, CompletePasswordResetAuthPasswordResetPutResponse, StripeWebhookBillingWebhookPostData, StripeWebhookBillingWebhookPostError, StripeWebhookBillingWebhookPostResponse, GetUserAuthSessionGetData, GetUserAuthSessionGetError, GetUserAuthSessionGetResponse, LogoutAuthLogoutGetData, LogoutAuthLogoutGetError, LogoutAuthLogoutGetResponse, ChangePasswordAuthChangePasswordPutData, ChangePasswordAuthChangePasswordPutError, ChangePasswordAuthChangePasswordPutResponse, CreateCheckoutSessionBillingCreateCheckoutSessionPostData, CreateCheckoutSessionBillingCreateCheckoutSessionPostError, CreateCheckoutSessionBillingCreateCheckoutSessionPostResponse, CreateCustomerPortalSessionBillingCreateCustomerPortalSessionPostData, CreateCustomerPortalSessionBillingCreateCustomerPortalSessionPostError, CreateCustomerPortalSessionBillingCreateCustomerPortalSessionPostResponse, DowngradeSubscriptionBillingDowngradeSubscriptionPutData, DowngradeSubscriptionBillingDowngradeSubscriptionPutError, DowngradeSubscriptionBillingDowngradeSubscriptionPutResponse, GetCategoriesCategoriesGetData, GetCategoriesCategoriesGetError, GetCategoriesCategoriesGetResponse, UpdateProfileProfilePutData, UpdateProfileProfilePutError, UpdateProfileProfilePutResponse, GetEventsEventsGetData, GetEventsEventsGetError, GetEventsEventsGetResponse, GetEventEventsIdGetData, GetEventEventsIdGetError, GetEventEventsIdGetResponse, GetEventNotesEventsIdNotesGetData, GetEventNotesEventsIdNotesGetError, GetEventNotesEventsIdNotesGetResponse, ReadEventEventsIdReadPostData, ReadEventEventsIdReadPostError, ReadEventEventsIdReadPostResponse, SearchWhateverEventsSearchGetData, SearchWhateverEventsSearchGetError, SearchWhateverEventsSearchGetResponse, AddBookmarkEventsIdBookmarksPostData, AddBookmarkEventsIdBookmarksPostError, AddBookmarkEventsIdBookmarksPostResponse, DeleteBookmarkEventsIdBookmarksDeleteData, DeleteBookmarkEventsIdBookmarksDeleteError, DeleteBookmarkEventsIdBookmarksDeleteResponse, GetUserQuestionsUserQuestionsGetData, GetUserQuestionsUserQuestionsGetError, GetUserQuestionsUserQuestionsGetResponse, CreateUserQuestionUserQuestionsPostData, CreateUserQuestionUserQuestionsPostError, CreateUserQuestionUserQuestionsPostResponse, GetUserQuestionUserQuestionsIdGetData, GetUserQuestionUserQuestionsIdGetError, GetUserQuestionUserQuestionsIdGetResponse, ClassifyQuestionUserQuestionsClassifyPostData, ClassifyQuestionUserQuestionsClassifyPostError, ClassifyQuestionUserQuestionsClassifyPostResponse, GetAllNotesNotesGetData, GetAllNotesNotesGetError, GetAllNotesNotesGetResponse, CreateNoteNotesPostData, CreateNoteNotesPostError, CreateNoteNotesPostResponse, UpdateNoteNotesIdPutData, UpdateNoteNotesIdPutError, UpdateNoteNotesIdPutResponse, DeleteNoteNotesIdDeleteData, DeleteNoteNotesIdDeleteError, DeleteNoteNotesIdDeleteResponse, GetPointNotesPointsIdNotesGetData, GetPointNotesPointsIdNotesGetError, GetPointNotesPointsIdNotesGetResponse, UpsertLikeLikesPostData, UpsertLikeLikesPostError, UpsertLikeLikesPostResponse, CreateEssayEssaysPostData, CreateEssayEssaysPostError, CreateEssayEssaysPostResponse, GetEssaysEssaysGetData, GetEssaysEssaysGetError, GetEssaysEssaysGetResponse, GetEssayEssaysIdGetData, GetEssayEssaysIdGetError, GetEssayEssaysIdGetResponse, DeleteEssayEssaysIdDeleteData, DeleteEssayEssaysIdDeleteError, DeleteEssayEssaysIdDeleteResponse, GetArticlesArticlesGetData, GetArticlesArticlesGetError, GetArticlesArticlesGetResponse, GetTopArticlesArticlesTopGetData, GetTopArticlesArticlesTopGetError, GetTopArticlesArticlesTopGetResponse, GetArticleArticlesIdGetData, GetArticleArticlesIdGetError, GetArticleArticlesIdGetResponse, AddBookmarkArticlesIdBookmarksPostData, AddBookmarkArticlesIdBookmarksPostError, AddBookmarkArticlesIdBookmarksPostResponse, DeleteBookmarkArticlesIdBookmarksDeleteData, DeleteBookmarkArticlesIdBookmarksDeleteError, DeleteBookmarkArticlesIdBookmarksDeleteResponse, ReadArticleArticlesIdReadPostData, ReadArticleArticlesIdReadPostError, ReadArticleArticlesIdReadPostResponse, GetSubscriptionSubscriptionsIdGetData, GetSubscriptionSubscriptionsIdGetError, GetSubscriptionSubscriptionsIdGetResponse, GetSubscriptionStatusSubscriptionsIdStatusGetData, GetSubscriptionStatusSubscriptionsIdStatusGetError, GetSubscriptionStatusSubscriptionsIdStatusGetResponse } from './types.gen'; export const client = createClient(createConfig()); @@ -26,6 +26,22 @@ export const logInAuthLoginPost = (options url: '/auth/login' }); }; +/** + * Complete Email Verification + */ +export const completeEmailVerificationAuthEmailVerificationPut = (options: Options) => { return (options?.client ?? client).put({ + ...options, + url: '/auth/email-verification' +}); }; + +/** + * Resend Verification Email + */ +export const resendVerificationEmailAuthEmailVerificationPost = (options?: Options) => { return (options?.client ?? client).post({ + ...options, + url: '/auth/email-verification' +}); }; + /** * Login Google */ @@ -43,43 +59,43 @@ export const authGoogleAuthGoogleGet = (op }); }; /** - * Stripe Webhook + * Request Password Reset */ -export const stripeWebhookBillingWebhookPost = (options?: Options) => { return (options?.client ?? client).post({ +export const requestPasswordResetAuthPasswordResetPost = (options: Options) => { return (options?.client ?? client).post({ ...options, - url: '/billing/webhook' + url: '/auth/password-reset' }); }; /** - * Get User + * Complete Password Reset */ -export const getUserAuthSessionGet = (options?: Options) => { return (options?.client ?? client).get({ +export const completePasswordResetAuthPasswordResetPut = (options: Options) => { return (options?.client ?? client).put({ ...options, - url: '/auth/session' + url: '/auth/password-reset' }); }; /** - * Logout + * Stripe Webhook */ -export const logoutAuthLogoutGet = (options?: Options) => { return (options?.client ?? client).get({ +export const stripeWebhookBillingWebhookPost = (options?: Options) => { return (options?.client ?? client).post({ ...options, - url: '/auth/logout' + url: '/billing/webhook' }); }; /** - * Request Password Reset + * Get User */ -export const requestPasswordResetAuthPasswordResetPost = (options: Options) => { return (options?.client ?? client).post({ +export const getUserAuthSessionGet = (options?: Options) => { return (options?.client ?? client).get({ ...options, - url: '/auth/password-reset' + url: '/auth/session' }); }; /** - * Complete Password Reset + * Logout */ -export const completePasswordResetAuthPasswordResetPut = (options: Options) => { return (options?.client ?? client).put({ +export const logoutAuthLogoutGet = (options?: Options) => { return (options?.client ?? client).get({ ...options, - url: '/auth/password-reset' + url: '/auth/logout' }); }; /** diff --git a/frontend/client/types.gen.ts b/frontend/client/types.gen.ts index 4fc8fbc0..7da1e3c7 100644 --- a/frontend/client/types.gen.ts +++ b/frontend/client/types.gen.ts @@ -410,7 +410,7 @@ export type TierDTO = { gp_question_limit: number; }; -export type TierNames = 'FREE' | 'ADMIN' | 'PREMIUM'; +export type TierNames = 'FREE' | 'ADMIN' | 'PREMIUM' | 'UNVERIFIED'; export type Token = { access_token: string; @@ -432,6 +432,7 @@ export type UserPublic = { subscription?: (SubscriptionDTO | null); usage?: (UsageDTO | null); tier: TierDTO; + verified: boolean; }; export type UserQuestionDTO = { @@ -500,6 +501,22 @@ export type LogInAuthLoginPostResponse = (Token); export type LogInAuthLoginPostError = (HTTPValidationError); +export type CompleteEmailVerificationAuthEmailVerificationPutData = { + query: { + code: string; + }; +}; + +export type CompleteEmailVerificationAuthEmailVerificationPutResponse = (Token); + +export type CompleteEmailVerificationAuthEmailVerificationPutError = (HTTPValidationError); + +export type ResendVerificationEmailAuthEmailVerificationPostData = unknown; + +export type ResendVerificationEmailAuthEmailVerificationPostResponse = (unknown); + +export type ResendVerificationEmailAuthEmailVerificationPostError = (HTTPValidationError); + export type LoginGoogleAuthLoginGoogleGetResponse = (unknown); export type LoginGoogleAuthLoginGoogleGetError = unknown; @@ -514,6 +531,25 @@ export type AuthGoogleAuthGoogleGetResponse = (Token); export type AuthGoogleAuthGoogleGetError = (HTTPValidationError); +export type RequestPasswordResetAuthPasswordResetPostData = { + body: PasswordResetRequestData; +}; + +export type RequestPasswordResetAuthPasswordResetPostResponse = (unknown); + +export type RequestPasswordResetAuthPasswordResetPostError = (HTTPValidationError); + +export type CompletePasswordResetAuthPasswordResetPutData = { + body: PasswordResetCompleteData; + query: { + code: string; + }; +}; + +export type CompletePasswordResetAuthPasswordResetPutResponse = (unknown); + +export type CompletePasswordResetAuthPasswordResetPutError = (HTTPValidationError); + export type StripeWebhookBillingWebhookPostData = { headers?: { 'Stripe-Signature'?: string; @@ -536,25 +572,6 @@ export type LogoutAuthLogoutGetResponse = (unknown); export type LogoutAuthLogoutGetError = (HTTPValidationError); -export type RequestPasswordResetAuthPasswordResetPostData = { - body: PasswordResetRequestData; -}; - -export type RequestPasswordResetAuthPasswordResetPostResponse = (unknown); - -export type RequestPasswordResetAuthPasswordResetPostError = (HTTPValidationError); - -export type CompletePasswordResetAuthPasswordResetPutData = { - body: PasswordResetCompleteData; - query: { - code: string; - }; -}; - -export type CompletePasswordResetAuthPasswordResetPutResponse = (unknown); - -export type CompletePasswordResetAuthPasswordResetPutError = (HTTPValidationError); - export type ChangePasswordAuthChangePasswordPutData = { body: PasswordResetMoreCompleteData; }; From e28668ad4e425a61af6cf8c1624a42771811eb60 Mon Sep 17 00:00:00 2001 From: Wang Haoyang Date: Thu, 31 Oct 2024 16:10:14 +0800 Subject: [PATCH 22/64] Remove unused imports --- frontend/app/(authenticated)/verify-email/page.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/frontend/app/(authenticated)/verify-email/page.tsx b/frontend/app/(authenticated)/verify-email/page.tsx index a72eaff2..6ae34e33 100644 --- a/frontend/app/(authenticated)/verify-email/page.tsx +++ b/frontend/app/(authenticated)/verify-email/page.tsx @@ -1,10 +1,9 @@ "use client"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useState } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { CheckCircleIcon } from "lucide-react"; -import Link from "@/components/navigation/link"; import { Box } from "@/components/ui/box"; import { Card, From 12db7bcb8311a163daa0e65c58b555ebd5ea2929 Mon Sep 17 00:00:00 2001 From: Wang Haoyang Date: Thu, 31 Oct 2024 17:30:57 +0800 Subject: [PATCH 23/64] Improve email verification backend -Update user.tier_id to free tier after successful email verification --- backend/src/auth/router.py | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/src/auth/router.py b/backend/src/auth/router.py index a4e24d35..9f01885d 100644 --- a/backend/src/auth/router.py +++ b/backend/src/auth/router.py @@ -135,6 +135,7 @@ def complete_email_verification( ) ) user.verified = True + user.tier_id = 1 email_verification.used = True session.add(user) session.add(email_verification) From 77eddbb6bf6c5edc1a0bfe1d0403c73182f400b7 Mon Sep 17 00:00:00 2001 From: Wang Haoyang Date: Thu, 31 Oct 2024 17:36:27 +0800 Subject: [PATCH 24/64] Implement email verification logic -Implement sending HTTP requests to the backend to complete email verification or resend email verification -Tested to be working --- .../app/(authenticated)/verify-email/page.tsx | 30 +++++++++++-------- .../navigation/unverified-alert.tsx | 8 ++++- 2 files changed, 25 insertions(+), 13 deletions(-) diff --git a/frontend/app/(authenticated)/verify-email/page.tsx b/frontend/app/(authenticated)/verify-email/page.tsx index 6ae34e33..982e31cc 100644 --- a/frontend/app/(authenticated)/verify-email/page.tsx +++ b/frontend/app/(authenticated)/verify-email/page.tsx @@ -14,6 +14,7 @@ import { } from "@/components/ui/card"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { useUserStore } from "@/store/user/user-store-provider"; +import { completeEmailVerificationAuthEmailVerificationPut } from "@/client/services.gen"; export const UNVERIFIED_TIER_ID = 4; export const VERIFY_SUCCESS_DELAY = 1; @@ -36,24 +37,29 @@ export default function VerifyEmail() { }; useEffect(() => { - const timeout = null; - if (code && user?.tier_id === UNVERIFIED_TIER_ID) { + let timeout: NodeJS.Timeout | null = null; + if (code && !user?.verified && user?.tier_id === UNVERIFIED_TIER_ID) { (async () => { - /* const response = await completeEmailVerificationAuthVerifyEmailPut({ + const response = await completeEmailVerificationAuthEmailVerificationPut({ query: { code }, }); // There is some problem where this function runs twice, causing an error // on the second run since the email verification is used. if (response.data) { setIsLoading(false); - const timeout = setTimeout(redirectAfterVerify, VERIFY_SUCCESS_DELAY * 1000); - } */ + timeout = setTimeout(redirectAfterVerify, VERIFY_SUCCESS_DELAY * 1000); + } else if (response.error) { + console.error("Error completing email verification: ", response.error); + } })(); } - if (timeout) { - // Cleanup redirect timeout on unmount of the page - return () => clearTimeout(timeout); - } + + return () => { + if (timeout) { + // Cleanup redirect timeout on unmount of the page + clearTimeout(timeout); + } + }; }, [code, user]); return ( @@ -61,7 +67,7 @@ export default function VerifyEmail() { - {isLoading ? "Verifying your email" : "Verified! Logging you in"} + {isLoading ? "Verifying your email" : "Verified! Redirecting you to Jippy..."} @@ -76,8 +82,8 @@ export default function VerifyEmail() { {isLoading - ? "Hang tight! We're logging you in. This shouldn't take too long." - : "All done! You'll be redirected soon. "} + ? "Hang tight! We're verifying your email. This shouldn't take long." + : "All done! You'll be redirected to Jippy soon. "} {!isLoading && ( { - const onClickResendVerification = () => {}; + const onClickResendVerification = async () => { + const response = await resendVerificationEmailAuthEmailVerificationPost(); + if (response.error) { + console.error("Error while sending new verification email: ", response.error); + } + }; return ( Date: Thu, 31 Oct 2024 17:37:25 +0800 Subject: [PATCH 25/64] Improve email verification check -Check both user.verified and user.tier_id to determine whether a user is verified instead of only checking user.tier_id --- frontend/app/(authenticated)/user/billing/page.tsx | 5 +++-- frontend/components/navigation/mobile/mobile-navbar.tsx | 3 ++- frontend/components/navigation/navbar.tsx | 3 ++- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/frontend/app/(authenticated)/user/billing/page.tsx b/frontend/app/(authenticated)/user/billing/page.tsx index 4d7f4fe3..35dedfdd 100644 --- a/frontend/app/(authenticated)/user/billing/page.tsx +++ b/frontend/app/(authenticated)/user/billing/page.tsx @@ -33,7 +33,8 @@ const getPriceButtonText = ( user: UserPublic | undefined, ) => { const userTierId = user?.tier_id || 1; - if (userTierId === UNVERIFIED_TIER_ID) { + const isUserUnverified = user?.verified === false || userTierId === UNVERIFIED_TIER_ID; + if (isUserUnverified) { return "Not allowed"; } else if (priceTierId == userTierId) { return "Current"; @@ -60,7 +61,7 @@ const Page = () => { const billingPath = usePathname(); const searchParams = useSearchParams(); - const isUserUnverified = user?.tier_id === UNVERIFIED_TIER_ID; + const isUserUnverified = user?.verified === false || user?.tier_id === UNVERIFIED_TIER_ID; let isSuccess = searchParams.get("success") === "true"; let stripeSessionId = searchParams.get("session_id"); let isCancelled = searchParams.get("cancelled") === "true"; diff --git a/frontend/components/navigation/mobile/mobile-navbar.tsx b/frontend/components/navigation/mobile/mobile-navbar.tsx index b4377789..a50ca53e 100644 --- a/frontend/components/navigation/mobile/mobile-navbar.tsx +++ b/frontend/components/navigation/mobile/mobile-navbar.tsx @@ -17,6 +17,7 @@ export const NavItems: NavItem[] = []; function MobileNavbar() { const { isLoggedIn, user } = useUserStore((state) => state); + const isUserVerified = user?.verified === false || user?.tier_id === UNVERIFIED_TIER_ID; return ( // min-h-[84px] max-h-[84px] @@ -56,7 +57,7 @@ function MobileNavbar() { ))} - {isLoggedIn && user?.tier_id === UNVERIFIED_TIER_ID && ( + {isLoggedIn && isUserVerified && (
diff --git a/frontend/components/navigation/navbar.tsx b/frontend/components/navigation/navbar.tsx index bf74b8b2..09b25466 100644 --- a/frontend/components/navigation/navbar.tsx +++ b/frontend/components/navigation/navbar.tsx @@ -23,6 +23,7 @@ export const NavItems: NavItem[] = []; function Navbar() { const { isLoggedIn, user } = useUserStore((state) => state); + const isUserVerified = user?.verified === false || user?.tier_id === UNVERIFIED_TIER_ID; return ( // min-h-[84px] max-h-[84px] @@ -68,7 +69,7 @@ function Navbar() { ))} - {isLoggedIn && user?.tier_id === UNVERIFIED_TIER_ID && ( + {isLoggedIn && isUserVerified && (
From 6b2225b390d3bd78b1f1d82430e0d2af8e7a7a23 Mon Sep 17 00:00:00 2001 From: Wang Haoyang Date: Thu, 31 Oct 2024 17:38:17 +0800 Subject: [PATCH 26/64] Add new query function -Add query helper function for completing email verification --- frontend/queries/user.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/frontend/queries/user.ts b/frontend/queries/user.ts index 2718e4b5..1d00ef16 100644 --- a/frontend/queries/user.ts +++ b/frontend/queries/user.ts @@ -6,6 +6,7 @@ import { import { changePasswordAuthChangePasswordPut, + completeEmailVerificationAuthEmailVerificationPut, getUserAuthSessionGet, updateProfileProfilePut, } from "@/client/services.gen"; @@ -75,3 +76,18 @@ export const useChangePassword = () => { }, }); }; + +export const useCompleteEmailVerification = () => { + return useMutation({ + mutationFn: ({code}: {code: string}) => { + return completeEmailVerificationAuthEmailVerificationPut({ + query: { + code + }, + }); + }, + onError: (error) => { + console.error("Error completing email verification: ", error); + } + }); +} From e24d51af1de3bf493c58b6000f7aab0c7c7b3e94 Mon Sep 17 00:00:00 2001 From: Wang Haoyang Date: Thu, 31 Oct 2024 17:42:19 +0800 Subject: [PATCH 27/64] Implement backend validation check -Raise HTTP exception when user who is already verified attempts to verify their email again --- backend/src/auth/router.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/backend/src/auth/router.py b/backend/src/auth/router.py index 9f01885d..e043b28f 100644 --- a/backend/src/auth/router.py +++ b/backend/src/auth/router.py @@ -134,6 +134,11 @@ def complete_email_verification( selectinload(User.usage), ) ) + + if user.verified and user.tier_id != UNVERIFIED_TIER_ID: + print(f"""ERROR: Attempt to verify email of user with ID {user.id} who is already verified""") + raise HTTPException(HTTPStatus.CONFLICT) + user.verified = True user.tier_id = 1 email_verification.used = True From 36c871ec1d46da22b681af7a67ce8bc26f622d6f Mon Sep 17 00:00:00 2001 From: Wang Haoyang Date: Thu, 31 Oct 2024 18:24:09 +0800 Subject: [PATCH 28/64] Implement already verified check on frontend -Inform the user when they are already verified but click on the email verification link -Also redirect the user back when they are already verified so they're not stuck on the email verification page -Add another check for the HTTP conflict status code returned from the backend just in case the backend deems the user as verified but the frontend still wrongly sent an email verifcation request to the backend --- .../app/(authenticated)/verify-email/page.tsx | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/frontend/app/(authenticated)/verify-email/page.tsx b/frontend/app/(authenticated)/verify-email/page.tsx index 982e31cc..aa767cb3 100644 --- a/frontend/app/(authenticated)/verify-email/page.tsx +++ b/frontend/app/(authenticated)/verify-email/page.tsx @@ -15,6 +15,7 @@ import { import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { useUserStore } from "@/store/user/user-store-provider"; import { completeEmailVerificationAuthEmailVerificationPut } from "@/client/services.gen"; +import { HttpStatusCode } from "axios"; export const UNVERIFIED_TIER_ID = 4; export const VERIFY_SUCCESS_DELAY = 1; @@ -25,6 +26,8 @@ export default function VerifyEmail() { const [isLoading, setIsLoading] = useState(true); const searchParams = useSearchParams(); const code = searchParams.get("code"); + const isUserUnverified = !user?.verified && user?.tier_id === UNVERIFIED_TIER_ID; + const [postVerifyMessage, setPostVerifyMessage] = useState("All done! You'll be redirected to Jippy soon. "); const redirectAfterVerify = () => { if (window.history?.length && window.history.length > 1) { @@ -38,7 +41,7 @@ export default function VerifyEmail() { useEffect(() => { let timeout: NodeJS.Timeout | null = null; - if (code && !user?.verified && user?.tier_id === UNVERIFIED_TIER_ID) { + if (code && isUserUnverified) { (async () => { const response = await completeEmailVerificationAuthEmailVerificationPut({ query: { code }, @@ -48,10 +51,22 @@ export default function VerifyEmail() { if (response.data) { setIsLoading(false); timeout = setTimeout(redirectAfterVerify, VERIFY_SUCCESS_DELAY * 1000); + } else if (response.status == HttpStatusCode.Conflict) { + // User is already verified + setPostVerifyMessage("Relax, you're already verified! :) "); + setIsLoading(false); + timeout = setTimeout(redirectAfterVerify, VERIFY_SUCCESS_DELAY * 1000); + console.log("WARNING: User is already verified"); } else if (response.error) { console.error("Error completing email verification: ", response.error); } })(); + } else if (!isUserUnverified) { + console.log("WARNING: User is already verified"); + // User is already verified, don't make the backend verify again + setPostVerifyMessage("Relax, you're already verified! :) "); + setIsLoading(false); + timeout = setTimeout(redirectAfterVerify, VERIFY_SUCCESS_DELAY * 1000); } return () => { @@ -83,7 +98,7 @@ export default function VerifyEmail() { {isLoading ? "Hang tight! We're verifying your email. This shouldn't take long." - : "All done! You'll be redirected to Jippy soon. "} + : postVerifyMessage} {!isLoading && ( Date: Thu, 31 Oct 2024 18:25:32 +0800 Subject: [PATCH 29/64] Improve email verification redirect -Always redirect the user back to home page after verifying email successfully for simplicity -Also convert the redirect function to an async function since there's no need for the main thread to wait for the redirect --- frontend/app/(authenticated)/verify-email/page.tsx | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/frontend/app/(authenticated)/verify-email/page.tsx b/frontend/app/(authenticated)/verify-email/page.tsx index aa767cb3..35204f3d 100644 --- a/frontend/app/(authenticated)/verify-email/page.tsx +++ b/frontend/app/(authenticated)/verify-email/page.tsx @@ -29,14 +29,9 @@ export default function VerifyEmail() { const isUserUnverified = !user?.verified && user?.tier_id === UNVERIFIED_TIER_ID; const [postVerifyMessage, setPostVerifyMessage] = useState("All done! You'll be redirected to Jippy soon. "); - const redirectAfterVerify = () => { - if (window.history?.length && window.history.length > 1) { - // Redirect the user to their previously accessed page after successful verification - router.back(); - } else { - // User has no previous page in browser history, redirect user to Jippy home page - router.replace("/", { scroll: false }); - } + const redirectAfterVerify = async () => { + // Redirect user to Jippy home page after verifying email + router.replace("/", { scroll: false }); }; useEffect(() => { From 3fb9cf0f6b4c1699d2568e70bf78e940930a3059 Mon Sep 17 00:00:00 2001 From: Wang Haoyang Date: Thu, 31 Oct 2024 19:04:23 +0800 Subject: [PATCH 30/64] Add more validation checks on backend -Invalidate all previous verification codes for a given user every time a new verification link is requested -Move the email-verification endpoint to the authenticated router -Ensure that user requesting for verification now matches the user that previously generated this verification code for security -Check that the given verification code is not already used to prevent anyone from reusing others' code or clicking on old verification links(after requesting for a new verification code) --- backend/src/auth/router.py | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/backend/src/auth/router.py b/backend/src/auth/router.py index e043b28f..794d72df 100644 --- a/backend/src/auth/router.py +++ b/backend/src/auth/router.py @@ -113,17 +113,21 @@ def log_in( return create_token(user, response) -@router.put("/email-verification") +@routerWithAuth.put("/email-verification") def complete_email_verification( + user: Annotated[User, Depends(get_current_user)], code: str, response: Response, session=Depends(get_session), ) -> Token: - email_verification = session.scalars( - select(EmailVerification).where(EmailVerification.code == code) - ).first() - if not email_verification or email_verification.used: + email_verification = session.scalar( + select(EmailVerification).where(EmailVerification.code == code).where(EmailVerification.user_id == user.id) # noqa: E712 + ) + if not email_verification: raise HTTPException(HTTPStatus.NOT_FOUND) + elif email_verification.used: + print(f"""ERROR: Attempt to reuse an old email verification code {code} for user with ID {email_verification.user_id}""") + raise HTTPException(HTTPStatus.BAD_REQUEST) user = session.scalar( select(User) @@ -158,6 +162,14 @@ def resend_verification_email( background_task: BackgroundTasks, session=Depends(get_session), ): + existing_email_verifications = session.scalars( + select(EmailVerification).where(EmailVerification.user_id == user.id) + ) + for email_verification in existing_email_verifications: + email_verification.used = True + session.add(email_verification) + session.commit() + code = str(uuid4()) email_validation = EmailVerification(user_id=user.id, code=code, used=False) session.add(email_validation) From f3c062113d9462e52fbb6a38b052e49f926fa211 Mon Sep 17 00:00:00 2001 From: Wang Haoyang Date: Thu, 31 Oct 2024 19:06:06 +0800 Subject: [PATCH 31/64] Add validation checks to verify email frontend -Check that the user is not using an old verification link or someone else's verification link -Display an error cross icon instead of a tick when the verification code is already used --- .../app/(authenticated)/verify-email/page.tsx | 30 ++++++++++++++----- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/frontend/app/(authenticated)/verify-email/page.tsx b/frontend/app/(authenticated)/verify-email/page.tsx index 35204f3d..00718661 100644 --- a/frontend/app/(authenticated)/verify-email/page.tsx +++ b/frontend/app/(authenticated)/verify-email/page.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from "react"; import { useRouter, useSearchParams } from "next/navigation"; -import { CheckCircleIcon } from "lucide-react"; +import { CheckCircleIcon, CircleX } from "lucide-react"; import { Box } from "@/components/ui/box"; import { @@ -19,6 +19,7 @@ import { HttpStatusCode } from "axios"; export const UNVERIFIED_TIER_ID = 4; export const VERIFY_SUCCESS_DELAY = 1; +export const VERIFY_ERROR_DELAY = 5; export default function VerifyEmail() { const user = useUserStore((store) => store.user); @@ -27,7 +28,9 @@ export default function VerifyEmail() { const searchParams = useSearchParams(); const code = searchParams.get("code"); const isUserUnverified = !user?.verified && user?.tier_id === UNVERIFIED_TIER_ID; - const [postVerifyMessage, setPostVerifyMessage] = useState("All done! You'll be redirected to Jippy soon. "); + const [isVerifySuccess, setIsVerifySuccess] = useState(false); + const [postVerifyTitle, setPostVerifyTitle] = useState("Verified! Redirecting you to Jippy..."); + const [postVerifySubtitle, setPostVerifySubtitle] = useState("All done! You'll be redirected to Jippy soon. "); const redirectAfterVerify = async () => { // Redirect user to Jippy home page after verifying email @@ -44,14 +47,24 @@ export default function VerifyEmail() { // There is some problem where this function runs twice, causing an error // on the second run since the email verification is used. if (response.data) { + setIsVerifySuccess(true); setIsLoading(false); timeout = setTimeout(redirectAfterVerify, VERIFY_SUCCESS_DELAY * 1000); } else if (response.status == HttpStatusCode.Conflict) { // User is already verified - setPostVerifyMessage("Relax, you're already verified! :) "); + setIsVerifySuccess(true); + setPostVerifySubtitle("Relax, you're already verified! :) "); setIsLoading(false); timeout = setTimeout(redirectAfterVerify, VERIFY_SUCCESS_DELAY * 1000); console.log("WARNING: User is already verified"); + } else if (response.status == HttpStatusCode.BadRequest) { + // Email verification has already been used + console.log("ERROR: Reusing old email verification code"); + setIsVerifySuccess(false); + setPostVerifyTitle("Invalid verification link"); + setPostVerifySubtitle("Check your email again! Please click the latest verification link"); + setIsLoading(false); + timeout = setTimeout(redirectAfterVerify, VERIFY_ERROR_DELAY * 1000); } else if (response.error) { console.error("Error completing email verification: ", response.error); } @@ -59,7 +72,8 @@ export default function VerifyEmail() { } else if (!isUserUnverified) { console.log("WARNING: User is already verified"); // User is already verified, don't make the backend verify again - setPostVerifyMessage("Relax, you're already verified! :) "); + setIsVerifySuccess(true); + setPostVerifySubtitle("Relax, you're already verified! :) "); setIsLoading(false); timeout = setTimeout(redirectAfterVerify, VERIFY_SUCCESS_DELAY * 1000); } @@ -77,7 +91,7 @@ export default function VerifyEmail() { - {isLoading ? "Verifying your email" : "Verified! Redirecting you to Jippy..."} + {isLoading ? "Verifying your email" : postVerifyTitle} @@ -86,16 +100,16 @@ export default function VerifyEmail() { {isLoading ? ( ) : ( - + isVerifySuccess ? : )}
{isLoading ? "Hang tight! We're verifying your email. This shouldn't take long." - : postVerifyMessage} + : postVerifySubtitle} - {!isLoading && ( + {!isLoading && isVerifySuccess && ( Date: Thu, 31 Oct 2024 19:18:20 +0800 Subject: [PATCH 32/64] More validation checks on verify email page -Fix having the user stuck on the email verification page when the verification code is invalid -Instead, display the error cross icon and inform the user to check their verification link -In other cases(any other HTTP status code returned from backend), inform the user to try again as a generic error message --- .../app/(authenticated)/verify-email/page.tsx | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/frontend/app/(authenticated)/verify-email/page.tsx b/frontend/app/(authenticated)/verify-email/page.tsx index 00718661..4670f75f 100644 --- a/frontend/app/(authenticated)/verify-email/page.tsx +++ b/frontend/app/(authenticated)/verify-email/page.tsx @@ -66,7 +66,21 @@ export default function VerifyEmail() { setIsLoading(false); timeout = setTimeout(redirectAfterVerify, VERIFY_ERROR_DELAY * 1000); } else if (response.error) { - console.error("Error completing email verification: ", response.error); + if (response.status == HttpStatusCode.NotFound) { + console.error("ERROR: Invalid verification code"); + setIsVerifySuccess(false); + setPostVerifyTitle("Invalid verification link"); + setPostVerifySubtitle("Check whether you entered the correct verification link.\nNote: Never click on a verification link that is not sent by us"); + setIsLoading(false); + timeout = setTimeout(redirectAfterVerify, VERIFY_ERROR_DELAY * 1000); + } else { + console.error("ERROR while verifying email"); + setIsVerifySuccess(false); + setPostVerifyTitle("Verification error"); + setPostVerifySubtitle("We're very sorry, something went wrong while verifying you. Please try again later."); + setIsLoading(false); + timeout = setTimeout(redirectAfterVerify, VERIFY_ERROR_DELAY * 1000); + } } })(); } else if (!isUserUnverified) { @@ -104,7 +118,7 @@ export default function VerifyEmail() { )}
- + {isLoading ? "Hang tight! We're verifying your email. This shouldn't take long." : postVerifySubtitle} From 291daba3e39215bcd96e4fd4bae1c28a92d791af Mon Sep 17 00:00:00 2001 From: Wang Haoyang Date: Thu, 31 Oct 2024 19:45:08 +0800 Subject: [PATCH 33/64] Style the unverified alert better --- .../navigation/mobile/mobile-navbar.tsx | 2 +- frontend/components/navigation/navbar.tsx | 2 +- .../navigation/unverified-alert.tsx | 36 +++++++++++-------- 3 files changed, 24 insertions(+), 16 deletions(-) diff --git a/frontend/components/navigation/mobile/mobile-navbar.tsx b/frontend/components/navigation/mobile/mobile-navbar.tsx index a50ca53e..781775aa 100644 --- a/frontend/components/navigation/mobile/mobile-navbar.tsx +++ b/frontend/components/navigation/mobile/mobile-navbar.tsx @@ -22,7 +22,7 @@ function MobileNavbar() { return ( // min-h-[84px] max-h-[84px]
diff --git a/frontend/components/navigation/navbar.tsx b/frontend/components/navigation/navbar.tsx index 09b25466..ce57fc71 100644 --- a/frontend/components/navigation/navbar.tsx +++ b/frontend/components/navigation/navbar.tsx @@ -28,7 +28,7 @@ function Navbar() { return ( // min-h-[84px] max-h-[84px]
diff --git a/frontend/components/navigation/unverified-alert.tsx b/frontend/components/navigation/unverified-alert.tsx index ecba278b..9966f95d 100644 --- a/frontend/components/navigation/unverified-alert.tsx +++ b/frontend/components/navigation/unverified-alert.tsx @@ -2,11 +2,17 @@ import { CircleAlert } from "lucide-react"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { resendVerificationEmailAuthEmailVerificationPost } from "@/client"; +import { toast } from "@/hooks/use-toast"; const UnverifiedAlert = () => { const onClickResendVerification = async () => { const response = await resendVerificationEmailAuthEmailVerificationPost(); if (response.error) { + toast({ + variant: "destructive", + title: "Error", + description: "Error while resending a new verification email. Please try again" + }); console.error("Error while sending new verification email: ", response.error); } }; @@ -17,21 +23,23 @@ const UnverifiedAlert = () => { role="alert" variant="destructive" > -
- +
+ +
+
+ Email verification + + Please verify your email address with the link sent + to your email.
+ Didn't receive the email?{" "} + + Resend + +
- Email verification - - Please verify your email address by clicking the verification link sent - to your email.
- - Resend - {" "} - verification link? -
); }; From 32c902b2f39766a093ecc7f926cdaf5598e464b5 Mon Sep 17 00:00:00 2001 From: Wang Haoyang Date: Thu, 31 Oct 2024 20:22:01 +0800 Subject: [PATCH 34/64] Improve UnverifiedAlert -Shorten the alert message -Remove alert title -Don't use rounded borders on this Alert -Hide borders on left and right side of this Alert --- .../components/navigation/unverified-alert.tsx | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/frontend/components/navigation/unverified-alert.tsx b/frontend/components/navigation/unverified-alert.tsx index 9966f95d..f304b544 100644 --- a/frontend/components/navigation/unverified-alert.tsx +++ b/frontend/components/navigation/unverified-alert.tsx @@ -19,19 +19,17 @@ const UnverifiedAlert = () => { return ( -
- -
+
+ +
- Email verification - Please verify your email address with the link sent - to your email.
- Didn't receive the email?{" "} + Verify your email with the link we sent + to you. Didn't receive it?{" "} Date: Thu, 31 Oct 2024 20:37:43 +0800 Subject: [PATCH 35/64] Improve UnverifiedAlert overlay -Move UnverifiedAlert to AppLayout component, just above ContentLayout so that the alert is always rendered above the main content(but below the navigation bar) --- frontend/components/layout/app-layout.tsx | 12 ++++++++++-- frontend/components/layout/content-layout.tsx | 8 ++++---- .../navigation/mobile/mobile-navbar.tsx | 14 +++----------- frontend/components/navigation/navbar.tsx | 15 +++------------ 4 files changed, 20 insertions(+), 29 deletions(-) diff --git a/frontend/components/layout/app-layout.tsx b/frontend/components/layout/app-layout.tsx index 474ff73d..6f935cf7 100644 --- a/frontend/components/layout/app-layout.tsx +++ b/frontend/components/layout/app-layout.tsx @@ -11,17 +11,20 @@ import { getUserProfile } from "@/queries/user"; import { useUserStore } from "@/store/user/user-store-provider"; import ContentLayout from "./content-layout"; +import { UNVERIFIED_TIER_ID } from "@/app/(authenticated)/verify-email/page"; +import UnverifiedAlert from "../navigation/unverified-alert"; export const NAVBAR_HEIGHT = 84; const AppLayout = ({ children }: { children: ReactNode }) => { - const { setLoggedIn, setNotLoggedIn, setIsFetching, setIsNotFetching } = + const { isLoggedIn, setLoggedIn, setNotLoggedIn, setIsFetching, setIsNotFetching, user } = useUserStore((state) => state); const { data: userProfile, isSuccess: isUserProfileSuccess, isLoading: isUserProfileLoading, } = useQuery(getUserProfile()); + const isUserVerified = user?.verified === false || user?.tier_id === UNVERIFIED_TIER_ID; useEffect(() => { if (isUserProfileLoading) { @@ -52,7 +55,7 @@ const AppLayout = ({ children }: { children: ReactNode }) => {
{
} > + {isLoggedIn && isUserVerified && ( +
+ +
+ )} {children} diff --git a/frontend/components/layout/content-layout.tsx b/frontend/components/layout/content-layout.tsx index e3306880..a1d30eea 100644 --- a/frontend/components/layout/content-layout.tsx +++ b/frontend/components/layout/content-layout.tsx @@ -43,21 +43,21 @@ const ContentLayout = ({ isLoading, children }: ContentLayoutProps) => { // TODO: fix all loading elements if (isLoading) return ( -
+
); if (isOnboarding) return ( -
+
{children}
); if (!isLoggedIn) return ( -
+
{children}
); @@ -116,7 +116,7 @@ const ContentLayout = ({ isLoading, children }: ContentLayoutProps) => { // For `sm` and `xs` breakpoints don't render anything return (
{children} diff --git a/frontend/components/navigation/mobile/mobile-navbar.tsx b/frontend/components/navigation/mobile/mobile-navbar.tsx index 781775aa..f33c3708 100644 --- a/frontend/components/navigation/mobile/mobile-navbar.tsx +++ b/frontend/components/navigation/mobile/mobile-navbar.tsx @@ -1,10 +1,8 @@ "use client"; -import { UNVERIFIED_TIER_ID } from "@/app/(authenticated)/verify-email/page"; import UserProfileButton from "@/components/auth/user-profile-button"; import { NAVBAR_HEIGHT } from "@/components/layout/app-layout"; import Link from "@/components/navigation/link"; -import UnverifiedAlert from "@/components/navigation/unverified-alert"; import { Button } from "@/components/ui/button"; import JippyIconSm from "@/public/jippy-icon/jippy-icon-sm"; import JippyLogo from "@/public/jippy-logo/jippy-logo-sm"; @@ -16,13 +14,12 @@ import MobileSidebar from "./mobile-sidebar"; export const NavItems: NavItem[] = []; function MobileNavbar() { - const { isLoggedIn, user } = useUserStore((state) => state); - const isUserVerified = user?.verified === false || user?.tier_id === UNVERIFIED_TIER_ID; + const isLoggedIn = useUserStore((state) => state.isLoggedIn); return ( - // min-h-[84px] max-h-[84px] + /* min-h-[84px] max-h-[84px] */
@@ -57,11 +54,6 @@ function MobileNavbar() { ))}
- {isLoggedIn && isUserVerified && ( -
- -
- )}
); } diff --git a/frontend/components/navigation/navbar.tsx b/frontend/components/navigation/navbar.tsx index ce57fc71..f41ad12d 100644 --- a/frontend/components/navigation/navbar.tsx +++ b/frontend/components/navigation/navbar.tsx @@ -7,7 +7,6 @@ import { NavigationMenuList, } from "@radix-ui/react-navigation-menu"; -import { UNVERIFIED_TIER_ID } from "@/app/(authenticated)/verify-email/page"; import UserProfileButton from "@/components/auth/user-profile-button"; import { NAVBAR_HEIGHT } from "@/components/layout/app-layout"; import Link from "@/components/navigation/link"; @@ -17,18 +16,15 @@ import JippyLogo from "@/public/jippy-logo/jippy-logo-sm"; import { useUserStore } from "@/store/user/user-store-provider"; import { NavItem } from "@/types/navigation"; -import UnverifiedAlert from "./unverified-alert"; - export const NavItems: NavItem[] = []; function Navbar() { - const { isLoggedIn, user } = useUserStore((state) => state); - const isUserVerified = user?.verified === false || user?.tier_id === UNVERIFIED_TIER_ID; + const isLoggedIn = useUserStore((state) => state.isLoggedIn); return ( - // min-h-[84px] max-h-[84px] + /* min-h-[84px] max-h-[84px] */
@@ -69,11 +65,6 @@ function Navbar() { ))}
- {isLoggedIn && isUserVerified && ( -
- -
- )}
); } From 14739843bf0ef74b536cb8d164bcda19b874040a Mon Sep 17 00:00:00 2001 From: Wang Haoyang Date: Thu, 31 Oct 2024 23:34:36 +0800 Subject: [PATCH 36/64] Add more variables/functions to types/billing -Add more useful constants and helper functions to types/billing.tsx on frontend -This would be future for future components to reduce hardcoding --- frontend/types/billing.ts | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/frontend/types/billing.ts b/frontend/types/billing.ts index 9deb70f1..0a855959 100644 --- a/frontend/types/billing.ts +++ b/frontend/types/billing.ts @@ -10,12 +10,43 @@ export enum JippyTierID { Enterprise = 3, } +export enum JippyTierStatus { + Active = "active", + Paused = "paused", + Cancelled = "cancelled", + Expired = "expired", + Unknown = "", +} + +export enum SubscriptionPeriod { + Month = "month", + Year = "year", +} + export enum TierPrice { Free = 0, Premium = 3.49, Enterprise = 26.18, } +/** + * Parses a Stripe subscription status and returns a user-friendly Jippy tier status string + */ +export const stripeSubscriptionStatusToTierStatus = (status: string): JippyTierStatus => { + switch (status) { + case "active": + return JippyTierStatus.Active; + case "paused": + return JippyTierStatus.Paused; + case "canceled": + return JippyTierStatus.Cancelled; + case "past_due": + return JippyTierStatus.Expired; + default: + return JippyTierStatus.Unknown; + } +}; + export const tierIDToTierName = (tierID: JippyTierID): string => { switch (tierID) { case JippyTierID.Free: From fbc3a340c2580f5e4f05e4e694efd4a96fd96125 Mon Sep 17 00:00:00 2001 From: Wang Haoyang Date: Thu, 31 Oct 2024 23:32:59 +0800 Subject: [PATCH 37/64] Add new SubscriptionCard component -Intended replacement for the current 'Your Tier' section on billing page -Future intention is to somehow extend this component to support reminding the user to verify their email(before obtaining free tier status) --- .../components/billing/subscription-card.tsx | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 frontend/components/billing/subscription-card.tsx diff --git a/frontend/components/billing/subscription-card.tsx b/frontend/components/billing/subscription-card.tsx new file mode 100644 index 00000000..b21609c1 --- /dev/null +++ b/frontend/components/billing/subscription-card.tsx @@ -0,0 +1,65 @@ +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import JippyIconMd from "@/public/jippy-icon/jippy-icon-md"; +import { stripeSubscriptionStatusToTierStatus, SubscriptionPeriod } from "@/types/billing"; +import { Button } from "@/components/ui/button"; +import Chip from "@/components/display/chip"; + +export interface SubscriptionInfo { + currentTierName: string; + // Montly price in dollars + tierPrice: number; + tierStatus: string; + tierSubscriptionPeriod: SubscriptionPeriod; + tierEndDate?: Date; + actionDescription?: string; + onClickAction?: () => void; +}; + +const toPascalCase = (string: string) => { + return string.charAt(0).toUpperCase() + string.slice(1); +}; + +const SubscriptionCard = ({currentTierName, tierPrice, tierStatus, tierSubscriptionPeriod, tierEndDate, actionDescription, onClickAction}: SubscriptionInfo) => { + return ( + + + + + Your Jippy + + +
+ {currentTierName} Tier + +
+ { actionDescription && onClickAction && + + } +
+
+
+ +
+ ${tierPrice} +  per  + {tierSubscriptionPeriod} +
+ { tierEndDate && +
+ Renews + {tierEndDate.toLocaleDateString()} +
+ } +
+
+ ); +}; + +export default SubscriptionCard; From 16ed11051d375018d2caf89a5880da613134cd11 Mon Sep 17 00:00:00 2001 From: Wang Haoyang Date: Thu, 31 Oct 2024 23:36:23 +0800 Subject: [PATCH 38/64] Replace 'Your Tier' section -Replace 'Your Tier' section of billing page with the new SubscriptionCard component -This should present a more Apple-like UI for the subscription status, at least on mobile -TODO: Improve desktop look of the SubscriptionCard component --- .../app/(authenticated)/user/billing/page.tsx | 39 +++++++++---------- 1 file changed, 18 insertions(+), 21 deletions(-) diff --git a/frontend/app/(authenticated)/user/billing/page.tsx b/frontend/app/(authenticated)/user/billing/page.tsx index 35dedfdd..217d4b9d 100644 --- a/frontend/app/(authenticated)/user/billing/page.tsx +++ b/frontend/app/(authenticated)/user/billing/page.tsx @@ -19,11 +19,13 @@ import { useUserStore } from "@/store/user/user-store-provider"; import { JippyTier, JippyTierID, + SubscriptionPeriod, tierIDToTierDescription, tierIDToTierFeature, tierIDToTierName, TierPrice, } from "@/types/billing"; +import SubscriptionCard from "@/components/billing/subscription-card"; const FREE_TIER_ID = 1; const TIER_STATUS_ACTIVE = "active"; @@ -62,6 +64,7 @@ const Page = () => { const billingPath = usePathname(); const searchParams = useSearchParams(); const isUserUnverified = user?.verified === false || user?.tier_id === UNVERIFIED_TIER_ID; + const hasSubscription = user?.tier_id != JippyTierID.Free && user?.tier_id !== 4; let isSuccess = searchParams.get("success") === "true"; let stripeSessionId = searchParams.get("session_id"); let isCancelled = searchParams.get("cancelled") === "true"; @@ -74,6 +77,13 @@ const Page = () => { // Display payment status toast for 5 secs const PAYMENT_TOAST_DURATION = 5000; + const getDateFrom = (dateString: string | null | undefined) => { + if (dateString) { + return new Date(dateString); + } + return undefined; + } + const onClickDowngradeSubscription = () => { downgradeSubscription.mutate(); }; @@ -165,30 +175,17 @@ const Page = () => {
-

Your Tier

{isUserUnverified ? ( ) : ( - <> -
-

{userTier} Tier:

- -
- {user?.subscription && ( - - )} - + )}
From ae86177736f6e0d61ff85871114f19a9e24f60a6 Mon Sep 17 00:00:00 2001 From: Wang Haoyang Date: Thu, 31 Oct 2024 23:43:29 +0800 Subject: [PATCH 39/64] Limit max width of SubscriptionCard -Don't indefinitely stretch the width of the SubscriptionCard component as the viewport gets wider -Set max width of 400px --- frontend/components/billing/subscription-card.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/frontend/components/billing/subscription-card.tsx b/frontend/components/billing/subscription-card.tsx index b21609c1..8a5796a1 100644 --- a/frontend/components/billing/subscription-card.tsx +++ b/frontend/components/billing/subscription-card.tsx @@ -4,6 +4,8 @@ import { stripeSubscriptionStatusToTierStatus, SubscriptionPeriod } from "@/type import { Button } from "@/components/ui/button"; import Chip from "@/components/display/chip"; +const MAX_CARD_HEIGHT_PX = 400; + export interface SubscriptionInfo { currentTierName: string; // Montly price in dollars @@ -21,7 +23,7 @@ const toPascalCase = (string: string) => { const SubscriptionCard = ({currentTierName, tierPrice, tierStatus, tierSubscriptionPeriod, tierEndDate, actionDescription, onClickAction}: SubscriptionInfo) => { return ( - + From 1aea79ec9035e714cb11aaa265fa8def31159555 Mon Sep 17 00:00:00 2001 From: Wang Haoyang Date: Thu, 31 Oct 2024 23:45:56 +0800 Subject: [PATCH 40/64] Add TODO comment -Add comment about an idea for future extension of the SubscriptionCard component --- frontend/components/billing/subscription-card.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/components/billing/subscription-card.tsx b/frontend/components/billing/subscription-card.tsx index 8a5796a1..01248370 100644 --- a/frontend/components/billing/subscription-card.tsx +++ b/frontend/components/billing/subscription-card.tsx @@ -26,6 +26,7 @@ const SubscriptionCard = ({currentTierName, tierPrice, tierStatus, tierSubscript + {/* TODO: Consider one day making an icon just for Jippy-branded subscriptions */} Your Jippy From 95e91f9f6a0c3fbdaa1281b63d691baa50d291ac Mon Sep 17 00:00:00 2001 From: Wang Haoyang Date: Fri, 1 Nov 2024 00:36:00 +0800 Subject: [PATCH 41/64] Refactor code in SubscriptionCard -Refactor duplicate logic into a new sub-component SubscriptionDetail -This improves code readability --- .../components/billing/subscription-card.tsx | 28 +++++++++++++------ 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/frontend/components/billing/subscription-card.tsx b/frontend/components/billing/subscription-card.tsx index 01248370..af19876c 100644 --- a/frontend/components/billing/subscription-card.tsx +++ b/frontend/components/billing/subscription-card.tsx @@ -3,6 +3,7 @@ import JippyIconMd from "@/public/jippy-icon/jippy-icon-md"; import { stripeSubscriptionStatusToTierStatus, SubscriptionPeriod } from "@/types/billing"; import { Button } from "@/components/ui/button"; import Chip from "@/components/display/chip"; +import { CalendarIcon, CircleDollarSignIcon } from "lucide-react"; const MAX_CARD_HEIGHT_PX = 400; @@ -21,6 +22,17 @@ const toPascalCase = (string: string) => { return string.charAt(0).toUpperCase() + string.slice(1); }; +const SubscriptionDetail = ({DetailIcon, detailDescription}: { DetailIcon: React.ComponentType>, detailDescription: string }) => { + return ( +
+ +
+ {detailDescription} +
+
+ ) +} + const SubscriptionCard = ({currentTierName, tierPrice, tierStatus, tierSubscriptionPeriod, tierEndDate, actionDescription, onClickAction}: SubscriptionInfo) => { return ( @@ -49,16 +61,14 @@ const SubscriptionCard = ({currentTierName, tierPrice, tierStatus, tierSubscript

-
- ${tierPrice} -  per  - {tierSubscriptionPeriod} -
+ { tierEndDate && -
- Renews - {tierEndDate.toLocaleDateString()} -
+ }
From 76a0f9a21ee32c9f75ccf189332e283f82b3a5f2 Mon Sep 17 00:00:00 2001 From: Wang Haoyang Date: Fri, 1 Nov 2024 00:37:51 +0800 Subject: [PATCH 42/64] Improve element alignment -Center align elements in SubscriptionCard's CardDescription --- frontend/components/billing/subscription-card.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/components/billing/subscription-card.tsx b/frontend/components/billing/subscription-card.tsx index af19876c..f1e420dd 100644 --- a/frontend/components/billing/subscription-card.tsx +++ b/frontend/components/billing/subscription-card.tsx @@ -43,7 +43,7 @@ const SubscriptionCard = ({currentTierName, tierPrice, tierStatus, tierSubscript Your Jippy
-
+
{currentTierName} Tier Date: Fri, 1 Nov 2024 00:39:37 +0800 Subject: [PATCH 43/64] Improve element alignment again -Set height contraint of CardDescription element in SubscriptionCard component to fit content --- frontend/components/billing/subscription-card.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/components/billing/subscription-card.tsx b/frontend/components/billing/subscription-card.tsx index f1e420dd..bae7ee79 100644 --- a/frontend/components/billing/subscription-card.tsx +++ b/frontend/components/billing/subscription-card.tsx @@ -42,7 +42,7 @@ const SubscriptionCard = ({currentTierName, tierPrice, tierStatus, tierSubscript Your Jippy - +
{currentTierName} Tier Date: Fri, 1 Nov 2024 00:57:49 +0800 Subject: [PATCH 44/64] Add helper function to frontend -Add new tierIDToPrice() helper function to convert user.tier_id to their corresponding numeric prices --- frontend/types/billing.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/frontend/types/billing.ts b/frontend/types/billing.ts index 0a855959..1e009b36 100644 --- a/frontend/types/billing.ts +++ b/frontend/types/billing.ts @@ -47,6 +47,19 @@ export const stripeSubscriptionStatusToTierStatus = (status: string): JippyTierS } }; +export const tierIDToPrice = (tierID: JippyTierID): number => { + switch (tierID) { + case JippyTierID.Free: + return TierPrice.Free; + case JippyTierID.Premium: + return TierPrice.Premium; + case JippyTierID.Enterprise: + return TierPrice.Enterprise; + default: + return 0; + } +}; + export const tierIDToTierName = (tierID: JippyTierID): string => { switch (tierID) { case JippyTierID.Free: From ac30fd56960a1c53785569e3e167cd74d6424729 Mon Sep 17 00:00:00 2001 From: Wang Haoyang Date: Fri, 1 Nov 2024 00:58:54 +0800 Subject: [PATCH 45/64] Significantly refactor SubscriptionCard -Greatly simplify the use of SubscriptionCard by only taking 1 prop now -Implement internal parsing logic to obtain the various variables required to display the subscription details --- .../components/billing/subscription-card.tsx | 56 ++++++++++++++----- 1 file changed, 42 insertions(+), 14 deletions(-) diff --git a/frontend/components/billing/subscription-card.tsx b/frontend/components/billing/subscription-card.tsx index bae7ee79..1fd6c2cc 100644 --- a/frontend/components/billing/subscription-card.tsx +++ b/frontend/components/billing/subscription-card.tsx @@ -1,21 +1,27 @@ +"use client"; + import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import JippyIconMd from "@/public/jippy-icon/jippy-icon-md"; -import { stripeSubscriptionStatusToTierStatus, SubscriptionPeriod } from "@/types/billing"; +import { JippyTierID, stripeSubscriptionStatusToTierStatus, SubscriptionPeriod, tierIDToPrice, tierIDToTierName } from "@/types/billing"; import { Button } from "@/components/ui/button"; import Chip from "@/components/display/chip"; import { CalendarIcon, CircleDollarSignIcon } from "lucide-react"; +import { UserPublic } from "@/client/types.gen"; +import { useMemo } from "react"; +import { useCreateStripeCustomerPortalSession } from "@/queries/billing"; const MAX_CARD_HEIGHT_PX = 400; +const TIER_STATUS_ACTIVE = "active"; export interface SubscriptionInfo { - currentTierName: string; - // Montly price in dollars - tierPrice: number; - tierStatus: string; - tierSubscriptionPeriod: SubscriptionPeriod; - tierEndDate?: Date; - actionDescription?: string; - onClickAction?: () => void; + user: UserPublic | undefined; +}; + +const getDateFrom = (dateString: string | null | undefined) => { + if (dateString) { + return new Date(dateString); + } + return undefined; }; const toPascalCase = (string: string) => { @@ -33,7 +39,29 @@ const SubscriptionDetail = ({DetailIcon, detailDescription}: { DetailIcon: React ) } -const SubscriptionCard = ({currentTierName, tierPrice, tierStatus, tierSubscriptionPeriod, tierEndDate, actionDescription, onClickAction}: SubscriptionInfo) => { +const SubscriptionCard = ({user}: SubscriptionInfo) => { + const currentTierName = useMemo(() => { + return tierIDToTierName(user?.tier_id || JippyTierID.Free); + }, [user?.tier_id]); + const tierEndDate = useMemo(() => { + return getDateFrom(user?.subscription?.subscription_ended_date || user?.subscription?.subscription_period_end); + }, [user?.subscription]); + const tierStatus = useMemo(() => { + return toPascalCase(stripeSubscriptionStatusToTierStatus(user?.subscription ? user.subscription.status : TIER_STATUS_ACTIVE)); + }, [user?.subscription]); + const tierPrice = useMemo(() => { + return tierIDToPrice(user?.tier_id || JippyTierID.Free); + }, [user?.tier_id]); + // TODO: Dynamically fetch the subscription period from Stripe if we ever support annual subscriptions + const tierSubscriptionPeriod = SubscriptionPeriod.Month; + const actionDescription = "Manage Subscription"; + + const stripeCustomerPortalMutation = useCreateStripeCustomerPortalSession(); + + const onClickManageSubscription = () => { + stripeCustomerPortalMutation.mutate(); + }; + return ( @@ -47,14 +75,14 @@ const SubscriptionCard = ({currentTierName, tierPrice, tierStatus, tierSubscript {currentTierName} Tier
- { actionDescription && onClickAction && - }
From 4f3eb65725c294e733a5e9c878ecf4e0353a9ce4 Mon Sep 17 00:00:00 2001 From: Wang Haoyang Date: Fri, 1 Nov 2024 00:59:15 +0800 Subject: [PATCH 46/64] Update billing page -Update billing page after the SubscriptionCard component update --- frontend/app/(authenticated)/user/billing/page.tsx | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/frontend/app/(authenticated)/user/billing/page.tsx b/frontend/app/(authenticated)/user/billing/page.tsx index 217d4b9d..16ed19c2 100644 --- a/frontend/app/(authenticated)/user/billing/page.tsx +++ b/frontend/app/(authenticated)/user/billing/page.tsx @@ -179,13 +179,7 @@ const Page = () => { ) : ( + user={user} /> )}
From 7b3d00305b124f5101f272ce3fa092d16c24408d Mon Sep 17 00:00:00 2001 From: Wang Haoyang Date: Fri, 1 Nov 2024 04:23:22 +0800 Subject: [PATCH 47/64] Augment SubscriptionCard component -Implement conditional rendering for unverified user -Now the card becomes half disabled and is themed red with an Alert warning to strongly remind the user to verify their email --- .../components/billing/subscription-card.tsx | 81 ++++++++++++------- 1 file changed, 52 insertions(+), 29 deletions(-) diff --git a/frontend/components/billing/subscription-card.tsx b/frontend/components/billing/subscription-card.tsx index 1fd6c2cc..f302706c 100644 --- a/frontend/components/billing/subscription-card.tsx +++ b/frontend/components/billing/subscription-card.tsx @@ -2,16 +2,18 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import JippyIconMd from "@/public/jippy-icon/jippy-icon-md"; -import { JippyTierID, stripeSubscriptionStatusToTierStatus, SubscriptionPeriod, tierIDToPrice, tierIDToTierName } from "@/types/billing"; +import { JippyTier, JippyTierID, stripeSubscriptionStatusToTierStatus, SubscriptionPeriod, tierIDToPrice, tierIDToTierName } from "@/types/billing"; import { Button } from "@/components/ui/button"; import Chip from "@/components/display/chip"; -import { CalendarIcon, CircleDollarSignIcon } from "lucide-react"; +import { CalendarIcon, CircleAlert, CircleDollarSignIcon } from "lucide-react"; import { UserPublic } from "@/client/types.gen"; import { useMemo } from "react"; import { useCreateStripeCustomerPortalSession } from "@/queries/billing"; +import { Alert, AlertDescription } from "../ui/alert"; const MAX_CARD_HEIGHT_PX = 400; const TIER_STATUS_ACTIVE = "active"; +const UNVERIFIED_TIER_ID = 4; export interface SubscriptionInfo { user: UserPublic | undefined; @@ -56,6 +58,8 @@ const SubscriptionCard = ({user}: SubscriptionInfo) => { const tierSubscriptionPeriod = SubscriptionPeriod.Month; const actionDescription = "Manage Subscription"; + const isUserUnverified = user?.verified === false || user?.tier_id === UNVERIFIED_TIER_ID; + const stripeCustomerPortalMutation = useCreateStripeCustomerPortalSession(); const onClickManageSubscription = () => { @@ -63,7 +67,7 @@ const SubscriptionCard = ({user}: SubscriptionInfo) => { }; return ( - + {/* TODO: Consider one day making an icon just for Jippy-branded subscriptions */} @@ -71,34 +75,53 @@ const SubscriptionCard = ({user}: SubscriptionInfo) => { Your Jippy -
- {currentTierName} Tier - -
- { user?.subscription && - - } + {!isUserUnverified ? ( + <> +
+ {currentTierName} Tier + +
+ { user?.subscription && + + } + + ) : ( +
+ {JippyTier.Free} Tier + +
+ +
+ + Unverified email. Verify your email now to enjoy full {JippyTier.Free} Tier access. + +
+
+ )}
-
- - - { tierEndDate && - - } - + { !isUserUnverified && ( + <> +
+ + + {tierEndDate && + + } + + + )}
); }; From f50f7e0cdef01e7926b17c4ed37dae6608353c98 Mon Sep 17 00:00:00 2001 From: Wang Haoyang Date: Fri, 1 Nov 2024 04:24:07 +0800 Subject: [PATCH 48/64] Switch billing page to SubscriptionCard competely -Stop conditionally rendering the 'Your Tier' section and rely on the conditional rendering inside SubscriptionCard instead --- frontend/app/(authenticated)/user/billing/page.tsx | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/frontend/app/(authenticated)/user/billing/page.tsx b/frontend/app/(authenticated)/user/billing/page.tsx index 16ed19c2..012542d7 100644 --- a/frontend/app/(authenticated)/user/billing/page.tsx +++ b/frontend/app/(authenticated)/user/billing/page.tsx @@ -175,12 +175,8 @@ const Page = () => {
- {isUserUnverified ? ( - - ) : ( - - )} +

Our Tiers

From cfc9ca9a8766ea38afa6f2ecd5947a792f2fbbd0 Mon Sep 17 00:00:00 2001 From: Wang Haoyang Date: Fri, 1 Nov 2024 05:05:59 +0800 Subject: [PATCH 49/64] Remove hardcoded date from frontend -Update ask question page to render the next Mon's date instead of hardcoding a specific date --- frontend/app/(authenticated)/ask/ask-page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/app/(authenticated)/ask/ask-page.tsx b/frontend/app/(authenticated)/ask/ask-page.tsx index c3edc84e..c7d2e339 100644 --- a/frontend/app/(authenticated)/ask/ask-page.tsx +++ b/frontend/app/(authenticated)/ask/ask-page.tsx @@ -135,7 +135,7 @@ const AskPage = ({ setIsLoading, isLoading }: AskPageProps) => { You have {triesLeft}{" "} {triesLeft === 1 ? "question" : "questions"} left. It will - reset on 4 Nov 2024 12:00AM. + reset on {toQueryDateFriendly(getNextMonday())} 12:00AM. ) : ( From cba4a7815bca9f90700b834be3fba87bbc9e2b41 Mon Sep 17 00:00:00 2001 From: Wang Haoyang Date: Fri, 1 Nov 2024 05:15:02 +0800 Subject: [PATCH 50/64] Refactor component on ask page -Refactor reused logic into a separate component for easier maintenance and code readability --- frontend/app/(authenticated)/ask/ask-page.tsx | 49 +++++++++---------- 1 file changed, 24 insertions(+), 25 deletions(-) diff --git a/frontend/app/(authenticated)/ask/ask-page.tsx b/frontend/app/(authenticated)/ask/ask-page.tsx index c7d2e339..54e5939c 100644 --- a/frontend/app/(authenticated)/ask/ask-page.tsx +++ b/frontend/app/(authenticated)/ask/ask-page.tsx @@ -37,6 +37,22 @@ interface AskPageProps { isLoading: boolean; } +const LimitAlert = ({ triesLeft, warningText, isRedAlert }: { triesLeft: number, warningText: string, isRedAlert: boolean }) => { + return ( + +
+ +
+ + {warningText} + +
+ ) +}; + const AskPage = ({ setIsLoading, isLoading }: AskPageProps) => { const router = useRouter(); const [questionInput, setQuestionInput] = useState(""); @@ -125,32 +141,15 @@ const AskPage = ({ setIsLoading, isLoading }: AskPageProps) => { Ask Jippy a General Paper exam question {hasTriesLeft ? ( - -
- -
- - You have {triesLeft}{" "} - {triesLeft === 1 ? "question" : "questions"} left. It will - reset on {toQueryDateFriendly(getNextMonday())} 12:00AM. - -
+ ) : ( - -
- -
- - You've reached the question limit. It will reset on{" "} - {toQueryDateFriendly(getNextMonday())} 12:00AM. - -
+ )} {errorMsg && ( From 11867aa30dcd4d53e02ab09317320608d7d0ef4c Mon Sep 17 00:00:00 2001 From: Wang Haoyang Date: Fri, 1 Nov 2024 05:27:19 +0800 Subject: [PATCH 51/64] Implement user unverified alert (1/2) -Use the existing LimitAlert to warn the user to verify their email when the user is still unverified -Use conditional rendering to display the actual, existing limit alert logic when the user is already verified --- frontend/app/(authenticated)/ask/ask-page.tsx | 44 ++++++++----------- 1 file changed, 18 insertions(+), 26 deletions(-) diff --git a/frontend/app/(authenticated)/ask/ask-page.tsx b/frontend/app/(authenticated)/ask/ask-page.tsx index 54e5939c..744a4aae 100644 --- a/frontend/app/(authenticated)/ask/ask-page.tsx +++ b/frontend/app/(authenticated)/ask/ask-page.tsx @@ -37,7 +37,7 @@ interface AskPageProps { isLoading: boolean; } -const LimitAlert = ({ triesLeft, warningText, isRedAlert }: { triesLeft: number, warningText: string, isRedAlert: boolean }) => { +const LimitAlert = ({ warningText, isRedAlert }: { warningText: string, isRedAlert: boolean }) => { return ( { const [errorMsg, setErrorMsg] = useState(null); const user = useUserStore((store) => store.user); const setLoggedIn = useUserStore((store) => store.setLoggedIn); + const isUserUnverified = user?.verified === false || user?.tier_id === UNVERIFIED_TIER_ID; if (!user) { // This should be impossible. @@ -140,35 +141,25 @@ const AskPage = ({ setIsLoading, isLoading }: AskPageProps) => {

Ask Jippy a General Paper exam question

- {hasTriesLeft ? ( + {isUserUnverified ? ( - ) : ( - - )} - - {errorMsg && ( - -
- -
- - {errorMsg} - -
+ ) : ( + hasTriesLeft ? ( + + ) : ( + + ) )}
setQuestionInput(event.target.value)} @@ -177,7 +168,8 @@ const AskPage = ({ setIsLoading, isLoading }: AskPageProps) => { />
{paragraphs.map((paragraph, index) => ( @@ -185,10 +206,22 @@ const EssayFeedbackPage = () => { anything else than providing you feedback.
+ {isUserUnverified && !errorMessage && ( +
+ +
+ +
+ + Verify your email to gain access to essay feedback. + +
+
+ )}
-
+
{ { { )} />
- From 471445f8bed5e044f6f130fedd9062e87aee52a3 Mon Sep 17 00:00:00 2001 From: Wang Haoyang Date: Fri, 1 Nov 2024 05:34:32 +0800 Subject: [PATCH 54/64] Improve alignment on feedback page -Improve alignment of the unverified alert by adding margin above it --- frontend/app/(authenticated)/essay-feedback/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/app/(authenticated)/essay-feedback/page.tsx b/frontend/app/(authenticated)/essay-feedback/page.tsx index 23f750b5..6ff34e14 100644 --- a/frontend/app/(authenticated)/essay-feedback/page.tsx +++ b/frontend/app/(authenticated)/essay-feedback/page.tsx @@ -148,7 +148,7 @@ const EssayFeedbackPage = () => { give you accurate and meaningful feedback! {errorMessage && ( - +
From c6210e33ac4ee9debf03502eb53fd27ca1e99ef8 Mon Sep 17 00:00:00 2001 From: Wang Haoyang Date: Fri, 1 Nov 2024 05:38:20 +0800 Subject: [PATCH 55/64] Remove red background -Update LimitAlert component on ask page to remove red background -This standardises the background color(none) of the alert components we have so far --- frontend/app/(authenticated)/ask/ask-page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/app/(authenticated)/ask/ask-page.tsx b/frontend/app/(authenticated)/ask/ask-page.tsx index 744a4aae..c3fb4855 100644 --- a/frontend/app/(authenticated)/ask/ask-page.tsx +++ b/frontend/app/(authenticated)/ask/ask-page.tsx @@ -40,7 +40,7 @@ interface AskPageProps { const LimitAlert = ({ warningText, isRedAlert }: { warningText: string, isRedAlert: boolean }) => { return (
From aaf8b203f85d17d0178c9253238263f95a8d9a37 Mon Sep 17 00:00:00 2001 From: Wang Haoyang Date: Fri, 1 Nov 2024 05:41:53 +0800 Subject: [PATCH 56/64] Remove redefined backend endpoint -Probably a merge conflict that was resolved wrongly --- backend/src/auth/router.py | 58 ++++++-------------------------------- 1 file changed, 9 insertions(+), 49 deletions(-) diff --git a/backend/src/auth/router.py b/backend/src/auth/router.py index bda15b6d..5809b918 100644 --- a/backend/src/auth/router.py +++ b/backend/src/auth/router.py @@ -119,12 +119,16 @@ def complete_email_verification( session=Depends(get_session), ) -> Token: email_verification = session.scalar( - select(EmailVerification).where(EmailVerification.code == code).where(EmailVerification.user_id == user.id) # noqa: E712 + select(EmailVerification) + .where(EmailVerification.code == code) + .where(EmailVerification.user_id == user.id) # noqa: E712 ) if not email_verification: raise HTTPException(HTTPStatus.NOT_FOUND) elif email_verification.used: - print(f"""ERROR: Attempt to reuse an old email verification code {code} for user with ID {email_verification.user_id}""") + print( + f"""ERROR: Attempt to reuse an old email verification code {code} for user with ID {email_verification.user_id}""" + ) raise HTTPException(HTTPStatus.BAD_REQUEST) user = session.scalar( @@ -138,7 +142,9 @@ def complete_email_verification( ) if user.verified and user.tier_id != UNVERIFIED_TIER_ID: - print(f"""ERROR: Attempt to verify email of user with ID {user.id} who is already verified""") + print( + f"""ERROR: Attempt to verify email of user with ID {user.id} who is already verified""" + ) raise HTTPException(HTTPStatus.CONFLICT) user.verified = True @@ -237,52 +243,6 @@ def auth_google( return token -####################### -# password reset # -####################### -@router.post("/password-reset") -def request_password_reset( - data: PasswordResetRequestData, - background_task: BackgroundTasks, - session=Depends(get_session), -): - email = data.email - user = session.scalars( - select(User) - .where(User.email == email) - .where(User.account_type == AccountType.NORMAL) - ).first() - if not user: - return - - code = str(uuid4()) - password_reset = PasswordReset(user_id=user.id, code=code, used=False) - session.add(password_reset) - session.commit() - background_task.add_task(send_reset_password_email, email, code) - - -@router.put("/password-reset") -def complete_password_reset( - code: str, - data: PasswordResetCompleteData, - session=Depends(get_session), -): - # 9b90a1bd-ccab-4dcb-93c9-9ef2367dbcc4 - password_reset = session.scalars( - select(PasswordReset).where(PasswordReset.code == code) - ).first() - if not password_reset or password_reset.used: - raise HTTPException(HTTPStatus.NOT_FOUND) - - user = session.get(User, password_reset.user_id) - user.hashed_password = get_password_hash(data.password) - password_reset.used = True - session.add(user) - session.add(password_reset) - session.commit() - - ####################### # Reset password # ####################### From cf30953e461a154f70a0b5513a174f17dfd8d41a Mon Sep 17 00:00:00 2001 From: Wang Haoyang Date: Fri, 1 Nov 2024 05:42:19 +0800 Subject: [PATCH 57/64] Format frontend with eslint --- frontend/app/(authenticated)/ask/ask-page.tsx | 49 ++-- .../(authenticated)/essay-feedback/page.tsx | 62 +++-- .../app/(authenticated)/user/billing/page.tsx | 16 +- .../app/(authenticated)/verify-email/page.tsx | 58 ++-- .../components/billing/subscription-card.tsx | 247 ++++++++++-------- frontend/components/layout/app-layout.tsx | 17 +- .../navigation/unverified-alert.tsx | 27 +- 7 files changed, 302 insertions(+), 174 deletions(-) diff --git a/frontend/app/(authenticated)/ask/ask-page.tsx b/frontend/app/(authenticated)/ask/ask-page.tsx index c3fb4855..7f234706 100644 --- a/frontend/app/(authenticated)/ask/ask-page.tsx +++ b/frontend/app/(authenticated)/ask/ask-page.tsx @@ -37,20 +37,30 @@ interface AskPageProps { isLoading: boolean; } -const LimitAlert = ({ warningText, isRedAlert }: { warningText: string, isRedAlert: boolean }) => { +const LimitAlert = ({ + warningText, + isRedAlert, +}: { + warningText: string; + isRedAlert: boolean; +}) => { return (
- +
- + {warningText}
- ) + ); }; const AskPage = ({ setIsLoading, isLoading }: AskPageProps) => { @@ -60,7 +70,8 @@ const AskPage = ({ setIsLoading, isLoading }: AskPageProps) => { const [errorMsg, setErrorMsg] = useState(null); const user = useUserStore((store) => store.user); const setLoggedIn = useUserStore((store) => store.setLoggedIn); - const isUserUnverified = user?.verified === false || user?.tier_id === UNVERIFIED_TIER_ID; + const isUserUnverified = + user?.verified === false || user?.tier_id === UNVERIFIED_TIER_ID; if (!user) { // This should be impossible. @@ -143,18 +154,22 @@ const AskPage = ({ setIsLoading, isLoading }: AskPageProps) => { {isUserUnverified ? ( + isRedAlert={true} + warningText="Verify your email to start asking essay questions." /> + /> + ) : hasTriesLeft ? ( + ) : ( - hasTriesLeft ? ( - - ) : ( - - ) + + /> )}
{ />
{isUserUnverified && !errorMessage && (
- +
@@ -221,7 +245,9 @@ const EssayFeedbackPage = () => {
-
+
{ @@ -246,8 +272,8 @@ const EssayFeedbackPage = () => { @@ -257,7 +283,13 @@ const EssayFeedbackPage = () => { )} />
- diff --git a/frontend/app/(authenticated)/user/billing/page.tsx b/frontend/app/(authenticated)/user/billing/page.tsx index 012542d7..03f24958 100644 --- a/frontend/app/(authenticated)/user/billing/page.tsx +++ b/frontend/app/(authenticated)/user/billing/page.tsx @@ -6,6 +6,7 @@ import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { UNVERIFIED_TIER_ID } from "@/app/(authenticated)/verify-email/page"; import { UserPublic } from "@/client"; import PricingTable from "@/components/billing/pricing-table"; +import SubscriptionCard from "@/components/billing/subscription-card"; import Chip from "@/components/display/chip"; import UnverifiedAlert from "@/components/navigation/unverified-alert"; import { Button } from "@/components/ui/button"; @@ -25,7 +26,6 @@ import { tierIDToTierName, TierPrice, } from "@/types/billing"; -import SubscriptionCard from "@/components/billing/subscription-card"; const FREE_TIER_ID = 1; const TIER_STATUS_ACTIVE = "active"; @@ -35,7 +35,8 @@ const getPriceButtonText = ( user: UserPublic | undefined, ) => { const userTierId = user?.tier_id || 1; - const isUserUnverified = user?.verified === false || userTierId === UNVERIFIED_TIER_ID; + const isUserUnverified = + user?.verified === false || userTierId === UNVERIFIED_TIER_ID; if (isUserUnverified) { return "Not allowed"; } else if (priceTierId == userTierId) { @@ -63,8 +64,10 @@ const Page = () => { const billingPath = usePathname(); const searchParams = useSearchParams(); - const isUserUnverified = user?.verified === false || user?.tier_id === UNVERIFIED_TIER_ID; - const hasSubscription = user?.tier_id != JippyTierID.Free && user?.tier_id !== 4; + const isUserUnverified = + user?.verified === false || user?.tier_id === UNVERIFIED_TIER_ID; + const hasSubscription = + user?.tier_id != JippyTierID.Free && user?.tier_id !== 4; let isSuccess = searchParams.get("success") === "true"; let stripeSessionId = searchParams.get("session_id"); let isCancelled = searchParams.get("cancelled") === "true"; @@ -82,7 +85,7 @@ const Page = () => { return new Date(dateString); } return undefined; - } + }; const onClickDowngradeSubscription = () => { downgradeSubscription.mutate(); @@ -175,8 +178,7 @@ const Page = () => {
- +

Our Tiers

diff --git a/frontend/app/(authenticated)/verify-email/page.tsx b/frontend/app/(authenticated)/verify-email/page.tsx index 4670f75f..6183f5c2 100644 --- a/frontend/app/(authenticated)/verify-email/page.tsx +++ b/frontend/app/(authenticated)/verify-email/page.tsx @@ -2,8 +2,10 @@ import { useEffect, useState } from "react"; import { useRouter, useSearchParams } from "next/navigation"; +import { HttpStatusCode } from "axios"; import { CheckCircleIcon, CircleX } from "lucide-react"; +import { completeEmailVerificationAuthEmailVerificationPut } from "@/client/services.gen"; import { Box } from "@/components/ui/box"; import { Card, @@ -14,8 +16,6 @@ import { } from "@/components/ui/card"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { useUserStore } from "@/store/user/user-store-provider"; -import { completeEmailVerificationAuthEmailVerificationPut } from "@/client/services.gen"; -import { HttpStatusCode } from "axios"; export const UNVERIFIED_TIER_ID = 4; export const VERIFY_SUCCESS_DELAY = 1; @@ -27,10 +27,15 @@ export default function VerifyEmail() { const [isLoading, setIsLoading] = useState(true); const searchParams = useSearchParams(); const code = searchParams.get("code"); - const isUserUnverified = !user?.verified && user?.tier_id === UNVERIFIED_TIER_ID; + const isUserUnverified = + !user?.verified && user?.tier_id === UNVERIFIED_TIER_ID; const [isVerifySuccess, setIsVerifySuccess] = useState(false); - const [postVerifyTitle, setPostVerifyTitle] = useState("Verified! Redirecting you to Jippy..."); - const [postVerifySubtitle, setPostVerifySubtitle] = useState("All done! You'll be redirected to Jippy soon. "); + const [postVerifyTitle, setPostVerifyTitle] = useState( + "Verified! Redirecting you to Jippy...", + ); + const [postVerifySubtitle, setPostVerifySubtitle] = useState( + "All done! You'll be redirected to Jippy soon. ", + ); const redirectAfterVerify = async () => { // Redirect user to Jippy home page after verifying email @@ -41,28 +46,37 @@ export default function VerifyEmail() { let timeout: NodeJS.Timeout | null = null; if (code && isUserUnverified) { (async () => { - const response = await completeEmailVerificationAuthEmailVerificationPut({ - query: { code }, - }); + const response = + await completeEmailVerificationAuthEmailVerificationPut({ + query: { code }, + }); // There is some problem where this function runs twice, causing an error // on the second run since the email verification is used. if (response.data) { setIsVerifySuccess(true); setIsLoading(false); - timeout = setTimeout(redirectAfterVerify, VERIFY_SUCCESS_DELAY * 1000); + timeout = setTimeout( + redirectAfterVerify, + VERIFY_SUCCESS_DELAY * 1000, + ); } else if (response.status == HttpStatusCode.Conflict) { // User is already verified setIsVerifySuccess(true); setPostVerifySubtitle("Relax, you're already verified! :) "); setIsLoading(false); - timeout = setTimeout(redirectAfterVerify, VERIFY_SUCCESS_DELAY * 1000); + timeout = setTimeout( + redirectAfterVerify, + VERIFY_SUCCESS_DELAY * 1000, + ); console.log("WARNING: User is already verified"); } else if (response.status == HttpStatusCode.BadRequest) { // Email verification has already been used console.log("ERROR: Reusing old email verification code"); setIsVerifySuccess(false); setPostVerifyTitle("Invalid verification link"); - setPostVerifySubtitle("Check your email again! Please click the latest verification link"); + setPostVerifySubtitle( + "Check your email again! Please click the latest verification link", + ); setIsLoading(false); timeout = setTimeout(redirectAfterVerify, VERIFY_ERROR_DELAY * 1000); } else if (response.error) { @@ -70,16 +84,26 @@ export default function VerifyEmail() { console.error("ERROR: Invalid verification code"); setIsVerifySuccess(false); setPostVerifyTitle("Invalid verification link"); - setPostVerifySubtitle("Check whether you entered the correct verification link.\nNote: Never click on a verification link that is not sent by us"); + setPostVerifySubtitle( + "Check whether you entered the correct verification link.\nNote: Never click on a verification link that is not sent by us", + ); setIsLoading(false); - timeout = setTimeout(redirectAfterVerify, VERIFY_ERROR_DELAY * 1000); + timeout = setTimeout( + redirectAfterVerify, + VERIFY_ERROR_DELAY * 1000, + ); } else { console.error("ERROR while verifying email"); setIsVerifySuccess(false); setPostVerifyTitle("Verification error"); - setPostVerifySubtitle("We're very sorry, something went wrong while verifying you. Please try again later."); + setPostVerifySubtitle( + "We're very sorry, something went wrong while verifying you. Please try again later.", + ); setIsLoading(false); - timeout = setTimeout(redirectAfterVerify, VERIFY_ERROR_DELAY * 1000); + timeout = setTimeout( + redirectAfterVerify, + VERIFY_ERROR_DELAY * 1000, + ); } } })(); @@ -113,8 +137,10 @@ export default function VerifyEmail() { {isLoading ? ( + ) : isVerifySuccess ? ( + ) : ( - isVerifySuccess ? : + )} diff --git a/frontend/components/billing/subscription-card.tsx b/frontend/components/billing/subscription-card.tsx index f302706c..d605ac6e 100644 --- a/frontend/components/billing/subscription-card.tsx +++ b/frontend/components/billing/subscription-card.tsx @@ -1,129 +1,172 @@ "use client"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; -import JippyIconMd from "@/public/jippy-icon/jippy-icon-md"; -import { JippyTier, JippyTierID, stripeSubscriptionStatusToTierStatus, SubscriptionPeriod, tierIDToPrice, tierIDToTierName } from "@/types/billing"; -import { Button } from "@/components/ui/button"; -import Chip from "@/components/display/chip"; +import { useMemo } from "react"; import { CalendarIcon, CircleAlert, CircleDollarSignIcon } from "lucide-react"; + import { UserPublic } from "@/client/types.gen"; -import { useMemo } from "react"; +import Chip from "@/components/display/chip"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import JippyIconMd from "@/public/jippy-icon/jippy-icon-md"; import { useCreateStripeCustomerPortalSession } from "@/queries/billing"; -import { Alert, AlertDescription } from "../ui/alert"; +import { + JippyTier, + JippyTierID, + stripeSubscriptionStatusToTierStatus, + SubscriptionPeriod, + tierIDToPrice, + tierIDToTierName, +} from "@/types/billing"; const MAX_CARD_HEIGHT_PX = 400; const TIER_STATUS_ACTIVE = "active"; const UNVERIFIED_TIER_ID = 4; export interface SubscriptionInfo { - user: UserPublic | undefined; -}; + user: UserPublic | undefined; +} const getDateFrom = (dateString: string | null | undefined) => { - if (dateString) { - return new Date(dateString); - } - return undefined; + if (dateString) { + return new Date(dateString); + } + return undefined; }; const toPascalCase = (string: string) => { - return string.charAt(0).toUpperCase() + string.slice(1); + return string.charAt(0).toUpperCase() + string.slice(1); }; -const SubscriptionDetail = ({DetailIcon, detailDescription}: { DetailIcon: React.ComponentType>, detailDescription: string }) => { - return ( -
- -
- {detailDescription} -
-
- ) -} +const SubscriptionDetail = ({ + DetailIcon, + detailDescription, +}: { + DetailIcon: React.ComponentType>; + detailDescription: string; +}) => { + return ( +
+ +
+ {detailDescription} +
+
+ ); +}; -const SubscriptionCard = ({user}: SubscriptionInfo) => { - const currentTierName = useMemo(() => { - return tierIDToTierName(user?.tier_id || JippyTierID.Free); - }, [user?.tier_id]); - const tierEndDate = useMemo(() => { - return getDateFrom(user?.subscription?.subscription_ended_date || user?.subscription?.subscription_period_end); - }, [user?.subscription]); - const tierStatus = useMemo(() => { - return toPascalCase(stripeSubscriptionStatusToTierStatus(user?.subscription ? user.subscription.status : TIER_STATUS_ACTIVE)); - }, [user?.subscription]); - const tierPrice = useMemo(() => { - return tierIDToPrice(user?.tier_id || JippyTierID.Free); - }, [user?.tier_id]); - // TODO: Dynamically fetch the subscription period from Stripe if we ever support annual subscriptions - const tierSubscriptionPeriod = SubscriptionPeriod.Month; - const actionDescription = "Manage Subscription"; +const SubscriptionCard = ({ user }: SubscriptionInfo) => { + const currentTierName = useMemo(() => { + return tierIDToTierName(user?.tier_id || JippyTierID.Free); + }, [user?.tier_id]); + const tierEndDate = useMemo(() => { + return getDateFrom( + user?.subscription?.subscription_ended_date || + user?.subscription?.subscription_period_end, + ); + }, [user?.subscription]); + const tierStatus = useMemo(() => { + return toPascalCase( + stripeSubscriptionStatusToTierStatus( + user?.subscription ? user.subscription.status : TIER_STATUS_ACTIVE, + ), + ); + }, [user?.subscription]); + const tierPrice = useMemo(() => { + return tierIDToPrice(user?.tier_id || JippyTierID.Free); + }, [user?.tier_id]); + // TODO: Dynamically fetch the subscription period from Stripe if we ever support annual subscriptions + const tierSubscriptionPeriod = SubscriptionPeriod.Month; + const actionDescription = "Manage Subscription"; - const isUserUnverified = user?.verified === false || user?.tier_id === UNVERIFIED_TIER_ID; + const isUserUnverified = + user?.verified === false || user?.tier_id === UNVERIFIED_TIER_ID; - const stripeCustomerPortalMutation = useCreateStripeCustomerPortalSession(); + const stripeCustomerPortalMutation = useCreateStripeCustomerPortalSession(); - const onClickManageSubscription = () => { - stripeCustomerPortalMutation.mutate(); - }; + const onClickManageSubscription = () => { + stripeCustomerPortalMutation.mutate(); + }; - return ( - - - - {/* TODO: Consider one day making an icon just for Jippy-branded subscriptions */} - - Your Jippy - - - {!isUserUnverified ? ( - <> -
- {currentTierName} Tier - -
- { user?.subscription && - - } - - ) : ( -
- {JippyTier.Free} Tier - -
- -
- - Unverified email. Verify your email now to enjoy full {JippyTier.Free} Tier access. - -
-
- )} -
-
- { !isUserUnverified && ( - <> -
- - - {tierEndDate && - - } - - + return ( + + + + {/* TODO: Consider one day making an icon just for Jippy-branded subscriptions */} + + Your Jippy + + + {!isUserUnverified ? ( + <> +
+ {currentTierName} Tier + +
+ {user?.subscription && ( + + )} + + ) : ( +
+ + {JippyTier.Free} Tier + + +
+ +
+ + Unverified email. Verify your email now to enjoy full{" "} + {JippyTier.Free} Tier access. + +
+
+ )} +
+
+ {!isUserUnverified && ( + <> +
+ + + {tierEndDate && ( + )} -
- ); + + + )} +
+ ); }; export default SubscriptionCard; diff --git a/frontend/components/layout/app-layout.tsx b/frontend/components/layout/app-layout.tsx index 6f935cf7..b0ef00ce 100644 --- a/frontend/components/layout/app-layout.tsx +++ b/frontend/components/layout/app-layout.tsx @@ -3,28 +3,35 @@ import { ReactNode, Suspense, useEffect } from "react"; import { useQuery } from "@tanstack/react-query"; +import { UNVERIFIED_TIER_ID } from "@/app/(authenticated)/verify-email/page"; import MobileNavbar from "@/components/navigation/mobile/mobile-navbar"; import Navbar from "@/components/navigation/navbar"; +import UnverifiedAlert from "@/components/navigation/unverified-alert"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { Toaster } from "@/components/ui/toaster"; import { getUserProfile } from "@/queries/user"; import { useUserStore } from "@/store/user/user-store-provider"; import ContentLayout from "./content-layout"; -import { UNVERIFIED_TIER_ID } from "@/app/(authenticated)/verify-email/page"; -import UnverifiedAlert from "../navigation/unverified-alert"; export const NAVBAR_HEIGHT = 84; const AppLayout = ({ children }: { children: ReactNode }) => { - const { isLoggedIn, setLoggedIn, setNotLoggedIn, setIsFetching, setIsNotFetching, user } = - useUserStore((state) => state); + const { + isLoggedIn, + setLoggedIn, + setNotLoggedIn, + setIsFetching, + setIsNotFetching, + user, + } = useUserStore((state) => state); const { data: userProfile, isSuccess: isUserProfileSuccess, isLoading: isUserProfileLoading, } = useQuery(getUserProfile()); - const isUserVerified = user?.verified === false || user?.tier_id === UNVERIFIED_TIER_ID; + const isUserVerified = + user?.verified === false || user?.tier_id === UNVERIFIED_TIER_ID; useEffect(() => { if (isUserProfileLoading) { diff --git a/frontend/components/navigation/unverified-alert.tsx b/frontend/components/navigation/unverified-alert.tsx index f304b544..b532089f 100644 --- a/frontend/components/navigation/unverified-alert.tsx +++ b/frontend/components/navigation/unverified-alert.tsx @@ -1,19 +1,23 @@ import { CircleAlert } from "lucide-react"; -import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { resendVerificationEmailAuthEmailVerificationPost } from "@/client"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { toast } from "@/hooks/use-toast"; const UnverifiedAlert = () => { const onClickResendVerification = async () => { const response = await resendVerificationEmailAuthEmailVerificationPost(); if (response.error) { - toast({ - variant: "destructive", - title: "Error", - description: "Error while resending a new verification email. Please try again" - }); - console.error("Error while sending new verification email: ", response.error); + toast({ + variant: "destructive", + title: "Error", + description: + "Error while resending a new verification email. Please try again", + }); + console.error( + "Error while sending new verification email: ", + response.error, + ); } }; @@ -28,14 +32,13 @@ const UnverifiedAlert = () => {
- Verify your email with the link we sent - to you. Didn't receive it?{" "} - + > Resend - +
From d46c859fb1eca176f00dbfe3bc9ef185b22a6ee6 Mon Sep 17 00:00:00 2001 From: Wang Haoyang Date: Fri, 1 Nov 2024 05:45:46 +0800 Subject: [PATCH 58/64] Fix compile errors after linting -Somehow syntax errors occurred after linting and an import disappeared, fix these --- frontend/app/(authenticated)/ask/ask-page.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/frontend/app/(authenticated)/ask/ask-page.tsx b/frontend/app/(authenticated)/ask/ask-page.tsx index 7f234706..cf6e2ef0 100644 --- a/frontend/app/(authenticated)/ask/ask-page.tsx +++ b/frontend/app/(authenticated)/ask/ask-page.tsx @@ -22,6 +22,7 @@ import { import JippyIcon from "@/public/jippy-icon/jippy-icon-sm"; import { useUserStore } from "@/store/user/user-store-provider"; import { getNextMonday, toQueryDateFriendly } from "@/utils/date"; +import { UNVERIFIED_TIER_ID } from "@/types/billing"; const MAX_GP_QUESTION_LEN: number = 200; // max character count @@ -155,7 +156,7 @@ const AskPage = ({ setIsLoading, isLoading }: AskPageProps) => { {isUserUnverified ? ( + warningText="Verify your email to start asking essay questions." /> ) : hasTriesLeft ? ( { ) : ( + warningText={`You've reached the question limit. It will reset on ${toQueryDateFriendly(getNextMonday())} 12:00AM.`} /> )}
From 2980d38b23ca418b7a0908ab16520e4bc87a6299 Mon Sep 17 00:00:00 2001 From: Wang Haoyang Date: Fri, 1 Nov 2024 05:49:47 +0800 Subject: [PATCH 59/64] Fix more eslint errors --- frontend/app/(authenticated)/ask/ask-page.tsx | 4 +- .../(authenticated)/essay-feedback/page.tsx | 2 +- .../app/(authenticated)/user/billing/page.tsx | 41 +------------------ .../navigation/unverified-alert.tsx | 5 ++- 4 files changed, 7 insertions(+), 45 deletions(-) diff --git a/frontend/app/(authenticated)/ask/ask-page.tsx b/frontend/app/(authenticated)/ask/ask-page.tsx index cf6e2ef0..50b7af58 100644 --- a/frontend/app/(authenticated)/ask/ask-page.tsx +++ b/frontend/app/(authenticated)/ask/ask-page.tsx @@ -21,8 +21,8 @@ import { } from "@/components/ui/card"; import JippyIcon from "@/public/jippy-icon/jippy-icon-sm"; import { useUserStore } from "@/store/user/user-store-provider"; -import { getNextMonday, toQueryDateFriendly } from "@/utils/date"; import { UNVERIFIED_TIER_ID } from "@/types/billing"; +import { getNextMonday, toQueryDateFriendly } from "@/utils/date"; const MAX_GP_QUESTION_LEN: number = 200; // max character count @@ -160,11 +160,11 @@ const AskPage = ({ setIsLoading, isLoading }: AskPageProps) => { /> ) : hasTriesLeft ? ( ) : ( { - return string.charAt(0).toUpperCase() + string.slice(1); -}; - const Page = () => { const user = useUserStore((store) => store.user); const stripeCheckoutMutation = useCreateStripeCheckoutSession(); - const stripeCustomerPortalMutation = useCreateStripeCustomerPortalSession(); const downgradeSubscription = useDowngradeSubscription(); const billingPath = usePathname(); const searchParams = useSearchParams(); const isUserUnverified = user?.verified === false || user?.tier_id === UNVERIFIED_TIER_ID; - const hasSubscription = - user?.tier_id != JippyTierID.Free && user?.tier_id !== 4; let isSuccess = searchParams.get("success") === "true"; let stripeSessionId = searchParams.get("session_id"); let isCancelled = searchParams.get("cancelled") === "true"; const router = useRouter(); - const [userTier, setUserTier] = useState(""); - const [userTierStatus, setUserTierStatus] = useState(""); - const { toast } = useToast(); // Display payment status toast for 5 secs const PAYMENT_TOAST_DURATION = 5000; - const getDateFrom = (dateString: string | null | undefined) => { - if (dateString) { - return new Date(dateString); - } - return undefined; - }; - const onClickDowngradeSubscription = () => { downgradeSubscription.mutate(); }; - const onClickManageSubscription = () => { - stripeCustomerPortalMutation.mutate(); - }; - const jippyTiers = [ { tierName: JippyTier.Free, @@ -160,16 +131,6 @@ const Page = () => { } }, [isSuccess, isCancelled, stripeSessionId]); - useEffect(() => { - if (user?.subscription) { - setUserTierStatus(user.subscription.status); - setUserTier(tierIDToTierName(user.tier_id || FREE_TIER_ID)); - } else { - setUserTier(tierIDToTierName(FREE_TIER_ID)); - setUserTierStatus(TIER_STATUS_ACTIVE); - } - }, [user?.subscription, user?.tier_id]); - return ( user && (
diff --git a/frontend/components/navigation/unverified-alert.tsx b/frontend/components/navigation/unverified-alert.tsx index b532089f..639d9d6d 100644 --- a/frontend/components/navigation/unverified-alert.tsx +++ b/frontend/components/navigation/unverified-alert.tsx @@ -1,7 +1,7 @@ import { CircleAlert } from "lucide-react"; import { resendVerificationEmailAuthEmailVerificationPost } from "@/client"; -import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Alert, AlertDescription } from "@/components/ui/alert"; import { toast } from "@/hooks/use-toast"; const UnverifiedAlert = () => { @@ -32,7 +32,8 @@ const UnverifiedAlert = () => {
- Verify your email with the link we sent to you. Didn't receive it?{" "} + Verify your email with the link we sent to you. Didn't receive + it?{" "} Date: Fri, 1 Nov 2024 05:53:27 +0800 Subject: [PATCH 60/64] Fix more eslint errors --- frontend/app/(authenticated)/verify-email/page.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/frontend/app/(authenticated)/verify-email/page.tsx b/frontend/app/(authenticated)/verify-email/page.tsx index 6183f5c2..d30ebaf5 100644 --- a/frontend/app/(authenticated)/verify-email/page.tsx +++ b/frontend/app/(authenticated)/verify-email/page.tsx @@ -16,12 +16,12 @@ import { } from "@/components/ui/card"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { useUserStore } from "@/store/user/user-store-provider"; +import { UNVERIFIED_TIER_ID } from "@/types/billing"; -export const UNVERIFIED_TIER_ID = 4; export const VERIFY_SUCCESS_DELAY = 1; export const VERIFY_ERROR_DELAY = 5; -export default function VerifyEmail() { +export const VerifyEmail = () => { const user = useUserStore((store) => store.user); const router = useRouter(); const [isLoading, setIsLoading] = useState(true); @@ -162,4 +162,6 @@ export default function VerifyEmail() { ); -} +}; + +export default VerifyEmail; From 67e6963c4203f3075b67659da5a13f209cff0ec2 Mon Sep 17 00:00:00 2001 From: Wang Haoyang Date: Fri, 1 Nov 2024 05:59:48 +0800 Subject: [PATCH 61/64] Fix even more eslint errors --- frontend/app/(authenticated)/verify-email/page.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/app/(authenticated)/verify-email/page.tsx b/frontend/app/(authenticated)/verify-email/page.tsx index d30ebaf5..4484b503 100644 --- a/frontend/app/(authenticated)/verify-email/page.tsx +++ b/frontend/app/(authenticated)/verify-email/page.tsx @@ -18,10 +18,10 @@ import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { useUserStore } from "@/store/user/user-store-provider"; import { UNVERIFIED_TIER_ID } from "@/types/billing"; -export const VERIFY_SUCCESS_DELAY = 1; -export const VERIFY_ERROR_DELAY = 5; - export const VerifyEmail = () => { + const VERIFY_SUCCESS_DELAY = 1; + const VERIFY_ERROR_DELAY = 5; + const user = useUserStore((store) => store.user); const router = useRouter(); const [isLoading, setIsLoading] = useState(true); @@ -122,7 +122,7 @@ export const VerifyEmail = () => { clearTimeout(timeout); } }; - }, [code, user]); + }, [code, isUserUnverified, redirectAfterVerify]); return ( From f5dec2111e9f32a614f084a3bd381988c942594f Mon Sep 17 00:00:00 2001 From: Wang Haoyang Date: Fri, 1 Nov 2024 06:03:54 +0800 Subject: [PATCH 62/64] Fix some more eslint errors --- frontend/app/(authenticated)/verify-email/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/app/(authenticated)/verify-email/page.tsx b/frontend/app/(authenticated)/verify-email/page.tsx index 4484b503..8b31e084 100644 --- a/frontend/app/(authenticated)/verify-email/page.tsx +++ b/frontend/app/(authenticated)/verify-email/page.tsx @@ -18,7 +18,7 @@ import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { useUserStore } from "@/store/user/user-store-provider"; import { UNVERIFIED_TIER_ID } from "@/types/billing"; -export const VerifyEmail = () => { +const VerifyEmail = () => { const VERIFY_SUCCESS_DELAY = 1; const VERIFY_ERROR_DELAY = 5; From 5f489a07f23c7030e95a724251110ea136e2876a Mon Sep 17 00:00:00 2001 From: Wang Haoyang Date: Fri, 1 Nov 2024 06:04:57 +0800 Subject: [PATCH 63/64] Fix some more eslint errors --- frontend/app/(authenticated)/user/billing/page.tsx | 2 +- frontend/components/layout/app-layout.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/app/(authenticated)/user/billing/page.tsx b/frontend/app/(authenticated)/user/billing/page.tsx index 45e4c1c9..94964e0a 100644 --- a/frontend/app/(authenticated)/user/billing/page.tsx +++ b/frontend/app/(authenticated)/user/billing/page.tsx @@ -3,7 +3,6 @@ import { useEffect } from "react"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; -import { UNVERIFIED_TIER_ID } from "@/app/(authenticated)/verify-email/page"; import { UserPublic } from "@/client"; import PricingTable from "@/components/billing/pricing-table"; import SubscriptionCard from "@/components/billing/subscription-card"; @@ -20,6 +19,7 @@ import { tierIDToTierFeature, tierIDToTierName, TierPrice, + UNVERIFIED_TIER_ID, } from "@/types/billing"; const getPriceButtonText = ( diff --git a/frontend/components/layout/app-layout.tsx b/frontend/components/layout/app-layout.tsx index b0ef00ce..2a47696c 100644 --- a/frontend/components/layout/app-layout.tsx +++ b/frontend/components/layout/app-layout.tsx @@ -3,7 +3,6 @@ import { ReactNode, Suspense, useEffect } from "react"; import { useQuery } from "@tanstack/react-query"; -import { UNVERIFIED_TIER_ID } from "@/app/(authenticated)/verify-email/page"; import MobileNavbar from "@/components/navigation/mobile/mobile-navbar"; import Navbar from "@/components/navigation/navbar"; import UnverifiedAlert from "@/components/navigation/unverified-alert"; @@ -13,6 +12,7 @@ import { getUserProfile } from "@/queries/user"; import { useUserStore } from "@/store/user/user-store-provider"; import ContentLayout from "./content-layout"; +import { UNVERIFIED_TIER_ID } from "@/types/billing"; export const NAVBAR_HEIGHT = 84; From 1839e9ddaeae89ca2a18d50ee79b31a61a3ba858 Mon Sep 17 00:00:00 2001 From: Wang Haoyang Date: Fri, 1 Nov 2024 06:07:54 +0800 Subject: [PATCH 64/64] Fix some more eslint errors --- frontend/components/layout/app-layout.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/components/layout/app-layout.tsx b/frontend/components/layout/app-layout.tsx index 2a47696c..8e2a5b86 100644 --- a/frontend/components/layout/app-layout.tsx +++ b/frontend/components/layout/app-layout.tsx @@ -10,9 +10,9 @@ import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { Toaster } from "@/components/ui/toaster"; import { getUserProfile } from "@/queries/user"; import { useUserStore } from "@/store/user/user-store-provider"; +import { UNVERIFIED_TIER_ID } from "@/types/billing"; import ContentLayout from "./content-layout"; -import { UNVERIFIED_TIER_ID } from "@/types/billing"; export const NAVBAR_HEIGHT = 84;