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..919106c5 100644 --- a/submit-api/src/submit_api/resources/invitation.py +++ b/submit-api/src/submit_api/resources/invitation.py @@ -210,3 +210,24 @@ 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 286bb339..ccd26da0 100644 --- a/submit-api/src/submit_api/services/invitation_service.py +++ b/submit-api/src/submit_api/services/invitation_service.py @@ -441,3 +441,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 + 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 diff --git a/submit-api/tests/unit/resources/test_invitation.py b/submit-api/tests/unit/resources/test_invitation.py index 2b2fc256..f5b117c3 100644 --- a/submit-api/tests/unit/resources/test_invitation.py +++ b/submit-api/tests/unit/resources/test_invitation.py @@ -340,3 +340,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(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 + + +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 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;