From a232e18c467d3d2685b29aa9d72e1141fdfe349c Mon Sep 17 00:00:00 2001 From: hz Date: Sat, 18 Jul 2026 15:53:11 -0500 Subject: [PATCH] Add session expiry renewal flow --- scripts/autoload/auth_state.gd | 199 +++++++++++++++++++- scripts/autoload/ccs_service.gd | 8 +- scripts/autoload/exam_simulation_service.gd | 8 +- scripts/autoload/feedback_service.gd | 8 +- scripts/autoload/question_bank_service.gd | 35 +++- scripts/ui/login_screen.gd | 6 +- scripts/ui/main.gd | 21 ++- scripts/ui/question_player.gd | 12 +- scripts/ui/session_expiry_dialog.gd | 181 ++++++++++++++++++ scripts/ui/session_expiry_dialog.gd.uid | 1 + tests/auth_session_test.gd | 98 ++++++++++ tests/auth_session_test.gd.uid | 1 + tests/post_answer_review_test.gd | 4 + tests/web_responsive_test.gd | 14 ++ 14 files changed, 575 insertions(+), 21 deletions(-) create mode 100644 scripts/ui/session_expiry_dialog.gd create mode 100644 scripts/ui/session_expiry_dialog.gd.uid create mode 100644 tests/auth_session_test.gd create mode 100644 tests/auth_session_test.gd.uid diff --git a/scripts/autoload/auth_state.gd b/scripts/autoload/auth_state.gd index 3c34f7b..efced24 100644 --- a/scripts/autoload/auth_state.gd +++ b/scripts/autoload/auth_state.gd @@ -1,6 +1,17 @@ extends Node signal auth_changed +signal session_warning(seconds_remaining: int) +signal session_countdown_changed(seconds_remaining: int) +signal session_refresh_started +signal session_refreshed +signal session_refresh_failed(message: String, terminal: bool) +signal session_expired(reason: String) + +const EndpointConfig := preload("res://scripts/autoload/api_endpoint_config.gd") +const DEFAULT_AUTH_BASE_URL := "https://pauth.ranaze.com/api/Auth" +const WARNING_SECONDS := 120 +const REFRESH_TIMEOUT_SECONDS := 20.0 var is_logged_in := false var username := "" @@ -8,22 +19,208 @@ var role := "" var access_token := "" var refresh_token := "" var expires_at := "" +var expiration_unix := 0.0 +var refresh_in_progress := false +var warning_active := false +var auth_base_url := DEFAULT_AUTH_BASE_URL +var expiry_timer: Timer +var refresh_request: HTTPRequest +var session_revision := 0 +var refresh_revision := -1 + + +func _ready() -> void: + auth_base_url = EndpointConfig.resolve( + str(ProjectSettings.get_setting("step3/auth_api_base_url", DEFAULT_AUTH_BASE_URL)), + "/api/Auth") + expiry_timer = Timer.new() + expiry_timer.wait_time = 1.0 + expiry_timer.timeout.connect(_evaluate_expiry) + add_child(expiry_timer) + refresh_request = HTTPRequest.new() + refresh_request.timeout = REFRESH_TIMEOUT_SECONDS + refresh_request.request_completed.connect(_on_refresh_completed) + add_child(refresh_request) func set_session(new_username: String, token: String, new_refresh_token: String = "", new_role: String = "", token_expiration: String = "") -> void: + session_revision += 1 is_logged_in = not token.is_empty() username = new_username access_token = token refresh_token = new_refresh_token role = new_role expires_at = token_expiration + expiration_unix = _parse_expiration(token_expiration, token) + refresh_in_progress = false + refresh_revision = -1 + warning_active = false + if is_logged_in and expiration_unix > 0.0: + expiry_timer.start() + else: + expiry_timer.stop() auth_changed.emit() + if is_logged_in: + _evaluate_expiry.call_deferred() func clear_session() -> void: + _clear_session_fields() + auth_changed.emit() + + +func expire_session(reason: String = "expired") -> void: + if not is_logged_in and access_token.is_empty(): + return + _clear_session_fields() + auth_changed.emit() + session_expired.emit(reason) + + +func request_session_refresh() -> void: + if refresh_in_progress or not is_logged_in: + return + if refresh_token.is_empty() or username.is_empty(): + expire_session("refresh_unavailable") + return + refresh_in_progress = true + refresh_revision = session_revision + session_refresh_started.emit() + var headers := PackedStringArray(["Content-Type: application/json"]) + var payload := JSON.stringify({"username": username, "refreshToken": refresh_token}) + var error := refresh_request.request( + "%s/refresh" % auth_base_url.rstrip("/"), + headers, + HTTPClient.METHOD_POST, + payload) + if error != OK: + refresh_in_progress = false + refresh_revision = -1 + session_refresh_failed.emit("Unable to contact the authentication service. Check your connection and try again.", false) + _evaluate_expiry() + + +func can_send_authenticated_request() -> bool: + if not is_logged_in or access_token.is_empty(): + return false + if expiration_unix <= 0.0 or Time.get_unix_time_from_system() < expiration_unix: + return true + if not refresh_in_progress: + expire_session("expired") + return false + + +func handle_unauthorized(token_used: String = "") -> void: + if not is_logged_in: + return + if not token_used.is_empty() and token_used != access_token: + return + expire_session("unauthorized") + + +func seconds_until_expiry() -> int: + if expiration_unix <= 0.0: + return 0 + return maxi(0, int(ceil(expiration_unix - Time.get_unix_time_from_system()))) + + +func _evaluate_expiry() -> void: + if not is_logged_in or expiration_unix <= 0.0: + return + var remaining := seconds_until_expiry() + if remaining <= 0: + if refresh_in_progress: + if warning_active: + session_countdown_changed.emit(0) + return + expire_session("expired") + return + if remaining <= WARNING_SECONDS: + if not warning_active: + warning_active = true + session_warning.emit(remaining) + session_countdown_changed.emit(remaining) + + +func _on_refresh_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void: + var completed_revision := refresh_revision + refresh_in_progress = false + refresh_revision = -1 + if completed_revision != session_revision or not is_logged_in: + return + var succeeded := result == HTTPRequest.RESULT_SUCCESS and response_code >= 200 and response_code < 300 + if not succeeded: + var terminal := response_code in [400, 401, 403] or seconds_until_expiry() <= 0 + var message := "Unable to renew the session. Check your connection and try again." + if terminal: + message = "Your session could not be renewed. Please sign in again." + session_refresh_failed.emit(message, terminal) + if terminal: + expire_session("refresh_failed") + else: + _evaluate_expiry() + return + var parsed: Variant = JSON.parse_string(body.get_string_from_utf8()) + if typeof(parsed) != TYPE_DICTIONARY: + session_refresh_failed.emit("The authentication service returned an invalid response.", true) + expire_session("refresh_failed") + return + var new_access_token := str(parsed.get("accessToken", "")) + var new_expiration := str(parsed.get("exp", "")) + var new_expiration_unix := _parse_expiration(new_expiration, new_access_token) + if new_access_token.is_empty() or new_expiration_unix <= Time.get_unix_time_from_system(): + session_refresh_failed.emit("The authentication service returned an invalid session.", true) + expire_session("refresh_failed") + return + access_token = new_access_token + expires_at = new_expiration + expiration_unix = new_expiration_unix + warning_active = false + session_revision += 1 + expiry_timer.start() + auth_changed.emit() + session_refreshed.emit() + + +func _clear_session_fields() -> void: + session_revision += 1 + if refresh_request != null and refresh_request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED: + refresh_request.cancel_request() + if expiry_timer != null: + expiry_timer.stop() is_logged_in = false username = "" role = "" access_token = "" refresh_token = "" expires_at = "" - auth_changed.emit() + expiration_unix = 0.0 + refresh_in_progress = false + refresh_revision = -1 + warning_active = false + + +func _parse_expiration(value: String, token: String = "") -> float: + var cleaned := value.strip_edges() + if not cleaned.is_empty() and cleaned != "": + if cleaned.is_valid_float(): + var numeric := float(cleaned) + return numeric / 1000.0 if numeric > 100000000000.0 else numeric + var iso_value := cleaned.trim_suffix("Z") + var parsed_iso := Time.get_unix_time_from_datetime_string(iso_value) + if parsed_iso > 0: + return float(parsed_iso) + return _expiration_from_jwt(token) + + +func _expiration_from_jwt(token: String) -> float: + var segments := token.split(".") + if segments.size() < 2: + return 0.0 + var encoded := str(segments[1]).replace("-", "+").replace("_", "/") + while encoded.length() % 4 != 0: + encoded += "=" + var payload_text := Marshalls.base64_to_raw(encoded).get_string_from_utf8() + var payload: Variant = JSON.parse_string(payload_text) + if typeof(payload) != TYPE_DICTIONARY: + return 0.0 + return float(payload.get("exp", 0.0)) diff --git a/scripts/autoload/ccs_service.gd b/scripts/autoload/ccs_service.gd index 0064bce..d332f77 100644 --- a/scripts/autoload/ccs_service.gd +++ b/scripts/autoload/ccs_service.gd @@ -31,6 +31,7 @@ var pending_action := "" var queue: Array = [] var text_scale := 1.0 var highlights := {} +var request_access_token := "" func _ready() -> void: @@ -148,11 +149,12 @@ func _send(action: String, method: int, suffix: String, payload: Dictionary = {} if is_loading: queue.append({"action": action, "method": method, "url": url, "payload": payload.duplicate(true)}) return - if AuthState.access_token.is_empty(): + if not AuthState.can_send_authenticated_request(): return is_loading = true pending_action = action - var headers := PackedStringArray(["Authorization: Bearer %s" % AuthState.access_token]) + request_access_token = AuthState.access_token + var headers := PackedStringArray(["Authorization: Bearer %s" % request_access_token]) var body := "" if not payload.is_empty(): headers.append("Content-Type: application/json") @@ -168,6 +170,8 @@ func _on_request_completed(result: int, response_code: int, _headers: PackedStri pending_action = "" is_loading = false call_deferred("_process_queue") + if response_code == 401: + AuthState.handle_unauthorized(request_access_token) if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300: var detail := body.get_string_from_utf8().strip_edges().left(240) var message := "CCS request failed (%d). %s" % [response_code, detail] diff --git a/scripts/autoload/exam_simulation_service.gd b/scripts/autoload/exam_simulation_service.gd index b43e51b..1186bb4 100644 --- a/scripts/autoload/exam_simulation_service.gd +++ b/scripts/autoload/exam_simulation_service.gd @@ -21,6 +21,7 @@ var history_next_cursor := "" var is_loading := false var pending_action := "" var request_queue: Array = [] +var request_access_token := "" func _ready() -> void: @@ -122,14 +123,15 @@ func _send(action: String, method: int, suffix: String, payload: Dictionary = {} func _send_absolute(action: String, method: int, url: String, payload: Dictionary = {}) -> void: - if AuthState.access_token == "": + if not AuthState.can_send_authenticated_request(): return if is_loading: request_queue.append({"action": action, "method": method, "url": url, "payload": payload.duplicate(true)}) return is_loading = true pending_action = action - var headers := PackedStringArray(["Authorization: Bearer %s" % AuthState.access_token]) + request_access_token = AuthState.access_token + var headers := PackedStringArray(["Authorization: Bearer %s" % request_access_token]) var body := "" if not payload.is_empty(): headers.append("Content-Type: application/json") @@ -146,6 +148,8 @@ func _on_request_completed(result: int, response_code: int, _headers: PackedStri pending_action = "" is_loading = false call_deferred("_process_request_queue") + if response_code == 401: + AuthState.handle_unauthorized(request_access_token) if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300: var server_message := body.get_string_from_utf8().strip_edges() if server_message.length() > 240: diff --git a/scripts/autoload/feedback_service.gd b/scripts/autoload/feedback_service.gd index d9eec74..216e4d5 100644 --- a/scripts/autoload/feedback_service.gd +++ b/scripts/autoload/feedback_service.gd @@ -10,6 +10,7 @@ signal feedback_failed(message: String) var api_url := DEFAULT_API_URL var request: HTTPRequest var is_submitting := false +var request_access_token := "" func _ready() -> void: @@ -25,13 +26,14 @@ func _ready() -> void: func submit(category: String, details: String, context: Dictionary) -> void: if is_submitting: return - if AuthState.access_token.is_empty(): + if not AuthState.can_send_authenticated_request(): feedback_failed.emit("Log in before sending feedback.") return is_submitting = true + request_access_token = AuthState.access_token var headers := PackedStringArray([ "Content-Type: application/json", - "Authorization: Bearer %s" % AuthState.access_token + "Authorization: Bearer %s" % request_access_token ]) var payload := {"category": category, "details": details, "context": context} var error := request.request(api_url, headers, HTTPClient.METHOD_POST, JSON.stringify(payload)) @@ -58,6 +60,8 @@ func build_context(screen_name: String, viewport_size: Vector2) -> Dictionary: func _on_request_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void: is_submitting = false + if response_code == 401: + AuthState.handle_unauthorized(request_access_token) if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300: var detail := body.get_string_from_utf8().strip_edges().left(240) var message := "Feedback submission failed with status %d." % response_code diff --git a/scripts/autoload/question_bank_service.gd b/scripts/autoload/question_bank_service.gd index c877ffd..f5af178 100644 --- a/scripts/autoload/question_bank_service.gd +++ b/scripts/autoload/question_bank_service.gd @@ -45,6 +45,10 @@ var pending_report_question_id := "" var attempt_queue: Array = [] var active_attempt_index := -1 var attempt_request_active := false +var questions_request_token := "" +var attempt_request_token := "" +var rating_request_token := "" +var report_request_token := "" var pending_excluded_question_ids: Array = [] var pending_require_media := false var image_question_search_attempts := 0 @@ -102,7 +106,7 @@ func _ready() -> void: func load_questions_from_api(reset_retry: bool = true, excluded_question_ids: Array = [], require_media: bool = false) -> void: - if AuthState.access_token.is_empty(): + if not AuthState.can_send_authenticated_request(): _set_load_error("Login required before loading questions.", false) return @@ -132,9 +136,10 @@ func load_questions_from_api(reset_retry: bool = true, excluded_question_ids: Ar if should_load_ratings: load_ratings_from_api() + questions_request_token = AuthState.access_token var headers := PackedStringArray([ "Content-Type: application/json", - "Authorization: Bearer %s" % AuthState.access_token + "Authorization: Bearer %s" % questions_request_token ]) var payload := { "limit": 1, @@ -149,6 +154,8 @@ func load_questions_from_api(reset_retry: bool = true, excluded_question_ids: Ar func _on_api_questions_loaded(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void: is_loading = false + if response_code == 401: + AuthState.handle_unauthorized(questions_request_token) if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300: var message := "The connection to the question service was interrupted." if response_code > 0: @@ -251,28 +258,29 @@ func clear_questions() -> void: func record_first_attempt(question_id: String, choice_id: String, presented_choice_ids: Array = []) -> void: - if AuthState.access_token.is_empty() or question_id == "" or choice_id == "": + if not AuthState.can_send_authenticated_request() or question_id == "" or choice_id == "": return _enqueue_attempt("answer", question_id, choice_id, presented_choice_ids) func skip_question(question_id: String) -> void: - if AuthState.access_token.is_empty() or question_id == "": + if not AuthState.can_send_authenticated_request() or question_id == "": return _enqueue_attempt("skip", question_id, "") func submit_question_report(question_id: String, category: String, details: String) -> bool: - if AuthState.access_token.is_empty() or question_id == "": + if not AuthState.can_send_authenticated_request() or question_id == "": question_report_failed.emit(question_id, "Login is required to submit a report.") return false if report_request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED: question_report_failed.emit(question_id, "Another report is still being submitted.") return false + report_request_token = AuthState.access_token var headers := PackedStringArray([ "Content-Type: application/json", - "Authorization: Bearer %s" % AuthState.access_token + "Authorization: Bearer %s" % report_request_token ]) var payload := { "questionExternalId": question_id, @@ -294,10 +302,11 @@ func submit_question_report(question_id: String, category: String, details: Stri func load_ratings_from_api() -> void: - if AuthState.access_token.is_empty(): + if not AuthState.can_send_authenticated_request(): return + rating_request_token = AuthState.access_token var headers := PackedStringArray([ - "Authorization: Bearer %s" % AuthState.access_token + "Authorization: Bearer %s" % rating_request_token ]) var err := rating_request.request(ratings_api_url, headers, HTTPClient.METHOD_GET) if err != OK: @@ -397,7 +406,7 @@ func _enqueue_attempt(kind: String, question_id: String, choice_id: String, pres func _process_attempt_queue() -> void: - if attempt_request_active or attempt_queue.is_empty() or AuthState.access_token.is_empty(): + if attempt_request_active or attempt_queue.is_empty() or not AuthState.can_send_authenticated_request(): return if attempt_retry_timer != null and not attempt_retry_timer.is_stopped(): return @@ -411,9 +420,10 @@ func _process_attempt_queue() -> void: return var queued: Dictionary = attempt_queue[active_attempt_index] + attempt_request_token = AuthState.access_token var headers := PackedStringArray([ "Content-Type: application/json", - "Authorization: Bearer %s" % AuthState.access_token + "Authorization: Bearer %s" % attempt_request_token ]) var question_id := str(queued.get("question_id", "")) var url := question_attempt_api_url @@ -460,6 +470,7 @@ func _on_first_attempt_recorded(result: int, response_code: int, _headers: Packe var succeeded := result == HTTPRequest.RESULT_SUCCESS and response_code >= 200 and response_code < 300 if not succeeded: if response_code == 401: + AuthState.handle_unauthorized(attempt_request_token) active_attempt_index = -1 elif response_code in [400, 403, 404]: push_warning("Discarding invalid question attempt with status %d." % response_code) @@ -563,6 +574,8 @@ func _on_question_report_submitted(result: int, response_code: int, _headers: Pa if question_id == "": question_id = pending_report_question_id pending_report_question_id = "" + if response_code == 401: + AuthState.handle_unauthorized(report_request_token) if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300: var message := "The report could not be submitted. Please try again." @@ -585,6 +598,8 @@ func _on_question_report_submitted(result: int, response_code: int, _headers: Pa func _on_ratings_loaded(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void: + if response_code == 401: + AuthState.handle_unauthorized(rating_request_token) if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300: push_warning("Ratings API request failed with status %d." % response_code) return diff --git a/scripts/ui/login_screen.gd b/scripts/ui/login_screen.gd index bb88a2e..55d1a57 100644 --- a/scripts/ui/login_screen.gd +++ b/scripts/ui/login_screen.gd @@ -72,10 +72,14 @@ func _ready() -> void: _apply_theme() -func on_show(_payload: Variant = null) -> void: +func on_show(payload: Variant = null) -> void: _set_busy(false) _set_mode("login") _clear_message() + var login_message := str(payload).strip_edges() if payload != null else "" + if not login_message.is_empty(): + message_label.text = login_message + message_label.add_theme_color_override("font_color", Color("ef4444")) login_input.grab_focus() diff --git a/scripts/ui/main.gd b/scripts/ui/main.gd index 0319276..d04454e 100644 --- a/scripts/ui/main.gd +++ b/scripts/ui/main.gd @@ -12,6 +12,7 @@ var sound_toggle: CheckButton var theme_button: Button var feedback_button: Button var feedback_dialog: Control +var session_expiry_dialog: Control var theme_service var current_screen_name := "" var screens_margin: MarginContainer @@ -285,10 +286,15 @@ func _build_ui() -> void: feedback_dialog = preload("res://scripts/ui/app_feedback_dialog.gd").new() feedback_dialog.name = "AppFeedbackDialog" add_child(feedback_dialog) + session_expiry_dialog = preload("res://scripts/ui/session_expiry_dialog.gd").new() + session_expiry_dialog.name = "SessionExpiryDialog" + session_expiry_dialog.logout_requested.connect(_logout) + add_child(session_expiry_dialog) _build_account_menu() _build_drawer() AuthState.auth_changed.connect(_refresh_header_controls) + AuthState.session_expired.connect(_on_session_expired) _refresh_header_controls() _build_app_menu() @@ -682,14 +688,25 @@ func _refresh_theme_button() -> void: func _logout() -> void: + _perform_logout("") + + +func _on_session_expired(_reason: String) -> void: + _perform_logout("Your session expired. Sign in again.") + + +func _perform_logout(login_message: String) -> void: _close_app_menu() + if session_expiry_dialog != null and session_expiry_dialog.has_method("dismiss_for_logout"): + session_expiry_dialog.dismiss_for_logout() if BlockSessionService.block_started and BlockSessionService.session_kind != "simulation": BlockSessionService.finish_block() elif BlockSessionService.session_kind == "simulation": BlockSessionService.block_started = false QuestionBankService.clear_questions() - AuthState.clear_session() - show_screen("login") + if AuthState.is_logged_in or not AuthState.access_token.is_empty(): + AuthState.clear_session() + show_screen("login", login_message) func _build_app_menu() -> void: diff --git a/scripts/ui/question_player.gd b/scripts/ui/question_player.gd index 2cabb60..f6229df 100644 --- a/scripts/ui/question_player.gd +++ b/scripts/ui/question_player.gd @@ -652,6 +652,10 @@ func _toolbar_box(dark: bool, active: bool = false) -> StyleBoxFlat: box.border_color = Brand.PURPLE if active else (Color("33415d") if dark else Brand.LIGHT_BORDER) box.set_border_width_all(1) box.set_corner_radius_all(7) + box.content_margin_left = 0 + box.content_margin_right = 0 + box.content_margin_top = 0 + box.content_margin_bottom = 0 return box @@ -687,7 +691,13 @@ func _style_asset_toolbar_button(button: Button, texture: Texture2D, dark: bool) button.icon = texture button.expand_icon = false button.focus_mode = Control.FOCUS_NONE - button.add_theme_constant_override("icon_max_width", 25) + button.alignment = HORIZONTAL_ALIGNMENT_CENTER + button.icon_alignment = HORIZONTAL_ALIGNMENT_CENTER + button.vertical_icon_alignment = VERTICAL_ALIGNMENT_CENTER + button.size_flags_horizontal = Control.SIZE_SHRINK_CENTER + button.size_flags_vertical = Control.SIZE_SHRINK_CENTER + button.add_theme_constant_override("h_separation", 0) + button.add_theme_constant_override("icon_max_width", 23) button.add_theme_stylebox_override("normal", _toolbar_box(dark)) button.add_theme_stylebox_override("hover", _toolbar_box(dark, true)) button.add_theme_stylebox_override("pressed", _toolbar_box(dark, true)) diff --git a/scripts/ui/session_expiry_dialog.gd b/scripts/ui/session_expiry_dialog.gd new file mode 100644 index 0000000..f07dbb5 --- /dev/null +++ b/scripts/ui/session_expiry_dialog.gd @@ -0,0 +1,181 @@ +extends Control + +const Brand := preload("res://scripts/ui/medmind_style.gd") + +signal logout_requested + +var panel: PanelContainer +var title_label: Label +var message_label: Label +var error_label: Label +var stay_button: Button +var logout_button: Button + + +func _ready() -> void: + visible = false + top_level = true + mouse_filter = Control.MOUSE_FILTER_STOP + set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + _build_ui() + AuthState.session_warning.connect(_show_warning) + AuthState.session_countdown_changed.connect(_update_countdown) + AuthState.session_refresh_started.connect(_refresh_started) + AuthState.session_refreshed.connect(_refresh_succeeded) + AuthState.session_refresh_failed.connect(_refresh_failed) + UIThemeService.theme_changed.connect(func(_dark: bool): _apply_theme()) + resized.connect(_refresh_size) + _refresh_size() + _apply_theme() + + +func _build_ui() -> void: + var dim := ColorRect.new() + dim.color = Color(0, 0, 0, 0.74) + dim.mouse_filter = Control.MOUSE_FILTER_STOP + dim.set_anchors_preset(Control.PRESET_FULL_RECT) + add_child(dim) + + var center := CenterContainer.new() + center.set_anchors_preset(Control.PRESET_FULL_RECT) + add_child(center) + + panel = PanelContainer.new() + panel.custom_minimum_size = Vector2(520, 280) + center.add_child(panel) + + var content := VBoxContainer.new() + content.add_theme_constant_override("separation", 16) + panel.add_child(content) + + title_label = Label.new() + title_label.text = "Stay logged in?" + title_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + title_label.add_theme_font_size_override("font_size", 28) + content.add_child(title_label) + + message_label = Label.new() + message_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + message_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + message_label.add_theme_font_size_override("font_size", 17) + content.add_child(message_label) + + error_label = Label.new() + error_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + error_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + error_label.add_theme_font_size_override("font_size", 14) + content.add_child(error_label) + + var actions := HBoxContainer.new() + actions.add_theme_constant_override("separation", 12) + content.add_child(actions) + + logout_button = Button.new() + logout_button.text = "Log out" + logout_button.custom_minimum_size = Vector2(150, 48) + logout_button.pressed.connect(_request_logout) + actions.add_child(logout_button) + + stay_button = Button.new() + stay_button.text = "Stay logged in" + stay_button.custom_minimum_size = Vector2(0, 48) + stay_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL + stay_button.pressed.connect(AuthState.request_session_refresh) + actions.add_child(stay_button) + + +func _show_warning(seconds_remaining: int) -> void: + error_label.text = "" + stay_button.disabled = false + logout_button.disabled = false + stay_button.text = "Stay logged in" + visible = true + _update_countdown(seconds_remaining) + move_to_front() + BlockSessionService.pause_current_question_timer() + + +func _update_countdown(seconds_remaining: int) -> void: + if not visible: + return + var minutes := floori(float(seconds_remaining) / 60.0) + var seconds := seconds_remaining % 60 + message_label.text = "Your session expires in %02d:%02d. Renew it to continue studying." % [minutes, seconds] + + +func _refresh_started() -> void: + if not visible: + return + stay_button.disabled = true + logout_button.disabled = true + stay_button.text = "Renewing..." + error_label.text = "" + + +func _refresh_succeeded() -> void: + _hide_dialog() + + +func _refresh_failed(message: String, terminal: bool) -> void: + if not visible: + return + error_label.text = message + stay_button.text = "Stay logged in" + stay_button.disabled = terminal + logout_button.disabled = false + + +func _request_logout() -> void: + logout_requested.emit() + + +func _hide_dialog() -> void: + visible = false + error_label.text = "" + stay_button.text = "Stay logged in" + stay_button.disabled = false + logout_button.disabled = false + BlockSessionService.resume_current_question_timer() + + +func dismiss_for_logout() -> void: + if visible: + _hide_dialog() + + +func _unhandled_input(event: InputEvent) -> void: + if visible and event.is_action_pressed("ui_cancel"): + get_viewport().set_input_as_handled() + + +func _refresh_size() -> void: + if panel == null: + return + panel.custom_minimum_size = Vector2( + minf(520.0, maxf(288.0, size.x - 32.0)), + minf(280.0, maxf(250.0, size.y - 32.0))) + + +func _apply_theme() -> void: + if panel == null: + return + var dark := UIThemeService.is_dark_mode() + var panel_style := Brand.panel_box(dark) + panel_style.content_margin_left = 32 + panel_style.content_margin_right = 32 + panel_style.content_margin_top = 30 + panel_style.content_margin_bottom = 30 + panel.add_theme_stylebox_override("panel", panel_style) + for label in [title_label, message_label]: + label.add_theme_color_override("font_color", Brand.text_color(dark)) + error_label.add_theme_color_override("font_color", Color("fb7185") if dark else Color("be123c")) + stay_button.add_theme_stylebox_override("normal", Brand.gradient_button_box()) + stay_button.add_theme_stylebox_override("hover", Brand.gradient_button_box()) + stay_button.add_theme_stylebox_override("pressed", Brand.gradient_button_box()) + stay_button.add_theme_color_override("font_color", Color.WHITE) + stay_button.add_theme_color_override("font_hover_color", Color.WHITE) + logout_button.add_theme_stylebox_override("normal", Brand.secondary_button_box(dark)) + logout_button.add_theme_stylebox_override("hover", Brand.secondary_button_box(dark, true)) + logout_button.add_theme_stylebox_override("pressed", Brand.secondary_button_box(dark, true)) + logout_button.add_theme_color_override("font_color", Brand.text_color(dark)) + logout_button.add_theme_color_override("font_hover_color", Brand.text_color(dark)) diff --git a/scripts/ui/session_expiry_dialog.gd.uid b/scripts/ui/session_expiry_dialog.gd.uid new file mode 100644 index 0000000..37341b4 --- /dev/null +++ b/scripts/ui/session_expiry_dialog.gd.uid @@ -0,0 +1 @@ +uid://dv8ssk14s6suf diff --git a/tests/auth_session_test.gd b/tests/auth_session_test.gd new file mode 100644 index 0000000..f58931d --- /dev/null +++ b/tests/auth_session_test.gd @@ -0,0 +1,98 @@ +extends SceneTree + +var failures := 0 +var auth + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + auth = load("res://scripts/autoload/auth_state.gd").new() + root.add_child(auth) + await process_frame + _test_expiration_parsing() + _test_warning_and_expiration() + _test_refresh_preserves_identity() + _test_refresh_in_flight_has_expiry_grace() + _test_stale_responses_cannot_restore_or_end_session() + auth.queue_free() + await process_frame + quit(1 if failures > 0 else 0) + + +func _test_expiration_parsing() -> void: + var iso_expiration: float = float(auth._parse_expiration("2030-01-02T03:04:05Z")) + var expected_iso := float(Time.get_unix_time_from_datetime_string("2030-01-02T03:04:05")) + _assert(is_equal_approx(iso_expiration, expected_iso), "session expiry should parse the Auth API ISO timestamp") + var jwt_expiration := 1893456000 + var payload: String = Marshalls.raw_to_base64(JSON.stringify({"exp": jwt_expiration}).to_utf8_buffer()) + payload = payload.replace("+", "-").replace("/", "_").trim_suffix("=") + var token := "header.%s.signature" % payload + _assert(int(auth._parse_expiration("", token)) == jwt_expiration, "session expiry should fall back to the JWT exp claim") + + +func _test_warning_and_expiration() -> void: + var observed := {"warning_seconds": -1, "expiration_reason": ""} + var warning_callback := func(seconds: int): observed["warning_seconds"] = seconds + var expiration_callback := func(reason: String): observed["expiration_reason"] = reason + auth.session_warning.connect(warning_callback) + auth.session_expired.connect(expiration_callback) + auth.set_session("tester", "warning-token", "refresh-token", "USER", str(Time.get_unix_time_from_system() + 90.0)) + auth._evaluate_expiry() + _assert(int(observed["warning_seconds"]) > 0 and int(observed["warning_seconds"]) <= 90, "session should warn inside the two-minute renewal window") + auth.expiration_unix = Time.get_unix_time_from_system() - 1.0 + auth._evaluate_expiry() + _assert(str(observed["expiration_reason"]) == "expired" and not auth.is_logged_in, "an unanswered warning should clear the session at expiry") + auth.session_warning.disconnect(warning_callback) + auth.session_expired.disconnect(expiration_callback) + + +func _test_refresh_preserves_identity() -> void: + var observed := {"refreshed": false} + var refreshed_callback := func(): observed["refreshed"] = true + auth.session_refreshed.connect(refreshed_callback) + auth.set_session("tester", "old-token", "persistent-refresh", "SUPER", str(Time.get_unix_time_from_system() + 60.0)) + auth.refresh_in_progress = true + auth.refresh_revision = auth.session_revision + var next_expiration := Time.get_unix_time_from_system() + 900.0 + var body := JSON.stringify({"accessToken": "new-token", "exp": next_expiration}).to_utf8_buffer() + auth._on_refresh_completed(HTTPRequest.RESULT_SUCCESS, 200, PackedStringArray(), body) + _assert(bool(observed["refreshed"]) and auth.access_token == "new-token", "successful renewal should install the replacement access token") + _assert(auth.refresh_token == "persistent-refresh" and auth.username == "tester" and auth.role == "SUPER", "renewal should preserve identity and the non-rotating refresh token") + _assert(not auth.warning_active and auth.expiration_unix > Time.get_unix_time_from_system(), "renewal should reset the warning window") + auth.session_refreshed.disconnect(refreshed_callback) + + +func _test_refresh_in_flight_has_expiry_grace() -> void: + auth.set_session("tester", "old-token", "refresh-token", "USER", str(Time.get_unix_time_from_system() + 60.0)) + auth.expiration_unix = Time.get_unix_time_from_system() - 1.0 + auth.refresh_in_progress = true + auth._evaluate_expiry() + _assert(auth.is_logged_in, "a submitted renewal should be allowed to finish after the old access token expires") + _assert(not auth.can_send_authenticated_request(), "expired access tokens should not be used while renewal is in progress") + auth.refresh_in_progress = false + auth.clear_session() + + +func _test_stale_responses_cannot_restore_or_end_session() -> void: + auth.set_session("tester", "old-token", "refresh-token", "USER", str(Time.get_unix_time_from_system() + 60.0)) + auth.refresh_in_progress = true + auth.refresh_revision = auth.session_revision + auth.clear_session() + var body := JSON.stringify({"accessToken": "stale-token", "exp": Time.get_unix_time_from_system() + 900.0}).to_utf8_buffer() + auth._on_refresh_completed(HTTPRequest.RESULT_SUCCESS, 200, PackedStringArray(), body) + _assert(auth.access_token.is_empty(), "a delayed refresh response must not restore a logged-out session") + auth.set_session("tester", "current-token", "refresh-token", "USER", str(Time.get_unix_time_from_system() + 900.0)) + auth.handle_unauthorized("old-token") + _assert(auth.is_logged_in and auth.access_token == "current-token", "a stale 401 must not end a newer session") + auth.handle_unauthorized("current-token") + _assert(not auth.is_logged_in, "a 401 for the active token should end the invalid session") + + +func _assert(condition: bool, message: String) -> void: + if condition: + return + failures += 1 + push_error(message) diff --git a/tests/auth_session_test.gd.uid b/tests/auth_session_test.gd.uid new file mode 100644 index 0000000..f225a3a --- /dev/null +++ b/tests/auth_session_test.gd.uid @@ -0,0 +1 @@ +uid://bkg4ginpxbkr7 diff --git a/tests/post_answer_review_test.gd b/tests/post_answer_review_test.gd index 9980f4f..9bebc03 100644 --- a/tests/post_answer_review_test.gd +++ b/tests/post_answer_review_test.gd @@ -348,6 +348,10 @@ func _test_player_runtime_construction() -> void: _assert(player.clear_highlight_button.icon != null, "clear highlights should use the supplied toolbar artwork") _assert(player.report_button.icon != null, "report question should use the supplied toolbar artwork") _assert(player.labs_button.icon != null, "lab results should use the supplied toolbar artwork") + for icon_button in [player.clear_highlight_button, player.report_button, player.labs_button]: + _assert(icon_button.alignment == HORIZONTAL_ALIGNMENT_CENTER and icon_button.icon_alignment == HORIZONTAL_ALIGNMENT_CENTER, "question toolbar artwork should be horizontally centered") + _assert(icon_button.vertical_icon_alignment == VERTICAL_ALIGNMENT_CENTER, "question toolbar artwork should be vertically centered") + _assert(icon_button.custom_minimum_size == Vector2(44, 44), "question toolbar artwork should keep stable square controls") player._style_asset_toolbar_button(player.clear_highlight_button, player.CLEAR_HIGHLIGHT_LIGHT_ICON, false) _assert(player.clear_highlight_button.icon == player.CLEAR_HIGHLIGHT_LIGHT_ICON, "clear highlights should support its light-mode artwork") player._style_asset_toolbar_button(player.report_button, player.REPORT_QUESTION_LIGHT_ICON, false) diff --git a/tests/web_responsive_test.gd b/tests/web_responsive_test.gd index 9dd2002..409686c 100644 --- a/tests/web_responsive_test.gd +++ b/tests/web_responsive_test.gd @@ -53,6 +53,20 @@ func _run() -> void: if menu_button.text == "Profile": pause_menu_has_profile = true _assert(not pause_menu_has_profile, "the paused-run menu should not duplicate profile navigation") + _assert(main_screen.session_expiry_dialog != null, "the application shell should construct a global session-expiry prompt") + main_screen.session_expiry_dialog._show_warning(120) + _assert(main_screen.session_expiry_dialog.visible, "the session-expiry prompt should cover every authenticated screen") + _assert(main_screen.session_expiry_dialog.message_label.text.contains("02:00"), "the session-expiry prompt should display a live countdown") + main_screen.session_expiry_dialog._refresh_started() + _assert(main_screen.session_expiry_dialog.stay_button.disabled, "the renewal action should lock while a refresh request is active") + main_screen.session_expiry_dialog._refresh_failed("Connection interrupted.", false) + _assert(not main_screen.session_expiry_dialog.stay_button.disabled, "a transient renewal failure should permit retry before expiry") + var cancel_event := InputEventAction.new() + cancel_event.action = "ui_cancel" + cancel_event.pressed = true + main_screen.session_expiry_dialog._unhandled_input(cancel_event) + _assert(main_screen.session_expiry_dialog.visible, "the session-expiry prompt should not be dismissible with Escape") + main_screen.session_expiry_dialog.dismiss_for_logout() _assert(not main_screen.account_chevron_button.visible, "nonessential account controls should collapse on phone-width headers") var question_bank_nav := main_screen.sidebar.find_child("NavQuestionBank", true, false) as Button _assert(question_bank_nav != null and question_bank_nav.text == "Question Bank", "the Practice navigation item should be replaced by Question Bank")