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_KEY": "",
"S3_SECRET": "",
"invitation_link": "/api/users/invitation?token=%s",
"change_password_link": "/reset-password?invitation=%s&&pass=%s",
"version_number": "1.2.0"
},
"lambda_timeout": 150,

View file

@ -60,7 +60,7 @@ _overrides.chalice_app(app)
def or_middleware(event, get_response):
global OR_SESSION_TOKEN
OR_SESSION_TOKEN = app.current_request.headers.get('vnd.openreplay.com.sid',
app.current_request.headers.get('vnd.asayer.io.sid'))
app.current_request.headers.get('vnd.asayer.io.sid'))
if "authorizer" in event.context and event.context["authorizer"] is None:
print("Deleted user!!")
pg_client.close()
@ -71,7 +71,13 @@ def or_middleware(event, get_response):
import time
now = int(time.time() * 1000)
response = get_response(event)
if response.status_code == 500 and helper.allow_sentry() and OR_SESSION_TOKEN is not None and not helper.is_local():
if response.status_code == 200 and response.body is not None and response.body.get("errors") is not None:
if "not found" in response.body["errors"][0]:
response = Response(status_code=404, body=response.body)
else:
response = Response(status_code=400, body=response.body)
if response.status_code // 100 == 5 and helper.allow_sentry() and OR_SESSION_TOKEN is not None and not helper.is_local():
with configure_scope() as scope:
scope.set_tag('stage', environ["stage"])
scope.set_tag('openReplaySessionToken', OR_SESSION_TOKEN)

View file

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

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

View file

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

View file

@ -38,9 +38,9 @@ def login():
for_plugin=False
)
if r is None:
return {
return Response(status_code=401, body={
'errors': ['Youve entered invalid Email or Password.']
}
})
tenant_id = r.pop("tenantId")
@ -53,11 +53,12 @@ def login():
c.pop("createdAt")
c["projects"] = projects.get_projects(tenant_id=tenant_id, recording_state=True, recorded=True,
stack_integrations=True)
c["smtp"] = helper.has_smtp()
return {
'jwt': r.pop('jwt'),
'data': {
"user": r,
"client": c,
"client": c
}
}
@ -74,7 +75,7 @@ def get_account(context):
"metadata": metadata.get_remaining_metadata_with_count(context['tenantId'])
},
**license.get_status(context["tenantId"]),
"smtp": environ["EMAIL_HOST"] is not None and len(environ["EMAIL_HOST"]) > 0
"smtp": helper.has_smtp()
}
}
@ -100,8 +101,11 @@ def create_edit_project(projectId, context):
@app.route('/projects/{projectId}', methods=['GET'])
def get_project(projectId, context):
return {"data": projects.get_project(tenant_id=context["tenantId"], project_id=projectId, include_last_session=True,
include_gdpr=True)}
data = projects.get_project(tenant_id=context["tenantId"], project_id=projectId, include_last_session=True,
include_gdpr=True)
if data is None:
return {"errors": ["project not found"]}
return {"data": data}
@app.route('/projects/{projectId}', methods=['DELETE'])
@ -353,6 +357,38 @@ def add_member(context):
return users.create_member(tenant_id=context['tenantId'], user_id=context['userId'], data=data)
@app.route('/users/invitation', methods=['GET'], authorizer=None)
def process_invitation_link():
params = app.current_request.query_params
if params is None or len(params.get("token", "")) < 64:
return {"errors": ["please provide a valid invitation"]}
user = users.get_by_invitation_token(params["token"])
if user is None:
return {"errors": ["invitation not found"]}
if user["expiredInvitation"]:
return {"errors": ["expired invitation, please ask your admin to send a new one"]}
pass_token = users.allow_password_change(user_id=user["userId"])
return Response(
status_code=307,
body='',
headers={'Location': environ["SITE_URL"] + environ["change_password_link"] % (params["token"], pass_token),
'Content-Type': 'text/plain'})
@app.route('/users/invitation/password', methods=['POST', 'PUT'], authorizer=None)
def change_password_by_invitation():
data = app.current_request.json_body
if data is None or len(data.get("invitation", "")) < 64 or len(data.get("pass", "")) < 8:
return {"errors": ["please provide a valid invitation & pass"]}
user = users.get_by_invitation_token(token=data["token"], pass_token=data["pass"])
if user is None:
return {"errors": ["invitation not found"]}
if user["expiredChange"]:
return {"errors": ["expired change, please re-use the invitation link"]}
return users.set_password_invitation(new_password=data["password"], user_id=user["userId"])
@app.route('/client/members/{memberId}', methods=['PUT', 'POST'])
def edit_member(memberId, context):
data = app.current_request.json_body
@ -360,6 +396,11 @@ def edit_member(memberId, context):
user_id_to_update=memberId)
@app.route('/client/members/{memberId}/reset', methods=['GET'])
def reset_reinvite_member(memberId, context):
return users.reset_member(tenant_id=context['tenantId'], editor_id=context['userId'], user_id_to_update=memberId)
@app.route('/client/members/{memberId}', methods=['DELETE'])
def delete_member(memberId, context):
return users.delete_member(tenant_id=context["tenantId"], user_id=context['userId'], id_to_delete=memberId)

View file

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

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):
f = get(funnel_id=funnel_id, project_id=project_id)
if f is None:
return {"errors": ["filter not found"]}
return {"errors": ["funnel not found"]}
get_start_end_time(filter_d=f["filter"], range_value=range_value, start_date=start_date, end_date=end_date)
return sessions.search2_pg(data=f["filter"], project_id=project_id, user_id=user_id)
@ -166,12 +166,12 @@ def get_sessions_on_the_fly(funnel_id, project_id, user_id, data):
def get_top_insights(project_id, funnel_id, range_value=None, start_date=None, end_date=None):
f = get(funnel_id=funnel_id, project_id=project_id)
if f is None:
return {"errors": ["filter not found"]}
return {"errors": ["funnel not found"]}
get_start_end_time(filter_d=f["filter"], range_value=range_value, start_date=start_date, end_date=end_date)
insights, total_drop_due_to_issues = significance.get_top_insights(filter_d=f["filter"], project_id=project_id)
insights[-1]["dropDueToIssues"] = total_drop_due_to_issues
return {"stages": helper.list_to_camel_case(insights),
"totalDropDueToIssues": total_drop_due_to_issues}
return {"data": {"stages": helper.list_to_camel_case(insights),
"totalDropDueToIssues": total_drop_due_to_issues}}
def get_top_insights_on_the_fly(funnel_id, project_id, data):
@ -187,8 +187,8 @@ def get_top_insights_on_the_fly(funnel_id, project_id, data):
insights, total_drop_due_to_issues = significance.get_top_insights(filter_d=data, project_id=project_id)
if len(insights) > 0:
insights[-1]["dropDueToIssues"] = total_drop_due_to_issues
return {"stages": helper.list_to_camel_case(insights),
"totalDropDueToIssues": total_drop_due_to_issues}
return {"data": {"stages": helper.list_to_camel_case(insights),
"totalDropDueToIssues": total_drop_due_to_issues}}
def get_issues(project_id, funnel_id, range_value=None, start_date=None, end_date=None):

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.core import projects
import re
@ -24,9 +23,10 @@ def get(project_id):
)
metas = cur.fetchone()
results = []
for i, k in enumerate(metas.keys()):
if metas[k] is not None:
results.append({"key": metas[k], "index": i + 1})
if metas is not None:
for i, k in enumerate(metas.keys()):
if metas[k] is not None:
results.append({"key": metas[k], "index": i + 1})
return results
@ -56,7 +56,7 @@ def __edit(project_id, col_index, colname, new_name):
old_metas = get(project_id)
old_metas = {k["index"]: k for k in old_metas}
if col_index not in list(old_metas.keys()):
return {"errors": ["custom field doesn't exist"]}
return {"errors": ["custom field not found"]}
with pg_client.PostgresClient() as cur:
if old_metas[col_index]["key"].lower() != new_name:
@ -79,7 +79,7 @@ def delete(tenant_id, project_id, index: int):
old_segments = get(project_id)
old_segments = [k["index"] for k in old_segments]
if index not in old_segments:
return {"errors": ["custom field doesn't exist"]}
return {"errors": ["custom field not found"]}
with pg_client.PostgresClient() as cur:
colname = index_to_colname(index)

View file

@ -18,48 +18,23 @@ def step1(data):
a_users = users.get_by_email_only(data["email"])
if len(a_users) > 1:
print(f"multiple users found for [{data['email']}] please contact our support")
return {"errors": ["please contact our support"]}
return {"errors": ["multiple users, please contact our support"]}
elif len(a_users) == 1:
a_users = a_users[0]
reset_token = secrets.token_urlsafe(6)
users.update(tenant_id=a_users["tenantId"], user_id=a_users["id"],
changes={"token": reset_token})
email_helper.send_reset_code(recipient=data["email"], reset_code=reset_token)
invitation_link=users.generate_new_invitation(user_id=a_users["id"])
email_helper.send_forgot_password(recipient=data["email"], invitation_link=invitation_link)
else:
print(f"invalid email address [{data['email']}]")
return {"errors": ["invalid email address"]}
return {"data": {"state": "success"}}
def step2(data):
print("====================== change password 2 ===============")
user = users.get_by_email_reset(data["email"], data["code"])
if not user:
print("error: wrong email or reset code")
return {"errors": ["wrong email or reset code"]}
users.update(tenant_id=user["tenantId"], user_id=user["id"],
changes={"token": None, "password": data["password"], "generatedPassword": False})
return {"data": {"state": "success"}}
def cron():
with pg_client.PostgresClient() as cur:
cur.execute(
cur.mogrify("""\
SELECT user_id
FROM public.basic_authentication
WHERE token notnull
AND (token_requested_at isnull or (EXTRACT(EPOCH FROM token_requested_at)*1000)::BIGINT < %(time)s);""",
{"time": chalicelib.utils.TimeUTC.TimeUTC.now(delta_days=-1)})
)
results = cur.fetchall()
if len(results) == 0:
return
results = tuple([r["user_id"] for r in results])
cur.execute(
cur.mogrify("""\
UPDATE public.basic_authentication
SET token = NULL, token_requested_at = NULL
WHERE user_id in %(ids)s;""",
{"ids": results})
)
# def step2(data):
# print("====================== change password 2 ===============")
# user = users.get_by_email_reset(data["email"], data["code"])
# if not user:
# print("error: wrong email or reset code")
# return {"errors": ["wrong email or reset code"]}
# users.update(tenant_id=user["tenantId"], user_id=user["id"],
# changes={"token": None, "password": data["password"], "generatedPassword": False})
# return {"data": {"state": "success"}}

