commit
91b4abbaa5
119 changed files with 34849 additions and 861 deletions
|
|
@ -52,6 +52,8 @@
|
|||
"S3_HOST": "",
|
||||
"S3_KEY": "",
|
||||
"S3_SECRET": "",
|
||||
"invitation_link": "/api/users/invitation?token=%s",
|
||||
"change_password_link": "/reset-password?invitation=%s&&pass=%s",
|
||||
"version_number": "1.2.0"
|
||||
},
|
||||
"lambda_timeout": 150,
|
||||
|
|
|
|||
10
api/app.py
10
api/app.py
|
|
@ -60,7 +60,7 @@ _overrides.chalice_app(app)
|
|||
def or_middleware(event, get_response):
|
||||
global OR_SESSION_TOKEN
|
||||
OR_SESSION_TOKEN = app.current_request.headers.get('vnd.openreplay.com.sid',
|
||||
app.current_request.headers.get('vnd.asayer.io.sid'))
|
||||
app.current_request.headers.get('vnd.asayer.io.sid'))
|
||||
if "authorizer" in event.context and event.context["authorizer"] is None:
|
||||
print("Deleted user!!")
|
||||
pg_client.close()
|
||||
|
|
@ -71,7 +71,13 @@ def or_middleware(event, get_response):
|
|||
import time
|
||||
now = int(time.time() * 1000)
|
||||
response = get_response(event)
|
||||
if response.status_code == 500 and helper.allow_sentry() and OR_SESSION_TOKEN is not None and not helper.is_local():
|
||||
|
||||
if response.status_code == 200 and response.body is not None and response.body.get("errors") is not None:
|
||||
if "not found" in response.body["errors"][0]:
|
||||
response = Response(status_code=404, body=response.body)
|
||||
else:
|
||||
response = Response(status_code=400, body=response.body)
|
||||
if response.status_code // 100 == 5 and helper.allow_sentry() and OR_SESSION_TOKEN is not None and not helper.is_local():
|
||||
with configure_scope() as scope:
|
||||
scope.set_tag('stage', environ["stage"])
|
||||
scope.set_tag('openReplaySessionToken', OR_SESSION_TOKEN)
|
||||
|
|
|
|||
|
|
@ -18,17 +18,19 @@ check_prereq() {
|
|||
}
|
||||
|
||||
function build_api(){
|
||||
tag=""
|
||||
# Copy enterprise code
|
||||
[[ $1 == "ee" ]] && {
|
||||
cp -rf ../ee/api/* ./
|
||||
cp -rf ../ee/api/.chalice/* ./.chalice/
|
||||
envarg="default-ee"
|
||||
tag="ee-"
|
||||
}
|
||||
docker build -f ./Dockerfile --build-arg envarg=$envarg -t ${DOCKER_REPO:-'local'}/chalice:${git_sha1} .
|
||||
[[ $PUSH_IMAGE -eq 1 ]] && {
|
||||
docker push ${DOCKER_REPO:-'local'}/chalice:${git_sha1}
|
||||
docker tag ${DOCKER_REPO:-'local'}/chalice:${git_sha1} ${DOCKER_REPO:-'local'}/chalice:latest
|
||||
docker push ${DOCKER_REPO:-'local'}/chalice:latest
|
||||
docker tag ${DOCKER_REPO:-'local'}/chalice:${git_sha1} ${DOCKER_REPO:-'local'}/chalice:${tag}latest
|
||||
docker push ${DOCKER_REPO:-'local'}/chalice:${tag}latest
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
0
api/chalicelib/blueprints/app/__init__.py
Normal file
0
api/chalicelib/blueprints/app/__init__.py
Normal file
|
|
@ -11,7 +11,7 @@ from chalicelib.core import log_tool_rollbar, sourcemaps, events, sessions_assig
|
|||
log_tool_stackdriver, reset_password, sessions_favorite_viewed, \
|
||||
log_tool_cloudwatch, log_tool_sentry, log_tool_sumologic, log_tools, errors, sessions, \
|
||||
log_tool_newrelic, announcements, log_tool_bugsnag, weekly_report, integration_jira_cloud, integration_github, \
|
||||
assist
|
||||
assist, heatmaps
|
||||
from chalicelib.core.collaboration_slack import Slack
|
||||
from chalicelib.utils import email_helper
|
||||
|
||||
|
|
@ -32,8 +32,10 @@ def get_favorite_sessions2(projectId, context):
|
|||
def get_session2(projectId, sessionId, context):
|
||||
data = sessions.get_by_id2_pg(project_id=projectId, session_id=sessionId, full_data=True, user_id=context["userId"],
|
||||
include_fav_viewed=True, group_metadata=True)
|
||||
if data is not None:
|
||||
sessions_favorite_viewed.view_session(project_id=projectId, user_id=context['userId'], session_id=sessionId)
|
||||
if data is None:
|
||||
return {"errors": ["session not found"]}
|
||||
|
||||
sessions_favorite_viewed.view_session(project_id=projectId, user_id=context['userId'], session_id=sessionId)
|
||||
return {
|
||||
'data': data
|
||||
}
|
||||
|
|
@ -507,8 +509,8 @@ def reset_password_handler(step):
|
|||
if "email" not in data or len(data["email"]) < 5:
|
||||
return {"errors": ["please provide a valid email address"]}
|
||||
return reset_password.step1(data)
|
||||
elif step == "2":
|
||||
return reset_password.step2(data)
|
||||
# elif step == "2":
|
||||
# return reset_password.step2(data)
|
||||
|
||||
|
||||
@app.route('/{projectId}/metadata', methods=['GET'])
|
||||
|
|
@ -585,9 +587,8 @@ def async_basic_emails(step):
|
|||
if data.pop("auth") != environ["async_Token"]:
|
||||
return {}
|
||||
if step.lower() == "member_invitation":
|
||||
email_helper.send_team_invitation(recipient=data["email"], user_name=data["userName"],
|
||||
temp_password=data["tempPassword"], client_id=data["clientId"],
|
||||
sender_name=data["senderName"])
|
||||
email_helper.send_team_invitation(recipient=data["email"], invitation_link=data["invitationLink"],
|
||||
client_id=data["clientId"], sender_name=data["senderName"])
|
||||
|
||||
|
||||
@app.route('/{projectId}/sample_rate', methods=['GET'])
|
||||
|
|
@ -724,10 +725,10 @@ def get_funnel_insights(projectId, funnelId, context):
|
|||
if params is None:
|
||||
params = {}
|
||||
|
||||
return {"data": funnels.get_top_insights(funnel_id=funnelId, project_id=projectId,
|
||||
range_value=params.get("range_value", None),
|
||||
start_date=params.get('startDate', None),
|
||||
end_date=params.get('endDate', None))}
|
||||
return funnels.get_top_insights(funnel_id=funnelId, project_id=projectId,
|
||||
range_value=params.get("range_value", None),
|
||||
start_date=params.get('startDate', None),
|
||||
end_date=params.get('endDate', None))
|
||||
|
||||
|
||||
@app.route('/{projectId}/funnels/{funnelId}/insights', methods=['POST', 'PUT'])
|
||||
|
|
@ -739,8 +740,7 @@ def get_funnel_insights_on_the_fly(projectId, funnelId, context):
|
|||
if data is None:
|
||||
data = {}
|
||||
|
||||
return {
|
||||
"data": funnels.get_top_insights_on_the_fly(funnel_id=funnelId, project_id=projectId, data={**params, **data})}
|
||||
return funnels.get_top_insights_on_the_fly(funnel_id=funnelId, project_id=projectId, data={**params, **data})
|
||||
|
||||
|
||||
@app.route('/{projectId}/funnels/{funnelId}/issues', methods=['GET'])
|
||||
|
|
@ -821,8 +821,11 @@ def get_funnel_issue_sessions(projectId, funnelId, issueId, context):
|
|||
|
||||
@app.route('/{projectId}/funnels/{funnelId}', methods=['GET'])
|
||||
def get_funnel(projectId, funnelId, context):
|
||||
return {"data": funnels.get(funnel_id=funnelId,
|
||||
project_id=projectId)}
|
||||
data = funnels.get(funnel_id=funnelId,
|
||||
project_id=projectId)
|
||||
if data is None:
|
||||
return {"errors": ["funnel not found"]}
|
||||
return data
|
||||
|
||||
|
||||
@app.route('/{projectId}/funnels/{funnelId}', methods=['POST', 'PUT'])
|
||||
|
|
@ -882,3 +885,9 @@ def removed_endpoints(projectId=None, context=None):
|
|||
def sessions_live(projectId, context):
|
||||
data = assist.get_live_sessions(projectId)
|
||||
return {'data': data}
|
||||
|
||||
|
||||
@app.route('/{projectId}/heatmaps/url', methods=['POST'])
|
||||
def get_heatmaps_by_url(projectId, context):
|
||||
data = app.current_request.json_body
|
||||
return {"data": heatmaps.get_by_url(project_id=projectId, data=data)}
|
||||
|
|
|
|||
|
|
@ -12,11 +12,6 @@ def run_scheduled_jobs(event):
|
|||
jobs.execute_jobs()
|
||||
|
||||
|
||||
@app.schedule(Cron('0/60', '*', '*', '*', '?', '*'))
|
||||
def clear_password_reset(event):
|
||||
reset_password.cron()
|
||||
|
||||
|
||||
# Run every monday.
|
||||
@app.schedule(Cron('5', '0', '?', '*', 'MON', '*'))
|
||||
def weekly_report2(event):
|
||||
|
|
|
|||
|
|
@ -38,9 +38,9 @@ def login():
|
|||
for_plugin=False
|
||||
)
|
||||
if r is None:
|
||||
return {
|
||||
return Response(status_code=401, body={
|
||||
'errors': ['You’ve entered invalid Email or Password.']
|
||||
}
|
||||
})
|
||||
|
||||
tenant_id = r.pop("tenantId")
|
||||
|
||||
|
|
@ -53,11 +53,12 @@ def login():
|
|||
c.pop("createdAt")
|
||||
c["projects"] = projects.get_projects(tenant_id=tenant_id, recording_state=True, recorded=True,
|
||||
stack_integrations=True)
|
||||
c["smtp"] = helper.has_smtp()
|
||||
return {
|
||||
'jwt': r.pop('jwt'),
|
||||
'data': {
|
||||
"user": r,
|
||||
"client": c,
|
||||
"client": c
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -74,7 +75,7 @@ def get_account(context):
|
|||
"metadata": metadata.get_remaining_metadata_with_count(context['tenantId'])
|
||||
},
|
||||
**license.get_status(context["tenantId"]),
|
||||
"smtp": environ["EMAIL_HOST"] is not None and len(environ["EMAIL_HOST"]) > 0
|
||||
"smtp": helper.has_smtp()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -100,8 +101,11 @@ def create_edit_project(projectId, context):
|
|||
|
||||
@app.route('/projects/{projectId}', methods=['GET'])
|
||||
def get_project(projectId, context):
|
||||
return {"data": projects.get_project(tenant_id=context["tenantId"], project_id=projectId, include_last_session=True,
|
||||
include_gdpr=True)}
|
||||
data = projects.get_project(tenant_id=context["tenantId"], project_id=projectId, include_last_session=True,
|
||||
include_gdpr=True)
|
||||
if data is None:
|
||||
return {"errors": ["project not found"]}
|
||||
return {"data": data}
|
||||
|
||||
|
||||
@app.route('/projects/{projectId}', methods=['DELETE'])
|
||||
|
|
@ -353,6 +357,38 @@ def add_member(context):
|
|||
return users.create_member(tenant_id=context['tenantId'], user_id=context['userId'], data=data)
|
||||
|
||||
|
||||
@app.route('/users/invitation', methods=['GET'], authorizer=None)
|
||||
def process_invitation_link():
|
||||
params = app.current_request.query_params
|
||||
if params is None or len(params.get("token", "")) < 64:
|
||||
return {"errors": ["please provide a valid invitation"]}
|
||||
user = users.get_by_invitation_token(params["token"])
|
||||
if user is None:
|
||||
return {"errors": ["invitation not found"]}
|
||||
if user["expiredInvitation"]:
|
||||
return {"errors": ["expired invitation, please ask your admin to send a new one"]}
|
||||
pass_token = users.allow_password_change(user_id=user["userId"])
|
||||
return Response(
|
||||
status_code=307,
|
||||
body='',
|
||||
headers={'Location': environ["SITE_URL"] + environ["change_password_link"] % (params["token"], pass_token),
|
||||
'Content-Type': 'text/plain'})
|
||||
|
||||
|
||||
@app.route('/users/invitation/password', methods=['POST', 'PUT'], authorizer=None)
|
||||
def change_password_by_invitation():
|
||||
data = app.current_request.json_body
|
||||
if data is None or len(data.get("invitation", "")) < 64 or len(data.get("pass", "")) < 8:
|
||||
return {"errors": ["please provide a valid invitation & pass"]}
|
||||
user = users.get_by_invitation_token(token=data["token"], pass_token=data["pass"])
|
||||
if user is None:
|
||||
return {"errors": ["invitation not found"]}
|
||||
if user["expiredChange"]:
|
||||
return {"errors": ["expired change, please re-use the invitation link"]}
|
||||
|
||||
return users.set_password_invitation(new_password=data["password"], user_id=user["userId"])
|
||||
|
||||
|
||||
@app.route('/client/members/{memberId}', methods=['PUT', 'POST'])
|
||||
def edit_member(memberId, context):
|
||||
data = app.current_request.json_body
|
||||
|
|
@ -360,6 +396,11 @@ def edit_member(memberId, context):
|
|||
user_id_to_update=memberId)
|
||||
|
||||
|
||||
@app.route('/client/members/{memberId}/reset', methods=['GET'])
|
||||
def reset_reinvite_member(memberId, context):
|
||||
return users.reset_member(tenant_id=context['tenantId'], editor_id=context['userId'], user_id_to_update=memberId)
|
||||
|
||||
|
||||
@app.route('/client/members/{memberId}', methods=['DELETE'])
|
||||
def delete_member(memberId, context):
|
||||
return users.delete_member(tenant_id=context["tenantId"], user_id=context['userId'], id_to_delete=memberId)
|
||||
|
|
|
|||
|
|
@ -239,7 +239,7 @@ def get_details(project_id, error_id, user_id, **data):
|
|||
cur.execute(cur.mogrify(main_pg_query, params))
|
||||
row = cur.fetchone()
|
||||
if row is None:
|
||||
return {"errors": ["error doesn't exist"]}
|
||||
return {"errors": ["error not found"]}
|
||||
row["tags"] = __process_tags(row)
|
||||
|
||||
query = cur.mogrify(
|
||||
|
|
@ -387,7 +387,7 @@ def get_details_chart(project_id, error_id, user_id, **data):
|
|||
cur.execute(cur.mogrify(main_pg_query, params))
|
||||
row = cur.fetchone()
|
||||
if row is None:
|
||||
return {"errors": ["error doesn't exist"]}
|
||||
return {"errors": ["error not found"]}
|
||||
row["tags"] = __process_tags(row)
|
||||
return {"data": helper.dict_to_camel_case(row)}
|
||||
|
||||
|
|
|
|||
|
|
@ -145,7 +145,7 @@ def delete(project_id, funnel_id, user_id):
|
|||
def get_sessions(project_id, funnel_id, user_id, range_value=None, start_date=None, end_date=None):
|
||||
f = get(funnel_id=funnel_id, project_id=project_id)
|
||||
if f is None:
|
||||
return {"errors": ["filter not found"]}
|
||||
return {"errors": ["funnel not found"]}
|
||||
get_start_end_time(filter_d=f["filter"], range_value=range_value, start_date=start_date, end_date=end_date)
|
||||
return sessions.search2_pg(data=f["filter"], project_id=project_id, user_id=user_id)
|
||||
|
||||
|
|
@ -166,12 +166,12 @@ def get_sessions_on_the_fly(funnel_id, project_id, user_id, data):
|
|||
def get_top_insights(project_id, funnel_id, range_value=None, start_date=None, end_date=None):
|
||||
f = get(funnel_id=funnel_id, project_id=project_id)
|
||||
if f is None:
|
||||
return {"errors": ["filter not found"]}
|
||||
return {"errors": ["funnel not found"]}
|
||||
get_start_end_time(filter_d=f["filter"], range_value=range_value, start_date=start_date, end_date=end_date)
|
||||
insights, total_drop_due_to_issues = significance.get_top_insights(filter_d=f["filter"], project_id=project_id)
|
||||
insights[-1]["dropDueToIssues"] = total_drop_due_to_issues
|
||||
return {"stages": helper.list_to_camel_case(insights),
|
||||
"totalDropDueToIssues": total_drop_due_to_issues}
|
||||
return {"data": {"stages": helper.list_to_camel_case(insights),
|
||||
"totalDropDueToIssues": total_drop_due_to_issues}}
|
||||
|
||||
|
||||
def get_top_insights_on_the_fly(funnel_id, project_id, data):
|
||||
|
|
@ -187,8 +187,8 @@ def get_top_insights_on_the_fly(funnel_id, project_id, data):
|
|||
insights, total_drop_due_to_issues = significance.get_top_insights(filter_d=data, project_id=project_id)
|
||||
if len(insights) > 0:
|
||||
insights[-1]["dropDueToIssues"] = total_drop_due_to_issues
|
||||
return {"stages": helper.list_to_camel_case(insights),
|
||||
"totalDropDueToIssues": total_drop_due_to_issues}
|
||||
return {"data": {"stages": helper.list_to_camel_case(insights),
|
||||
"totalDropDueToIssues": total_drop_due_to_issues}}
|
||||
|
||||
|
||||
def get_issues(project_id, funnel_id, range_value=None, start_date=None, end_date=None):
|
||||
|
|
|
|||
30
api/chalicelib/core/heatmaps.py
Normal file
30
api/chalicelib/core/heatmaps.py
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
from chalicelib.utils.TimeUTC import TimeUTC
|
||||
from chalicelib.utils import helper, pg_client
|
||||
from chalicelib.utils import dev
|
||||
|
||||
|
||||
@dev.timed
|
||||
def get_by_url(project_id, data):
|
||||
args = {"startDate": data.get('startDate', TimeUTC.now(delta_days=-30)),
|
||||
"endDate": data.get('endDate', TimeUTC.now()),
|
||||
"project_id": project_id, "url": data["url"]}
|
||||
|
||||
with pg_client.PostgresClient() as cur:
|
||||
query = cur.mogrify("""SELECT selector, count(1) AS count
|
||||
FROM events.clicks
|
||||
INNER JOIN sessions USING (session_id)
|
||||
WHERE project_id = %(project_id)s
|
||||
AND url = %(url)s
|
||||
AND timestamp >= %(startDate)s
|
||||
AND timestamp <= %(endDate)s
|
||||
AND start_ts >= %(startDate)s
|
||||
AND start_ts <= %(endDate)s
|
||||
AND duration IS NOT NULL
|
||||
GROUP BY selector;""",
|
||||
args)
|
||||
|
||||
cur.execute(
|
||||
query
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
return helper.dict_to_camel_case(rows)
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
from chalicelib.utils import pg_client, helper, dev
|
||||
|
||||
|
||||
from chalicelib.core import projects
|
||||
import re
|
||||
|
||||
|
|
@ -24,9 +23,10 @@ def get(project_id):
|
|||
)
|
||||
metas = cur.fetchone()
|
||||
results = []
|
||||
for i, k in enumerate(metas.keys()):
|
||||
if metas[k] is not None:
|
||||
results.append({"key": metas[k], "index": i + 1})
|
||||
if metas is not None:
|
||||
for i, k in enumerate(metas.keys()):
|
||||
if metas[k] is not None:
|
||||
results.append({"key": metas[k], "index": i + 1})
|
||||
return results
|
||||
|
||||
|
||||
|
|
@ -56,7 +56,7 @@ def __edit(project_id, col_index, colname, new_name):
|
|||
old_metas = get(project_id)
|
||||
old_metas = {k["index"]: k for k in old_metas}
|
||||
if col_index not in list(old_metas.keys()):
|
||||
return {"errors": ["custom field doesn't exist"]}
|
||||
return {"errors": ["custom field not found"]}
|
||||
|
||||
with pg_client.PostgresClient() as cur:
|
||||
if old_metas[col_index]["key"].lower() != new_name:
|
||||
|
|
@ -79,7 +79,7 @@ def delete(tenant_id, project_id, index: int):
|
|||
old_segments = get(project_id)
|
||||
old_segments = [k["index"] for k in old_segments]
|
||||
if index not in old_segments:
|
||||
return {"errors": ["custom field doesn't exist"]}
|
||||
return {"errors": ["custom field not found"]}
|
||||
|
||||
with pg_client.PostgresClient() as cur:
|
||||
colname = index_to_colname(index)
|
||||
|
|
|
|||
|
|
@ -18,48 +18,23 @@ def step1(data):
|
|||
a_users = users.get_by_email_only(data["email"])
|
||||
if len(a_users) > 1:
|
||||
print(f"multiple users found for [{data['email']}] please contact our support")
|
||||
return {"errors": ["please contact our support"]}
|
||||
return {"errors": ["multiple users, please contact our support"]}
|
||||
elif len(a_users) == 1:
|
||||
a_users = a_users[0]
|
||||
reset_token = secrets.token_urlsafe(6)
|
||||
users.update(tenant_id=a_users["tenantId"], user_id=a_users["id"],
|
||||
changes={"token": reset_token})
|
||||
email_helper.send_reset_code(recipient=data["email"], reset_code=reset_token)
|
||||
invitation_link=users.generate_new_invitation(user_id=a_users["id"])
|
||||
email_helper.send_forgot_password(recipient=data["email"], invitation_link=invitation_link)
|
||||
else:
|
||||
print(f"invalid email address [{data['email']}]")
|
||||
return {"errors": ["invalid email address"]}
|
||||
return {"data": {"state": "success"}}
|
||||
|
||||
|
||||
def step2(data):
|
||||
print("====================== change password 2 ===============")
|
||||
user = users.get_by_email_reset(data["email"], data["code"])
|
||||
if not user:
|
||||
print("error: wrong email or reset code")
|
||||
return {"errors": ["wrong email or reset code"]}
|
||||
users.update(tenant_id=user["tenantId"], user_id=user["id"],
|
||||
changes={"token": None, "password": data["password"], "generatedPassword": False})
|
||||
return {"data": {"state": "success"}}
|
||||
|
||||
|
||||
def cron():
|
||||
with pg_client.PostgresClient() as cur:
|
||||
cur.execute(
|
||||
cur.mogrify("""\
|
||||
SELECT user_id
|
||||
FROM public.basic_authentication
|
||||
WHERE token notnull
|
||||
AND (token_requested_at isnull or (EXTRACT(EPOCH FROM token_requested_at)*1000)::BIGINT < %(time)s);""",
|
||||
{"time": chalicelib.utils.TimeUTC.TimeUTC.now(delta_days=-1)})
|
||||
)
|
||||
results = cur.fetchall()
|
||||
if len(results) == 0:
|
||||
return
|
||||
results = tuple([r["user_id"] for r in results])
|
||||
cur.execute(
|
||||
cur.mogrify("""\
|
||||
UPDATE public.basic_authentication
|
||||
SET token = NULL, token_requested_at = NULL
|
||||
WHERE user_id in %(ids)s;""",
|
||||
{"ids": results})
|
||||
)
|
||||
# def step2(data):
|
||||
# print("====================== change password 2 ===============")
|
||||
# user = users.get_by_email_reset(data["email"], data["code"])
|
||||
# if not user:
|
||||
# print("error: wrong email or reset code")
|
||||
# return {"errors": ["wrong email or reset code"]}
|
||||
# users.update(tenant_id=user["tenantId"], user_id=user["id"],
|
||||
# changes={"token": None, "password": data["password"], "generatedPassword": False})
|
||||
# return {"data": {"state": "success"}}
|
||||
|
|
|
|||
|
|
@ -3,30 +3,29 @@ from chalicelib.core import events, sessions_metas, socket_ios, metadata, events
|
|||
sessions_mobs, issues, projects, errors, resources, assist
|
||||
|
||||
SESSION_PROJECTION_COLS = """s.project_id,
|
||||
s.session_id::text AS session_id,
|
||||
s.user_uuid,
|
||||
s.user_id,
|
||||
s.user_agent,
|
||||
s.user_os,
|
||||
s.user_browser,
|
||||
s.user_device,
|
||||
s.user_device_type,
|
||||
s.user_country,
|
||||
s.start_ts,
|
||||
s.duration,
|
||||
s.events_count,
|
||||
s.pages_count,
|
||||
s.errors_count,
|
||||
s.user_anonymous_id,
|
||||
s.platform,
|
||||
s.issue_score,
|
||||
to_jsonb(s.issue_types) AS issue_types,
|
||||
favorite_sessions.session_id NOTNULL AS favorite,
|
||||
COALESCE((SELECT TRUE
|
||||
FROM public.user_viewed_sessions AS fs
|
||||
WHERE s.session_id = fs.session_id
|
||||
AND fs.user_id = %(userId)s LIMIT 1), FALSE) AS viewed
|
||||
"""
|
||||
s.session_id::text AS session_id,
|
||||
s.user_uuid,
|
||||
s.user_id,
|
||||
s.user_agent,
|
||||
s.user_os,
|
||||
s.user_browser,
|
||||
s.user_device,
|
||||
s.user_device_type,
|
||||
s.user_country,
|
||||
s.start_ts,
|
||||
s.duration,
|
||||
s.events_count,
|
||||
s.pages_count,
|
||||
s.errors_count,
|
||||
s.user_anonymous_id,
|
||||
s.platform,
|
||||
s.issue_score,
|
||||
to_jsonb(s.issue_types) AS issue_types,
|
||||
favorite_sessions.session_id NOTNULL AS favorite,
|
||||
COALESCE((SELECT TRUE
|
||||
FROM public.user_viewed_sessions AS fs
|
||||
WHERE s.session_id = fs.session_id
|
||||
AND fs.user_id = %(userId)s LIMIT 1), FALSE) AS viewed """
|
||||
|
||||
|
||||
def __group_metadata(session, project_metadata):
|
||||
|
|
@ -120,7 +119,14 @@ new_line = "\n"
|
|||
|
||||
def __get_sql_operator(op):
|
||||
op = op.lower()
|
||||
return "=" if op == "is" or op == "on" else "!=" if op == "isnot" else "ILIKE" if op == "contains" else "NOT ILIKE" if op == "notcontains" else "="
|
||||
return {
|
||||
"is": "=",
|
||||
"on": "=",
|
||||
"isnot": "!=",
|
||||
"noton": "!=",
|
||||
"contains": "ILIKE",
|
||||
"notcontains": "NOT ILIKE",
|
||||
}.get(op, "=")
|
||||
|
||||
|
||||
def __is_negation_operator(op):
|
||||
|
|
@ -165,27 +171,30 @@ def search2_pg(data, project_id, user_id, favorite_only=False, errors_only=False
|
|||
fav_only_join = "LEFT JOIN public.user_favorite_sessions AS fs ON fs.session_id = s.session_id"
|
||||
extra_constraints.append(cur.mogrify("fs.user_id = %(userId)s", {"userId": user_id}))
|
||||
events_query_part = ""
|
||||
strict = True
|
||||
|
||||
if len(data.get("events", [])) > 0:
|
||||
events_query_from = []
|
||||
event_index = 0
|
||||
|
||||
for event in data["events"]:
|
||||
# TODO: remove this when message_id is removed
|
||||
seq_id = False
|
||||
event_type = event["type"].upper()
|
||||
if event.get("operator") is None:
|
||||
event["operator"] = "is"
|
||||
op = __get_sql_operator(event["operator"])
|
||||
is_not = False
|
||||
if __is_negation_operator(op) and event_index > 0:
|
||||
if __is_negation_operator(op):
|
||||
is_not = True
|
||||
op = __reverse_sql_operator(op)
|
||||
event_from = "%s INNER JOIN public.sessions AS ms USING (session_id)"
|
||||
event_where = ["ms.project_id = %(projectId)s", "main.timestamp >= %(startDate)s",
|
||||
"main.timestamp <= %(endDate)s", "ms.start_ts >= %(startDate)s",
|
||||
"ms.start_ts <= %(endDate)s"]
|
||||
if event_index == 0:
|
||||
event_from = "%s INNER JOIN public.sessions AS ms USING (session_id)"
|
||||
event_where = ["ms.project_id = %(projectId)s", "main.timestamp >= %(startDate)s",
|
||||
"main.timestamp <= %(endDate)s", "ms.start_ts >= %(startDate)s",
|
||||
"ms.start_ts <= %(endDate)s", "ms.duration IS NOT NULL"]
|
||||
else:
|
||||
event_from = "%s"
|
||||
event_where = ["main.timestamp >= %(startDate)s", "main.timestamp <= %(endDate)s",
|
||||
f"event_{event_index - 1}.timestamp <= main.timestamp",
|
||||
"main.session_id=event_0.session_id"]
|
||||
event_args = {"value": helper.string_to_sql_like_with_op(event['value'], op)}
|
||||
if event_type not in list(events.SUPPORTED_TYPES.keys()) \
|
||||
or event.get("value") in [None, "", "*"] \
|
||||
|
|
@ -206,11 +215,9 @@ def search2_pg(data, project_id, user_id, favorite_only=False, errors_only=False
|
|||
event_from = event_from % f"{events.event_type.LOCATION.table} AS main "
|
||||
event_where.append(f"main.{events.event_type.LOCATION.column} {op} %(value)s")
|
||||
elif event_type == events.event_type.CUSTOM.ui_type:
|
||||
seq_id = True
|
||||
event_from = event_from % f"{events.event_type.CUSTOM.table} AS main "
|
||||
event_where.append(f"main.{events.event_type.CUSTOM.column} {op} %(value)s")
|
||||
elif event_type == events.event_type.REQUEST.ui_type:
|
||||
seq_id = True
|
||||
event_from = event_from % f"{events.event_type.REQUEST.table} AS main "
|
||||
event_where.append(f"main.{events.event_type.REQUEST.column} {op} %(value)s")
|
||||
elif event_type == events.event_type.GRAPHQL.ui_type:
|
||||
|
|
@ -234,12 +241,10 @@ def search2_pg(data, project_id, user_id, favorite_only=False, errors_only=False
|
|||
|
||||
# ----- IOS
|
||||
elif event_type == events.event_type.CLICK_IOS.ui_type:
|
||||
seq_id = True
|
||||
event_from = event_from % f"{events.event_type.CLICK_IOS.table} AS main "
|
||||
event_where.append(f"main.{events.event_type.CLICK_IOS.column} {op} %(value)s")
|
||||
|
||||
elif event_type == events.event_type.INPUT_IOS.ui_type:
|
||||
seq_id = True
|
||||
event_from = event_from % f"{events.event_type.INPUT_IOS.table} AS main "
|
||||
event_where.append(f"main.{events.event_type.INPUT_IOS.column} {op} %(value)s")
|
||||
|
||||
|
|
@ -247,19 +252,15 @@ def search2_pg(data, project_id, user_id, favorite_only=False, errors_only=False
|
|||
event_where.append("main.value ILIKE %(custom)s")
|
||||
event_args["custom"] = helper.string_to_sql_like_with_op(event['custom'], "ILIKE")
|
||||
elif event_type == events.event_type.VIEW_IOS.ui_type:
|
||||
seq_id = True
|
||||
event_from = event_from % f"{events.event_type.VIEW_IOS.table} AS main "
|
||||
event_where.append(f"main.{events.event_type.VIEW_IOS.column} {op} %(value)s")
|
||||
elif event_type == events.event_type.CUSTOM_IOS.ui_type:
|
||||
seq_id = True
|
||||
event_from = event_from % f"{events.event_type.CUSTOM_IOS.table} AS main "
|
||||
event_where.append(f"main.{events.event_type.CUSTOM_IOS.column} {op} %(value)s")
|
||||
elif event_type == events.event_type.REQUEST_IOS.ui_type:
|
||||
seq_id = True
|
||||
event_from = event_from % f"{events.event_type.REQUEST_IOS.table} AS main "
|
||||
event_where.append(f"main.{events.event_type.REQUEST_IOS.column} {op} %(value)s")
|
||||
elif event_type == events.event_type.ERROR_IOS.ui_type:
|
||||
seq_id = True
|
||||
event_from = event_from % f"{events.event_type.ERROR_IOS.table} AS main INNER JOIN public.crashes_ios AS main1 USING(crash_id)"
|
||||
if event.get("value") not in [None, "*", ""]:
|
||||
event_where.append(f"(main1.reason {op} %(value)s OR main1.name {op} %(value)s)")
|
||||
|
|
@ -267,29 +268,50 @@ def search2_pg(data, project_id, user_id, favorite_only=False, errors_only=False
|
|||
else:
|
||||
continue
|
||||
|
||||
event_index += 1
|
||||
if is_not:
|
||||
event_from += f""" LEFT JOIN (SELECT session_id FROM {event_from} WHERE {" AND ".join(event_where)}) AS left_not USING (session_id)"""
|
||||
event_where[-1] = "left_not.session_id ISNULL"
|
||||
events_query_from.append(cur.mogrify(f"""\
|
||||
if event_index == 0:
|
||||
events_query_from.append(cur.mogrify(f"""\
|
||||
(SELECT
|
||||
session_id,
|
||||
0 AS timestamp,
|
||||
{event_index} AS funnel_step
|
||||
FROM sessions
|
||||
WHERE EXISTS(SELECT session_id
|
||||
FROM {event_from}
|
||||
WHERE {" AND ".join(event_where)}
|
||||
AND sessions.session_id=ms.session_id) IS FALSE
|
||||
AND project_id = %(projectId)s
|
||||
AND start_ts >= %(startDate)s
|
||||
AND start_ts <= %(endDate)s
|
||||
AND duration IS NOT NULL
|
||||
) AS event_{event_index} {"ON(TRUE)" if event_index > 0 else ""}\
|
||||
""", {**generic_args, **event_args}).decode('UTF-8'))
|
||||
else:
|
||||
events_query_from.append(cur.mogrify(f"""\
|
||||
(SELECT
|
||||
main.session_id, {'seq_index' if seq_id else 'message_id %%%% 2147483647 AS seq_index'}, timestamp, {event_index} AS funnel_step
|
||||
event_0.session_id,
|
||||
event_{event_index - 1}.timestamp AS timestamp,
|
||||
{event_index} AS funnel_step
|
||||
WHERE EXISTS(SELECT session_id FROM {event_from} WHERE {" AND ".join(event_where)}) IS FALSE
|
||||
) AS event_{event_index} {"ON(TRUE)" if event_index > 0 else ""}\
|
||||
""", {**generic_args, **event_args}).decode('UTF-8'))
|
||||
else:
|
||||
events_query_from.append(cur.mogrify(f"""\
|
||||
(SELECT main.session_id, MIN(timestamp) AS timestamp,{event_index} AS funnel_step
|
||||
FROM {event_from}
|
||||
WHERE {" AND ".join(event_where)}
|
||||
)\
|
||||
GROUP BY 1
|
||||
) AS event_{event_index} {"ON(TRUE)" if event_index > 0 else ""}\
|
||||
""", {**generic_args, **event_args}).decode('UTF-8'))
|
||||
|
||||
if len(events_query_from) > 0:
|
||||
events_query_part = f"""\
|
||||
SELECT
|
||||
session_id, MIN(timestamp) AS first_event_ts, MAX(timestamp) AS last_event_ts
|
||||
FROM
|
||||
({(" UNION ALL ").join(events_query_from)}) AS f_query
|
||||
GROUP BY 1
|
||||
{"" if event_index < 2 else f"HAVING events.funnel(array_agg(funnel_step ORDER BY timestamp,seq_index ASC), {event_index})" if strict
|
||||
else f"HAVING array_length(array_agg(DISTINCT funnel_step), 1) = {len(data['events'])}"}
|
||||
{fav_only_join}
|
||||
"""
|
||||
event_index += 1
|
||||
if event_index > 0:
|
||||
events_query_part = f"""SELECT
|
||||
event_0.session_id,
|
||||
MIN(event_0.timestamp) AS first_event_ts,
|
||||
MAX(event_{event_index - 1}.timestamp) AS last_event_ts
|
||||
FROM {(" INNER JOIN LATERAL ").join(events_query_from)}
|
||||
GROUP BY 1
|
||||
{fav_only_join}"""
|
||||
else:
|
||||
data["events"] = []
|
||||
|
||||
|
|
@ -423,8 +445,7 @@ def search2_pg(data, project_id, user_id, favorite_only=False, errors_only=False
|
|||
{" AND ".join(extra_constraints)}"""
|
||||
|
||||
if errors_only:
|
||||
main_query = cur.mogrify(f"""\
|
||||
SELECT DISTINCT er.error_id, ser.status, ser.parent_error_id, ser.payload,
|
||||
main_query = cur.mogrify(f"""SELECT DISTINCT er.error_id, ser.status, ser.parent_error_id, ser.payload,
|
||||
COALESCE((SELECT TRUE
|
||||
FROM public.user_favorite_sessions AS fs
|
||||
WHERE s.session_id = fs.session_id
|
||||
|
|
@ -437,13 +458,12 @@ def search2_pg(data, project_id, user_id, favorite_only=False, errors_only=False
|
|||
generic_args)
|
||||
|
||||
elif count_only:
|
||||
main_query = cur.mogrify(f"""\
|
||||
SELECT COUNT(DISTINCT s.session_id) AS count_sessions, COUNT(DISTINCT s.user_uuid) AS count_users
|
||||
main_query = cur.mogrify(
|
||||
f"""SELECT COUNT(DISTINCT s.session_id) AS count_sessions, COUNT(DISTINCT s.user_uuid) AS count_users
|
||||
{query_part};""",
|
||||
generic_args)
|
||||
generic_args)
|
||||
else:
|
||||
main_query = cur.mogrify(f"""\
|
||||
SELECT * FROM
|
||||
main_query = cur.mogrify(f"""SELECT * FROM
|
||||
(SELECT DISTINCT ON(s.session_id) {SESSION_PROJECTION_COLS}
|
||||
{query_part}
|
||||
ORDER BY s.session_id desc) AS filtred_sessions
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ def create_step1(data):
|
|||
errors.append("Tenant already exists, please select it from dropdown")
|
||||
elif len(signed_ups) == 0 and data.get("tenantId") is not None \
|
||||
or len(signed_ups) > 0 and data.get("tenantId") not in [t['tenantId'] for t in signed_ups]:
|
||||
errors.append("Tenant does not exist")
|
||||
errors.append("Tenant not found")
|
||||
|
||||
if len(errors) > 0:
|
||||
print("==> error")
|
||||
|
|
|
|||
|
|
@ -9,20 +9,26 @@ from chalicelib.utils.TimeUTC import TimeUTC
|
|||
from chalicelib.utils.helper import environ
|
||||
|
||||
from chalicelib.core import tenants
|
||||
import secrets
|
||||
|
||||
|
||||
def create_new_member(email, password, admin, name, owner=False):
|
||||
def __generate_invitation_token():
|
||||
return secrets.token_urlsafe(64)
|
||||
|
||||
|
||||
def create_new_member(email, invitation_token, admin, name, owner=False):
|
||||
with pg_client.PostgresClient() as cur:
|
||||
query = cur.mogrify(f"""\
|
||||
WITH u AS (
|
||||
INSERT INTO public.users (email, role, name, data)
|
||||
VALUES (%(email)s, %(role)s, %(name)s, %(data)s)
|
||||
RETURNING user_id,email,role,name,appearance
|
||||
),
|
||||
au AS (INSERT
|
||||
INTO public.basic_authentication (user_id, password, generated_password)
|
||||
VALUES ((SELECT user_id FROM u), crypt(%(password)s, gen_salt('bf', 12)), TRUE))
|
||||
SELECT u.user_id AS id,
|
||||
WITH u AS (INSERT INTO public.users (email, role, name, data)
|
||||
VALUES (%(email)s, %(role)s, %(name)s, %(data)s)
|
||||
RETURNING user_id,email,role,name,appearance
|
||||
),
|
||||
au AS (INSERT INTO public.basic_authentication (user_id, generated_password, invitation_token, invited_at)
|
||||
VALUES ((SELECT user_id FROM u), TRUE, %(invitation_token)s, timezone('utc'::text, now()))
|
||||
RETURNING invitation_token
|
||||
)
|
||||
SELECT u.user_id,
|
||||
u.user_id AS id,
|
||||
u.email,
|
||||
u.role,
|
||||
u.name,
|
||||
|
|
@ -30,18 +36,18 @@ def create_new_member(email, password, admin, name, owner=False):
|
|||
(CASE WHEN u.role = 'owner' THEN TRUE ELSE FALSE END) AS super_admin,
|
||||
(CASE WHEN u.role = 'admin' THEN TRUE ELSE FALSE END) AS admin,
|
||||
(CASE WHEN u.role = 'member' THEN TRUE ELSE FALSE END) AS member,
|
||||
u.appearance
|
||||
FROM u;""",
|
||||
{"email": email, "password": password,
|
||||
"role": "owner" if owner else "admin" if admin else "member", "name": name,
|
||||
"data": json.dumps({"lastAnnouncementView": TimeUTC.now()})})
|
||||
au.invitation_token
|
||||
FROM u,au;""",
|
||||
{"email": email, "role": "owner" if owner else "admin" if admin else "member", "name": name,
|
||||
"data": json.dumps({"lastAnnouncementView": TimeUTC.now()}),
|
||||
"invitation_token": invitation_token})
|
||||
cur.execute(
|
||||
query
|
||||
)
|
||||
return helper.dict_to_camel_case(cur.fetchone())
|
||||
|
||||
|
||||
def restore_member(user_id, email, password, admin, name, owner=False):
|
||||
def restore_member(user_id, email, invitation_token, admin, name, owner=False):
|
||||
with pg_client.PostgresClient() as cur:
|
||||
query = cur.mogrify(f"""\
|
||||
UPDATE public.users
|
||||
|
|
@ -58,31 +64,62 @@ def restore_member(user_id, email, password, admin, name, owner=False):
|
|||
TRUE AS change_password,
|
||||
(CASE WHEN role = 'owner' THEN TRUE ELSE FALSE END) AS super_admin,
|
||||
(CASE WHEN role = 'admin' THEN TRUE ELSE FALSE END) AS admin,
|
||||
(CASE WHEN role = 'member' THEN TRUE ELSE FALSE END) AS member,
|
||||
appearance;""",
|
||||
(CASE WHEN role = 'member' THEN TRUE ELSE FALSE END) AS member;""",
|
||||
{"user_id": user_id, "email": email,
|
||||
"role": "owner" if owner else "admin" if admin else "member", "name": name})
|
||||
cur.execute(
|
||||
query
|
||||
)
|
||||
result = helper.dict_to_camel_case(cur.fetchone())
|
||||
result = cur.fetchone()
|
||||
query = cur.mogrify("""\
|
||||
UPDATE public.basic_authentication
|
||||
SET password= crypt(%(password)s, gen_salt('bf', 12)),
|
||||
generated_password= TRUE,
|
||||
token=NULL,
|
||||
token_requested_at=NULL
|
||||
WHERE user_id=%(user_id)s;""",
|
||||
{"user_id": user_id, "password": password})
|
||||
SET generated_password = TRUE,
|
||||
invitation_token = %(invitation_token)s,
|
||||
invited_at = timezone('utc'::text, now()),
|
||||
change_pwd_expire_at = NULL,
|
||||
change_pwd_token = NULL
|
||||
WHERE user_id=%(user_id)s
|
||||
RETURNING invitation_token;""",
|
||||
{"user_id": user_id, "invitation_token": invitation_token})
|
||||
cur.execute(
|
||||
query
|
||||
)
|
||||
result["invitation_token"] = cur.fetchone()["invitation_token"]
|
||||
|
||||
return result
|
||||
return helper.dict_to_camel_case(result)
|
||||
|
||||
|
||||
def generate_new_invitation(user_id):
|
||||
invitation_token = __generate_invitation_token()
|
||||
with pg_client.PostgresClient() as cur:
|
||||
query = cur.mogrify("""\
|
||||
UPDATE public.basic_authentication
|
||||
SET invitation_token = %(invitation_token)s,
|
||||
invited_at = timezone('utc'::text, now()),
|
||||
change_pwd_expire_at = NULL,
|
||||
change_pwd_token = NULL
|
||||
WHERE user_id=%(user_id)s
|
||||
RETURNING invitation_token;""",
|
||||
{"user_id": user_id, "invitation_token": invitation_token})
|
||||
cur.execute(
|
||||
query
|
||||
)
|
||||
return __get_invitation_link(cur.fetchone().pop("invitation_token"))
|
||||
|
||||
|
||||
|
||||
def reset_member(tenant_id, editor_id, user_id_to_update):
|
||||
admin = get(tenant_id=tenant_id, user_id=editor_id)
|
||||
if not admin["admin"] and not admin["superAdmin"]:
|
||||
return {"errors": ["unauthorized"]}
|
||||
user = get(tenant_id=tenant_id, user_id=user_id_to_update)
|
||||
if not user:
|
||||
return {"errors": ["user not found"]}
|
||||
return {"data": {"invitationLink": generate_new_invitation(user_id_to_update)}}
|
||||
|
||||
|
||||
def update(tenant_id, user_id, changes):
|
||||
AUTH_KEYS = ["password", "generatedPassword", "token"]
|
||||
AUTH_KEYS = ["password", "generatedPassword", "invitationToken", "invitedAt", "changePwdExpireAt", "changePwdToken"]
|
||||
if len(changes.keys()) == 0:
|
||||
return None
|
||||
|
||||
|
|
@ -93,13 +130,6 @@ def update(tenant_id, user_id, changes):
|
|||
if key == "password":
|
||||
sub_query_bauth.append("password = crypt(%(password)s, gen_salt('bf', 12))")
|
||||
sub_query_bauth.append("changed_at = timezone('utc'::text, now())")
|
||||
elif key == "token":
|
||||
if changes[key] is not None:
|
||||
sub_query_bauth.append("token = %(token)s")
|
||||
sub_query_bauth.append("token_requested_at = timezone('utc'::text, now())")
|
||||
else:
|
||||
sub_query_bauth.append("token = NULL")
|
||||
sub_query_bauth.append("token_requested_at = NULL")
|
||||
else:
|
||||
sub_query_bauth.append(f"{helper.key_to_snake_case(key)} = %({key})s")
|
||||
else:
|
||||
|
|
@ -166,26 +196,43 @@ def create_member(tenant_id, user_id, data):
|
|||
return {"errors": ["invalid user name"]}
|
||||
if name is None:
|
||||
name = data["email"]
|
||||
temp_pass = helper.generate_salt()[:8]
|
||||
invitation_token = __generate_invitation_token()
|
||||
user = get_deleted_user_by_email(email=data["email"])
|
||||
if user is not None:
|
||||
new_member = restore_member(email=data["email"], password=temp_pass,
|
||||
new_member = restore_member(email=data["email"], invitation_token=invitation_token,
|
||||
admin=data.get("admin", False), name=name, user_id=user["userId"])
|
||||
else:
|
||||
new_member = create_new_member(email=data["email"], password=temp_pass,
|
||||
new_member = create_new_member(email=data["email"], invitation_token=invitation_token,
|
||||
admin=data.get("admin", False), name=name)
|
||||
|
||||
new_member["invitationLink"] = __get_invitation_link(new_member.pop("invitationToken"))
|
||||
helper.async_post(environ['email_basic'] % 'member_invitation',
|
||||
{
|
||||
"email": data["email"],
|
||||
"userName": data["email"],
|
||||
"tempPassword": temp_pass,
|
||||
"invitationLink": new_member["invitationLink"],
|
||||
"clientId": tenants.get_by_tenant_id(tenant_id)["name"],
|
||||
"senderName": admin["name"]
|
||||
})
|
||||
return {"data": new_member}
|
||||
|
||||
|
||||
def __get_invitation_link(invitation_token):
|
||||
return environ["SITE_URL"] + environ["invitation_link"] % invitation_token
|
||||
|
||||
|
||||
def allow_password_change(user_id, delta_min=10):
|
||||
pass_token = secrets.token_urlsafe(8)
|
||||
with pg_client.PostgresClient() as cur:
|
||||
query = cur.mogrify(f"""UPDATE public.basic_authentication
|
||||
SET change_pwd_expire_at = timezone('utc'::text, now()+INTERVAL '%(delta)s MINUTES'),
|
||||
change_pwd_token = %(pass_token)s
|
||||
WHERE user_id = %(user_id)s""",
|
||||
{"user_id": user_id, "delta": delta_min, "pass_token": pass_token})
|
||||
cur.execute(
|
||||
query
|
||||
)
|
||||
return pass_token
|
||||
|
||||
|
||||
def get(user_id, tenant_id):
|
||||
with pg_client.PostgresClient() as cur:
|
||||
cur.execute(
|
||||
|
|
@ -317,14 +364,24 @@ def get_members(tenant_id):
|
|||
basic_authentication.generated_password,
|
||||
(CASE WHEN users.role = 'owner' THEN TRUE ELSE FALSE END) AS super_admin,
|
||||
(CASE WHEN users.role = 'admin' THEN TRUE ELSE FALSE END) AS admin,
|
||||
(CASE WHEN users.role = 'member' THEN TRUE ELSE FALSE END) AS member
|
||||
(CASE WHEN users.role = 'member' THEN TRUE ELSE FALSE END) AS member,
|
||||
DATE_PART('day',timezone('utc'::text, now()) \
|
||||
- COALESCE(basic_authentication.invited_at,'2000-01-01'::timestamp ))>=1 AS expired_invitation,
|
||||
basic_authentication.password IS NOT NULL AS joined,
|
||||
invitation_token
|
||||
FROM public.users LEFT JOIN public.basic_authentication ON users.user_id=basic_authentication.user_id
|
||||
WHERE users.deleted_at IS NULL
|
||||
ORDER BY name, id"""
|
||||
)
|
||||
r = cur.fetchall()
|
||||
if len(r):
|
||||
return helper.list_to_camel_case(r)
|
||||
r = helper.list_to_camel_case(r)
|
||||
for u in r:
|
||||
if u["invitationToken"]:
|
||||
u["invitationLink"] = __get_invitation_link(u.pop("invitationToken"))
|
||||
else:
|
||||
u["invitationLink"] = None
|
||||
return r
|
||||
|
||||
return []
|
||||
|
||||
|
|
@ -367,6 +424,15 @@ def change_password(tenant_id, user_id, email, old_password, new_password):
|
|||
"jwt": authenticate(email, new_password)["jwt"]}
|
||||
|
||||
|
||||
def set_password_invitation(user_id, new_password):
|
||||
changes = {"password": new_password, "generatedPassword": False,
|
||||
"invitationToken": None, "invitedAt": None,
|
||||
"changePwdExpireAt": None, "changePwdToken": None}
|
||||
user = update(tenant_id=-1, user_id=user_id, changes=changes)
|
||||
return {"data": user,
|
||||
"jwt": authenticate(user["email"], new_password)["jwt"]}
|
||||
|
||||
|
||||
def count_members():
|
||||
with pg_client.PostgresClient() as cur:
|
||||
cur.execute("""SELECT COUNT(user_id)
|
||||
|
|
@ -409,6 +475,24 @@ def get_deleted_user_by_email(email):
|
|||
return helper.dict_to_camel_case(r)
|
||||
|
||||
|
||||
def get_by_invitation_token(token, pass_token=None):
|
||||
with pg_client.PostgresClient() as cur:
|
||||
cur.execute(
|
||||
cur.mogrify(
|
||||
f"""SELECT
|
||||
*,
|
||||
DATE_PART('day',timezone('utc'::text, now()) \
|
||||
- COALESCE(basic_authentication.invited_at,'2000-01-01'::timestamp ))>=1 AS expired_invitation,
|
||||
change_pwd_expire_at <= timezone('utc'::text, now()) AS expired_change
|
||||
FROM public.users INNER JOIN public.basic_authentication USING(user_id)
|
||||
WHERE invitation_token = %(token)s {"AND change_pwd_token = %(pass_token)s" if pass_token else ""}
|
||||
LIMIT 1;""",
|
||||
{"token": token, "pass_token": token})
|
||||
)
|
||||
r = cur.fetchone()
|
||||
return helper.dict_to_camel_case(r)
|
||||
|
||||
|
||||
def auth_exists(user_id, tenant_id, jwt_iat, jwt_aud):
|
||||
with pg_client.PostgresClient() as cur:
|
||||
cur.execute(
|
||||
|
|
|
|||
|
|
@ -2,18 +2,18 @@ from chalicelib.utils.TimeUTC import TimeUTC
|
|||
from chalicelib.utils.email_handler import __get_html_from_file, send_html, __escape_text_html
|
||||
|
||||
|
||||
def send_team_invitation(recipient, user_name, temp_password, client_id, sender_name):
|
||||
def send_team_invitation(recipient, client_id, sender_name, invitation_link):
|
||||
BODY_HTML = __get_html_from_file("chalicelib/utils/html/invitation.html",
|
||||
formatting_variables={"userName": __escape_text_html(user_name),
|
||||
"password": temp_password, "clientId": client_id,
|
||||
formatting_variables={"invitationLink": invitation_link,
|
||||
"clientId": client_id,
|
||||
"sender": sender_name})
|
||||
SUBJECT = "Welcome to OpenReplay"
|
||||
send_html(BODY_HTML, SUBJECT, recipient)
|
||||
|
||||
|
||||
def send_reset_code(recipient, reset_code):
|
||||
def send_forgot_password(recipient, invitation_link):
|
||||
BODY_HTML = __get_html_from_file("chalicelib/utils/html/reset_password.html",
|
||||
formatting_variables={"code": reset_code})
|
||||
formatting_variables={"invitationLink": invitation_link})
|
||||
SUBJECT = "Password recovery"
|
||||
send_html(BODY_HTML, SUBJECT, recipient)
|
||||
|
||||
|
|
|
|||
|
|
@ -363,3 +363,7 @@ def get_internal_project_id(project_id64):
|
|||
return None
|
||||
project_id = int(project_id64)
|
||||
return project_id
|
||||
|
||||
|
||||
def has_smtp():
|
||||
return environ["EMAIL_HOST"] is not None and len(environ["EMAIL_HOST"]) > 0
|
||||
|
|
|
|||
|
|
@ -447,10 +447,10 @@ width: 25%!important
|
|||
<p style="font-size: 14px; line-height: 16px; text-align: center; margin: 0;">
|
||||
Please use this link to login:</p>
|
||||
<p style="font-size: 14px; line-height: 21px; text-align: center; margin: 0;">
|
||||
<span style="font-size: 18px;"><a href="%(frontend_url)s"
|
||||
<span style="font-size: 18px;"><a href="%(invitationLink)s"
|
||||
rel="noopener"
|
||||
style="text-decoration: underline; color: #009193;"
|
||||
target="_blank" title="OpenReplay Login">%(frontend_url)s</a></span><span
|
||||
target="_blank" title="OpenReplay Login">%(invitationLink)s</a></span><span
|
||||
style="font-size: 18px; line-height: 21px;"></span></p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -485,40 +485,18 @@ width: 25%!important
|
|||
<tr>
|
||||
<td style="padding-right: 10px; padding-left: 10px; padding-top: 10px; padding-bottom: 10px; font-family: Arial, sans-serif">
|
||||
<![endif]-->
|
||||
<div style="color:#555555;font-family:-apple-system,BlinkMacSystemFont,'Helvetica Neue','Segoe UI',Roboto,Oxygen-Sans,Ubuntu,Cantarell,sans-serif;line-height:120%;padding-top:10px;padding-right:10px;padding-bottom:10px;padding-left:10px;">
|
||||
<div style="font-size: 12px; line-height: 14px; color: #555555; font-family: -apple-system,BlinkMacSystemFont,'Helvetica Neue','Segoe UI',Roboto,Oxygen-Sans,Ubuntu,Cantarell,sans-serif;">
|
||||
<p style="font-size: 14px; line-height: 16px; text-align: center; margin: 0;">
|
||||
Your login credentials</p>
|
||||
</div>
|
||||
</div>
|
||||
<!--[if mso]></td></tr></table><![endif]-->
|
||||
<!--[if mso]>
|
||||
<table width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr>
|
||||
<td style="padding-right: 10px; padding-left: 10px; padding-top: 10px; padding-bottom: 10px; font-family: Arial, sans-serif">
|
||||
<![endif]-->
|
||||
<div style="color:#555555;font-family:-apple-system,BlinkMacSystemFont,'Helvetica Neue','Segoe UI',Roboto,Oxygen-Sans,Ubuntu,Cantarell,sans-serif;line-height:120%;padding-top:10px;padding-right:10px;padding-bottom:10px;padding-left:10px;">
|
||||
<div style="font-size: 12px; line-height: 14px; color: #555555; font-family: -apple-system,BlinkMacSystemFont,'Helvetica Neue','Segoe UI',Roboto,Oxygen-Sans,Ubuntu,Cantarell,sans-serif;">
|
||||
<p style="font-size: 14px; line-height: 16px; text-align: center; margin: 0;">
|
||||
<strong>Username / Email</strong></p>
|
||||
<p style="font-size: 14px; line-height: 16px; text-align: center; margin: 0;">
|
||||
<span style="text-decoration: none; color: #009193;">%(userName)s</span></p>
|
||||
</div>
|
||||
</div>
|
||||
<!--[if mso]></td></tr></table><![endif]-->
|
||||
<!--[if mso]>
|
||||
<table width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr>
|
||||
<td style="padding-right: 10px; padding-left: 10px; padding-top: 10px; padding-bottom: 10px; font-family: Arial, sans-serif">
|
||||
<![endif]-->
|
||||
<div style="color:#555555;font-family:-apple-system,BlinkMacSystemFont,'Helvetica Neue','Segoe UI',Roboto,Oxygen-Sans,Ubuntu,Cantarell,sans-serif;line-height:120%;padding-top:10px;padding-right:10px;padding-bottom:10px;padding-left:10px;">
|
||||
<div style="font-size: 12px; line-height: 14px; color: #555555; font-family: -apple-system,BlinkMacSystemFont,'Helvetica Neue','Segoe UI',Roboto,Oxygen-Sans,Ubuntu,Cantarell,sans-serif;">
|
||||
<p style="font-size: 14px; line-height: 16px; text-align: center; margin: 0;">
|
||||
<strong>Password</strong></p>
|
||||
<p style="font-size: 14px; line-height: 16px; text-align: center; margin: 0;">
|
||||
%(password)s</p>
|
||||
</div>
|
||||
</div>
|
||||
<!--[if mso]></td></tr></table><![endif]-->
|
||||
<table border="0" cellpadding="0" cellspacing="0" class="divider"
|
||||
role="presentation"
|
||||
|
|
|
|||
|
|
@ -452,10 +452,10 @@ width: 25%!important
|
|||
<div style="color:#555555;font-family:-apple-system,BlinkMacSystemFont,'Helvetica Neue','Segoe UI',Roboto,Oxygen-Sans,Ubuntu,Cantarell,sans-serif;line-height:120%;padding-top:10px;padding-right:10px;padding-bottom:10px;padding-left:10px;">
|
||||
<div style="font-size: 12px; line-height: 14px; color: #555555; font-family: -apple-system,BlinkMacSystemFont,'Helvetica Neue','Segoe UI',Roboto,Oxygen-Sans,Ubuntu,Cantarell,sans-serif;">
|
||||
<p style="font-size: 14px; line-height: 16px; text-align: center; margin: 0;">
|
||||
Use the code below to reset your password (valid for 24 hours only):</p>
|
||||
Use the link below to reset your password (valid for 24 hours only):</p>
|
||||
<p style="font-size: 14px; line-height: 21px; text-align: center; margin: 0;">
|
||||
<br/>
|
||||
<span style="font-size: 18px;"><b>%(code)s</b></span><span
|
||||
<span style="font-size: 18px;"><a href="%(invitationLink)s"><b>%(invitationLink)s</b></a></span><span
|
||||
style="font-size: 18px; line-height: 21px;"></span></p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,3 @@
|
|||
BEGIN;
|
||||
CREATE INDEX pages_first_contentful_paint_time_idx ON events.pages (first_contentful_paint_time) WHERE first_contentful_paint_time>0;
|
||||
CREATE INDEX pages_dom_content_loaded_time_idx ON events.pages (dom_content_loaded_time) WHERE dom_content_loaded_time>0;
|
||||
CREATE INDEX pages_first_paint_time_idx ON events.pages (first_paint_time) WHERE first_paint_time > 0;
|
||||
CREATE INDEX pages_ttfb_idx ON events.pages (ttfb) WHERE ttfb > 0;
|
||||
CREATE INDEX pages_time_to_interactive_idx ON events.pages (time_to_interactive) WHERE time_to_interactive > 0;
|
||||
COMMIT;
|
||||
4
backend/pkg/db/cache/project.go
vendored
4
backend/pkg/db/cache/project.go
vendored
|
|
@ -11,7 +11,7 @@ func (c *PGCache) GetProjectByKey(projectKey string) (*Project, error) {
|
|||
return c.projectsByKeys[ projectKey ].Project, nil
|
||||
}
|
||||
p, err := c.Conn.GetProjectByKey(projectKey)
|
||||
if err != nil {
|
||||
if p == nil {
|
||||
return nil, err
|
||||
}
|
||||
c.projectsByKeys[ projectKey ] = &ProjectMeta{ p, time.Now().Add(c.projectExpirationTimeout) }
|
||||
|
|
@ -27,7 +27,7 @@ func (c *PGCache) GetProject(projectID uint32) (*Project, error) {
|
|||
return c.projects[ projectID ].Project, nil
|
||||
}
|
||||
p, err := c.Conn.GetProject(projectID)
|
||||
if err != nil {
|
||||
if p == nil {
|
||||
return nil, err
|
||||
}
|
||||
c.projects[ projectID ] = &ProjectMeta{ p, time.Now().Add(c.projectExpirationTimeout) }
|
||||
|
|
|
|||
|
|
@ -105,11 +105,14 @@ func (conn *Conn) InsertWebClickEvent(sessionID uint64, e *ClickEvent) error {
|
|||
defer tx.rollback()
|
||||
if err = tx.exec(`
|
||||
INSERT INTO events.clicks
|
||||
(session_id, message_id, timestamp, label)
|
||||
VALUES
|
||||
($1, $2, $3, NULLIF($4, ''))
|
||||
(session_id, message_id, timestamp, label, selector, url)
|
||||
(SELECT
|
||||
$1, $2, $3, NULLIF($4, ''), $5, host || base_path
|
||||
FROM events.pages
|
||||
WHERE session_id = $1 AND timestamp <= $3 ORDER BY timestamp DESC LIMIT 1
|
||||
)
|
||||
`,
|
||||
sessionID, e.MessageID, e.Timestamp, e.Label,
|
||||
sessionID, e.MessageID, e.Timestamp, e.Label, e.Selector,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ func ReadBatch(b []byte, callback func(Message)) error {
|
|||
} else if err != nil {
|
||||
return errors.Wrapf(err, "Batch Message decoding error on message with index %v", index)
|
||||
}
|
||||
msg = transformDepricated(msg)
|
||||
|
||||
isBatchMeta := false
|
||||
switch m := msg.(type){
|
||||
case *BatchMeta: // Is not required to be present in batch since IOS doesn't have it (though we might change it)
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
// Auto-generated, do not edit
|
||||
package messages
|
||||
|
||||
|
||||
|
|
|
|||
32
backend/pkg/messages/legacy_message_transform.go
Normal file
32
backend/pkg/messages/legacy_message_transform.go
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
package messages
|
||||
|
||||
|
||||
func transformDepricated(msg Message) Message {
|
||||
switch m := msg.(type) {
|
||||
case *MouseClickDepricated:
|
||||
meta := m.Meta()
|
||||
meta.TypeID = 33
|
||||
return &MouseClick{
|
||||
meta: meta,
|
||||
ID: m.ID,
|
||||
HesitationTime: m.HesitationTime,
|
||||
Label: m.Label,
|
||||
// Selector: '',
|
||||
}
|
||||
// case *FetchDepricated:
|
||||
// return &Fetch {
|
||||
// Method: m.Method,
|
||||
// URL: m.URL,
|
||||
// Request: m.Request,
|
||||
// Response: m.Response,
|
||||
// Status: m.Status,
|
||||
// Timestamp: m.Timestamp,
|
||||
// Duration: m.Duration,
|
||||
// // Headers: ''
|
||||
// }
|
||||
default:
|
||||
return msg
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -350,13 +350,13 @@ p = WriteUint(msg.Y, buf, p)
|
|||
return buf[:p]
|
||||
}
|
||||
|
||||
type MouseClick struct {
|
||||
type MouseClickDepricated struct {
|
||||
*meta
|
||||
ID uint64
|
||||
HesitationTime uint64
|
||||
Label string
|
||||
}
|
||||
func (msg *MouseClick) Encode() []byte{
|
||||
func (msg *MouseClickDepricated) Encode() []byte{
|
||||
buf := make([]byte, 31 + len(msg.Label))
|
||||
buf[0] = 21
|
||||
p := 1
|
||||
|
|
@ -582,15 +582,17 @@ type ClickEvent struct {
|
|||
Timestamp uint64
|
||||
HesitationTime uint64
|
||||
Label string
|
||||
Selector string
|
||||
}
|
||||
func (msg *ClickEvent) Encode() []byte{
|
||||
buf := make([]byte, 41 + len(msg.Label))
|
||||
buf := make([]byte, 51 + len(msg.Label)+ len(msg.Selector))
|
||||
buf[0] = 33
|
||||
p := 1
|
||||
p = WriteUint(msg.MessageID, buf, p)
|
||||
p = WriteUint(msg.Timestamp, buf, p)
|
||||
p = WriteUint(msg.HesitationTime, buf, p)
|
||||
p = WriteString(msg.Label, buf, p)
|
||||
p = WriteString(msg.Selector, buf, p)
|
||||
return buf[:p]
|
||||
}
|
||||
|
||||
|
|
@ -1146,6 +1148,24 @@ p = WriteString(msg.BaseURL, buf, p)
|
|||
return buf[:p]
|
||||
}
|
||||
|
||||
type MouseClick struct {
|
||||
*meta
|
||||
ID uint64
|
||||
HesitationTime uint64
|
||||
Label string
|
||||
Selector string
|
||||
}
|
||||
func (msg *MouseClick) Encode() []byte{
|
||||
buf := make([]byte, 41 + len(msg.Label)+ len(msg.Selector))
|
||||
buf[0] = 69
|
||||
p := 1
|
||||
p = WriteUint(msg.ID, buf, p)
|
||||
p = WriteUint(msg.HesitationTime, buf, p)
|
||||
p = WriteString(msg.Label, buf, p)
|
||||
p = WriteString(msg.Selector, buf, p)
|
||||
return buf[:p]
|
||||
}
|
||||
|
||||
type IOSSessionStart struct {
|
||||
*meta
|
||||
Timestamp uint64
|
||||
|
|
|
|||
|
|
@ -159,7 +159,7 @@ if msg.Y, err = ReadUint(reader); err != nil { return nil, err }
|
|||
return msg, nil
|
||||
|
||||
case 21:
|
||||
msg := &MouseClick{ meta: &meta{ TypeID: 21} }
|
||||
msg := &MouseClickDepricated{ meta: &meta{ TypeID: 21} }
|
||||
if msg.ID, err = ReadUint(reader); err != nil { return nil, err }
|
||||
if msg.HesitationTime, err = ReadUint(reader); err != nil { return nil, err }
|
||||
if msg.Label, err = ReadString(reader); err != nil { return nil, err }
|
||||
|
|
@ -265,6 +265,7 @@ if msg.Label, err = ReadString(reader); err != nil { return nil, err }
|
|||
if msg.Timestamp, err = ReadUint(reader); err != nil { return nil, err }
|
||||
if msg.HesitationTime, err = ReadUint(reader); err != nil { return nil, err }
|
||||
if msg.Label, err = ReadString(reader); err != nil { return nil, err }
|
||||
if msg.Selector, err = ReadString(reader); err != nil { return nil, err }
|
||||
return msg, nil
|
||||
|
||||
case 34:
|
||||
|
|
@ -512,6 +513,14 @@ if msg.Index, err = ReadUint(reader); err != nil { return nil, err }
|
|||
if msg.BaseURL, err = ReadString(reader); err != nil { return nil, err }
|
||||
return msg, nil
|
||||
|
||||
case 69:
|
||||
msg := &MouseClick{ meta: &meta{ TypeID: 69} }
|
||||
if msg.ID, err = ReadUint(reader); err != nil { return nil, err }
|
||||
if msg.HesitationTime, err = ReadUint(reader); err != nil { return nil, err }
|
||||
if msg.Label, err = ReadString(reader); err != nil { return nil, err }
|
||||
if msg.Selector, err = ReadString(reader); err != nil { return nil, err }
|
||||
return msg, nil
|
||||
|
||||
case 90:
|
||||
msg := &IOSSessionStart{ meta: &meta{ TypeID: 90} }
|
||||
if msg.Timestamp, err = ReadUint(reader); err != nil { return nil, err }
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"net/url"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func getSessionKey(sessionID uint64) string {
|
||||
|
|
@ -56,15 +57,16 @@ func GetFullCachableURL(baseURL string, relativeURL string) (string, bool) {
|
|||
const OPENREPLAY_QUERY_START = "OPENREPLAY_QUERY"
|
||||
|
||||
func getCachePath(rawurl string) string {
|
||||
u, _ := url.Parse(rawurl)
|
||||
s := "/" + u.Scheme + "/" + u.Hostname() + u.Path
|
||||
if u.RawQuery != "" {
|
||||
if (s[len(s) - 1] != '/') {
|
||||
s += "/"
|
||||
}
|
||||
s += OPENREPLAY_QUERY_START + url.PathEscape(u.RawQuery)
|
||||
}
|
||||
return s
|
||||
return strings.ReplaceAll(url.QueryEscape(rawurl), "%", "!") // s3 keys are ok with "!"
|
||||
// u, _ := url.Parse(rawurl)
|
||||
// s := "/" + u.Scheme + "/" + u.Hostname() + u.Path
|
||||
// if u.RawQuery != "" {
|
||||
// if (s[len(s) - 1] != '/') {
|
||||
// s += "/"
|
||||
// }
|
||||
// s += OPENREPLAY_QUERY_START + url.PathEscape(u.RawQuery)
|
||||
// }
|
||||
// return s
|
||||
}
|
||||
|
||||
func getCachePathWithKey(sessionID uint64, rawurl string) string {
|
||||
|
|
|
|||
|
|
@ -187,6 +187,7 @@ func (b *builder) handleMessage(message Message, messageID uint64) {
|
|||
Label: msg.Label,
|
||||
HesitationTime: msg.HesitationTime,
|
||||
Timestamp: b.timestamp,
|
||||
Selector: msg.Selector,
|
||||
})
|
||||
}
|
||||
case *JSException:
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import (
|
|||
)
|
||||
|
||||
|
||||
const CLICK_TIME_DIFF = 200
|
||||
const CLICK_TIME_DIFF = 300
|
||||
const MIN_CLICKS_IN_A_ROW = 3
|
||||
|
||||
type clickRageDetector struct {
|
||||
|
|
@ -40,7 +40,7 @@ func (crd *clickRageDetector) Build() *IssueEvent {
|
|||
}
|
||||
|
||||
func (crd *clickRageDetector) HandleMouseClick(msg *MouseClick, messageID uint64, timestamp uint64) *IssueEvent {
|
||||
if crd.lastTimestamp + CLICK_TIME_DIFF < timestamp && crd.lastLabel == msg.Label {
|
||||
if crd.lastTimestamp + CLICK_TIME_DIFF > timestamp && crd.lastLabel == msg.Label {
|
||||
crd.lastTimestamp = timestamp
|
||||
crd.countsInARow += 1
|
||||
return nil
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ type deadClickDetector struct {
|
|||
lastMouseClick *MouseClick
|
||||
lastTimestamp uint64
|
||||
lastMessageID uint64
|
||||
inputIDSet map[uint64]bool
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -24,6 +25,7 @@ func (d *deadClickDetector) HandleReaction(timestamp uint64) *IssueEvent {
|
|||
MessageID: d.lastMessageID,
|
||||
}
|
||||
}
|
||||
d.inputIDSet = nil
|
||||
d.lastMouseClick = nil
|
||||
d.lastTimestamp = 0
|
||||
d.lastMessageID = 0
|
||||
|
|
@ -33,8 +35,18 @@ func (d *deadClickDetector) HandleReaction(timestamp uint64) *IssueEvent {
|
|||
func (d *deadClickDetector) HandleMessage(msg Message, messageID uint64, timestamp uint64) *IssueEvent {
|
||||
var i *IssueEvent
|
||||
switch m := msg.(type) {
|
||||
case *SetInputTarget:
|
||||
if d.inputIDSet == nil {
|
||||
d.inputIDSet = make(map[uint64]bool)
|
||||
}
|
||||
d.inputIDSet[m.ID] = true
|
||||
case *CreateDocument:
|
||||
d.inputIDSet = nil
|
||||
case *MouseClick:
|
||||
i = d.HandleReaction(timestamp)
|
||||
if d.inputIDSet[m.ID] { // ignore if input
|
||||
return i
|
||||
}
|
||||
d.lastMouseClick = m
|
||||
d.lastTimestamp = timestamp
|
||||
d.lastMessageID = messageID
|
||||
|
|
|
|||
|
|
@ -61,62 +61,64 @@ func main() {
|
|||
Addr: ":" + HTTP_PORT,
|
||||
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// TODO: agree with specification
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "POST")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type,Authorization")
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
// TODO: agree with specification
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "POST")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type,Authorization")
|
||||
if r.Method == http.MethodOptions {
|
||||
w.Header().Set("Cache-Control", "max-age=86400")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
switch r.URL.Path {
|
||||
case "/":
|
||||
w.WriteHeader(http.StatusOK)
|
||||
case "/v1/web/not-started":
|
||||
switch r.Method {
|
||||
case "POST":
|
||||
case http.MethodPost:
|
||||
notStartedHandler(w, r)
|
||||
default:
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
}
|
||||
case "/v1/web/start":
|
||||
switch r.Method {
|
||||
case "POST":
|
||||
case http.MethodPost:
|
||||
startSessionHandlerWeb(w, r)
|
||||
default:
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
}
|
||||
case "/v1/web/i":
|
||||
switch r.Method {
|
||||
case "POST":
|
||||
case http.MethodPost:
|
||||
pushMessagesSeparatelyHandler(w, r)
|
||||
default:
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
}
|
||||
// case "/v1/ios/start":
|
||||
// switch r.Method {
|
||||
// case "POST":
|
||||
// case http.MethodPost:
|
||||
// startSessionHandlerIOS(w, r)
|
||||
// default:
|
||||
// w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
// }
|
||||
// case "/v1/ios/append":
|
||||
// switch r.Method {
|
||||
// case "POST":
|
||||
// case http.MethodPost:
|
||||
// pushMessagesHandler(w, r)
|
||||
// default:
|
||||
// w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
// }
|
||||
// case "/v1/ios/late":
|
||||
// switch r.Method {
|
||||
// case "POST":
|
||||
// case http.MethodPost:
|
||||
// pushLateMessagesHandler(w, r)
|
||||
// default:
|
||||
// w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
// }
|
||||
// case "/v1/ios/images":
|
||||
// switch r.Method {
|
||||
// case "POST":
|
||||
// case http.MethodPost:
|
||||
// iosImagesUploadHandler(w, r)
|
||||
// default:
|
||||
// w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
|
|
|
|||
|
|
@ -61,7 +61,9 @@
|
|||
"idp_entityId": "",
|
||||
"idp_sso_url": "",
|
||||
"idp_x509cert": "",
|
||||
"idp_sls_url": ""
|
||||
"idp_sls_url": "",
|
||||
"invitation_link": "/api/users/invitation?token=%s",
|
||||
"change_password_link": "/reset-password?invitation=%s&&pass=%s"
|
||||
},
|
||||
"lambda_timeout": 150,
|
||||
"lambda_memory_size": 400,
|
||||
|
|
|
|||
|
|
@ -87,7 +87,12 @@ def or_middleware(event, get_response):
|
|||
import time
|
||||
now = int(time.time() * 1000)
|
||||
response = get_response(event)
|
||||
if response.status_code == 500 and helper.allow_sentry() and OR_SESSION_TOKEN is not None and not helper.is_local():
|
||||
if response.status_code == 200 and response.body is not None and response.body.get("errors") is not None:
|
||||
if "not found" in response.body["errors"][0]:
|
||||
response = Response(status_code=404, body=response.body)
|
||||
else:
|
||||
response = Response(status_code=400, body=response.body)
|
||||
if response.status_code // 100 == 5 and helper.allow_sentry() and OR_SESSION_TOKEN is not None and not helper.is_local():
|
||||
with configure_scope() as scope:
|
||||
scope.set_tag('stage', environ["stage"])
|
||||
scope.set_tag('openReplaySessionToken', OR_SESSION_TOKEN)
|
||||
|
|
|
|||
|
|
@ -38,9 +38,9 @@ def login():
|
|||
for_plugin=False
|
||||
)
|
||||
if r is None:
|
||||
return {
|
||||
return Response(status_code=401, body={
|
||||
'errors': ['You’ve entered invalid Email or Password.']
|
||||
}
|
||||
})
|
||||
elif "errors" in r:
|
||||
return r
|
||||
|
||||
|
|
@ -103,8 +103,11 @@ def create_edit_project(projectId, context):
|
|||
|
||||
@app.route('/projects/{projectId}', methods=['GET'])
|
||||
def get_project(projectId, context):
|
||||
return {"data": projects.get_project(tenant_id=context["tenantId"], project_id=projectId, include_last_session=True,
|
||||
include_gdpr=True)}
|
||||
data = projects.get_project(tenant_id=context["tenantId"], project_id=projectId, include_last_session=True,
|
||||
include_gdpr=True)
|
||||
if data is None:
|
||||
return {"errors": ["project not found"]}
|
||||
return {"data": data}
|
||||
|
||||
|
||||
@app.route('/projects/{projectId}', methods=['DELETE'])
|
||||
|
|
@ -359,6 +362,38 @@ def add_member(context):
|
|||
return users.create_member(tenant_id=context['tenantId'], user_id=context['userId'], data=data)
|
||||
|
||||
|
||||
@app.route('/users/invitation', methods=['GET'], authorizer=None)
|
||||
def process_invitation_link():
|
||||
params = app.current_request.query_params
|
||||
if params is None or len(params.get("token", "")) < 64:
|
||||
return {"errors": ["please provide a valid invitation"]}
|
||||
user = users.get_by_invitation_token(params["token"])
|
||||
if user is None:
|
||||
return {"errors": ["invitation not found"]}
|
||||
if user["expiredInvitation"]:
|
||||
return {"errors": ["expired invitation, please ask your admin to send a new one"]}
|
||||
pass_token = users.allow_password_change(user_id=user["userId"])
|
||||
return Response(
|
||||
status_code=307,
|
||||
body='',
|
||||
headers={'Location': environ["SITE_URL"] + environ["change_password_link"] % (params["token"], pass_token),
|
||||
'Content-Type': 'text/plain'})
|
||||
|
||||
|
||||
@app.route('/users/invitation/password', methods=['POST', 'PUT'], authorizer=None)
|
||||
def change_password_by_invitation():
|
||||
data = app.current_request.json_body
|
||||
if data is None or len(data.get("invitation", "")) < 64 or len(data.get("pass", "")) < 8:
|
||||
return {"errors": ["please provide a valid invitation & pass"]}
|
||||
user = users.get_by_invitation_token(token=data["token"], pass_token=data["pass"])
|
||||
if user is None:
|
||||
return {"errors": ["invitation not found"]}
|
||||
if user["expiredChange"]:
|
||||
return {"errors": ["expired change, please re-use the invitation link"]}
|
||||
|
||||
return users.set_password_invitation(new_password=data["password"], user_id=user["userId"])
|
||||
|
||||
|
||||
@app.route('/client/members/{memberId}', methods=['PUT', 'POST'])
|
||||
def edit_member(memberId, context):
|
||||
data = app.current_request.json_body
|
||||
|
|
@ -366,6 +401,11 @@ def edit_member(memberId, context):
|
|||
user_id_to_update=memberId)
|
||||
|
||||
|
||||
@app.route('/client/members/{memberId}/reset', methods=['GET'])
|
||||
def reset_reinvite_member(memberId, context):
|
||||
return users.reset_member(tenant_id=context['tenantId'], editor_id=context['userId'], user_id_to_update=memberId)
|
||||
|
||||
|
||||
@app.route('/client/members/{memberId}', methods=['DELETE'])
|
||||
def delete_member(memberId, context):
|
||||
return users.delete_member(tenant_id=context["tenantId"], user_id=context['userId'], id_to_delete=memberId)
|
||||
|
|
|
|||
|
|
@ -250,7 +250,7 @@ def get_details(project_id, error_id, user_id, **data):
|
|||
# print("--------------------")
|
||||
row = ch.execute(query=main_ch_query, params=params)
|
||||
if len(row) == 0:
|
||||
return {"errors": ["error doesn't exist"]}
|
||||
return {"errors": ["error not found"]}
|
||||
row = row[0]
|
||||
row["tags"] = __process_tags(row)
|
||||
with pg_client.PostgresClient() as cur:
|
||||
|
|
@ -406,7 +406,7 @@ def get_details_chart(project_id, error_id, user_id, **data):
|
|||
# print(main_ch_query % params)
|
||||
row = ch.execute(query=main_ch_query, params=params)
|
||||
if len(row) == 0:
|
||||
return {"errors": ["error doesn't exist"]}
|
||||
return {"errors": ["error not found"]}
|
||||
row = row[0]
|
||||
row["tags"] = __process_tags(row)
|
||||
row["chart"] = __rearrange_chart_details(start_at=data["startDate"], end_at=data["endDate"], density=density,
|
||||
|
|
|
|||
|
|
@ -151,7 +151,7 @@ def delete(project_id, funnel_id, user_id):
|
|||
def get_sessions(project_id, funnel_id, user_id, range_value=None, start_date=None, end_date=None):
|
||||
f = get(funnel_id=funnel_id, project_id=project_id)
|
||||
if f is None:
|
||||
return {"errors": ["filter not found"]}
|
||||
return {"errors": ["funnel not found"]}
|
||||
get_start_end_time(filter_d=f["filter"], range_value=range_value, start_date=start_date, end_date=end_date)
|
||||
return sessions.search2_pg(data=f["filter"], project_id=project_id, user_id=user_id)
|
||||
|
||||
|
|
@ -172,12 +172,12 @@ def get_sessions_on_the_fly(funnel_id, project_id, user_id, data):
|
|||
def get_top_insights(project_id, funnel_id, range_value=None, start_date=None, end_date=None):
|
||||
f = get(funnel_id=funnel_id, project_id=project_id)
|
||||
if f is None:
|
||||
return {"errors": ["filter not found"]}
|
||||
return {"errors": ["funnel not found"]}
|
||||
get_start_end_time(filter_d=f["filter"], range_value=range_value, start_date=start_date, end_date=end_date)
|
||||
insights, total_drop_due_to_issues = significance.get_top_insights(filter_d=f["filter"], project_id=project_id)
|
||||
insights[-1]["dropDueToIssues"] = total_drop_due_to_issues
|
||||
return {"stages": helper.list_to_camel_case(insights),
|
||||
"totalDropDueToIssues": total_drop_due_to_issues}
|
||||
return {"data": {"stages": helper.list_to_camel_case(insights),
|
||||
"totalDropDueToIssues": total_drop_due_to_issues}}
|
||||
|
||||
|
||||
def get_top_insights_on_the_fly(funnel_id, project_id, data):
|
||||
|
|
@ -193,8 +193,8 @@ def get_top_insights_on_the_fly(funnel_id, project_id, data):
|
|||
insights, total_drop_due_to_issues = significance.get_top_insights(filter_d=data, project_id=project_id)
|
||||
if len(insights) > 0:
|
||||
insights[-1]["dropDueToIssues"] = total_drop_due_to_issues
|
||||
return {"stages": helper.list_to_camel_case(insights),
|
||||
"totalDropDueToIssues": total_drop_due_to_issues}
|
||||
return {"data": {"stages": helper.list_to_camel_case(insights),
|
||||
"totalDropDueToIssues": total_drop_due_to_issues}}
|
||||
|
||||
|
||||
def get_issues(project_id, funnel_id, range_value=None, start_date=None, end_date=None):
|
||||
|
|
@ -272,4 +272,4 @@ def search_by_issue(user_id, project_id, funnel_id, issue_id, data, range_value=
|
|||
data=data) if issue is not None else {"total": 0, "sessions": []},
|
||||
# "stages": helper.list_to_camel_case(insights),
|
||||
# "totalDropDueToIssues": total_drop_due_to_issues,
|
||||
"issue": issue}
|
||||
"issue": issue}
|
||||
|
|
|
|||
|
|
@ -24,9 +24,10 @@ def get(project_id):
|
|||
)
|
||||
metas = cur.fetchone()
|
||||
results = []
|
||||
for i, k in enumerate(metas.keys()):
|
||||
if metas[k] is not None:
|
||||
results.append({"key": metas[k], "index": i + 1})
|
||||
if metas is not None:
|
||||
for i, k in enumerate(metas.keys()):
|
||||
if metas[k] is not None:
|
||||
results.append({"key": metas[k], "index": i + 1})
|
||||
return results
|
||||
|
||||
|
||||
|
|
@ -56,7 +57,7 @@ def __edit(project_id, col_index, colname, new_name):
|
|||
old_metas = get(project_id)
|
||||
old_metas = {k["index"]: k for k in old_metas}
|
||||
if col_index not in list(old_metas.keys()):
|
||||
return {"errors": ["custom field doesn't exist"]}
|
||||
return {"errors": ["custom field not found"]}
|
||||
|
||||
with pg_client.PostgresClient() as cur:
|
||||
if old_metas[col_index]["key"].lower() != new_name:
|
||||
|
|
@ -79,7 +80,7 @@ def delete(tenant_id, project_id, index: int):
|
|||
old_segments = get(project_id)
|
||||
old_segments = [k["index"] for k in old_segments]
|
||||
if index not in old_segments:
|
||||
return {"errors": ["custom field doesn't exist"]}
|
||||
return {"errors": ["custom field not found"]}
|
||||
|
||||
with pg_client.PostgresClient() as cur:
|
||||
colname = index_to_colname(index)
|
||||
|
|
@ -136,7 +137,7 @@ def search(tenant_id, project_id, key, value):
|
|||
key = c
|
||||
break
|
||||
if key is None:
|
||||
return {"errors": ["key does not exist"]}
|
||||
return {"errors": ["key not found"]}
|
||||
cur.execute(
|
||||
cur.mogrify(
|
||||
f"""\
|
||||
|
|
@ -259,4 +260,4 @@ def get_remaining_metadata_with_count(tenant_id):
|
|||
remaining = MAX_INDEXES - len(used_metas)
|
||||
results.append({**p, "limit": MAX_INDEXES, "remaining": remaining, "count": len(used_metas)})
|
||||
|
||||
return results
|
||||
return results
|
||||
|
|
|
|||
|
|
@ -1,8 +1,4 @@
|
|||
import chalicelib.utils.TimeUTC
|
||||
from chalicelib.utils import email_helper, captcha, helper
|
||||
import secrets
|
||||
from chalicelib.utils import pg_client
|
||||
|
||||
from chalicelib.core import users
|
||||
|
||||
|
||||
|
|
@ -18,49 +14,23 @@ def step1(data):
|
|||
a_users = users.get_by_email_only(data["email"])
|
||||
if len(a_users) > 1:
|
||||
print(f"multiple users found for [{data['email']}] please contact our support")
|
||||
return {"errors": ["please contact our support"]}
|
||||
return {"errors": ["multiple users, please contact our support"]}
|
||||
elif len(a_users) == 1:
|
||||
a_users = a_users[0]
|
||||
reset_token = secrets.token_urlsafe(6)
|
||||
users.update(tenant_id=a_users["tenantId"], user_id=a_users["id"],
|
||||
changes={"token": reset_token})
|
||||
email_helper.send_reset_code(recipient=data["email"], reset_code=reset_token)
|
||||
invitation_link = users.generate_new_invitation(user_id=a_users["id"])
|
||||
email_helper.send_forgot_password(recipient=data["email"], invitation_link=invitation_link)
|
||||
else:
|
||||
print(f"invalid email address [{data['email']}]")
|
||||
return {"errors": ["invalid email address"]}
|
||||
return {"data": {"state": "success"}}
|
||||
|
||||
|
||||
def step2(data):
|
||||
print("====================== change password 2 ===============")
|
||||
user = users.get_by_email_reset(data["email"], data["code"])
|
||||
if not user:
|
||||
print("error: wrong email or reset code")
|
||||
return {"errors": ["wrong email or reset code"]}
|
||||
users.update(tenant_id=user["tenantId"], user_id=user["id"],
|
||||
changes={"token": None, "password": data["password"], "generatedPassword": False,
|
||||
"verifiedEmail": True})
|
||||
return {"data": {"state": "success"}}
|
||||
|
||||
|
||||
def cron():
|
||||
with pg_client.PostgresClient() as cur:
|
||||
cur.execute(
|
||||
cur.mogrify("""\
|
||||
SELECT user_id
|
||||
FROM public.basic_authentication
|
||||
WHERE token notnull
|
||||
AND (token_requested_at isnull or (EXTRACT(EPOCH FROM token_requested_at)*1000)::BIGINT < %(time)s);""",
|
||||
{"time": chalicelib.utils.TimeUTC.TimeUTC.now(delta_days=-1)})
|
||||
)
|
||||
results = cur.fetchall()
|
||||
if len(results) == 0:
|
||||
return
|
||||
results = tuple([r["user_id"] for r in results])
|
||||
cur.execute(
|
||||
cur.mogrify("""\
|
||||
UPDATE public.basic_authentication
|
||||
SET token = NULL, token_requested_at = NULL
|
||||
WHERE user_id in %(ids)s;""",
|
||||
{"ids": results})
|
||||
)
|
||||
# def step2(data):
|
||||
# print("====================== change password 2 ===============")
|
||||
# user = users.get_by_email_reset(data["email"], data["code"])
|
||||
# if not user:
|
||||
# print("error: wrong email or reset code")
|
||||
# return {"errors": ["wrong email or reset code"]}
|
||||
# users.update(tenant_id=user["tenantId"], user_id=user["id"],
|
||||
# changes={"token": None, "password": data["password"], "generatedPassword": False,
|
||||
# "verifiedEmail": True})
|
||||
# return {"data": {"state": "success"}}
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ def create_step1(data):
|
|||
if len(signed_ups) == 0 and data.get("tenantId") is not None \
|
||||
or len(signed_ups) > 0 and data.get("tenantId") is not None\
|
||||
and data.get("tenantId") not in [t['tenantId'] for t in signed_ups]:
|
||||
errors.append("Tenant does not exist")
|
||||
errors.append("Tenant not found")
|
||||
if len(errors) > 0:
|
||||
print("==> error")
|
||||
print(errors)
|
||||
|
|
|
|||
|
|
@ -4,11 +4,17 @@ from chalicelib.core import authorizers
|
|||
from chalicelib.core import tenants
|
||||
from chalicelib.utils import helper
|
||||
from chalicelib.utils import pg_client
|
||||
from chalicelib.utils import dev
|
||||
from chalicelib.utils.TimeUTC import TimeUTC
|
||||
from chalicelib.utils.helper import environ
|
||||
import secrets
|
||||
|
||||
|
||||
def create_new_member(tenant_id, email, password, admin, name, owner=False):
|
||||
def __generate_invitation_token():
|
||||
return secrets.token_urlsafe(64)
|
||||
|
||||
|
||||
def create_new_member(tenant_id, email, invitation_token, admin, name, owner=False):
|
||||
with pg_client.PostgresClient() as cur:
|
||||
query = cur.mogrify(f"""\
|
||||
WITH u AS (
|
||||
|
|
@ -16,10 +22,12 @@ def create_new_member(tenant_id, email, password, admin, name, owner=False):
|
|||
VALUES (%(tenantId)s, %(email)s, %(role)s, %(name)s, %(data)s)
|
||||
RETURNING user_id,email,role,name,appearance
|
||||
),
|
||||
au AS (INSERT
|
||||
INTO public.basic_authentication (user_id, password, generated_password)
|
||||
VALUES ((SELECT user_id FROM u), crypt(%(password)s, gen_salt('bf', 12)), TRUE))
|
||||
au AS (INSERT INTO public.basic_authentication (user_id, generated_password, invitation_token, invited_at)
|
||||
VALUES ((SELECT user_id FROM u), TRUE, %(invitation_token)s, timezone('utc'::text, now()))
|
||||
RETURNING invitation_token
|
||||
)
|
||||
SELECT u.user_id AS id,
|
||||
u.user_id,
|
||||
u.email,
|
||||
u.role,
|
||||
u.name,
|
||||
|
|
@ -27,18 +35,19 @@ def create_new_member(tenant_id, email, password, admin, name, owner=False):
|
|||
(CASE WHEN u.role = 'owner' THEN TRUE ELSE FALSE END) AS super_admin,
|
||||
(CASE WHEN u.role = 'admin' THEN TRUE ELSE FALSE END) AS admin,
|
||||
(CASE WHEN u.role = 'member' THEN TRUE ELSE FALSE END) AS member,
|
||||
u.appearance
|
||||
FROM u;""",
|
||||
{"tenantId": tenant_id, "email": email, "password": password,
|
||||
au.invitation_token
|
||||
FROM u,au;""",
|
||||
{"tenantId": tenant_id, "email": email,
|
||||
"role": "owner" if owner else "admin" if admin else "member", "name": name,
|
||||
"data": json.dumps({"lastAnnouncementView": TimeUTC.now()})})
|
||||
"data": json.dumps({"lastAnnouncementView": TimeUTC.now()}),
|
||||
"invitation_token": invitation_token})
|
||||
cur.execute(
|
||||
query
|
||||
)
|
||||
return helper.dict_to_camel_case(cur.fetchone())
|
||||
|
||||
|
||||
def restore_member(tenant_id, user_id, email, password, admin, name, owner=False):
|
||||
def restore_member(tenant_id, user_id, email, invitation_token, admin, name, owner=False):
|
||||
with pg_client.PostgresClient() as cur:
|
||||
query = cur.mogrify(f"""\
|
||||
UPDATE public.users
|
||||
|
|
@ -56,31 +65,62 @@ def restore_member(tenant_id, user_id, email, password, admin, name, owner=False
|
|||
TRUE AS change_password,
|
||||
(CASE WHEN role = 'owner' THEN TRUE ELSE FALSE END) AS super_admin,
|
||||
(CASE WHEN role = 'admin' THEN TRUE ELSE FALSE END) AS admin,
|
||||
(CASE WHEN role = 'member' THEN TRUE ELSE FALSE END) AS member,
|
||||
appearance;""",
|
||||
(CASE WHEN role = 'member' THEN TRUE ELSE FALSE END) AS member;""",
|
||||
{"tenant_id": tenant_id, "user_id": user_id, "email": email,
|
||||
"role": "owner" if owner else "admin" if admin else "member", "name": name})
|
||||
cur.execute(
|
||||
query
|
||||
)
|
||||
result = helper.dict_to_camel_case(cur.fetchone())
|
||||
result = cur.fetchone()
|
||||
query = cur.mogrify("""\
|
||||
UPDATE public.basic_authentication
|
||||
SET password= crypt(%(password)s, gen_salt('bf', 12)),
|
||||
generated_password= TRUE,
|
||||
token=NULL,
|
||||
token_requested_at=NULL
|
||||
WHERE user_id=%(user_id)s;""",
|
||||
{"user_id": user_id, "password": password})
|
||||
SET generated_password = TRUE,
|
||||
invitation_token = %(invitation_token)s,
|
||||
invited_at = timezone('utc'::text, now()),
|
||||
change_pwd_expire_at = NULL,
|
||||
change_pwd_token = NULL
|
||||
WHERE user_id=%(user_id)s
|
||||
RETURNING invitation_token;""",
|
||||
{"user_id": user_id, "invitation_token": invitation_token})
|
||||
cur.execute(
|
||||
query
|
||||
)
|
||||
result["invitation_token"] = cur.fetchone()["invitation_token"]
|
||||
|
||||
return result
|
||||
return helper.dict_to_camel_case(result)
|
||||
|
||||
|
||||
def generate_new_invitation(user_id):
|
||||
invitation_token = __generate_invitation_token()
|
||||
with pg_client.PostgresClient() as cur:
|
||||
query = cur.mogrify("""\
|
||||
UPDATE public.basic_authentication
|
||||
SET invitation_token = %(invitation_token)s,
|
||||
invited_at = timezone('utc'::text, now()),
|
||||
change_pwd_expire_at = NULL,
|
||||
change_pwd_token = NULL
|
||||
WHERE user_id=%(user_id)s
|
||||
RETURNING invitation_token;""",
|
||||
{"user_id": user_id, "invitation_token": invitation_token})
|
||||
cur.execute(
|
||||
query
|
||||
)
|
||||
return __get_invitation_link(cur.fetchone().pop("invitation_token"))
|
||||
|
||||
|
||||
|
||||
def reset_member(tenant_id, editor_id, user_id_to_update):
|
||||
admin = get(tenant_id=tenant_id, user_id=editor_id)
|
||||
if not admin["admin"] and not admin["superAdmin"]:
|
||||
return {"errors": ["unauthorized"]}
|
||||
user = get(tenant_id=tenant_id, user_id=user_id_to_update)
|
||||
if not user:
|
||||
return {"errors": ["user not found"]}
|
||||
return {"data": {"invitationLink": generate_new_invitation(user_id_to_update)}}
|
||||
|
||||
|
||||
def update(tenant_id, user_id, changes):
|
||||
AUTH_KEYS = ["password", "generatedPassword", "token"]
|
||||
AUTH_KEYS = ["password", "generatedPassword", "invitationToken", "invitedAt", "changePwdExpireAt", "changePwdToken"]
|
||||
if len(changes.keys()) == 0:
|
||||
return None
|
||||
|
||||
|
|
@ -91,13 +131,6 @@ def update(tenant_id, user_id, changes):
|
|||
if key == "password":
|
||||
sub_query_bauth.append("password = crypt(%(password)s, gen_salt('bf', 12))")
|
||||
sub_query_bauth.append("changed_at = timezone('utc'::text, now())")
|
||||
elif key == "token":
|
||||
if changes[key] is not None:
|
||||
sub_query_bauth.append("token = %(token)s")
|
||||
sub_query_bauth.append("token_requested_at = timezone('utc'::text, now())")
|
||||
else:
|
||||
sub_query_bauth.append("token = NULL")
|
||||
sub_query_bauth.append("token_requested_at = NULL")
|
||||
else:
|
||||
sub_query_bauth.append(f"{helper.key_to_snake_case(key)} = %({key})s")
|
||||
else:
|
||||
|
|
@ -166,26 +199,43 @@ def create_member(tenant_id, user_id, data):
|
|||
return {"errors": ["invalid user name"]}
|
||||
if name is None:
|
||||
name = data["email"]
|
||||
temp_pass = helper.generate_salt()[:8]
|
||||
invitation_token = __generate_invitation_token()
|
||||
user = get_deleted_user_by_email(email=data["email"])
|
||||
if user is not None:
|
||||
new_member = restore_member(tenant_id=tenant_id, email=data["email"], password=temp_pass,
|
||||
new_member = restore_member(tenant_id=tenant_id, email=data["email"], invitation_token=invitation_token,
|
||||
admin=data.get("admin", False), name=name, user_id=user["userId"])
|
||||
else:
|
||||
new_member = create_new_member(tenant_id=tenant_id, email=data["email"], password=temp_pass,
|
||||
new_member = create_new_member(tenant_id=tenant_id, email=data["email"], invitation_token=invitation_token,
|
||||
admin=data.get("admin", False), name=name)
|
||||
|
||||
new_member["invitationLink"] = __get_invitation_link(new_member.pop("invitationToken"))
|
||||
helper.async_post(environ['email_basic'] % 'member_invitation',
|
||||
{
|
||||
"email": data["email"],
|
||||
"userName": data["email"],
|
||||
"tempPassword": temp_pass,
|
||||
"invitationLink": new_member["invitationLink"],
|
||||
"clientId": tenants.get_by_tenant_id(tenant_id)["name"],
|
||||
"senderName": admin["name"]
|
||||
})
|
||||
return {"data": new_member}
|
||||
|
||||
|
||||
def __get_invitation_link(invitation_token):
|
||||
return environ["SITE_URL"] + environ["invitation_link"] % invitation_token
|
||||
|
||||
|
||||
def allow_password_change(user_id, delta_min=10):
|
||||
pass_token = secrets.token_urlsafe(8)
|
||||
with pg_client.PostgresClient() as cur:
|
||||
query = cur.mogrify(f"""UPDATE public.basic_authentication
|
||||
SET change_pwd_expire_at = timezone('utc'::text, now()+INTERVAL '%(delta)s MINUTES'),
|
||||
change_pwd_token = %(pass_token)s
|
||||
WHERE user_id = %(user_id)s""",
|
||||
{"user_id": user_id, "delta": delta_min, "pass_token": pass_token})
|
||||
cur.execute(
|
||||
query
|
||||
)
|
||||
return pass_token
|
||||
|
||||
|
||||
def get(user_id, tenant_id):
|
||||
with pg_client.PostgresClient() as cur:
|
||||
cur.execute(
|
||||
|
|
@ -321,7 +371,11 @@ def get_members(tenant_id):
|
|||
basic_authentication.generated_password,
|
||||
(CASE WHEN users.role = 'owner' THEN TRUE ELSE FALSE END) AS super_admin,
|
||||
(CASE WHEN users.role = 'admin' THEN TRUE ELSE FALSE END) AS admin,
|
||||
(CASE WHEN users.role = 'member' THEN TRUE ELSE FALSE END) AS member
|
||||
(CASE WHEN users.role = 'member' THEN TRUE ELSE FALSE END) AS member,
|
||||
DATE_PART('day',timezone('utc'::text, now()) \
|
||||
- COALESCE(basic_authentication.invited_at,'2000-01-01'::timestamp ))>=1 AS expired_invitation,
|
||||
basic_authentication.password IS NOT NULL AS joined,
|
||||
invitation_token
|
||||
FROM public.users LEFT JOIN public.basic_authentication ON users.user_id=basic_authentication.user_id
|
||||
WHERE users.tenant_id = %(tenantId)s AND users.deleted_at IS NULL
|
||||
ORDER BY name, id""",
|
||||
|
|
@ -329,7 +383,13 @@ def get_members(tenant_id):
|
|||
)
|
||||
r = cur.fetchall()
|
||||
if len(r):
|
||||
return helper.list_to_camel_case(r)
|
||||
r = helper.list_to_camel_case(r)
|
||||
for u in r:
|
||||
if u["invitationToken"]:
|
||||
u["invitationLink"] = __get_invitation_link(u.pop("invitationToken"))
|
||||
else:
|
||||
u["invitationLink"] = None
|
||||
return r
|
||||
|
||||
return []
|
||||
|
||||
|
|
@ -374,6 +434,15 @@ def change_password(tenant_id, user_id, email, old_password, new_password):
|
|||
"jwt": authenticate(email, new_password)["jwt"]}
|
||||
|
||||
|
||||
def set_password_invitation(user_id, new_password):
|
||||
changes = {"password": new_password, "generatedPassword": False,
|
||||
"invitationToken": None, "invitedAt": None,
|
||||
"changePwdExpireAt": None, "changePwdToken": None}
|
||||
user = update(tenant_id=-1, user_id=user_id, changes=changes)
|
||||
return {"data": user,
|
||||
"jwt": authenticate(user["email"], new_password)["jwt"]}
|
||||
|
||||
|
||||
def count_members(tenant_id):
|
||||
with pg_client.PostgresClient() as cur:
|
||||
cur.execute(
|
||||
|
|
@ -393,7 +462,7 @@ def email_exists(email):
|
|||
cur.mogrify(
|
||||
f"""SELECT
|
||||
count(user_id)
|
||||
FROM public.users
|
||||
FROM public.users
|
||||
WHERE
|
||||
email = %(email)s
|
||||
AND deleted_at IS NULL
|
||||
|
|
@ -410,7 +479,7 @@ def get_deleted_user_by_email(email):
|
|||
cur.mogrify(
|
||||
f"""SELECT
|
||||
*
|
||||
FROM public.users
|
||||
FROM public.users
|
||||
WHERE
|
||||
email = %(email)s
|
||||
AND deleted_at NOTNULL
|
||||
|
|
@ -421,6 +490,24 @@ def get_deleted_user_by_email(email):
|
|||
return helper.dict_to_camel_case(r)
|
||||
|
||||
|
||||
def get_by_invitation_token(token, pass_token=None):
|
||||
with pg_client.PostgresClient() as cur:
|
||||
cur.execute(
|
||||
cur.mogrify(
|
||||
f"""SELECT
|
||||
*,
|
||||
DATE_PART('day',timezone('utc'::text, now()) \
|
||||
- COALESCE(basic_authentication.invited_at,'2000-01-01'::timestamp ))>=1 AS expired_invitation,
|
||||
change_pwd_expire_at <= timezone('utc'::text, now()) AS expired_change
|
||||
FROM public.users INNER JOIN public.basic_authentication USING(user_id)
|
||||
WHERE invitation_token = %(token)s {"AND change_pwd_token = %(pass_token)s" if pass_token else ""}
|
||||
LIMIT 1;""",
|
||||
{"token": token, "pass_token": token})
|
||||
)
|
||||
r = cur.fetchone()
|
||||
return helper.dict_to_camel_case(r)
|
||||
|
||||
|
||||
def auth_exists(user_id, tenant_id, jwt_iat, jwt_aud):
|
||||
with pg_client.PostgresClient() as cur:
|
||||
cur.execute(
|
||||
|
|
@ -450,6 +537,7 @@ def change_jwt_iat(user_id):
|
|||
return cur.fetchone().get("jwt_iat")
|
||||
|
||||
|
||||
@dev.timed
|
||||
def authenticate(email, password, for_change_password=False, for_plugin=False):
|
||||
with pg_client.PostgresClient() as cur:
|
||||
query = cur.mogrify(
|
||||
|
|
|
|||
22
ee/scripts/helm/db/init_dbs/postgresql/1.3.0/1.3.0.sql
Normal file
22
ee/scripts/helm/db/init_dbs/postgresql/1.3.0/1.3.0.sql
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
BEGIN;
|
||||
CREATE INDEX sessions_session_id_project_id_start_ts_durationNN_idx ON sessions (session_id, project_id, start_ts) WHERE duration IS NOT NULL;
|
||||
CREATE INDEX clicks_label_session_id_timestamp_idx ON events.clicks (label, session_id, timestamp);
|
||||
CREATE INDEX pages_base_path_session_id_timestamp_idx ON events.pages (base_path, session_id, timestamp);
|
||||
CREATE INDEX ON unstarted_sessions (project_id);
|
||||
CREATE INDEX ON assigned_sessions (session_id);
|
||||
CREATE INDEX ON technical_info (session_id);
|
||||
CREATE INDEX inputs_label_session_id_timestamp_idx ON events.inputs (label, session_id, timestamp);
|
||||
|
||||
CREATE INDEX clicks_url_idx ON events.clicks (url);
|
||||
CREATE INDEX clicks_url_gin_idx ON events.clicks USING GIN (url gin_trgm_ops);
|
||||
CREATE INDEX clicks_url_session_id_timestamp_selector_idx ON events.clicks (url, session_id, timestamp, selector);
|
||||
|
||||
ALTER TABLE public.basic_authentication
|
||||
RENAME COLUMN token TO invitation_token;
|
||||
ALTER TABLE public.basic_authentication
|
||||
RENAME COLUMN token_requested_at TO invited_at;
|
||||
ALTER TABLE public.basic_authentication
|
||||
ADD COLUMN change_pwd_expire_at timestamp without time zone NULL DEFAULT NULL;
|
||||
ALTER TABLE basic_authentication
|
||||
ADD COLUMN change_pwd_token text NULL DEFAULT NULL;
|
||||
COMMIT;
|
||||
|
|
@ -60,7 +60,7 @@ CREATE TABLE users
|
|||
"role": "dev",
|
||||
"dashboard": {
|
||||
"cpu": true,
|
||||
"fps": false,
|
||||
"fps": false,
|
||||
"avgCpu": true,
|
||||
"avgFps": true,
|
||||
"errors": true,
|
||||
|
|
@ -121,19 +121,21 @@ CREATE TABLE users
|
|||
jwt_iat timestamp without time zone NULL DEFAULT NULL,
|
||||
data jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
weekly_report boolean NOT NULL DEFAULT TRUE,
|
||||
origin user_origin NULL DEFAULT NULL,
|
||||
|
||||
origin user_origin NULL DEFAULT NULL,
|
||||
|
||||
);
|
||||
|
||||
|
||||
CREATE TABLE basic_authentication
|
||||
(
|
||||
user_id integer NOT NULL REFERENCES users (user_id) ON DELETE CASCADE,
|
||||
password text DEFAULT NULL,
|
||||
generated_password boolean NOT NULL DEFAULT false,
|
||||
token text NULL DEFAULT NULL,
|
||||
token_requested_at timestamp without time zone NULL DEFAULT NULL,
|
||||
changed_at timestamp,
|
||||
user_id integer NOT NULL REFERENCES users (user_id) ON DELETE CASCADE,
|
||||
password text DEFAULT NULL,
|
||||
generated_password boolean NOT NULL DEFAULT false,
|
||||
invitation_token text NULL DEFAULT NULL,
|
||||
invited_at timestamp without time zone NULL DEFAULT NULL,
|
||||
change_pwd_token text NULL DEFAULT NULL,
|
||||
change_pwd_expire_at timestamp without time zone NULL DEFAULT NULL,
|
||||
changed_at timestamp,
|
||||
UNIQUE (user_id)
|
||||
);
|
||||
|
||||
|
|
@ -534,6 +536,8 @@ CREATE INDEX sessions_user_anonymous_id_gin_idx ON public.sessions USING GIN (us
|
|||
CREATE INDEX sessions_user_country_gin_idx ON public.sessions (project_id, user_country);
|
||||
CREATE INDEX ON sessions (project_id, user_country);
|
||||
CREATE INDEX ON sessions (project_id, user_browser);
|
||||
CREATE INDEX sessions_session_id_project_id_start_ts_durationNN_idx ON sessions (session_id, project_id, start_ts) WHERE duration IS NOT NULL;
|
||||
|
||||
|
||||
ALTER TABLE public.sessions
|
||||
ADD CONSTRAINT web_browser_constraint CHECK ( (sessions.platform = 'web' AND sessions.user_browser NOTNULL) OR
|
||||
|
|
@ -574,6 +578,7 @@ create table assigned_sessions
|
|||
created_at timestamp default timezone('utc'::text, now()) NOT NULL,
|
||||
provider_data jsonb default '{}'::jsonb NOT NULL
|
||||
);
|
||||
CREATE INDEX ON assigned_sessions (session_id);
|
||||
|
||||
-- --- events_common.sql ---
|
||||
|
||||
|
|
@ -677,6 +682,7 @@ CREATE INDEX pages_path_idx ON events.pages (path);
|
|||
CREATE INDEX pages_visually_complete_idx ON events.pages (visually_complete) WHERE visually_complete > 0;
|
||||
CREATE INDEX pages_dom_building_time_idx ON events.pages (dom_building_time) WHERE dom_building_time > 0;
|
||||
CREATE INDEX pages_load_time_idx ON events.pages (load_time) WHERE load_time > 0;
|
||||
CREATE INDEX pages_base_path_session_id_timestamp_idx ON events.pages (base_path, session_id, timestamp);
|
||||
|
||||
|
||||
CREATE TABLE events.clicks
|
||||
|
|
@ -691,6 +697,11 @@ CREATE INDEX ON events.clicks (session_id);
|
|||
CREATE INDEX ON events.clicks (label);
|
||||
CREATE INDEX clicks_label_gin_idx ON events.clicks USING GIN (label gin_trgm_ops);
|
||||
CREATE INDEX ON events.clicks (timestamp);
|
||||
CREATE INDEX clicks_label_session_id_timestamp_idx ON events.clicks (label, session_id, timestamp);
|
||||
CREATE INDEX clicks_url_idx ON events.clicks (url);
|
||||
CREATE INDEX clicks_url_gin_idx ON events.clicks USING GIN (url gin_trgm_ops);
|
||||
CREATE INDEX clicks_url_session_id_timestamp_selector_idx ON events.clicks (url, session_id, timestamp, selector);
|
||||
|
||||
|
||||
CREATE TABLE events.inputs
|
||||
(
|
||||
|
|
@ -706,6 +717,7 @@ CREATE INDEX ON events.inputs (label, value);
|
|||
CREATE INDEX inputs_label_gin_idx ON events.inputs USING GIN (label gin_trgm_ops);
|
||||
CREATE INDEX inputs_label_idx ON events.inputs (label);
|
||||
CREATE INDEX ON events.inputs (timestamp);
|
||||
CREATE INDEX inputs_label_session_id_timestamp_idx ON events.inputs (label, session_id, timestamp);
|
||||
|
||||
CREATE TABLE events.errors
|
||||
(
|
||||
|
|
|
|||
|
|
@ -2,10 +2,13 @@ import { configure, addDecorator } from '@storybook/react';
|
|||
import { Provider } from 'react-redux';
|
||||
import store from '../app/store';
|
||||
import { MemoryRouter } from "react-router"
|
||||
import { PlayerProvider } from '../app/player/store'
|
||||
|
||||
const withProvider = (story) => (
|
||||
<Provider store={store}>
|
||||
{ story() }
|
||||
<PlayerProvider>
|
||||
{ story() }
|
||||
</PlayerProvider>
|
||||
</Provider>
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -21,7 +21,8 @@ const siteIdRequiredPaths = [
|
|||
'/sourcemaps',
|
||||
'/errors',
|
||||
'/funnels',
|
||||
'/assist'
|
||||
'/assist',
|
||||
'/heatmaps'
|
||||
];
|
||||
|
||||
const noStoringFetchPathStarts = [
|
||||
|
|
|
|||
BIN
frontend/app/assets/img/live-sessions.png
Normal file
BIN
frontend/app/assets/img/live-sessions.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 151 KiB |
|
|
@ -9,8 +9,7 @@
|
|||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
|
||||
<link rel="stylesheet" href="//cdn.jsdelivr.net/npm/semantic-ui@2.4.2/dist/semantic.min.css" />
|
||||
<link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/path/to/styles/theme-name.css">
|
||||
<link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
|
|
|||
|
|
@ -35,8 +35,8 @@ function LiveSessionList(props: Props) {
|
|||
<div>
|
||||
<NoContent
|
||||
title={"No live sessions!"}
|
||||
subtext="Please try changing your search parameters."
|
||||
icon="exclamation-circle"
|
||||
// subtext="Please try changing your search parameters."
|
||||
image={<img src="/img/live-sessions.png" style={{ width: '70%', marginBottom: '30px' }}/>}
|
||||
show={ !loading && list && list.size === 0}
|
||||
>
|
||||
<Loader loading={ loading }>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,66 @@
|
|||
import Highlight from 'react-highlight'
|
||||
import ToggleContent from 'Shared/ToggleContent'
|
||||
import DocLink from 'Shared/DocLink/DocLink';
|
||||
|
||||
const AssistDoc = (props) => {
|
||||
return (
|
||||
<div className="p-4">
|
||||
<div>OpenReplay Assist allows you to support your users by seeing their live screen and instantly hopping on call (WebRTC) with them without requiring any 3rd-party screen sharing software.</div>
|
||||
|
||||
<div className="font-bold my-2">Installation</div>
|
||||
<Highlight className="js">
|
||||
{`npm i @openreplay/tracker-assist`}
|
||||
</Highlight>
|
||||
|
||||
<div className="font-bold my-2">Usage</div>
|
||||
<p>Initialize the tracker then load the @openreplay/tracker-assist plugin.</p>
|
||||
<div className="py-3" />
|
||||
|
||||
<div className="font-bold my-2">Usage</div>
|
||||
<ToggleContent
|
||||
label="Is SSR?"
|
||||
first={
|
||||
<Highlight className="js">
|
||||
{`import Tracker from '@openreplay/tracker';
|
||||
import trackerAssist from '@openreplay/tracker-assist';
|
||||
const tracker = new Tracker({
|
||||
projectKey: PROJECT_KEY,
|
||||
});
|
||||
tracker.start();
|
||||
tracker.use(trackerAssist(options)); // check the list of available options below`}
|
||||
</Highlight>
|
||||
}
|
||||
second={
|
||||
<Highlight className="js">
|
||||
{`import OpenReplay from '@openreplay/tracker/cjs';
|
||||
import trackerFetch from '@openreplay/tracker-assist/cjs';
|
||||
const tracker = new OpenReplay({
|
||||
projectKey: PROJECT_KEY
|
||||
});
|
||||
const trackerAssist = tracker.use(trackerAssist(options)); // check the list of available options below
|
||||
//...
|
||||
function MyApp() {
|
||||
useEffect(() => { // use componentDidMount in case of React Class Component
|
||||
tracker.start();
|
||||
}, [])
|
||||
//...
|
||||
}`}
|
||||
</Highlight>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="font-bold my-2">Options</div>
|
||||
<Highlight className="js">
|
||||
{`trackerAssist({
|
||||
confirmText: string;
|
||||
})`}
|
||||
</Highlight>
|
||||
|
||||
<DocLink className="mt-4" label="Install Assist" url="https://docs.openreplay.com/installation/assist" />
|
||||
</div>
|
||||
)
|
||||
};
|
||||
|
||||
AssistDoc.displayName = "AssistDoc";
|
||||
|
||||
export default AssistDoc;
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from './AssistDoc'
|
||||
|
|
@ -28,6 +28,7 @@ import SlackAddForm from './SlackAddForm';
|
|||
import FetchDoc from './FetchDoc';
|
||||
import MobxDoc from './MobxDoc';
|
||||
import ProfilerDoc from './ProfilerDoc';
|
||||
import AssistDoc from './AssistDoc';
|
||||
|
||||
const NONE = -1;
|
||||
const SENTRY = 0;
|
||||
|
|
@ -49,6 +50,7 @@ const SLACK = 15;
|
|||
const FETCH = 16;
|
||||
const MOBX = 17;
|
||||
const PROFILER = 18;
|
||||
const ASSIST = 19;
|
||||
|
||||
const TITLE = {
|
||||
[ SENTRY ]: 'Sentry',
|
||||
|
|
@ -70,6 +72,7 @@ const TITLE = {
|
|||
[ FETCH ] : 'Fetch',
|
||||
[ MOBX ] : 'MobX',
|
||||
[ PROFILER ] : 'Profiler',
|
||||
[ ASSIST ] : 'Assist',
|
||||
}
|
||||
|
||||
const DOCS = [REDUX, VUE, GRAPHQL, NGRX, FETCH, MOBX, PROFILER]
|
||||
|
|
@ -182,6 +185,8 @@ export default class Integrations extends React.PureComponent {
|
|||
return <MobxDoc onClose={ this.closeModal } />
|
||||
case PROFILER:
|
||||
return <ProfilerDoc onClose={ this.closeModal } />
|
||||
case ASSIST:
|
||||
return <AssistDoc onClose={ this.closeModal } />
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
|
@ -253,7 +258,7 @@ export default class Integrations extends React.PureComponent {
|
|||
{plugins && (
|
||||
<div className="" >
|
||||
<div className="mb-4">Use plugins to better debug your application's store, monitor queries and track performance issues.</div>
|
||||
<div className="flex">
|
||||
<div className="flex flex-wrap">
|
||||
<IntegrationItem
|
||||
title="Redux"
|
||||
icon="integrations/redux"
|
||||
|
|
@ -313,6 +318,14 @@ export default class Integrations extends React.PureComponent {
|
|||
onClick={ () => this.showIntegrationConfig(PROFILER) }
|
||||
// integrated={ sentryIntegrated }
|
||||
/>
|
||||
<IntegrationItem
|
||||
title="Assist"
|
||||
icon="integrations/assist"
|
||||
url={ null }
|
||||
dockLink="https://docs.openreplay.com/installation/assist"
|
||||
onClick={ () => this.showIntegrationConfig(ASSIST) }
|
||||
// integrated={ sentryIntegrated }
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { connect } from 'react-redux';
|
||||
import cn from 'classnames';
|
||||
import withPageTitle from 'HOCs/withPageTitle';
|
||||
import { IconButton, SlideModal, Input, Button, Loader, NoContent, Popup } from 'UI';
|
||||
import { init, save, edit, remove as deleteMember, fetchList } from 'Duck/member';
|
||||
import { IconButton, SlideModal, Input, Button, Loader, NoContent, Popup, CopyButton } from 'UI';
|
||||
import { init, save, edit, remove as deleteMember, fetchList, generateInviteLink } from 'Duck/member';
|
||||
import styles from './manageUsers.css';
|
||||
import UserItem from './UserItem';
|
||||
import { confirm } from 'UI/Confirmation';
|
||||
|
|
@ -24,11 +24,12 @@ const LIMIT_WARNING = 'You have reached users limit.';
|
|||
save,
|
||||
edit,
|
||||
deleteMember,
|
||||
fetchList
|
||||
fetchList,
|
||||
generateInviteLink
|
||||
})
|
||||
@withPageTitle('Manage Users - OpenReplay Preferences')
|
||||
class ManageUsers extends React.PureComponent {
|
||||
state = { showModal: false, remaining: this.props.account.limits.teamMember.remaining }
|
||||
state = { showModal: false, remaining: this.props.account.limits.teamMember.remaining, invited: false }
|
||||
|
||||
onChange = (e, { name, value }) => this.props.edit({ [ name ]: value });
|
||||
onChangeCheckbox = ({ target: { checked, name } }) => this.props.edit({ [ name ]: checked });
|
||||
|
|
@ -70,11 +71,12 @@ class ManageUsers extends React.PureComponent {
|
|||
toast.error(e);
|
||||
})
|
||||
}
|
||||
this.closeModal()
|
||||
this.setState({ invited: true })
|
||||
// this.closeModal()
|
||||
});
|
||||
}
|
||||
|
||||
formContent = member => (
|
||||
|
||||
formContent = (member, account) => (
|
||||
<div className={ styles.form }>
|
||||
<form onSubmit={ this.save } >
|
||||
<div className={ styles.formGroup }>
|
||||
|
|
@ -99,7 +101,11 @@ class ManageUsers extends React.PureComponent {
|
|||
className={ styles.input }
|
||||
/>
|
||||
</div>
|
||||
|
||||
{ !account.smtp &&
|
||||
<div className={cn("mb-4 p-2", styles.smtpMessage)}>
|
||||
SMTP is not configured, <a className="link" href="https://docs.openreplay.com/configuration/configure-smtp" target="_blank">setup SMTP</a>
|
||||
</div>
|
||||
}
|
||||
<div className={ styles.formGroup }>
|
||||
<label className={ styles.checkbox }>
|
||||
<input
|
||||
|
|
@ -111,26 +117,37 @@ class ManageUsers extends React.PureComponent {
|
|||
/>
|
||||
<span>{ 'Admin' }</span>
|
||||
</label>
|
||||
<div className={ styles.adminInfo }>{ 'Can manage Projects and Users.' }</div>
|
||||
<div className={ styles.adminInfo }>{ 'Can manage Projects and team members.' }</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<Button
|
||||
onClick={ this.save }
|
||||
disabled={ !member.validate() }
|
||||
loading={ this.props.saving }
|
||||
primary
|
||||
marginRight
|
||||
>
|
||||
{ member.exists() ? 'Update' : 'Invite' }
|
||||
</Button>
|
||||
<Button
|
||||
data-hidden={ !member.exists() }
|
||||
onClick={ this.closeModal }
|
||||
outline
|
||||
>
|
||||
{ 'Cancel' }
|
||||
</Button>
|
||||
<div className="flex items-center">
|
||||
<div className="flex items-center mr-auto">
|
||||
<Button
|
||||
onClick={ this.save }
|
||||
disabled={ !member.validate() }
|
||||
loading={ this.props.saving }
|
||||
primary
|
||||
marginRight
|
||||
>
|
||||
{ member.exists() ? 'Update' : 'Invite' }
|
||||
</Button>
|
||||
<Button
|
||||
data-hidden={ !member.exists() }
|
||||
onClick={ this.closeModal }
|
||||
outline
|
||||
>
|
||||
{ 'Cancel' }
|
||||
</Button>
|
||||
</div>
|
||||
{ !member.joined && member.invitationLink &&
|
||||
<CopyButton
|
||||
content={member.invitationLink}
|
||||
className="link"
|
||||
btnText="Copy invite link"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
|
|
@ -144,7 +161,7 @@ class ManageUsers extends React.PureComponent {
|
|||
const {
|
||||
members, member, loading, account, hideHeader = false,
|
||||
} = this.props;
|
||||
const { showModal, remaining } = this.state;
|
||||
const { showModal, remaining, invited } = this.state;
|
||||
const isAdmin = account.admin || account.superAdmin;
|
||||
const canAddUsers = isAdmin && remaining !== 0;
|
||||
|
||||
|
|
@ -155,7 +172,7 @@ class ManageUsers extends React.PureComponent {
|
|||
title="Inivte People"
|
||||
size="small"
|
||||
isDisplayed={ showModal }
|
||||
content={ this.formContent(member) }
|
||||
content={ this.formContent(member, account) }
|
||||
onClose={ this.closeModal }
|
||||
/>
|
||||
<div className={ styles.wrapper }>
|
||||
|
|
@ -202,6 +219,7 @@ class ManageUsers extends React.PureComponent {
|
|||
{
|
||||
members.map(user => (
|
||||
<UserItem
|
||||
generateInviteLink={this.props.generateInviteLink}
|
||||
key={ user.id }
|
||||
user={ user }
|
||||
adminLabel={ this.adminLabel(user) }
|
||||
|
|
|
|||
|
|
@ -1,13 +1,43 @@
|
|||
import React from 'react';
|
||||
import { Icon } from 'UI';
|
||||
import { Icon, CopyButton, Popup } from 'UI';
|
||||
import styles from './userItem.css';
|
||||
|
||||
const UserItem = ({ user, adminLabel, deleteHandler, editHandler }) => (
|
||||
const UserItem = ({ user, adminLabel, deleteHandler, editHandler, generateInviteLink }) => (
|
||||
<div className={ styles.wrapper } id="user-row">
|
||||
<Icon name="user-alt" size="16" marginRight="10" />
|
||||
<div id="user-name">{ user.name || user.email }</div>
|
||||
{ adminLabel && <div className={ styles.adminLabel }>{ adminLabel }</div>}
|
||||
<div className={ styles.actions }>
|
||||
{ user.expiredInvitation && !user.joined &&
|
||||
<Popup
|
||||
trigger={
|
||||
<div className={ styles.button } onClick={ () => generateInviteLink(user) } id="trash">
|
||||
<Icon name="link-45deg" size="16" color="red"/>
|
||||
</div>
|
||||
}
|
||||
content={ `Generate Invitation Link` }
|
||||
size="tiny"
|
||||
inverted
|
||||
position="top center"
|
||||
/>
|
||||
}
|
||||
{ !user.expiredInvitation && !user.joined && user.invitationLink &&
|
||||
<Popup
|
||||
trigger={
|
||||
<div className={ styles.button }>
|
||||
<CopyButton
|
||||
content={user.invitationLink}
|
||||
className="link"
|
||||
btnText={<Icon name="link-45deg" size="16" color="teal"/>}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
content={ `Copy Invitation Link` }
|
||||
size="tiny"
|
||||
inverted
|
||||
position="top center"
|
||||
/>
|
||||
}
|
||||
{ !!deleteHandler &&
|
||||
<div className={ styles.button } onClick={ () => deleteHandler(user) } id="trash">
|
||||
<Icon name="trash" size="16" color="teal"/>
|
||||
|
|
|
|||
|
|
@ -34,4 +34,9 @@
|
|||
.adminInfo {
|
||||
font-size: 12px;
|
||||
color: $gray-medium;
|
||||
}
|
||||
|
||||
.smtpMessage {
|
||||
background-color: #faf6e0;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
|
@ -35,6 +35,9 @@
|
|||
padding: 5px;
|
||||
cursor: pointer;
|
||||
margin-left: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
&:hover {
|
||||
& svg {
|
||||
fill: $teal-dark;
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import withPageTitle from 'HOCs/withPageTitle';
|
|||
import { Loader, Button, Link, Icon, Message } from 'UI';
|
||||
import { requestResetPassword, resetPassword } from 'Duck/user';
|
||||
import { login as loginRoute } from 'App/routes';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
import { validateEmail } from 'App/validate';
|
||||
import cn from 'classnames';
|
||||
import stl from './forgotPassword.css';
|
||||
|
||||
|
|
@ -17,14 +19,16 @@ const checkDontMatch = (newPassword, newPasswordRepeat) =>
|
|||
newPasswordRepeat.length > 0 && newPasswordRepeat !== newPassword;
|
||||
|
||||
@connect(
|
||||
state => ({
|
||||
(state, props) => ({
|
||||
errors: state.getIn([ 'user', 'requestResetPassowrd', 'errors' ]),
|
||||
resetErrors: state.getIn([ 'user', 'resetPassword', 'errors' ]),
|
||||
loading: state.getIn([ 'user', 'requestResetPassowrd', 'loading' ]),
|
||||
params: new URLSearchParams(props.location.search)
|
||||
}),
|
||||
{ requestResetPassword, resetPassword },
|
||||
)
|
||||
@withPageTitle("Password Reset - OpenReplay")
|
||||
@withRouter
|
||||
export default class ForgotPassword extends React.PureComponent {
|
||||
state = {
|
||||
email: '',
|
||||
|
|
@ -37,15 +41,20 @@ export default class ForgotPassword extends React.PureComponent {
|
|||
|
||||
handleSubmit = (token) => {
|
||||
const { email, requested, code, password } = this.state;
|
||||
const { params } = this.props;
|
||||
|
||||
if (!requested) {
|
||||
const pass = params.get('pass')
|
||||
const invitation = params.get('invitation')
|
||||
const resetting = pass && invitation
|
||||
|
||||
if (!resetting) {
|
||||
this.props.requestResetPassword({ email: email.trim(), 'g-recaptcha-response': token }).then(() => {
|
||||
const { errors } = this.props;
|
||||
if (!errors) this.setState({ requested: true });
|
||||
});
|
||||
} else {
|
||||
if (this.isSubmitDisabled()) return;
|
||||
this.props.resetPassword({ email: email.trim(), code, password }).then(() => {
|
||||
this.props.resetPassword({ email: email.trim(), invitation, pass, password }).then(() => {
|
||||
const { resetErrors } = this.props;
|
||||
if (!resetErrors) this.setState({ updated: true });
|
||||
});
|
||||
|
|
@ -78,9 +87,14 @@ export default class ForgotPassword extends React.PureComponent {
|
|||
}
|
||||
|
||||
render() {
|
||||
const { errors, loading } = this.props;
|
||||
const { requested, updated, password, passwordRepeat, code } = this.state;
|
||||
const dontMatch = checkDontMatch(password, passwordRepeat);
|
||||
const { errors, loading, params } = this.props;
|
||||
const { requested, updated, password, passwordRepeat, email } = this.state;
|
||||
const dontMatch = checkDontMatch(password, passwordRepeat);
|
||||
|
||||
const pass = params.get('pass')
|
||||
const invitation = params.get('invitation')
|
||||
const resetting = pass && invitation
|
||||
const validEmail = validateEmail(email)
|
||||
|
||||
return (
|
||||
<div className="flex" style={{ height: '100vh'}}>
|
||||
|
|
@ -113,7 +127,7 @@ export default class ForgotPassword extends React.PureComponent {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{ !requested ?
|
||||
{ !resetting && !requested &&
|
||||
<div className={ stl.inputWithIcon }>
|
||||
<i className={ stl.inputIconUser } />
|
||||
<input
|
||||
|
|
@ -125,47 +139,57 @@ export default class ForgotPassword extends React.PureComponent {
|
|||
onChange={ this.write }
|
||||
className={ stl.input }
|
||||
/>
|
||||
</div>
|
||||
:
|
||||
<React.Fragment>
|
||||
<div className={ stl.inputWithIcon } >
|
||||
<i className={ stl.inputIconPassword } />
|
||||
<input
|
||||
autocomplete="new-password"
|
||||
type="text"
|
||||
placeholder="Code"
|
||||
name="code"
|
||||
onChange={ this.write }
|
||||
className={ stl.input }
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div className={ stl.inputWithIcon } >
|
||||
<i className={ stl.inputIconPassword } />
|
||||
<input
|
||||
autocomplete="new-password"
|
||||
type="password"
|
||||
placeholder="New Password"
|
||||
name="password"
|
||||
onChange={ this.write }
|
||||
className={ stl.input }
|
||||
/>
|
||||
</div>
|
||||
<div className={ stl.passwordPolicy } data-hidden={ !this.shouldShouwPolicy() }>
|
||||
{ PASSWORD_POLICY }
|
||||
</div>
|
||||
<div className={ stl.inputWithIcon } >
|
||||
<i className={ stl.inputIconPassword } />
|
||||
<input
|
||||
autocomplete="new-password"
|
||||
type="password"
|
||||
placeholder="Repeat New Password"
|
||||
name="passwordRepeat"
|
||||
onChange={ this.write }
|
||||
className={ stl.input }
|
||||
/>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
{
|
||||
requested && (
|
||||
<div>Reset password link has been sent to your email.</div>
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
resetting && (
|
||||
<React.Fragment>
|
||||
{/* <div className={ stl.inputWithIcon } >
|
||||
<i className={ stl.inputIconPassword } />
|
||||
<input
|
||||
autocomplete="new-password"
|
||||
type="text"
|
||||
placeholder="Code"
|
||||
name="code"
|
||||
onChange={ this.write }
|
||||
className={ stl.input }
|
||||
/>
|
||||
</div> */}
|
||||
|
||||
<div className={ stl.inputWithIcon } >
|
||||
<i className={ stl.inputIconPassword } />
|
||||
<input
|
||||
autocomplete="new-password"
|
||||
type="password"
|
||||
placeholder="New Password"
|
||||
name="password"
|
||||
onChange={ this.write }
|
||||
className={ stl.input }
|
||||
/>
|
||||
</div>
|
||||
<div className={ stl.passwordPolicy } data-hidden={ !this.shouldShouwPolicy() }>
|
||||
{ PASSWORD_POLICY }
|
||||
</div>
|
||||
<div className={ stl.inputWithIcon } >
|
||||
<i className={ stl.inputIconPassword } />
|
||||
<input
|
||||
autocomplete="new-password"
|
||||
type="password"
|
||||
placeholder="Repeat New Password"
|
||||
name="passwordRepeat"
|
||||
onChange={ this.write }
|
||||
className={ stl.input }
|
||||
/>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
<Message error hidden={ !dontMatch }>
|
||||
|
|
@ -185,7 +209,13 @@ export default class ForgotPassword extends React.PureComponent {
|
|||
</div>
|
||||
</div>
|
||||
<div className={ stl.formFooter }>
|
||||
<Button data-hidden={ updated } type="submit" primary >{ 'Reset' }</Button>
|
||||
<Button
|
||||
data-hidden={ updated || requested }
|
||||
type="submit" primary
|
||||
disabled={ (resetting && this.isSubmitDisabled()) || (!resetting && !validEmail)}
|
||||
>
|
||||
{ 'Reset' }
|
||||
</Button>
|
||||
|
||||
<div className={ stl.links }>
|
||||
<Link to={ LOGIN }>
|
||||
|
|
|
|||
|
|
@ -109,7 +109,7 @@ const FunnelHeader = (props) => {
|
|||
endDate={funnelFilters.endDate}
|
||||
onDateChange={onDateChange}
|
||||
customRangeRight
|
||||
/>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -31,12 +31,7 @@ function Layout({ children, player, toolbar }) {
|
|||
</div>
|
||||
{ !player.fullscreen.enabled && <ToolPanel player={ player } toolbar={ toolbar }/> }
|
||||
</div>
|
||||
{ !player.fullscreen.enabled &&
|
||||
<Events
|
||||
style={{ width: "270px" }}
|
||||
player={ player }
|
||||
/>
|
||||
}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
43
frontend/app/components/Session/RightBlock.tsx
Normal file
43
frontend/app/components/Session/RightBlock.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import React, { useState } from 'react'
|
||||
import EventsBlock from '../Session_/EventsBlock';
|
||||
import PageInsightsPanel from '../Session_/PageInsightsPanel/PageInsightsPanel'
|
||||
import { Controls as PlayerControls } from 'Player';
|
||||
import { Tabs } from 'UI';
|
||||
import { connectPlayer } from 'Player';
|
||||
|
||||
const EVENTS = 'Events';
|
||||
const HEATMAPS = 'Heatmaps';
|
||||
|
||||
const TABS = [ EVENTS, HEATMAPS ].map(tab => ({ text: tab, key: tab }));
|
||||
|
||||
|
||||
const EventsBlockConnected = connectPlayer(state => ({
|
||||
currentTimeEventIndex: state.eventListNow.length > 0 ? state.eventListNow.length - 1 : 0,
|
||||
playing: state.playing,
|
||||
}))(EventsBlock)
|
||||
|
||||
export default function RightBlock() {
|
||||
const [activeTab, setActiveTab] = useState(EVENTS)
|
||||
|
||||
const renderActiveTab = (tab) => {
|
||||
switch(tab) {
|
||||
case EVENTS:
|
||||
return <EventsBlockConnected player={PlayerControls}/>
|
||||
case HEATMAPS:
|
||||
return <PageInsightsPanel />
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div style={{ width: '270px', height: 'calc(100vh- 50px)'}} className="flex flex-col">
|
||||
<Tabs
|
||||
tabs={ TABS }
|
||||
active={ activeTab }
|
||||
onClick={ (tab) => setActiveTab(tab) }
|
||||
border={ true }
|
||||
/>
|
||||
{
|
||||
renderActiveTab(activeTab)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -8,22 +8,13 @@ import {
|
|||
init as initPlayer,
|
||||
clean as cleanPlayer,
|
||||
} from 'Player';
|
||||
import { Controls as PlayerControls, toggleEvents } from 'Player';
|
||||
import cn from 'classnames'
|
||||
import RightBlock from './RightBlock'
|
||||
|
||||
|
||||
import PlayerBlockHeader from '../Session_/PlayerBlockHeader';
|
||||
import EventsBlock from '../Session_/EventsBlock';
|
||||
import PlayerBlock from '../Session_/PlayerBlock';
|
||||
import styles from '../Session_/session.css';
|
||||
import EventsToggleButton from './EventsToggleButton';
|
||||
|
||||
|
||||
|
||||
const EventsBlockConnected = connectPlayer(state => ({
|
||||
currentTimeEventIndex: state.eventListNow.length > 0 ? state.eventListNow.length - 1 : 0,
|
||||
playing: state.playing,
|
||||
}))(EventsBlock)
|
||||
|
||||
|
||||
const InitLoader = connectPlayer(state => ({
|
||||
|
|
@ -32,14 +23,14 @@ const InitLoader = connectPlayer(state => ({
|
|||
|
||||
const PlayerContentConnected = connectPlayer(state => ({
|
||||
showEvents: !state.showEvents
|
||||
}), { toggleEvents })(PlayerContent);
|
||||
}))(PlayerContent);
|
||||
|
||||
|
||||
function PlayerContent({ live, fullscreen, showEvents, toggleEvents }) {
|
||||
function PlayerContent({ live, fullscreen, showEvents }) {
|
||||
return (
|
||||
<div className={ cn(styles.session, 'relative') } data-fullscreen={fullscreen}>
|
||||
<PlayerBlock />
|
||||
{ showEvents && !live && !fullscreen && <EventsBlockConnected player={PlayerControls}/> }
|
||||
{ showEvents && !live && !fullscreen && <RightBlock /> }
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
export { default } from './AutoplayTimer'
|
||||
|
|
@ -6,7 +6,7 @@ export default function EventSearch(props) {
|
|||
const [showSearch, setShowSearch] = useState(false)
|
||||
return (
|
||||
<div className="flex items-center w-full">
|
||||
<div className="flex-1 relative">
|
||||
<div className="flex flex-1 relative items-center" style={{ height: '32px' }}>
|
||||
{ showSearch ?
|
||||
<div className="flex items-center">
|
||||
<Input
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { connect } from 'react-redux';
|
||||
import cn from 'classnames';
|
||||
import { List, AutoSizer, CellMeasurer, CellMeasurerCache } from "react-virtualized";
|
||||
import { Avatar, Input, Dropdown, Icon } from 'UI';
|
||||
import { TYPES } from 'Types/session/event';
|
||||
import { setSelected } from 'Duck/events';
|
||||
import { setEventFilter } from 'Duck/sessions';
|
||||
|
|
@ -18,8 +17,7 @@ import EventSearch from './EventSearch/EventSearch';
|
|||
eventsIndex: state.getIn([ 'sessions', 'eventsIndex' ]),
|
||||
selectedEvents: state.getIn([ 'events', 'selected' ]),
|
||||
targetDefinerDisplayed: state.getIn([ 'components', 'targetDefiner', 'isDisplayed' ]),
|
||||
testsAvaliable: false,
|
||||
//state.getIn([ 'user', 'account', 'appearance', 'tests' ]),
|
||||
testsAvaliable: false,
|
||||
}), {
|
||||
showTargetDefiner,
|
||||
setSelected,
|
||||
|
|
@ -74,9 +72,6 @@ export default class EventsBlock extends React.PureComponent {
|
|||
this.setState({ editingEvent: null });
|
||||
}
|
||||
if (prevProps.session !== this.props.session) { // Doesn't happen
|
||||
// this.setState({
|
||||
// groups: groupEvents(this.props.session.events),
|
||||
// });
|
||||
this.cache = new CellMeasurerCache({
|
||||
fixedWidth: true,
|
||||
defaultHeight: 300
|
||||
|
|
@ -148,8 +143,7 @@ export default class EventsBlock extends React.PureComponent {
|
|||
<CellMeasurer
|
||||
key={key}
|
||||
cache={this.cache}
|
||||
parent={parent}
|
||||
//columnIndex={0}
|
||||
parent={parent}
|
||||
rowIndex={index}
|
||||
>
|
||||
{({measure, registerChild}) => (
|
||||
|
|
@ -176,14 +170,12 @@ export default class EventsBlock extends React.PureComponent {
|
|||
|
||||
render() {
|
||||
const { query } = this.state;
|
||||
const {
|
||||
playing,
|
||||
const {
|
||||
testsAvaliable,
|
||||
session: {
|
||||
events,
|
||||
userNumericHash,
|
||||
userDisplayName,
|
||||
userUuid,
|
||||
userDisplayName,
|
||||
userId,
|
||||
userAnonymousId
|
||||
},
|
||||
|
|
@ -193,7 +185,7 @@ export default class EventsBlock extends React.PureComponent {
|
|||
const _events = filteredEvents || events;
|
||||
|
||||
return (
|
||||
<div className={ cn("flex flex-col", styles.eventsBlock) }>
|
||||
<>
|
||||
<div className={ cn(styles.header, 'p-3') }>
|
||||
<UserCard
|
||||
className=""
|
||||
|
|
@ -203,8 +195,7 @@ export default class EventsBlock extends React.PureComponent {
|
|||
userAnonymousId={userAnonymousId}
|
||||
/>
|
||||
|
||||
<div className={ cn(styles.hAndProgress, 'mt-3') }>
|
||||
{/* <div className="text-lg">{ `User Events (${ events.size })` }</div> */}
|
||||
<div className={ cn(styles.hAndProgress, 'mt-3') }>
|
||||
<EventSearch
|
||||
onChange={this.write}
|
||||
clearSearch={this.clearSearch}
|
||||
|
|
@ -213,29 +204,7 @@ export default class EventsBlock extends React.PureComponent {
|
|||
<div className="text-lg">{ `User Events (${ events.size })` }</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex mt-3">
|
||||
{/* <Dropdown
|
||||
trigger={
|
||||
<div className={cn("py-3 px-3 bg-white flex items-center text-sm mb-2 border rounded ml-2")} style={{ height: '32px' }}>
|
||||
<Icon name="filter" size="12" color="teal" />
|
||||
</div>
|
||||
}
|
||||
options={ [
|
||||
// { text: 'Visited', value: TYPES.LOCATION },
|
||||
{ text: 'Clicked', value: TYPES.CLICK },
|
||||
{ text: 'Input', value: TYPES.INPUT },
|
||||
] }
|
||||
name="filter"
|
||||
icon={null}
|
||||
onChange={this.onSetEventFilter}
|
||||
basic
|
||||
direction="left"
|
||||
scrolling
|
||||
selectOnBlur={true}
|
||||
closeOnChange={true}
|
||||
/> */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={ cn("flex-1 px-3 pb-3", styles.eventsList) }
|
||||
|
|
@ -263,7 +232,7 @@ export default class EventsBlock extends React.PureComponent {
|
|||
</AutoSizer>
|
||||
</div>
|
||||
{ testsAvaliable && <AutomateButton /> }
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,91 @@
|
|||
import React, { useEffect, useState } from 'react'
|
||||
import { Dropdown, Loader } from 'UI'
|
||||
import DateRange from 'Shared/DateRange';
|
||||
import { connect } from 'react-redux';
|
||||
import { fetchInsights } from 'Duck/sessions';
|
||||
import SelectorsList from './components/SelectorsList/SelectorsList';
|
||||
import { markTargets, Controls as Player } from 'Player';
|
||||
|
||||
const JUMP_OFFSET = 1000;
|
||||
interface Props {
|
||||
filters: any
|
||||
fetchInsights: (filters) => void
|
||||
urls: []
|
||||
insights: any
|
||||
events: Array<any>
|
||||
urlOptions: Array<any>
|
||||
loading: boolean
|
||||
}
|
||||
|
||||
function PageInsightsPanel({ filters, fetchInsights, events = [], insights, urlOptions, loading = true }: Props) {
|
||||
const [insightsFilters, setInsightsFilters] = useState(filters)
|
||||
|
||||
const onDateChange = (e) => {
|
||||
const { startDate, endDate, rangeValue } = e;
|
||||
setInsightsFilters({ ...insightsFilters, startDate, endDate, rangeValue })
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
markTargets(insights.toJS());
|
||||
return () => {
|
||||
markTargets(null)
|
||||
}
|
||||
}, [insights])
|
||||
|
||||
useEffect(() => {
|
||||
const url = insightsFilters.url ? insightsFilters.url : urlOptions[0].value;
|
||||
Player.pause();
|
||||
fetchInsights({ ...insightsFilters, url })
|
||||
}, [insightsFilters])
|
||||
|
||||
const onPageSelect = (e, { name, value }) => {
|
||||
const event = events.find(item => item.url === value)
|
||||
Player.jump(event.time + JUMP_OFFSET)
|
||||
setInsightsFilters({ ...insightsFilters, url: value })
|
||||
markTargets([])
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="px-4 bg-gray-lightest">
|
||||
<div className="my-3 flex -ml-2">
|
||||
<DateRange
|
||||
rangeValue={insightsFilters.rangeValue}
|
||||
startDate={insightsFilters.startDate}
|
||||
endDate={insightsFilters.endDate}
|
||||
onDateChange={onDateChange}
|
||||
customHidden
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-4 flex items-center">
|
||||
<div className="mr-2 flex-shrink-0">In Page</div>
|
||||
<Dropdown
|
||||
search
|
||||
labeled
|
||||
placeholder="change"
|
||||
selection
|
||||
options={ urlOptions }
|
||||
name="url"
|
||||
defaultValue={urlOptions[0].value}
|
||||
onChange={ onPageSelect }
|
||||
id="change-dropdown"
|
||||
className="customDropdown"
|
||||
style={{ minWidth: '80px', width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
<Loader loading={ loading }>
|
||||
<SelectorsList />
|
||||
</Loader>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default connect(state => {
|
||||
const events = state.getIn([ 'sessions', 'visitedEvents' ])
|
||||
return {
|
||||
filters: state.getIn(['sessions', 'insightFilters']),
|
||||
insights: state.getIn([ 'sessions', 'insights' ]),
|
||||
events: events,
|
||||
urlOptions: events.map(({ url }) => ({ text: url, value: url})),
|
||||
loading: state.getIn([ 'sessions', 'fetchInsightsRequest', 'loading' ]),
|
||||
}
|
||||
}, { fetchInsights })(PageInsightsPanel);
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
.wrapper {
|
||||
padding: 10px;
|
||||
box-shadow: 1px 1px 1px rgba(0, 0, 0, 0.2);
|
||||
border-radius: 3px;
|
||||
background-color: white;
|
||||
margin-bottom: 15px;
|
||||
|
||||
& .top {
|
||||
display: flex;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
& .index {
|
||||
margin-right: 10px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 10px;
|
||||
background-color: $tealx;
|
||||
flex-shrink: 0;
|
||||
border: solid thin white;
|
||||
box-shadow: 1px 1px 1px rgba(0, 0, 0, 0.1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
& .counts {
|
||||
text-align: center;
|
||||
padding: 5px;
|
||||
margin: 20px 0;
|
||||
|
||||
& div:first-child {
|
||||
font-size: 18px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.active {
|
||||
background-color: #f9ffff;
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
import React, { useState } from 'react'
|
||||
import stl from './SelectorCard.css'
|
||||
import cn from 'classnames';
|
||||
import type { MarkedTarget } from 'Player/MessageDistributor/StatedScreen/StatedScreen';
|
||||
import { activeTarget } from 'Player';
|
||||
|
||||
interface Props {
|
||||
index?: number,
|
||||
target: MarkedTarget,
|
||||
showContent: boolean
|
||||
}
|
||||
|
||||
export default function SelectorCard({ index = 1, target, showContent } : Props) {
|
||||
return (
|
||||
<div className={cn(stl.wrapper, { [stl.active]: showContent })}>
|
||||
<div className={stl.top} onClick={() => activeTarget(index)}>
|
||||
<div className={stl.index}>{index + 1}</div>
|
||||
<div className="truncate">{target.selector}</div>
|
||||
</div>
|
||||
{ showContent && (
|
||||
<div className={stl.counts}>
|
||||
<div>{target.count} Clicks - {target.percent}%</div>
|
||||
<div className="color-gray-medium">TOTAL CLICKS</div>
|
||||
</div>
|
||||
) }
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from './SelectorCard'
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
import React, { useState } from 'react'
|
||||
import { NoContent } from 'UI'
|
||||
import { connectPlayer } from 'Player/store';
|
||||
import SelectorCard from '../SelectorCard/SelectorCard';
|
||||
import type { MarkedTarget } from 'Player/MessageDistributor/StatedScreen/StatedScreen';
|
||||
|
||||
interface Props {
|
||||
targets: Array<MarkedTarget>,
|
||||
activeTargetIndex: number
|
||||
}
|
||||
|
||||
function SelectorsList({ targets, activeTargetIndex }: Props) {
|
||||
return (
|
||||
<NoContent
|
||||
title="No data available."
|
||||
size="small"
|
||||
show={ targets && targets.length === 0 }
|
||||
>
|
||||
{ targets && targets.map((target, index) => (
|
||||
<SelectorCard target={target} index={index} showContent={activeTargetIndex === index} />
|
||||
))}
|
||||
</NoContent>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
export default connectPlayer(state => ({
|
||||
targets: state.markedTargets,
|
||||
activeTargetIndex: state.activeTargetIndex,
|
||||
}))(SelectorsList)
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from './SelectorsList'
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from './PageInsightsPanel'
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
import { storiesOf } from '@storybook/react';
|
||||
import { List } from 'immutable';
|
||||
import PageInsightsPanel from './';
|
||||
|
||||
const list = [
|
||||
{
|
||||
"alertId": 2,
|
||||
"projectId": 1,
|
||||
"name": "new alert",
|
||||
"description": null,
|
||||
"active": true,
|
||||
"threshold": 240,
|
||||
"detectionMethod": "threshold",
|
||||
"query": {
|
||||
"left": "avgPageLoad",
|
||||
"right": 1.0,
|
||||
"operator": ">="
|
||||
},
|
||||
"createdAt": 1591893324078,
|
||||
"options": {
|
||||
"message": [
|
||||
{
|
||||
"type": "slack",
|
||||
"value": "51"
|
||||
},
|
||||
],
|
||||
"LastNotification": 1592929583000,
|
||||
"renotifyInterval": 120
|
||||
}
|
||||
},
|
||||
{
|
||||
"alertId": 14,
|
||||
"projectId": 1,
|
||||
"name": "alert 19.06",
|
||||
"description": null,
|
||||
"active": true,
|
||||
"threshold": 30,
|
||||
"detectionMethod": "threshold",
|
||||
"query": {
|
||||
"left": "avgPageLoad",
|
||||
"right": 3000.0,
|
||||
"operator": ">="
|
||||
},
|
||||
"createdAt": 1592579750935,
|
||||
"options": {
|
||||
"message": [
|
||||
{
|
||||
"type": "slack",
|
||||
"value": "51"
|
||||
}
|
||||
],
|
||||
"renotifyInterval": 120
|
||||
}
|
||||
},
|
||||
{
|
||||
"alertId": 15,
|
||||
"projectId": 1,
|
||||
"name": "notify every 60min",
|
||||
"description": null,
|
||||
"active": true,
|
||||
"threshold": 30,
|
||||
"detectionMethod": "threshold",
|
||||
"query": {
|
||||
"left": "avgPageLoad",
|
||||
"right": 1.0,
|
||||
"operator": ">="
|
||||
},
|
||||
"createdAt": 1592848779604,
|
||||
"options": {
|
||||
"message": [
|
||||
{
|
||||
"type": "slack",
|
||||
"value": "51"
|
||||
},
|
||||
],
|
||||
"LastNotification": 1599135058000,
|
||||
"renotifyInterval": 60
|
||||
}
|
||||
},
|
||||
{
|
||||
"alertId": 21,
|
||||
"projectId": 1,
|
||||
"name": "always notify",
|
||||
"description": null,
|
||||
"active": true,
|
||||
"threshold": 30,
|
||||
"detectionMethod": "threshold",
|
||||
"query": {
|
||||
"left": "avgPageLoad",
|
||||
"right": 1.0,
|
||||
"operator": ">="
|
||||
},
|
||||
"createdAt": 1592849011350,
|
||||
"options": {
|
||||
"message": [
|
||||
{
|
||||
"type": "slack",
|
||||
"value": "51"
|
||||
}
|
||||
],
|
||||
"LastNotification": 1599135058000,
|
||||
"renotifyInterval": 10
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
const notifications = List([
|
||||
{ title: 'test', type: 'change', createdAt: 1591893324078, description: 'Lorem ipusm'},
|
||||
{ title: 'test', type: 'threshold', createdAt: 1591893324078, description: 'Lorem ipusm'},
|
||||
{ title: 'test', type: 'threshold', createdAt: 1591893324078, description: 'Lorem ipusm'},
|
||||
{ title: 'test', type: 'threshold', createdAt: 1591893324078, description: 'Lorem ipusm'},
|
||||
])
|
||||
storiesOf('PageInsights', module)
|
||||
.add('Panel', () => (
|
||||
<PageInsightsPanel />
|
||||
))
|
||||
|
|
@ -73,7 +73,7 @@ function getStorageName(type) {
|
|||
skip: state.skip,
|
||||
skipToIssue: state.skipToIssue,
|
||||
speed: state.speed,
|
||||
disabled: state.cssLoading || state.messagesLoading || state.inspectorMode,
|
||||
disabled: state.cssLoading || state.messagesLoading || state.inspectorMode || state.markedTargets,
|
||||
inspectorMode: state.inspectorMode,
|
||||
fullscreenDisabled: state.messagesLoading,
|
||||
logCount: state.logListNow.length,
|
||||
|
|
@ -246,11 +246,12 @@ export default class Controls extends React.Component {
|
|||
showLongtasks,
|
||||
exceptionsCount,
|
||||
showExceptions,
|
||||
fullscreen,
|
||||
skipToIssue
|
||||
fullscreen,
|
||||
skipToIssue,
|
||||
inspectorMode
|
||||
} = this.props;
|
||||
|
||||
const inspectorMode = bottomBlock === INSPECTOR;
|
||||
// const inspectorMode = bottomBlock === INSPECTOR;
|
||||
|
||||
return (
|
||||
<div className={ cn(styles.controls, {'px-5 pt-0' : live}) }>
|
||||
|
|
@ -419,6 +420,7 @@ export default class Controls extends React.Component {
|
|||
</React.Fragment>
|
||||
}
|
||||
|
||||
|
||||
{!live && (
|
||||
<ControlButton
|
||||
disabled={ disabled && !inspectorMode }
|
||||
|
|
|
|||
79
frontend/app/components/Session_/Player/Overlay.tsx
Normal file
79
frontend/app/components/Session_/Player/Overlay.tsx
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import React, {useEffect} from 'react';
|
||||
import { connectPlayer, markTargets } from 'Player';
|
||||
import { getStatusText } from 'Player/MessageDistributor/managers/AssistManager';
|
||||
import type { MarkedTarget } from 'Player/MessageDistributor/StatedScreen/StatedScreen';
|
||||
|
||||
import AutoplayTimer from './Overlay/AutoplayTimer';
|
||||
import PlayIconLayer from './Overlay/PlayIconLayer';
|
||||
import LiveStatusText from './Overlay/LiveStatusText';
|
||||
import Loader from './Overlay/Loader';
|
||||
import ElementsMarker from './Overlay/ElementsMarker';
|
||||
|
||||
interface Props {
|
||||
playing: boolean,
|
||||
completed: boolean,
|
||||
inspectorMode: boolean,
|
||||
messagesLoading: boolean,
|
||||
loading: boolean,
|
||||
live: boolean,
|
||||
liveStatusText: string,
|
||||
autoplay: boolean,
|
||||
markedTargets: MarkedTarget[] | null,
|
||||
activeTargetIndex: number,
|
||||
|
||||
nextId: string,
|
||||
togglePlay: () => void,
|
||||
}
|
||||
|
||||
function Overlay({
|
||||
playing,
|
||||
completed,
|
||||
inspectorMode,
|
||||
messagesLoading,
|
||||
loading,
|
||||
live,
|
||||
liveStatusText,
|
||||
autoplay,
|
||||
markedTargets,
|
||||
activeTargetIndex,
|
||||
nextId,
|
||||
togglePlay,
|
||||
}: Props) {
|
||||
|
||||
// useEffect(() =>{
|
||||
// setTimeout(() => markTargets([{ selector: 'div', count:6}]), 5000)
|
||||
// setTimeout(() => markTargets(null), 8000)
|
||||
// },[])
|
||||
|
||||
const showAutoplayTimer = !live && completed && autoplay && nextId
|
||||
const showPlayIconLayer = !live && !markedTargets && !inspectorMode && !loading && !showAutoplayTimer;
|
||||
const showLiveStatusText = live && liveStatusText && !loading;
|
||||
|
||||
return (
|
||||
<>
|
||||
{ showAutoplayTimer && <AutoplayTimer /> }
|
||||
{ showLiveStatusText &&
|
||||
<LiveStatusText text={liveStatusText} />
|
||||
}
|
||||
{ messagesLoading && <Loader/> }
|
||||
{ showPlayIconLayer &&
|
||||
<PlayIconLayer playing={playing} togglePlay={togglePlay} />
|
||||
}
|
||||
{ markedTargets && <ElementsMarker targets={ markedTargets } activeIndex={activeTargetIndex}/>
|
||||
}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export default connectPlayer(state => ({
|
||||
playing: state.playing,
|
||||
messagesLoading: state.messagesLoading,
|
||||
loading: state.messagesLoading || state.cssLoading,
|
||||
completed: state.completed,
|
||||
autoplay: state.autoplay,
|
||||
live: state.live,
|
||||
liveStatusText: getStatusText(state.peerConnectionStatus),
|
||||
markedTargets: state.markedTargets,
|
||||
activeTargetIndex: state.activeTargetIndex,
|
||||
}))(Overlay);
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
.overlayBg {
|
||||
background-color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
|
@ -1,9 +1,11 @@
|
|||
import React, { useEffect, useState } from 'react'
|
||||
import cn from 'classnames';
|
||||
import { connect } from 'react-redux'
|
||||
import { withRouter } from 'react-router-dom';
|
||||
import { Button, Link } from 'UI'
|
||||
import { session as sessionRoute, withSiteId } from 'App/routes'
|
||||
import stl from './AutoplayTimer.css'
|
||||
import { withRouter } from 'react-router-dom';
|
||||
import stl from './AutoplayTimer.css';
|
||||
import clsOv from './overlay.css';
|
||||
|
||||
function AutoplayTimer({ nextId, siteId, history }) {
|
||||
let timer
|
||||
|
|
@ -33,7 +35,7 @@ function AutoplayTimer({ nextId, siteId, history }) {
|
|||
return ''
|
||||
|
||||
return (
|
||||
<div className={stl.overlay}>
|
||||
<div className={ cn(clsOv.overlay, stl.overlayBg) } >
|
||||
<div className="border p-6 shadow-lg bg-white rounded">
|
||||
<div className="py-4">Next recording will be played in {counter}s</div>
|
||||
<div className="flex items-center">
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import React from 'react';
|
||||
import Marker from './ElementsMarker/Marker';
|
||||
|
||||
export default function ElementsMarker({ targets, activeIndex }) {
|
||||
return targets && targets.map(t => <Marker target={t} active={activeIndex === t.index - 1}/>)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
.marker {
|
||||
position: absolute;
|
||||
z-index: 100;
|
||||
border: 2px dotted transparent;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
|
||||
&:hover {
|
||||
& .index {
|
||||
opacity: 1;
|
||||
transition: 0.3s;
|
||||
}
|
||||
}
|
||||
|
||||
& .index {
|
||||
opacity: 0.3;
|
||||
position: absolute;
|
||||
top: -10px;
|
||||
left: -10px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 10px;
|
||||
background-color: $tealx;
|
||||
flex-shrink: 0;
|
||||
border: solid thin white;
|
||||
box-shadow: 1px 1px 1px rgba(0, 0, 0, 0.1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
& .tooltip {
|
||||
padding: 10px;
|
||||
border-radius: 3px;
|
||||
background-color: $tealx;
|
||||
box-shadow: 1px 1px 1px rgba(0, 0, 0, 0.2);
|
||||
font-size: 12px;
|
||||
/* position: absolute; */
|
||||
/* bottom: 100%; */
|
||||
/* left: 0; */
|
||||
/* margin-bottom: 20px; */
|
||||
color: white;
|
||||
|
||||
&::before {
|
||||
top: 100%;
|
||||
left: 40%;
|
||||
border-color: $tealx transparent transparent transparent;
|
||||
content: "";
|
||||
display: block;
|
||||
border-style: solid;
|
||||
border-width: 10px 10px 10px 10px;
|
||||
position: absolute;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.active {
|
||||
border: 2px dotted $tealx;
|
||||
|
||||
& .index {
|
||||
opacity: 1
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
import React from 'react';
|
||||
import type { MarkedTarget } from 'Player/MessageDistributor/StatedScreen/StatedScreen';
|
||||
import { Tooltip } from 'react-tippy';
|
||||
import cn from 'classnames';
|
||||
import stl from './Marker.css';
|
||||
import { activeTarget } from 'Player';
|
||||
|
||||
interface Props {
|
||||
target: MarkedTarget;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export default function Marker({ target, active }: Props) {
|
||||
const style = {
|
||||
top: `${ target.boundingRect.top }px`,
|
||||
left: `${ target.boundingRect.left }px`,
|
||||
width: `${ target.boundingRect.width }px`,
|
||||
height: `${ target.boundingRect.height }px`,
|
||||
}
|
||||
return (
|
||||
<div className={ cn(stl.marker, { [stl.active] : active }) } style={ style } onClick={() => activeTarget(target.index - 1)}>
|
||||
<div className={stl.index}>{target.index}</div>
|
||||
<Tooltip
|
||||
open={active}
|
||||
arrow
|
||||
sticky
|
||||
distance={15}
|
||||
html={(
|
||||
<div>{target.count} Clicks</div>
|
||||
)}
|
||||
trigger="mouseenter"
|
||||
>
|
||||
<div className="absolute inset-0"></div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
.text {
|
||||
color: $gray-light;
|
||||
font-size: 40px;
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
import React from 'react';
|
||||
import stl from './LiveStatusText.css';
|
||||
import ovStl from './overlay.css';
|
||||
|
||||
interface Props {
|
||||
text: string;
|
||||
}
|
||||
|
||||
export default function LiveStatusText({ text }: Props) {
|
||||
return <div className={ovStl.overlay}><span className={stl.text}>{text}</span></div>
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
import React from 'react';
|
||||
import { Loader } from 'UI';
|
||||
import ovStl from './overlay.css';
|
||||
|
||||
export default function OverlayLoader() {
|
||||
return <div className={ovStl.overlay}><Loader loading /></div>
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
.iconWrapper {
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
opacity: 0;
|
||||
transition: all .2s; /* Animation */
|
||||
}
|
||||
|
||||
.zoomIcon {
|
||||
opacity: 1;
|
||||
transform: scale(1.8);
|
||||
transition: all .8s;
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
import React, { useState, useCallback } from 'react';
|
||||
import cn from 'classnames';
|
||||
import { Icon } from 'UI';
|
||||
|
||||
import cls from './PlayIconLayer.css';
|
||||
import clsOv from './overlay.css';
|
||||
|
||||
interface Props {
|
||||
togglePlay: () => void,
|
||||
playing: boolean,
|
||||
}
|
||||
|
||||
export default function PlayIconLayer({ playing, togglePlay }: Props) {
|
||||
const [ showPlayOverlayIcon, setShowPlayOverlayIcon ] = useState(false);
|
||||
const togglePlayAnimated = useCallback(() => {
|
||||
setShowPlayOverlayIcon(true);
|
||||
togglePlay();
|
||||
setTimeout(
|
||||
() => setShowPlayOverlayIcon(false),
|
||||
800,
|
||||
);
|
||||
}, []);
|
||||
return (
|
||||
<div className={ clsOv.overlay } onClick={ togglePlayAnimated }>
|
||||
<div
|
||||
className={ cn(cls.iconWrapper, {
|
||||
[ cls.zoomIcon ]: showPlayOverlayIcon
|
||||
}) }
|
||||
>
|
||||
<Icon
|
||||
name={ playing ? "play" : "pause" }
|
||||
color="gray-medium"
|
||||
size={30}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,8 +1,3 @@
|
|||
.wrapper {
|
||||
width: 30%;
|
||||
height: 30%;
|
||||
}
|
||||
|
||||
.overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
|
|
@ -13,5 +8,4 @@
|
|||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
}
|
||||
|
|
@ -2,29 +2,17 @@ import { connect } from 'react-redux';
|
|||
import { findDOMNode } from 'react-dom';
|
||||
import cn from 'classnames';
|
||||
import { Loader, IconButton, EscapeButton } from 'UI';
|
||||
import { hide as hideTargetDefiner, toggleInspectorMode } from 'Duck/components/targetDefiner';
|
||||
import { hide as hideTargetDefiner } from 'Duck/components/targetDefiner';
|
||||
import { fullscreenOff } from 'Duck/components/player';
|
||||
import withOverlay from 'Components/hocs/withOverlay';
|
||||
import { attach as attachPlayer, Controls as PlayerControls, connectPlayer } from 'Player';
|
||||
import Controls from './Controls';
|
||||
import Overlay from './Overlay';
|
||||
import stl from './player.css';
|
||||
import AutoplayTimer from '../AutoplayTimer';
|
||||
import EventsToggleButton from '../../Session/EventsToggleButton';
|
||||
import { getStatusText } from 'Player/MessageDistributor/managers/AssistManager';
|
||||
|
||||
|
||||
const ScreenWrapper = withOverlay()(React.memo(() => <div className={ stl.screenWrapper } />));
|
||||
|
||||
@connectPlayer(state => ({
|
||||
playing: state.playing,
|
||||
loading: state.messagesLoading,
|
||||
disconnected: state.disconnected,
|
||||
disabled: state.cssLoading || state.messagesLoading || state.inspectorMode,
|
||||
removeOverlay: !state.messagesLoading && state.inspectorMode || state.live,
|
||||
completed: state.completed,
|
||||
autoplay: state.autoplay,
|
||||
live: state.live,
|
||||
liveStatusText: getStatusText(state.peerConnectionStatus),
|
||||
}))
|
||||
@connect(state => ({
|
||||
//session: state.getIn([ 'sessions', 'current' ]),
|
||||
|
|
@ -32,15 +20,9 @@ const ScreenWrapper = withOverlay()(React.memo(() => <div className={ stl.screen
|
|||
nextId: state.getIn([ 'sessions', 'nextId' ]),
|
||||
}), {
|
||||
hideTargetDefiner,
|
||||
toggleInspectorMode: () => toggleInspectorMode(false),
|
||||
fullscreenOff,
|
||||
})
|
||||
export default class Player extends React.PureComponent {
|
||||
state = {
|
||||
showPlayOverlayIcon: false,
|
||||
|
||||
startedToPlayAt: Date.now(),
|
||||
};
|
||||
screenWrapper = React.createRef();
|
||||
|
||||
componentDidMount() {
|
||||
|
|
@ -48,69 +30,14 @@ export default class Player extends React.PureComponent {
|
|||
attachPlayer(parentElement);
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (prevProps.targetSelector !== this.props.targetSelector) {
|
||||
PlayerControls.mark(this.props.targetSelector);
|
||||
}
|
||||
if (prevProps.playing !== this.props.playing) {
|
||||
if (this.props.playing) {
|
||||
this.setState({ startedToPlayAt: Date.now() });
|
||||
} else {
|
||||
this.updateWatchingTime();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this.props.playing) {
|
||||
this.updateWatchingTime();
|
||||
}
|
||||
}
|
||||
|
||||
updateWatchingTime() {
|
||||
const diff = Date.now() - this.state.startedToPlayAt;
|
||||
}
|
||||
|
||||
|
||||
// onTargetClick = (targetPath) => {
|
||||
// const { targetCustomList, location } = this.props;
|
||||
// const targetCustomFromList = targetCustomList !== this.props.targetSelector
|
||||
// .find(({ path }) => path === targetPath);
|
||||
// const target = targetCustomFromList
|
||||
// ? targetCustomFromList.set('location', location)
|
||||
// : { path: targetPath, isCustom: true, location };
|
||||
// this.props.showTargetDefiner(target);
|
||||
// }
|
||||
|
||||
togglePlay = () => {
|
||||
this.setState({ showPlayOverlayIcon: true });
|
||||
PlayerControls.togglePlay();
|
||||
|
||||
setTimeout(
|
||||
() => this.setState({ showPlayOverlayIcon: false }),
|
||||
800,
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
showPlayOverlayIcon,
|
||||
} = this.state;
|
||||
const {
|
||||
className,
|
||||
playing,
|
||||
disabled,
|
||||
removeOverlay,
|
||||
bottomBlockIsActive,
|
||||
loading,
|
||||
disconnected,
|
||||
fullscreen,
|
||||
fullscreenOff,
|
||||
completed,
|
||||
autoplay,
|
||||
nextId,
|
||||
live,
|
||||
liveStatusText,
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
|
|
@ -120,40 +47,12 @@ export default class Player extends React.PureComponent {
|
|||
>
|
||||
{ fullscreen &&
|
||||
<EscapeButton onClose={ fullscreenOff } />
|
||||
// <IconButton
|
||||
// size="18"
|
||||
// className="ml-auto mb-5"
|
||||
// style={{ marginTop: '-5px' }}
|
||||
// onClick={ fullscreenOff }
|
||||
// size="small"
|
||||
// icon="close"
|
||||
// label="Esc"
|
||||
// />
|
||||
}
|
||||
{!live && !fullscreen && <EventsToggleButton /> }
|
||||
<div className="relative flex-1">
|
||||
{ (!removeOverlay || live && liveStatusText) &&
|
||||
<div
|
||||
className={ stl.overlay }
|
||||
onClick={ disabled ? null : this.togglePlay }
|
||||
>
|
||||
{ live && liveStatusText
|
||||
? <span className={stl.liveStatusText}>{liveStatusText}</span>
|
||||
: <Loader loading={ loading } />
|
||||
}
|
||||
{ !live &&
|
||||
<div
|
||||
className={ cn(stl.iconWrapper, {
|
||||
[ stl.zoomIcon ]: showPlayOverlayIcon
|
||||
}) }
|
||||
>
|
||||
<div className={ playing ? stl.playIcon : stl.pauseIcon } />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
{ completed && autoplay && nextId && <AutoplayTimer /> }
|
||||
<ScreenWrapper
|
||||
<div className="relative flex-1 overflow-hidden">
|
||||
<Overlay nextId={nextId} togglePlay={PlayerControls.togglePlay} />
|
||||
<div
|
||||
className={ stl.screenWrapper }
|
||||
ref={ this.screenWrapper }
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
@import 'icons.css';
|
||||
|
||||
.playerBody {
|
||||
background: $white;
|
||||
/* border-radius: 3px; */
|
||||
|
|
@ -25,61 +23,8 @@
|
|||
font-weight: 200;
|
||||
color: $gray-medium;
|
||||
}
|
||||
.overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
|
||||
/* &[data-protect] {
|
||||
pointer-events: none;
|
||||
background: $white;
|
||||
opacity: 0.3;
|
||||
}
|
||||
*/
|
||||
& .iconWrapper {
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
opacity: 0;
|
||||
transition: all .2s; /* Animation */
|
||||
}
|
||||
|
||||
& .zoomIcon {
|
||||
opacity: 1;
|
||||
transform: scale(1.8);
|
||||
transition: all .8s;
|
||||
}
|
||||
|
||||
& .playIcon {
|
||||
@mixin icon play, $gray-medium, 30px;
|
||||
}
|
||||
|
||||
& .pauseIcon {
|
||||
@mixin icon pause, $gray-medium, 30px;
|
||||
}
|
||||
}
|
||||
|
||||
.playerView {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.inspectorMode {
|
||||
z-index: 99991 !important;
|
||||
}
|
||||
|
||||
.liveStatusText {
|
||||
color: $gray-light;
|
||||
font-size: 40px;
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@ import { connect } from 'react-redux';
|
|||
import DateRangeDropdown from 'Shared/DateRangeDropdown';
|
||||
|
||||
function DateRange (props) {
|
||||
const { startDate, endDate, rangeValue, className, onDateChange, customRangeRight=false } = props;
|
||||
const { startDate, endDate, rangeValue, className, onDateChange, customRangeRight=false, customHidden = false } = props;
|
||||
|
||||
return (
|
||||
<DateRangeDropdown
|
||||
|
|
@ -13,6 +13,7 @@ function DateRange (props) {
|
|||
endDate={ endDate }
|
||||
className={ className }
|
||||
customRangeRight={customRangeRight}
|
||||
customHidden={customHidden}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
.wrapper {
|
||||
& .body {
|
||||
display: flex;
|
||||
border-bottom: solid thin $gray-light;
|
||||
padding: 5px;
|
||||
}
|
||||
background-color: white;
|
||||
outline: solid thin #CCC;
|
||||
& .body {
|
||||
display: flex;
|
||||
border-bottom: solid thin $gray-light;
|
||||
padding: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.preSelections {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import React from 'react'
|
|||
import { useState } from 'react';
|
||||
import copy from 'copy-to-clipboard';
|
||||
|
||||
function CopyButton({ content, className }) {
|
||||
function CopyButton({ content, className, btnText = 'copy' }) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const copyHandler = () => {
|
||||
|
|
@ -17,7 +17,7 @@ function CopyButton({ content, className }) {
|
|||
className={ className }
|
||||
onClick={ copyHandler }
|
||||
>
|
||||
{ copied ? 'copied' : 'copy' }
|
||||
{ copied ? 'copied' : btnText }
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import React from 'react';
|
||||
import cn from 'classnames';
|
||||
import SVG from 'UI/SVG';
|
||||
import styles from './icon.css';
|
||||
|
|
|
|||
|
|
@ -9,8 +9,12 @@ export default ({
|
|||
show = true,
|
||||
children = null,
|
||||
empty = false,
|
||||
image = null
|
||||
}) => (!show ? children :
|
||||
<div className={ `${ styles.wrapper } ${ size && styles[ size ] }` }>
|
||||
{
|
||||
image && image
|
||||
}
|
||||
{
|
||||
icon && <div className={ empty ? styles.emptyIcon : styles.icon } />
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,48 @@
|
|||
import { Map } from 'immutable';
|
||||
import Member from 'Types/member';
|
||||
import crudDuckGenerator from './tools/crudDuck';
|
||||
import withRequestState, { RequestTypes } from 'Duck/requestStateCreator';
|
||||
import { reduceDucks } from 'Duck/tools';
|
||||
|
||||
const GENERATE_LINK = new RequestTypes('member/GENERATE_LINK');
|
||||
|
||||
const crudDuck = crudDuckGenerator('client/member', Member, { idKey: 'id' });
|
||||
export const {
|
||||
fetchList, init, edit, remove,
|
||||
} = crudDuck.actions;
|
||||
export const { fetchList, init, edit, remove, } = crudDuck.actions;
|
||||
|
||||
const initialState = Map({
|
||||
definedPercent: 0,
|
||||
});
|
||||
|
||||
const reducer = (state = initialState, action = {}) => {
|
||||
switch (action.type) {
|
||||
case GENERATE_LINK.SUCCESS:
|
||||
return state.update(
|
||||
'list',
|
||||
list => list
|
||||
.map(member => {
|
||||
if(member.id === action.id) {
|
||||
return Member({...member.toJS(), invitationLink: action.data.invitationLink })
|
||||
}
|
||||
return member
|
||||
})
|
||||
);
|
||||
}
|
||||
return state;
|
||||
};
|
||||
|
||||
export function save(instance) {
|
||||
return {
|
||||
types: crudDuck.actionTypes.SAVE.toArray(),
|
||||
call: client => client.put( instance.id ? `/client/members/${ instance.id }` : '/client/members', instance.toData()),
|
||||
call: client => client.put( instance.id ? `/client/members/${ instance.id }` : '/client/members', instance.toData()),
|
||||
};
|
||||
}
|
||||
|
||||
export default crudDuck.reducer;
|
||||
export function generateInviteLink(instance) {
|
||||
return {
|
||||
types: GENERATE_LINK.toArray(),
|
||||
call: client => client.get(`/client/members/${ instance.id }/reset`),
|
||||
id: instance.id
|
||||
};
|
||||
}
|
||||
|
||||
export default reduceDucks(crudDuck, { initialState, reducer }).reducer;
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import Watchdog, { getSessionWatchdogTypes } from 'Types/watchdog';
|
|||
import { clean as cleanParams } from 'App/api_client';
|
||||
import withRequestState, { RequestTypes } from './requestStateCreator';
|
||||
import { getRE } from 'App/utils';
|
||||
import { LAST_7_DAYS } from 'Types/app/period';
|
||||
import { getDateRangeFromValue } from 'App/dateRange';
|
||||
|
||||
|
||||
const INIT = 'sessions/INIT';
|
||||
|
|
@ -15,6 +17,7 @@ const FETCH_FAVORITE_LIST = new RequestTypes('sessions/FETCH_FAVORITE_LIST');
|
|||
const FETCH_LIVE_LIST = new RequestTypes('sessions/FETCH_LIVE_LIST');
|
||||
const TOGGLE_FAVORITE = new RequestTypes('sessions/TOGGLE_FAVORITE');
|
||||
const FETCH_ERROR_STACK = new RequestTypes('sessions/FETCH_ERROR_STACK');
|
||||
const FETCH_INSIGHTS = new RequestTypes('sessions/FETCH_INSIGHTS');
|
||||
const SORT = 'sessions/SORT';
|
||||
const REDEFINE_TARGET = 'sessions/REDEFINE_TARGET';
|
||||
const SET_TIMEZONE = 'sessions/SET_TIMEZONE';
|
||||
|
|
@ -24,6 +27,14 @@ const TOGGLE_CHAT_WINDOW = 'sessions/TOGGLE_CHAT_WINDOW';
|
|||
|
||||
const SET_ACTIVE_TAB = 'sessions/SET_ACTIVE_TAB';
|
||||
|
||||
const range = getDateRangeFromValue(LAST_7_DAYS);
|
||||
const defaultDateFilters = {
|
||||
url: '',
|
||||
rangeValue: LAST_7_DAYS,
|
||||
startDate: range.start.unix() * 1000,
|
||||
endDate: range.end.unix() * 1000
|
||||
}
|
||||
|
||||
const initialState = Map({
|
||||
list: List(),
|
||||
sessionIds: [],
|
||||
|
|
@ -39,7 +50,10 @@ const initialState = Map({
|
|||
sourcemapUploaded: true,
|
||||
filteredEvents: null,
|
||||
showChatWindow: false,
|
||||
liveSessions: List()
|
||||
liveSessions: List(),
|
||||
visitedEvents: List(),
|
||||
insights: List(),
|
||||
insightFilters: defaultDateFilters
|
||||
});
|
||||
|
||||
const reducer = (state = initialState, action = {}) => {
|
||||
|
|
@ -136,21 +150,32 @@ const reducer = (state = initialState, action = {}) => {
|
|||
const session = Session(action.data);
|
||||
|
||||
const matching = [];
|
||||
|
||||
const visitedEvents = []
|
||||
const tmpMap = {}
|
||||
session.events.forEach(event => {
|
||||
if (event.type === 'LOCATION' && !tmpMap.hasOwnProperty(event.url)) {
|
||||
tmpMap[event.url] = event.url
|
||||
visitedEvents.push(event)
|
||||
}
|
||||
})
|
||||
|
||||
events.forEach(({ key, operator, value }) => {
|
||||
events.forEach(({ key, operator, value }) => {
|
||||
session.events.forEach((e, index) => {
|
||||
if (key === e.type) {
|
||||
const val = (e.type === 'LOCATION' ? e.url : e.value);
|
||||
if (key === e.type) {
|
||||
const val = (e.type === 'LOCATION' ? e.url : e.value);
|
||||
if (operator === 'is' && value === val) {
|
||||
matching.push(index);
|
||||
}
|
||||
if (operator === 'contains' && val.includes(value)) {
|
||||
matching.push(index);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
return state.set('current', current.merge(session)).set('eventsIndex', matching);
|
||||
return state.set('current', current.merge(session))
|
||||
.set('eventsIndex', matching)
|
||||
.set('visitedEvents', visitedEvents);
|
||||
}
|
||||
case FETCH_FAVORITE_LIST.SUCCESS:
|
||||
return state
|
||||
|
|
@ -202,9 +227,11 @@ const reducer = (state = initialState, action = {}) => {
|
|||
.set('sessionIds', allList.map(({ sessionId }) => sessionId ).toJS())
|
||||
case SET_TIMEZONE:
|
||||
return state.set('timezone', action.timezone)
|
||||
case TOGGLE_CHAT_WINDOW:
|
||||
console.log(action)
|
||||
case TOGGLE_CHAT_WINDOW:
|
||||
return state.set('showChatWindow', action.state)
|
||||
case FETCH_INSIGHTS.SUCCESS:
|
||||
return state.set('insights', List(action.data).sort((a, b) => b.count - a.count));
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
|
|
@ -215,6 +242,7 @@ export default withRequestState({
|
|||
fetchFavoriteListRequest: FETCH_FAVORITE_LIST,
|
||||
toggleFavoriteRequest: TOGGLE_FAVORITE,
|
||||
fetchErrorStackList: FETCH_ERROR_STACK,
|
||||
fetchInsightsRequest: FETCH_INSIGHTS,
|
||||
}, reducer);
|
||||
|
||||
function init(session) {
|
||||
|
|
@ -263,6 +291,13 @@ export function fetchFavoriteList() {
|
|||
};
|
||||
}
|
||||
|
||||
export function fetchInsights(params) {
|
||||
return {
|
||||
types: FETCH_INSIGHTS.toArray(),
|
||||
call: client => client.post('/heatmaps/url', params),
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchLiveList() {
|
||||
return {
|
||||
types: FETCH_LIVE_LIST.toArray(),
|
||||
|
|
|
|||
|
|
@ -24,8 +24,7 @@ const initialState = Map({
|
|||
|
||||
const reducer = (state = initialState, action = {}) => {
|
||||
switch (action.type) {
|
||||
case FETCH_LIST.SUCCESS: {
|
||||
console.log(action);
|
||||
case FETCH_LIST.SUCCESS: {
|
||||
return state.set('list', List(action.data).map(i => {
|
||||
const type = i.type === 'navigate' ? i.type : 'location';
|
||||
return {...i, type: type.toUpperCase()}
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ const reducer = (state = initialState, action = {}) => {
|
|||
case UPDATE_PASSWORD.SUCCESS:
|
||||
case LOGIN.SUCCESS:
|
||||
return setClient(
|
||||
state.set('account', Account(action.data.user)),
|
||||
state.set('account', Account({...action.data.user, smtp: action.data.client.smtp })),
|
||||
action.data.client,
|
||||
);
|
||||
case SIGNUP.SUCCESS:
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import {
|
|||
setStartTime as setListsStartTime
|
||||
} from '../lists';
|
||||
|
||||
import StatedScreen from './StatedScreen';
|
||||
import StatedScreen from './StatedScreen/StatedScreen';
|
||||
|
||||
import ListWalker from './managers/ListWalker';
|
||||
import PagesManager from './managers/PagesManager';
|
||||
|
|
@ -27,7 +27,7 @@ import AssistManager from './managers/AssistManager';
|
|||
|
||||
import MessageReader from './MessageReader';
|
||||
|
||||
import { INITIAL_STATE as SUPER_INITIAL_STATE, State as SuperState } from './StatedScreen';
|
||||
import { INITIAL_STATE as SUPER_INITIAL_STATE, State as SuperState } from './StatedScreen/StatedScreen';
|
||||
import { INITIAL_STATE as ASSIST_INITIAL_STATE, State as AssistState } from './managers/AssistManager';
|
||||
|
||||
import type { TimedMessage } from './Timed';
|
||||
|
|
@ -306,9 +306,7 @@ export default class MessageDistributor extends StatedScreen {
|
|||
this.pagesManager.moveReady(t).then(() => {
|
||||
|
||||
const lastScroll = this.scrollManager.moveToLast(t, index);
|
||||
// @ts-ignore ??can't see double inheritance
|
||||
if (!!lastScroll && this.window) {
|
||||
// @ts-ignore
|
||||
this.window.scrollTo(lastScroll.x, lastScroll.y);
|
||||
}
|
||||
// Moving mouse and setting :hover classes on ready view
|
||||
|
|
@ -479,7 +477,6 @@ export default class MessageDistributor extends StatedScreen {
|
|||
|
||||
// TODO: clean managers?
|
||||
clean() {
|
||||
// @ts-ignore
|
||||
super.clean();
|
||||
//if (this._socket) this._socket.close();
|
||||
update(INITIAL_STATE);
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ export const INITIAL_STATE: State = {
|
|||
export default abstract class BaseScreen {
|
||||
public readonly overlay: HTMLDivElement;
|
||||
private readonly iframe: HTMLIFrameElement;
|
||||
private readonly _screen: HTMLDivElement;
|
||||
protected readonly screen: HTMLDivElement;
|
||||
protected parentElement: HTMLElement | null = null;
|
||||
constructor() {
|
||||
const iframe = document.createElement('iframe');
|
||||
|
|
@ -44,7 +44,7 @@ export default abstract class BaseScreen {
|
|||
screen.className = styles.screen;
|
||||
screen.appendChild(iframe);
|
||||
screen.appendChild(overlay);
|
||||
this._screen = screen;
|
||||
this.screen = screen;
|
||||
}
|
||||
|
||||
attach(parentElement: HTMLElement) {
|
||||
|
|
@ -52,7 +52,7 @@ export default abstract class BaseScreen {
|
|||
throw new Error("BaseScreen: Trying to attach an attached screen.");
|
||||
}
|
||||
|
||||
parentElement.appendChild(this._screen);
|
||||
parentElement.appendChild(this.screen);
|
||||
|
||||
this.parentElement = parentElement;
|
||||
// parentElement.onresize = this.scale;
|
||||
|
|
@ -115,29 +115,38 @@ export default abstract class BaseScreen {
|
|||
return this.getElementsFromInternalPoint(this.getInternalCoordinates(point));
|
||||
}
|
||||
|
||||
getElementBySelector(selector: string): Element | null {
|
||||
if (!selector) return null;
|
||||
return this.document?.querySelector(selector) || null;
|
||||
}
|
||||
|
||||
display(flag: boolean = true) {
|
||||
this._screen.style.display = flag ? '' : 'none';
|
||||
this.screen.style.display = flag ? '' : 'none';
|
||||
}
|
||||
|
||||
displayFrame(flag: boolean = true) {
|
||||
this.iframe.style.display = flag ? '' : 'none';
|
||||
}
|
||||
|
||||
private s: number = 1;
|
||||
getScale() {
|
||||
return this.s;
|
||||
}
|
||||
|
||||
_scale() {
|
||||
if (!this.parentElement) return;
|
||||
let s = 1;
|
||||
const { height, width } = getState();
|
||||
const { offsetWidth, offsetHeight } = this.parentElement;
|
||||
|
||||
s = Math.min(offsetWidth / width, offsetHeight / height);
|
||||
if (s > 1) {
|
||||
s = 1;
|
||||
this.s = Math.min(offsetWidth / width, offsetHeight / height);
|
||||
if (this.s > 1) {
|
||||
this.s = 1;
|
||||
} else {
|
||||
s = Math.round(s * 1e3) / 1e3;
|
||||
this.s = Math.round(this.s * 1e3) / 1e3;
|
||||
}
|
||||
this._screen.style.transform = `scale(${ s }) translate(-50%, -50%)`;
|
||||
this._screen.style.width = width + 'px';
|
||||
this._screen.style.height = height + 'px';
|
||||
this.screen.style.transform = `scale(${ this.s }) translate(-50%, -50%)`;
|
||||
this.screen.style.width = width + 'px';
|
||||
this.screen.style.height = height + 'px';
|
||||
this.iframe.style.width = width + 'px';
|
||||
this.iframe.style.height = height + 'px';
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,33 @@
|
|||
import Screen, { INITIAL_STATE as SUPER_INITIAL_STATE, State as SuperState } from './Screen';
|
||||
import Screen, { INITIAL_STATE as SUPER_INITIAL_STATE, State as SuperState } from './Screen/Screen';
|
||||
import { update, getState } from '../../store';
|
||||
|
||||
|
||||
//export interface targetPosition
|
||||
|
||||
interface BoundingRect {
|
||||
top: number,
|
||||
left: number,
|
||||
width: number,
|
||||
height: number,
|
||||
}
|
||||
|
||||
export interface MarkedTarget {
|
||||
boundingRect: BoundingRect,
|
||||
el: Element,
|
||||
selector: string,
|
||||
count: number,
|
||||
index: number,
|
||||
active?: boolean,
|
||||
percent: number
|
||||
}
|
||||
|
||||
export interface State extends SuperState {
|
||||
messagesLoading: boolean,
|
||||
cssLoading: boolean,
|
||||
disconnected: boolean,
|
||||
userPageLoading: boolean,
|
||||
markedTargets: MarkedTarget[] | null,
|
||||
activeTargetIndex: number
|
||||
}
|
||||
|
||||
export const INITIAL_STATE: State = {
|
||||
|
|
@ -15,40 +36,88 @@ export const INITIAL_STATE: State = {
|
|||
cssLoading: false,
|
||||
disconnected: false,
|
||||
userPageLoading: false,
|
||||
}
|
||||
markedTargets: null,
|
||||
activeTargetIndex: 0
|
||||
};
|
||||
|
||||
export default class StatedScreen extends Screen {
|
||||
constructor() { super(); }
|
||||
|
||||
setMessagesLoading(messagesLoading: boolean) {
|
||||
// @ts-ignore
|
||||
this.display(!messagesLoading);
|
||||
update({ messagesLoading });
|
||||
}
|
||||
|
||||
setCSSLoading(cssLoading: boolean) {
|
||||
// @ts-ignore
|
||||
|
||||
this.displayFrame(!cssLoading);
|
||||
update({ cssLoading });
|
||||
}
|
||||
|
||||
setDisconnected(disconnected: boolean) {
|
||||
if (!getState().live) return; //?
|
||||
// @ts-ignore
|
||||
this.display(!disconnected);
|
||||
update({ disconnected });
|
||||
}
|
||||
|
||||
setUserPageLoading(userPageLoading: boolean) {
|
||||
// @ts-ignore
|
||||
this.display(!userPageLoading);
|
||||
update({ userPageLoading });
|
||||
}
|
||||
|
||||
setSize({ height, width }: { height: number, width: number }) {
|
||||
update({ width, height });
|
||||
// @ts-ignore
|
||||
this.scale();
|
||||
|
||||
const { markedTargets } = getState();
|
||||
if (markedTargets) {
|
||||
update({
|
||||
markedTargets: markedTargets.map(mt => ({
|
||||
...mt,
|
||||
boundingRect: this.calculateRelativeBoundingRect(mt.el),
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private calculateRelativeBoundingRect(el: Element): BoundingRect {
|
||||
if (!this.parentElement) return {top:0, left:0, width:0,height:0} //TODO
|
||||
const { top, left, width, height } = el.getBoundingClientRect();
|
||||
const s = this.getScale();
|
||||
const scrinRect = this.screen.getBoundingClientRect();
|
||||
const parentRect = this.parentElement.getBoundingClientRect();
|
||||
|
||||
return {
|
||||
top: top*s + scrinRect.top - parentRect.top,
|
||||
left: left*s + scrinRect.left - parentRect.left,
|
||||
width: width*s,
|
||||
height: height*s,
|
||||
}
|
||||
}
|
||||
|
||||
setActiveTarget(index) {
|
||||
update({ activeTargetIndex: index });
|
||||
}
|
||||
|
||||
setMarkedTargets(selections: { selector: string, count: number }[] | null) {
|
||||
if (selections) {
|
||||
const targets: MarkedTarget[] = [];
|
||||
const totalCount = selections.reduce((a, b) => {
|
||||
return a + b.count
|
||||
}, 0);
|
||||
selections.forEach((s, index) => {
|
||||
const el = this.getElementBySelector(s.selector);
|
||||
if (!el) return;
|
||||
targets.push({
|
||||
...s,
|
||||
el,
|
||||
index,
|
||||
percent: Math.round((s.count * totalCount) / 100),
|
||||
boundingRect: this.calculateRelativeBoundingRect(el),
|
||||
})
|
||||
});
|
||||
update({ markedTargets: targets });
|
||||
} else {
|
||||
update({ markedTargets: null });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -17,6 +17,7 @@ export enum CallingState {
|
|||
|
||||
export enum ConnectionStatus {
|
||||
Connecting,
|
||||
WaitingMessages,
|
||||
Connected,
|
||||
Inactive,
|
||||
Disconnected,
|
||||
|
|
@ -36,6 +37,8 @@ export function getStatusText(status: ConnectionStatus): string {
|
|||
return "Disconnected";
|
||||
case ConnectionStatus.Error:
|
||||
return "Something went wrong. Try to reload the page.";
|
||||
case ConnectionStatus.WaitingMessages:
|
||||
return "Connected. Waiting for the data..."
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -114,6 +117,21 @@ function resolveCSS(baseURL: string, css: string): string {
|
|||
export default class AssistManager {
|
||||
constructor(private session, private md: MessageDistributor) {}
|
||||
|
||||
|
||||
private setStatus(status: ConnectionStatus) {
|
||||
if (status === ConnectionStatus.Connecting) {
|
||||
this.md.setMessagesLoading(true);
|
||||
} else {
|
||||
this.md.setMessagesLoading(false);
|
||||
}
|
||||
if (status === ConnectionStatus.Connected) {
|
||||
// this.md.display(true);
|
||||
} else {
|
||||
// this.md.display(false);
|
||||
}
|
||||
update({ peerConnectionStatus: status });
|
||||
}
|
||||
|
||||
private get peerID(): string {
|
||||
return `${this.session.projectKey}-${this.session.sessionId}`
|
||||
}
|
||||
|
|
@ -126,7 +144,7 @@ export default class AssistManager {
|
|||
console.error("AssistManager: trying to connect more than once");
|
||||
return;
|
||||
}
|
||||
this.md.setMessagesLoading(true);
|
||||
this.setStatus(ConnectionStatus.Connecting)
|
||||
import('peerjs').then(({ default: Peer }) => {
|
||||
// @ts-ignore
|
||||
const peer = new Peer({
|
||||
|
|
@ -139,16 +157,16 @@ export default class AssistManager {
|
|||
peer.on('error', e => {
|
||||
if (['peer-unavailable', 'network'].includes(e.type)) {
|
||||
if (this.peer && this.connectionAttempts++ < MAX_RECONNECTION_COUNT) {
|
||||
update({ peerConnectionStatus: ConnectionStatus.Connecting });
|
||||
this.setStatus(ConnectionStatus.Connecting);
|
||||
console.log("peerunavailable")
|
||||
this.connectToPeer();
|
||||
} else {
|
||||
update({ peerConnectionStatus: ConnectionStatus.Disconnected });
|
||||
this.setStatus(ConnectionStatus.Disconnected);
|
||||
this.dataCheckIntervalID && clearInterval(this.dataCheckIntervalID);
|
||||
}
|
||||
} else {
|
||||
console.error(`PeerJS error (on peer). Type ${e.type}`, e);
|
||||
update({ peerConnectionStatus: ConnectionStatus.Error })
|
||||
this.setStatus(ConnectionStatus.Error)
|
||||
}
|
||||
})
|
||||
peer.on("open", () => {
|
||||
|
|
@ -163,7 +181,7 @@ export default class AssistManager {
|
|||
private dataCheckIntervalID: ReturnType<typeof setInterval> | undefined;
|
||||
private connectToPeer() {
|
||||
if (!this.peer) { return; }
|
||||
update({ peerConnectionStatus: ConnectionStatus.Connecting })
|
||||
this.setStatus(ConnectionStatus.Connecting);
|
||||
const id = this.peerID;
|
||||
console.log("trying to connect to", id)
|
||||
const conn = this.peer.connect(id, { serialization: 'json', reliable: true});
|
||||
|
|
@ -174,14 +192,14 @@ export default class AssistManager {
|
|||
let i = 0;
|
||||
let firstMessage = true;
|
||||
|
||||
update({ peerConnectionStatus: ConnectionStatus.Connected })
|
||||
this.setStatus(ConnectionStatus.WaitingMessages)
|
||||
|
||||
conn.on('data', (data) => {
|
||||
if (!Array.isArray(data)) { return this.handleCommand(data); }
|
||||
this.mesagesRecieved = true;
|
||||
if (firstMessage) {
|
||||
firstMessage = false;
|
||||
this.md.setMessagesLoading(false);
|
||||
this.setStatus(ConnectionStatus.Connected)
|
||||
}
|
||||
|
||||
let time = 0;
|
||||
|
|
@ -230,8 +248,7 @@ export default class AssistManager {
|
|||
|
||||
const onDataClose = () => {
|
||||
this.initiateCallEnd();
|
||||
this.md.setMessagesLoading(true);
|
||||
update({ peerConnectionStatus: ConnectionStatus.Connecting });
|
||||
this.setStatus(ConnectionStatus.Connecting);
|
||||
console.log('closed peer conn. Reconnecting...')
|
||||
this.connectToPeer();
|
||||
}
|
||||
|
|
@ -244,7 +261,7 @@ export default class AssistManager {
|
|||
conn.on('close', onDataClose);// Does it work ?
|
||||
conn.on("error", (e) => {
|
||||
console.log("PeerJS connection error", e);
|
||||
update({ peerConnectionStatus: ConnectionStatus.Error });
|
||||
this.setStatus(ConnectionStatus.Error);
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -304,7 +321,7 @@ export default class AssistManager {
|
|||
// @ts-ignore
|
||||
this.md.display(false);
|
||||
this.dataConnection?.close();
|
||||
update({ peerConnectionStatus: ConnectionStatus.Disconnected });
|
||||
this.setStatus(ConnectionStatus.Disconnected);
|
||||
}, 8000); // TODO: more convenient way
|
||||
//this.dataConnection?.close();
|
||||
return;
|
||||
|
|
@ -313,7 +330,7 @@ export default class AssistManager {
|
|||
return;
|
||||
case "call_error":
|
||||
this.onTrackerCallEnd();
|
||||
update({ peerConnectionStatus: ConnectionStatus.Error });
|
||||
this.setStatus(ConnectionStatus.Error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ export const ID_TP_MAP = {
|
|||
22: "console_log",
|
||||
37: "css_insert_rule",
|
||||
38: "css_delete_rule",
|
||||
39: "fetch_depricated",
|
||||
39: "fetch",
|
||||
40: "profiler",
|
||||
41: "o_table",
|
||||
44: "redux",
|
||||
|
|
@ -36,7 +36,6 @@ export const ID_TP_MAP = {
|
|||
54: "connection_information",
|
||||
55: "set_page_visibility",
|
||||
59: "long_task",
|
||||
68: "fetch",
|
||||
} as const;
|
||||
|
||||
|
||||
|
|
@ -167,8 +166,8 @@ export interface CssDeleteRule {
|
|||
index: number,
|
||||
}
|
||||
|
||||
export interface FetchDepricated {
|
||||
tp: "fetch_depricated",
|
||||
export interface Fetch {
|
||||
tp: "fetch",
|
||||
method: string,
|
||||
url: string,
|
||||
request: string,
|
||||
|
|
@ -256,20 +255,8 @@ export interface LongTask {
|
|||
containerName: string,
|
||||
}
|
||||
|
||||
export interface Fetch {
|
||||
tp: "fetch",
|
||||
method: string,
|
||||
url: string,
|
||||
request: string,
|
||||
response: string,
|
||||
status: number,
|
||||
timestamp: number,
|
||||
duration: number,
|
||||
headers: string,
|
||||
}
|
||||
|
||||
|
||||
export type Message = Timestamp | SetPageLocation | SetViewportSize | SetViewportScroll | CreateDocument | CreateElementNode | CreateTextNode | MoveNode | RemoveNode | SetNodeAttribute | RemoveNodeAttribute | SetNodeData | SetCssData | SetNodeScroll | SetInputValue | SetInputChecked | MouseMove | ConsoleLog | CssInsertRule | CssDeleteRule | FetchDepricated | Profiler | OTable | Redux | Vuex | MobX | NgRx | GraphQl | PerformanceTrack | ConnectionInformation | SetPageVisibility | LongTask | Fetch;
|
||||
export type Message = Timestamp | SetPageLocation | SetViewportSize | SetViewportScroll | CreateDocument | CreateElementNode | CreateTextNode | MoveNode | RemoveNode | SetNodeAttribute | RemoveNodeAttribute | SetNodeData | SetCssData | SetNodeScroll | SetInputValue | SetInputChecked | MouseMove | ConsoleLog | CssInsertRule | CssDeleteRule | Fetch | Profiler | OTable | Redux | Vuex | MobX | NgRx | GraphQl | PerformanceTrack | ConnectionInformation | SetPageVisibility | LongTask;
|
||||
|
||||
export default function (r: PrimitiveReader): Message | null {
|
||||
switch (r.readUint()) {
|
||||
|
|
@ -522,19 +509,6 @@ export default function (r: PrimitiveReader): Message | null {
|
|||
containerName: r.readString(),
|
||||
};
|
||||
|
||||
case 68:
|
||||
return {
|
||||
tp: ID_TP_MAP[68],
|
||||
method: r.readString(),
|
||||
url: r.readString(),
|
||||
request: r.readString(),
|
||||
response: r.readString(),
|
||||
status: r.readUint(),
|
||||
timestamp: r.readUint(),
|
||||
duration: r.readUint(),
|
||||
headers: r.readString(),
|
||||
};
|
||||
|
||||
default:
|
||||
r.readUint(); // IOS skip timestamp
|
||||
r.skip(r.readUint());
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue