- Steam-linked login no longer writes literal "<null>" into the email field (and no longer locks it read-only) when the verified identity has no email - Login screen releases focus on app focus-out so the username caret stops blinking while the window is inactive - Calculator display text stays legible in light mode; the LCD-style panel background is always dark, so its text should not follow the app theme - Question player: gate the "save as flashcard" toolbar button behind Brand.is_uat_environment(), matching the nav item, so it's hidden in prod - Hide the discipline/system label while a question is unanswered so it can't hint at the answer; reveal it once the explanation is shown - Top-align question content instead of vertically centering it, with a 20px top margin, so it sits closer to the progress indicator
1008 lines
43 KiB
GDScript
1008 lines
43 KiB
GDScript
extends Control
|
|
|
|
const Brand := preload("res://scripts/ui/medmind_style.gd")
|
|
const EndpointConfig := preload("res://scripts/autoload/api_endpoint_config.gd")
|
|
const AUTH_BASE := "https://us3q.ranaze.com/api/Auth"
|
|
const EXTERNAL_AUTH_STORAGE_PREFIX := "ranaze_external_auth_url_"
|
|
const STEAM_DARK_ICON := preload("res://assets/icons/brands/steam-dark.svg")
|
|
const STEAM_LIGHT_ICON := preload("res://assets/icons/brands/steam-light.svg")
|
|
const GOOGLE_ICON := preload("res://assets/icons/brands/google.svg")
|
|
|
|
var main: Control
|
|
var auth_base_url := AUTH_BASE
|
|
var background: ColorRect
|
|
var left_brand: VBoxContainer
|
|
var brand_tagline: RichTextLabel
|
|
var auth_panel: PanelContainer
|
|
var panel_content: VBoxContainer
|
|
var heading: Label
|
|
var subheading: Label
|
|
var message_label: Label
|
|
var theme_button: Button
|
|
var login_view: VBoxContainer
|
|
var register_view: VBoxContainer
|
|
var reset_view: VBoxContainer
|
|
var steam_view: VBoxContainer
|
|
var login_input: LineEdit
|
|
var password_input: LineEdit
|
|
var password_reveal_entries: Array[Dictionary] = []
|
|
var legal_consent_texts: Array[RichTextLabel] = []
|
|
var legal_consent_squares: Array[Panel] = []
|
|
var registration_username_input: LineEdit
|
|
var registration_email_input: LineEdit
|
|
var registration_password_input: LineEdit
|
|
var registration_confirm_input: LineEdit
|
|
var registration_legal_check: CheckBox
|
|
var reset_login_input: LineEdit
|
|
var reset_code_input: LineEdit
|
|
var reset_password_input: LineEdit
|
|
var reset_confirm_input: LineEdit
|
|
var reset_confirm_group: VBoxContainer
|
|
var steam_action: OptionButton
|
|
var steam_username_input: LineEdit
|
|
var steam_email_input: LineEdit
|
|
var steam_password_input: LineEdit
|
|
var steam_confirm_input: LineEdit
|
|
var steam_legal_check: CheckBox
|
|
var external_buttons: Dictionary = {}
|
|
var panel_gutter: MarginContainer
|
|
var submit_buttons: Array[Button] = []
|
|
var fields: Array[LineEdit] = []
|
|
var requests: Dictionary = {}
|
|
var poll_timer: Timer
|
|
var mode := "login"
|
|
var reset_requested := false
|
|
var steam_transaction_id := ""
|
|
var steam_poll_deadline_msec := 0
|
|
var steam_poll_failures := 0
|
|
var steam_popup_opened := false
|
|
var external_provider_id := ""
|
|
var external_provider_name := ""
|
|
var external_verified_email := ""
|
|
var automatic_login_pending := false
|
|
var login_attempt_username := ""
|
|
var compact_layout := false
|
|
var layout_updating := false
|
|
|
|
|
|
func _notification(what: int) -> void:
|
|
if what == NOTIFICATION_APPLICATION_FOCUS_OUT:
|
|
var focus_owner := get_viewport().gui_get_focus_owner()
|
|
if focus_owner != null:
|
|
focus_owner.release_focus()
|
|
|
|
|
|
func _ready() -> void:
|
|
auth_base_url = EndpointConfig.resolve(AUTH_BASE, "/api/Auth")
|
|
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
|
_build_ui()
|
|
_create_request("login", _on_login_completed)
|
|
_create_request("register", _on_register_completed)
|
|
_create_request("reset_request", _on_reset_requested)
|
|
_create_request("reset_confirm", _on_reset_confirmed)
|
|
_create_request("providers", _on_providers_loaded)
|
|
_create_request("steam_start", _on_steam_started)
|
|
_create_request("steam_status", _on_steam_status)
|
|
_create_request("steam_complete", _on_steam_completed)
|
|
poll_timer = Timer.new()
|
|
poll_timer.wait_time = 2.0
|
|
poll_timer.timeout.connect(_poll_steam)
|
|
add_child(poll_timer)
|
|
get_viewport().size_changed.connect(_update_responsive_layout)
|
|
resized.connect(_update_responsive_layout)
|
|
UIThemeService.theme_changed.connect(_on_theme_changed)
|
|
_update_responsive_layout.call_deferred()
|
|
_set_mode("login")
|
|
_apply_theme()
|
|
_load_external_providers()
|
|
|
|
|
|
func on_show(payload: Variant = null) -> void:
|
|
_reset_steam_flow()
|
|
_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()
|
|
|
|
|
|
func on_hide() -> void:
|
|
_reset_steam_flow()
|
|
|
|
|
|
func _build_ui() -> void:
|
|
background = ColorRect.new()
|
|
background.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
|
background.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
|
add_child(background)
|
|
|
|
_add_dot_grid(Vector2(22, 24))
|
|
_add_dot_grid(Vector2(22, 840))
|
|
|
|
theme_button = Button.new()
|
|
theme_button.tooltip_text = "Switch color theme"
|
|
theme_button.custom_minimum_size = Vector2(44, 44)
|
|
theme_button.set_anchors_preset(Control.PRESET_TOP_RIGHT)
|
|
theme_button.position = Vector2(-58, 14)
|
|
theme_button.pressed.connect(UIThemeService.toggle_theme)
|
|
add_child(theme_button)
|
|
|
|
var page_margin := MarginContainer.new()
|
|
page_margin.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
|
page_margin.add_theme_constant_override("margin_left", 52)
|
|
page_margin.add_theme_constant_override("margin_right", 52)
|
|
page_margin.add_theme_constant_override("margin_top", 28)
|
|
page_margin.add_theme_constant_override("margin_bottom", 28)
|
|
add_child(page_margin)
|
|
|
|
var columns := HBoxContainer.new()
|
|
columns.add_theme_constant_override("separation", 72)
|
|
page_margin.add_child(columns)
|
|
|
|
left_brand = VBoxContainer.new()
|
|
left_brand.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
left_brand.alignment = BoxContainer.ALIGNMENT_CENTER
|
|
left_brand.add_theme_constant_override("separation", 24)
|
|
columns.add_child(left_brand)
|
|
var brand_row := HBoxContainer.new()
|
|
brand_row.alignment = BoxContainer.ALIGNMENT_CENTER
|
|
brand_row.add_theme_constant_override("separation", 24)
|
|
left_brand.add_child(brand_row)
|
|
var large_icon := TextureRect.new()
|
|
large_icon.texture = Brand.ranaze_mark_texture()
|
|
large_icon.custom_minimum_size = Vector2(190, 190)
|
|
large_icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
|
large_icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
|
brand_row.add_child(large_icon)
|
|
var wordmark := VBoxContainer.new()
|
|
wordmark.size_flags_vertical = Control.SIZE_SHRINK_CENTER
|
|
wordmark.add_theme_constant_override("separation", 4)
|
|
brand_row.add_child(wordmark)
|
|
var name_row := HBoxContainer.new()
|
|
name_row.add_theme_constant_override("separation", 0)
|
|
wordmark.add_child(name_row)
|
|
var rana_wordmark := Label.new()
|
|
rana_wordmark.text = "Rana"
|
|
rana_wordmark.add_theme_font_size_override("font_size", 76)
|
|
rana_wordmark.add_theme_font_override("font", Brand.font)
|
|
name_row.add_child(rana_wordmark)
|
|
var ze_wordmark := Label.new()
|
|
ze_wordmark.text = "Ze"
|
|
ze_wordmark.add_theme_font_size_override("font_size", 76)
|
|
ze_wordmark.add_theme_font_override("font", Brand.font)
|
|
ze_wordmark.add_theme_color_override("font_color", Brand.SKY_BLUE)
|
|
ze_wordmark.set_meta("brand_accent", true)
|
|
name_row.add_child(ze_wordmark)
|
|
var trainer_wordmark := Label.new()
|
|
trainer_wordmark.text = "STEP 3 TRAINER"
|
|
trainer_wordmark.add_theme_font_size_override("font_size", 22)
|
|
trainer_wordmark.add_theme_font_override("font", Brand.font)
|
|
trainer_wordmark.add_theme_color_override("font_color", Brand.muted_color(true))
|
|
trainer_wordmark.set_meta("brand_subtitle", true)
|
|
trainer_wordmark.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
|
wordmark.add_child(trainer_wordmark)
|
|
brand_tagline = RichTextLabel.new()
|
|
brand_tagline.bbcode_enabled = true
|
|
brand_tagline.fit_content = true
|
|
brand_tagline.scroll_active = false
|
|
brand_tagline.text = "Focused practice. Better decisions.\n[color=#2563eb]Step 3[/color] success."
|
|
brand_tagline.add_theme_font_override("normal_font", Brand.font)
|
|
brand_tagline.add_theme_font_size_override("normal_font_size", 20)
|
|
brand_tagline.custom_minimum_size = Vector2(520, 64)
|
|
brand_tagline.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
|
left_brand.add_child(brand_tagline)
|
|
|
|
var panel_center := CenterContainer.new()
|
|
panel_center.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
panel_center.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
|
columns.add_child(panel_center)
|
|
auth_panel = PanelContainer.new()
|
|
auth_panel.custom_minimum_size = Vector2(710, 1000)
|
|
panel_center.add_child(auth_panel)
|
|
var scroll := ScrollContainer.new()
|
|
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
|
|
auth_panel.add_child(scroll)
|
|
panel_gutter = MarginContainer.new()
|
|
panel_gutter.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
panel_gutter.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
|
panel_gutter.add_theme_constant_override("margin_right", 28)
|
|
scroll.add_child(panel_gutter)
|
|
panel_content = VBoxContainer.new()
|
|
panel_content.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
panel_content.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
|
panel_content.add_theme_constant_override("separation", 24)
|
|
panel_gutter.add_child(panel_content)
|
|
|
|
# main_column holds everything except the legal footer and is the part
|
|
# that should center vertically when a view (e.g. login) is shorter than
|
|
# the panel - the footer stays pinned to the very bottom either way.
|
|
var main_column := VBoxContainer.new()
|
|
main_column.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
|
main_column.alignment = BoxContainer.ALIGNMENT_CENTER
|
|
main_column.add_theme_constant_override("separation", 16)
|
|
panel_content.add_child(main_column)
|
|
|
|
var panel_header := HBoxContainer.new()
|
|
panel_header.add_theme_constant_override("separation", 18)
|
|
main_column.add_child(panel_header)
|
|
var small_icon := TextureRect.new()
|
|
small_icon.texture = Brand.ranaze_mark_texture()
|
|
small_icon.custom_minimum_size = Vector2(72, 72)
|
|
small_icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
|
small_icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
|
panel_header.add_child(small_icon)
|
|
var titles := VBoxContainer.new()
|
|
titles.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
panel_header.add_child(titles)
|
|
heading = Label.new()
|
|
heading.add_theme_font_size_override("font_size", 32)
|
|
heading.add_theme_font_override("font", Brand.font)
|
|
titles.add_child(heading)
|
|
subheading = Label.new()
|
|
subheading.add_theme_font_size_override("font_size", 20)
|
|
subheading.add_theme_font_override("font", Brand.font)
|
|
titles.add_child(subheading)
|
|
|
|
login_view = _build_login_view()
|
|
register_view = _build_register_view()
|
|
reset_view = _build_reset_view()
|
|
steam_view = _build_steam_view()
|
|
for view in [login_view, register_view, reset_view, steam_view]:
|
|
main_column.add_child(view)
|
|
|
|
message_label = Label.new()
|
|
message_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
|
message_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
|
message_label.add_theme_font_size_override("font_size", 14)
|
|
message_label.add_theme_font_override("font", Brand.font)
|
|
main_column.add_child(message_label)
|
|
var legal_footer := HBoxContainer.new()
|
|
legal_footer.alignment = BoxContainer.ALIGNMENT_CENTER
|
|
legal_footer.add_theme_constant_override("separation", 12)
|
|
panel_content.add_child(legal_footer)
|
|
_add_legal_links(legal_footer)
|
|
|
|
|
|
func _build_login_view() -> VBoxContainer:
|
|
var view := _new_form()
|
|
login_input = _add_field(view, "Email or Username", "Enter your email or username")
|
|
password_input = _add_field(view, "Password", "Enter your password", true)
|
|
_add_password_reveal(password_input)
|
|
var forgot := Button.new()
|
|
forgot.text = "Forgot password?"
|
|
forgot.flat = true
|
|
forgot.alignment = HORIZONTAL_ALIGNMENT_RIGHT
|
|
forgot.pressed.connect(func(): _set_mode("reset"))
|
|
view.add_child(forgot)
|
|
var sign_in := _add_primary_button(view, "Sign In", _on_login_pressed)
|
|
password_input.text_submitted.connect(func(_text: String): _on_login_pressed())
|
|
view.add_child(_build_divider())
|
|
for provider in [["steam", "Steam"], ["google", "Google"]]:
|
|
var external := Button.new()
|
|
external.text = "Sign in with %s" % provider[1]
|
|
external.icon = GOOGLE_ICON if provider[0] == "google" else STEAM_LIGHT_ICON
|
|
external.add_theme_constant_override("icon_max_width", 20)
|
|
external.custom_minimum_size = Vector2(0, 68)
|
|
external.visible = false
|
|
external.pressed.connect(_start_external.bind(str(provider[0]), str(provider[1])))
|
|
view.add_child(external)
|
|
external_buttons[provider[0]] = external
|
|
submit_buttons.append(external)
|
|
var create := Button.new()
|
|
create.text = "New to RanaZe? Create an account"
|
|
create.flat = true
|
|
create.pressed.connect(func(): _set_mode("register"))
|
|
view.add_child(create)
|
|
submit_buttons.append(sign_in)
|
|
return view
|
|
|
|
|
|
func _build_register_view() -> VBoxContainer:
|
|
var view := _new_form()
|
|
registration_username_input = _add_field(view, "Username", "3-24 letters, numbers, or underscores")
|
|
registration_email_input = _add_field(view, "Email", "[email protected]")
|
|
registration_password_input = _add_field(view, "Password", "At least 8 characters", true)
|
|
registration_confirm_input = _add_field(view, "Confirm password", "Re-enter your password", true)
|
|
_add_password_reveal(registration_password_input)
|
|
_add_password_reveal(registration_confirm_input)
|
|
registration_legal_check = _add_legal_consent(view)
|
|
_add_primary_button(view, "Create Account", _on_register_pressed)
|
|
_add_back_button(view, "Already have an account? Sign in", "login")
|
|
registration_confirm_input.text_submitted.connect(func(_text: String): _on_register_pressed())
|
|
return view
|
|
|
|
|
|
func _build_reset_view() -> VBoxContainer:
|
|
var view := _new_form()
|
|
reset_login_input = _add_field(view, "Email or Username", "Enter your account email or username")
|
|
_add_primary_button(view, "Send Verification Code", _request_reset)
|
|
reset_confirm_group = _new_form()
|
|
view.add_child(reset_confirm_group)
|
|
reset_code_input = _add_field(reset_confirm_group, "Verification code", "6-digit code")
|
|
reset_code_input.max_length = 6
|
|
reset_password_input = _add_field(reset_confirm_group, "New password", "At least 8 characters", true)
|
|
reset_confirm_input = _add_field(reset_confirm_group, "Confirm new password", "Re-enter your new password", true)
|
|
_add_password_reveal(reset_password_input)
|
|
_add_password_reveal(reset_confirm_input)
|
|
_add_primary_button(reset_confirm_group, "Update Password", _confirm_reset)
|
|
_add_back_button(view, "Back to sign in", "login")
|
|
reset_confirm_group.visible = false
|
|
return view
|
|
|
|
|
|
func _build_steam_view() -> VBoxContainer:
|
|
var view := _new_form()
|
|
var help := Label.new()
|
|
help.text = "Your identity was verified. Create a RanaZe account or link an existing one."
|
|
help.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
|
view.add_child(help)
|
|
steam_action = OptionButton.new()
|
|
steam_action.add_item("Create a new RanaZe account")
|
|
steam_action.add_item("Link an existing RanaZe account")
|
|
steam_action.item_selected.connect(_on_steam_action_changed)
|
|
view.add_child(steam_action)
|
|
steam_username_input = _add_field(view, "Username or email", "Choose a username")
|
|
steam_email_input = _add_field(view, "Email", "[email protected]")
|
|
steam_password_input = _add_field(view, "Password", "At least 8 characters", true)
|
|
steam_confirm_input = _add_field(view, "Confirm password", "Re-enter your password", true)
|
|
_add_password_reveal(steam_password_input)
|
|
_add_password_reveal(steam_confirm_input)
|
|
steam_legal_check = _add_legal_consent(view)
|
|
_add_primary_button(view, "Continue", _complete_steam)
|
|
_add_back_button(view, "Cancel external sign in", "login")
|
|
return view
|
|
|
|
|
|
func _add_legal_consent(parent: VBoxContainer) -> CheckBox:
|
|
# A bare CheckBox's own word-wrap left-aligns every wrapped line flush
|
|
# with the checkbox glyph, so a second line reads as un-indented. Split
|
|
# the toggle from its text into a row instead - the RichTextLabel's own
|
|
# wrapping then stays confined to its column, and "Terms of Service"/
|
|
# "Privacy Policy" render as inline links rather than a duplicate row
|
|
# of link buttons underneath.
|
|
#
|
|
# The actual toggle state lives on a real CheckBox (kept invisible and
|
|
# out of the row's layout) because other code reads .button_pressed -
|
|
# but Button/CheckBox reserves vertical space for a text baseline even
|
|
# with an empty string, which was making the visible square non-square
|
|
# no matter what custom_minimum_size said. Drawing the visible square
|
|
# as a plain Panel sidesteps that entirely.
|
|
var check := CheckBox.new()
|
|
check.visible = false
|
|
parent.add_child(check)
|
|
|
|
var margin := MarginContainer.new()
|
|
margin.add_theme_constant_override("margin_top", 8)
|
|
margin.add_theme_constant_override("margin_bottom", 8)
|
|
parent.add_child(margin)
|
|
var center := CenterContainer.new()
|
|
margin.add_child(center)
|
|
var row := HBoxContainer.new()
|
|
row.add_theme_constant_override("separation", 12)
|
|
center.add_child(row)
|
|
|
|
var square := Panel.new()
|
|
square.custom_minimum_size = Vector2(20, 20)
|
|
square.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
|
|
square.size_flags_vertical = Control.SIZE_SHRINK_CENTER
|
|
square.mouse_filter = Control.MOUSE_FILTER_STOP
|
|
square.set_meta("legal_consent_checkbox", check)
|
|
square.gui_input.connect(func(event: InputEvent):
|
|
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
|
|
check.button_pressed = not check.button_pressed
|
|
)
|
|
row.add_child(square)
|
|
legal_consent_squares.append(square)
|
|
check.toggled.connect(func(_pressed: bool): _refresh_legal_consent_square(square))
|
|
|
|
var consent_text := RichTextLabel.new()
|
|
consent_text.bbcode_enabled = true
|
|
consent_text.fit_content = true
|
|
consent_text.scroll_active = false
|
|
consent_text.meta_underlined = false
|
|
consent_text.custom_minimum_size = Vector2(480, 0)
|
|
consent_text.size_flags_vertical = Control.SIZE_SHRINK_CENTER
|
|
consent_text.add_theme_font_override("normal_font", Brand.font)
|
|
consent_text.add_theme_font_size_override("normal_font_size", 13)
|
|
consent_text.meta_clicked.connect(func(meta: Variant): _open_legal_document(str(meta)))
|
|
legal_consent_texts.append(consent_text)
|
|
row.add_child(consent_text)
|
|
return check
|
|
|
|
|
|
func _refresh_legal_consent_text(consent_text: RichTextLabel, dark: bool) -> void:
|
|
var text_hex := Brand.text_color(dark).to_html(false)
|
|
var link_hex := (Brand.SKY_BLUE if dark else Brand.ROYAL_BLUE).to_html(false)
|
|
consent_text.text = (
|
|
"[color=#%s]I agree to the [/color][url=terms][color=#%s]Terms of Service[/color][/url][color=#%s] and acknowledge the [/color][url=privacy][color=#%s]Privacy Policy[/color][/url][color=#%s].[/color]"
|
|
% [text_hex, link_hex, text_hex, link_hex, text_hex]
|
|
)
|
|
|
|
|
|
func _refresh_legal_consent_square(square: Panel) -> void:
|
|
var dark := UIThemeService.is_dark_mode()
|
|
var accent := Brand.SKY_BLUE if dark else Brand.ROYAL_BLUE
|
|
var checked := square.get_meta("legal_consent_checkbox", null) as CheckBox
|
|
var is_checked := checked != null and checked.button_pressed
|
|
var box := StyleBoxFlat.new()
|
|
box.bg_color = accent if is_checked else Color.TRANSPARENT
|
|
box.border_color = accent
|
|
box.set_border_width_all(2)
|
|
box.set_corner_radius_all(5)
|
|
square.add_theme_stylebox_override("panel", box)
|
|
|
|
|
|
func _add_legal_links(parent: HBoxContainer) -> void:
|
|
for spec in [["Terms of Service", "terms"], ["Privacy Policy", "privacy"]]:
|
|
var link := Button.new()
|
|
link.text = spec[0]
|
|
link.flat = true
|
|
link.pressed.connect(_open_legal_document.bind(str(spec[1])))
|
|
parent.add_child(link)
|
|
|
|
|
|
func _open_legal_document(kind: String) -> void:
|
|
main.open_legal_document(kind)
|
|
|
|
|
|
func _new_form() -> VBoxContainer:
|
|
var form := VBoxContainer.new()
|
|
form.add_theme_constant_override("separation", 10)
|
|
return form
|
|
|
|
|
|
func _build_divider() -> HBoxContainer:
|
|
var row := HBoxContainer.new()
|
|
row.add_theme_constant_override("separation", 22)
|
|
for index in range(3):
|
|
if index == 1:
|
|
var label := Label.new()
|
|
label.text = "or"
|
|
label.add_theme_font_override("font", Brand.font)
|
|
row.add_child(label)
|
|
else:
|
|
var line := HSeparator.new()
|
|
line.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
row.add_child(line)
|
|
return row
|
|
|
|
|
|
func _add_field(parent: VBoxContainer, label_text: String, placeholder: String, secret := false) -> LineEdit:
|
|
var label := Label.new()
|
|
label.text = label_text
|
|
label.add_theme_font_size_override("font_size", 15)
|
|
label.add_theme_font_override("font", Brand.font)
|
|
parent.add_child(label)
|
|
var input := LineEdit.new()
|
|
input.placeholder_text = placeholder
|
|
input.secret = secret
|
|
input.caret_blink = true
|
|
input.caret_blink_interval = 0.5
|
|
input.custom_minimum_size = Vector2(0, 56)
|
|
input.add_theme_font_size_override("font_size", 17)
|
|
input.add_theme_font_override("font", Brand.font)
|
|
parent.add_child(input)
|
|
fields.append(input)
|
|
return input
|
|
|
|
|
|
func _add_password_reveal(input: LineEdit) -> void:
|
|
var reveal := Button.new()
|
|
password_reveal_entries.append({"input": input, "button": reveal})
|
|
reveal.text = ""
|
|
reveal.tooltip_text = "Show password"
|
|
reveal.flat = true
|
|
reveal.focus_mode = Control.FOCUS_NONE
|
|
reveal.custom_minimum_size = Vector2(52, 52)
|
|
reveal.set_anchors_preset(Control.PRESET_CENTER_RIGHT)
|
|
reveal.position = Vector2(-60, -26)
|
|
_refresh_password_reveal(input, reveal)
|
|
reveal.pressed.connect(func():
|
|
input.secret = not input.secret
|
|
reveal.tooltip_text = "Show password" if input.secret else "Hide password"
|
|
_refresh_password_reveal(input, reveal)
|
|
)
|
|
input.add_child(reveal)
|
|
|
|
|
|
func _refresh_password_reveal(input: LineEdit, reveal: Button) -> void:
|
|
reveal.icon = preload("res://assets/icons/lucide/eye.svg") if input.secret else preload("res://assets/icons/lucide/eye-off.svg")
|
|
reveal.expand_icon = false
|
|
reveal.add_theme_constant_override("icon_max_width", 24)
|
|
var icon_color := Brand.muted_color(UIThemeService.is_dark_mode())
|
|
for color_name in ["icon_normal_color", "icon_hover_color", "icon_pressed_color", "icon_focus_color"]:
|
|
reveal.add_theme_color_override(color_name, icon_color)
|
|
|
|
|
|
func _add_primary_button(parent: VBoxContainer, text_value: String, callback: Callable) -> Button:
|
|
var button := Button.new()
|
|
button.text = text_value
|
|
button.custom_minimum_size = Vector2(0, 68)
|
|
button.pressed.connect(callback)
|
|
parent.add_child(button)
|
|
submit_buttons.append(button)
|
|
return button
|
|
|
|
|
|
func _add_back_button(parent: VBoxContainer, text_value: String, target_mode: String) -> void:
|
|
var button := Button.new()
|
|
button.text = text_value
|
|
button.flat = true
|
|
button.pressed.connect(func(): _set_mode(target_mode))
|
|
parent.add_child(button)
|
|
|
|
|
|
func _add_dot_grid(origin: Vector2) -> void:
|
|
for row in range(4):
|
|
for column in range(5):
|
|
var dot := ColorRect.new()
|
|
dot.color = Color(Brand.ROYAL_BLUE, 0.45)
|
|
dot.position = origin + Vector2(column * 30, row * 30)
|
|
dot.size = Vector2(5, 5)
|
|
dot.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
|
add_child(dot)
|
|
|
|
|
|
func _set_mode(next_mode: String) -> void:
|
|
if mode == "steam" and next_mode != "steam":
|
|
_reset_steam_flow()
|
|
mode = next_mode
|
|
if login_view == null: return
|
|
login_view.visible = mode == "login"
|
|
register_view.visible = mode == "register"
|
|
reset_view.visible = mode == "reset"
|
|
steam_view.visible = mode == "steam"
|
|
match mode:
|
|
"register": heading.text = "Create your account"; subheading.text = "Start studying with RanaZe"
|
|
"reset": heading.text = "Reset your password"; subheading.text = "We will email you a verification code"
|
|
"steam": heading.text = "Finish %s sign in" % external_provider_name; subheading.text = "Connect %s to RanaZe" % external_provider_name
|
|
_: heading.text = "Welcome back"; subheading.text = "Sign in to continue"
|
|
_clear_message()
|
|
_update_responsive_layout()
|
|
|
|
|
|
func _update_responsive_layout() -> void:
|
|
if left_brand == null or layout_updating: return
|
|
layout_updating = true
|
|
var layout_width := size.x
|
|
if layout_width <= 0.0:
|
|
layout_width = get_viewport_rect().size.x
|
|
var layout_height := size.y
|
|
if layout_height <= 0.0:
|
|
layout_height = get_viewport_rect().size.y
|
|
var compact_desktop := layout_width < 900.0
|
|
var mobile_web := false
|
|
if OS.has_feature("web"):
|
|
var browser_width: Variant = JavaScriptBridge.eval("window.innerWidth", true)
|
|
mobile_web = float(browser_width) < 760.0
|
|
compact_layout = compact_desktop or mobile_web
|
|
left_brand.visible = not compact_layout
|
|
if mobile_web:
|
|
theme_button.custom_minimum_size = Vector2(160, 160)
|
|
theme_button.position = Vector2(-190, 20)
|
|
theme_button.add_theme_font_size_override("font_size", 64)
|
|
auth_panel.custom_minimum_size = Vector2(1700, 2300)
|
|
panel_gutter.add_theme_constant_override("margin_right", 96)
|
|
_apply_compact_scale(panel_content, 3.8)
|
|
else:
|
|
theme_button.custom_minimum_size = Vector2(44, 44)
|
|
theme_button.position = Vector2(-58, 14)
|
|
theme_button.add_theme_font_size_override("font_size", 20)
|
|
# 1000/1010 comfortably fits every view (login/register/reset/steam)
|
|
# without the inner ScrollContainer kicking in, on any window tall
|
|
# enough to hold it - but clamp to whatever height is actually
|
|
# available so a short window shrinks the panel (and lets the
|
|
# ScrollContainer take over) instead of the panel overflowing off
|
|
# the top/bottom of the screen.
|
|
var available_panel_height := layout_height - 56.0
|
|
var target_height := 1010.0 if compact_desktop else 1000.0
|
|
var panel_height := clampf(available_panel_height, 400.0, target_height)
|
|
auth_panel.custom_minimum_size = Vector2(640 if compact_desktop else 710, panel_height)
|
|
panel_gutter.add_theme_constant_override("margin_right", 28)
|
|
_apply_compact_scale(panel_content, 1.0)
|
|
_apply_theme()
|
|
layout_updating = false
|
|
|
|
|
|
func _apply_compact_scale(control: Control, factor: float) -> void:
|
|
if not control.has_meta("medmind_base_minimum"):
|
|
control.set_meta("medmind_base_minimum", control.custom_minimum_size)
|
|
var base_minimum: Vector2 = control.get_meta("medmind_base_minimum")
|
|
control.custom_minimum_size = base_minimum * factor
|
|
if control is Label or control is Button or control is LineEdit or control is OptionButton:
|
|
if not control.has_meta("medmind_base_font_size"):
|
|
control.set_meta("medmind_base_font_size", control.get_theme_font_size("font_size"))
|
|
control.add_theme_font_size_override("font_size", int(round(float(control.get_meta("medmind_base_font_size")) * factor)))
|
|
for child in control.get_children():
|
|
if child is Control:
|
|
_apply_compact_scale(child, factor)
|
|
|
|
|
|
func _apply_theme() -> void:
|
|
var dark := UIThemeService.is_dark_mode()
|
|
background.color = Brand.background_color(dark)
|
|
background.material = Brand.background_material(dark)
|
|
auth_panel.add_theme_stylebox_override("panel", Brand.panel_box(dark))
|
|
Brand.set_theme_toggle_icon(theme_button, dark)
|
|
theme_button.tooltip_text = "Use light theme" if dark else "Use dark theme"
|
|
theme_button.add_theme_font_override("font", Brand.font)
|
|
for node in find_children("*", "Label", true, false):
|
|
var label := node as Label
|
|
if label.has_meta("brand_accent"):
|
|
label.add_theme_color_override("font_color", Brand.SKY_BLUE if dark else Brand.ROYAL_BLUE)
|
|
elif label.has_meta("brand_subtitle"):
|
|
label.add_theme_color_override("font_color", Brand.muted_color(dark))
|
|
else:
|
|
label.add_theme_color_override("font_color", Brand.text_color(dark))
|
|
for input in fields:
|
|
_style_auth_field(input, dark)
|
|
for consent_text in legal_consent_texts:
|
|
_refresh_legal_consent_text(consent_text, dark)
|
|
for square in legal_consent_squares:
|
|
_refresh_legal_consent_square(square)
|
|
for button in find_children("*", "Button", true, false):
|
|
if button is CheckBox:
|
|
continue
|
|
var typed := button as Button
|
|
typed.add_theme_font_override("font", Brand.font)
|
|
typed.add_theme_color_override("font_color", Brand.text_color(dark))
|
|
if not typed.flat:
|
|
typed.add_theme_stylebox_override("normal", Brand.secondary_button_box(dark))
|
|
typed.add_theme_stylebox_override("hover", Brand.secondary_button_box(dark, true))
|
|
for button in submit_buttons:
|
|
if not button.text.begins_with("Sign in with "):
|
|
button.add_theme_stylebox_override("normal", Brand.gradient_button_box())
|
|
button.add_theme_color_override("font_color", Color.WHITE)
|
|
if external_buttons.has("steam"):
|
|
(external_buttons["steam"] as Button).icon = STEAM_DARK_ICON if dark else STEAM_LIGHT_ICON
|
|
var theme_normal := StyleBoxFlat.new()
|
|
theme_normal.bg_color = Color("111b30") if dark else Brand.LIGHT_SURFACE
|
|
theme_normal.border_color = Color("33415d") if dark else Brand.LIGHT_BORDER
|
|
theme_normal.set_border_width_all(1)
|
|
theme_normal.set_corner_radius_all(22)
|
|
theme_button.add_theme_stylebox_override("normal", theme_normal)
|
|
theme_button.add_theme_stylebox_override("hover", theme_normal)
|
|
theme_button.add_theme_color_override("font_color", Brand.AMBER)
|
|
for entry in password_reveal_entries:
|
|
_refresh_password_reveal(entry["input"], entry["button"])
|
|
subheading.add_theme_color_override("font_color", Brand.muted_color(dark))
|
|
message_label.add_theme_color_override("font_color", Brand.muted_color(dark))
|
|
if brand_tagline != null:
|
|
brand_tagline.add_theme_color_override("default_color", Brand.muted_color(dark))
|
|
|
|
|
|
func _style_auth_field(input: LineEdit, dark: bool) -> void:
|
|
input.add_theme_color_override("font_color", Brand.text_color(dark))
|
|
input.add_theme_color_override("font_uneditable_color", Brand.text_color(dark))
|
|
input.add_theme_color_override("font_placeholder_color", Brand.muted_color(dark))
|
|
input.add_theme_color_override("caret_color", Brand.SKY_BLUE if dark else Brand.ROYAL_BLUE)
|
|
input.add_theme_stylebox_override("normal", Brand.field_box(dark))
|
|
input.add_theme_stylebox_override("focus", Brand.field_box(dark, true))
|
|
input.add_theme_stylebox_override("read_only", Brand.field_box(dark))
|
|
|
|
|
|
func _on_theme_changed(_is_dark: bool) -> void:
|
|
_apply_theme()
|
|
|
|
|
|
func _on_login_pressed() -> void:
|
|
_send_login(login_input.text.strip_edges(), password_input.text, false)
|
|
|
|
|
|
func _send_login(identifier: String, password: String, automatic: bool) -> void:
|
|
if identifier.is_empty() or password.is_empty(): return _show_error("Email or username and password are required.")
|
|
automatic_login_pending = automatic
|
|
login_attempt_username = identifier
|
|
_send_json("login", "/login", {"username": identifier, "password": password}, "Signing in...")
|
|
|
|
|
|
func _on_login_completed(result: int, code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
|
|
_set_busy(false)
|
|
if result != HTTPRequest.RESULT_SUCCESS or code < 200 or code >= 300:
|
|
if automatic_login_pending:
|
|
_set_mode("login"); login_input.text = login_attempt_username
|
|
_show_error("Account created, but automatic sign in failed. Please sign in.")
|
|
else: _show_error("Sign in failed. Check your credentials and connection.")
|
|
automatic_login_pending = false
|
|
return
|
|
_accept_session(body, login_attempt_username)
|
|
|
|
|
|
func _on_register_pressed() -> void:
|
|
var username := registration_username_input.text.strip_edges()
|
|
var email := registration_email_input.text.strip_edges()
|
|
var password := registration_password_input.text
|
|
var error := _validate_account(username, email, password, registration_confirm_input.text)
|
|
if not error.is_empty(): return _show_error(error)
|
|
if not registration_legal_check.button_pressed:
|
|
return _show_error("Agree to the Terms of Service and acknowledge the Privacy Policy to create an account.")
|
|
login_attempt_username = username
|
|
_send_json("register", "/register", {"username": username, "email": email, "password": password, "acceptLegal": true}, "Creating account...")
|
|
|
|
|
|
func _on_register_completed(result: int, code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
|
|
_set_busy(false)
|
|
if result != HTTPRequest.RESULT_SUCCESS or code < 200 or code >= 300:
|
|
return _show_error(_response_message(body, "Account creation failed."))
|
|
_send_login(login_attempt_username, registration_password_input.text, true)
|
|
|
|
|
|
func _request_reset() -> void:
|
|
if reset_login_input.text.strip_edges().is_empty(): return _show_error("Enter your email or username.")
|
|
_send_json("reset_request", "/password-reset/request", {"login": reset_login_input.text.strip_edges()}, "Sending code...")
|
|
|
|
|
|
func _on_reset_requested(result: int, code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
|
|
_set_busy(false)
|
|
if result != HTTPRequest.RESULT_SUCCESS or code < 200 or code >= 300:
|
|
return _show_error(_response_message(body, "Unable to send a verification code."))
|
|
reset_requested = true
|
|
reset_confirm_group.visible = true
|
|
_show_success("If the account exists, a verification code has been sent.")
|
|
reset_code_input.grab_focus()
|
|
|
|
|
|
func _confirm_reset() -> void:
|
|
if reset_code_input.text.length() != 6: return _show_error("Enter the 6-digit verification code.")
|
|
if reset_password_input.text.length() < 8: return _show_error("Password must be at least 8 characters.")
|
|
if reset_password_input.text != reset_confirm_input.text: return _show_error("Passwords do not match.")
|
|
_send_json("reset_confirm", "/password-reset/confirm", {
|
|
"login": reset_login_input.text.strip_edges(), "code": reset_code_input.text,
|
|
"newPassword": reset_password_input.text
|
|
}, "Updating password...")
|
|
|
|
|
|
func _on_reset_confirmed(result: int, code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
|
|
_set_busy(false)
|
|
if result != HTTPRequest.RESULT_SUCCESS or code < 200 or code >= 300:
|
|
return _show_error(_response_message(body, "The verification code is invalid or expired."))
|
|
var identifier := reset_login_input.text
|
|
_set_mode("login")
|
|
login_input.text = identifier
|
|
_show_success("Password updated. You can now sign in.")
|
|
|
|
|
|
func _load_external_providers() -> void:
|
|
var request: HTTPRequest = requests["providers"]
|
|
if request.get_http_client_status() == HTTPClient.STATUS_DISCONNECTED:
|
|
request.request("%s/external/providers" % auth_base_url)
|
|
|
|
|
|
func _on_providers_loaded(result: int, code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
|
|
if result != HTTPRequest.RESULT_SUCCESS or code < 200 or code >= 300:
|
|
return
|
|
var data := _parse_dictionary(body)
|
|
var enabled: Dictionary = {}
|
|
for provider_value in data.get("providers", []):
|
|
if typeof(provider_value) == TYPE_DICTIONARY:
|
|
var provider: Dictionary = provider_value
|
|
enabled[str(provider.get("id", ""))] = true
|
|
for provider_id in external_buttons:
|
|
(external_buttons[provider_id] as Button).visible = enabled.has(provider_id)
|
|
|
|
|
|
func _start_steam() -> void:
|
|
_start_external("steam", "Steam")
|
|
|
|
|
|
func _start_external(provider_id: String, provider_name: String) -> void:
|
|
external_provider_id = provider_id
|
|
external_provider_name = provider_name
|
|
external_verified_email = ""
|
|
var storage_key := _external_storage_key()
|
|
if OS.has_feature("web"):
|
|
var popup_result: Variant = JavaScriptBridge.eval(
|
|
"window.localStorage.removeItem(%s); var ranazeExternalBridge = new URL('/external-redirect.html?key=' + encodeURIComponent(%s) + '&provider=' + encodeURIComponent(%s), window.location.origin).href; window.ranazeExternalWindow = window.open(ranazeExternalBridge, 'ranaze_external', 'popup=yes,width=560,height=720,resizable=yes,scrollbars=yes'); if (window.ranazeExternalWindow) { window.ranazeExternalWindow.blur(); window.focus(); } window.ranazeExternalWindow !== null;" % [JSON.stringify(storage_key), JSON.stringify(storage_key), JSON.stringify(provider_name)],
|
|
true
|
|
)
|
|
steam_popup_opened = bool(popup_result)
|
|
if not steam_popup_opened:
|
|
return _show_error("%s sign in needs a popup. Allow popups for this site, then try again." % provider_name)
|
|
_send_json("steam_start", "/external/%s/start" % provider_id, {"purpose": "login"}, "Opening %s..." % provider_name)
|
|
|
|
|
|
func _on_steam_started(result: int, code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
|
|
_set_busy(false)
|
|
if result != HTTPRequest.RESULT_SUCCESS or code < 200 or code >= 300:
|
|
_close_steam_popup()
|
|
return _show_error("Unable to start %s sign in." % external_provider_name)
|
|
var data := _parse_dictionary(body)
|
|
steam_transaction_id = str(data.get("transactionId", ""))
|
|
var auth_url := str(data.get("authUrl", ""))
|
|
if steam_transaction_id.is_empty() or auth_url.is_empty():
|
|
_close_steam_popup()
|
|
return _show_error("%s returned an invalid sign-in response." % external_provider_name)
|
|
if OS.has_feature("web"):
|
|
JavaScriptBridge.eval(
|
|
"var ranazeExternalAuthUrl = %s; window.localStorage.setItem(%s, ranazeExternalAuthUrl); if (window.ranazeExternalWindow && !window.ranazeExternalWindow.closed) { try { window.ranazeExternalWindow.location.replace(ranazeExternalAuthUrl); } catch (_) {} window.ranazeExternalWindow.focus(); } else { var ranazeExternalPopup = window.open(ranazeExternalAuthUrl, 'ranaze_external'); if (ranazeExternalPopup) { window.ranazeExternalWindow = ranazeExternalPopup; ranazeExternalPopup.focus(); } else { window.location.assign(ranazeExternalAuthUrl); } }" % [JSON.stringify(auth_url), JSON.stringify(_external_storage_key())],
|
|
true
|
|
)
|
|
else:
|
|
OS.shell_open(auth_url)
|
|
steam_poll_deadline_msec = Time.get_ticks_msec() + 300000
|
|
steam_poll_failures = 0
|
|
_show_success("Complete sign in in the %s window. Waiting for verification..." % external_provider_name)
|
|
poll_timer.start()
|
|
|
|
|
|
func _poll_steam() -> void:
|
|
var status_request: HTTPRequest = requests["steam_status"]
|
|
if steam_transaction_id.is_empty() or status_request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED: return
|
|
if steam_poll_deadline_msec > 0 and Time.get_ticks_msec() >= steam_poll_deadline_msec:
|
|
_reset_steam_flow()
|
|
return _show_error("Steam sign in timed out. Try again when ready.")
|
|
var error: Error = status_request.request("%s/external/%s/status/%s" % [auth_base_url, external_provider_id, steam_transaction_id])
|
|
if error != OK:
|
|
steam_poll_failures += 1
|
|
if steam_poll_failures >= 3: _show_error("Connection interrupted. Still waiting for %s..." % external_provider_name)
|
|
|
|
|
|
func _on_steam_status(result: int, code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
|
|
if result != HTTPRequest.RESULT_SUCCESS or code >= 500 or code == 429:
|
|
steam_poll_failures += 1
|
|
if steam_poll_failures >= 3: _show_error("Connection interrupted. Still waiting for %s..." % external_provider_name)
|
|
return
|
|
if code == 404:
|
|
_reset_steam_flow()
|
|
return _show_error("%s sign in expired. Please try again." % external_provider_name)
|
|
if code < 200 or code >= 300:
|
|
_reset_steam_flow()
|
|
return _show_error(_response_message(body, "%s sign in could not be completed." % external_provider_name))
|
|
steam_poll_failures = 0
|
|
var data := _parse_dictionary(body)
|
|
match str(data.get("status", "pending")):
|
|
"pending": _show_success("Complete sign in in the %s window. Waiting for verification..." % external_provider_name)
|
|
"requires_account":
|
|
poll_timer.stop()
|
|
_close_steam_popup()
|
|
var verified_email_value: Variant = data.get("email")
|
|
external_verified_email = "" if verified_email_value == null else str(verified_email_value)
|
|
steam_email_input.text = external_verified_email
|
|
steam_email_input.editable = external_verified_email.is_empty()
|
|
_set_mode("steam")
|
|
"complete": poll_timer.stop(); _close_steam_popup(); _accept_session(body, str(data.get("username", "")))
|
|
|
|
|
|
func _on_steam_action_changed(index: int) -> void:
|
|
_set_field_visible(steam_email_input, index == 0)
|
|
_set_field_visible(steam_confirm_input, index == 0)
|
|
if index != 0:
|
|
steam_confirm_input.text = ""
|
|
steam_legal_check.visible = index == 0
|
|
steam_username_input.placeholder_text = "Choose a username" if index == 0 else "Existing email or username"
|
|
|
|
|
|
func _complete_steam() -> void:
|
|
var action := "create" if steam_action.selected == 0 else "link"
|
|
if action == "create":
|
|
var validation := _validate_account(steam_username_input.text.strip_edges(), steam_email_input.text.strip_edges(), steam_password_input.text, steam_confirm_input.text)
|
|
if not validation.is_empty(): return _show_error(validation)
|
|
if not steam_legal_check.button_pressed:
|
|
return _show_error("Agree to the Terms of Service and acknowledge the Privacy Policy to create an account.")
|
|
elif steam_username_input.text.strip_edges().is_empty() or steam_password_input.text.is_empty():
|
|
return _show_error("Enter your existing account credentials.")
|
|
_send_json("steam_complete", "/external/%s/complete" % external_provider_id, {
|
|
"transactionId": steam_transaction_id, "action": action,
|
|
"username": steam_username_input.text.strip_edges(), "email": steam_email_input.text.strip_edges(),
|
|
"password": steam_password_input.text, "acceptLegal": action == "create"
|
|
}, "Connecting account...")
|
|
|
|
|
|
func _set_field_visible(input: LineEdit, visible_value: bool) -> void:
|
|
input.visible = visible_value
|
|
var index := input.get_index()
|
|
if index > 0:
|
|
var label := input.get_parent().get_child(index - 1)
|
|
if label is Label:
|
|
label.visible = visible_value
|
|
|
|
|
|
func _on_steam_completed(result: int, code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
|
|
_set_busy(false)
|
|
if result != HTTPRequest.RESULT_SUCCESS or code < 200 or code >= 300:
|
|
return _show_error(_response_message(body, "Unable to connect this %s account." % external_provider_name))
|
|
steam_poll_failures = 0
|
|
poll_timer.start()
|
|
_poll_steam()
|
|
|
|
|
|
func _reset_steam_flow() -> void:
|
|
if poll_timer != null: poll_timer.stop()
|
|
_close_steam_popup()
|
|
steam_transaction_id = ""
|
|
steam_poll_deadline_msec = 0
|
|
steam_poll_failures = 0
|
|
external_provider_id = ""
|
|
external_provider_name = ""
|
|
external_verified_email = ""
|
|
|
|
|
|
func _close_steam_popup() -> void:
|
|
if OS.has_feature("web") and steam_popup_opened:
|
|
JavaScriptBridge.eval("window.localStorage.removeItem(%s); if (window.ranazeExternalWindow && !window.ranazeExternalWindow.closed) { window.ranazeExternalWindow.close(); } window.ranazeExternalWindow = null;" % JSON.stringify(_external_storage_key()), true)
|
|
steam_popup_opened = false
|
|
|
|
|
|
func _external_storage_key() -> String:
|
|
return EXTERNAL_AUTH_STORAGE_PREFIX + external_provider_id
|
|
|
|
|
|
func _accept_session(body: PackedByteArray, fallback_username: String) -> void:
|
|
var response := _parse_dictionary(body)
|
|
var token := str(response.get("accessToken", ""))
|
|
if token.is_empty(): return _show_error("Sign in response did not include an access token.")
|
|
var legal_value: Variant = response.get("legal", {})
|
|
var legal: Dictionary = legal_value if typeof(legal_value) == TYPE_DICTIONARY else {}
|
|
AuthState.set_session(str(response.get("username", fallback_username)), token,
|
|
str(response.get("refreshToken", "")), str(response.get("role", "")), str(response.get("exp", "")), legal)
|
|
password_input.text = ""
|
|
_set_busy(false)
|
|
UISoundService.play_sound("correct")
|
|
main.show_screen("legal_consent" if AuthState.requires_legal_acceptance else "dashboard")
|
|
|
|
|
|
func _create_request(key: String, callback: Callable) -> void:
|
|
var request := HTTPRequest.new()
|
|
request.timeout = 20.0
|
|
request.request_completed.connect(callback)
|
|
add_child(request)
|
|
requests[key] = request
|
|
|
|
|
|
func _send_json(key: String, path: String, payload: Dictionary, status: String) -> void:
|
|
_clear_message()
|
|
_set_busy(true)
|
|
message_label.text = status
|
|
var request: HTTPRequest = requests[key]
|
|
var error := request.request(auth_base_url + path, PackedStringArray(["Content-Type: application/json"]), HTTPClient.METHOD_POST, JSON.stringify(payload))
|
|
if error != OK:
|
|
_set_busy(false)
|
|
_show_error("Unable to send the request. Check your connection and try again.")
|
|
|
|
|
|
func _set_busy(busy: bool) -> void:
|
|
for button in submit_buttons: button.disabled = busy
|
|
for field in fields: field.editable = not busy
|
|
|
|
|
|
func _validate_account(username: String, email: String, password: String, confirmation: String) -> String:
|
|
var username_regex := RegEx.new(); username_regex.compile("^[A-Za-z0-9_]{3,24}$")
|
|
if username_regex.search(username) == null: return "Username must be 3-24 letters, numbers, or underscores."
|
|
var email_regex := RegEx.new(); email_regex.compile("^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$")
|
|
if email_regex.search(email) == null: return "Enter a valid email address."
|
|
if password.length() < 8: return "Password must be at least 8 characters."
|
|
if password != confirmation: return "Passwords do not match."
|
|
return ""
|
|
|
|
|
|
func _parse_dictionary(body: PackedByteArray) -> Dictionary:
|
|
var parsed: Variant = JSON.parse_string(body.get_string_from_utf8())
|
|
return parsed if typeof(parsed) == TYPE_DICTIONARY else {}
|
|
|
|
|
|
func _response_message(body: PackedByteArray, fallback: String) -> String:
|
|
var raw := body.get_string_from_utf8().strip_edges()
|
|
var parsed: Variant = JSON.parse_string(raw)
|
|
if typeof(parsed) == TYPE_STRING and not str(parsed).is_empty(): return str(parsed)
|
|
if typeof(parsed) == TYPE_DICTIONARY and parsed.has("message"): return str(parsed.message)
|
|
return fallback
|
|
|
|
|
|
func _clear_message() -> void:
|
|
if message_label != null: message_label.text = ""
|
|
|
|
|
|
func _show_error(value: String) -> void:
|
|
_set_busy(false)
|
|
message_label.text = value
|
|
message_label.add_theme_color_override("font_color", Color("ef4444"))
|
|
UISoundService.play_sound("error")
|
|
|
|
|
|
func _show_success(value: String) -> void:
|
|
message_label.text = value
|
|
message_label.add_theme_color_override("font_color", Brand.SUCCESS)
|