Merge pull request #117 from openreplay/dev

v1.3.0
This commit is contained in:
Kraiem Taha Yassine 2021-08-04 23:29:36 +02:00 committed by GitHub
commit 91b4abbaa5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
119 changed files with 34849 additions and 861 deletions

View file

@ -52,6 +52,8 @@
"S3_HOST": "", "S3_HOST": "",
"S3_KEY": "", "S3_KEY": "",
"S3_SECRET": "", "S3_SECRET": "",
"invitation_link": "/api/users/invitation?token=%s",
"change_password_link": "/reset-password?invitation=%s&&pass=%s",
"version_number": "1.2.0" "version_number": "1.2.0"
}, },
"lambda_timeout": 150, "lambda_timeout": 150,

View file

@ -60,7 +60,7 @@ _overrides.chalice_app(app)
def or_middleware(event, get_response): def or_middleware(event, get_response):
global OR_SESSION_TOKEN global OR_SESSION_TOKEN
OR_SESSION_TOKEN = app.current_request.headers.get('vnd.openreplay.com.sid', 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: if "authorizer" in event.context and event.context["authorizer"] is None:
print("Deleted user!!") print("Deleted user!!")
pg_client.close() pg_client.close()
@ -71,7 +71,13 @@ def or_middleware(event, get_response):
import time import time
now = int(time.time() * 1000) now = int(time.time() * 1000)
response = get_response(event) 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: with configure_scope() as scope:
scope.set_tag('stage', environ["stage"]) scope.set_tag('stage', environ["stage"])
scope.set_tag('openReplaySessionToken', OR_SESSION_TOKEN) scope.set_tag('openReplaySessionToken', OR_SESSION_TOKEN)

View file

@ -18,17 +18,19 @@ check_prereq() {
} }
function build_api(){ function build_api(){
tag=""
# Copy enterprise code # Copy enterprise code
[[ $1 == "ee" ]] && { [[ $1 == "ee" ]] && {
cp -rf ../ee/api/* ./ cp -rf ../ee/api/* ./
cp -rf ../ee/api/.chalice/* ./.chalice/ cp -rf ../ee/api/.chalice/* ./.chalice/
envarg="default-ee" envarg="default-ee"
tag="ee-"
} }
docker build -f ./Dockerfile --build-arg envarg=$envarg -t ${DOCKER_REPO:-'local'}/chalice:${git_sha1} . docker build -f ./Dockerfile --build-arg envarg=$envarg -t ${DOCKER_REPO:-'local'}/chalice:${git_sha1} .
[[ $PUSH_IMAGE -eq 1 ]] && { [[ $PUSH_IMAGE -eq 1 ]] && {
docker push ${DOCKER_REPO:-'local'}/chalice:${git_sha1} docker push ${DOCKER_REPO:-'local'}/chalice:${git_sha1}
docker tag ${DOCKER_REPO:-'local'}/chalice:${git_sha1} ${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:latest docker push ${DOCKER_REPO:-'local'}/chalice:${tag}latest
} }
} }

View 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_stackdriver, reset_password, sessions_favorite_viewed, \
log_tool_cloudwatch, log_tool_sentry, log_tool_sumologic, log_tools, errors, sessions, \ 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, \ 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.core.collaboration_slack import Slack
from chalicelib.utils import email_helper from chalicelib.utils import email_helper
@ -32,8 +32,10 @@ def get_favorite_sessions2(projectId, context):
def get_session2(projectId, sessionId, 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"], 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) include_fav_viewed=True, group_metadata=True)
if data is not None: if data is None:
sessions_favorite_viewed.view_session(project_id=projectId, user_id=context['userId'], session_id=sessionId) return {"errors": ["session not found"]}
sessions_favorite_viewed.view_session(project_id=projectId, user_id=context['userId'], session_id=sessionId)
return { return {
'data': data 'data': data
} }
@ -507,8 +509,8 @@ def reset_password_handler(step):
if "email" not in data or len(data["email"]) < 5: if "email" not in data or len(data["email"]) < 5:
return {"errors": ["please provide a valid email address"]} return {"errors": ["please provide a valid email address"]}
return reset_password.step1(data) return reset_password.step1(data)
elif step == "2": # elif step == "2":
return reset_password.step2(data) # return reset_password.step2(data)
@app.route('/{projectId}/metadata', methods=['GET']) @app.route('/{projectId}/metadata', methods=['GET'])
@ -585,9 +587,8 @@ def async_basic_emails(step):
if data.pop("auth") != environ["async_Token"]: if data.pop("auth") != environ["async_Token"]:
return {} return {}
if step.lower() == "member_invitation": if step.lower() == "member_invitation":
email_helper.send_team_invitation(recipient=data["email"], user_name=data["userName"], email_helper.send_team_invitation(recipient=data["email"], invitation_link=data["invitationLink"],
temp_password=data["tempPassword"], client_id=data["clientId"], client_id=data["clientId"], sender_name=data["senderName"])
sender_name=data["senderName"])
@app.route('/{projectId}/sample_rate', methods=['GET']) @app.route('/{projectId}/sample_rate', methods=['GET'])
@ -724,10 +725,10 @@ def get_funnel_insights(projectId, funnelId, context):
if params is None: if params is None:
params = {} params = {}
return {"data": funnels.get_top_insights(funnel_id=funnelId, project_id=projectId, return funnels.get_top_insights(funnel_id=funnelId, project_id=projectId,
range_value=params.get("range_value", None), range_value=params.get("range_value", None),
start_date=params.get('startDate', None), start_date=params.get('startDate', None),
end_date=params.get('endDate', None))} end_date=params.get('endDate', None))
@app.route('/{projectId}/funnels/{funnelId}/insights', methods=['POST', 'PUT']) @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: if data is None:
data = {} data = {}
return { return funnels.get_top_insights_on_the_fly(funnel_id=funnelId, project_id=projectId, data={**params, **data})
"data": funnels.get_top_insights_on_the_fly(funnel_id=funnelId, project_id=projectId, data={**params, **data})}
@app.route('/{projectId}/funnels/{funnelId}/issues', methods=['GET']) @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']) @app.route('/{projectId}/funnels/{funnelId}', methods=['GET'])
def get_funnel(projectId, funnelId, context): def get_funnel(projectId, funnelId, context):
return {"data": funnels.get(funnel_id=funnelId, data = funnels.get(funnel_id=funnelId,
project_id=projectId)} project_id=projectId)
if data is None:
return {"errors": ["funnel not found"]}
return data
@app.route('/{projectId}/funnels/{funnelId}', methods=['POST', 'PUT']) @app.route('/{projectId}/funnels/{funnelId}', methods=['POST', 'PUT'])
@ -882,3 +885,9 @@ def removed_endpoints(projectId=None, context=None):
def sessions_live(projectId, context): def sessions_live(projectId, context):
data = assist.get_live_sessions(projectId) data = assist.get_live_sessions(projectId)
return {'data': data} 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)}

View file

@ -12,11 +12,6 @@ def run_scheduled_jobs(event):
jobs.execute_jobs() jobs.execute_jobs()
@app.schedule(Cron('0/60', '*', '*', '*', '?', '*'))
def clear_password_reset(event):
reset_password.cron()
# Run every monday. # Run every monday.
@app.schedule(Cron('5', '0', '?', '*', 'MON', '*')) @app.schedule(Cron('5', '0', '?', '*', 'MON', '*'))
def weekly_report2(event): def weekly_report2(event):

View file

@ -38,9 +38,9 @@ def login():
for_plugin=False for_plugin=False
) )
if r is None: if r is None:
return { return Response(status_code=401, body={
'errors': ['Youve entered invalid Email or Password.'] 'errors': ['Youve entered invalid Email or Password.']
} })
tenant_id = r.pop("tenantId") tenant_id = r.pop("tenantId")
@ -53,11 +53,12 @@ def login():
c.pop("createdAt") c.pop("createdAt")
c["projects"] = projects.get_projects(tenant_id=tenant_id, recording_state=True, recorded=True, c["projects"] = projects.get_projects(tenant_id=tenant_id, recording_state=True, recorded=True,
stack_integrations=True) stack_integrations=True)
c["smtp"] = helper.has_smtp()
return { return {
'jwt': r.pop('jwt'), 'jwt': r.pop('jwt'),
'data': { 'data': {
"user": r, "user": r,
"client": c, "client": c
} }
} }
@ -74,7 +75,7 @@ def get_account(context):
"metadata": metadata.get_remaining_metadata_with_count(context['tenantId']) "metadata": metadata.get_remaining_metadata_with_count(context['tenantId'])
}, },
**license.get_status(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']) @app.route('/projects/{projectId}', methods=['GET'])
def get_project(projectId, context): def get_project(projectId, context):
return {"data": projects.get_project(tenant_id=context["tenantId"], project_id=projectId, include_last_session=True, data = projects.get_project(tenant_id=context["tenantId"], project_id=projectId, include_last_session=True,
include_gdpr=True)} include_gdpr=True)
if data is None:
return {"errors": ["project not found"]}
return {"data": data}
@app.route('/projects/{projectId}', methods=['DELETE']) @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) 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']) @app.route('/client/members/{memberId}', methods=['PUT', 'POST'])
def edit_member(memberId, context): def edit_member(memberId, context):
data = app.current_request.json_body data = app.current_request.json_body
@ -360,6 +396,11 @@ def edit_member(memberId, context):
user_id_to_update=memberId) 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']) @app.route('/client/members/{memberId}', methods=['DELETE'])
def delete_member(memberId, context): def delete_member(memberId, context):
return users.delete_member(tenant_id=context["tenantId"], user_id=context['userId'], id_to_delete=memberId) return users.delete_member(tenant_id=context["tenantId"], user_id=context['userId'], id_to_delete=memberId)

View file

@ -239,7 +239,7 @@ def get_details(project_id, error_id, user_id, **data):
cur.execute(cur.mogrify(main_pg_query, params)) cur.execute(cur.mogrify(main_pg_query, params))
row = cur.fetchone() row = cur.fetchone()
if row is None: if row is None:
return {"errors": ["error doesn't exist"]} return {"errors": ["error not found"]}
row["tags"] = __process_tags(row) row["tags"] = __process_tags(row)
query = cur.mogrify( 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)) cur.execute(cur.mogrify(main_pg_query, params))
row = cur.fetchone() row = cur.fetchone()
if row is None: if row is None:
return {"errors": ["error doesn't exist"]} return {"errors": ["error not found"]}
row["tags"] = __process_tags(row) row["tags"] = __process_tags(row)
return {"data": helper.dict_to_camel_case(row)} return {"data": helper.dict_to_camel_case(row)}

View file

@ -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): 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) f = get(funnel_id=funnel_id, project_id=project_id)
if f is None: 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) 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) 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): 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) f = get(funnel_id=funnel_id, project_id=project_id)
if f is None: 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) 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, 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 insights[-1]["dropDueToIssues"] = total_drop_due_to_issues
return {"stages": helper.list_to_camel_case(insights), return {"data": {"stages": helper.list_to_camel_case(insights),
"totalDropDueToIssues": total_drop_due_to_issues} "totalDropDueToIssues": total_drop_due_to_issues}}
def get_top_insights_on_the_fly(funnel_id, project_id, data): 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) insights, total_drop_due_to_issues = significance.get_top_insights(filter_d=data, project_id=project_id)
if len(insights) > 0: if len(insights) > 0:
insights[-1]["dropDueToIssues"] = total_drop_due_to_issues insights[-1]["dropDueToIssues"] = total_drop_due_to_issues
return {"stages": helper.list_to_camel_case(insights), return {"data": {"stages": helper.list_to_camel_case(insights),
"totalDropDueToIssues": total_drop_due_to_issues} "totalDropDueToIssues": total_drop_due_to_issues}}
def get_issues(project_id, funnel_id, range_value=None, start_date=None, end_date=None): def get_issues(project_id, funnel_id, range_value=None, start_date=None, end_date=None):

View 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)

View file

@ -1,6 +1,5 @@
from chalicelib.utils import pg_client, helper, dev from chalicelib.utils import pg_client, helper, dev
from chalicelib.core import projects from chalicelib.core import projects
import re import re
@ -24,9 +23,10 @@ def get(project_id):
) )
metas = cur.fetchone() metas = cur.fetchone()
results = [] results = []
for i, k in enumerate(metas.keys()): if metas is not None:
if metas[k] is not None: for i, k in enumerate(metas.keys()):
results.append({"key": metas[k], "index": i + 1}) if metas[k] is not None:
results.append({"key": metas[k], "index": i + 1})
return results return results
@ -56,7 +56,7 @@ def __edit(project_id, col_index, colname, new_name):
old_metas = get(project_id) old_metas = get(project_id)
old_metas = {k["index"]: k for k in old_metas} old_metas = {k["index"]: k for k in old_metas}
if col_index not in list(old_metas.keys()): 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: with pg_client.PostgresClient() as cur:
if old_metas[col_index]["key"].lower() != new_name: 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 = get(project_id)
old_segments = [k["index"] for k in old_segments] old_segments = [k["index"] for k in old_segments]
if index not 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: with pg_client.PostgresClient() as cur:
colname = index_to_colname(index) colname = index_to_colname(index)

View file

@ -18,48 +18,23 @@ def step1(data):
a_users = users.get_by_email_only(data["email"]) a_users = users.get_by_email_only(data["email"])
if len(a_users) > 1: if len(a_users) > 1:
print(f"multiple users found for [{data['email']}] please contact our support") 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: elif len(a_users) == 1:
a_users = a_users[0] a_users = a_users[0]
reset_token = secrets.token_urlsafe(6) invitation_link=users.generate_new_invitation(user_id=a_users["id"])
users.update(tenant_id=a_users["tenantId"], user_id=a_users["id"], email_helper.send_forgot_password(recipient=data["email"], invitation_link=invitation_link)
changes={"token": reset_token})
email_helper.send_reset_code(recipient=data["email"], reset_code=reset_token)
else: else:
print(f"invalid email address [{data['email']}]") print(f"invalid email address [{data['email']}]")
return {"errors": ["invalid email address"]} return {"errors": ["invalid email address"]}
return {"data": {"state": "success"}} return {"data": {"state": "success"}}
def step2(data): # def step2(data):
print("====================== change password 2 ===============") # print("====================== change password 2 ===============")
user = users.get_by_email_reset(data["email"], data["code"]) # user = users.get_by_email_reset(data["email"], data["code"])
if not user: # if not user:
print("error: wrong email or reset code") # print("error: wrong email or reset code")
return {"errors": ["wrong email or reset code"]} # return {"errors": ["wrong email or reset code"]}
users.update(tenant_id=user["tenantId"], user_id=user["id"], # users.update(tenant_id=user["tenantId"], user_id=user["id"],
changes={"token": None, "password": data["password"], "generatedPassword": False}) # changes={"token": None, "password": data["password"], "generatedPassword": False})
return {"data": {"state": "success"}} # 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})
)

View file

@ -3,30 +3,29 @@ from chalicelib.core import events, sessions_metas, socket_ios, metadata, events
sessions_mobs, issues, projects, errors, resources, assist sessions_mobs, issues, projects, errors, resources, assist
SESSION_PROJECTION_COLS = """s.project_id, SESSION_PROJECTION_COLS = """s.project_id,
s.session_id::text AS session_id, s.session_id::text AS session_id,
s.user_uuid, s.user_uuid,
s.user_id, s.user_id,
s.user_agent, s.user_agent,
s.user_os, s.user_os,
s.user_browser, s.user_browser,
s.user_device, s.user_device,
s.user_device_type, s.user_device_type,
s.user_country, s.user_country,
s.start_ts, s.start_ts,
s.duration, s.duration,
s.events_count, s.events_count,
s.pages_count, s.pages_count,
s.errors_count, s.errors_count,
s.user_anonymous_id, s.user_anonymous_id,
s.platform, s.platform,
s.issue_score, s.issue_score,
to_jsonb(s.issue_types) AS issue_types, to_jsonb(s.issue_types) AS issue_types,
favorite_sessions.session_id NOTNULL AS favorite, favorite_sessions.session_id NOTNULL AS favorite,
COALESCE((SELECT TRUE COALESCE((SELECT TRUE
FROM public.user_viewed_sessions AS fs FROM public.user_viewed_sessions AS fs
WHERE s.session_id = fs.session_id WHERE s.session_id = fs.session_id
AND fs.user_id = %(userId)s LIMIT 1), FALSE) AS viewed AND fs.user_id = %(userId)s LIMIT 1), FALSE) AS viewed """
"""
def __group_metadata(session, project_metadata): def __group_metadata(session, project_metadata):
@ -120,7 +119,14 @@ new_line = "\n"
def __get_sql_operator(op): def __get_sql_operator(op):
op = op.lower() 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): 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" 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})) extra_constraints.append(cur.mogrify("fs.user_id = %(userId)s", {"userId": user_id}))
events_query_part = "" events_query_part = ""
strict = True
if len(data.get("events", [])) > 0: if len(data.get("events", [])) > 0:
events_query_from = [] events_query_from = []
event_index = 0 event_index = 0
for event in data["events"]: for event in data["events"]:
# TODO: remove this when message_id is removed
seq_id = False
event_type = event["type"].upper() event_type = event["type"].upper()
if event.get("operator") is None: if event.get("operator") is None:
event["operator"] = "is" event["operator"] = "is"
op = __get_sql_operator(event["operator"]) op = __get_sql_operator(event["operator"])
is_not = False is_not = False
if __is_negation_operator(op) and event_index > 0: if __is_negation_operator(op):
is_not = True is_not = True
op = __reverse_sql_operator(op) op = __reverse_sql_operator(op)
event_from = "%s INNER JOIN public.sessions AS ms USING (session_id)" if event_index == 0:
event_where = ["ms.project_id = %(projectId)s", "main.timestamp >= %(startDate)s", event_from = "%s INNER JOIN public.sessions AS ms USING (session_id)"
"main.timestamp <= %(endDate)s", "ms.start_ts >= %(startDate)s", event_where = ["ms.project_id = %(projectId)s", "main.timestamp >= %(startDate)s",
"ms.start_ts <= %(endDate)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)} event_args = {"value": helper.string_to_sql_like_with_op(event['value'], op)}
if event_type not in list(events.SUPPORTED_TYPES.keys()) \ if event_type not in list(events.SUPPORTED_TYPES.keys()) \
or event.get("value") in [None, "", "*"] \ 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_from = event_from % f"{events.event_type.LOCATION.table} AS main "
event_where.append(f"main.{events.event_type.LOCATION.column} {op} %(value)s") event_where.append(f"main.{events.event_type.LOCATION.column} {op} %(value)s")
elif event_type == events.event_type.CUSTOM.ui_type: 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_from = event_from % f"{events.event_type.CUSTOM.table} AS main "
event_where.append(f"main.{events.event_type.CUSTOM.column} {op} %(value)s") event_where.append(f"main.{events.event_type.CUSTOM.column} {op} %(value)s")
elif event_type == events.event_type.REQUEST.ui_type: 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_from = event_from % f"{events.event_type.REQUEST.table} AS main "
event_where.append(f"main.{events.event_type.REQUEST.column} {op} %(value)s") event_where.append(f"main.{events.event_type.REQUEST.column} {op} %(value)s")
elif event_type == events.event_type.GRAPHQL.ui_type: 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 # ----- IOS
elif event_type == events.event_type.CLICK_IOS.ui_type: 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_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") event_where.append(f"main.{events.event_type.CLICK_IOS.column} {op} %(value)s")
elif event_type == events.event_type.INPUT_IOS.ui_type: 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_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") 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_where.append("main.value ILIKE %(custom)s")
event_args["custom"] = helper.string_to_sql_like_with_op(event['custom'], "ILIKE") event_args["custom"] = helper.string_to_sql_like_with_op(event['custom'], "ILIKE")
elif event_type == events.event_type.VIEW_IOS.ui_type: 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_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") event_where.append(f"main.{events.event_type.VIEW_IOS.column} {op} %(value)s")
elif event_type == events.event_type.CUSTOM_IOS.ui_type: 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_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") event_where.append(f"main.{events.event_type.CUSTOM_IOS.column} {op} %(value)s")
elif event_type == events.event_type.REQUEST_IOS.ui_type: 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_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") event_where.append(f"main.{events.event_type.REQUEST_IOS.column} {op} %(value)s")
elif event_type == events.event_type.ERROR_IOS.ui_type: 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)" 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, "*", ""]: if event.get("value") not in [None, "*", ""]:
event_where.append(f"(main1.reason {op} %(value)s OR main1.name {op} %(value)s)") 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: else:
continue continue
event_index += 1
if is_not: 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)""" if event_index == 0:
event_where[-1] = "left_not.session_id ISNULL" events_query_from.append(cur.mogrify(f"""\
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 (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} FROM {event_from}
WHERE {" AND ".join(event_where)} 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')) """, {**generic_args, **event_args}).decode('UTF-8'))
event_index += 1
if len(events_query_from) > 0: if event_index > 0:
events_query_part = f"""\ events_query_part = f"""SELECT
SELECT event_0.session_id,
session_id, MIN(timestamp) AS first_event_ts, MAX(timestamp) AS last_event_ts MIN(event_0.timestamp) AS first_event_ts,
FROM MAX(event_{event_index - 1}.timestamp) AS last_event_ts
({(" UNION ALL ").join(events_query_from)}) AS f_query FROM {(" INNER JOIN LATERAL ").join(events_query_from)}
GROUP BY 1 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 {fav_only_join}"""
else f"HAVING array_length(array_agg(DISTINCT funnel_step), 1) = {len(data['events'])}"}
{fav_only_join}
"""
else: else:
data["events"] = [] data["events"] = []
@ -423,8 +445,7 @@ def search2_pg(data, project_id, user_id, favorite_only=False, errors_only=False
{" AND ".join(extra_constraints)}""" {" AND ".join(extra_constraints)}"""
if errors_only: if errors_only:
main_query = cur.mogrify(f"""\ main_query = cur.mogrify(f"""SELECT DISTINCT er.error_id, ser.status, ser.parent_error_id, ser.payload,
SELECT DISTINCT er.error_id, ser.status, ser.parent_error_id, ser.payload,
COALESCE((SELECT TRUE COALESCE((SELECT TRUE
FROM public.user_favorite_sessions AS fs FROM public.user_favorite_sessions AS fs
WHERE s.session_id = fs.session_id 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) generic_args)
elif count_only: elif count_only:
main_query = cur.mogrify(f"""\ main_query = cur.mogrify(
SELECT COUNT(DISTINCT s.session_id) AS count_sessions, COUNT(DISTINCT s.user_uuid) AS count_users f"""SELECT COUNT(DISTINCT s.session_id) AS count_sessions, COUNT(DISTINCT s.user_uuid) AS count_users
{query_part};""", {query_part};""",
generic_args) generic_args)
else: else:
main_query = cur.mogrify(f"""\ main_query = cur.mogrify(f"""SELECT * FROM
SELECT * FROM
(SELECT DISTINCT ON(s.session_id) {SESSION_PROJECTION_COLS} (SELECT DISTINCT ON(s.session_id) {SESSION_PROJECTION_COLS}
{query_part} {query_part}
ORDER BY s.session_id desc) AS filtred_sessions ORDER BY s.session_id desc) AS filtred_sessions

View file

@ -62,7 +62,7 @@ def create_step1(data):
errors.append("Tenant already exists, please select it from dropdown") errors.append("Tenant already exists, please select it from dropdown")
elif len(signed_ups) == 0 and data.get("tenantId") is not None \ 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]: 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: if len(errors) > 0:
print("==> error") print("==> error")

View file

@ -9,20 +9,26 @@ from chalicelib.utils.TimeUTC import TimeUTC
from chalicelib.utils.helper import environ from chalicelib.utils.helper import environ
from chalicelib.core import tenants 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: with pg_client.PostgresClient() as cur:
query = cur.mogrify(f"""\ query = cur.mogrify(f"""\
WITH u AS ( WITH u AS (INSERT INTO public.users (email, role, name, data)
INSERT INTO public.users (email, role, name, data) VALUES (%(email)s, %(role)s, %(name)s, %(data)s)
VALUES (%(email)s, %(role)s, %(name)s, %(data)s) RETURNING user_id,email,role,name,appearance
RETURNING user_id,email,role,name,appearance ),
), au AS (INSERT INTO public.basic_authentication (user_id, generated_password, invitation_token, invited_at)
au AS (INSERT VALUES ((SELECT user_id FROM u), TRUE, %(invitation_token)s, timezone('utc'::text, now()))
INTO public.basic_authentication (user_id, password, generated_password) RETURNING invitation_token
VALUES ((SELECT user_id FROM u), crypt(%(password)s, gen_salt('bf', 12)), TRUE)) )
SELECT u.user_id AS id, SELECT u.user_id,
u.user_id AS id,
u.email, u.email,
u.role, u.role,
u.name, 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 = '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 = 'admin' THEN TRUE ELSE FALSE END) AS admin,
(CASE WHEN u.role = 'member' THEN TRUE ELSE FALSE END) AS member, (CASE WHEN u.role = 'member' THEN TRUE ELSE FALSE END) AS member,
u.appearance au.invitation_token
FROM u;""", FROM u,au;""",
{"email": email, "password": password, {"email": email, "role": "owner" if owner else "admin" if admin else "member", "name": name,
"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( cur.execute(
query query
) )
return helper.dict_to_camel_case(cur.fetchone()) 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: with pg_client.PostgresClient() as cur:
query = cur.mogrify(f"""\ query = cur.mogrify(f"""\
UPDATE public.users UPDATE public.users
@ -58,31 +64,62 @@ def restore_member(user_id, email, password, admin, name, owner=False):
TRUE AS change_password, TRUE AS change_password,
(CASE WHEN role = 'owner' THEN TRUE ELSE FALSE END) AS super_admin, (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 = 'admin' THEN TRUE ELSE FALSE END) AS admin,
(CASE WHEN role = 'member' THEN TRUE ELSE FALSE END) AS member, (CASE WHEN role = 'member' THEN TRUE ELSE FALSE END) AS member;""",
appearance;""",
{"user_id": user_id, "email": email, {"user_id": user_id, "email": email,
"role": "owner" if owner else "admin" if admin else "member", "name": name}) "role": "owner" if owner else "admin" if admin else "member", "name": name})
cur.execute( cur.execute(
query query
) )
result = helper.dict_to_camel_case(cur.fetchone()) result = cur.fetchone()
query = cur.mogrify("""\ query = cur.mogrify("""\
UPDATE public.basic_authentication UPDATE public.basic_authentication
SET password= crypt(%(password)s, gen_salt('bf', 12)), SET generated_password = TRUE,
generated_password= TRUE, invitation_token = %(invitation_token)s,
token=NULL, invited_at = timezone('utc'::text, now()),
token_requested_at=NULL change_pwd_expire_at = NULL,
WHERE user_id=%(user_id)s;""", change_pwd_token = NULL
{"user_id": user_id, "password": password}) WHERE user_id=%(user_id)s
RETURNING invitation_token;""",
{"user_id": user_id, "invitation_token": invitation_token})
cur.execute( cur.execute(
query 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): 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: if len(changes.keys()) == 0:
return None return None
@ -93,13 +130,6 @@ def update(tenant_id, user_id, changes):
if key == "password": if key == "password":
sub_query_bauth.append("password = crypt(%(password)s, gen_salt('bf', 12))") sub_query_bauth.append("password = crypt(%(password)s, gen_salt('bf', 12))")
sub_query_bauth.append("changed_at = timezone('utc'::text, now())") 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: else:
sub_query_bauth.append(f"{helper.key_to_snake_case(key)} = %({key})s") sub_query_bauth.append(f"{helper.key_to_snake_case(key)} = %({key})s")
else: else:
@ -166,26 +196,43 @@ def create_member(tenant_id, user_id, data):
return {"errors": ["invalid user name"]} return {"errors": ["invalid user name"]}
if name is None: if name is None:
name = data["email"] name = data["email"]
temp_pass = helper.generate_salt()[:8] invitation_token = __generate_invitation_token()
user = get_deleted_user_by_email(email=data["email"]) user = get_deleted_user_by_email(email=data["email"])
if user is not None: 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"]) admin=data.get("admin", False), name=name, user_id=user["userId"])
else: 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) 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', helper.async_post(environ['email_basic'] % 'member_invitation',
{ {
"email": data["email"], "email": data["email"],
"userName": data["email"], "invitationLink": new_member["invitationLink"],
"tempPassword": temp_pass,
"clientId": tenants.get_by_tenant_id(tenant_id)["name"], "clientId": tenants.get_by_tenant_id(tenant_id)["name"],
"senderName": admin["name"] "senderName": admin["name"]
}) })
return {"data": new_member} 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): def get(user_id, tenant_id):
with pg_client.PostgresClient() as cur: with pg_client.PostgresClient() as cur:
cur.execute( cur.execute(
@ -317,14 +364,24 @@ def get_members(tenant_id):
basic_authentication.generated_password, basic_authentication.generated_password,
(CASE WHEN users.role = 'owner' THEN TRUE ELSE FALSE END) AS super_admin, (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 = '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 FROM public.users LEFT JOIN public.basic_authentication ON users.user_id=basic_authentication.user_id
WHERE users.deleted_at IS NULL WHERE users.deleted_at IS NULL
ORDER BY name, id""" ORDER BY name, id"""
) )
r = cur.fetchall() r = cur.fetchall()
if len(r): 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 [] return []
@ -367,6 +424,15 @@ def change_password(tenant_id, user_id, email, old_password, new_password):
"jwt": authenticate(email, new_password)["jwt"]} "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(): def count_members():
with pg_client.PostgresClient() as cur: with pg_client.PostgresClient() as cur:
cur.execute("""SELECT COUNT(user_id) cur.execute("""SELECT COUNT(user_id)
@ -409,6 +475,24 @@ def get_deleted_user_by_email(email):
return helper.dict_to_camel_case(r) 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): def auth_exists(user_id, tenant_id, jwt_iat, jwt_aud):
with pg_client.PostgresClient() as cur: with pg_client.PostgresClient() as cur:
cur.execute( cur.execute(

View file

@ -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 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", BODY_HTML = __get_html_from_file("chalicelib/utils/html/invitation.html",
formatting_variables={"userName": __escape_text_html(user_name), formatting_variables={"invitationLink": invitation_link,
"password": temp_password, "clientId": client_id, "clientId": client_id,
"sender": sender_name}) "sender": sender_name})
SUBJECT = "Welcome to OpenReplay" SUBJECT = "Welcome to OpenReplay"
send_html(BODY_HTML, SUBJECT, recipient) 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", 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" SUBJECT = "Password recovery"
send_html(BODY_HTML, SUBJECT, recipient) send_html(BODY_HTML, SUBJECT, recipient)

View file

@ -363,3 +363,7 @@ def get_internal_project_id(project_id64):
return None return None
project_id = int(project_id64) project_id = int(project_id64)
return project_id return project_id
def has_smtp():
return environ["EMAIL_HOST"] is not None and len(environ["EMAIL_HOST"]) > 0

View file

@ -447,10 +447,10 @@ width: 25%!important
<p style="font-size: 14px; line-height: 16px; text-align: center; margin: 0;"> <p style="font-size: 14px; line-height: 16px; text-align: center; margin: 0;">
Please use this link to login:</p> Please use this link to login:</p>
<p style="font-size: 14px; line-height: 21px; text-align: center; margin: 0;"> <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" rel="noopener"
style="text-decoration: underline; color: #009193;" 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> style="font-size: 18px; line-height: 21px;"></span></p>
</div> </div>
</div> </div>
@ -485,40 +485,18 @@ width: 25%!important
<tr> <tr>
<td style="padding-right: 10px; padding-left: 10px; padding-top: 10px; padding-bottom: 10px; font-family: Arial, sans-serif"> <td style="padding-right: 10px; padding-left: 10px; padding-top: 10px; padding-bottom: 10px; font-family: Arial, sans-serif">
<![endif]--> <![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]></td></tr></table><![endif]-->
<!--[if mso]> <!--[if mso]>
<table width="100%" cellpadding="0" cellspacing="0" border="0"> <table width="100%" cellpadding="0" cellspacing="0" border="0">
<tr> <tr>
<td style="padding-right: 10px; padding-left: 10px; padding-top: 10px; padding-bottom: 10px; font-family: Arial, sans-serif"> <td style="padding-right: 10px; padding-left: 10px; padding-top: 10px; padding-bottom: 10px; font-family: Arial, sans-serif">
<![endif]--> <![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]></td></tr></table><![endif]-->
<!--[if mso]> <!--[if mso]>
<table width="100%" cellpadding="0" cellspacing="0" border="0"> <table width="100%" cellpadding="0" cellspacing="0" border="0">
<tr> <tr>
<td style="padding-right: 10px; padding-left: 10px; padding-top: 10px; padding-bottom: 10px; font-family: Arial, sans-serif"> <td style="padding-right: 10px; padding-left: 10px; padding-top: 10px; padding-bottom: 10px; font-family: Arial, sans-serif">
<![endif]--> <![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]--> <!--[if mso]></td></tr></table><![endif]-->
<table border="0" cellpadding="0" cellspacing="0" class="divider" <table border="0" cellpadding="0" cellspacing="0" class="divider"
role="presentation" role="presentation"

View file

@ -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="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;"> <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;"> <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;"> <p style="font-size: 14px; line-height: 21px; text-align: center; margin: 0;">
<br/> <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> style="font-size: 18px; line-height: 21px;"></span></p>
</div> </div>
</div> </div>

View file

@ -1,7 +1,3 @@
BEGIN; 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_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; COMMIT;

View file

@ -11,7 +11,7 @@ func (c *PGCache) GetProjectByKey(projectKey string) (*Project, error) {
return c.projectsByKeys[ projectKey ].Project, nil return c.projectsByKeys[ projectKey ].Project, nil
} }
p, err := c.Conn.GetProjectByKey(projectKey) p, err := c.Conn.GetProjectByKey(projectKey)
if err != nil { if p == nil {
return nil, err return nil, err
} }
c.projectsByKeys[ projectKey ] = &ProjectMeta{ p, time.Now().Add(c.projectExpirationTimeout) } 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 return c.projects[ projectID ].Project, nil
} }
p, err := c.Conn.GetProject(projectID) p, err := c.Conn.GetProject(projectID)
if err != nil { if p == nil {
return nil, err return nil, err
} }
c.projects[ projectID ] = &ProjectMeta{ p, time.Now().Add(c.projectExpirationTimeout) } c.projects[ projectID ] = &ProjectMeta{ p, time.Now().Add(c.projectExpirationTimeout) }

View file

@ -105,11 +105,14 @@ func (conn *Conn) InsertWebClickEvent(sessionID uint64, e *ClickEvent) error {
defer tx.rollback() defer tx.rollback()
if err = tx.exec(` if err = tx.exec(`
INSERT INTO events.clicks INSERT INTO events.clicks
(session_id, message_id, timestamp, label) (session_id, message_id, timestamp, label, selector, url)
VALUES (SELECT
($1, $2, $3, NULLIF($4, '')) $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 { ); err != nil {
return err return err
} }

View file

@ -18,6 +18,8 @@ func ReadBatch(b []byte, callback func(Message)) error {
} else if err != nil { } else if err != nil {
return errors.Wrapf(err, "Batch Message decoding error on message with index %v", index) return errors.Wrapf(err, "Batch Message decoding error on message with index %v", index)
} }
msg = transformDepricated(msg)
isBatchMeta := false isBatchMeta := false
switch m := msg.(type){ 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) case *BatchMeta: // Is not required to be present in batch since IOS doesn't have it (though we might change it)

View file

@ -1,3 +1,4 @@
// Auto-generated, do not edit
package messages package messages

View 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
}
}

View file

@ -350,13 +350,13 @@ p = WriteUint(msg.Y, buf, p)
return buf[:p] return buf[:p]
} }
type MouseClick struct { type MouseClickDepricated struct {
*meta *meta
ID uint64 ID uint64
HesitationTime uint64 HesitationTime uint64
Label string Label string
} }
func (msg *MouseClick) Encode() []byte{ func (msg *MouseClickDepricated) Encode() []byte{
buf := make([]byte, 31 + len(msg.Label)) buf := make([]byte, 31 + len(msg.Label))
buf[0] = 21 buf[0] = 21
p := 1 p := 1
@ -582,15 +582,17 @@ type ClickEvent struct {
Timestamp uint64 Timestamp uint64
HesitationTime uint64 HesitationTime uint64
Label string Label string
Selector string
} }
func (msg *ClickEvent) Encode() []byte{ 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 buf[0] = 33
p := 1 p := 1
p = WriteUint(msg.MessageID, buf, p) p = WriteUint(msg.MessageID, buf, p)
p = WriteUint(msg.Timestamp, buf, p) p = WriteUint(msg.Timestamp, buf, p)
p = WriteUint(msg.HesitationTime, buf, p) p = WriteUint(msg.HesitationTime, buf, p)
p = WriteString(msg.Label, buf, p) p = WriteString(msg.Label, buf, p)
p = WriteString(msg.Selector, buf, p)
return buf[:p] return buf[:p]
} }
@ -1146,6 +1148,24 @@ p = WriteString(msg.BaseURL, buf, p)
return 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 { type IOSSessionStart struct {
*meta *meta
Timestamp uint64 Timestamp uint64

View file

@ -159,7 +159,7 @@ if msg.Y, err = ReadUint(reader); err != nil { return nil, err }
return msg, nil return msg, nil
case 21: 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.ID, err = ReadUint(reader); err != nil { return nil, err }
if msg.HesitationTime, 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.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.Timestamp, err = ReadUint(reader); err != nil { return nil, err }
if msg.HesitationTime, 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.Label, err = ReadString(reader); err != nil { return nil, err }
if msg.Selector, err = ReadString(reader); err != nil { return nil, err }
return msg, nil return msg, nil
case 34: 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 } if msg.BaseURL, err = ReadString(reader); err != nil { return nil, err }
return msg, nil 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: case 90:
msg := &IOSSessionStart{ meta: &meta{ TypeID: 90} } msg := &IOSSessionStart{ meta: &meta{ TypeID: 90} }
if msg.Timestamp, err = ReadUint(reader); err != nil { return nil, err } if msg.Timestamp, err = ReadUint(reader); err != nil { return nil, err }

View file

@ -4,6 +4,7 @@ import (
"net/url" "net/url"
"path/filepath" "path/filepath"
"strconv" "strconv"
"strings"
) )
func getSessionKey(sessionID uint64) string { func getSessionKey(sessionID uint64) string {
@ -56,15 +57,16 @@ func GetFullCachableURL(baseURL string, relativeURL string) (string, bool) {
const OPENREPLAY_QUERY_START = "OPENREPLAY_QUERY" const OPENREPLAY_QUERY_START = "OPENREPLAY_QUERY"
func getCachePath(rawurl string) string { func getCachePath(rawurl string) string {
u, _ := url.Parse(rawurl) return strings.ReplaceAll(url.QueryEscape(rawurl), "%", "!") // s3 keys are ok with "!"
s := "/" + u.Scheme + "/" + u.Hostname() + u.Path // u, _ := url.Parse(rawurl)
if u.RawQuery != "" { // s := "/" + u.Scheme + "/" + u.Hostname() + u.Path
if (s[len(s) - 1] != '/') { // if u.RawQuery != "" {
s += "/" // if (s[len(s) - 1] != '/') {
} // s += "/"
s += OPENREPLAY_QUERY_START + url.PathEscape(u.RawQuery) // }
} // s += OPENREPLAY_QUERY_START + url.PathEscape(u.RawQuery)
return s // }
// return s
} }
func getCachePathWithKey(sessionID uint64, rawurl string) string { func getCachePathWithKey(sessionID uint64, rawurl string) string {

View file

@ -187,6 +187,7 @@ func (b *builder) handleMessage(message Message, messageID uint64) {
Label: msg.Label, Label: msg.Label,
HesitationTime: msg.HesitationTime, HesitationTime: msg.HesitationTime,
Timestamp: b.timestamp, Timestamp: b.timestamp,
Selector: msg.Selector,
}) })
} }
case *JSException: case *JSException:

View file

@ -7,7 +7,7 @@ import (
) )
const CLICK_TIME_DIFF = 200 const CLICK_TIME_DIFF = 300
const MIN_CLICKS_IN_A_ROW = 3 const MIN_CLICKS_IN_A_ROW = 3
type clickRageDetector struct { type clickRageDetector struct {
@ -40,7 +40,7 @@ func (crd *clickRageDetector) Build() *IssueEvent {
} }
func (crd *clickRageDetector) HandleMouseClick(msg *MouseClick, messageID uint64, timestamp uint64) *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.lastTimestamp = timestamp
crd.countsInARow += 1 crd.countsInARow += 1
return nil return nil

View file

@ -11,6 +11,7 @@ type deadClickDetector struct {
lastMouseClick *MouseClick lastMouseClick *MouseClick
lastTimestamp uint64 lastTimestamp uint64
lastMessageID uint64 lastMessageID uint64
inputIDSet map[uint64]bool
} }
@ -24,6 +25,7 @@ func (d *deadClickDetector) HandleReaction(timestamp uint64) *IssueEvent {
MessageID: d.lastMessageID, MessageID: d.lastMessageID,
} }
} }
d.inputIDSet = nil
d.lastMouseClick = nil d.lastMouseClick = nil
d.lastTimestamp = 0 d.lastTimestamp = 0
d.lastMessageID = 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 { func (d *deadClickDetector) HandleMessage(msg Message, messageID uint64, timestamp uint64) *IssueEvent {
var i *IssueEvent var i *IssueEvent
switch m := msg.(type) { 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: case *MouseClick:
i = d.HandleReaction(timestamp) i = d.HandleReaction(timestamp)
if d.inputIDSet[m.ID] { // ignore if input
return i
}
d.lastMouseClick = m d.lastMouseClick = m
d.lastTimestamp = timestamp d.lastTimestamp = timestamp
d.lastMessageID = messageID d.lastMessageID = messageID

View file

@ -61,62 +61,64 @@ func main() {
Addr: ":" + HTTP_PORT, Addr: ":" + HTTP_PORT,
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// TODO: agree with specification // TODO: agree with specification
w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "POST") w.Header().Set("Access-Control-Allow-Methods", "POST")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type,Authorization") w.Header().Set("Access-Control-Allow-Headers", "Content-Type,Authorization")
if r.Method == http.MethodOptions { if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusOK) w.Header().Set("Cache-Control", "max-age=86400")
return w.WriteHeader(http.StatusOK)
} return
}
switch r.URL.Path { switch r.URL.Path {
case "/": case "/":
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
case "/v1/web/not-started": case "/v1/web/not-started":
switch r.Method { switch r.Method {
case "POST": case http.MethodPost:
notStartedHandler(w, r) notStartedHandler(w, r)
default: default:
w.WriteHeader(http.StatusMethodNotAllowed) w.WriteHeader(http.StatusMethodNotAllowed)
} }
case "/v1/web/start": case "/v1/web/start":
switch r.Method { switch r.Method {
case "POST": case http.MethodPost:
startSessionHandlerWeb(w, r) startSessionHandlerWeb(w, r)
default: default:
w.WriteHeader(http.StatusMethodNotAllowed) w.WriteHeader(http.StatusMethodNotAllowed)
} }
case "/v1/web/i": case "/v1/web/i":
switch r.Method { switch r.Method {
case "POST": case http.MethodPost:
pushMessagesSeparatelyHandler(w, r) pushMessagesSeparatelyHandler(w, r)
default: default:
w.WriteHeader(http.StatusMethodNotAllowed) w.WriteHeader(http.StatusMethodNotAllowed)
} }
// case "/v1/ios/start": // case "/v1/ios/start":
// switch r.Method { // switch r.Method {
// case "POST": // case http.MethodPost:
// startSessionHandlerIOS(w, r) // startSessionHandlerIOS(w, r)
// default: // default:
// w.WriteHeader(http.StatusMethodNotAllowed) // w.WriteHeader(http.StatusMethodNotAllowed)
// } // }
// case "/v1/ios/append": // case "/v1/ios/append":
// switch r.Method { // switch r.Method {
// case "POST": // case http.MethodPost:
// pushMessagesHandler(w, r) // pushMessagesHandler(w, r)
// default: // default:
// w.WriteHeader(http.StatusMethodNotAllowed) // w.WriteHeader(http.StatusMethodNotAllowed)
// } // }
// case "/v1/ios/late": // case "/v1/ios/late":
// switch r.Method { // switch r.Method {
// case "POST": // case http.MethodPost:
// pushLateMessagesHandler(w, r) // pushLateMessagesHandler(w, r)
// default: // default:
// w.WriteHeader(http.StatusMethodNotAllowed) // w.WriteHeader(http.StatusMethodNotAllowed)
// } // }
// case "/v1/ios/images": // case "/v1/ios/images":
// switch r.Method { // switch r.Method {
// case "POST": // case http.MethodPost:
// iosImagesUploadHandler(w, r) // iosImagesUploadHandler(w, r)
// default: // default:
// w.WriteHeader(http.StatusMethodNotAllowed) // w.WriteHeader(http.StatusMethodNotAllowed)

View file

@ -61,7 +61,9 @@
"idp_entityId": "", "idp_entityId": "",
"idp_sso_url": "", "idp_sso_url": "",
"idp_x509cert": "", "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_timeout": 150,
"lambda_memory_size": 400, "lambda_memory_size": 400,

View file

@ -87,7 +87,12 @@ def or_middleware(event, get_response):
import time import time
now = int(time.time() * 1000) now = int(time.time() * 1000)
response = get_response(event) 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: with configure_scope() as scope:
scope.set_tag('stage', environ["stage"]) scope.set_tag('stage', environ["stage"])
scope.set_tag('openReplaySessionToken', OR_SESSION_TOKEN) scope.set_tag('openReplaySessionToken', OR_SESSION_TOKEN)

View file

@ -38,9 +38,9 @@ def login():
for_plugin=False for_plugin=False
) )
if r is None: if r is None:
return { return Response(status_code=401, body={
'errors': ['Youve entered invalid Email or Password.'] 'errors': ['Youve entered invalid Email or Password.']
} })
elif "errors" in r: elif "errors" in r:
return r return r
@ -103,8 +103,11 @@ def create_edit_project(projectId, context):
@app.route('/projects/{projectId}', methods=['GET']) @app.route('/projects/{projectId}', methods=['GET'])
def get_project(projectId, context): def get_project(projectId, context):
return {"data": projects.get_project(tenant_id=context["tenantId"], project_id=projectId, include_last_session=True, data = projects.get_project(tenant_id=context["tenantId"], project_id=projectId, include_last_session=True,
include_gdpr=True)} include_gdpr=True)
if data is None:
return {"errors": ["project not found"]}
return {"data": data}
@app.route('/projects/{projectId}', methods=['DELETE']) @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) 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']) @app.route('/client/members/{memberId}', methods=['PUT', 'POST'])
def edit_member(memberId, context): def edit_member(memberId, context):
data = app.current_request.json_body data = app.current_request.json_body
@ -366,6 +401,11 @@ def edit_member(memberId, context):
user_id_to_update=memberId) 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']) @app.route('/client/members/{memberId}', methods=['DELETE'])
def delete_member(memberId, context): def delete_member(memberId, context):
return users.delete_member(tenant_id=context["tenantId"], user_id=context['userId'], id_to_delete=memberId) return users.delete_member(tenant_id=context["tenantId"], user_id=context['userId'], id_to_delete=memberId)

View file

@ -250,7 +250,7 @@ def get_details(project_id, error_id, user_id, **data):
# print("--------------------") # print("--------------------")
row = ch.execute(query=main_ch_query, params=params) row = ch.execute(query=main_ch_query, params=params)
if len(row) == 0: if len(row) == 0:
return {"errors": ["error doesn't exist"]} return {"errors": ["error not found"]}
row = row[0] row = row[0]
row["tags"] = __process_tags(row) row["tags"] = __process_tags(row)
with pg_client.PostgresClient() as cur: 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) # print(main_ch_query % params)
row = ch.execute(query=main_ch_query, params=params) row = ch.execute(query=main_ch_query, params=params)
if len(row) == 0: if len(row) == 0:
return {"errors": ["error doesn't exist"]} return {"errors": ["error not found"]}
row = row[0] row = row[0]
row["tags"] = __process_tags(row) row["tags"] = __process_tags(row)
row["chart"] = __rearrange_chart_details(start_at=data["startDate"], end_at=data["endDate"], density=density, row["chart"] = __rearrange_chart_details(start_at=data["startDate"], end_at=data["endDate"], density=density,

View file

@ -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): 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) f = get(funnel_id=funnel_id, project_id=project_id)
if f is None: 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) 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) 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): 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) f = get(funnel_id=funnel_id, project_id=project_id)
if f is None: 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) 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, 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 insights[-1]["dropDueToIssues"] = total_drop_due_to_issues
return {"stages": helper.list_to_camel_case(insights), return {"data": {"stages": helper.list_to_camel_case(insights),
"totalDropDueToIssues": total_drop_due_to_issues} "totalDropDueToIssues": total_drop_due_to_issues}}
def get_top_insights_on_the_fly(funnel_id, project_id, data): 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) insights, total_drop_due_to_issues = significance.get_top_insights(filter_d=data, project_id=project_id)
if len(insights) > 0: if len(insights) > 0:
insights[-1]["dropDueToIssues"] = total_drop_due_to_issues insights[-1]["dropDueToIssues"] = total_drop_due_to_issues
return {"stages": helper.list_to_camel_case(insights), return {"data": {"stages": helper.list_to_camel_case(insights),
"totalDropDueToIssues": total_drop_due_to_issues} "totalDropDueToIssues": total_drop_due_to_issues}}
def get_issues(project_id, funnel_id, range_value=None, start_date=None, end_date=None): 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": []}, data=data) if issue is not None else {"total": 0, "sessions": []},
# "stages": helper.list_to_camel_case(insights), # "stages": helper.list_to_camel_case(insights),
# "totalDropDueToIssues": total_drop_due_to_issues, # "totalDropDueToIssues": total_drop_due_to_issues,
"issue": issue} "issue": issue}

View file

@ -24,9 +24,10 @@ def get(project_id):
) )
metas = cur.fetchone() metas = cur.fetchone()
results = [] results = []
for i, k in enumerate(metas.keys()): if metas is not None:
if metas[k] is not None: for i, k in enumerate(metas.keys()):
results.append({"key": metas[k], "index": i + 1}) if metas[k] is not None:
results.append({"key": metas[k], "index": i + 1})
return results return results
@ -56,7 +57,7 @@ def __edit(project_id, col_index, colname, new_name):
old_metas = get(project_id) old_metas = get(project_id)
old_metas = {k["index"]: k for k in old_metas} old_metas = {k["index"]: k for k in old_metas}
if col_index not in list(old_metas.keys()): 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: with pg_client.PostgresClient() as cur:
if old_metas[col_index]["key"].lower() != new_name: 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 = get(project_id)
old_segments = [k["index"] for k in old_segments] old_segments = [k["index"] for k in old_segments]
if index not 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: with pg_client.PostgresClient() as cur:
colname = index_to_colname(index) colname = index_to_colname(index)
@ -136,7 +137,7 @@ def search(tenant_id, project_id, key, value):
key = c key = c
break break
if key is None: if key is None:
return {"errors": ["key does not exist"]} return {"errors": ["key not found"]}
cur.execute( cur.execute(
cur.mogrify( cur.mogrify(
f"""\ f"""\
@ -259,4 +260,4 @@ def get_remaining_metadata_with_count(tenant_id):
remaining = MAX_INDEXES - len(used_metas) remaining = MAX_INDEXES - len(used_metas)
results.append({**p, "limit": MAX_INDEXES, "remaining": remaining, "count": len(used_metas)}) results.append({**p, "limit": MAX_INDEXES, "remaining": remaining, "count": len(used_metas)})
return results return results

View file

@ -1,8 +1,4 @@
import chalicelib.utils.TimeUTC
from chalicelib.utils import email_helper, captcha, helper from chalicelib.utils import email_helper, captcha, helper
import secrets
from chalicelib.utils import pg_client
from chalicelib.core import users from chalicelib.core import users
@ -18,49 +14,23 @@ def step1(data):
a_users = users.get_by_email_only(data["email"]) a_users = users.get_by_email_only(data["email"])
if len(a_users) > 1: if len(a_users) > 1:
print(f"multiple users found for [{data['email']}] please contact our support") 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: elif len(a_users) == 1:
a_users = a_users[0] a_users = a_users[0]
reset_token = secrets.token_urlsafe(6) invitation_link = users.generate_new_invitation(user_id=a_users["id"])
users.update(tenant_id=a_users["tenantId"], user_id=a_users["id"], email_helper.send_forgot_password(recipient=data["email"], invitation_link=invitation_link)
changes={"token": reset_token})
email_helper.send_reset_code(recipient=data["email"], reset_code=reset_token)
else: else:
print(f"invalid email address [{data['email']}]") print(f"invalid email address [{data['email']}]")
return {"errors": ["invalid email address"]} return {"errors": ["invalid email address"]}
return {"data": {"state": "success"}} return {"data": {"state": "success"}}
# def step2(data):
def step2(data): # print("====================== change password 2 ===============")
print("====================== change password 2 ===============") # user = users.get_by_email_reset(data["email"], data["code"])
user = users.get_by_email_reset(data["email"], data["code"]) # if not user:
if not user: # print("error: wrong email or reset code")
print("error: wrong email or reset code") # return {"errors": ["wrong email or reset code"]}
return {"errors": ["wrong email or reset code"]} # users.update(tenant_id=user["tenantId"], user_id=user["id"],
users.update(tenant_id=user["tenantId"], user_id=user["id"], # changes={"token": None, "password": data["password"], "generatedPassword": False,
changes={"token": None, "password": data["password"], "generatedPassword": False, # "verifiedEmail": True})
"verifiedEmail": True}) # return {"data": {"state": "success"}}
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})
)

View file

@ -59,7 +59,7 @@ def create_step1(data):
if len(signed_ups) == 0 and data.get("tenantId") is not None \ if len(signed_ups) == 0 and data.get("tenantId") is not None \
or 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]: 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: if len(errors) > 0:
print("==> error") print("==> error")
print(errors) print(errors)

View file

@ -4,11 +4,17 @@ from chalicelib.core import authorizers
from chalicelib.core import tenants from chalicelib.core import tenants
from chalicelib.utils import helper from chalicelib.utils import helper
from chalicelib.utils import pg_client from chalicelib.utils import pg_client
from chalicelib.utils import dev
from chalicelib.utils.TimeUTC import TimeUTC from chalicelib.utils.TimeUTC import TimeUTC
from chalicelib.utils.helper import environ 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: with pg_client.PostgresClient() as cur:
query = cur.mogrify(f"""\ query = cur.mogrify(f"""\
WITH u AS ( 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) VALUES (%(tenantId)s, %(email)s, %(role)s, %(name)s, %(data)s)
RETURNING user_id,email,role,name,appearance RETURNING user_id,email,role,name,appearance
), ),
au AS (INSERT au AS (INSERT INTO public.basic_authentication (user_id, generated_password, invitation_token, invited_at)
INTO public.basic_authentication (user_id, password, generated_password) VALUES ((SELECT user_id FROM u), TRUE, %(invitation_token)s, timezone('utc'::text, now()))
VALUES ((SELECT user_id FROM u), crypt(%(password)s, gen_salt('bf', 12)), TRUE)) RETURNING invitation_token
)
SELECT u.user_id AS id, SELECT u.user_id AS id,
u.user_id,
u.email, u.email,
u.role, u.role,
u.name, 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 = '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 = 'admin' THEN TRUE ELSE FALSE END) AS admin,
(CASE WHEN u.role = 'member' THEN TRUE ELSE FALSE END) AS member, (CASE WHEN u.role = 'member' THEN TRUE ELSE FALSE END) AS member,
u.appearance au.invitation_token
FROM u;""", FROM u,au;""",
{"tenantId": tenant_id, "email": email, "password": password, {"tenantId": tenant_id, "email": email,
"role": "owner" if owner else "admin" if admin else "member", "name": name, "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( cur.execute(
query query
) )
return helper.dict_to_camel_case(cur.fetchone()) 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: with pg_client.PostgresClient() as cur:
query = cur.mogrify(f"""\ query = cur.mogrify(f"""\
UPDATE public.users UPDATE public.users
@ -56,31 +65,62 @@ def restore_member(tenant_id, user_id, email, password, admin, name, owner=False
TRUE AS change_password, TRUE AS change_password,
(CASE WHEN role = 'owner' THEN TRUE ELSE FALSE END) AS super_admin, (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 = 'admin' THEN TRUE ELSE FALSE END) AS admin,
(CASE WHEN role = 'member' THEN TRUE ELSE FALSE END) AS member, (CASE WHEN role = 'member' THEN TRUE ELSE FALSE END) AS member;""",
appearance;""",
{"tenant_id": tenant_id, "user_id": user_id, "email": email, {"tenant_id": tenant_id, "user_id": user_id, "email": email,
"role": "owner" if owner else "admin" if admin else "member", "name": name}) "role": "owner" if owner else "admin" if admin else "member", "name": name})
cur.execute( cur.execute(
query query
) )
result = helper.dict_to_camel_case(cur.fetchone()) result = cur.fetchone()
query = cur.mogrify("""\ query = cur.mogrify("""\
UPDATE public.basic_authentication UPDATE public.basic_authentication
SET password= crypt(%(password)s, gen_salt('bf', 12)), SET generated_password = TRUE,
generated_password= TRUE, invitation_token = %(invitation_token)s,
token=NULL, invited_at = timezone('utc'::text, now()),
token_requested_at=NULL change_pwd_expire_at = NULL,
WHERE user_id=%(user_id)s;""", change_pwd_token = NULL
{"user_id": user_id, "password": password}) WHERE user_id=%(user_id)s
RETURNING invitation_token;""",
{"user_id": user_id, "invitation_token": invitation_token})
cur.execute( cur.execute(
query 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): 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: if len(changes.keys()) == 0:
return None return None
@ -91,13 +131,6 @@ def update(tenant_id, user_id, changes):
if key == "password": if key == "password":
sub_query_bauth.append("password = crypt(%(password)s, gen_salt('bf', 12))") sub_query_bauth.append("password = crypt(%(password)s, gen_salt('bf', 12))")
sub_query_bauth.append("changed_at = timezone('utc'::text, now())") 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: else:
sub_query_bauth.append(f"{helper.key_to_snake_case(key)} = %({key})s") sub_query_bauth.append(f"{helper.key_to_snake_case(key)} = %({key})s")
else: else:
@ -166,26 +199,43 @@ def create_member(tenant_id, user_id, data):
return {"errors": ["invalid user name"]} return {"errors": ["invalid user name"]}
if name is None: if name is None:
name = data["email"] name = data["email"]
temp_pass = helper.generate_salt()[:8] invitation_token = __generate_invitation_token()
user = get_deleted_user_by_email(email=data["email"]) user = get_deleted_user_by_email(email=data["email"])
if user is not None: 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"]) admin=data.get("admin", False), name=name, user_id=user["userId"])
else: 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) 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', helper.async_post(environ['email_basic'] % 'member_invitation',
{ {
"email": data["email"], "email": data["email"],
"userName": data["email"], "invitationLink": new_member["invitationLink"],
"tempPassword": temp_pass,
"clientId": tenants.get_by_tenant_id(tenant_id)["name"], "clientId": tenants.get_by_tenant_id(tenant_id)["name"],
"senderName": admin["name"] "senderName": admin["name"]
}) })
return {"data": new_member} 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): def get(user_id, tenant_id):
with pg_client.PostgresClient() as cur: with pg_client.PostgresClient() as cur:
cur.execute( cur.execute(
@ -321,7 +371,11 @@ def get_members(tenant_id):
basic_authentication.generated_password, basic_authentication.generated_password,
(CASE WHEN users.role = 'owner' THEN TRUE ELSE FALSE END) AS super_admin, (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 = '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 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 WHERE users.tenant_id = %(tenantId)s AND users.deleted_at IS NULL
ORDER BY name, id""", ORDER BY name, id""",
@ -329,7 +383,13 @@ def get_members(tenant_id):
) )
r = cur.fetchall() r = cur.fetchall()
if len(r): 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 [] return []
@ -374,6 +434,15 @@ def change_password(tenant_id, user_id, email, old_password, new_password):
"jwt": authenticate(email, new_password)["jwt"]} "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): def count_members(tenant_id):
with pg_client.PostgresClient() as cur: with pg_client.PostgresClient() as cur:
cur.execute( cur.execute(
@ -393,7 +462,7 @@ def email_exists(email):
cur.mogrify( cur.mogrify(
f"""SELECT f"""SELECT
count(user_id) count(user_id)
FROM public.users FROM public.users
WHERE WHERE
email = %(email)s email = %(email)s
AND deleted_at IS NULL AND deleted_at IS NULL
@ -410,7 +479,7 @@ def get_deleted_user_by_email(email):
cur.mogrify( cur.mogrify(
f"""SELECT f"""SELECT
* *
FROM public.users FROM public.users
WHERE WHERE
email = %(email)s email = %(email)s
AND deleted_at NOTNULL AND deleted_at NOTNULL
@ -421,6 +490,24 @@ def get_deleted_user_by_email(email):
return helper.dict_to_camel_case(r) 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): def auth_exists(user_id, tenant_id, jwt_iat, jwt_aud):
with pg_client.PostgresClient() as cur: with pg_client.PostgresClient() as cur:
cur.execute( cur.execute(
@ -450,6 +537,7 @@ def change_jwt_iat(user_id):
return cur.fetchone().get("jwt_iat") return cur.fetchone().get("jwt_iat")
@dev.timed
def authenticate(email, password, for_change_password=False, for_plugin=False): def authenticate(email, password, for_change_password=False, for_plugin=False):
with pg_client.PostgresClient() as cur: with pg_client.PostgresClient() as cur:
query = cur.mogrify( query = cur.mogrify(

View 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;

View file

@ -60,7 +60,7 @@ CREATE TABLE users
"role": "dev", "role": "dev",
"dashboard": { "dashboard": {
"cpu": true, "cpu": true,
"fps": false, "fps": false,
"avgCpu": true, "avgCpu": true,
"avgFps": true, "avgFps": true,
"errors": true, "errors": true,
@ -121,19 +121,21 @@ CREATE TABLE users
jwt_iat timestamp without time zone NULL DEFAULT NULL, jwt_iat timestamp without time zone NULL DEFAULT NULL,
data jsonb NOT NULL DEFAULT '{}'::jsonb, data jsonb NOT NULL DEFAULT '{}'::jsonb,
weekly_report boolean NOT NULL DEFAULT TRUE, weekly_report boolean NOT NULL DEFAULT TRUE,
origin user_origin NULL DEFAULT NULL, origin user_origin NULL DEFAULT NULL,
); );
CREATE TABLE basic_authentication CREATE TABLE basic_authentication
( (
user_id integer NOT NULL REFERENCES users (user_id) ON DELETE CASCADE, user_id integer NOT NULL REFERENCES users (user_id) ON DELETE CASCADE,
password text DEFAULT NULL, password text DEFAULT NULL,
generated_password boolean NOT NULL DEFAULT false, generated_password boolean NOT NULL DEFAULT false,
token text NULL DEFAULT NULL, invitation_token text NULL DEFAULT NULL,
token_requested_at timestamp without time zone NULL DEFAULT NULL, invited_at timestamp without time zone NULL DEFAULT NULL,
changed_at timestamp, change_pwd_token text NULL DEFAULT NULL,
change_pwd_expire_at timestamp without time zone NULL DEFAULT NULL,
changed_at timestamp,
UNIQUE (user_id) 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 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_country);
CREATE INDEX ON sessions (project_id, user_browser); 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 ALTER TABLE public.sessions
ADD CONSTRAINT web_browser_constraint CHECK ( (sessions.platform = 'web' AND sessions.user_browser NOTNULL) OR 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, created_at timestamp default timezone('utc'::text, now()) NOT NULL,
provider_data jsonb default '{}'::jsonb NOT NULL provider_data jsonb default '{}'::jsonb NOT NULL
); );
CREATE INDEX ON assigned_sessions (session_id);
-- --- events_common.sql --- -- --- 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_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_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_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 CREATE TABLE events.clicks
@ -691,6 +697,11 @@ CREATE INDEX ON events.clicks (session_id);
CREATE INDEX ON events.clicks (label); CREATE INDEX ON events.clicks (label);
CREATE INDEX clicks_label_gin_idx ON events.clicks USING GIN (label gin_trgm_ops); CREATE INDEX clicks_label_gin_idx ON events.clicks USING GIN (label gin_trgm_ops);
CREATE INDEX ON events.clicks (timestamp); 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 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_gin_idx ON events.inputs USING GIN (label gin_trgm_ops);
CREATE INDEX inputs_label_idx ON events.inputs (label); CREATE INDEX inputs_label_idx ON events.inputs (label);
CREATE INDEX ON events.inputs (timestamp); 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 CREATE TABLE events.errors
( (

View file

@ -2,10 +2,13 @@ import { configure, addDecorator } from '@storybook/react';
import { Provider } from 'react-redux'; import { Provider } from 'react-redux';
import store from '../app/store'; import store from '../app/store';
import { MemoryRouter } from "react-router" import { MemoryRouter } from "react-router"
import { PlayerProvider } from '../app/player/store'
const withProvider = (story) => ( const withProvider = (story) => (
<Provider store={store}> <Provider store={store}>
{ story() } <PlayerProvider>
{ story() }
</PlayerProvider>
</Provider> </Provider>
) )

View file

@ -21,7 +21,8 @@ const siteIdRequiredPaths = [
'/sourcemaps', '/sourcemaps',
'/errors', '/errors',
'/funnels', '/funnels',
'/assist' '/assist',
'/heatmaps'
]; ];
const noStoringFetchPathStarts = [ const noStoringFetchPathStarts = [

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

View file

@ -9,8 +9,7 @@
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png"> <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="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 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 href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700" rel="stylesheet">
<link rel="stylesheet" href="/path/to/styles/theme-name.css">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

View file

@ -35,8 +35,8 @@ function LiveSessionList(props: Props) {
<div> <div>
<NoContent <NoContent
title={"No live sessions!"} title={"No live sessions!"}
subtext="Please try changing your search parameters." // subtext="Please try changing your search parameters."
icon="exclamation-circle" image={<img src="/img/live-sessions.png" style={{ width: '70%', marginBottom: '30px' }}/>}
show={ !loading && list && list.size === 0} show={ !loading && list && list.size === 0}
> >
<Loader loading={ loading }> <Loader loading={ loading }>

View file

@ -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;

View file

@ -0,0 +1 @@
export { default } from './AssistDoc'

View file

@ -28,6 +28,7 @@ import SlackAddForm from './SlackAddForm';
import FetchDoc from './FetchDoc'; import FetchDoc from './FetchDoc';
import MobxDoc from './MobxDoc'; import MobxDoc from './MobxDoc';
import ProfilerDoc from './ProfilerDoc'; import ProfilerDoc from './ProfilerDoc';
import AssistDoc from './AssistDoc';
const NONE = -1; const NONE = -1;
const SENTRY = 0; const SENTRY = 0;
@ -49,6 +50,7 @@ const SLACK = 15;
const FETCH = 16; const FETCH = 16;
const MOBX = 17; const MOBX = 17;
const PROFILER = 18; const PROFILER = 18;
const ASSIST = 19;
const TITLE = { const TITLE = {
[ SENTRY ]: 'Sentry', [ SENTRY ]: 'Sentry',
@ -70,6 +72,7 @@ const TITLE = {
[ FETCH ] : 'Fetch', [ FETCH ] : 'Fetch',
[ MOBX ] : 'MobX', [ MOBX ] : 'MobX',
[ PROFILER ] : 'Profiler', [ PROFILER ] : 'Profiler',
[ ASSIST ] : 'Assist',
} }
const DOCS = [REDUX, VUE, GRAPHQL, NGRX, FETCH, MOBX, PROFILER] 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 } /> return <MobxDoc onClose={ this.closeModal } />
case PROFILER: case PROFILER:
return <ProfilerDoc onClose={ this.closeModal } /> return <ProfilerDoc onClose={ this.closeModal } />
case ASSIST:
return <AssistDoc onClose={ this.closeModal } />
default: default:
return null; return null;
} }
@ -253,7 +258,7 @@ export default class Integrations extends React.PureComponent {
{plugins && ( {plugins && (
<div className="" > <div className="" >
<div className="mb-4">Use plugins to better debug your application's store, monitor queries and track performance issues.</div> <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 <IntegrationItem
title="Redux" title="Redux"
icon="integrations/redux" icon="integrations/redux"
@ -313,6 +318,14 @@ export default class Integrations extends React.PureComponent {
onClick={ () => this.showIntegrationConfig(PROFILER) } onClick={ () => this.showIntegrationConfig(PROFILER) }
// integrated={ sentryIntegrated } // 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>
</div> </div>
)} )}

View file

@ -1,8 +1,8 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import cn from 'classnames'; import cn from 'classnames';
import withPageTitle from 'HOCs/withPageTitle'; import withPageTitle from 'HOCs/withPageTitle';
import { IconButton, SlideModal, Input, Button, Loader, NoContent, Popup } from 'UI'; import { IconButton, SlideModal, Input, Button, Loader, NoContent, Popup, CopyButton } from 'UI';
import { init, save, edit, remove as deleteMember, fetchList } from 'Duck/member'; import { init, save, edit, remove as deleteMember, fetchList, generateInviteLink } from 'Duck/member';
import styles from './manageUsers.css'; import styles from './manageUsers.css';
import UserItem from './UserItem'; import UserItem from './UserItem';
import { confirm } from 'UI/Confirmation'; import { confirm } from 'UI/Confirmation';
@ -24,11 +24,12 @@ const LIMIT_WARNING = 'You have reached users limit.';
save, save,
edit, edit,
deleteMember, deleteMember,
fetchList fetchList,
generateInviteLink
}) })
@withPageTitle('Manage Users - OpenReplay Preferences') @withPageTitle('Manage Users - OpenReplay Preferences')
class ManageUsers extends React.PureComponent { 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 }); onChange = (e, { name, value }) => this.props.edit({ [ name ]: value });
onChangeCheckbox = ({ target: { checked, name } }) => this.props.edit({ [ name ]: checked }); onChangeCheckbox = ({ target: { checked, name } }) => this.props.edit({ [ name ]: checked });
@ -70,11 +71,12 @@ class ManageUsers extends React.PureComponent {
toast.error(e); toast.error(e);
}) })
} }
this.closeModal() this.setState({ invited: true })
// this.closeModal()
}); });
} }
formContent = member => ( formContent = (member, account) => (
<div className={ styles.form }> <div className={ styles.form }>
<form onSubmit={ this.save } > <form onSubmit={ this.save } >
<div className={ styles.formGroup }> <div className={ styles.formGroup }>
@ -99,7 +101,11 @@ class ManageUsers extends React.PureComponent {
className={ styles.input } className={ styles.input }
/> />
</div> </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 }> <div className={ styles.formGroup }>
<label className={ styles.checkbox }> <label className={ styles.checkbox }>
<input <input
@ -111,26 +117,37 @@ class ManageUsers extends React.PureComponent {
/> />
<span>{ 'Admin' }</span> <span>{ 'Admin' }</span>
</label> </label>
<div className={ styles.adminInfo }>{ 'Can manage Projects and Users.' }</div> <div className={ styles.adminInfo }>{ 'Can manage Projects and team members.' }</div>
</div> </div>
</form> </form>
<Button <div className="flex items-center">
onClick={ this.save } <div className="flex items-center mr-auto">
disabled={ !member.validate() } <Button
loading={ this.props.saving } onClick={ this.save }
primary disabled={ !member.validate() }
marginRight loading={ this.props.saving }
> primary
{ member.exists() ? 'Update' : 'Invite' } marginRight
</Button> >
<Button { member.exists() ? 'Update' : 'Invite' }
data-hidden={ !member.exists() } </Button>
onClick={ this.closeModal } <Button
outline data-hidden={ !member.exists() }
> onClick={ this.closeModal }
{ 'Cancel' } outline
</Button> >
{ 'Cancel' }
</Button>
</div>
{ !member.joined && member.invitationLink &&
<CopyButton
content={member.invitationLink}
className="link"
btnText="Copy invite link"
/>
}
</div>
</div> </div>
) )
@ -144,7 +161,7 @@ class ManageUsers extends React.PureComponent {
const { const {
members, member, loading, account, hideHeader = false, members, member, loading, account, hideHeader = false,
} = this.props; } = this.props;
const { showModal, remaining } = this.state; const { showModal, remaining, invited } = this.state;
const isAdmin = account.admin || account.superAdmin; const isAdmin = account.admin || account.superAdmin;
const canAddUsers = isAdmin && remaining !== 0; const canAddUsers = isAdmin && remaining !== 0;
@ -155,7 +172,7 @@ class ManageUsers extends React.PureComponent {
title="Inivte People" title="Inivte People"
size="small" size="small"
isDisplayed={ showModal } isDisplayed={ showModal }
content={ this.formContent(member) } content={ this.formContent(member, account) }
onClose={ this.closeModal } onClose={ this.closeModal }
/> />
<div className={ styles.wrapper }> <div className={ styles.wrapper }>
@ -202,6 +219,7 @@ class ManageUsers extends React.PureComponent {
{ {
members.map(user => ( members.map(user => (
<UserItem <UserItem
generateInviteLink={this.props.generateInviteLink}
key={ user.id } key={ user.id }
user={ user } user={ user }
adminLabel={ this.adminLabel(user) } adminLabel={ this.adminLabel(user) }

View file

@ -1,13 +1,43 @@
import React from 'react'; import React from 'react';
import { Icon } from 'UI'; import { Icon, CopyButton, Popup } from 'UI';
import styles from './userItem.css'; 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"> <div className={ styles.wrapper } id="user-row">
<Icon name="user-alt" size="16" marginRight="10" /> <Icon name="user-alt" size="16" marginRight="10" />
<div id="user-name">{ user.name || user.email }</div> <div id="user-name">{ user.name || user.email }</div>
{ adminLabel && <div className={ styles.adminLabel }>{ adminLabel }</div>} { adminLabel && <div className={ styles.adminLabel }>{ adminLabel }</div>}
<div className={ styles.actions }> <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 && { !!deleteHandler &&
<div className={ styles.button } onClick={ () => deleteHandler(user) } id="trash"> <div className={ styles.button } onClick={ () => deleteHandler(user) } id="trash">
<Icon name="trash" size="16" color="teal"/> <Icon name="trash" size="16" color="teal"/>

View file

@ -34,4 +34,9 @@
.adminInfo { .adminInfo {
font-size: 12px; font-size: 12px;
color: $gray-medium; color: $gray-medium;
}
.smtpMessage {
background-color: #faf6e0;
border-radius: 3px;
} }

View file

@ -35,6 +35,9 @@
padding: 5px; padding: 5px;
cursor: pointer; cursor: pointer;
margin-left: 10px; margin-left: 10px;
display: flex;
align-items: center;
justify-content: center;
&:hover { &:hover {
& svg { & svg {
fill: $teal-dark; fill: $teal-dark;

View file

@ -4,6 +4,8 @@ import withPageTitle from 'HOCs/withPageTitle';
import { Loader, Button, Link, Icon, Message } from 'UI'; import { Loader, Button, Link, Icon, Message } from 'UI';
import { requestResetPassword, resetPassword } from 'Duck/user'; import { requestResetPassword, resetPassword } from 'Duck/user';
import { login as loginRoute } from 'App/routes'; import { login as loginRoute } from 'App/routes';
import { withRouter } from 'react-router-dom';
import { validateEmail } from 'App/validate';
import cn from 'classnames'; import cn from 'classnames';
import stl from './forgotPassword.css'; import stl from './forgotPassword.css';
@ -17,14 +19,16 @@ const checkDontMatch = (newPassword, newPasswordRepeat) =>
newPasswordRepeat.length > 0 && newPasswordRepeat !== newPassword; newPasswordRepeat.length > 0 && newPasswordRepeat !== newPassword;
@connect( @connect(
state => ({ (state, props) => ({
errors: state.getIn([ 'user', 'requestResetPassowrd', 'errors' ]), errors: state.getIn([ 'user', 'requestResetPassowrd', 'errors' ]),
resetErrors: state.getIn([ 'user', 'resetPassword', 'errors' ]), resetErrors: state.getIn([ 'user', 'resetPassword', 'errors' ]),
loading: state.getIn([ 'user', 'requestResetPassowrd', 'loading' ]), loading: state.getIn([ 'user', 'requestResetPassowrd', 'loading' ]),
params: new URLSearchParams(props.location.search)
}), }),
{ requestResetPassword, resetPassword }, { requestResetPassword, resetPassword },
) )
@withPageTitle("Password Reset - OpenReplay") @withPageTitle("Password Reset - OpenReplay")
@withRouter
export default class ForgotPassword extends React.PureComponent { export default class ForgotPassword extends React.PureComponent {
state = { state = {
email: '', email: '',
@ -37,15 +41,20 @@ export default class ForgotPassword extends React.PureComponent {
handleSubmit = (token) => { handleSubmit = (token) => {
const { email, requested, code, password } = this.state; 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(() => { this.props.requestResetPassword({ email: email.trim(), 'g-recaptcha-response': token }).then(() => {
const { errors } = this.props; const { errors } = this.props;
if (!errors) this.setState({ requested: true }); if (!errors) this.setState({ requested: true });
}); });
} else { } else {
if (this.isSubmitDisabled()) return; 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; const { resetErrors } = this.props;
if (!resetErrors) this.setState({ updated: true }); if (!resetErrors) this.setState({ updated: true });
}); });
@ -78,9 +87,14 @@ export default class ForgotPassword extends React.PureComponent {
} }
render() { render() {
const { errors, loading } = this.props; const { errors, loading, params } = this.props;
const { requested, updated, password, passwordRepeat, code } = this.state; const { requested, updated, password, passwordRepeat, email } = this.state;
const dontMatch = checkDontMatch(password, passwordRepeat); const dontMatch = checkDontMatch(password, passwordRepeat);
const pass = params.get('pass')
const invitation = params.get('invitation')
const resetting = pass && invitation
const validEmail = validateEmail(email)
return ( return (
<div className="flex" style={{ height: '100vh'}}> <div className="flex" style={{ height: '100vh'}}>
@ -113,7 +127,7 @@ export default class ForgotPassword extends React.PureComponent {
</div> </div>
)} )}
{ !requested ? { !resetting && !requested &&
<div className={ stl.inputWithIcon }> <div className={ stl.inputWithIcon }>
<i className={ stl.inputIconUser } /> <i className={ stl.inputIconUser } />
<input <input
@ -125,47 +139,57 @@ export default class ForgotPassword extends React.PureComponent {
onChange={ this.write } onChange={ this.write }
className={ stl.input } className={ stl.input }
/> />
</div> </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 className={ stl.inputWithIcon } > {
<i className={ stl.inputIconPassword } /> requested && (
<input <div>Reset password link has been sent to your email.</div>
autocomplete="new-password" )
type="password" }
placeholder="New Password"
name="password" {
onChange={ this.write } resetting && (
className={ stl.input } <React.Fragment>
/> {/* <div className={ stl.inputWithIcon } >
</div> <i className={ stl.inputIconPassword } />
<div className={ stl.passwordPolicy } data-hidden={ !this.shouldShouwPolicy() }> <input
{ PASSWORD_POLICY } autocomplete="new-password"
</div> type="text"
<div className={ stl.inputWithIcon } > placeholder="Code"
<i className={ stl.inputIconPassword } /> name="code"
<input onChange={ this.write }
autocomplete="new-password" className={ stl.input }
type="password" />
placeholder="Repeat New Password" </div> */}
name="passwordRepeat"
onChange={ this.write } <div className={ stl.inputWithIcon } >
className={ stl.input } <i className={ stl.inputIconPassword } />
/> <input
</div> autocomplete="new-password"
</React.Fragment> 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 }> <Message error hidden={ !dontMatch }>
@ -185,7 +209,13 @@ export default class ForgotPassword extends React.PureComponent {
</div> </div>
</div> </div>
<div className={ stl.formFooter }> <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 }> <div className={ stl.links }>
<Link to={ LOGIN }> <Link to={ LOGIN }>

View file

@ -109,7 +109,7 @@ const FunnelHeader = (props) => {
endDate={funnelFilters.endDate} endDate={funnelFilters.endDate}
onDateChange={onDateChange} onDateChange={onDateChange}
customRangeRight customRangeRight
/> />
</div> </div>
</div> </div>
</div> </div>

View file

@ -31,12 +31,7 @@ function Layout({ children, player, toolbar }) {
</div> </div>
{ !player.fullscreen.enabled && <ToolPanel player={ player } toolbar={ toolbar }/> } { !player.fullscreen.enabled && <ToolPanel player={ player } toolbar={ toolbar }/> }
</div> </div>
{ !player.fullscreen.enabled &&
<Events
style={{ width: "270px" }}
player={ player }
/>
}
</div> </div>
</div> </div>
); );

View 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>
)
}