View file

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

View file

@ -62,7 +62,7 @@ def create_step1(data):
errors.append("Tenant already exists, please select it from dropdown")
elif len(signed_ups) == 0 and data.get("tenantId") is not None \
or len(signed_ups) > 0 and data.get("tenantId") not in [t['tenantId'] for t in signed_ups]:
errors.append("Tenant does not exist")
errors.append("Tenant not found")
if len(errors) > 0:
print("==> error")

View file

@ -9,20 +9,26 @@ from chalicelib.utils.TimeUTC import TimeUTC
from chalicelib.utils.helper import environ
from chalicelib.core import tenants
import secrets
def create_new_member(email, password, admin, name, owner=False):
def __generate_invitation_token():
return secrets.token_urlsafe(64)
def create_new_member(email, invitation_token, admin, name, owner=False):
with pg_client.PostgresClient() as cur:
query = cur.mogrify(f"""\
WITH u AS (
INSERT INTO public.users (email, role, name, data)
VALUES (%(email)s, %(role)s, %(name)s, %(data)s)
RETURNING user_id,email,role,name,appearance
),
au AS (INSERT
INTO public.basic_authentication (user_id, password, generated_password)
VALUES ((SELECT user_id FROM u), crypt(%(password)s, gen_salt('bf', 12)), TRUE))
SELECT u.user_id AS id,
WITH u AS (INSERT INTO public.users (email, role, name, data)
VALUES (%(email)s, %(role)s, %(name)s, %(data)s)
RETURNING user_id,email,role,name,appearance
),
au AS (INSERT INTO public.basic_authentication (user_id, generated_password, invitation_token, invited_at)
VALUES ((SELECT user_id FROM u), TRUE, %(invitation_token)s, timezone('utc'::text, now()))
RETURNING invitation_token
)
SELECT u.user_id,
u.user_id AS id,
u.email,
u.role,
u.name,
@ -30,18 +36,18 @@ def create_new_member(email, password, admin, name, owner=False):
(CASE WHEN u.role = 'owner' THEN TRUE ELSE FALSE END) AS super_admin,
(CASE WHEN u.role = 'admin' THEN TRUE ELSE FALSE END) AS admin,
(CASE WHEN u.role = 'member' THEN TRUE ELSE FALSE END) AS member,
u.appearance
FROM u;""",
{"email": email, "password": password,
"role": "owner" if owner else "admin" if admin else "member", "name": name,
"data": json.dumps({"lastAnnouncementView": TimeUTC.now()})})
au.invitation_token
FROM u,au;""",
{"email": email, "role": "owner" if owner else "admin" if admin else "member", "name": name,
"data": json.dumps({"lastAnnouncementView": TimeUTC.now()}),
"invitation_token": invitation_token})
cur.execute(
query
)
return helper.dict_to_camel_case(cur.fetchone())
def restore_member(user_id, email, password, admin, name, owner=False):
def restore_member(user_id, email, invitation_token, admin, name, owner=False):
with pg_client.PostgresClient() as cur:
query = cur.mogrify(f"""\
UPDATE public.users
@ -58,31 +64,62 @@ def restore_member(user_id, email, password, admin, name, owner=False):
TRUE AS change_password,
(CASE WHEN role = 'owner' THEN TRUE ELSE FALSE END) AS super_admin,
(CASE WHEN role = 'admin' THEN TRUE ELSE FALSE END) AS admin,
(CASE WHEN role = 'member' THEN TRUE ELSE FALSE END) AS member,
appearance;""",
(CASE WHEN role = 'member' THEN TRUE ELSE FALSE END) AS member;""",
{"user_id": user_id, "email": email,
"role": "owner" if owner else "admin" if admin else "member", "name": name})
cur.execute(
query
)
result = helper.dict_to_camel_case(cur.fetchone())
result = cur.fetchone()
query = cur.mogrify("""\
UPDATE public.basic_authentication
SET password= crypt(%(password)s, gen_salt('bf', 12)),
generated_password= TRUE,
token=NULL,
token_requested_at=NULL
WHERE user_id=%(user_id)s;""",
{"user_id": user_id, "password": password})
SET generated_password = TRUE,
invitation_token = %(invitation_token)s,
invited_at = timezone('utc'::text, now()),
change_pwd_expire_at = NULL,
change_pwd_token = NULL
WHERE user_id=%(user_id)s
RETURNING invitation_token;""",
{"user_id": user_id, "invitation_token": invitation_token})
cur.execute(
query
)
result["invitation_token"] = cur.fetchone()["invitation_token"]
return result
return helper.dict_to_camel_case(result)
def generate_new_invitation(user_id):
invitation_token = __generate_invitation_token()
with pg_client.PostgresClient() as cur:
query = cur.mogrify("""\
UPDATE public.basic_authentication
SET invitation_token = %(invitation_token)s,
invited_at = timezone('utc'::text, now()),
change_pwd_expire_at = NULL,
change_pwd_token = NULL
WHERE user_id=%(user_id)s
RETURNING invitation_token;""",
{"user_id": user_id, "invitation_token": invitation_token})
cur.execute(
query
)
return __get_invitation_link(cur.fetchone().pop("invitation_token"))
def reset_member(tenant_id, editor_id, user_id_to_update):
admin = get(tenant_id=tenant_id, user_id=editor_id)
if not admin["admin"] and not admin["superAdmin"]:
return {"errors": ["unauthorized"]}
user = get(tenant_id=tenant_id, user_id=user_id_to_update)
if not user:
return {"errors": ["user not found"]}
return {"data": {"invitationLink": generate_new_invitation(user_id_to_update)}}
def update(tenant_id, user_id, changes):
AUTH_KEYS = ["password", "generatedPassword", "token"]
AUTH_KEYS = ["password", "generatedPassword", "invitationToken", "invitedAt", "changePwdExpireAt", "changePwdToken"]
if len(changes.keys()) == 0:
return None
@ -93,13 +130,6 @@ def update(tenant_id, user_id, changes):
if key == "password":
sub_query_bauth.append("password = crypt(%(password)s, gen_salt('bf', 12))")
sub_query_bauth.append("changed_at = timezone('utc'::text, now())")
elif key == "token":
if changes[key] is not None:
sub_query_bauth.append("token = %(token)s")
sub_query_bauth.append("token_requested_at = timezone('utc'::text, now())")
else:
sub_query_bauth.append("token = NULL")
sub_query_bauth.append("token_requested_at = NULL")
else:
sub_query_bauth.append(f"{helper.key_to_snake_case(key)} = %({key})s")
else:
@ -166,26 +196,43 @@ def create_member(tenant_id, user_id, data):
return {"errors": ["invalid user name"]}
if name is None:
name = data["email"]
temp_pass = helper.generate_salt()[:8]
invitation_token = __generate_invitation_token()
user = get_deleted_user_by_email(email=data["email"])
if user is not None:
new_member = restore_member(email=data["email"], password=temp_pass,
new_member = restore_member(email=data["email"], invitation_token=invitation_token,
admin=data.get("admin", False), name=name, user_id=user["userId"])
else:
new_member = create_new_member(email=data["email"], password=temp_pass,
new_member = create_new_member(email=data["email"], invitation_token=invitation_token,
admin=data.get("admin", False), name=name)
new_member["invitationLink"] = __get_invitation_link(new_member.pop("invitationToken"))
helper.async_post(environ['email_basic'] % 'member_invitation',
{
"email": data["email"],
"userName": data["email"],
"tempPassword": temp_pass,
"invitationLink": new_member["invitationLink"],
"clientId": tenants.get_by_tenant_id(tenant_id)["name"],
"senderName": admin["name"]
})
return {"data": new_member}
def __get_invitation_link(invitation_token):
return environ["SITE_URL"] + environ["invitation_link"] % invitation_token
def allow_password_change(user_id, delta_min=10):
pass_token = secrets.token_urlsafe(8)
with pg_client.PostgresClient() as cur:
query = cur.mogrify(f"""UPDATE public.basic_authentication
SET change_pwd_expire_at = timezone('utc'::text, now()+INTERVAL '%(delta)s MINUTES'),
change_pwd_token = %(pass_token)s
WHERE user_id = %(user_id)s""",
{"user_id": user_id, "delta": delta_min, "pass_token": pass_token})
cur.execute(
query
)
return pass_token
def get(user_id, tenant_id):
with pg_client.PostgresClient() as cur:
cur.execute(
@ -317,14 +364,24 @@ def get_members(tenant_id):
basic_authentication.generated_password,
(CASE WHEN users.role = 'owner' THEN TRUE ELSE FALSE END) AS super_admin,
(CASE WHEN users.role = 'admin' THEN TRUE ELSE FALSE END) AS admin,
(CASE WHEN users.role = 'member' THEN TRUE ELSE FALSE END) AS member
(CASE WHEN users.role = 'member' THEN TRUE ELSE FALSE END) AS member,
DATE_PART('day',timezone('utc'::text, now()) \
- COALESCE(basic_authentication.invited_at,'2000-01-01'::timestamp ))>=1 AS expired_invitation,
basic_authentication.password IS NOT NULL AS joined,
invitation_token
FROM public.users LEFT JOIN public.basic_authentication ON users.user_id=basic_authentication.user_id
WHERE users.deleted_at IS NULL
ORDER BY name, id"""
)
r = cur.fetchall()
if len(r):
return helper.list_to_camel_case(r)
r = helper.list_to_camel_case(r)
for u in r:
if u["invitationToken"]:
u["invitationLink"] = __get_invitation_link(u.pop("invitationToken"))
else:
u["invitationLink"] = None
return r
return []
@ -367,6 +424,15 @@ def change_password(tenant_id, user_id, email, old_password, new_password):
"jwt": authenticate(email, new_password)["jwt"]}
def set_password_invitation(user_id, new_password):
changes = {"password": new_password, "generatedPassword": False,
"invitationToken": None, "invitedAt": None,
"changePwdExpireAt": None, "changePwdToken": None}
user = update(tenant_id=-1, user_id=user_id, changes=changes)
return {"data": user,
"jwt": authenticate(user["email"], new_password)["jwt"]}
def count_members():
with pg_client.PostgresClient() as cur:
cur.execute("""SELECT COUNT(user_id)
@ -409,6 +475,24 @@ def get_deleted_user_by_email(email):
return helper.dict_to_camel_case(r)
def get_by_invitation_token(token, pass_token=None):
with pg_client.PostgresClient() as cur:
cur.execute(
cur.mogrify(
f"""SELECT
*,
DATE_PART('day',timezone('utc'::text, now()) \
- COALESCE(basic_authentication.invited_at,'2000-01-01'::timestamp ))>=1 AS expired_invitation,
change_pwd_expire_at <= timezone('utc'::text, now()) AS expired_change
FROM public.users INNER JOIN public.basic_authentication USING(user_id)
WHERE invitation_token = %(token)s {"AND change_pwd_token = %(pass_token)s" if pass_token else ""}
LIMIT 1;""",
{"token": token, "pass_token": token})
)
r = cur.fetchone()
return helper.dict_to_camel_case(r)
def auth_exists(user_id, tenant_id, jwt_iat, jwt_aud):
with pg_client.PostgresClient() as cur:
cur.execute(

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

View file

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

View file

@ -447,10 +447,10 @@ width: 25%!important
<p style="font-size: 14px; line-height: 16px; text-align: center; margin: 0;">
Please use this link to login:</p>
<p style="font-size: 14px; line-height: 21px; text-align: center; margin: 0;">
<span style="font-size: 18px;"><a href="%(frontend_url)s"
<span style="font-size: 18px;"><a href="%(invitationLink)s"
rel="noopener"
style="text-decoration: underline; color: #009193;"
target="_blank" title="OpenReplay Login">%(frontend_url)s</a></span><span
target="_blank" title="OpenReplay Login">%(invitationLink)s</a></span><span
style="font-size: 18px; line-height: 21px;"></span></p>
</div>
</div>
@ -485,40 +485,18 @@ width: 25%!important
<tr>
<td style="padding-right: 10px; padding-left: 10px; padding-top: 10px; padding-bottom: 10px; font-family: Arial, sans-serif">
<![endif]-->
<div style="color:#555555;font-family:-apple-system,BlinkMacSystemFont,'Helvetica Neue','Segoe UI',Roboto,Oxygen-Sans,Ubuntu,Cantarell,sans-serif;line-height:120%;padding-top:10px;padding-right:10px;padding-bottom:10px;padding-left:10px;">
<div style="font-size: 12px; line-height: 14px; color: #555555; font-family: -apple-system,BlinkMacSystemFont,'Helvetica Neue','Segoe UI',Roboto,Oxygen-Sans,Ubuntu,Cantarell,sans-serif;">
<p style="font-size: 14px; line-height: 16px; text-align: center; margin: 0;">
Your login credentials</p>
</div>
</div>
<!--[if mso]></td></tr></table><![endif]-->
<!--[if mso]>
<table width="100%" cellpadding="0" cellspacing="0" border="0">
<tr>
<td style="padding-right: 10px; padding-left: 10px; padding-top: 10px; padding-bottom: 10px; font-family: Arial, sans-serif">
<![endif]-->
<div style="color:#555555;font-family:-apple-system,BlinkMacSystemFont,'Helvetica Neue','Segoe UI',Roboto,Oxygen-Sans,Ubuntu,Cantarell,sans-serif;line-height:120%;padding-top:10px;padding-right:10px;padding-bottom:10px;padding-left:10px;">
<div style="font-size: 12px; line-height: 14px; color: #555555; font-family: -apple-system,BlinkMacSystemFont,'Helvetica Neue','Segoe UI',Roboto,Oxygen-Sans,Ubuntu,Cantarell,sans-serif;">
<p style="font-size: 14px; line-height: 16px; text-align: center; margin: 0;">
<strong>Username / Email</strong></p>
<p style="font-size: 14px; line-height: 16px; text-align: center; margin: 0;">
<span style="text-decoration: none; color: #009193;">%(userName)s</span></p>
</div>
</div>
<!--[if mso]></td></tr></table><![endif]-->
<!--[if mso]>
<table width="100%" cellpadding="0" cellspacing="0" border="0">
<tr>
<td style="padding-right: 10px; padding-left: 10px; padding-top: 10px; padding-bottom: 10px; font-family: Arial, sans-serif">
<![endif]-->
<div style="color:#555555;font-family:-apple-system,BlinkMacSystemFont,'Helvetica Neue','Segoe UI',Roboto,Oxygen-Sans,Ubuntu,Cantarell,sans-serif;line-height:120%;padding-top:10px;padding-right:10px;padding-bottom:10px;padding-left:10px;">
<div style="font-size: 12px; line-height: 14px; color: #555555; font-family: -apple-system,BlinkMacSystemFont,'Helvetica Neue','Segoe UI',Roboto,Oxygen-Sans,Ubuntu,Cantarell,sans-serif;">
<p style="font-size: 14px; line-height: 16px; text-align: center; margin: 0;">
<strong>Password</strong></p>
<p style="font-size: 14px; line-height: 16px; text-align: center; margin: 0;">
%(password)s</p>
</div>
</div>
<!--[if mso]></td></tr></table><![endif]-->
<table border="0" cellpadding="0" cellspacing="0" class="divider"
role="presentation"

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="font-size: 12px; line-height: 14px; color: #555555; font-family: -apple-system,BlinkMacSystemFont,'Helvetica Neue','Segoe UI',Roboto,Oxygen-Sans,Ubuntu,Cantarell,sans-serif;">
<p style="font-size: 14px; line-height: 16px; text-align: center; margin: 0;">
Use the code below to reset your password (valid for 24 hours only):</p>
Use the link below to reset your password (valid for 24 hours only):</p>
<p style="font-size: 14px; line-height: 21px; text-align: center; margin: 0;">
<br/>
<span style="font-size: 18px;"><b>%(code)s</b></span><span
<span style="font-size: 18px;"><a href="%(invitationLink)s"><b>%(invitationLink)s</b></a></span><span
style="font-size: 18px; line-height: 21px;"></span></p>
</div>
</div>

View file

@ -1,7 +1,3 @@
BEGIN;
CREATE INDEX pages_first_contentful_paint_time_idx ON events.pages (first_contentful_paint_time) WHERE first_contentful_paint_time>0;
CREATE INDEX pages_dom_content_loaded_time_idx ON events.pages (dom_content_loaded_time) WHERE dom_content_loaded_time>0;
CREATE INDEX pages_first_paint_time_idx ON events.pages (first_paint_time) WHERE first_paint_time > 0;
CREATE INDEX pages_ttfb_idx ON events.pages (ttfb) WHERE ttfb > 0;
CREATE INDEX pages_time_to_interactive_idx ON events.pages (time_to_interactive) WHERE time_to_interactive > 0;
COMMIT;

View file

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

View file

@ -105,11 +105,14 @@ func (conn *Conn) InsertWebClickEvent(sessionID uint64, e *ClickEvent) error {
defer tx.rollback()
if err = tx.exec(`
INSERT INTO events.clicks
(session_id, message_id, timestamp, label)
VALUES
($1, $2, $3, NULLIF($4, ''))
(session_id, message_id, timestamp, label, selector, url)
(SELECT
$1, $2, $3, NULLIF($4, ''), $5, host || base_path
FROM events.pages
WHERE session_id = $1 AND timestamp <= $3 ORDER BY timestamp DESC LIMIT 1
)
`,
sessionID, e.MessageID, e.Timestamp, e.Label,
sessionID, e.MessageID, e.Timestamp, e.Label, e.Selector,
); err != nil {
return err
}

View file

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

View file

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

View file

@ -159,7 +159,7 @@ if msg.Y, err = ReadUint(reader); err != nil { return nil, err }
return msg, nil
case 21:
msg := &MouseClick{ meta: &meta{ TypeID: 21} }
msg := &MouseClickDepricated{ meta: &meta{ TypeID: 21} }
if msg.ID, err = ReadUint(reader); err != nil { return nil, err }
if msg.HesitationTime, err = ReadUint(reader); err != nil { return nil, err }
if msg.Label, err = ReadString(reader); err != nil { return nil, err }
@ -265,6 +265,7 @@ if msg.Label, err = ReadString(reader); err != nil { return nil, err }
if msg.Timestamp, err = ReadUint(reader); err != nil { return nil, err }
if msg.HesitationTime, err = ReadUint(reader); err != nil { return nil, err }
if msg.Label, err = ReadString(reader); err != nil { return nil, err }
if msg.Selector, err = ReadString(reader); err != nil { return nil, err }
return msg, nil
case 34:
@ -512,6 +513,14 @@ if msg.Index, err = ReadUint(reader); err != nil { return nil, err }
if msg.BaseURL, err = ReadString(reader); err != nil { return nil, err }
return msg, nil
case 69:
msg := &MouseClick{ meta: &meta{ TypeID: 69} }
if msg.ID, err = ReadUint(reader); err != nil { return nil, err }
if msg.HesitationTime, err = ReadUint(reader); err != nil { return nil, err }
if msg.Label, err = ReadString(reader); err != nil { return nil, err }
if msg.Selector, err = ReadString(reader); err != nil { return nil, err }
return msg, nil
case 90:
msg := &IOSSessionStart{ meta: &meta{ TypeID: 90} }
if msg.Timestamp, err = ReadUint(reader); err != nil { return nil, err }

View file

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

View file

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

View file

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

View file

@ -11,6 +11,7 @@ type deadClickDetector struct {
lastMouseClick *MouseClick
lastTimestamp uint64
lastMessageID uint64
inputIDSet map[uint64]bool
}
@ -24,6 +25,7 @@ func (d *deadClickDetector) HandleReaction(timestamp uint64) *IssueEvent {
MessageID: d.lastMessageID,
}
}
d.inputIDSet = nil
d.lastMouseClick = nil
d.lastTimestamp = 0
d.lastMessageID = 0
@ -33,8 +35,18 @@ func (d *deadClickDetector) HandleReaction(timestamp uint64) *IssueEvent {
func (d *deadClickDetector) HandleMessage(msg Message, messageID uint64, timestamp uint64) *IssueEvent {
var i *IssueEvent
switch m := msg.(type) {
case *SetInputTarget:
if d.inputIDSet == nil {
d.inputIDSet = make(map[uint64]bool)
}
d.inputIDSet[m.ID] = true
case *CreateDocument:
d.inputIDSet = nil
case *MouseClick:
i = d.HandleReaction(timestamp)
if d.inputIDSet[m.ID] { // ignore if input
return i
}
d.lastMouseClick = m
d.lastTimestamp = timestamp
d.lastMessageID = messageID

View file

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

View file

@ -61,7 +61,9 @@
"idp_entityId": "",
"idp_sso_url": "",
"idp_x509cert": "",
"idp_sls_url": ""
"idp_sls_url": "",
"invitation_link": "/api/users/invitation?token=%s",
"change_password_link": "/reset-password?invitation=%s&&pass=%s"
},
"lambda_timeout": 150,
"lambda_memory_size": 400,

View file

@ -87,7 +87,12 @@ def or_middleware(event, get_response):
import time
now = int(time.time() * 1000)
response = get_response(event)
if response.status_code == 500 and helper.allow_sentry() and OR_SESSION_TOKEN is not None and not helper.is_local():
if response.status_code == 200 and response.body is not None and response.body.get("errors") is not None:
if "not found" in response.body["errors"][0]:
response = Response(status_code=404, body=response.body)
else:
response = Response(status_code=400, body=response.body)
if response.status_code // 100 == 5 and helper.allow_sentry() and OR_SESSION_TOKEN is not None and not helper.is_local():
with configure_scope() as scope:
scope.set_tag('stage', environ["stage"])
scope.set_tag('openReplaySessionToken', OR_SESSION_TOKEN)

View file

@ -38,9 +38,9 @@ def login():
for_plugin=False
)
if r is None:
return {
return Response(status_code=401, body={
'errors': ['Youve entered invalid Email or Password.']
}
})
elif "errors" in r:
return r
@ -103,8 +103,11 @@ def create_edit_project(projectId, context):
@app.route('/projects/{projectId}', methods=['GET'])
def get_project(projectId, context):
return {"data": projects.get_project(tenant_id=context["tenantId"], project_id=projectId, include_last_session=True,
include_gdpr=True)}
data = projects.get_project(tenant_id=context["tenantId"], project_id=projectId, include_last_session=True,
include_gdpr=True)
if data is None:
return {"errors": ["project not found"]}
return {"data": data}
@app.route('/projects/{projectId}', methods=['DELETE'])
@ -359,6 +362,38 @@ def add_member(context):
return users.create_member(tenant_id=context['tenantId'], user_id=context['userId'], data=data)
@app.route('/users/invitation', methods=['GET'], authorizer=None)
def process_invitation_link():
params = app.current_request.query_params
if params is None or len(params.get("token", "")) < 64:
return {"errors": ["please provide a valid invitation"]}
user = users.get_by_invitation_token(params["token"])
if user is None:
return {"errors": ["invitation not found"]}
if user["expiredInvitation"]:
return {"errors": ["expired invitation, please ask your admin to send a new one"]}
pass_token = users.allow_password_change(user_id=user["userId"])
return Response(
status_code=307,
body='',
headers={'Location': environ["SITE_URL"] + environ["change_password_link"] % (params["token"], pass_token),
'Content-Type': 'text/plain'})
@app.route('/users/invitation/password', methods=['POST', 'PUT'], authorizer=None)
def change_password_by_invitation():
data = app.current_request.json_body
if data is None or len(data.get("invitation", "")) < 64 or len(data.get("pass", "")) < 8:
return {"errors": ["please provide a valid invitation & pass"]}
user = users.get_by_invitation_token(token=data["token"], pass_token=data["pass"])
if user is None:
return {"errors": ["invitation not found"]}
if user["expiredChange"]:
return {"errors": ["expired change, please re-use the invitation link"]}
return users.set_password_invitation(new_password=data["password"], user_id=user["userId"])
@app.route('/client/members/{memberId}', methods=['PUT', 'POST'])
def edit_member(memberId, context):
data = app.current_request.json_body
@ -366,6 +401,11 @@ def edit_member(memberId, context):
user_id_to_update=memberId)
@app.route('/client/members/{memberId}/reset', methods=['GET'])
def reset_reinvite_member(memberId, context):
return users.reset_member(tenant_id=context['tenantId'], editor_id=context['userId'], user_id_to_update=memberId)
@app.route('/client/members/{memberId}', methods=['DELETE'])
def delete_member(memberId, context):
return users.delete_member(tenant_id=context["tenantId"], user_id=context['userId'], id_to_delete=memberId)

View file

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

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):
f = get(funnel_id=funnel_id, project_id=project_id)
if f is None:
return {"errors": ["filter not found"]}
return {"errors": ["funnel not found"]}
get_start_end_time(filter_d=f["filter"], range_value=range_value, start_date=start_date, end_date=end_date)
return sessions.search2_pg(data=f["filter"], project_id=project_id, user_id=user_id)
@ -172,12 +172,12 @@ def get_sessions_on_the_fly(funnel_id, project_id, user_id, data):
def get_top_insights(project_id, funnel_id, range_value=None, start_date=None, end_date=None):
f = get(funnel_id=funnel_id, project_id=project_id)
if f is None:
return {"errors": ["filter not found"]}
return {"errors": ["funnel not found"]}
get_start_end_time(filter_d=f["filter"], range_value=range_value, start_date=start_date, end_date=end_date)
insights, total_drop_due_to_issues = significance.get_top_insights(filter_d=f["filter"], project_id=project_id)
insights[-1]["dropDueToIssues"] = total_drop_due_to_issues
return {"stages": helper.list_to_camel_case(insights),
"totalDropDueToIssues": total_drop_due_to_issues}
return {"data": {"stages": helper.list_to_camel_case(insights),
"totalDropDueToIssues": total_drop_due_to_issues}}
def get_top_insights_on_the_fly(funnel_id, project_id, data):
@ -193,8 +193,8 @@ def get_top_insights_on_the_fly(funnel_id, project_id, data):
insights, total_drop_due_to_issues = significance.get_top_insights(filter_d=data, project_id=project_id)
if len(insights) > 0:
insights[-1]["dropDueToIssues"] = total_drop_due_to_issues
return {"stages": helper.list_to_camel_case(insights),
"totalDropDueToIssues": total_drop_due_to_issues}
return {"data": {"stages": helper.list_to_camel_case(insights),
"totalDropDueToIssues": total_drop_due_to_issues}}
def get_issues(project_id, funnel_id, range_value=None, start_date=None, end_date=None):
@ -272,4 +272,4 @@ def search_by_issue(user_id, project_id, funnel_id, issue_id, data, range_value=
data=data) if issue is not None else {"total": 0, "sessions": []},
# "stages": helper.list_to_camel_case(insights),
# "totalDropDueToIssues": total_drop_due_to_issues,
"issue": issue}
"issue": issue}

View file

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

View file

@ -1,8 +1,4 @@
import chalicelib.utils.TimeUTC
from chalicelib.utils import email_helper, captcha, helper
import secrets
from chalicelib.utils import pg_client
from chalicelib.core import users
@ -18,49 +14,23 @@ def step1(data):
a_users = users.get_by_email_only(data["email"])
if len(a_users) > 1:
print(f"multiple users found for [{data['email']}] please contact our support")
return {"errors": ["please contact our support"]}
return {"errors": ["multiple users, please contact our support"]}
elif len(a_users) == 1:
a_users = a_users[0]
reset_token = secrets.token_urlsafe(6)
users.update(tenant_id=a_users["tenantId"], user_id=a_users["id"],
changes={"token": reset_token})
email_helper.send_reset_code(recipient=data["email"], reset_code=reset_token)
invitation_link = users.generate_new_invitation(user_id=a_users["id"])
email_helper.send_forgot_password(recipient=data["email"], invitation_link=invitation_link)
else:
print(f"invalid email address [{data['email']}]")
return {"errors": ["invalid email address"]}
return {"data": {"state": "success"}}
def step2(data):
print("====================== change password 2 ===============")
user = users.get_by_email_reset(data["email"], data["code"])
if not user:
print("error: wrong email or reset code")
return {"errors": ["wrong email or reset code"]}
users.update(tenant_id=user["tenantId"], user_id=user["id"],
changes={"token": None, "password": data["password"], "generatedPassword": False,
"verifiedEmail": True})
return {"data": {"state": "success"}}
def cron():
with pg_client.PostgresClient() as cur:
cur.execute(
cur.mogrify("""\
SELECT user_id
FROM public.basic_authentication
WHERE token notnull
AND (token_requested_at isnull or (EXTRACT(EPOCH FROM token_requested_at)*1000)::BIGINT < %(time)s);""",
{"time": chalicelib.utils.TimeUTC.TimeUTC.now(delta_days=-1)})
)
results = cur.fetchall()
if len(results) == 0:
return
results = tuple([r["user_id"] for r in results])
cur.execute(
cur.mogrify("""\
UPDATE public.basic_authentication
SET token = NULL, token_requested_at = NULL
WHERE user_id in %(ids)s;""",
{"ids": results})
)
# def step2(data):
# print("====================== change password 2 ===============")
# user = users.get_by_email_reset(data["email"], data["code"])
# if not user:
# print("error: wrong email or reset code")
# return {"errors": ["wrong email or reset code"]}
# users.update(tenant_id=user["tenantId"], user_id=user["id"],
# changes={"token": None, "password": data["password"], "generatedPassword": False,
# "verifiedEmail": True})
# return {"data": {"state": "success"}}

View file

@ -59,7 +59,7 @@ def create_step1(data):
if len(signed_ups) == 0 and data.get("tenantId") is not None \
or len(signed_ups) > 0 and data.get("tenantId") is not None\
and data.get("tenantId") not in [t['tenantId'] for t in signed_ups]:
errors.append("Tenant does not exist")
errors.append("Tenant not found")
if len(errors) > 0:
print("==> error")
print(errors)

View file

@ -4,11 +4,17 @@ from chalicelib.core import authorizers
from chalicelib.core import tenants
from chalicelib.utils import helper
from chalicelib.utils import pg_client
from chalicelib.utils import dev
from chalicelib.utils.TimeUTC import TimeUTC
from chalicelib.utils.helper import environ
import secrets
def create_new_member(tenant_id, email, password, admin, name, owner=False):
def __generate_invitation_token():
return secrets.token_urlsafe(64)
def create_new_member(tenant_id, email, invitation_token, admin, name, owner=False):
with pg_client.PostgresClient() as cur:
query = cur.mogrify(f"""\
WITH u AS (
@ -16,10 +22,12 @@ def create_new_member(tenant_id, email, password, admin, name, owner=False):
VALUES (%(tenantId)s, %(email)s, %(role)s, %(name)s, %(data)s)
RETURNING user_id,email,role,name,appearance
),
au AS (INSERT
INTO public.basic_authentication (user_id, password, generated_password)
VALUES ((SELECT user_id FROM u), crypt(%(password)s, gen_salt('bf', 12)), TRUE))
au AS (INSERT INTO public.basic_authentication (user_id, generated_password, invitation_token, invited_at)
VALUES ((SELECT user_id FROM u), TRUE, %(invitation_token)s, timezone('utc'::text, now()))
RETURNING invitation_token
)
SELECT u.user_id AS id,
u.user_id,
u.email,
u.role,
u.name,
@ -27,18 +35,19 @@ def create_new_member(tenant_id, email, password, admin, name, owner=False):
(CASE WHEN u.role = 'owner' THEN TRUE ELSE FALSE END) AS super_admin,
(CASE WHEN u.role = 'admin' THEN TRUE ELSE FALSE END) AS admin,
(CASE WHEN u.role = 'member' THEN TRUE ELSE FALSE END) AS member,
u.appearance
FROM u;""",
{"tenantId": tenant_id, "email": email, "password": password,
au.invitation_token
FROM u,au;""",
{"tenantId": tenant_id, "email": email,
"role": "owner" if owner else "admin" if admin else "member", "name": name,
"data": json.dumps({"lastAnnouncementView": TimeUTC.now()})})
"data": json.dumps({"lastAnnouncementView": TimeUTC.now()}),
"invitation_token": invitation_token})
cur.execute(
query
)
return helper.dict_to_camel_case(cur.fetchone())
def restore_member(tenant_id, user_id, email, password, admin, name, owner=False):
def restore_member(tenant_id, user_id, email, invitation_token, admin, name, owner=False):
with pg_client.PostgresClient() as cur:
query = cur.mogrify(f"""\
UPDATE public.users
@ -56,31 +65,62 @@ def restore_member(tenant_id, user_id, email, password, admin, name, owner=False
TRUE AS change_password,
(CASE WHEN role = 'owner' THEN TRUE ELSE FALSE END) AS super_admin,
(CASE WHEN role = 'admin' THEN TRUE ELSE FALSE END) AS admin,
(CASE WHEN role = 'member' THEN TRUE ELSE FALSE END) AS member,
appearance;""",
(CASE WHEN role = 'member' THEN TRUE ELSE FALSE END) AS member;""",
{"tenant_id": tenant_id, "user_id": user_id, "email": email,
"role": "owner" if owner else "admin" if admin else "member", "name": name})
cur.execute(
query
)
result = helper.dict_to_camel_case(cur.fetchone())
result = cur.fetchone()
query = cur.mogrify("""\
UPDATE public.basic_authentication
SET password= crypt(%(password)s, gen_salt('bf', 12)),
generated_password= TRUE,
token=NULL,
token_requested_at=NULL
WHERE user_id=%(user_id)s;""",
{"user_id": user_id, "password": password})
SET generated_password = TRUE,
invitation_token = %(invitation_token)s,
invited_at = timezone('utc'::text, now()),
change_pwd_expire_at = NULL,
change_pwd_token = NULL
WHERE user_id=%(user_id)s
RETURNING invitation_token;""",
{"user_id": user_id, "invitation_token": invitation_token})
cur.execute(
query
)
result["invitation_token"] = cur.fetchone()["invitation_token"]
return result
return helper.dict_to_camel_case(result)
def generate_new_invitation(user_id):
invitation_token = __generate_invitation_token()
with pg_client.PostgresClient() as cur:
query = cur.mogrify("""\
UPDATE public.basic_authentication
SET invitation_token = %(invitation_token)s,
invited_at = timezone('utc'::text, now()),
change_pwd_expire_at = NULL,
change_pwd_token = NULL
WHERE user_id=%(user_id)s
RETURNING invitation_token;""",
{"user_id": user_id, "invitation_token": invitation_token})
cur.execute(
query
)
return __get_invitation_link(cur.fetchone().pop("invitation_token"))
def reset_member(tenant_id, editor_id, user_id_to_update):
admin = get(tenant_id=tenant_id, user_id=editor_id)
if not admin["admin"] and not admin["superAdmin"]:
return {"errors": ["unauthorized"]}
user = get(tenant_id=tenant_id, user_id=user_id_to_update)
if not user:
return {"errors": ["user not found"]}
return {"data": {"invitationLink": generate_new_invitation(user_id_to_update)}}
def update(tenant_id, user_id, changes):
AUTH_KEYS = ["password", "generatedPassword", "token"]
AUTH_KEYS = ["password", "generatedPassword", "invitationToken", "invitedAt", "changePwdExpireAt", "changePwdToken"]
if len(changes.keys()) == 0:
return None
@ -91,13 +131,6 @@ def update(tenant_id, user_id, changes):
if key == "password":
sub_query_bauth.append("password = crypt(%(password)s, gen_salt('bf', 12))")
sub_query_bauth.append("changed_at = timezone('utc'::text, now())")
elif key == "token":
if changes[key] is not None:
sub_query_bauth.append("token = %(token)s")
sub_query_bauth.append("token_requested_at = timezone('utc'::text, now())")
else:
sub_query_bauth.append("token = NULL")
sub_query_bauth.append("token_requested_at = NULL")
else:
sub_query_bauth.append(f"{helper.key_to_snake_case(key)} = %({key})s")
else:
@ -166,26 +199,43 @@ def create_member(tenant_id, user_id, data):
return {"errors": ["invalid user name"]}
if name is None:
name = data["email"]
temp_pass = helper.generate_salt()[:8]
invitation_token = __generate_invitation_token()
user = get_deleted_user_by_email(email=data["email"])
if user is not None:
new_member = restore_member(tenant_id=tenant_id, email=data["email"], password=temp_pass,
new_member = restore_member(tenant_id=tenant_id, email=data["email"], invitation_token=invitation_token,
admin=data.get("admin", False), name=name, user_id=user["userId"])
else:
new_member = create_new_member(tenant_id=tenant_id, email=data["email"], password=temp_pass,
new_member = create_new_member(tenant_id=tenant_id, email=data["email"], invitation_token=invitation_token,
admin=data.get("admin", False), name=name)
new_member["invitationLink"] = __get_invitation_link(new_member.pop("invitationToken"))
helper.async_post(environ['email_basic'] % 'member_invitation',
{
"email": data["email"],
"userName": data["email"],
"tempPassword": temp_pass,
"invitationLink": new_member["invitationLink"],
"clientId": tenants.get_by_tenant_id(tenant_id)["name"],
"senderName": admin["name"]
})
return {"data": new_member}
def __get_invitation_link(invitation_token):
return environ["SITE_URL"] + environ["invitation_link"] % invitation_token
def allow_password_change(user_id, delta_min=10):
pass_token = secrets.token_urlsafe(8)
with pg_client.PostgresClient() as cur:
query = cur.mogrify(f"""UPDATE public.basic_authentication
SET change_pwd_expire_at = timezone('utc'::text, now()+INTERVAL '%(delta)s MINUTES'),
change_pwd_token = %(pass_token)s
WHERE user_id = %(user_id)s""",
{"user_id": user_id, "delta": delta_min, "pass_token": pass_token})
cur.execute(
query
)
return pass_token
def get(user_id, tenant_id):
with pg_client.PostgresClient() as cur:
cur.execute(
@ -321,7 +371,11 @@ def get_members(tenant_id):
basic_authentication.generated_password,
(CASE WHEN users.role = 'owner' THEN TRUE ELSE FALSE END) AS super_admin,
(CASE WHEN users.role = 'admin' THEN TRUE ELSE FALSE END) AS admin,
(CASE WHEN users.role = 'member' THEN TRUE ELSE FALSE END) AS member
(CASE WHEN users.role = 'member' THEN TRUE ELSE FALSE END) AS member,
DATE_PART('day',timezone('utc'::text, now()) \
- COALESCE(basic_authentication.invited_at,'2000-01-01'::timestamp ))>=1 AS expired_invitation,
basic_authentication.password IS NOT NULL AS joined,
invitation_token
FROM public.users LEFT JOIN public.basic_authentication ON users.user_id=basic_authentication.user_id
WHERE users.tenant_id = %(tenantId)s AND users.deleted_at IS NULL
ORDER BY name, id""",
@ -329,7 +383,13 @@ def get_members(tenant_id):
)
r = cur.fetchall()
if len(r):
return helper.list_to_camel_case(r)
r = helper.list_to_camel_case(r)
for u in r:
if u["invitationToken"]:
u["invitationLink"] = __get_invitation_link(u.pop("invitationToken"))
else:
u["invitationLink"] = None
return r
return []
@ -374,6 +434,15 @@ def change_password(tenant_id, user_id, email, old_password, new_password):
"jwt": authenticate(email, new_password)["jwt"]}
def set_password_invitation(user_id, new_password):
changes = {"password": new_password, "generatedPassword": False,
"invitationToken": None, "invitedAt": None,
"changePwdExpireAt": None, "changePwdToken": None}
user = update(tenant_id=-1, user_id=user_id, changes=changes)
return {"data": user,
"jwt": authenticate(user["email"], new_password)["jwt"]}
def count_members(tenant_id):
with pg_client.PostgresClient() as cur:
cur.execute(
@ -393,7 +462,7 @@ def email_exists(email):
cur.mogrify(
f"""SELECT
count(user_id)
FROM public.users
FROM public.users
WHERE
email = %(email)s
AND deleted_at IS NULL
@ -410,7 +479,7 @@ def get_deleted_user_by_email(email):
cur.mogrify(
f"""SELECT
*
FROM public.users
FROM public.users
WHERE
email = %(email)s
AND deleted_at NOTNULL
@ -421,6 +490,24 @@ def get_deleted_user_by_email(email):
return helper.dict_to_camel_case(r)
def get_by_invitation_token(token, pass_token=None):
with pg_client.PostgresClient() as cur:
cur.execute(
cur.mogrify(
f"""SELECT
*,
DATE_PART('day',timezone('utc'::text, now()) \
- COALESCE(basic_authentication.invited_at,'2000-01-01'::timestamp ))>=1 AS expired_invitation,
change_pwd_expire_at <= timezone('utc'::text, now()) AS expired_change
FROM public.users INNER JOIN public.basic_authentication USING(user_id)
WHERE invitation_token = %(token)s {"AND change_pwd_token = %(pass_token)s" if pass_token else ""}
LIMIT 1;""",
{"token": token, "pass_token": token})
)
r = cur.fetchone()
return helper.dict_to_camel_case(r)
def auth_exists(user_id, tenant_id, jwt_iat, jwt_aud):
with pg_client.PostgresClient() as cur:
cur.execute(
@ -450,6 +537,7 @@ def change_jwt_iat(user_id):
return cur.fetchone().get("jwt_iat")
@dev.timed
def authenticate(email, password, for_change_password=False, for_plugin=False):
with pg_client.PostgresClient() as cur:
query = cur.mogrify(

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",
"dashboard": {
"cpu": true,
"fps": false,
"fps": false,
"avgCpu": true,
"avgFps": true,
"errors": true,
@ -121,19 +121,21 @@ CREATE TABLE users
jwt_iat timestamp without time zone NULL DEFAULT NULL,
data jsonb NOT NULL DEFAULT '{}'::jsonb,
weekly_report boolean NOT NULL DEFAULT TRUE,
origin user_origin NULL DEFAULT NULL,
origin user_origin NULL DEFAULT NULL,
);
CREATE TABLE basic_authentication
(
user_id integer NOT NULL REFERENCES users (user_id) ON DELETE CASCADE,
password text DEFAULT NULL,
generated_password boolean NOT NULL DEFAULT false,
token text NULL DEFAULT NULL,
token_requested_at timestamp without time zone NULL DEFAULT NULL,
changed_at timestamp,
user_id integer NOT NULL REFERENCES users (user_id) ON DELETE CASCADE,
password text DEFAULT NULL,
generated_password boolean NOT NULL DEFAULT false,
invitation_token text NULL DEFAULT NULL,
invited_at timestamp without time zone NULL DEFAULT NULL,
change_pwd_token text NULL DEFAULT NULL,
change_pwd_expire_at timestamp without time zone NULL DEFAULT NULL,
changed_at timestamp,
UNIQUE (user_id)
);
@ -534,6 +536,8 @@ CREATE INDEX sessions_user_anonymous_id_gin_idx ON public.sessions USING GIN (us
CREATE INDEX sessions_user_country_gin_idx ON public.sessions (project_id, user_country);
CREATE INDEX ON sessions (project_id, user_country);
CREATE INDEX ON sessions (project_id, user_browser);
CREATE INDEX sessions_session_id_project_id_start_ts_durationNN_idx ON sessions (session_id, project_id, start_ts) WHERE duration IS NOT NULL;
ALTER TABLE public.sessions
ADD CONSTRAINT web_browser_constraint CHECK ( (sessions.platform = 'web' AND sessions.user_browser NOTNULL) OR
@ -574,6 +578,7 @@ create table assigned_sessions
created_at timestamp default timezone('utc'::text, now()) NOT NULL,
provider_data jsonb default '{}'::jsonb NOT NULL
);
CREATE INDEX ON assigned_sessions (session_id);
-- --- events_common.sql ---
@ -677,6 +682,7 @@ CREATE INDEX pages_path_idx ON events.pages (path);
CREATE INDEX pages_visually_complete_idx ON events.pages (visually_complete) WHERE visually_complete > 0;
CREATE INDEX pages_dom_building_time_idx ON events.pages (dom_building_time) WHERE dom_building_time > 0;
CREATE INDEX pages_load_time_idx ON events.pages (load_time) WHERE load_time > 0;
CREATE INDEX pages_base_path_session_id_timestamp_idx ON events.pages (base_path, session_id, timestamp);
CREATE TABLE events.clicks
@ -691,6 +697,11 @@ CREATE INDEX ON events.clicks (session_id);
CREATE INDEX ON events.clicks (label);
CREATE INDEX clicks_label_gin_idx ON events.clicks USING GIN (label gin_trgm_ops);
CREATE INDEX ON events.clicks (timestamp);
CREATE INDEX clicks_label_session_id_timestamp_idx ON events.clicks (label, session_id, timestamp);
CREATE INDEX clicks_url_idx ON events.clicks (url);
CREATE INDEX clicks_url_gin_idx ON events.clicks USING GIN (url gin_trgm_ops);
CREATE INDEX clicks_url_session_id_timestamp_selector_idx ON events.clicks (url, session_id, timestamp, selector);
CREATE TABLE events.inputs
(
@ -706,6 +717,7 @@ CREATE INDEX ON events.inputs (label, value);
CREATE INDEX inputs_label_gin_idx ON events.inputs USING GIN (label gin_trgm_ops);
CREATE INDEX inputs_label_idx ON events.inputs (label);
CREATE INDEX ON events.inputs (timestamp);
CREATE INDEX inputs_label_session_id_timestamp_idx ON events.inputs (label, session_id, timestamp);
CREATE TABLE events.errors
(

View file

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

View file

@ -21,7 +21,8 @@ const siteIdRequiredPaths = [
'/sourcemaps',
'/errors',
'/funnels',
'/assist'
'/assist',
'/heatmaps'
];
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="16x16" href="/favicon-16x16.png">
<link rel="stylesheet" href="//cdn.jsdelivr.net/npm/semantic-ui@2.4.2/dist/semantic.min.css" />
<link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700" rel="stylesheet">
<link rel="stylesheet" href="/path/to/styles/theme-name.css">
<link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700" rel="stylesheet">
</head>
<body>
<div id="app"></div>

View file

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

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

View file

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

View file

@ -1,13 +1,43 @@
import React from 'react';
import { Icon } from 'UI';
import { Icon, CopyButton, Popup } from 'UI';
import styles from './userItem.css';
const UserItem = ({ user, adminLabel, deleteHandler, editHandler }) => (
const UserItem = ({ user, adminLabel, deleteHandler, editHandler, generateInviteLink }) => (
<div className={ styles.wrapper } id="user-row">
<Icon name="user-alt" size="16" marginRight="10" />
<div id="user-name">{ user.name || user.email }</div>
{ adminLabel && <div className={ styles.adminLabel }>{ adminLabel }</div>}
<div className={ styles.actions }>
{ user.expiredInvitation && !user.joined &&
<Popup
trigger={
<div className={ styles.button } onClick={ () => generateInviteLink(user) } id="trash">
<Icon name="link-45deg" size="16" color="red"/>
</div>
}
content={ `Generate Invitation Link` }
size="tiny"
inverted
position="top center"
/>
}
{ !user.expiredInvitation && !user.joined && user.invitationLink &&
<Popup
trigger={
<div className={ styles.button }>
<CopyButton
content={user.invitationLink}
className="link"
btnText={<Icon name="link-45deg" size="16" color="teal"/>}
/>
</div>
}
content={ `Copy Invitation Link` }
size="tiny"
inverted
position="top center"
/>
}
{ !!deleteHandler &&
<div className={ styles.button } onClick={ () => deleteHandler(user) } id="trash">
<Icon name="trash" size="16" color="teal"/>

View file

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

View file

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

View file

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

View file

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

View file

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

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

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)
return (
<div className="flex items-center w-full">
<div className="flex-1 relative">
<div className="flex flex-1 relative items-center" style={{ height: '32px' }}>
{ showSearch ?
<div className="flex items-center">
<Input

View file

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

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

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

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 {
position: absolute;
top: 0;
@ -13,5 +8,4 @@
display: flex;
align-items: 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 cn from 'classnames';
import { Loader, IconButton, EscapeButton } from 'UI';
import { hide as hideTargetDefiner, toggleInspectorMode } from 'Duck/components/targetDefiner';
import { hide as hideTargetDefiner } from 'Duck/components/targetDefiner';
import { fullscreenOff } from 'Duck/components/player';
import withOverlay from 'Components/hocs/withOverlay';
import { attach as attachPlayer, Controls as PlayerControls, connectPlayer } from 'Player';
import Controls from './Controls';
import Overlay from './Overlay';
import stl from './player.css';
import AutoplayTimer from '../AutoplayTimer';
import EventsToggleButton from '../../Session/EventsToggleButton';
import { getStatusText } from 'Player/MessageDistributor/managers/AssistManager';
const ScreenWrapper = withOverlay()(React.memo(() => <div className={ stl.screenWrapper } />));
@connectPlayer(state => ({
playing: state.playing,
loading: state.messagesLoading,
disconnected: state.disconnected,
disabled: state.cssLoading || state.messagesLoading || state.inspectorMode,
removeOverlay: !state.messagesLoading && state.inspectorMode || state.live,
completed: state.completed,
autoplay: state.autoplay,
live: state.live,
liveStatusText: getStatusText(state.peerConnectionStatus),
}))
@connect(state => ({
//session: state.getIn([ 'sessions', 'current' ]),
@ -32,15 +20,9 @@ const ScreenWrapper = withOverlay()(React.memo(() => <div className={ stl.screen
nextId: state.getIn([ 'sessions', 'nextId' ]),
}), {
hideTargetDefiner,
toggleInspectorMode: () => toggleInspectorMode(false),
fullscreenOff,
})
export default class Player extends React.PureComponent {
state = {
showPlayOverlayIcon: false,
startedToPlayAt: Date.now(),
};
screenWrapper = React.createRef();
componentDidMount() {
@ -48,69 +30,14 @@ export default class Player extends React.PureComponent {
attachPlayer(parentElement);
}
componentDidUpdate(prevProps) {
if (prevProps.targetSelector !== this.props.targetSelector) {
PlayerControls.mark(this.props.targetSelector);
}
if (prevProps.playing !== this.props.playing) {
if (this.props.playing) {
this.setState({ startedToPlayAt: Date.now() });
} else {
this.updateWatchingTime();
}
}
}
componentWillUnmount() {
if (this.props.playing) {
this.updateWatchingTime();
}
}
updateWatchingTime() {
const diff = Date.now() - this.state.startedToPlayAt;
}
// onTargetClick = (targetPath) => {
// const { targetCustomList, location } = this.props;
// const targetCustomFromList = targetCustomList !== this.props.targetSelector
// .find(({ path }) => path === targetPath);
// const target = targetCustomFromList
// ? targetCustomFromList.set('location', location)
// : { path: targetPath, isCustom: true, location };
// this.props.showTargetDefiner(target);
// }
togglePlay = () => {
this.setState({ showPlayOverlayIcon: true });
PlayerControls.togglePlay();
setTimeout(
() => this.setState({ showPlayOverlayIcon: false }),
800,
);
}
render() {
const {
showPlayOverlayIcon,
} = this.state;
const {
className,
playing,
disabled,
removeOverlay,
bottomBlockIsActive,
loading,
disconnected,
fullscreen,
fullscreenOff,
completed,
autoplay,
nextId,
live,
liveStatusText,
} = this.props;
return (
@ -120,40 +47,12 @@ export default class Player extends React.PureComponent {
>
{ fullscreen &&
<EscapeButton onClose={ fullscreenOff } />
// <IconButton
// size="18"
// className="ml-auto mb-5"
// style={{ marginTop: '-5px' }}
// onClick={ fullscreenOff }
// size="small"
// icon="close"
// label="Esc"
// />
}
{!live && !fullscreen && <EventsToggleButton /> }
<div className="relative flex-1">
{ (!removeOverlay || live && liveStatusText) &&
<div
className={ stl.overlay }
onClick={ disabled ? null : this.togglePlay }
>
{ live && liveStatusText
? <span className={stl.liveStatusText}>{liveStatusText}</span>
: <Loader loading={ loading } />
}
{ !live &&
<div
className={ cn(stl.iconWrapper, {
[ stl.zoomIcon ]: showPlayOverlayIcon
}) }
>
<div className={ playing ? stl.playIcon : stl.pauseIcon } />
</div>
}
</div>
}
{ completed && autoplay && nextId && <AutoplayTimer /> }
<ScreenWrapper
<div className="relative flex-1 overflow-hidden">
<Overlay nextId={nextId} togglePlay={PlayerControls.togglePlay} />
<div
className={ stl.screenWrapper }
ref={ this.screenWrapper }
/>
</div>

View file

@ -1,5 +1,3 @@
@import 'icons.css';
.playerBody {
background: $white;
/* border-radius: 3px; */
@ -25,61 +23,8 @@
font-weight: 200;
color: $gray-medium;
}
.overlay {
position: absolute;
top: 0;
bottom: 0;
right: 0;
left: 0;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
/* &[data-protect] {
pointer-events: none;
background: $white;
opacity: 0.3;
}
*/
& .iconWrapper {
background-color: rgba(0, 0, 0, 0.1);
width: 50px;
height: 50px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
opacity: 0;
transition: all .2s; /* Animation */
}
& .zoomIcon {
opacity: 1;
transform: scale(1.8);
transition: all .8s;
}
& .playIcon {
@mixin icon play, $gray-medium, 30px;
}
& .pauseIcon {
@mixin icon pause, $gray-medium, 30px;
}
}
.playerView {
position: relative;
flex: 1;
}
.inspectorMode {
z-index: 99991 !important;
}
.liveStatusText {
color: $gray-light;
font-size: 40px;
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,16 +1,48 @@
import { Map } from 'immutable';
import Member from 'Types/member';
import crudDuckGenerator from './tools/crudDuck';
import withRequestState, { RequestTypes } from 'Duck/requestStateCreator';
import { reduceDucks } from 'Duck/tools';
const GENERATE_LINK = new RequestTypes('member/GENERATE_LINK');
const crudDuck = crudDuckGenerator('client/member', Member, { idKey: 'id' });
export const {
fetchList, init, edit, remove,
} = crudDuck.actions;
export const { fetchList, init, edit, remove, } = crudDuck.actions;
const initialState = Map({
definedPercent: 0,
});
const reducer = (state = initialState, action = {}) => {
switch (action.type) {
case GENERATE_LINK.SUCCESS:
return state.update(
'list',
list => list
.map(member => {
if(member.id === action.id) {
return Member({...member.toJS(), invitationLink: action.data.invitationLink })
}
return member
})
);
}
return state;
};
export function save(instance) {
return {
types: crudDuck.actionTypes.SAVE.toArray(),
call: client => client.put( instance.id ? `/client/members/${ instance.id }` : '/client/members', instance.toData()),
call: client => client.put( instance.id ? `/client/members/${ instance.id }` : '/client/members', instance.toData()),
};
}
export default crudDuck.reducer;
export function generateInviteLink(instance) {
return {
types: GENERATE_LINK.toArray(),
call: client => client.get(`/client/members/${ instance.id }/reset`),
id: instance.id
};
}
export default reduceDucks(crudDuck, { initialState, reducer }).reducer;

View file

@ -5,6 +5,8 @@ import Watchdog, { getSessionWatchdogTypes } from 'Types/watchdog';
import { clean as cleanParams } from 'App/api_client';
import withRequestState, { RequestTypes } from './requestStateCreator';
import { getRE } from 'App/utils';
import { LAST_7_DAYS } from 'Types/app/period';
import { getDateRangeFromValue } from 'App/dateRange';
const INIT = 'sessions/INIT';
@ -15,6 +17,7 @@ const FETCH_FAVORITE_LIST = new RequestTypes('sessions/FETCH_FAVORITE_LIST');
const FETCH_LIVE_LIST = new RequestTypes('sessions/FETCH_LIVE_LIST');
const TOGGLE_FAVORITE = new RequestTypes('sessions/TOGGLE_FAVORITE');
const FETCH_ERROR_STACK = new RequestTypes('sessions/FETCH_ERROR_STACK');
const FETCH_INSIGHTS = new RequestTypes('sessions/FETCH_INSIGHTS');
const SORT = 'sessions/SORT';
const REDEFINE_TARGET = 'sessions/REDEFINE_TARGET';
const SET_TIMEZONE = 'sessions/SET_TIMEZONE';
@ -24,6 +27,14 @@ const TOGGLE_CHAT_WINDOW = 'sessions/TOGGLE_CHAT_WINDOW';
const SET_ACTIVE_TAB = 'sessions/SET_ACTIVE_TAB';
const range = getDateRangeFromValue(LAST_7_DAYS);
const defaultDateFilters = {
url: '',
rangeValue: LAST_7_DAYS,
startDate: range.start.unix() * 1000,
endDate: range.end.unix() * 1000
}
const initialState = Map({
list: List(),
sessionIds: [],
@ -39,7 +50,10 @@ const initialState = Map({
sourcemapUploaded: true,
filteredEvents: null,
showChatWindow: false,
liveSessions: List()
liveSessions: List(),
visitedEvents: List(),
insights: List(),
insightFilters: defaultDateFilters
});
const reducer = (state = initialState, action = {}) => {
@ -136,21 +150,32 @@ const reducer = (state = initialState, action = {}) => {
const session = Session(action.data);
const matching = [];
const visitedEvents = []
const tmpMap = {}
session.events.forEach(event => {
if (event.type === 'LOCATION' && !tmpMap.hasOwnProperty(event.url)) {
tmpMap[event.url] = event.url
visitedEvents.push(event)
}
})
events.forEach(({ key, operator, value }) => {
events.forEach(({ key, operator, value }) => {
session.events.forEach((e, index) => {
if (key === e.type) {
const val = (e.type === 'LOCATION' ? e.url : e.value);
if (key === e.type) {
const val = (e.type === 'LOCATION' ? e.url : e.value);
if (operator === 'is' && value === val) {
matching.push(index);
}
if (operator === 'contains' && val.includes(value)) {
matching.push(index);
}
}
}
}
})
})
return state.set('current', current.merge(session)).set('eventsIndex', matching);
return state.set('current', current.merge(session))
.set('eventsIndex', matching)
.set('visitedEvents', visitedEvents);
}
case FETCH_FAVORITE_LIST.SUCCESS:
return state
@ -202,9 +227,11 @@ const reducer = (state = initialState, action = {}) => {
.set('sessionIds', allList.map(({ sessionId }) => sessionId ).toJS())
case SET_TIMEZONE:
return state.set('timezone', action.timezone)
case TOGGLE_CHAT_WINDOW:
console.log(action)
case TOGGLE_CHAT_WINDOW:
return state.set('showChatWindow', action.state)
case FETCH_INSIGHTS.SUCCESS: 
return state.set('insights', List(action.data).sort((a, b) => b.count - a.count));
default:
return state;
}
@ -215,6 +242,7 @@ export default withRequestState({
fetchFavoriteListRequest: FETCH_FAVORITE_LIST,
toggleFavoriteRequest: TOGGLE_FAVORITE,
fetchErrorStackList: FETCH_ERROR_STACK,
fetchInsightsRequest: FETCH_INSIGHTS,
}, reducer);
function init(session) {
@ -263,6 +291,13 @@ export function fetchFavoriteList() {
};
}
export function fetchInsights(params) {
return {
types: FETCH_INSIGHTS.toArray(),
call: client => client.post('/heatmaps/url', params),
};
}
export function fetchLiveList() {
return {
types: FETCH_LIVE_LIST.toArray(),

View file

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

View file

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

View file

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

View file

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

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';
//export interface targetPosition
interface BoundingRect {
top: number,
left: number,
width: number,
height: number,
}
export interface MarkedTarget {
boundingRect: BoundingRect,
el: Element,
selector: string,
count: number,
index: number,
active?: boolean,
percent: number
}
export interface State extends SuperState {
messagesLoading: boolean,
cssLoading: boolean,
disconnected: boolean,
userPageLoading: boolean,
markedTargets: MarkedTarget[] | null,
activeTargetIndex: number
}
export const INITIAL_STATE: State = {
@ -15,40 +36,88 @@ export const INITIAL_STATE: State = {
cssLoading: false,
disconnected: false,
userPageLoading: false,
}
markedTargets: null,
activeTargetIndex: 0
};
export default class StatedScreen extends Screen {
constructor() { super(); }
setMessagesLoading(messagesLoading: boolean) {
// @ts-ignore
this.display(!messagesLoading);
update({ messagesLoading });
}
setCSSLoading(cssLoading: boolean) {
// @ts-ignore
this.displayFrame(!cssLoading);
update({ cssLoading });
}
setDisconnected(disconnected: boolean) {
if (!getState().live) return; //?
// @ts-ignore
this.display(!disconnected);
update({ disconnected });
}
setUserPageLoading(userPageLoading: boolean) {
// @ts-ignore
this.display(!userPageLoading);
update({ userPageLoading });
}
setSize({ height, width }: { height: number, width: number }) {
update({ width, height });
// @ts-ignore
this.scale();
const { markedTargets } = getState();
if (markedTargets) {
update({
markedTargets: markedTargets.map(mt => ({
...mt,
boundingRect: this.calculateRelativeBoundingRect(mt.el),
})),
});
}
}
private calculateRelativeBoundingRect(el: Element): BoundingRect {
if (!this.parentElement) return {top:0, left:0, width:0,height:0} //TODO
const { top, left, width, height } = el.getBoundingClientRect();
const s = this.getScale();
const scrinRect = this.screen.getBoundingClientRect();
const parentRect = this.parentElement.getBoundingClientRect();
return {
top: top*s + scrinRect.top - parentRect.top,
left: left*s + scrinRect.left - parentRect.left,
width: width*s,
height: height*s,
}
}
setActiveTarget(index) {
update({ activeTargetIndex: index });
}
setMarkedTargets(selections: { selector: string, count: number }[] | null) {
if (selections) {
const targets: MarkedTarget[] = [];
const totalCount = selections.reduce((a, b) => {
return a + b.count
}, 0);
selections.forEach((s, index) => {
const el = this.getElementBySelector(s.selector);
if (!el) return;
targets.push({
...s,
el,
index,
percent: Math.round((s.count * totalCount) / 100),
boundingRect: this.calculateRelativeBoundingRect(el),
})
});
update({ markedTargets: targets });
} else {
update({ markedTargets: null });
}
}
}

View file

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

View file

@ -24,7 +24,7 @@ export const ID_TP_MAP = {
22: "console_log",
37: "css_insert_rule",
38: "css_delete_rule",
39: "fetch_depricated",
39: "fetch",
40: "profiler",
41: "o_table",
44: "redux",
@ -36,7 +36,6 @@ export const ID_TP_MAP = {
54: "connection_information",
55: "set_page_visibility",
59: "long_task",
68: "fetch",
} as const;
@ -167,8 +166,8 @@ export interface CssDeleteRule {
index: number,
}
export interface FetchDepricated {
tp: "fetch_depricated",
export interface Fetch {
tp: "fetch",
method: string,
url: string,
request: string,
@ -256,20 +255,8 @@ export interface LongTask {
containerName: string,
}
export interface Fetch {
tp: "fetch",
method: string,
url: string,
request: string,
response: string,
status: number,
timestamp: number,
duration: number,
headers: string,
}
export type Message = Timestamp | SetPageLocation | SetViewportSize | SetViewportScroll | CreateDocument | CreateElementNode | CreateTextNode | MoveNode | RemoveNode | SetNodeAttribute | RemoveNodeAttribute | SetNodeData | SetCssData | SetNodeScroll | SetInputValue | SetInputChecked | MouseMove | ConsoleLog | CssInsertRule | CssDeleteRule | FetchDepricated | Profiler | OTable | Redux | Vuex | MobX | NgRx | GraphQl | PerformanceTrack | ConnectionInformation | SetPageVisibility | LongTask | Fetch;
export type Message = Timestamp | SetPageLocation | SetViewportSize | SetViewportScroll | CreateDocument | CreateElementNode | CreateTextNode | MoveNode | RemoveNode | SetNodeAttribute | RemoveNodeAttribute | SetNodeData | SetCssData | SetNodeScroll | SetInputValue | SetInputChecked | MouseMove | ConsoleLog | CssInsertRule | CssDeleteRule | Fetch | Profiler | OTable | Redux | Vuex | MobX | NgRx | GraphQl | PerformanceTrack | ConnectionInformation | SetPageVisibility | LongTask;
export default function (r: PrimitiveReader): Message | null {
switch (r.readUint()) {
@ -522,19 +509,6 @@ export default function (r: PrimitiveReader): Message | null {
containerName: r.readString(),
};
case 68:
return {
tp: ID_TP_MAP[68],
method: r.readString(),
url: r.readString(),
request: r.readString(),
response: r.readString(),
status: r.readUint(),
timestamp: r.readUint(),
duration: r.readUint(),
headers: r.readString(),
};
default:
r.readUint(); // IOS skip timestamp
r.skip(r.readUint());

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