Feature/guest form submission (#447)

* WIP: some initial code to allow anonymous users get a task w/ burnettk

* added scripts to get the url for a given human task w/ burnettk

* users can complete a task anonymously

* pyl

* fixed up login flow and added submission confirmation message for guest tasks w/ burnettk

* added only_guest_task_completion to guest token so we can remove items from the ui with it

* renamed anonymous to guest

* force logout guest users when verifying the token if certain criteria are met and do not do it random controller methods

* also allow saving draft data to use guest users w/ burnettk

* updated bpmn-js-spiffworkflow and added test to test allow guest

* pyl

* fix typo and remove bad file

* remove allow_guest column and moved allow guest check to TaskModel

* removed unnecessary comment

* missing import

* do not allow guest users to see completed tasks and remove save and close button for guest users w/ burnettk

---------

Co-authored-by: jasquat <jasquat@users.noreply.github.com>
Co-authored-by: burnettk <burnettk@users.noreply.github.com>
This commit is contained in:
jasquat 2023-09-07 10:33:02 -04:00 committed by GitHub
parent 80ad92a0c3
commit ffe2a18ce9
25 changed files with 734 additions and 98 deletions

View File

@ -17,6 +17,16 @@ paths:
required: false required: false
schema: schema:
type: string type: string
- name: process_instance_id
in: query
required: false
schema:
type: integer
- name: task_guid
in: query
required: false
schema:
type: string
get: get:
summary: redirect to open id authentication server summary: redirect to open id authentication server
operationId: spiffworkflow_backend.routes.user.login operationId: spiffworkflow_backend.routes.user.login

View File

@ -13,7 +13,8 @@ if TYPE_CHECKING:
from spiffworkflow_backend.models.user_group_assignment import UserGroupAssignmentModel # noqa: F401 from spiffworkflow_backend.models.user_group_assignment import UserGroupAssignmentModel # noqa: F401
SPIFF_NO_AUTH_ANONYMOUS_GROUP = "spiff_anonymous_group" SPIFF_NO_AUTH_GROUP = "spiff_no_auth_group"
SPIFF_GUEST_GROUP = "spiff_guest_group"
class GroupNotFoundError(Exception): class GroupNotFoundError(Exception):

View File

@ -98,6 +98,27 @@ class TaskModel(SpiffworkflowBaseDBModel):
task_model: TaskModel = self.__class__.query.filter_by(guid=self.properties_json["parent"]).first() task_model: TaskModel = self.__class__.query.filter_by(guid=self.properties_json["parent"]).first()
return task_model return task_model
# this will redirect to login if the task does not allow guest access.
# so if you already completed the task, and you are not signed in, you will get sent to a login page.
def allows_guest(self, process_instance_id: int) -> bool:
properties_json = self.task_definition.properties_json
if (
"extensions" in properties_json
and "allowGuest" in properties_json["extensions"]
and properties_json["extensions"]["allowGuest"] == "true"
and self.process_instance_id == int(process_instance_id)
and self.state != "COMPLETED"
):
return True
return False
@classmethod
def task_guid_allows_guest(cls, task_guid: str, process_instance_id: int) -> bool:
task_model = cls.query.filter_by(guid=task_guid).first()
if task_model is not None and task_model.allows_guest(process_instance_id):
return True
return False
class Task: class Task:
HUMAN_TASK_TYPES = ["User Task", "Manual Task"] HUMAN_TASK_TYPES = ["User Task", "Manual Task"]

View File

@ -13,7 +13,8 @@ from spiffworkflow_backend.models.db import SpiffworkflowBaseDBModel
from spiffworkflow_backend.models.db import db from spiffworkflow_backend.models.db import db
from spiffworkflow_backend.models.group import GroupModel from spiffworkflow_backend.models.group import GroupModel
SPIFF_NO_AUTH_ANONYMOUS_USER = "spiff_anonymous_user" SPIFF_NO_AUTH_USER = "spiff_no_auth_guest_user"
SPIFF_GUEST_USER = "spiff_guest_user"
class UserNotFoundError(Exception): class UserNotFoundError(Exception):

View File

@ -333,6 +333,7 @@ def task_show(
task_model.typename = task_definition.typename task_model.typename = task_definition.typename
task_model.can_complete = can_complete task_model.can_complete = can_complete
task_model.name_for_display = TaskService.get_name_for_display(task_definition) task_model.name_for_display = TaskService.get_name_for_display(task_definition)
extensions = TaskService.get_extensions_from_task_model(task_model)
if with_form_data: if with_form_data:
task_process_identifier = task_model.bpmn_process.bpmn_process_definition.bpmn_identifier task_process_identifier = task_model.bpmn_process.bpmn_process_definition.bpmn_identifier
@ -353,7 +354,6 @@ def task_show(
form_schema_file_name = "" form_schema_file_name = ""
form_ui_schema_file_name = "" form_ui_schema_file_name = ""
extensions = TaskService.get_extensions_from_task_model(task_model)
task_model.signal_buttons = TaskService.get_ready_signals_with_button_labels( task_model.signal_buttons = TaskService.get_ready_signals_with_button_labels(
process_instance_id, task_model.guid process_instance_id, task_model.guid
) )
@ -409,7 +409,8 @@ def task_show(
_munge_form_ui_schema_based_on_hidden_fields_in_task_data(task_model) _munge_form_ui_schema_based_on_hidden_fields_in_task_data(task_model)
JinjaService.render_instructions_for_end_user(task_model, extensions) JinjaService.render_instructions_for_end_user(task_model, extensions)
task_model.extensions = extensions
task_model.extensions = extensions
return make_response(jsonify(task_model), 200) return make_response(jsonify(task_model), 200)
@ -736,7 +737,13 @@ def _task_submit_shared(
) )
if next_human_task_assigned_to_me: if next_human_task_assigned_to_me:
return make_response(jsonify(HumanTaskModel.to_task(next_human_task_assigned_to_me)), 200) return make_response(jsonify(HumanTaskModel.to_task(next_human_task_assigned_to_me)), 200)
elif processor.next_task():
if "guestConfirmation" in spiff_task.task_spec.extensions:
return make_response(
jsonify({"guest_confirmation": spiff_task.task_spec.extensions["guestConfirmation"]}), 200
)
if processor.next_task():
task = ProcessInstanceService.spiff_task_to_api_task(processor, processor.next_task()) task = ProcessInstanceService.spiff_task_to_api_task(processor, processor.next_task())
return make_response(jsonify(task), 200) return make_response(jsonify(task), 200)

View File

@ -16,16 +16,16 @@ from werkzeug.wrappers import Response
from spiffworkflow_backend.exceptions.api_error import ApiError from spiffworkflow_backend.exceptions.api_error import ApiError
from spiffworkflow_backend.helpers.api_version import V1_API_PATH_PREFIX from spiffworkflow_backend.helpers.api_version import V1_API_PATH_PREFIX
from spiffworkflow_backend.models.db import db from spiffworkflow_backend.models.group import SPIFF_GUEST_GROUP
from spiffworkflow_backend.models.group import SPIFF_NO_AUTH_ANONYMOUS_GROUP from spiffworkflow_backend.models.group import SPIFF_NO_AUTH_GROUP
from spiffworkflow_backend.models.group import GroupModel from spiffworkflow_backend.models.task import TaskModel # noqa: F401
from spiffworkflow_backend.models.user import SPIFF_NO_AUTH_ANONYMOUS_USER from spiffworkflow_backend.models.user import SPIFF_GUEST_USER
from spiffworkflow_backend.models.user import SPIFF_NO_AUTH_USER
from spiffworkflow_backend.models.user import UserModel from spiffworkflow_backend.models.user import UserModel
from spiffworkflow_backend.services.authentication_service import AuthenticationService from spiffworkflow_backend.services.authentication_service import AuthenticationService
from spiffworkflow_backend.services.authentication_service import MissingAccessTokenError from spiffworkflow_backend.services.authentication_service import MissingAccessTokenError
from spiffworkflow_backend.services.authentication_service import TokenExpiredError from spiffworkflow_backend.services.authentication_service import TokenExpiredError
from spiffworkflow_backend.services.authorization_service import AuthorizationService from spiffworkflow_backend.services.authorization_service import AuthorizationService
from spiffworkflow_backend.services.group_service import GroupService
from spiffworkflow_backend.services.user_service import UserService from spiffworkflow_backend.services.user_service import UserService
""" """
@ -82,19 +82,8 @@ def verify_token(token: str | None = None, force_run: bool | None = False) -> No
f"Exception in verify_token getting user from decoded internal token. {e}" f"Exception in verify_token getting user from decoded internal token. {e}"
) )
# if the user is the anonymous user and we have auth enabled then make sure we clean up the anonymouse user # if the user is forced logged out then stop processing the token
if ( if _force_logout_user_if_necessary(user_model):
user_model
and not current_app.config.get("SPIFFWORKFLOW_BACKEND_AUTHENTICATION_DISABLED")
and user_model.username == SPIFF_NO_AUTH_ANONYMOUS_USER
and user_model.service_id == "spiff_anonymous_service_id"
):
group_model = GroupModel.query.filter_by(identifier=SPIFF_NO_AUTH_ANONYMOUS_GROUP).first()
db.session.delete(group_model)
db.session.delete(user_model)
db.session.commit()
tld = current_app.config["THREAD_LOCAL_DATA"]
tld.user_has_logged_out = True
return None return None
elif "iss" in decoded_token.keys(): elif "iss" in decoded_token.keys():
@ -211,20 +200,22 @@ def set_new_access_token_in_cookie(
return response return response
def login(redirect_url: str = "/") -> Response: def login(redirect_url: str = "/", process_instance_id: int | None = None, task_guid: str | None = None) -> Response:
if current_app.config.get("SPIFFWORKFLOW_BACKEND_AUTHENTICATION_DISABLED"): if current_app.config.get("SPIFFWORKFLOW_BACKEND_AUTHENTICATION_DISABLED"):
user = UserModel.query.filter_by(username=SPIFF_NO_AUTH_ANONYMOUS_USER).first() AuthorizationService.create_guest_token(
if user is None: username=SPIFF_NO_AUTH_USER,
user = UserService.create_user( group_identifier=SPIFF_NO_AUTH_GROUP,
SPIFF_NO_AUTH_ANONYMOUS_USER, "spiff_anonymous_service", "spiff_anonymous_service_id" permission_target="/*",
) auth_token_properties={"authentication_disabled": True},
GroupService.add_user_to_group_or_add_to_waiting(user.username, SPIFF_NO_AUTH_ANONYMOUS_GROUP) )
AuthorizationService.add_permission_from_uri_or_macro(SPIFF_NO_AUTH_ANONYMOUS_GROUP, "all", "/*") return redirect(redirect_url)
g.user = user
g.token = user.encode_auth_token({"authentication_disabled": True}) if process_instance_id and task_guid and TaskModel.task_guid_allows_guest(task_guid, process_instance_id):
tld = current_app.config["THREAD_LOCAL_DATA"] AuthorizationService.create_guest_token(
tld.new_access_token = g.token username=SPIFF_GUEST_USER,
tld.new_id_token = g.token group_identifier=SPIFF_GUEST_GROUP,
auth_token_properties={"only_guest_task_completion": True},
)
return redirect(redirect_url) return redirect(redirect_url)
state = AuthenticationService.generate_state(redirect_url) state = AuthenticationService.generate_state(redirect_url)
@ -375,3 +366,25 @@ def _clear_auth_tokens_from_thread_local_data() -> None:
delattr(tld, "new_id_token") delattr(tld, "new_id_token")
if hasattr(tld, "user_has_logged_out"): if hasattr(tld, "user_has_logged_out"):
delattr(tld, "user_has_logged_out") delattr(tld, "user_has_logged_out")
def _force_logout_user_if_necessary(user_model: UserModel | None = None) -> bool:
"""Logs out a guest user if certain criteria gets met.
* if the user is a no auth guest and we have auth enabled
* if the user is a guest and goes somewhere else that does not allow guests
"""
if user_model is not None:
if (
not current_app.config.get("SPIFFWORKFLOW_BACKEND_AUTHENTICATION_DISABLED")
and user_model.username == SPIFF_NO_AUTH_USER
and user_model.service_id == "spiff_guest_service_id"
) or (
user_model.username == SPIFF_GUEST_USER
and user_model.service_id == "spiff_guest_service_id"
and not AuthorizationService.request_allows_guest_access()
):
tld = current_app.config["THREAD_LOCAL_DATA"]
tld.user_has_logged_out = True
return True
return False

View File

@ -0,0 +1,20 @@
from typing import Any
from spiffworkflow_backend.models.script_attributes_context import ScriptAttributesContext
from spiffworkflow_backend.scripts.script import Script
from spiffworkflow_backend.services.process_instance_processor import ProcessInstanceProcessor
class GetCurrentTaskInfo(Script):
@staticmethod
def requires_privileged_permissions() -> bool:
"""We have deemed this function safe to run without elevated permissions."""
return False
def get_description(self) -> str:
return """Returns the information about the current task."""
def run(self, script_attributes_context: ScriptAttributesContext, *_args: Any, **kwargs: Any) -> Any:
task_dict = ProcessInstanceProcessor._serializer.task_to_dict(script_attributes_context.task)
task_dict.pop("data")
return task_dict

View File

@ -0,0 +1,46 @@
from typing import Any
from flask import current_app
from spiffworkflow_backend.models.script_attributes_context import ScriptAttributesContext
from spiffworkflow_backend.scripts.script import Script
from spiffworkflow_backend.services.process_instance_processor import ProcessInstanceProcessor
class GetUrlForTaskWithBpmnIdentifier(Script):
@staticmethod
def requires_privileged_permissions() -> bool:
"""We have deemed this function safe to run without elevated permissions."""
return False
def get_description(self) -> str:
return (
"Returns the url to the task show page for a task with the given bpmn identifier. The script task calling"
" this MUST be in the same process as the desired task and should be next to each other in the diagram."
)
def run(self, script_attributes_context: ScriptAttributesContext, *args: Any, **kwargs: Any) -> Any:
bpmn_identifier = args[0]
if bpmn_identifier is None:
raise Exception("Bpmn identifier is required for get_url_for_task_with_bpmn_identifier")
spiff_task = script_attributes_context.task
if spiff_task is None:
raise Exception("Initial spiff task not given to get_url_for_task_with_bpmn_identifier")
desired_spiff_task = ProcessInstanceProcessor.get_task_by_bpmn_identifier(bpmn_identifier, spiff_task.workflow)
if desired_spiff_task is None:
raise Exception(
f"Could not find a task with bpmn identifier '{bpmn_identifier}' in"
" get_url_for_task_with_bpmn_identifier"
)
if not desired_spiff_task.task_spec.manual:
raise Exception(
f"Given bpmn identifier ({bpmn_identifier}) represents a task that cannot be completed by people and"
" therefore it does not have a url to retrieve"
)
guid = str(desired_spiff_task.id)
fe_url = current_app.config["SPIFFWORKFLOW_BACKEND_URL_FOR_FRONTEND"]
url = f"{fe_url}/tasks/{script_attributes_context.process_instance_id}/{guid}"
return url

View File

@ -20,6 +20,7 @@ from spiffworkflow_backend.models.permission_assignment import PermissionAssignm
from spiffworkflow_backend.models.permission_target import PermissionTargetModel from spiffworkflow_backend.models.permission_target import PermissionTargetModel
from spiffworkflow_backend.models.principal import MissingPrincipalError from spiffworkflow_backend.models.principal import MissingPrincipalError
from spiffworkflow_backend.models.principal import PrincipalModel from spiffworkflow_backend.models.principal import PrincipalModel
from spiffworkflow_backend.models.task import TaskModel # noqa: F401
from spiffworkflow_backend.models.user import UserModel from spiffworkflow_backend.models.user import UserModel
from spiffworkflow_backend.models.user_group_assignment import UserGroupAssignmentModel from spiffworkflow_backend.models.user_group_assignment import UserGroupAssignmentModel
from spiffworkflow_backend.routes.openid_blueprint import openid_blueprint from spiffworkflow_backend.routes.openid_blueprint import openid_blueprint
@ -132,6 +133,24 @@ class AuthorizationService:
"unauthorized", "unauthorized",
) )
@classmethod
def create_guest_token(
cls,
username: str,
group_identifier: str,
permission_target: str | None = None,
permission: str = "all",
auth_token_properties: dict | None = None,
) -> None:
guest_user = GroupService.find_or_create_guest_user(username=username, group_identifier=group_identifier)
if permission_target is not None:
cls.add_permission_from_uri_or_macro(group_identifier, permission=permission, target=permission_target)
g.user = guest_user
g.token = guest_user.encode_auth_token(auth_token_properties)
tld = current_app.config["THREAD_LOCAL_DATA"]
tld.new_access_token = g.token
tld.new_id_token = g.token
@classmethod @classmethod
def has_permission(cls, principals: list[PrincipalModel], permission: str, target_uri: str) -> bool: def has_permission(cls, principals: list[PrincipalModel], permission: str, target_uri: str) -> bool:
principal_ids = [p.id for p in principals] principal_ids = [p.id for p in principals]
@ -297,15 +316,15 @@ class AuthorizationService:
if cls.should_disable_auth_for_request(): if cls.should_disable_auth_for_request():
return None return None
authorization_exclusion_list = ["permissions_check"]
if not hasattr(g, "user"): if not hasattr(g, "user"):
raise UserNotLoggedInError( raise UserNotLoggedInError(
"User is not logged in. Please log in", "User is not logged in. Please log in",
) )
api_view_function = current_app.view_functions[request.endpoint] if cls.request_is_excluded_from_permission_check():
if api_view_function and api_view_function.__name__ in authorization_exclusion_list: return None
if cls.request_allows_guest_access():
return None return None
permission_string = cls.get_permission_from_http_method(request.method) permission_string = cls.get_permission_from_http_method(request.method)
@ -325,6 +344,27 @@ class AuthorizationService:
), ),
) )
@classmethod
def request_is_excluded_from_permission_check(cls) -> bool:
authorization_exclusion_list = ["permissions_check"]
api_view_function = current_app.view_functions[request.endpoint]
if api_view_function and api_view_function.__name__ in authorization_exclusion_list:
return True
return False
@classmethod
def request_allows_guest_access(cls) -> bool:
if cls.request_is_excluded_from_permission_check():
return True
api_view_function = current_app.view_functions[request.endpoint]
if api_view_function.__name__ in ["task_show", "task_submit", "task_save_draft"]:
process_instance_id = int(request.path.split("/")[3])
task_guid = request.path.split("/")[4]
if TaskModel.task_guid_allows_guest(task_guid, process_instance_id):
return True
return False
@staticmethod @staticmethod
def decode_auth_token(auth_token: str) -> dict[str, str | None]: def decode_auth_token(auth_token: str) -> dict[str, str | None]:
secret_key = current_app.config.get("SECRET_KEY") secret_key = current_app.config.get("SECRET_KEY")

View File

@ -1,5 +1,7 @@
from spiffworkflow_backend.models.db import db from spiffworkflow_backend.models.db import db
from spiffworkflow_backend.models.group import SPIFF_GUEST_GROUP
from spiffworkflow_backend.models.group import GroupModel from spiffworkflow_backend.models.group import GroupModel
from spiffworkflow_backend.models.user import SPIFF_GUEST_USER
from spiffworkflow_backend.models.user import UserModel from spiffworkflow_backend.models.user import UserModel
from spiffworkflow_backend.services.user_service import UserService from spiffworkflow_backend.services.user_service import UserService
@ -23,3 +25,16 @@ class GroupService:
UserService.add_user_to_group(user, group) UserService.add_user_to_group(user, group)
else: else:
UserService.add_waiting_group_assignment(username, group) UserService.add_waiting_group_assignment(username, group)
@classmethod
def find_or_create_guest_user(
cls, username: str = SPIFF_GUEST_USER, group_identifier: str = SPIFF_GUEST_GROUP
) -> UserModel:
guest_user: UserModel | None = UserModel.query.filter_by(
username=username, service="spiff_guest_service", service_id="spiff_guest_service_id"
).first()
if guest_user is None:
guest_user = UserService.create_user(username, "spiff_guest_service", "spiff_guest_service_id")
GroupService.add_user_to_group_or_add_to_waiting(guest_user.username, group_identifier)
return guest_user

