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", "name@example.com") 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", "name@example.com") 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)