Multiple frontend & backend fixes
This commit is contained in:
@@ -128,8 +128,8 @@ import Draggable from "react-draggable";
|
||||
|
||||
import cytoscapestyle from "../defaultCytoscapeStyle.jsx";
|
||||
|
||||
import { validateJson, GetIconInfo } from "./Workflows.jsx";
|
||||
import { GetParsedPaths } from "./Apps.jsx";
|
||||
import { validateJson, GetIconInfo } from "../views/Workflows.jsx";
|
||||
import { GetParsedPaths, internalIds, } from "../views/Apps.jsx";
|
||||
import ConfigureWorkflow from "../components/ConfigureWorkflow.jsx";
|
||||
import AuthenticationOauth2 from "../components/Oauth2Auth.jsx";
|
||||
import ParsedAction from "../components/ParsedAction.jsx";
|
||||
@@ -523,6 +523,7 @@ const AngularWorkflow = (defaultprops) => {
|
||||
const [_, setUpdate] = useState(""); // Used to force rendring, don't remove
|
||||
|
||||
const [workflowExecutions, setWorkflowExecutions] = React.useState([]);
|
||||
const [workflowExecutionCount, setWorkflowExecutionCount] = React.useState(0);
|
||||
const [defaultEnvironmentIndex, setDefaultEnvironmentIndex] = React.useState(0);
|
||||
const [workflowRecommendations, setWorkflowRecommendations] = React.useState(undefined);
|
||||
|
||||
@@ -547,6 +548,8 @@ const AngularWorkflow = (defaultprops) => {
|
||||
"field_id": "",
|
||||
})
|
||||
|
||||
const [executionArgumentModalOpen, setExecutionArgumentModalOpen] = React.useState(false);
|
||||
|
||||
// This should all be set once, not on every iteration
|
||||
// Use states and don't update lol
|
||||
const cloudSyncEnabled =
|
||||
@@ -709,6 +712,33 @@ const AngularWorkflow = (defaultprops) => {
|
||||
});
|
||||
};
|
||||
|
||||
const getWorkflowExecutionCount = (workflowId) => {
|
||||
fetch(`${globalUrl}/api/v1/workflows/${workflowId}/executions/count`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
credentials: "include",
|
||||
})
|
||||
.then((response) => {
|
||||
if (response.status !== 200) {
|
||||
console.log("Status not 200 for workflow execution count: O!");
|
||||
return;
|
||||
} else {
|
||||
return response.json();
|
||||
}
|
||||
})
|
||||
.then((responseJson) => {
|
||||
if (responseJson !== undefined) {
|
||||
setWorkflowExecutionCount(responseJson.count || 0);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
toast(error.toString());
|
||||
});
|
||||
};
|
||||
|
||||
const getAvailableWorkflows = (trigger_index) => {
|
||||
fetch(globalUrl + "/api/v1/workflows", {
|
||||
method: "GET",
|
||||
@@ -992,6 +1022,7 @@ const AngularWorkflow = (defaultprops) => {
|
||||
})
|
||||
.then((responseJson) => {
|
||||
console.log("GOT A RESPONSE??")
|
||||
getWorkflowExecutionCount(id);
|
||||
if (responseJson !== undefined && responseJson !== null && responseJson.executions !== undefined && responseJson.executions !== null) {
|
||||
|
||||
// - means it's opposite
|
||||
@@ -1733,6 +1764,7 @@ const AngularWorkflow = (defaultprops) => {
|
||||
};
|
||||
|
||||
const executeWorkflow = (executionArgument, startNode, hasSaved) => {
|
||||
|
||||
ReactDOM.unstable_batchedUpdates(() => {
|
||||
if (hasSaved === false) {
|
||||
setExecutionRequestStarted(true);
|
||||
@@ -1755,6 +1787,41 @@ const AngularWorkflow = (defaultprops) => {
|
||||
setExecutionRequest({})
|
||||
stop()
|
||||
|
||||
// FIXME: Check if any node contains $exec in a param
|
||||
// If they do, show a popup asking if they want to execute it without an execution argument, or to use a previous one
|
||||
if (executionArgument === undefined || executionArgument === null || executionArgument.length === 0 && workflow.actions !== undefined && workflow.actions !== null && workflow.actions.length > 0) {
|
||||
var foundmissing = false
|
||||
for (let actionkey in workflow.actions) {
|
||||
if (workflow.actions[actionkey].parameters === undefined || workflow.actions[actionkey].parameters === null || workflow.actions[actionkey].parameters.length === 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
for (let paramkey in workflow.actions[actionkey].parameters) {
|
||||
const param = workflow.actions[actionkey].parameters[paramkey]
|
||||
if (param.value === undefined || param.value === null || param.value.length === 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (param.value.indexOf("$exec") !== -1) {
|
||||
foundmissing = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (foundmissing) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
console.log("FOUNDMISSING: ", foundmissing)
|
||||
if (foundmissing) {
|
||||
//toast("This workflow contains a node that requires an execution argument. Please provide one.")
|
||||
setExecutionRequestStarted(false)
|
||||
setExecutionArgumentModalOpen(true)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var curelements = cy.elements();
|
||||
for (let i = 0; i < curelements.length; i++) {
|
||||
curelements[i].addClass("not-executing-highlight");
|
||||
@@ -1824,7 +1891,6 @@ const AngularWorkflow = (defaultprops) => {
|
||||
setExecutionModalOpen(true);
|
||||
setExecutionModalView(1);
|
||||
start();
|
||||
navigate(`/workflows/${props.match.params.key}?execution_id=${responseJson.execution_id}`)
|
||||
})
|
||||
.catch((error) => {
|
||||
//toast(error.toString());
|
||||
@@ -1837,7 +1903,6 @@ const AngularWorkflow = (defaultprops) => {
|
||||
// This can be used to only show prioritzed ones later
|
||||
// Right now, it can prioritize authenticated ones
|
||||
//"Testing",
|
||||
const internalIds = ["Shuffle Tools", "http", "email"];
|
||||
|
||||
const getAppAuthentication = (reset, updateAction, closeMenu) => {
|
||||
fetch(globalUrl + "/api/v1/apps/authentication", {
|
||||
@@ -2039,8 +2104,8 @@ const AngularWorkflow = (defaultprops) => {
|
||||
setApps(responseJson);
|
||||
|
||||
if (isCloud) {
|
||||
setFilteredApps(responseJson.filter((app) => !internalIds.includes(app.name)));
|
||||
setPrioritizedApps(responseJson.filter((app) => internalIds.includes(app.name)));
|
||||
setFilteredApps(responseJson.filter((app) => !internalIds.includes(app.name.toLowerCase())));
|
||||
setPrioritizedApps(responseJson.filter((app) => internalIds.includes(app.name.toLowerCase())));
|
||||
|
||||
} else {
|
||||
//setFilteredApps(
|
||||
@@ -2051,12 +2116,12 @@ const AngularWorkflow = (defaultprops) => {
|
||||
// )
|
||||
//);
|
||||
|
||||
var tmpFiltered = responseJson.filter((app) => !internalIds.includes(app.name))
|
||||
var tmpFiltered = responseJson.filter((app) => !internalIds.includes(app.name.toLowerCase()))
|
||||
//tmpFiltered = sortByKey(tmpFiltered, "activated")
|
||||
setFilteredApps(tmpFiltered)
|
||||
|
||||
//!(!app.activated && app.generated)
|
||||
setPrioritizedApps(responseJson.filter((app) => internalIds.includes(app.name)));
|
||||
setPrioritizedApps(responseJson.filter((app) => internalIds.includes(app.name.toLowerCase())));
|
||||
}
|
||||
|
||||
setAppsLoaded(true)
|
||||
@@ -3785,19 +3850,19 @@ const AngularWorkflow = (defaultprops) => {
|
||||
return;
|
||||
} else if (data.isDescriptor) {
|
||||
console.log("Can't select descriptor");
|
||||
if (data.isTrigger) {
|
||||
console.log("But maybe we can select trigger descriptor? Maybe open execution tab?")
|
||||
setExecutionModalOpen(true)
|
||||
}
|
||||
if (data.isTrigger) {
|
||||
console.log("But maybe we can select trigger descriptor? Maybe open execution tab?")
|
||||
setExecutionModalOpen(true)
|
||||
}
|
||||
|
||||
event.target.unselect();
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.type === undefined) {
|
||||
console.log("No type, automatically setting to action");
|
||||
data.type = "ACTION"
|
||||
}
|
||||
if (data.type === undefined) {
|
||||
console.log("No type, automatically setting to action");
|
||||
data.type = "ACTION"
|
||||
}
|
||||
|
||||
if (data.type === "ACTION") {
|
||||
setSelectedComment({})
|
||||
@@ -3924,39 +3989,60 @@ const AngularWorkflow = (defaultprops) => {
|
||||
const tmpAuth = JSON.parse(JSON.stringify(newAppAuth));
|
||||
//console.log("FOUND AUTH OPTIONS: ", tmpAuth)
|
||||
|
||||
const curappName = curapp.name.toLowerCase()
|
||||
for (let tmpAuthKey in tmpAuth) {
|
||||
var item = tmpAuth[tmpAuthKey];
|
||||
const curappName = curapp.name.toLowerCase()
|
||||
for (let tmpAuthKey in tmpAuth) {
|
||||
var item = tmpAuth[tmpAuthKey];
|
||||
|
||||
const newfields = {};
|
||||
if (item.app.name.toLowerCase() !== curappName) {
|
||||
continue
|
||||
}
|
||||
const newfields = {};
|
||||
if (item.app.name.toLowerCase() !== curappName) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Makes list into key:value object
|
||||
for (let fieldkey in item.fields) {
|
||||
if (item.fields[fieldkey] === undefined) {
|
||||
console.log("Problem with filterkey in Node select", fieldkey)
|
||||
continue
|
||||
}
|
||||
// Makes list into key:value object
|
||||
for (let fieldkey in item.fields) {
|
||||
if (item.fields[fieldkey] === undefined) {
|
||||
console.log("Problem with filterkey in Node select", fieldkey)
|
||||
continue
|
||||
}
|
||||
|
||||
const filterkey = item.fields[fieldkey]["key"]
|
||||
if (filterkey === null || filterkey === undefined) {
|
||||
console.log("Problem with filterkey 2. Null or undefined 3")
|
||||
continue
|
||||
}
|
||||
const filterkey = item.fields[fieldkey]["key"]
|
||||
if (filterkey === null || filterkey === undefined) {
|
||||
console.log("Problem with filterkey 2. Null or undefined 3")
|
||||
continue
|
||||
}
|
||||
|
||||
newfields[filterkey] = item.fields[fieldkey]["value"];
|
||||
}
|
||||
newfields[filterkey] = item.fields[fieldkey]["value"];
|
||||
}
|
||||
|
||||
item.fields = newfields;
|
||||
if (item.app.name.toLowerCase() === curappName) {
|
||||
authenticationOptions.push(item);
|
||||
if (item.id === findAuthId) {
|
||||
curaction.selectedAuthentication = item;
|
||||
}
|
||||
}
|
||||
}
|
||||
item.fields = newfields;
|
||||
if (item.app.name.toLowerCase() === curappName) {
|
||||
authenticationOptions.push(item);
|
||||
if (item.id === findAuthId) {
|
||||
curaction.selectedAuthentication = item;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log("OPTIONS: ", authenticationOptions)
|
||||
// Find with authenticationOption (authenticationOptions) has the highest .edited time. In this index, set the "last_modified" to true
|
||||
|
||||
var latesttime = 0
|
||||
var latestindex = -1
|
||||
|
||||
for (var i = 0; i < authenticationOptions.length; i++) {
|
||||
const authopt = authenticationOptions[i]
|
||||
console.log("AUTHOPT: ", authopt)
|
||||
|
||||
if (authopt.edited > latesttime) {
|
||||
latesttime = authopt.edited
|
||||
latestindex = i
|
||||
}
|
||||
}
|
||||
|
||||
console.log("LATEST INDEX: ", latestindex)
|
||||
if (latestindex !== -1) {
|
||||
authenticationOptions[latestindex].last_modified = true
|
||||
}
|
||||
|
||||
curaction.authentication = authenticationOptions;
|
||||
if (
|
||||
@@ -7468,26 +7554,26 @@ const AngularWorkflow = (defaultprops) => {
|
||||
|
||||
const tmpAuth = JSON.parse(JSON.stringify(appAuthentication));
|
||||
for (let authkey in tmpAuth) {
|
||||
if (authkey === undefined) {
|
||||
continue
|
||||
}
|
||||
if (authkey === undefined) {
|
||||
continue
|
||||
}
|
||||
|
||||
var item = tmpAuth[authkey];
|
||||
const newfields = {};
|
||||
for (let fieldkey in item.fields) {
|
||||
if (item.fields[fieldkey] === undefined) {
|
||||
console.log("Problem with filterkey in Node select", fieldkey)
|
||||
continue
|
||||
}
|
||||
for (let fieldkey in item.fields) {
|
||||
if (item.fields[fieldkey] === undefined) {
|
||||
console.log("Problem with filterkey in Node select", fieldkey)
|
||||
continue
|
||||
}
|
||||
|
||||
const filterkey = item.fields[fieldkey]["key"]
|
||||
if (filterkey === null || filterkey === undefined) {
|
||||
console.log("Problem with filterkey 2. Null or undefined 3")
|
||||
continue
|
||||
}
|
||||
const filterkey = item.fields[fieldkey]["key"]
|
||||
if (filterkey === null || filterkey === undefined) {
|
||||
console.log("Problem with filterkey 2. Null or undefined 3")
|
||||
continue
|
||||
}
|
||||
|
||||
newfields[filterkey] = item.fields[fieldkey]["value"];
|
||||
}
|
||||
newfields[filterkey] = item.fields[fieldkey]["value"];
|
||||
}
|
||||
|
||||
item.fields = newfields;
|
||||
if (item.app.id === app.id || item.app.name === app.name) {
|
||||
@@ -7734,9 +7820,9 @@ const AngularWorkflow = (defaultprops) => {
|
||||
const [visibleApps, setVisibleApps] = React.useState(
|
||||
Array.prototype.concat.apply(
|
||||
prioritizedApps,
|
||||
filteredApps.filter((innerapp) => !internalIds.includes(innerapp.id)),
|
||||
)
|
||||
);
|
||||
filteredApps.filter((innerapp) => !internalIds.includes(innerapp.id.toLowerCase()))
|
||||
)
|
||||
)
|
||||
|
||||
var delay = -75
|
||||
var runDelay = false
|
||||
@@ -7983,7 +8069,7 @@ const AngularWorkflow = (defaultprops) => {
|
||||
setVisibleApps(
|
||||
prioritizedApps.concat(
|
||||
filteredApps.filter(
|
||||
(innerapp) => !internalIds.includes(innerapp.id)
|
||||
(innerapp) => !internalIds.includes(innerapp.id.toLowerCase())
|
||||
)
|
||||
)
|
||||
);
|
||||
@@ -9159,6 +9245,133 @@ const AngularWorkflow = (defaultprops) => {
|
||||
backgroundColor: theme.palette.inputColor,
|
||||
};
|
||||
|
||||
|
||||
// Makes a list of items the user can choose from based on previous runs
|
||||
var availableArguments = []
|
||||
if (executionArgumentModalOpen && workflowExecutions.length > 0) {
|
||||
for (let executionKey in workflowExecutions) {
|
||||
if (availableArguments.length > 5) {
|
||||
break
|
||||
}
|
||||
|
||||
const execution = workflowExecutions[executionKey]
|
||||
if (execution.execution_argument === undefined || execution.execution_argument === null || execution.execution_argument.length < 2) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (availableArguments.includes(execution.execution_argument)) {
|
||||
continue
|
||||
}
|
||||
|
||||
availableArguments.push(execution.execution_argument)
|
||||
}
|
||||
}
|
||||
|
||||
const executionArgumentModal =
|
||||
<Dialog
|
||||
PaperComponent={PaperComponent}
|
||||
disableEnforceFocus={true}
|
||||
hideBackdrop={true}
|
||||
disableBackdropClick={true}
|
||||
style={{ pointerEvents: "none" }}
|
||||
PaperComponent={PaperComponent}
|
||||
aria-labelledby="draggable-dialog-title"
|
||||
open={executionArgumentModalOpen}
|
||||
PaperProps={{
|
||||
style: {
|
||||
padding: 30,
|
||||
pointerEvents: "auto",
|
||||
color: "white",
|
||||
minWidth: isMobile ? "90%" : 800,
|
||||
border: theme.palette.defaultBorder,
|
||||
},
|
||||
}}
|
||||
onClose={() => {
|
||||
}}
|
||||
>
|
||||
<Tooltip
|
||||
title="Close window"
|
||||
placement="top"
|
||||
style={{ zIndex: 10011 }}
|
||||
>
|
||||
<IconButton
|
||||
style={{ zIndex: 5000, position: "absolute", top: 34, right: 34 }}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setExecutionArgumentModalOpen(false);
|
||||
}}
|
||||
>
|
||||
<CloseIcon style={{ color: "white" }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<DialogTitle id="draggable-dialog-title" style={{ cursor: "move", }}>
|
||||
<span style={{ color: "white" }}>Please provide an execution argument</span>
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography variant="body1" color="textSecondary">
|
||||
At least one node in this workflow requires an execution argument. Please select one below, or provide a custom one in the text field next to the run button.
|
||||
</Typography>
|
||||
{/*
|
||||
<div style={{marginTop: 10, }}>
|
||||
<Tooltip
|
||||
color="primary"
|
||||
title="An argument to be used for execution. This is a variable available to every node in your workflow."
|
||||
placement="top"
|
||||
>
|
||||
<TextField
|
||||
id="execution_argument_input_field"
|
||||
style={theme.palette.textFieldStyle}
|
||||
disabled={workflow.public}
|
||||
color="secondary"
|
||||
placeholder={"Execution Argument"}
|
||||
defaultValue={executionText}
|
||||
onBlur={(e) => {
|
||||
setExecutionText(e.target.value);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
*/}
|
||||
|
||||
<Divider style={{marginTop: 10, marginBottom: 20, }}/>
|
||||
{availableArguments.length > 0 ?
|
||||
<div>
|
||||
<Typography variant="body1" style={{}}>
|
||||
Previously used arguments:
|
||||
</Typography>
|
||||
{availableArguments.map((data) => {
|
||||
return (
|
||||
<Paper style={{ padding: 10, marginTop: 10, backgroundColor: theme.palette.platformColor, maxHeight: 70, overflow: "auto", cursor: "pointer", position: "relative", }}
|
||||
onClick={() => {
|
||||
setExecutionText(data)
|
||||
executeWorkflow(data, workflow.start, lastSaved);
|
||||
setExecutionArgumentModalOpen(false)
|
||||
}}
|
||||
>
|
||||
<div style={{height: "100%", width: 2, backgroundColor: "rgba(255, 255, 255, 0.5)", position: "absolute", left: 0, top: 0}} />
|
||||
<Typography variant="body1" color="textSecondary">
|
||||
{data}
|
||||
</Typography>
|
||||
</Paper>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
: null}
|
||||
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
onClick={() => {
|
||||
executeWorkflow(" ", workflow.start, lastSaved);
|
||||
setExecutionArgumentModalOpen(false);
|
||||
}}
|
||||
style={{ marginTop: 20, marginBottom: 20 }}
|
||||
>
|
||||
Run anyway
|
||||
</Button>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
const aiQueryModal =
|
||||
<Dialog
|
||||
PaperComponent={PaperComponent}
|
||||
@@ -13490,9 +13703,54 @@ const AngularWorkflow = (defaultprops) => {
|
||||
variant="body2"
|
||||
>
|
||||
{workflow.errors.slice(0,3).map((error) => {
|
||||
// Loop through each word, and if it matches "Action <name> " then replace it with a link to the action
|
||||
var colornext = false
|
||||
const newerror = error === undefined || error == null ? "" : error.split(" ").map((word) => {
|
||||
if (colornext) {
|
||||
colornext = false
|
||||
return (
|
||||
<span
|
||||
style={{color: "#f85a3e", cursor: "pointer"}}
|
||||
onClick={() => {
|
||||
console.log("Clicked action: ", word)
|
||||
|
||||
// Find it in cytoscape
|
||||
if (cy === undefined || cy === null) {
|
||||
return
|
||||
}
|
||||
|
||||
const foundnode = cy.nodes().filter((node) => {
|
||||
return node.data().label === word
|
||||
})
|
||||
|
||||
if (foundnode === undefined || foundnode === null || foundnode.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
console.log("Found node: ", foundnode)
|
||||
cy.elements().unselect()
|
||||
foundnode[0].select()
|
||||
}}
|
||||
>
|
||||
{word}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
if (word.toLowerCase() === "action") {
|
||||
colornext = true
|
||||
}
|
||||
|
||||
return word + " "
|
||||
})
|
||||
|
||||
if (newerror === undefined || newerror === null || newerror === "") {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
- {error}
|
||||
- {newerror}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
@@ -13635,7 +13893,7 @@ const AngularWorkflow = (defaultprops) => {
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
{workflow.public ? (
|
||||
{workflow.public || userdata.support == true ? (
|
||||
<Tooltip
|
||||
color="secondary"
|
||||
title="Download workflow"
|
||||
@@ -14811,7 +15069,7 @@ const AngularWorkflow = (defaultprops) => {
|
||||
>
|
||||
<h2 style={{ color: "rgba(255,255,255,0.5)" }}>
|
||||
<DirectionsRunIcon style={{ marginRight: 10 }} />
|
||||
All Workflow Runs
|
||||
All Workflow Runs: { workflowExecutionCount }
|
||||
</h2>
|
||||
</Breadcrumbs>
|
||||
<Tooltip
|
||||
@@ -14819,7 +15077,7 @@ const AngularWorkflow = (defaultprops) => {
|
||||
placement="left-start"
|
||||
style={{ zIndex: 10010 }}
|
||||
>
|
||||
<a href={`/workflows/debug?workflow_id=${workflow.id}`} style={{textDecoration: "none", }}>
|
||||
<a target="_blank" href={`/workflows/debug?workflow_id=${workflow.id}`} style={{textDecoration: "none", }}>
|
||||
<Button
|
||||
color="secondary"
|
||||
style={{marginLeft: 50, maxHeight: 30, marginTop: 20, }}
|
||||
@@ -14839,7 +15097,7 @@ const AngularWorkflow = (defaultprops) => {
|
||||
color="primary"
|
||||
>
|
||||
<CachedIcon style={{ marginRight: 10 }} />
|
||||
Refresh Runs
|
||||
Refresh Runs
|
||||
</Button>
|
||||
<Divider
|
||||
style={{
|
||||
@@ -17910,6 +18168,7 @@ const AngularWorkflow = (defaultprops) => {
|
||||
{newView}
|
||||
<VariablesModal variableInfo={variableInfo} setVariableInfo={setVariableInfo} />
|
||||
<ExecutionVariableModal variableInfo={variableInfo} setVariableInfo={setVariableInfo} />
|
||||
{executionArgumentModal}
|
||||
{aiQueryModal}
|
||||
{conditionsModal}
|
||||
{authenticationModal}
|
||||
|
||||
Reference in New Issue
Block a user