diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..cd68102 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,11 @@ +repos: + - repo: local + hooks: + - id: da-build + name: docassemble build checks + entry: scripts/run_da_build_checks.sh + language: python + additional_dependencies: + - dayamlchecker>=1.5.0 + pass_filenames: false + always_run: true diff --git a/docassemble/ALDashboard/aldashboard.py b/docassemble/ALDashboard/aldashboard.py index 9828357..788d37f 100644 --- a/docassemble/ALDashboard/aldashboard.py +++ b/docassemble/ALDashboard/aldashboard.py @@ -24,7 +24,7 @@ # SQLAlchemy expressions are shared by both the legacy engine and Flask extension. from sqlalchemy.sql import text -from sqlalchemy import func, or_, select +from sqlalchemy import bindparam, func, or_, select from sqlalchemy.orm import joinedload from typing import Any, List, Tuple, Dict, Optional, Callable, Set import math @@ -170,8 +170,11 @@ "get_session_file_for_download", "get_session_files_zip", "get_user_details", + "can_access_user_sessions", "disable_user_mfa", "get_password_reset_link", + "send_password_reset_email", + "recent_account_activity_report", "inactive_developer_login_summary", "inactive_developer_account_report", "delete_inactive_developer_accounts", @@ -574,7 +577,11 @@ def get_users_and_name_by_ids(ids: List[int]) -> List[Tuple[int, str, str, str]] return results -def search_users_by_email(wordstart: str, limit: int = 20) -> List[Tuple[int, str]]: +def search_users_by_email( + wordstart: str, + limit: int = 20, + exclude_privileged: bool = False, +) -> List[Tuple[int, str]]: """ Search for users whose email starts with the given text. Used by the input type: ajax field on large servers, so we never load the full user table - just return a handful of matches as the admin types. @@ -583,12 +590,14 @@ def search_users_by_email(wordstart: str, limit: int = 20) -> List[Tuple[int, st if not wordstart: return [] - statement = ( - select(UserModel.id, UserModel.email, UserModel.first_name, UserModel.last_name) - .where(UserModel.email.istartswith(wordstart)) - .limit(limit) - ) - + statement = select( + UserModel.id, UserModel.email, UserModel.first_name, UserModel.last_name + ).where(UserModel.email.istartswith(wordstart)) + if exclude_privileged: + statement = statement.where( + ~UserModel.roles.any(Role.name.in_(["admin", "developer", "cron"])) + ) + statement = statement.limit(limit) with _get_db_session() as session: users = session.execute(statement).all() @@ -644,7 +653,9 @@ def get_user_details(user_id: int) -> Optional[Dict]: Returns: A dictionary with user details, or None if the user is not found. Keys include: id, email, first_name, last_name, active, account_type, privileges (list of str), - mfa_enabled (bool), and mfa_type (str or None: "app", "sms", or None). + mfa_enabled (bool), mfa_type (str or None: "app", "sms", or None), + last_login (datetime or None), session_count (int), and + last_session_activity (datetime or None). """ with _get_db_session() as session: user = ( @@ -671,6 +682,8 @@ def get_user_details(user_id: int) -> Optional[Dict]: account_type = re.sub(r"\$.*", "", user.social_id) if user.social_id else "unknown" + session_summary = _user_session_summary(user.id) + return { "id": user.id, "email": user.email, @@ -681,9 +694,49 @@ def get_user_details(user_id: int) -> Optional[Dict]: "privileges": privileges, "mfa_enabled": mfa_enabled, "mfa_type": mfa_type, + "last_login": getattr(user, "last_login", None), + "session_count": session_summary["session_count"], + "last_session_activity": session_summary["last_session_activity"], } +def _current_user_permissions() -> Set[str]: + """Return the current user's configured docassemble permissions.""" + if not user_logged_in(): + return set() + try: + return set(getattr(user_info(), "permissions", []) or []) + except (AttributeError, TypeError, RuntimeError): + return set() + + +def can_access_user_sessions(user_id: Optional[int] = None) -> bool: + """Whether the current user may inspect another account's sessions. + + Docassemble's ``access_sessions`` permission grants access to other users' + sessions. The user-centered entry point also requires ``access_user_info``. + Non-admin support users may not use it to inspect protected accounts. + """ + if not user_logged_in(): + return False + if user_id is not None: + try: + target_user_id = int(user_id) + except (TypeError, ValueError): + return False + if target_user_id == _resolve_current_user_id(): + return True + if user_has_privilege(["admin", "developer"]): + return True + permissions = _current_user_permissions() + if not {"access_sessions", "access_user_info"}.issubset(permissions): + return False + if user_id is None: + return True + privileged = is_user_privileged(target_user_id) + return privileged is False + + def disable_user_mfa(user_id: int) -> bool: """Disable two-factor authentication for a user by clearing their OTP secret. @@ -776,6 +829,203 @@ def get_password_reset_link(user_id: int) -> Optional[str]: return reset_link +def send_password_reset_email(user_id: int) -> Dict[str, Any]: + """Ask docassemble's user manager to send its standard reset email. + + This deliberately uses the same authorization and protected-account rules + as :func:`get_password_reset_link`, and delegates token generation, message + templates, and delivery to docassemble's built-in forgot-password flow. + """ + if not user_logged_in() or ( + not user_has_privilege("admin") + and "edit_user_password" not in _current_user_permissions() + ): + log("send_password_reset_email: current user lacks permission") + return { + "sent": False, + "message": "You do not have permission to send a reset email.", + } + + if is_user_privileged(user_id) and not user_has_privilege(["admin", "developer"]): + log( + "send_password_reset_email: refusing protected target " + f"user {user_id} for a non-admin support user" + ) + return { + "sent": False, + "message": "Only an administrator can reset this account.", + } + + with _get_db_session() as session: + user = session.execute( + select(UserModel).where(UserModel.id == user_id) + ).scalar_one_or_none() + + if user is None or not user.email: + log(f"send_password_reset_email: no email address for user {user_id}") + return { + "sent": False, + "message": "This account does not have an email address.", + } + + user_manager = current_app.user_manager # type: ignore[attr-defined] + if not getattr(user_manager, "enable_forgot_password", False): + return { + "sent": False, + "message": "Password reset email is disabled on this server.", + } + if not current_app.config.get("ALLOW_CHANGING_PASSWORD", True): + return { + "sent": False, + "message": "Password changes are disabled on this server.", + } + + try: + user_manager.send_reset_password_email(user.email) + except Exception as error: + log( + "send_password_reset_email: delivery failed for " + f"user {user_id}: {error.__class__.__name__}: {error}" + ) + return { + "sent": False, + "message": "The server could not send the reset email.", + } + return {"sent": True, "message": "The password reset email was sent."} + + +def recent_account_activity_report( + days: int = 30, + user_limit: int = 200, + anonymous_limit: int = 100, +) -> Dict[str, Any]: + """Return recent login activity and grouped anonymous session activity. + + Anonymous activity is grouped by docassemble's temporary user identifier. + The interview/session tables do not contain a reliable historical source IP. + """ + if not can_access_user_sessions(): + return {"authorized": False, "users": [], "anonymous": []} + try: + days = min(365, max(1, int(days))) + except (TypeError, ValueError): + days = 30 + try: + user_limit = min(500, max(1, int(user_limit))) + except (TypeError, ValueError): + user_limit = 200 + try: + anonymous_limit = min(500, max(1, int(anonymous_limit))) + except (TypeError, ValueError): + anonymous_limit = 100 + cutoff = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(days=days) + + user_statement = ( + select( + UserModel.id, + UserModel.email, + UserModel.first_name, + UserModel.last_name, + UserModel.last_login, + ) + .where( + UserModel.last_login.isnot(None), + UserModel.last_login >= cutoff, + ~UserModel.social_id.startswith("disabled$"), + ) + .order_by(UserModel.last_login.desc()) + .limit(user_limit) + ) + if not user_has_privilege(["admin", "developer"]): + user_statement = user_statement.where( + ~UserModel.roles.any(Role.name.in_(["admin", "developer", "cron"])) + ) + + recent_user_sessions_statement = text(""" + SELECT + userdictkeys.user_id AS user_id, + COUNT(DISTINCT userdictkeys.key) AS recent_session_count, + MAX(userdict.modtime) AS last_session_activity + FROM userdictkeys + JOIN userdict + ON userdict.key = userdictkeys.key + AND userdict.filename = userdictkeys.filename + WHERE userdictkeys.user_id IN :user_ids + AND userdict.modtime >= :cutoff + GROUP BY userdictkeys.user_id + """).bindparams(bindparam("user_ids", expanding=True)) + + anonymous_statement = text(""" + SELECT + userdictkeys.temp_user_id AS temp_user_id, + COUNT(DISTINCT userdictkeys.key) AS session_count, + MAX(userdict.modtime) AS last_activity + FROM userdictkeys + JOIN userdict + ON userdict.key = userdictkeys.key + AND userdict.filename = userdictkeys.filename + WHERE userdictkeys.user_id IS NULL + AND userdictkeys.temp_user_id IS NOT NULL + AND userdict.modtime >= :cutoff + GROUP BY userdictkeys.temp_user_id + ORDER BY last_activity DESC + LIMIT :anonymous_limit + """) + + with _get_db_session() as session: + user_rows = session.execute(user_statement).all() + if user_rows: + recent_user_session_rows = session.execute( + recent_user_sessions_statement, + {"user_ids": [row.id for row in user_rows], "cutoff": cutoff}, + ).all() + else: + recent_user_session_rows = [] + anonymous_rows = session.execute( + anonymous_statement, + {"cutoff": cutoff, "anonymous_limit": anonymous_limit}, + ).all() + + recent_sessions_by_user = { + row.user_id: { + "recent_session_count": int(row.recent_session_count or 0), + "last_session_activity": row.last_session_activity, + } + for row in recent_user_session_rows + } + + return { + "authorized": True, + "days": days, + "cutoff": cutoff, + "users": [ + { + "id": row.id, + "email": row.email or "", + "name": " ".join( + part for part in (row.first_name or "", row.last_name or "") if part + ), + "last_login": row.last_login, + "recent_session_count": recent_sessions_by_user.get(row.id, {}).get( + "recent_session_count", 0 + ), + "last_session_activity": recent_sessions_by_user.get(row.id, {}).get( + "last_session_activity" + ), + } + for row in user_rows + ], + "anonymous": [ + { + "temp_user_id": row.temp_user_id, + "session_count": int(row.session_count or 0), + "last_activity": row.last_activity, + } + for row in anonymous_rows + ], + } + + PLAYGROUND_SECTIONS = ( "playground", "playgroundtemplate", @@ -1324,6 +1574,7 @@ def format_session_users(session: Any, users_by_id: Dict[int, str]) -> str: def speedy_get_sessions( user_id: Optional[int] = None, + temp_user_id: Optional[int] = None, filename: Optional[str] = None, filter_step1: bool = True, metadata_key_name: str = "metadata", @@ -1332,7 +1583,8 @@ def speedy_get_sessions( search_criteria_text: Optional[str] = None, ) -> List[Tuple]: """ - Return a list of the most recent 500 sessions, optionally tied to a specific user ID. + Return the 500 most recently active sessions, optionally tied to a registered + user ID or an anonymous temporary-user ID. Each session is a tuple with named columns: filename, @@ -1341,9 +1593,45 @@ def speedy_get_sessions( key """ get_sessions_query = text(""" +WITH mostrecent AS ( + SELECT + key, + MAX(modtime) AS modtime, + COUNT(key) AS num_keys + FROM userdict + WHERE + (user_id = :user_id OR :user_id IS NULL) + AND ( + :temp_user_id IS NULL + OR EXISTS ( + SELECT 1 + FROM userdictkeys AS target_user + WHERE target_user.key = userdict.key + AND target_user.filename = userdict.filename + AND target_user.temp_user_id = :temp_user_id + ) + ) + AND (filename = :filename OR :filename IS NULL) + GROUP BY key + HAVING + (COUNT(key) > 1 OR :filter_step1 = FALSE) + AND (:start_date IS NULL OR DATE(MAX(modtime)) >= CAST(:start_date AS DATE)) + AND (:end_date IS NULL OR DATE(MAX(modtime)) <= CAST(:end_date AS DATE)) + ORDER BY modtime DESC + LIMIT 500 +), +joined_users AS ( + SELECT + userdictkeys.key, + MIN(userdictkeys.user_id) AS user_id, + STRING_AGG(DISTINCT CAST(userdictkeys.user_id AS TEXT), ',') AS user_ids + FROM userdictkeys + JOIN mostrecent ON mostrecent.key = userdictkeys.key + GROUP BY userdictkeys.key +) SELECT userdict.filename as filename, - num_keys, + mostrecent.num_keys, joined_users.user_id as user_id, joined_users.user_ids as user_ids, mostrecent.modtime as modtime, -- This retrieves the most recent modification time for each key @@ -1353,45 +1641,46 @@ def speedy_get_sessions( jsonstorage.data->>'description' as description, jsonstorage.data->>'steps' as steps, jsonstorage.data->>'progress' as progress -FROM - userdict -NATURAL JOIN - ( - SELECT - key, - MAX(modtime) AS modtime, -- Calculate the most recent modification time for each key - COUNT(key) AS num_keys - FROM - userdict - GROUP BY - key - HAVING - COUNT(key) > 1 OR :filter_step1 = False - ) mostrecent -LEFT JOIN - ( - SELECT - key, - MIN(user_id) AS user_id, - STRING_AGG(DISTINCT CAST(user_id AS TEXT), ',') AS user_ids - FROM userdictkeys - GROUP BY key - ) joined_users ON joined_users.key = userdict.key +FROM mostrecent +JOIN userdict + ON userdict.key = mostrecent.key AND userdict.modtime = mostrecent.modtime +LEFT JOIN joined_users ON joined_users.key = userdict.key LEFT JOIN jsonstorage ON jsonstorage.key = userdict.key AND jsonstorage.tags = :metadata -WHERE - (userdict.user_id = :user_id OR :user_id is null) - AND (userdict.filename = :filename OR :filename is null) - AND (:start_date is null OR DATE(mostrecent.modtime) >= CAST(:start_date AS DATE)) - AND (:end_date is null OR DATE(mostrecent.modtime) <= CAST(:end_date AS DATE)) +WHERE + (userdict.user_id = :user_id OR :user_id IS NULL) + AND ( + :temp_user_id IS NULL + OR EXISTS ( + SELECT 1 + FROM userdictkeys AS target_user + WHERE target_user.key = userdict.key + AND target_user.filename = userdict.filename + AND target_user.temp_user_id = :temp_user_id + ) + ) + AND (userdict.filename = :filename OR :filename IS NULL) ORDER BY modtime DESC LIMIT 500; """) + if user_id is not None and temp_user_id is not None: + raise ValueError("Filter by either user_id or temp_user_id, not both.") + if user_id and not can_access_user_sessions(user_id): + raise Exception("You do not have permission to view this user's sessions.") + if temp_user_id is not None: + if not can_access_user_sessions(): + raise Exception( + "You do not have permission to view this anonymous user's sessions." + ) + try: + temp_user_id = int(temp_user_id) + except (TypeError, ValueError) as error: + raise ValueError("Invalid temporary user ID.") from error if not filename: - if not user_has_privilege(["admin", "developer"]): + if not can_access_user_sessions(): raise Exception( - "You must provide a filename to filter sessions unless you are a developer or administrator." + "You must provide a filename to filter sessions unless you have access to other users' sessions." ) filename = None # Explicitly treat empty string as equivalent to None if not user_id: @@ -1413,6 +1702,7 @@ def speedy_get_sessions( get_sessions_query, { "user_id": user_id, + "temp_user_id": temp_user_id, "filename": filename, "filter_step1": filter_step1, "metadata": metadata_key_name, @@ -1728,13 +2018,14 @@ def build_session_files_zip( def get_allowed_interview_filenames() -> Optional[Set[str]]: """ Return the set of interview filenames the current user may look at sessions - for, or None if the user is unrestricted (admins and developers). + for, or None if the user is unrestricted (admins, developers, and users + granted both ``access_user_info`` and ``access_sessions``). Non-privileged users are limited to the interviews listed under `assembly line: interview viewers:` in the configuration for one of the privileges they hold. """ - if user_has_privilege(["admin", "developer"]): + if can_access_user_sessions(): return None allowed_filenames: Set[str] = set() interview_viewers = get_config("assembly line", {}).get("interview viewers", {}) diff --git a/docassemble/ALDashboard/data/questions/list_sessions.yml b/docassemble/ALDashboard/data/questions/list_sessions.yml index 9f1bcd9..b1f225e 100644 --- a/docassemble/ALDashboard/data/questions/list_sessions.yml +++ b/docassemble/ALDashboard/data/questions/list_sessions.yml @@ -32,9 +32,10 @@ question: | Splash screen --- code: | - # get_allowed_interview_filenames() returns None for admins and developers, - # who can see every interview, and otherwise the set of interviews listed - # under `assembly line: interview viewers:` for the user's privileges. + # get_allowed_interview_filenames() returns None for admins, developers, and + # support users with both account and session access. Otherwise it returns the + # interviews listed under `assembly line: interview viewers:` for the user's + # privileges. allowed_interview_filenames = get_allowed_interview_filenames() interviews = { interview["filename"]: interview @@ -57,7 +58,7 @@ code: | code: | # Used to decide whether it's safe to load every user into the dropdown below, # or whether the server has too many users for that - if user_has_privilege(["admin", "developer"]): + if can_access_user_sessions(): total_user_count = get_user_count() else: total_user_count = 0 @@ -149,7 +150,7 @@ fields: ["browse", "search"].includes(val("session_list_mode")) show if: code: | - user_has_privilege(['admin', 'developer']) + can_access_user_sessions() - Filename: filename code: | sorted( @@ -171,7 +172,7 @@ fields: ["browse", "search"].includes(val("session_list_mode")) show if: code: | - not user_has_privilege(['admin', 'developer']) + not can_access_user_sessions() - User (leave blank to view all sessions): chosen_user required: False datatype: integer @@ -184,7 +185,7 @@ fields: code: | ( speedy_get_users() - if user_has_privilege(["admin", "developer"]) + if can_access_user_sessions() else [{user_info().id: user_info().email}] ) - User (leave blank to view all sessions): chosen_user diff --git a/docassemble/ALDashboard/data/questions/manage_users.yml b/docassemble/ALDashboard/data/questions/manage_users.yml index 5b784de..739acf0 100644 --- a/docassemble/ALDashboard/data/questions/manage_users.yml +++ b/docassemble/ALDashboard/data/questions/manage_users.yml @@ -18,31 +18,45 @@ modules: mandatory: True id: interview order code: | - chosen_user - if not user_has_privilege("admin"): - if is_user_privileged(chosen_user) and user_task in ( - "reset_password", - "get_reset_link", - "elevate_permissions", - "disable_mfa", - "edit_profile", - ): - exit_cant_manage_privileged_user - if user_task == "reset_password": - do_update_password - elif user_task == "get_reset_link": - display_reset_link_screen - elif user_task == "elevate_permissions": - do_update_privileges - elif user_task == "download_playground": - do_download_playground - elif user_task == "view_user_info": - view_user_info_screen - elif user_task == "disable_mfa": - confirm_disable_mfa - do_disable_mfa - elif user_task == "edit_profile": - edit_profile_screen + user_task + if user_task == "recent_activity": + recent_activity_days + recent_activity_data + recent_activity_report_screen + else: + chosen_user + if not user_has_privilege("admin"): + if is_user_privileged(chosen_user) and user_task in ( + "reset_password", + "get_reset_link", + "send_reset_email", + "view_sessions", + "elevate_permissions", + "disable_mfa", + "edit_profile", + ): + exit_cant_manage_privileged_user + if user_task == "reset_password": + do_update_password + elif user_task == "get_reset_link": + display_reset_link_screen + elif user_task == "send_reset_email": + confirm_reset_email_delivery + send_reset_email_result + send_reset_email_complete_screen + elif user_task == "elevate_permissions": + do_update_privileges + elif user_task == "download_playground": + do_download_playground + elif user_task == "view_user_info": + view_user_info_screen + elif user_task == "view_sessions": + view_user_sessions_screen + elif user_task == "disable_mfa": + confirm_disable_mfa + do_disable_mfa + elif user_task == "edit_profile": + edit_profile_screen ending_screen --- id: exit_cant_manage_privileged_user @@ -53,14 +67,26 @@ subquestion: | Only users with the `admin` privilege are allowed to edit privileged accounts. --- code: | - user_lookup = {} - for user in get_users_and_name(): - user_lookup[user[0]] = f"{user[2]} {user[3]} ({user[1]})".strip() + user_details = get_user_details(chosen_user) + if user_details: + user_label = f"{user_details['first_name'] or ''} {user_details['last_name'] or ''} ({user_details['email'] or 'no email'})".strip() + else: + user_label = f"User ID {chosen_user}" --- code: | - user_details = get_user_details(chosen_user) + manage_user_count = get_user_count() +--- +id: select recent activity period +question: | + Recently active users +fields: + - Days of activity to include: recent_activity_days + datatype: integer + min: 1 + max: 365 + default: 5 --- -id: select user +id: select user and management task question: | Manage users subquestion: | @@ -85,6 +111,7 @@ subquestion: | % endif fields: - User: chosen_user + required: False datatype: integer input type: combobox code: | @@ -96,28 +123,44 @@ fields: ] exclude: - "2" - # User ID 2 is the "cron" user and should not be managed here + show if: + code: | + manage_user_count <= 200 + js show if: | + val("user_task") != "recent_activity" + - User: chosen_user + required: False + datatype: integer + input type: ajax + action: manage_user_search_ajax + show if: + code: | + manage_user_count > 200 + js show if: | + val("user_task") != "recent_activity" - What do you want to do?: user_task datatype: radio + default: view_user_info choices: - View user information: view_user_info + - View saved interviews and answers: view_sessions + - Visit recent user report: recent_activity - Reset password: reset_password - Get password reset link: get_reset_link + - Send password reset email: send_reset_email - Download playground files: download_playground - Change user permissions: elevate_permissions - Disable two-factor authentication: disable_mfa - Edit profile in docassemble: edit_profile - New password: new_user_password datatype: password - show if: - variable: user_task - is: reset_password + js show if: | + val("user_task") == "reset_password" validate: valid_password - Verify new password: new_user_password_2 datatype: password - show if: - variable: user_task - is: reset_password + js show if: | + val("user_task") == "reset_password" validate: valid_password - New permissions: new_permission_level datatype: multiselect @@ -126,10 +169,10 @@ fields: exclude: | 'cron' show if: - variable: user_task - is: elevate_permissions code: | "access_privileges" in user_info().permissions + js show if: | + val("user_task") == "elevate_permissions" help: | The user's permissions will be completely reset to match the new list of permissions. Use "ctrl" to select multiple. @@ -139,9 +182,33 @@ fields: code: | not "access_privileges" in user_info().permissions validation code: | + if user_task != "recent_activity" and not chosen_user: + validation_error("Select a user", field="chosen_user") if user_task == "reset_password" and new_user_password != new_user_password_2: validation_error("The passwords do not match", field="new_user_password_2") --- +event: manage_user_search_ajax +code: | + set_save_status("ignore") + original = action_argument("wordstart") + try: + original_id = int(original) + existing_match = get_users_and_name_by_ids([original_id]) + if existing_match: + user_id, email, name, last = existing_match[0] + if user_has_privilege(["admin", "developer"]) or not is_user_privileged(user_id): + json_response([[str(user_id), f"{name} {last} ({email})".strip()]]) + except (TypeError, ValueError): + pass + results = [ + [str(user_id), label] + for user_id, label in search_users_by_email( + original, + exclude_privileged=not user_has_privilege(["admin", "developer"]), + ) + ] + json_response(results) +--- code: | set_user_info(user_id=chosen_user, password=new_user_password) do_update_password = True @@ -154,10 +221,10 @@ code: | --- id: download playground question: | - Playground download for ${ user_lookup[chosen_user] } + Playground download for ${ user_label } subquestion: | % for project in get_list_of_projects(chosen_user) + ['default']: - [${project}]( ${ create_user_playground_zip(chosen_user, space_to_underscore(user_lookup[chosen_user]), project).url_for() }) + [${project}]( ${ create_user_playground_zip(chosen_user, space_to_underscore(user_label), project).url_for() }) % endfor event: do_download_playground --- @@ -191,6 +258,9 @@ subquestion: | **Active** | ${ "Yes" if user_details["active"] else "No" } **Account type** | ${ user_details["account_type"] } **Privileges** | ${ ", ".join(user_details["privileges"]) if user_details["privileges"] else "user" } + **Last login** | ${ user_details["last_login"].strftime("%b %d, %Y at %H:%M UTC") if user_details["last_login"] else "Never" } + **Saved interview sessions** | ${ user_details["session_count"] } + **Last interview activity** | ${ user_details["last_session_activity"].strftime("%b %d, %Y at %H:%M UTC") if user_details["last_session_activity"] else "Never" } **2FA enabled** | ${ "Yes" if user_details["mfa_enabled"] else "No" } % if user_details["mfa_enabled"]: **2FA type** | ${ "Authenticator app" if user_details["mfa_type"] == "app" else "SMS" } @@ -198,6 +268,16 @@ subquestion: | % else: The selected user could not be found in the database. % endif +under: | + % if user_details: + % if can_access_user_sessions(user_details["id"]): + ${ action_button_html(url_action("view_user_sessions_screen"), label="View saved interviews and answers", icon="folder-open", color="primary") } + % endif + % if user_has_privilege("admin") or "edit_user_password" in getattr(user_info(), "permissions", []): + ${ action_button_html(url_action("start_send_reset_email"), label="Send password reset email", icon="envelope", color="secondary") } + ${ action_button_html(url_action("display_reset_link_screen"), label="Copy password reset link", icon="link", color="secondary") } + % endif + % endif buttons: - Restart: restart --- @@ -260,10 +340,182 @@ code: | # This does not send an email, it only generates the URL for the admin to send manually password_reset_link = get_password_reset_link(chosen_user) --- +id: confirm send reset email +question: | + Send a password reset email? +subquestion: | + Docassemble will send its standard password reset message to + **${ (user_details["email"] or "no email address") if user_details else user_label }**. + + The message will contain a private, expiring reset link. Sending it may cause + the user to lose access to encrypted saved answers after they reset their password. +field: confirm_reset_email_delivery +buttons: + - Send reset email: True + - Cancel: False +--- +event: start_send_reset_email +code: | + undefine("confirm_reset_email_delivery") + confirm_reset_email_delivery + send_reset_email_result + send_reset_email_complete_screen +--- +code: | + if confirm_reset_email_delivery: + send_reset_email_result = send_password_reset_email(chosen_user) + else: + send_reset_email_result = {"sent": False, "message": "No email was sent."} +--- +event: send_reset_email_complete_screen +id: password reset email result +question: | + ${ "Password reset email sent" if send_reset_email_result["sent"] else "Password reset email not sent" } +subquestion: | + ${ send_reset_email_result["message"] } +buttons: + - Restart: restart +--- +code: | + if can_access_user_sessions(chosen_user): + managed_user_sessions = speedy_get_sessions( + user_id=chosen_user, + filename=None, + filter_step1=False, + ) + else: + managed_user_sessions = [] +--- +event: view_user_sessions_screen +code: | + if not can_access_user_sessions(chosen_user): + response(response="You do not have permission to view this user's sessions.", response_code=403) + managed_session_subject = user_label + managed_sessions = managed_user_sessions + view_managed_sessions_screen +--- +event: view_anonymous_sessions_screen +code: | + if not can_access_user_sessions(): + response(response="You do not have permission to view anonymous sessions.", response_code=403) + try: + managed_temp_user_id = int(action_argument("temp_user_id")) + except (TypeError, ValueError): + response(response="Invalid anonymous user identifier.", response_code=400) + managed_session_subject = f"anonymous user {managed_temp_user_id}" + managed_sessions = speedy_get_sessions( + temp_user_id=managed_temp_user_id, + filename=None, + filter_step1=False, + ) + view_managed_sessions_screen +--- +event: view_managed_sessions_screen +id: managed user sessions +question: | + Saved interviews and answers for ${ managed_session_subject } +subquestion: | + % if managed_sessions: +
| Interview | +Modified | +Step / progress | +Actions | +
|---|---|---|---|
|
+ ${ interview.title or interview.auto_title or nicer_interview_filename(interview.filename) }
+ ${ nicer_interview_filename(interview.filename) } + ${ interview.key }
+ |
+ ${ format_date(interview.modtime, "MMM d YYYY") } | +${ f"Step {interview.num_keys}" if not interview.progress else f"{interview.progress}%" } | +
+ Join live interview + Details and files + View answers + |
+
| User | Last login | Recent sessions | Last session activity | Action |
|---|---|---|---|---|
| ${ row["name"] or row["email"] or f'User ID {row["id"]}' } ${ row["email"] } |
+ ${ row["last_login"].strftime("%b %d, %Y at %H:%M UTC") } | +${ row["recent_session_count"] } | +${ row["last_session_activity"].strftime("%b %d, %Y at %H:%M UTC") if row["last_session_activity"] else "None" } | +View user information | +
No registered users logged in during this period.
+ % endif + +| Anonymous identifier | Recent sessions | Last activity | Action |
|---|---|---|---|
temp-user-${ row["temp_user_id"] } |
+ ${ row["session_count"] } | +${ row["last_activity"].strftime("%b %d, %Y at %H:%M UTC") } | +View sessions | +
No anonymous activity was found during this period.
+ % endif + % endif +back button: True +--- +event: view_activity_user +code: | + try: + chosen_user = int(action_argument("user_id")) + except (TypeError, ValueError): + response(response="Invalid user identifier.", response_code=400) + if not user_has_privilege("admin") and is_user_privileged(chosen_user): + response(response="You do not have permission to view this user's information.", response_code=403) + undefine("user_details", "user_label", "managed_user_sessions", "managed_sessions", "managed_session_subject") + view_user_info_screen +--- id: display reset link event: display_reset_link_screen question: | - Password reset link for ${ user_lookup.get(chosen_user, "this user") } + Password reset link for ${ user_label } subquestion: | % if password_reset_link: Copy the link below and send it to the user directly. @@ -275,8 +527,8 @@ subquestion: |