View file

@ -8,22 +8,13 @@ import {
init as initPlayer, init as initPlayer,
clean as cleanPlayer, clean as cleanPlayer,
} from 'Player'; } from 'Player';
import { Controls as PlayerControls, toggleEvents } from 'Player';
import cn from 'classnames' import cn from 'classnames'
import RightBlock from './RightBlock'
import PlayerBlockHeader from '../Session_/PlayerBlockHeader'; import PlayerBlockHeader from '../Session_/PlayerBlockHeader';
import EventsBlock from '../Session_/EventsBlock';
import PlayerBlock from '../Session_/PlayerBlock'; import PlayerBlock from '../Session_/PlayerBlock';
import styles from '../Session_/session.css'; 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 => ({ const InitLoader = connectPlayer(state => ({
@ -32,14 +23,14 @@ const InitLoader = connectPlayer(state => ({
const PlayerContentConnected = connectPlayer(state => ({ const PlayerContentConnected = connectPlayer(state => ({
showEvents: !state.showEvents showEvents: !state.showEvents
}), { toggleEvents })(PlayerContent); }))(PlayerContent);
function PlayerContent({ live, fullscreen, showEvents, toggleEvents }) { function PlayerContent({ live, fullscreen, showEvents }) {
return ( return (
<div className={ cn(styles.session, 'relative') } data-fullscreen={fullscreen}> <div className={ cn(styles.session, 'relative') } data-fullscreen={fullscreen}>
<PlayerBlock /> <PlayerBlock />
{ showEvents && !live && !fullscreen && <EventsBlockConnected player={PlayerControls}/> } { showEvents && !live && !fullscreen && <RightBlock /> }
</div> </div>
) )
} }

View file

@ -1 +0,0 @@
export { default } from './AutoplayTimer'

View file

@ -6,7 +6,7 @@ export default function EventSearch(props) {
const [showSearch, setShowSearch] = useState(false) const [showSearch, setShowSearch] = useState(false)
return ( return (
<div className="flex items-center w-full"> <div className="flex items-center w-full">
<div className="flex-1 relative"> <div className="flex flex-1 relative items-center" style={{ height: '32px' }}>
{ showSearch ? { showSearch ?
<div className="flex items-center"> <div className="flex items-center">
<Input <Input

View file

@ -1,7 +1,6 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import cn from 'classnames'; import cn from 'classnames';
import { List, AutoSizer, CellMeasurer, CellMeasurerCache } from "react-virtualized"; import { List, AutoSizer, CellMeasurer, CellMeasurerCache } from "react-virtualized";
import { Avatar, Input, Dropdown, Icon } from 'UI';
import { TYPES } from 'Types/session/event'; import { TYPES } from 'Types/session/event';
import { setSelected } from 'Duck/events'; import { setSelected } from 'Duck/events';
import { setEventFilter } from 'Duck/sessions'; import { setEventFilter } from 'Duck/sessions';
@ -18,8 +17,7 @@ import EventSearch from './EventSearch/EventSearch';
eventsIndex: state.getIn([ 'sessions', 'eventsIndex' ]), eventsIndex: state.getIn([ 'sessions', 'eventsIndex' ]),
selectedEvents: state.getIn([ 'events', 'selected' ]), selectedEvents: state.getIn([ 'events', 'selected' ]),
targetDefinerDisplayed: state.getIn([ 'components', 'targetDefiner', 'isDisplayed' ]), targetDefinerDisplayed: state.getIn([ 'components', 'targetDefiner', 'isDisplayed' ]),
testsAvaliable: false, testsAvaliable: false,
//state.getIn([ 'user', 'account', 'appearance', 'tests' ]),
}), { }), {
showTargetDefiner, showTargetDefiner,
setSelected, setSelected,
@ -74,9 +72,6 @@ export default class EventsBlock extends React.PureComponent {
this.setState({ editingEvent: null }); this.setState({ editingEvent: null });
} }
if (prevProps.session !== this.props.session) { // Doesn't happen if (prevProps.session !== this.props.session) { // Doesn't happen
// this.setState({
// groups: groupEvents(this.props.session.events),
// });
this.cache = new CellMeasurerCache({ this.cache = new CellMeasurerCache({
fixedWidth: true, fixedWidth: true,
defaultHeight: 300 defaultHeight: 300
@ -148,8 +143,7 @@ export default class EventsBlock extends React.PureComponent {
<CellMeasurer <CellMeasurer
key={key} key={key}
cache={this.cache} cache={this.cache}
parent={parent} parent={parent}
//columnIndex={0}
rowIndex={index} rowIndex={index}
> >
{({measure, registerChild}) => ( {({measure, registerChild}) => (
@ -176,14 +170,12 @@ export default class EventsBlock extends React.PureComponent {
render() { render() {
const { query } = this.state; const { query } = this.state;
const { const {
playing,
testsAvaliable, testsAvaliable,
session: { session: {
events, events,
userNumericHash, userNumericHash,
userDisplayName, userDisplayName,
userUuid,
userId, userId,
userAnonymousId userAnonymousId
}, },
@ -193,7 +185,7 @@ export default class EventsBlock extends React.PureComponent {
const _events = filteredEvents || events; const _events = filteredEvents || events;
return ( return (
<div className={ cn("flex flex-col", styles.eventsBlock) }> <>
<div className={ cn(styles.header, 'p-3') }> <div className={ cn(styles.header, 'p-3') }>
<UserCard <UserCard
className="" className=""
@ -203,8 +195,7 @@ export default class EventsBlock extends React.PureComponent {
userAnonymousId={userAnonymousId} userAnonymousId={userAnonymousId}
/> />
<div className={ cn(styles.hAndProgress, 'mt-3') }> <div className={ cn(styles.hAndProgress, 'mt-3') }>
{/* <div className="text-lg">{ `User Events (${ events.size })` }</div> */}
<EventSearch <EventSearch
onChange={this.write} onChange={this.write}
clearSearch={this.clearSearch} clearSearch={this.clearSearch}
@ -213,29 +204,7 @@ export default class EventsBlock extends React.PureComponent {
<div className="text-lg">{ `User Events (${ events.size })` }</div> <div className="text-lg">{ `User Events (${ events.size })` }</div>
} }
/> />
</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 <div
className={ cn("flex-1 px-3 pb-3", styles.eventsList) } className={ cn("flex-1 px-3 pb-3", styles.eventsList) }
@ -263,7 +232,7 @@ export default class EventsBlock extends React.PureComponent {
</AutoSizer> </AutoSizer>
</div> </div>
{ testsAvaliable && <AutomateButton /> } { testsAvaliable && <AutomateButton /> }
</div> </>
); );
} }
} }

View file

@ -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);

View file

@ -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;
}

View file

@ -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>
)
}

View file

@ -0,0 +1 @@
export { default } from './SelectorCard'

View file

@ -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)

View file

@ -0,0 +1 @@
export { default } from './SelectorsList'

View file

@ -0,0 +1 @@
export { default } from './PageInsightsPanel'

View file

@ -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 />
))

View file

@ -73,7 +73,7 @@ function getStorageName(type) {
skip: state.skip, skip: state.skip,
skipToIssue: state.skipToIssue, skipToIssue: state.skipToIssue,
speed: state.speed, speed: state.speed,
disabled: state.cssLoading || state.messagesLoading || state.inspectorMode, disabled: state.cssLoading || state.messagesLoading || state.inspectorMode || state.markedTargets,
inspectorMode: state.inspectorMode, inspectorMode: state.inspectorMode,
fullscreenDisabled: state.messagesLoading, fullscreenDisabled: state.messagesLoading,
logCount: state.logListNow.length, logCount: state.logListNow.length,
@ -246,11 +246,12 @@ export default class Controls extends React.Component {
showLongtasks, showLongtasks,
exceptionsCount, exceptionsCount,
showExceptions, showExceptions,
fullscreen, fullscreen,
skipToIssue skipToIssue,
inspectorMode
} = this.props; } = this.props;
const inspectorMode = bottomBlock === INSPECTOR; // const inspectorMode = bottomBlock === INSPECTOR;
return ( return (
<div className={ cn(styles.controls, {'px-5 pt-0' : live}) }> <div className={ cn(styles.controls, {'px-5 pt-0' : live}) }>
@ -419,6 +420,7 @@ export default class Controls extends React.Component {
</React.Fragment> </React.Fragment>
} }
{!live && ( {!live && (
<ControlButton <ControlButton
disabled={ disabled && !inspectorMode } disabled={ disabled && !inspectorMode }

View 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);

View file

@ -0,0 +1,3 @@
.overlayBg {
background-color: rgba(255, 255, 255, 0.8);
}

View file

@ -1,9 +1,11 @@
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import cn from 'classnames';
import { connect } from 'react-redux' import { connect } from 'react-redux'
import { withRouter } from 'react-router-dom';
import { Button, Link } from 'UI' import { Button, Link } from 'UI'
import { session as sessionRoute, withSiteId } from 'App/routes' import { session as sessionRoute, withSiteId } from 'App/routes'
import stl from './AutoplayTimer.css' import stl from './AutoplayTimer.css';
import { withRouter } from 'react-router-dom'; import clsOv from './overlay.css';
function AutoplayTimer({ nextId, siteId, history }) { function AutoplayTimer({ nextId, siteId, history }) {
let timer let timer
@ -33,7 +35,7 @@ function AutoplayTimer({ nextId, siteId, history }) {
return '' return ''
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="border p-6 shadow-lg bg-white rounded">
<div className="py-4">Next recording will be played in {counter}s</div> <div className="py-4">Next recording will be played in {counter}s</div>
<div className="flex items-center"> <div className="flex items-center">

View file

@ -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}/>)
}

View file

@ -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
}
}

View file

@ -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>
)
}

View file

@ -0,0 +1,4 @@
.text {
color: $gray-light;
font-size: 40px;
}

View file

@ -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>
}

View file

@ -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>
}

View file

@ -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;
}

View file

@ -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>
)
}

View file

@ -1,8 +1,3 @@
.wrapper {
width: 30%;
height: 30%;
}
.overlay { .overlay {
position: absolute; position: absolute;
top: 0; top: 0;
@ -13,5 +8,4 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background-color: rgba(255, 255, 255, 0.8); }
}

View file

@ -2,29 +2,17 @@ import { connect } from 'react-redux';
import { findDOMNode } from 'react-dom'; import { findDOMNode } from 'react-dom';
import cn from 'classnames'; import cn from 'classnames';
import { Loader, IconButton, EscapeButton } from 'UI'; 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 { fullscreenOff } from 'Duck/components/player';
import withOverlay from 'Components/hocs/withOverlay';
import { attach as attachPlayer, Controls as PlayerControls, connectPlayer } from 'Player'; import { attach as attachPlayer, Controls as PlayerControls, connectPlayer } from 'Player';
import Controls from './Controls'; import Controls from './Controls';
import Overlay from './Overlay';
import stl from './player.css'; import stl from './player.css';
import AutoplayTimer from '../AutoplayTimer';
import EventsToggleButton from '../../Session/EventsToggleButton'; import EventsToggleButton from '../../Session/EventsToggleButton';
import { getStatusText } from 'Player/MessageDistributor/managers/AssistManager';
const ScreenWrapper = withOverlay()(React.memo(() => <div className={ stl.screenWrapper } />));
@connectPlayer(state => ({ @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, live: state.live,
liveStatusText: getStatusText(state.peerConnectionStatus),
})) }))
@connect(state => ({ @connect(state => ({
//session: state.getIn([ 'sessions', 'current' ]), //session: state.getIn([ 'sessions', 'current' ]),
@ -32,15 +20,9 @@ const ScreenWrapper = withOverlay()(React.memo(() => <div className={ stl.screen
nextId: state.getIn([ 'sessions', 'nextId' ]), nextId: state.getIn([ 'sessions', 'nextId' ]),
}), { }), {
hideTargetDefiner, hideTargetDefiner,
toggleInspectorMode: () => toggleInspectorMode(false),
fullscreenOff, fullscreenOff,
}) })
export default class Player extends React.PureComponent { export default class Player extends React.PureComponent {
state = {
showPlayOverlayIcon: false,
startedToPlayAt: Date.now(),
};
screenWrapper = React.createRef(); screenWrapper = React.createRef();
componentDidMount() { componentDidMount() {
@ -48,69 +30,14 @@ export default class Player extends React.PureComponent {
attachPlayer(parentElement); 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() { render() {
const {
showPlayOverlayIcon,
} = this.state;
const { const {
className, className,
playing,
disabled,
removeOverlay,
bottomBlockIsActive, bottomBlockIsActive,
loading,
disconnected,
fullscreen, fullscreen,
fullscreenOff, fullscreenOff,
completed,
autoplay,
nextId, nextId,
live, live,
liveStatusText,
} = this.props; } = this.props;
return ( return (
@ -120,40 +47,12 @@ export default class Player extends React.PureComponent {
> >
{ fullscreen && { fullscreen &&
<EscapeButton onClose={ fullscreenOff } /> <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 /> } {!live && !fullscreen && <EventsToggleButton /> }
<div className="relative flex-1"> <div className="relative flex-1 overflow-hidden">
{ (!removeOverlay || live && liveStatusText) && <Overlay nextId={nextId} togglePlay={PlayerControls.togglePlay} />
<div <div
className={ stl.overlay } className={ stl.screenWrapper }
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
ref={ this.screenWrapper } ref={ this.screenWrapper }
/> />
</div> </div>

View file

@ -1,5 +1,3 @@
@import 'icons.css';
.playerBody { .playerBody {
background: $white; background: $white;
/* border-radius: 3px; */ /* border-radius: 3px; */
@ -25,61 +23,8 @@
font-weight: 200; font-weight: 200;
color: $gray-medium; 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 { .playerView {
position: relative; position: relative;
flex: 1; flex: 1;
} }
.inspectorMode {
z-index: 99991 !important;
}
.liveStatusText {
color: $gray-light;
font-size: 40px;
}

View file

@ -2,7 +2,7 @@ import { connect } from 'react-redux';
import DateRangeDropdown from 'Shared/DateRangeDropdown'; import DateRangeDropdown from 'Shared/DateRangeDropdown';
function DateRange (props) { function DateRange (props) {
const { startDate, endDate, rangeValue, className, onDateChange, customRangeRight=false } = props; const { startDate, endDate, rangeValue, className, onDateChange, customRangeRight=false, customHidden = false } = props;
return ( return (
<DateRangeDropdown <DateRangeDropdown
@ -13,6 +13,7 @@ function DateRange (props) {
endDate={ endDate } endDate={ endDate }
className={ className } className={ className }
customRangeRight={customRangeRight} customRangeRight={customRangeRight}
customHidden={customHidden}
/> />
); );
} }

View file

@ -1,9 +1,11 @@
.wrapper { .wrapper {
& .body { background-color: white;
display: flex; outline: solid thin #CCC;
border-bottom: solid thin $gray-light; & .body {
padding: 5px; display: flex;
} border-bottom: solid thin $gray-light;
padding: 5px;
}
} }
.preSelections { .preSelections {

View file

@ -2,7 +2,7 @@ import React from 'react'
import { useState } from 'react'; import { useState } from 'react';
import copy from 'copy-to-clipboard'; import copy from 'copy-to-clipboard';
function CopyButton({ content, className }) { function CopyButton({ content, className, btnText = 'copy' }) {
const [copied, setCopied] = useState(false) const [copied, setCopied] = useState(false)
const copyHandler = () => { const copyHandler = () => {
@ -17,7 +17,7 @@ function CopyButton({ content, className }) {
className={ className } className={ className }
onClick={ copyHandler } onClick={ copyHandler }
> >
{ copied ? 'copied' : 'copy' } { copied ? 'copied' : btnText }
</button> </button>
) )
} }

View file

@ -1,3 +1,4 @@
import React from 'react';
import cn from 'classnames'; import cn from 'classnames';
import SVG from 'UI/SVG'; import SVG from 'UI/SVG';
import styles from './icon.css'; import styles from './icon.css';

View file

@ -9,8 +9,12 @@ export default ({
show = true, show = true,
children = null, children = null,
empty = false, empty = false,
image = null
}) => (!show ? children : }) => (!show ? children :
<div className={ `${ styles.wrapper } ${ size && styles[ size ] }` }> <div className={ `${ styles.wrapper } ${ size && styles[ size ] }` }>
{
image && image
}
{ {
icon && <div className={ empty ? styles.emptyIcon : styles.icon } /> icon && <div className={ empty ? styles.emptyIcon : styles.icon } />
} }

View file

@ -1,16 +1,48 @@
import { Map } from 'immutable';
import Member from 'Types/member'; import Member from 'Types/member';
import crudDuckGenerator from './tools/crudDuck'; 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' }); const crudDuck = crudDuckGenerator('client/member', Member, { idKey: 'id' });
export const { export const { fetchList, init, edit, remove, } = crudDuck.actions;
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) { export function save(instance) {
return { return {
types: crudDuck.actionTypes.SAVE.toArray(), 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;

View file

@ -5,6 +5,8 @@ import Watchdog, { getSessionWatchdogTypes } from 'Types/watchdog';
import { clean as cleanParams } from 'App/api_client'; import { clean as cleanParams } from 'App/api_client';
import withRequestState, { RequestTypes } from './requestStateCreator'; import withRequestState, { RequestTypes } from './requestStateCreator';
import { getRE } from 'App/utils'; import { getRE } from 'App/utils';
import { LAST_7_DAYS } from 'Types/app/period';
import { getDateRangeFromValue } from 'App/dateRange';
const INIT = 'sessions/INIT'; 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 FETCH_LIVE_LIST = new RequestTypes('sessions/FETCH_LIVE_LIST');
const TOGGLE_FAVORITE = new RequestTypes('sessions/TOGGLE_FAVORITE'); const TOGGLE_FAVORITE = new RequestTypes('sessions/TOGGLE_FAVORITE');
const FETCH_ERROR_STACK = new RequestTypes('sessions/FETCH_ERROR_STACK'); const FETCH_ERROR_STACK = new RequestTypes('sessions/FETCH_ERROR_STACK');
const FETCH_INSIGHTS = new RequestTypes('sessions/FETCH_INSIGHTS');
const SORT = 'sessions/SORT'; const SORT = 'sessions/SORT';
const REDEFINE_TARGET = 'sessions/REDEFINE_TARGET'; const REDEFINE_TARGET = 'sessions/REDEFINE_TARGET';
const SET_TIMEZONE = 'sessions/SET_TIMEZONE'; 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 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({ const initialState = Map({
list: List(), list: List(),
sessionIds: [], sessionIds: [],
@ -39,7 +50,10 @@ const initialState = Map({
sourcemapUploaded: true, sourcemapUploaded: true,
filteredEvents: null, filteredEvents: null,
showChatWindow: false, showChatWindow: false,
liveSessions: List() liveSessions: List(),
visitedEvents: List(),
insights: List(),
insightFilters: defaultDateFilters
}); });
const reducer = (state = initialState, action = {}) => { const reducer = (state = initialState, action = {}) => {
@ -136,21 +150,32 @@ const reducer = (state = initialState, action = {}) => {
const session = Session(action.data); const session = Session(action.data);
const matching = []; 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) => { session.events.forEach((e, index) => {
if (key === e.type) { if (key === e.type) {
const val = (e.type === 'LOCATION' ? e.url : e.value); const val = (e.type === 'LOCATION' ? e.url : e.value);
if (operator === 'is' && value === val) { if (operator === 'is' && value === val) {
matching.push(index); matching.push(index);
} }
if (operator === 'contains' && val.includes(value)) { if (operator === 'contains' && val.includes(value)) {
matching.push(index); 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: case FETCH_FAVORITE_LIST.SUCCESS:
return state return state
@ -202,9 +227,11 @@ const reducer = (state = initialState, action = {}) => {
.set('sessionIds', allList.map(({ sessionId }) => sessionId ).toJS()) .set('sessionIds', allList.map(({ sessionId }) => sessionId ).toJS())
case SET_TIMEZONE: case SET_TIMEZONE:
return state.set('timezone', action.timezone) return state.set('timezone', action.timezone)
case TOGGLE_CHAT_WINDOW: case TOGGLE_CHAT_WINDOW:
console.log(action)
return state.set('showChatWindow', action.state) 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: default:
return state; return state;
} }
@ -215,6 +242,7 @@ export default withRequestState({
fetchFavoriteListRequest: FETCH_FAVORITE_LIST, fetchFavoriteListRequest: FETCH_FAVORITE_LIST,
toggleFavoriteRequest: TOGGLE_FAVORITE, toggleFavoriteRequest: TOGGLE_FAVORITE,
fetchErrorStackList: FETCH_ERROR_STACK, fetchErrorStackList: FETCH_ERROR_STACK,
fetchInsightsRequest: FETCH_INSIGHTS,
}, reducer); }, reducer);
function init(session) { 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() { export function fetchLiveList() {
return { return {
types: FETCH_LIVE_LIST.toArray(), types: FETCH_LIVE_LIST.toArray(),

View file

@ -24,8 +24,7 @@ const initialState = Map({
const reducer = (state = initialState, action = {}) => { const reducer = (state = initialState, action = {}) => {
switch (action.type) { switch (action.type) {
case FETCH_LIST.SUCCESS: { case FETCH_LIST.SUCCESS: {
console.log(action);
return state.set('list', List(action.data).map(i => { return state.set('list', List(action.data).map(i => {
const type = i.type === 'navigate' ? i.type : 'location'; const type = i.type === 'navigate' ? i.type : 'location';
return {...i, type: type.toUpperCase()} return {...i, type: type.toUpperCase()}

View file

@ -52,7 +52,7 @@ const reducer = (state = initialState, action = {}) => {
case UPDATE_PASSWORD.SUCCESS: case UPDATE_PASSWORD.SUCCESS:
case LOGIN.SUCCESS: case LOGIN.SUCCESS:
return setClient( return setClient(
state.set('account', Account(action.data.user)), state.set('account', Account({...action.data.user, smtp: action.data.client.smtp })),
action.data.client, action.data.client,
); );
case SIGNUP.SUCCESS: case SIGNUP.SUCCESS:

View file

@ -14,7 +14,7 @@ import {
setStartTime as setListsStartTime setStartTime as setListsStartTime
} from '../lists'; } from '../lists';
import StatedScreen from './StatedScreen'; import StatedScreen from './StatedScreen/StatedScreen';
import ListWalker from './managers/ListWalker'; import ListWalker from './managers/ListWalker';
import PagesManager from './managers/PagesManager'; import PagesManager from './managers/PagesManager';
@ -27,7 +27,7 @@ import AssistManager from './managers/AssistManager';
import MessageReader from './MessageReader'; 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 { INITIAL_STATE as ASSIST_INITIAL_STATE, State as AssistState } from './managers/AssistManager';
import type { TimedMessage } from './Timed'; import type { TimedMessage } from './Timed';
@ -306,9 +306,7 @@ export default class MessageDistributor extends StatedScreen {
this.pagesManager.moveReady(t).then(() => { this.pagesManager.moveReady(t).then(() => {
const lastScroll = this.scrollManager.moveToLast(t, index); const lastScroll = this.scrollManager.moveToLast(t, index);
// @ts-ignore ??can't see double inheritance
if (!!lastScroll && this.window) { if (!!lastScroll && this.window) {
// @ts-ignore
this.window.scrollTo(lastScroll.x, lastScroll.y); this.window.scrollTo(lastScroll.x, lastScroll.y);
} }
// Moving mouse and setting :hover classes on ready view // Moving mouse and setting :hover classes on ready view
@ -479,7 +477,6 @@ export default class MessageDistributor extends StatedScreen {
// TODO: clean managers? // TODO: clean managers?
clean() { clean() {
// @ts-ignore
super.clean(); super.clean();
//if (this._socket) this._socket.close(); //if (this._socket) this._socket.close();
update(INITIAL_STATE); update(INITIAL_STATE);

View file

@ -18,7 +18,7 @@ export const INITIAL_STATE: State = {
export default abstract class BaseScreen { export default abstract class BaseScreen {
public readonly overlay: HTMLDivElement; public readonly overlay: HTMLDivElement;
private readonly iframe: HTMLIFrameElement; private readonly iframe: HTMLIFrameElement;
private readonly _screen: HTMLDivElement; protected readonly screen: HTMLDivElement;
protected parentElement: HTMLElement | null = null; protected parentElement: HTMLElement | null = null;
constructor() { constructor() {
const iframe = document.createElement('iframe'); const iframe = document.createElement('iframe');
@ -44,7 +44,7 @@ export default abstract class BaseScreen {
screen.className = styles.screen; screen.className = styles.screen;
screen.appendChild(iframe); screen.appendChild(iframe);
screen.appendChild(overlay); screen.appendChild(overlay);
this._screen = screen; this.screen = screen;
} }
attach(parentElement: HTMLElement) { attach(parentElement: HTMLElement) {
@ -52,7 +52,7 @@ export default abstract class BaseScreen {
throw new Error("BaseScreen: Trying to attach an attached screen."); throw new Error("BaseScreen: Trying to attach an attached screen.");
} }
parentElement.appendChild(this._screen); parentElement.appendChild(this.screen);
this.parentElement = parentElement; this.parentElement = parentElement;
// parentElement.onresize = this.scale; // parentElement.onresize = this.scale;
@ -115,29 +115,38 @@ export default abstract class BaseScreen {
return this.getElementsFromInternalPoint(this.getInternalCoordinates(point)); 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) { display(flag: boolean = true) {
this._screen.style.display = flag ? '' : 'none'; this.screen.style.display = flag ? '' : 'none';
} }
displayFrame(flag: boolean = true) { displayFrame(flag: boolean = true) {
this.iframe.style.display = flag ? '' : 'none'; this.iframe.style.display = flag ? '' : 'none';
} }
private s: number = 1;
getScale() {
return this.s;
}
_scale() { _scale() {
if (!this.parentElement) return; if (!this.parentElement) return;
let s = 1;
const { height, width } = getState(); const { height, width } = getState();
const { offsetWidth, offsetHeight } = this.parentElement; const { offsetWidth, offsetHeight } = this.parentElement;
s = Math.min(offsetWidth / width, offsetHeight / height); this.s = Math.min(offsetWidth / width, offsetHeight / height);
if (s > 1) { if (this.s > 1) {
s = 1; this.s = 1;
} else { } 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.transform = `scale(${ this.s }) translate(-50%, -50%)`;
this._screen.style.width = width + 'px'; this.screen.style.width = width + 'px';
this._screen.style.height = height + 'px'; this.screen.style.height = height + 'px';
this.iframe.style.width = width + 'px'; this.iframe.style.width = width + 'px';
this.iframe.style.height = height + 'px'; this.iframe.style.height = height + 'px';

View file

@ -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'; 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 { export interface State extends SuperState {
messagesLoading: boolean, messagesLoading: boolean,
cssLoading: boolean, cssLoading: boolean,
disconnected: boolean, disconnected: boolean,
userPageLoading: boolean, userPageLoading: boolean,
markedTargets: MarkedTarget[] | null,
activeTargetIndex: number
} }
export const INITIAL_STATE: State = { export const INITIAL_STATE: State = {
@ -15,40 +36,88 @@ export const INITIAL_STATE: State = {
cssLoading: false, cssLoading: false,
disconnected: false, disconnected: false,
userPageLoading: false, userPageLoading: false,
} markedTargets: null,
activeTargetIndex: 0
};
export default class StatedScreen extends Screen { export default class StatedScreen extends Screen {
constructor() { super(); } constructor() { super(); }
setMessagesLoading(messagesLoading: boolean) { setMessagesLoading(messagesLoading: boolean) {
// @ts-ignore
this.display(!messagesLoading); this.display(!messagesLoading);
update({ messagesLoading }); update({ messagesLoading });
} }
setCSSLoading(cssLoading: boolean) { setCSSLoading(cssLoading: boolean) {
// @ts-ignore
this.displayFrame(!cssLoading); this.displayFrame(!cssLoading);
update({ cssLoading }); update({ cssLoading });
} }
setDisconnected(disconnected: boolean) { setDisconnected(disconnected: boolean) {
if (!getState().live) return; //? if (!getState().live) return; //?
// @ts-ignore
this.display(!disconnected); this.display(!disconnected);
update({ disconnected }); update({ disconnected });
} }
setUserPageLoading(userPageLoading: boolean) { setUserPageLoading(userPageLoading: boolean) {
// @ts-ignore
this.display(!userPageLoading); this.display(!userPageLoading);
update({ userPageLoading }); update({ userPageLoading });
} }
setSize({ height, width }: { height: number, width: number }) { setSize({ height, width }: { height: number, width: number }) {
update({ width, height }); update({ width, height });
// @ts-ignore
this.scale(); 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 });
}
} }
} }

View file

@ -17,6 +17,7 @@ export enum CallingState {
export enum ConnectionStatus { export enum ConnectionStatus {
Connecting, Connecting,
WaitingMessages,
Connected, Connected,
Inactive, Inactive,
Disconnected, Disconnected,
@ -36,6 +37,8 @@ export function getStatusText(status: ConnectionStatus): string {
return "Disconnected"; return "Disconnected";
case ConnectionStatus.Error: case ConnectionStatus.Error:
return "Something went wrong. Try to reload the page."; 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 { export default class AssistManager {
constructor(private session, private md: MessageDistributor) {} 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 { private get peerID(): string {
return `${this.session.projectKey}-${this.session.sessionId}` return `${this.session.projectKey}-${this.session.sessionId}`
} }
@ -126,7 +144,7 @@ export default class AssistManager {
console.error("AssistManager: trying to connect more than once"); console.error("AssistManager: trying to connect more than once");
return; return;
} }
this.md.setMessagesLoading(true); this.setStatus(ConnectionStatus.Connecting)
import('peerjs').then(({ default: Peer }) => { import('peerjs').then(({ default: Peer }) => {
// @ts-ignore // @ts-ignore
const peer = new Peer({ const peer = new Peer({
@ -139,16 +157,16 @@ export default class AssistManager {
peer.on('error', e => { peer.on('error', e => {
if (['peer-unavailable', 'network'].includes(e.type)) { if (['peer-unavailable', 'network'].includes(e.type)) {
if (this.peer && this.connectionAttempts++ < MAX_RECONNECTION_COUNT) { if (this.peer && this.connectionAttempts++ < MAX_RECONNECTION_COUNT) {
update({ peerConnectionStatus: ConnectionStatus.Connecting }); this.setStatus(ConnectionStatus.Connecting);
console.log("peerunavailable") console.log("peerunavailable")
this.connectToPeer(); this.connectToPeer();
} else { } else {
update({ peerConnectionStatus: ConnectionStatus.Disconnected }); this.setStatus(ConnectionStatus.Disconnected);
this.dataCheckIntervalID && clearInterval(this.dataCheckIntervalID); this.dataCheckIntervalID && clearInterval(this.dataCheckIntervalID);
} }
} else { } else {
console.error(`PeerJS error (on peer). Type ${e.type}`, e); console.error(`PeerJS error (on peer). Type ${e.type}`, e);
update({ peerConnectionStatus: ConnectionStatus.Error }) this.setStatus(ConnectionStatus.Error)
} }
}) })
peer.on("open", () => { peer.on("open", () => {
@ -163,7 +181,7 @@ export default class AssistManager {
private dataCheckIntervalID: ReturnType<typeof setInterval> | undefined; private dataCheckIntervalID: ReturnType<typeof setInterval> | undefined;
private connectToPeer() { private connectToPeer() {
if (!this.peer) { return; } if (!this.peer) { return; }
update({ peerConnectionStatus: ConnectionStatus.Connecting }) this.setStatus(ConnectionStatus.Connecting);
const id = this.peerID; const id = this.peerID;
console.log("trying to connect to", id) console.log("trying to connect to", id)
const conn = this.peer.connect(id, { serialization: 'json', reliable: true}); const conn = this.peer.connect(id, { serialization: 'json', reliable: true});
@ -174,14 +192,14 @@ export default class AssistManager {
let i = 0; let i = 0;
let firstMessage = true; let firstMessage = true;
update({ peerConnectionStatus: ConnectionStatus.Connected }) this.setStatus(ConnectionStatus.WaitingMessages)
conn.on('data', (data) => { conn.on('data', (data) => {
if (!Array.isArray(data)) { return this.handleCommand(data); } if (!Array.isArray(data)) { return this.handleCommand(data); }
this.mesagesRecieved = true; this.mesagesRecieved = true;
if (firstMessage) { if (firstMessage) {
firstMessage = false; firstMessage = false;
this.md.setMessagesLoading(false); this.setStatus(ConnectionStatus.Connected)
} }
let time = 0; let time = 0;
@ -230,8 +248,7 @@ export default class AssistManager {
const onDataClose = () => { const onDataClose = () => {
this.initiateCallEnd(); this.initiateCallEnd();
this.md.setMessagesLoading(true); this.setStatus(ConnectionStatus.Connecting);
update({ peerConnectionStatus: ConnectionStatus.Connecting });
console.log('closed peer conn. Reconnecting...') console.log('closed peer conn. Reconnecting...')
this.connectToPeer(); this.connectToPeer();
} }
@ -244,7 +261,7 @@ export default class AssistManager {
conn.on('close', onDataClose);// Does it work ? conn.on('close', onDataClose);// Does it work ?
conn.on("error", (e) => { conn.on("error", (e) => {
console.log("PeerJS connection 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 // @ts-ignore
this.md.display(false); this.md.display(false);
this.dataConnection?.close(); this.dataConnection?.close();
update({ peerConnectionStatus: ConnectionStatus.Disconnected }); this.setStatus(ConnectionStatus.Disconnected);
}, 8000); // TODO: more convenient way }, 8000); // TODO: more convenient way
//this.dataConnection?.close(); //this.dataConnection?.close();
return; return;
@ -313,7 +330,7 @@ export default class AssistManager {
return; return;
case "call_error": case "call_error":
this.onTrackerCallEnd(); this.onTrackerCallEnd();
update({ peerConnectionStatus: ConnectionStatus.Error }); this.setStatus(ConnectionStatus.Error);
return; return;
} }
} }

View file

@ -24,7 +24,7 @@ export const ID_TP_MAP = {
22: "console_log", 22: "console_log",
37: "css_insert_rule", 37: "css_insert_rule",
38: "css_delete_rule", 38: "css_delete_rule",
39: "fetch_depricated", 39: "fetch",
40: "profiler", 40: "profiler",
41: "o_table", 41: "o_table",
44: "redux", 44: "redux",
@ -36,7 +36,6 @@ export const ID_TP_MAP = {
54: "connection_information", 54: "connection_information",
55: "set_page_visibility", 55: "set_page_visibility",
59: "long_task", 59: "long_task",
68: "fetch",
} as const; } as const;
@ -167,8 +166,8 @@ export interface CssDeleteRule {
index: number, index: number,
} }
export interface FetchDepricated { export interface Fetch {
tp: "fetch_depricated", tp: "fetch",
method: string, method: string,
url: string, url: string,
request: string, request: string,
@ -256,20 +255,8 @@ export interface LongTask {
containerName: string, 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 | Fetch | Profiler | OTable | Redux | Vuex | MobX | NgRx | GraphQl | PerformanceTrack | ConnectionInformation | SetPageVisibility | LongTask;
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 default function (r: PrimitiveReader): Message | null { export default function (r: PrimitiveReader): Message | null {
switch (r.readUint()) { switch (r.readUint()) {
@ -522,19 +509,6 @@ export default function (r: PrimitiveReader): Message | null {
containerName: r.readString(), 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: default:
r.readUint(); // IOS skip timestamp r.readUint(); // IOS skip timestamp
r.skip(r.readUint()); r.skip(r.readUint());

Some files were not shown because too many files have changed in this diff Show more