View File

@ -72,6 +72,7 @@ from spiffworkflow_backend.scripts.script import Script
from spiffworkflow_backend.services.custom_parser import MyCustomParser from spiffworkflow_backend.services.custom_parser import MyCustomParser
from spiffworkflow_backend.services.element_units_service import ElementUnitsService from spiffworkflow_backend.services.element_units_service import ElementUnitsService
from spiffworkflow_backend.services.file_system_service import FileSystemService from spiffworkflow_backend.services.file_system_service import FileSystemService
from spiffworkflow_backend.services.group_service import GroupService
from spiffworkflow_backend.services.jinja_service import JinjaHelpers from spiffworkflow_backend.services.jinja_service import JinjaHelpers
from spiffworkflow_backend.services.process_instance_queue_service import ProcessInstanceQueueService from spiffworkflow_backend.services.process_instance_queue_service import ProcessInstanceQueueService
from spiffworkflow_backend.services.process_instance_tmp_service import ProcessInstanceTmpService from spiffworkflow_backend.services.process_instance_tmp_service import ProcessInstanceTmpService
@ -780,7 +781,11 @@ class ProcessInstanceProcessor:
potential_owner_ids = [] potential_owner_ids = []
lane_assignment_id = None lane_assignment_id = None
if re.match(r"(process.?)initiator", task_lane, re.IGNORECASE):
if "allowGuest" in task.task_spec.extensions and task.task_spec.extensions["allowGuest"] == "true":
guest_user = GroupService.find_or_create_guest_user()
potential_owner_ids = [guest_user.id]
elif re.match(r"(process.?)initiator", task_lane, re.IGNORECASE):
potential_owner_ids = [self.process_instance_model.process_initiator_id] potential_owner_ids = [self.process_instance_model.process_initiator_id]
else: else:
group_model = GroupModel.query.filter_by(identifier=task_lane).first() group_model = GroupModel.query.filter_by(identifier=task_lane).first()

View File

@ -0,0 +1,70 @@
<?xml version="1.0" encoding="UTF-8"?>
<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI" xmlns:dc="http://www.omg.org/spec/DD/20100524/DC" xmlns:di="http://www.omg.org/spec/DD/20100524/DI" xmlns:spiffworkflow="http://spiffworkflow.org/bpmn/schema/1.0/core" id="Definitions_96f6665" targetNamespace="http://bpmn.io/schema/bpmn" exporter="Camunda Modeler" exporterVersion="3.0.0-dev">
<bpmn:process id="Process_czdgvu1" isExecutable="true">
<bpmn:startEvent id="StartEvent_1">
<bpmn:outgoing>Flow_0xsrhef</bpmn:outgoing>
</bpmn:startEvent>
<bpmn:sequenceFlow id="Flow_0xsrhef" sourceRef="StartEvent_1" targetRef="manual_task_one" />
<bpmn:endEvent id="Event_1qsae34">
<bpmn:incoming>Flow_02dvhev</bpmn:incoming>
</bpmn:endEvent>
<bpmn:sequenceFlow id="Flow_0l1pg29" sourceRef="manual_task_one" targetRef="script_task" />
<bpmn:sequenceFlow id="Flow_02dvhev" sourceRef="manual_task_two" targetRef="Event_1qsae34" />
<bpmn:manualTask id="manual_task_two">
<bpmn:extensionElements>
<spiffworkflow:allowGuest>true</spiffworkflow:allowGuest>
<spiffworkflow:guestConfirmation>You have completed the task.</spiffworkflow:guestConfirmation>
</bpmn:extensionElements>
<bpmn:incoming>Flow_14w7df0</bpmn:incoming>
<bpmn:outgoing>Flow_02dvhev</bpmn:outgoing>
</bpmn:manualTask>
<bpmn:manualTask id="manual_task_one">
<bpmn:extensionElements>
<spiffworkflow:allowGuest>true</spiffworkflow:allowGuest>
</bpmn:extensionElements>
<bpmn:incoming>Flow_0xsrhef</bpmn:incoming>
<bpmn:outgoing>Flow_0l1pg29</bpmn:outgoing>
</bpmn:manualTask>
<bpmn:sequenceFlow id="Flow_14w7df0" sourceRef="script_task" targetRef="manual_task_two" />
<bpmn:scriptTask id="script_task">
<bpmn:incoming>Flow_0l1pg29</bpmn:incoming>
<bpmn:outgoing>Flow_14w7df0</bpmn:outgoing>
<bpmn:script>a = 1</bpmn:script>
</bpmn:scriptTask>
</bpmn:process>
<bpmndi:BPMNDiagram id="BPMNDiagram_1">
<bpmndi:BPMNPlane id="BPMNPlane_1" bpmnElement="Process_czdgvu1">
<bpmndi:BPMNShape id="Event_1qsae34_di" bpmnElement="Event_1qsae34">
<dc:Bounds x="642" y="159" width="36" height="36" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Activity_12fbatl_di" bpmnElement="manual_task_two">
<dc:Bounds x="500" y="137" width="100" height="80" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Activity_05mndd3_di" bpmnElement="manual_task_one">
<dc:Bounds x="230" y="137" width="100" height="80" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="_BPMNShape_StartEvent_2" bpmnElement="StartEvent_1">
<dc:Bounds x="162" y="159" width="36" height="36" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Activity_0otxewg_di" bpmnElement="script_task">
<dc:Bounds x="380" y="137" width="100" height="80" />
</bpmndi:BPMNShape>
<bpmndi:BPMNEdge id="Flow_0xsrhef_di" bpmnElement="Flow_0xsrhef">
<di:waypoint x="198" y="177" />
<di:waypoint x="230" y="177" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_0l1pg29_di" bpmnElement="Flow_0l1pg29">
<di:waypoint x="330" y="177" />
<di:waypoint x="380" y="177" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_02dvhev_di" bpmnElement="Flow_02dvhev">
<di:waypoint x="600" y="177" />
<di:waypoint x="642" y="177" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_14w7df0_di" bpmnElement="Flow_14w7df0">
<di:waypoint x="480" y="177" />
<di:waypoint x="500" y="177" />
</bpmndi:BPMNEdge>
</bpmndi:BPMNPlane>
</bpmndi:BPMNDiagram>
</bpmn:definitions>

View File

@ -0,0 +1,9 @@
{
"description": "",
"display_name": "hey",
"exception_notification_addresses": [],
"fault_or_suspend_on_exception": "fault",
"metadata_extraction_paths": null,
"primary_file_name": "test-get-current-task-info-script.bpmn",
"primary_process_id": "Process_vfh8kgr"
}

View File

@ -0,0 +1,55 @@
<?xml version="1.0" encoding="UTF-8"?>
<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI" xmlns:dc="http://www.omg.org/spec/DD/20100524/DC" xmlns:spiffworkflow="http://spiffworkflow.org/bpmn/schema/1.0/core" xmlns:di="http://www.omg.org/spec/DD/20100524/DI" id="Definitions_96f6665" targetNamespace="http://bpmn.io/schema/bpmn" exporter="Camunda Modeler" exporterVersion="3.0.0-dev">
<bpmn:process id="Process_vfh8kgr" isExecutable="true">
<bpmn:startEvent id="StartEvent_1">
<bpmn:outgoing>Flow_1jnpyt6</bpmn:outgoing>
</bpmn:startEvent>
<bpmn:sequenceFlow id="Flow_1jnpyt6" sourceRef="StartEvent_1" targetRef="get_task_info" />
<bpmn:endEvent id="Event_1py5403">
<bpmn:incoming>Flow_0gcbbpt</bpmn:incoming>
</bpmn:endEvent>
<bpmn:sequenceFlow id="Flow_1hi0iix" sourceRef="get_task_info" targetRef="manual_task" />
<bpmn:scriptTask id="get_task_info">
<bpmn:incoming>Flow_1jnpyt6</bpmn:incoming>
<bpmn:outgoing>Flow_1hi0iix</bpmn:outgoing>
<bpmn:script>script_task_info = get_current_task_info()</bpmn:script>
</bpmn:scriptTask>
<bpmn:sequenceFlow id="Flow_0gcbbpt" sourceRef="manual_task" targetRef="Event_1py5403" />
<bpmn:manualTask id="manual_task">
<bpmn:extensionElements>
<spiffworkflow:postScript />
<spiffworkflow:preScript>manual_task_info = get_current_task_info()</spiffworkflow:preScript>
</bpmn:extensionElements>
<bpmn:incoming>Flow_1hi0iix</bpmn:incoming>
<bpmn:outgoing>Flow_0gcbbpt</bpmn:outgoing>
</bpmn:manualTask>
</bpmn:process>
<bpmndi:BPMNDiagram id="BPMNDiagram_1">
<bpmndi:BPMNPlane id="BPMNPlane_1" bpmnElement="Process_vfh8kgr">
<bpmndi:BPMNShape id="_BPMNShape_StartEvent_2" bpmnElement="StartEvent_1">
<dc:Bounds x="179" y="159" width="36" height="36" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Activity_18p9gvm_di" bpmnElement="get_task_info">
<dc:Bounds x="270" y="137" width="100" height="80" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Event_1py5403_di" bpmnElement="Event_1py5403">
<dc:Bounds x="592" y="159" width="36" height="36" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Activity_0n1z5ct_di" bpmnElement="manual_task">
<dc:Bounds x="420" y="137" width="100" height="80" />
</bpmndi:BPMNShape>
<bpmndi:BPMNEdge id="Flow_1jnpyt6_di" bpmnElement="Flow_1jnpyt6">
<di:waypoint x="215" y="177" />
<di:waypoint x="270" y="177" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_1hi0iix_di" bpmnElement="Flow_1hi0iix">
<di:waypoint x="370" y="177" />
<di:waypoint x="420" y="177" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_0gcbbpt_di" bpmnElement="Flow_0gcbbpt">
<di:waypoint x="520" y="177" />
<di:waypoint x="592" y="177" />
</bpmndi:BPMNEdge>
</bpmndi:BPMNPlane>
</bpmndi:BPMNDiagram>
</bpmn:definitions>

View File

@ -0,0 +1,9 @@
{
"description": "",
"display_name": "test-get-url-for-task",
"exception_notification_addresses": [],
"fault_or_suspend_on_exception": "fault",
"metadata_extraction_paths": null,
"primary_file_name": "test-get-url-for-task.bpmn",
"primary_process_id": "Process_2jd03k0"
}

View File

@ -0,0 +1,51 @@
<?xml version="1.0" encoding="UTF-8"?>
<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI" xmlns:dc="http://www.omg.org/spec/DD/20100524/DC" xmlns:di="http://www.omg.org/spec/DD/20100524/DI" id="Definitions_96f6665" targetNamespace="http://bpmn.io/schema/bpmn" exporter="Camunda Modeler" exporterVersion="3.0.0-dev">
<bpmn:process id="Process_2jd03k0" isExecutable="true">
<bpmn:startEvent id="StartEvent_1">
<bpmn:outgoing>Flow_1sk03xw</bpmn:outgoing>
</bpmn:startEvent>
<bpmn:sequenceFlow id="Flow_1sk03xw" sourceRef="StartEvent_1" targetRef="script_task" />
<bpmn:endEvent id="Event_1skunad">
<bpmn:incoming>Flow_1xw30wr</bpmn:incoming>
</bpmn:endEvent>
<bpmn:sequenceFlow id="Flow_1ckjs49" sourceRef="script_task" targetRef="manual_task" />
<bpmn:scriptTask id="script_task">
<bpmn:incoming>Flow_1sk03xw</bpmn:incoming>
<bpmn:outgoing>Flow_1ckjs49</bpmn:outgoing>
<bpmn:script>url = get_url_for_task_with_bpmn_identifier("manual_task")</bpmn:script>
</bpmn:scriptTask>
<bpmn:manualTask id="manual_task">
<bpmn:incoming>Flow_1ckjs49</bpmn:incoming>
<bpmn:outgoing>Flow_1xw30wr</bpmn:outgoing>
</bpmn:manualTask>
<bpmn:sequenceFlow id="Flow_1xw30wr" sourceRef="manual_task" targetRef="Event_1skunad" />
</bpmn:process>
<bpmndi:BPMNDiagram id="BPMNDiagram_1">
<bpmndi:BPMNPlane id="BPMNPlane_1" bpmnElement="Process_2jd03k0">
<bpmndi:BPMNShape id="_BPMNShape_StartEvent_2" bpmnElement="StartEvent_1">
<dc:Bounds x="179" y="159" width="36" height="36" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Activity_15lnnd2_di" bpmnElement="script_task">
<dc:Bounds x="270" y="137" width="100" height="80" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Event_1skunad_di" bpmnElement="Event_1skunad">
<dc:Bounds x="552" y="159" width="36" height="36" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Activity_0ozc1ka_di" bpmnElement="manual_task">
<dc:Bounds x="410" y="137" width="100" height="80" />
</bpmndi:BPMNShape>
<bpmndi:BPMNEdge id="Flow_1sk03xw_di" bpmnElement="Flow_1sk03xw">
<di:waypoint x="215" y="177" />
<di:waypoint x="270" y="177" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_1ckjs49_di" bpmnElement="Flow_1ckjs49">
<di:waypoint x="370" y="177" />
<di:waypoint x="410" y="177" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_1xw30wr_di" bpmnElement="Flow_1xw30wr">
<di:waypoint x="510" y="177" />
<di:waypoint x="552" y="177" />
</bpmndi:BPMNEdge>
</bpmndi:BPMNPlane>
</bpmndi:BPMNDiagram>
</bpmn:definitions>

View File

