Add session expiry renewal flow
This commit is contained in:
@@ -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 != "<null>":
|
||||
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))
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
+19
-2
@@ -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:
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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))
|
||||
@@ -0,0 +1 @@
|
||||
uid://dv8ssk14s6suf
|
||||
Reference in New Issue
Block a user