Files
step3/scripts/ui/login_screen.gd
T
hzandClaude Sonnet 5 548b519d44 Vertically center the login screen wordmark against the logo
The RanaZe/STEP 3 TRAINER text block was top-aligned inside the
brand row's HBoxContainer, which is taller than the text due to the
190x190 icon beside it, so it hugged the top instead of centering
against the shield mark.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-26 18:08:36 -05:00

1000 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 _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()
external_verified_email = str(data.get("email", ""))
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)