Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions submit-api/src/submit_api/models/invitations.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions submit-api/src/submit_api/resources/invitation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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/<int:invitation_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
13 changes: 13 additions & 0 deletions submit-api/src/submit_api/services/invitation_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
29 changes: 29 additions & 0 deletions submit-api/tests/unit/resources/test_invitation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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({
Expand All @@ -58,7 +66,7 @@ export const RegistrationUrl = ({
/>,
);
};

const handleGenerateUrlClick = () => {
if (selectedProjectsIds.length === 0) {
openConfirmationModal();
Expand All @@ -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 (
<Grid
container
spacing={2}
sx={{
mb: BCDesignTokens.layoutMarginXxlarge
sx={{
mb: BCDesignTokens.layoutMarginXxlarge,
}}
>
<Grid item sm={12} md={5}>
<TextField
value={pendingInvitation ? url : ""}
sx={{ margin: 0 }}
InputProps={{
sx={{
margin: 0,
}}
InputProps={{
readOnly: true,
endAdornment: (
<Tooltip title={tooltipText} arrow>
Expand All @@ -105,23 +130,41 @@ export const RegistrationUrl = ({
</IconButton>
</span>
</Tooltip>
)
),
}}
onFocus={(e) => e.target.blur()}
helperText={helperText}
FormHelperTextProps={{
sx: {
ml: "14px !important",
color: pendingInvitation?.is_expired
? BCDesignTokens.typographyColorDanger + " !important"
: "",
},
}}
fullWidth
/>
</Grid>
<Grid item xs={2}>
<LoadingButton
variant="contained"
color="primary"
loading={isCreatingInvitation}
onClick={handleGenerateUrlClick}
disabled={!!pendingInvitation}
sx={{ whiteSpace: "nowrap" }}
>
Generate URL
</LoadingButton>
{pendingInvitation?.is_expired &&
pendingInvitation.status === InvitationStatus.PENDING ? (
<LoadingButton
color="secondary"
loading={isRenewingInvitation}
onClick={handleRenewUrlClick}
>
Renew Link
</LoadingButton>
) : (
<LoadingButton
color="primary"
loading={isCreatingInvitation}
onClick={handleGenerateUrlClick}
disabled={!!pendingInvitation}
>
Generate Link
</LoadingButton>
)}
</Grid>
</Grid>
);
Expand Down
14 changes: 14 additions & 0 deletions submit-web/src/hooks/api/useInvitations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
});
};
1 change: 1 addition & 0 deletions submit-web/src/models/Invitation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down