From e3ab6f067c2b5ebda8219b50982e48234bc1ade0 Mon Sep 17 00:00:00 2001 From: Nitheesh T Ganesh Date: Wed, 4 Feb 2026 12:07:21 -0700 Subject: [PATCH 1/8] patch api for renew invitation --- .../src/submit_api/models/invitations.py | 1 + .../src/submit_api/resources/invitation.py | 19 +++++++++++++++++++ .../submit_api/services/invitation_service.py | 13 +++++++++++++ 3 files changed, 33 insertions(+) diff --git a/submit-api/src/submit_api/models/invitations.py b/submit-api/src/submit_api/models/invitations.py index 43be0a0d..ccf8b814 100644 --- a/submit-api/src/submit_api/models/invitations.py +++ b/submit-api/src/submit_api/models/invitations.py @@ -62,6 +62,7 @@ def to_dict(self): "expiry_date": self.expiry_date, "role_id": self.role_id, "role": self.role.to_dict() if self.role else None, + "is_expired": self.is_expired, } @classmethod diff --git a/submit-api/src/submit_api/resources/invitation.py b/submit-api/src/submit_api/resources/invitation.py index e93dbc02..56ab9919 100644 --- a/submit-api/src/submit_api/resources/invitation.py +++ b/submit-api/src/submit_api/resources/invitation.py @@ -210,3 +210,22 @@ def delete(invitation_id): if result: return {}, HTTPStatus.NO_CONTENT return {"error": "Invitation not found or already used"}, HTTPStatus.NOT_FOUND + +@cors_preflight("PATCH, OPTIONS") +@API.route("/id//renew", methods=["PATCH", "OPTIONS"]) +class InvitationRenewResource(Resource): + """Resource to renew an invitation by ID.""" + @staticmethod + @ApiHelper.swagger_decorators(API, endpoint_description="Renew invitation by ID") + @API.response(HTTPStatus.OK, "Invitation renewed") + @API.response(HTTPStatus.NOT_FOUND, "Invitation not found") + @auth.require + @auth.has_one_of_roles([ProponentPermissionsEnum.INVITE_USERS.value]) + def patch(invitation_id): + """Renew an invitation by ID.""" + invitation = InvitationService.get_invitation_by_id(invitation_id) + if invitation: + result = InvitationService.renew_invitation(invitation_id) + if result: + return {}, HTTPStatus.NO_CONTENT + return {"error": "Invitation not found or already used"}, HTTPStatus.NOT_FOUND diff --git a/submit-api/src/submit_api/services/invitation_service.py b/submit-api/src/submit_api/services/invitation_service.py index 2cfb4140..2181133a 100644 --- a/submit-api/src/submit_api/services/invitation_service.py +++ b/submit-api/src/submit_api/services/invitation_service.py @@ -435,3 +435,16 @@ def resend_invitation(token): session.add(invitation) return True + + @staticmethod + def renew_invitation(invitation_id): + """Renew an invitation by ID.""" + invitation = InvitationsModel.find_by_id(invitation_id) + if invitation: + InvitationService._check_action_authorized(invitation.project_ids) + invitation.status = InvitationStatus.PENDING.value + # renew invitation expiry date by 30 days from the current date + invitation.expiry_date = datetime.datetime.utcnow() + datetime.timedelta(days=30) + InvitationsModel.commit() + return True + return False From f1f7bd068c2447e4a6b9d7ec7a0d9fbdf725f6b5 Mon Sep 17 00:00:00 2001 From: Nitheesh T Ganesh Date: Wed, 4 Feb 2026 12:07:30 -0700 Subject: [PATCH 2/8] unit tests --- .../tests/unit/resources/test_invitation.py | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/submit-api/tests/unit/resources/test_invitation.py b/submit-api/tests/unit/resources/test_invitation.py index 7dad6046..22fa5e0d 100644 --- a/submit-api/tests/unit/resources/test_invitation.py +++ b/submit-api/tests/unit/resources/test_invitation.py @@ -333,3 +333,32 @@ def test_create_invitation_with_multiple_projects(client, session, jwt): assert response.status_code == HTTPStatus.CREATED data = response.get_json() assert "token" in data + + +def test_renew_invitation(client, session, jwt): + """Test renewing an invitation.""" + headers, account_project = setup_authenticated_proponent(session, jwt) + invitation = factory_invitation_model( + account_id=account_project.account_id, + project_ids=[account_project.project_id], + expiry_date=datetime.now(UTC) - timedelta(days=1) # Expired + ) + + response = client.patch(f"/api/invitations/id/{invitation.id}/renew", headers=headers) + + assert response.status_code == HTTPStatus.NO_CONTENT + + # Verify expiry date is updated + assert invitation.expiry_date > datetime.utcnow() + assert invitation.status == InvitationStatus.PENDING.value + + +def test_renew_invitation_not_found(client, session, jwt): + """Test renewing a non-existent invitation.""" + auth_guid = TestJwtClaims.staff_admin_role['preferred_username'] + factory_user_model(auth_guid=auth_guid) + + headers = factory_auth_header(jwt=jwt, claims=TestJwtClaims.staff_admin_role) + response = client.patch("/api/invitations/id/99999/renew", headers=headers) + + assert response.status_code == HTTPStatus.NOT_FOUND From 16682a5dd96b79a334e2c5a6d13024128a365016 Mon Sep 17 00:00:00 2001 From: Nitheesh T Ganesh Date: Wed, 4 Feb 2026 12:08:11 -0700 Subject: [PATCH 3/8] renew button and api integration if link expired after 30 days --- .../RegistrationUrl/RegistrationUrl.tsx | 93 ++++++++++++++----- submit-web/src/hooks/api/useInvitations.ts | 14 +++ submit-web/src/models/Invitation.ts | 1 + 3 files changed, 83 insertions(+), 25 deletions(-) diff --git a/submit-web/src/components/App/Proponents/RegistrationUrl/RegistrationUrl.tsx b/submit-web/src/components/App/Proponents/RegistrationUrl/RegistrationUrl.tsx index 2f311af4..a0ef8bc7 100644 --- a/submit-web/src/components/App/Proponents/RegistrationUrl/RegistrationUrl.tsx +++ b/submit-web/src/components/App/Proponents/RegistrationUrl/RegistrationUrl.tsx @@ -2,8 +2,11 @@ import { LoadingButton } from "@/components/Shared/LoadingButton"; import ConfirmationModal from "@/components/Shared/Modals/ConfirmationModal"; import { useModal } from "@/components/Shared/Modals/modalStore"; import { notify } from "@/components/Shared/Snackbar/snackbarStore"; -import { useCreateNewAccountProjectInvitation } from "@/hooks/api/useInvitations"; -import { Invitation } from "@/models/Invitation"; +import { + useCreateNewAccountProjectInvitation, + useRenewInvitation, +} from "@/hooks/api/useInvitations"; +import { Invitation, InvitationStatus } from "@/models/Invitation"; import { USER_MANAGEMENT_ROLE } from "@/models/Role"; import { AppConfig } from "@/utils/config"; import ContentCopyIcon from "@mui/icons-material/ContentCopy"; @@ -21,18 +24,23 @@ type RegistrationUrlProps = { export const RegistrationUrl = ({ pendingInvitation, selectedProjectsIds, - onInvitationCreated + onInvitationCreated, }: RegistrationUrlProps) => { const [tooltipText, setTooltipText] = useState("Copy"); const { proponentId } = useParams({ from: "/staff/_staffLayout/proponents/$proponentId", }); - const { - setOpen: setOpenModal, - setClose: setCloseModal, - } = useModal(); - + const { setOpen: setOpenModal, setClose: setCloseModal } = useModal(); + const url = `${AppConfig.appUrl}/proponent/account-registration?token=${pendingInvitation?.token}`; + const helperText = pendingInvitation + ? pendingInvitation.is_expired + ? "This link has expired, You can renew the link by clicking the 'Renew Link' button" + : pendingInvitation.expiry_date + ? "This link will expire on " + + new Date(pendingInvitation.expiry_date).toISOString().split("T")[0] + : "" + : ""; const { mutate: createInvitation, isPending: isCreatingInvitation } = useCreateNewAccountProjectInvitation({ @@ -58,7 +66,7 @@ export const RegistrationUrl = ({ />, ); }; - + const handleGenerateUrlClick = () => { if (selectedProjectsIds.length === 0) { openConfirmationModal(); @@ -71,26 +79,43 @@ export const RegistrationUrl = ({ }); }; + const { mutate: renewInvitation, isPending: isRenewingInvitation } = + useRenewInvitation({ + onSuccess: () => { + onInvitationCreated(); + notify.success("Invitation URL renewed successfully"); + }, + onError: () => { + notify.error("Error renewing invitation URL"); + }, + }); + + const handleRenewUrlClick = () => { + renewInvitation(pendingInvitation?.id || 0); + }; + const handleCopyClick = () => { navigator.clipboard.writeText(url); setTooltipText("Copied"); setTimeout(() => setTooltipText("Copy"), 2000); notify.success("Link copied successfully"); }; - + return ( @@ -105,23 +130,41 @@ export const RegistrationUrl = ({ - ) + ), }} onFocus={(e) => e.target.blur()} + helperText={helperText} + FormHelperTextProps={{ + sx: { + ml: "14px !important", + color: pendingInvitation?.is_expired + ? BCDesignTokens.typographyColorDanger + " !important" + : "", + }, + }} fullWidth /> - - Generate URL - + {pendingInvitation?.is_expired && + pendingInvitation.status === InvitationStatus.PENDING ? ( + + Renew Link + + ) : ( + + Generate Link + + )} ); diff --git a/submit-web/src/hooks/api/useInvitations.ts b/submit-web/src/hooks/api/useInvitations.ts index f8628c51..bf340da2 100644 --- a/submit-web/src/hooks/api/useInvitations.ts +++ b/submit-web/src/hooks/api/useInvitations.ts @@ -190,3 +190,17 @@ const revokeInvitation = (invitationId: number) => { method: "delete", }); }; + +export const useRenewInvitation = (options?: Options) => { + return useMutation({ + mutationFn: (invitationId: number) => renewInvitation(invitationId), + ...options, + }); +}; + +const renewInvitation = (invitationId: number) => { + return submitRequest({ + url: `/invitations/id/${invitationId}/renew`, + method: "patch", + }); +}; diff --git a/submit-web/src/models/Invitation.ts b/submit-web/src/models/Invitation.ts index 5f916ab3..6c8cdf5a 100644 --- a/submit-web/src/models/Invitation.ts +++ b/submit-web/src/models/Invitation.ts @@ -14,6 +14,7 @@ export type Invitation = { email: string; status: string; expiry_date: string; + is_expired: boolean; created_date: string; is_first_time: boolean; role: Role; From bedc1536e4f2ecf5cdf5ed1eea092f443e21eb26 Mon Sep 17 00:00:00 2001 From: Nitheesh T Ganesh Date: Wed, 4 Feb 2026 12:30:44 -0700 Subject: [PATCH 4/8] expiry days from config --- submit-api/src/submit_api/services/invitation_service.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/submit-api/src/submit_api/services/invitation_service.py b/submit-api/src/submit_api/services/invitation_service.py index 2181133a..2e148470 100644 --- a/submit-api/src/submit_api/services/invitation_service.py +++ b/submit-api/src/submit_api/services/invitation_service.py @@ -443,8 +443,8 @@ def renew_invitation(invitation_id): if invitation: InvitationService._check_action_authorized(invitation.project_ids) invitation.status = InvitationStatus.PENDING.value - # renew invitation expiry date by 30 days from the current date - invitation.expiry_date = datetime.datetime.utcnow() + datetime.timedelta(days=30) + expiry_days = current_app.config['INVITATION_EXPIRY_DAYS'] + invitation.expiry_date = datetime.datetime.utcnow() + datetime.timedelta(days=expiry_days) InvitationsModel.commit() return True return False From 902c7e10583ede2367b908e7135c5b0cedddc006 Mon Sep 17 00:00:00 2001 From: Nitheesh T Ganesh Date: Wed, 4 Feb 2026 14:45:41 -0700 Subject: [PATCH 5/8] lint fix --- submit-api/src/submit_api/resources/invitation.py | 2 ++ submit-api/tests/unit/resources/test_invitation.py | 6 +++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/submit-api/src/submit_api/resources/invitation.py b/submit-api/src/submit_api/resources/invitation.py index 56ab9919..919106c5 100644 --- a/submit-api/src/submit_api/resources/invitation.py +++ b/submit-api/src/submit_api/resources/invitation.py @@ -211,10 +211,12 @@ def delete(invitation_id): return {}, HTTPStatus.NO_CONTENT return {"error": "Invitation not found or already used"}, HTTPStatus.NOT_FOUND + @cors_preflight("PATCH, OPTIONS") @API.route("/id//renew", methods=["PATCH", "OPTIONS"]) class InvitationRenewResource(Resource): """Resource to renew an invitation by ID.""" + @staticmethod @ApiHelper.swagger_decorators(API, endpoint_description="Renew invitation by ID") @API.response(HTTPStatus.OK, "Invitation renewed") diff --git a/submit-api/tests/unit/resources/test_invitation.py b/submit-api/tests/unit/resources/test_invitation.py index 22fa5e0d..48c73c44 100644 --- a/submit-api/tests/unit/resources/test_invitation.py +++ b/submit-api/tests/unit/resources/test_invitation.py @@ -3,7 +3,7 @@ Tests for invitation resource endpoints. """ -from datetime import datetime, timedelta, UTC +from datetime import datetime, timedelta from http import HTTPStatus from unittest.mock import patch @@ -124,7 +124,7 @@ def test_get_invitation_expired(client, session, jwt): _, account_project = setup_authenticated_proponent(session, jwt) invitation = factory_invitation_model( account_id=account_project.account_id, - expiry_date=datetime.now(UTC) - timedelta(days=1), # Expired + expiry_date=datetime.utcnow() - timedelta(days=1), # Expired ) response = client.get(f"/api/invitations/{invitation.token}") @@ -341,7 +341,7 @@ def test_renew_invitation(client, session, jwt): invitation = factory_invitation_model( account_id=account_project.account_id, project_ids=[account_project.project_id], - expiry_date=datetime.now(UTC) - timedelta(days=1) # Expired + expiry_date=datetime.utcnow() - timedelta(days=1) # Expired ) response = client.patch(f"/api/invitations/id/{invitation.id}/renew", headers=headers) From 7b83069ef3f9ad9a5e3616ab2667d2c8dd7f6ad0 Mon Sep 17 00:00:00 2001 From: Nitheesh T Ganesh Date: Wed, 4 Feb 2026 14:55:51 -0700 Subject: [PATCH 6/8] lint fix --- submit-api/tests/unit/resources/test_invitation.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/submit-api/tests/unit/resources/test_invitation.py b/submit-api/tests/unit/resources/test_invitation.py index 2db07380..2d6c8b42 100644 --- a/submit-api/tests/unit/resources/test_invitation.py +++ b/submit-api/tests/unit/resources/test_invitation.py @@ -348,15 +348,15 @@ def test_renew_invitation(client, session, jwt): invitation = factory_invitation_model( account_id=account_project.account_id, project_ids=[account_project.project_id], - expiry_date=datetime.utcnow() - timedelta(days=1) # Expired + expiry_date=datetime.now(timezone.utc) - timedelta(days=1) # Expired ) response = client.patch(f"/api/invitations/id/{invitation.id}/renew", headers=headers) assert response.status_code == HTTPStatus.NO_CONTENT - + # Verify expiry date is updated - assert invitation.expiry_date > datetime.utcnow() + assert invitation.expiry_date > datetime.now(timezone.utc) assert invitation.status == InvitationStatus.PENDING.value From b4579f107c2c63c2a6233b0ed109c6c19a66436a Mon Sep 17 00:00:00 2001 From: Nitheesh T Ganesh Date: Wed, 4 Feb 2026 14:55:51 -0700 Subject: [PATCH 7/8] lint fix --- submit-api/tests/unit/resources/test_invitation.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/submit-api/tests/unit/resources/test_invitation.py b/submit-api/tests/unit/resources/test_invitation.py index 2db07380..f5b117c3 100644 --- a/submit-api/tests/unit/resources/test_invitation.py +++ b/submit-api/tests/unit/resources/test_invitation.py @@ -348,13 +348,13 @@ def test_renew_invitation(client, session, jwt): invitation = factory_invitation_model( account_id=account_project.account_id, project_ids=[account_project.project_id], - expiry_date=datetime.utcnow() - timedelta(days=1) # Expired + expiry_date=datetime.now(timezone.utc) - timedelta(days=1) # Expired ) response = client.patch(f"/api/invitations/id/{invitation.id}/renew", headers=headers) assert response.status_code == HTTPStatus.NO_CONTENT - + # Verify expiry date is updated assert invitation.expiry_date > datetime.utcnow() assert invitation.status == InvitationStatus.PENDING.value From 23a9dd01c2414858b90d51e7e30967c91bf60a98 Mon Sep 17 00:00:00 2001 From: Nitheesh T Ganesh Date: Wed, 4 Feb 2026 15:09:55 -0700 Subject: [PATCH 8/8] test fix --- submit-api/tests/unit/resources/test_invitation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/submit-api/tests/unit/resources/test_invitation.py b/submit-api/tests/unit/resources/test_invitation.py index 2d6c8b42..f5b117c3 100644 --- a/submit-api/tests/unit/resources/test_invitation.py +++ b/submit-api/tests/unit/resources/test_invitation.py @@ -356,7 +356,7 @@ def test_renew_invitation(client, session, jwt): assert response.status_code == HTTPStatus.NO_CONTENT # Verify expiry date is updated - assert invitation.expiry_date > datetime.now(timezone.utc) + assert invitation.expiry_date > datetime.utcnow() assert invitation.status == InvitationStatus.PENDING.value