@ -31,7 +31,6 @@ class TestTasksController(BaseTest):
with_super_admin_user, with_super_admin_user,
process_group_id=process_group_id, process_group_id=process_group_id,
process_model_id=process_model_id, process_model_id=process_model_id,
# bpmn_file_name=bpmn_file_name,
bpmn_file_location=bpmn_file_location, bpmn_file_location=bpmn_file_location,
) )
@ -387,3 +386,83 @@ class TestTasksController(BaseTest):
assert response.json is not None assert response.json is not None
assert response.json["saved_form_data"] is None assert response.json["saved_form_data"] is None
assert response.json["data"]["HEY"] == draft_data["HEY"] assert response.json["data"]["HEY"] == draft_data["HEY"]
def test_can_complete_complete_a_guest_task(
self,
app: Flask,
client: FlaskClient,
with_db_and_bpmn_file_cleanup: None,
with_super_admin_user: UserModel,
) -> None:
process_group_id = "my_process_group"
process_model_id = "test-allow-guest"
bpmn_file_location = "test-allow-guest"
process_model = self.create_group_and_model_with_bpmn(
client,
with_super_admin_user,
process_group_id=process_group_id,
process_model_id=process_model_id,
bpmn_file_location=bpmn_file_location,
)
headers = self.logged_in_headers(with_super_admin_user)
response = self.create_process_instance_from_process_model_id_with_api(client, process_model.id, headers)
assert response.json is not None
process_instance_id = response.json["id"]
response = client.post(
f"/v1.0/process-instances/{self.modify_process_identifier_for_path_param(process_model.id)}/{process_instance_id}/run",
headers=self.logged_in_headers(with_super_admin_user),
)
assert response.status_code == 200
assert response.json is not None
assert "next_task" in response.json
task_guid = response.json["next_task"]["id"]
assert task_guid is not None
# log in a guest user to complete the tasks
redirect_url = "/test-redirect-dne"
response = client.get(
f"/v1.0/login?process_instance_id={process_instance_id}&task_guid={task_guid}&redirect_url={redirect_url}",
)
assert response.status_code == 302
assert response.location == redirect_url
headers_dict = dict(response.headers)
assert "Set-Cookie" in headers_dict
cookie = headers_dict["Set-Cookie"]
access_token = cookie.split(";")[0].split("=")[1]
assert access_token is not None
# ensure guest user can get and complete both guest manual tasks
response = client.get(
f"/v1.0/tasks/{process_instance_id}/{task_guid}",
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == 200
response = client.put(
f"/v1.0/tasks/{process_instance_id}/{task_guid}",
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == 200
assert response.json is not None
task_guid = response.json["id"]
assert task_guid is not None
response = client.put(
f"/v1.0/tasks/{process_instance_id}/{task_guid}",
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == 200
assert response.json is not None
assert "guest_confirmation" in response.json
# ensure user gets logged out when they try to go anywhere else
response = client.get(
"/v1.0/tasks",
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == 403
headers_dict = dict(response.headers)
assert "Set-Cookie" in headers_dict
cookie = headers_dict["Set-Cookie"]
access_token = cookie.split(";")[0].split("=")[1]
assert access_token == ""

View File

@ -0,0 +1,46 @@
from flask.app import Flask
from spiffworkflow_backend.models.process_instance import ProcessInstanceStatus
from spiffworkflow_backend.services.authorization_service import AuthorizationService
from spiffworkflow_backend.services.process_instance_processor import ProcessInstanceProcessor
from spiffworkflow_backend.services.process_instance_service import ProcessInstanceService
from tests.spiffworkflow_backend.helpers.base_test import BaseTest
from tests.spiffworkflow_backend.helpers.test_data import load_test_spec
class TestGetCurrentTaskInfo(BaseTest):
def test_get_current_task_info_works(
self,
app: Flask,
with_db_and_bpmn_file_cleanup: None,
) -> None:
initiator_user = self.find_or_create_user("initiator_user")
assert initiator_user.principal is not None
AuthorizationService.import_permissions_from_yaml_file()
process_model = load_test_spec(
process_model_id="misc/test-get-current-task-info-script",
process_model_source_directory="test-get-current-task-info-script",
)
process_instance = self.create_process_instance_from_process_model(
process_model=process_model, user=initiator_user
)
processor = ProcessInstanceProcessor(process_instance)
processor.do_engine_steps(save=True)
assert len(process_instance.active_human_tasks) == 1
human_task = process_instance.active_human_tasks[0]
assert len(human_task.potential_owners) == 1
assert human_task.potential_owners[0] == initiator_user
spiff_task = processor.__class__.get_task_by_bpmn_identifier(
human_task.task_name, processor.bpmn_process_instance
)
ProcessInstanceService.complete_form_task(processor, spiff_task, {}, initiator_user, human_task)
assert process_instance.status == ProcessInstanceStatus.complete.value
assert spiff_task is not None
assert "script_task_info" in spiff_task.data
assert spiff_task.data["script_task_info"]["id"] is not None
assert "manual_task_info" in spiff_task.data
assert spiff_task.data["manual_task_info"]["id"] is not None
assert isinstance(spiff_task.data["manual_task_info"]["id"], str)

View File

@ -0,0 +1,46 @@
from flask.app import Flask
from spiffworkflow_backend.models.process_instance import ProcessInstanceStatus
from spiffworkflow_backend.services.authorization_service import AuthorizationService
from spiffworkflow_backend.services.process_instance_processor import ProcessInstanceProcessor
from spiffworkflow_backend.services.process_instance_service import ProcessInstanceService
from tests.spiffworkflow_backend.helpers.base_test import BaseTest
from tests.spiffworkflow_backend.helpers.test_data import load_test_spec
class TestGetUrlForTaskWithBpmnIdentifier(BaseTest):
def test_get_url_for_task_works(
self,
app: Flask,
with_db_and_bpmn_file_cleanup: None,
) -> None:
initiator_user = self.find_or_create_user("initiator_user")
assert initiator_user.principal is not None
AuthorizationService.import_permissions_from_yaml_file()
process_model = load_test_spec(
process_model_id="misc/test-get-url-for-task-with-bpmn-identifier",
process_model_source_directory="test-get-url-for-task-with-bpmn-identifier",
)
process_instance = self.create_process_instance_from_process_model(
process_model=process_model, user=initiator_user
)
processor = ProcessInstanceProcessor(process_instance)
processor.do_engine_steps(save=True)
assert len(process_instance.active_human_tasks) == 1
human_task = process_instance.active_human_tasks[0]
assert len(human_task.potential_owners) == 1
assert human_task.potential_owners[0] == initiator_user
spiff_task = processor.__class__.get_task_by_bpmn_identifier(
human_task.task_name, processor.bpmn_process_instance
)
ProcessInstanceService.complete_form_task(processor, spiff_task, {}, initiator_user, human_task)
assert process_instance.status == ProcessInstanceStatus.complete.value
assert spiff_task is not None
assert "url" in spiff_task.data
fe_url = app.config["SPIFFWORKFLOW_BACKEND_URL_FOR_FRONTEND"]
expected_url = f"{fe_url}/tasks/{process_instance.id}/{str(spiff_task.id)}"
assert spiff_task.data["url"] == expected_url

View File

@ -63,8 +63,13 @@ export default function NavigationBar() {
[targetUris.messageInstanceListPath]: ['GET'], [targetUris.messageInstanceListPath]: ['GET'],
[targetUris.secretListPath]: ['GET'], [targetUris.secretListPath]: ['GET'],
[targetUris.dataStoreListPath]: ['GET'], [targetUris.dataStoreListPath]: ['GET'],
[targetUris.extensionListPath]: ['GET'],
[targetUris.processInstanceListForMePath]: ['POST'],
[targetUris.processGroupListPath]: ['GET'],
}; };
const { ability } = usePermissionFetcher(permissionRequestData); const { ability, permissionsLoaded } = usePermissionFetcher(
permissionRequestData
);
// default to readthedocs and let someone specify an environment variable to override: // default to readthedocs and let someone specify an environment variable to override:
// //
@ -97,7 +102,12 @@ export default function NavigationBar() {
setActiveKey(newActiveKey); setActiveKey(newActiveKey);
}, [location]); }, [location]);
// eslint-disable-next-line sonarjs/cognitive-complexity
useEffect(() => { useEffect(() => {
if (!permissionsLoaded) {
return;
}
const processExtensionResult = (processModels: ProcessModel[]) => { const processExtensionResult = (processModels: ProcessModel[]) => {
const eni: UiSchemaNavItem[] = processModels const eni: UiSchemaNavItem[] = processModels
.map((processModel: ProcessModel) => { .map((processModel: ProcessModel) => {
@ -126,11 +136,13 @@ export default function NavigationBar() {
} }
}; };
HttpService.makeCallToBackend({ if (ability.can('GET', targetUris.extensionListPath)) {
path: targetUris.extensionListPath, HttpService.makeCallToBackend({
successCallback: processExtensionResult, path: targetUris.extensionListPath,
}); successCallback: processExtensionResult,
}, [targetUris.extensionListPath]); });
}
}, [targetUris.extensionListPath, permissionsLoaded, ability]);
const isActivePage = (menuItemPath: string) => { const isActivePage = (menuItemPath: string) => {
return activeKey === menuItemPath; return activeKey === menuItemPath;
@ -278,19 +290,27 @@ export default function NavigationBar() {
<HeaderMenuItem href="/" isCurrentPage={isActivePage('/')}> <HeaderMenuItem href="/" isCurrentPage={isActivePage('/')}>
Home Home
</HeaderMenuItem> </HeaderMenuItem>
<HeaderMenuItem <Can I="GET" a={targetUris.processGroupListPath} ability={ability}>
href="/admin/process-groups" <HeaderMenuItem
isCurrentPage={isActivePage('/admin/process-groups')} href="/admin/process-groups"
data-qa="header-nav-processes" isCurrentPage={isActivePage('/admin/process-groups')}
data-qa="header-nav-processes"
>
Processes
</HeaderMenuItem>
</Can>
<Can
I="POST"
a={targetUris.processInstanceListForMePath}
ability={ability}
> >
Processes <HeaderMenuItem
</HeaderMenuItem> href="/admin/process-instances"
<HeaderMenuItem isCurrentPage={isActivePage('/admin/process-instances')}
href="/admin/process-instances" >
isCurrentPage={isActivePage('/admin/process-instances')} Process Instances
> </HeaderMenuItem>
Process Instances </Can>
</HeaderMenuItem>
<Can I="GET" a={targetUris.messageInstanceListPath} ability={ability}> <Can I="GET" a={targetUris.messageInstanceListPath} ability={ability}>
<HeaderMenuItem <HeaderMenuItem
href="/admin/messages" href="/admin/messages"
@ -313,7 +333,7 @@ export default function NavigationBar() {
); );
}; };
if (activeKey && ability) { if (activeKey && ability && !UserService.onlyGuestTaskCompletion()) {
return ( return (
<HeaderContainer <HeaderContainer
render={({ isSideNavExpanded, onClickSideNavExpand }: any) => ( render={({ isSideNavExpanded, onClickSideNavExpand }: any) => (

View File

@ -24,6 +24,7 @@ export default function ProcessBreadcrumb({ hotCrumbs }: OwnProps) {
if ('entityToExplode' in crumb) { if ('entityToExplode' in crumb) {
const { entityToExplode, entityType } = crumb; const { entityToExplode, entityType } = crumb;
if (entityType === 'process-model-id') { if (entityType === 'process-model-id') {
console.log('crumb', crumb);
HttpService.makeCallToBackend({ HttpService.makeCallToBackend({
path: `/process-models/${modifyProcessIdentifierForPathParam( path: `/process-models/${modifyProcessIdentifierForPathParam(
entityToExplode as string entityToExplode as string

View File

@ -70,6 +70,8 @@ export interface BasicTask {
process_model_identifier: string; process_model_identifier: string;
name_for_display: string; name_for_display: string;
can_complete: boolean; can_complete: boolean;
extensions?: any;
} }
// TODO: merge with ProcessInstanceTask // TODO: merge with ProcessInstanceTask

View File

@ -11,14 +11,17 @@ export default function OnboardingView() {
const navigate = useNavigate(); const navigate = useNavigate();
useEffect(() => { useEffect(() => {
if (location.pathname.match(/^\/tasks\/\d+\//)) {
return;
}
HttpService.makeCallToBackend({ HttpService.makeCallToBackend({
path: `/onboarding`, path: `/onboarding`,
successCallback: setOnboarding, successCallback: setOnboarding,
}); });
}, [setOnboarding]); }, [setOnboarding, location.pathname]);
const onboardingElement = () => { const onboardingElement = () => {
if (location.pathname.match(/^\/tasks\/\d+\/\b/)) { if (location.pathname.match(/^\/tasks\/\d+\//)) {
return null; return null;
} }

View File

@ -4,6 +4,7 @@ import { useNavigate, useParams } from 'react-router-dom';
import { Grid, Column, Button, ButtonSet, Loading } from '@carbon/react'; import { Grid, Column, Button, ButtonSet, Loading } from '@carbon/react';
import { useDebouncedCallback } from 'use-debounce'; import { useDebouncedCallback } from 'use-debounce';
import MDEditor from '@uiw/react-md-editor';
import HttpService from '../services/HttpService'; import HttpService from '../services/HttpService';
import useAPIError from '../hooks/UseApiError'; import useAPIError from '../hooks/UseApiError';
import { import {
@ -12,17 +13,26 @@ import {
recursivelyChangeNullAndUndefined, recursivelyChangeNullAndUndefined,
} from '../helpers'; } from '../helpers';
import { BasicTask, EventDefinition, Task } from '../interfaces'; import { BasicTask, EventDefinition, Task } from '../interfaces';
import CustomForm from '../components/CustomForm';
import ProcessBreadcrumb from '../components/ProcessBreadcrumb'; import ProcessBreadcrumb from '../components/ProcessBreadcrumb';
import InstructionsForEndUser from '../components/InstructionsForEndUser'; import InstructionsForEndUser from '../components/InstructionsForEndUser';
import CustomForm from '../components/CustomForm'; import UserService from '../services/UserService';
export default function TaskShow() { export default function TaskShow() {
// get a basic task which doesn't get the form data so we can load
// the basic form and structure of the page without waiting for form data.
// this was mainly to help with loading form data with large files attached to it
const [basicTask, setBasicTask] = useState<BasicTask | null>(null); const [basicTask, setBasicTask] = useState<BasicTask | null>(null);
const [taskWithTaskData, setTaskWithTaskData] = useState<Task | null>(null); const [taskWithTaskData, setTaskWithTaskData] = useState<Task | null>(null);
const params = useParams(); const params = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const [formButtonsDisabled, setFormButtonsDisabled] = useState(false); const [formButtonsDisabled, setFormButtonsDisabled] = useState(false);
const [guestConfirmationText, setGuestConfirmationText] = useState<
string | null
>(null);
const [taskData, setTaskData] = useState<any>(null); const [taskData, setTaskData] = useState<any>(null);
const [autosaveOnFormChanges, setAutosaveOnFormChanges] = const [autosaveOnFormChanges, setAutosaveOnFormChanges] =
useState<boolean>(true); useState<boolean>(true);
@ -32,11 +42,15 @@ export default function TaskShow() {
// if a user can complete a task then the for-me page should // if a user can complete a task then the for-me page should
// always work for them so use that since it will work in all cases // always work for them so use that since it will work in all cases
const navigateToInterstitial = (myTask: BasicTask) => { const navigateToInterstitial = (myTask: BasicTask) => {
navigate( if (UserService.onlyGuestTaskCompletion()) {
`/admin/process-instances/for-me/${modifyProcessIdentifierForPathParam( setGuestConfirmationText('Thank you!');
myTask.process_model_identifier } else {
)}/${myTask.process_instance_id}/interstitial` navigate(
); `/admin/process-instances/for-me/${modifyProcessIdentifierForPathParam(
myTask.process_model_identifier
)}/${myTask.process_instance_id}/interstitial`
);
}
}; };
useEffect(() => { useEffect(() => {
@ -92,13 +106,18 @@ export default function TaskShow() {
}; };
const sendAutosaveEvent = (eventDetails?: any) => { const sendAutosaveEvent = (eventDetails?: any) => {
(document.getElementById('hidden-form-for-autosave') as any).dispatchEvent( const elementToDispath: any = document.getElementById(
new CustomEvent('submit', { 'hidden-form-for-autosave'
cancelable: true,
bubbles: true,
detail: eventDetails,
})
); );
if (elementToDispath) {
elementToDispath.dispatchEvent(
new CustomEvent('submit', {
cancelable: true,
bubbles: true,
detail: eventDetails,
})
);
}
}; };
const addDebouncedTaskDataAutoSave = useDebouncedCallback( const addDebouncedTaskDataAutoSave = useDebouncedCallback(
@ -115,6 +134,8 @@ export default function TaskShow() {
removeError(); removeError();
if (result.ok) { if (result.ok) {
navigate(`/tasks`); navigate(`/tasks`);
} else if (result.guest_confirmation) {
setGuestConfirmationText(result.guest_confirmation);
} else if (result.process_instance_id) { } else if (result.process_instance_id) {
if (result.can_complete) { if (result.can_complete) {
navigate(`/tasks/${result.process_instance_id}/${result.id}`); navigate(`/tasks/${result.process_instance_id}/${result.id}`);
@ -239,7 +260,10 @@ export default function TaskShow() {
let closeButton = null; let closeButton = null;
if (taskWithTaskData.typename === 'ManualTask') { if (taskWithTaskData.typename === 'ManualTask') {
submitButtonText = 'Continue'; submitButtonText = 'Continue';
} else if (taskWithTaskData.typename === 'UserTask') { } else if (
taskWithTaskData.typename === 'UserTask' &&
!UserService.onlyGuestTaskCompletion()
) {
closeButton = ( closeButton = (
<Button <Button
id="close-button" id="close-button"
@ -248,7 +272,7 @@ export default function TaskShow() {
kind="secondary" kind="secondary"
title="Save data as draft and close the form." title="Save data as draft and close the form."
> >
Save and Close Save and close
</Button> </Button>
); );
} }
@ -328,27 +352,39 @@ export default function TaskShow() {
statusString = ` ${basicTask.state}`; statusString = ` ${basicTask.state}`;
} }
pageElements.push( if (
<ProcessBreadcrumb !('allowGuest' in basicTask.extensions) ||
hotCrumbs={[ basicTask.extensions.allowGuest !== 'true'
[ ) {
`Process Instance Id: ${params.process_instance_id}`, pageElements.push(
`/admin/process-instances/for-me/${modifyProcessIdentifierForPathParam( <ProcessBreadcrumb
basicTask.process_model_identifier hotCrumbs={[
)}/${params.process_instance_id}`, [
], `Process Instance Id: ${params.process_instance_id}`,
[`Task: ${basicTask.name_for_display || basicTask.id}`], `/admin/process-instances/for-me/${modifyProcessIdentifierForPathParam(
]} basicTask.process_model_identifier
/> )}/${params.process_instance_id}`,
); ],
pageElements.push( [`Task: ${basicTask.name_for_display || basicTask.id}`],
<h1> ]}
Task: {basicTask.name_for_display} ( />
{basicTask.process_model_display_name}){statusString} );
</h1> pageElements.push(
); <h3>
Task: {basicTask.name_for_display} (
{basicTask.process_model_display_name}){statusString}
</h3>
);
}
} }
if (basicTask && taskData) {
if (guestConfirmationText) {
pageElements.push(
<div data-color-mode="light">
<MDEditor.Markdown linkTarget="_blank" source={guestConfirmationText} />
</div>
);
} else if (basicTask && taskData) {
pageElements.push(<InstructionsForEndUser task={taskWithTaskData} />); pageElements.push(<InstructionsForEndUser task={taskWithTaskData} />);
pageElements.push(formElement()); pageElements.push(formElement());
} else { } else {

View File

@ -29,8 +29,28 @@ const getCurrentLocation = (queryParams: string = window.location.search) => {
); );
}; };
const checkPathForTaskShowParams = () => {
// expected url pattern:
// /tasks/[process_instance_id]/[task_guid]
const pathSegments = window.location.pathname.match(
/^\/tasks\/(\d+)\/([0-9a-z]{8}-([0-9a-z]{4}-){3}[0-9a-z]{12})$/
);
if (pathSegments) {
return { process_instance_id: pathSegments[1], task_guid: pathSegments[2] };
}
return null;
};
const doLogin = () => { const doLogin = () => {
const url = `${BACKEND_BASE_URL}/login?redirect_url=${getCurrentLocation()}`; const taskShowParams = checkPathForTaskShowParams();
const loginParams = [`redirect_url=${getCurrentLocation()}`];
if (taskShowParams) {
loginParams.push(
`process_instance_id=${taskShowParams.process_instance_id}`
);
loginParams.push(`task_guid=${taskShowParams.task_guid}`);
}
const url = `${BACKEND_BASE_URL}/login?${loginParams.join('&')}`;
window.location.href = url; window.location.href = url;
}; };
@ -71,6 +91,15 @@ const authenticationDisabled = () => {
return false; return false;
}; };
const onlyGuestTaskCompletion = () => {
const idToken = getIdToken();
if (idToken) {
const idObject = jwt(idToken);
return (idObject as any).only_guest_task_completion;
}
return false;
};
const getPreferredUsername = () => { const getPreferredUsername = () => {
const idToken = getIdToken(); const idToken = getIdToken();
if (idToken) { if (idToken) {
@ -100,6 +129,7 @@ const UserService = {
hasRole, hasRole,
isLoggedIn, isLoggedIn,
loginIfNeeded, loginIfNeeded,
onlyGuestTaskCompletion,
}; };
export default UserService; export default UserService;