Files
step3/scripts/ui/login_screen.gd
T

349 lines
12 KiB
GDScript

extends VBoxContainer
const Style := preload("res://scripts/ui/ui_style.gd")
const EndpointConfig := preload("res://scripts/autoload/api_endpoint_config.gd")
const DEFAULT_AUTH_LOGIN_URL := "https://pauth.ranaze.com/api/Auth/login"
const DEFAULT_AUTH_REGISTER_URL := "https://pauth.ranaze.com/api/Auth/register"
var main: Control
var auth_panel: PanelContainer
var auth_prompt_label: Label
var switch_mode_button: Button
var login_fields: VBoxContainer
var register_fields: VBoxContainer
var username_input: LineEdit
var password_input: LineEdit
var registration_username_input: LineEdit
var registration_email_input: LineEdit
var registration_password_input: LineEdit
var registration_confirm_input: LineEdit
var login_button: Button
var register_button: Button
var error_label: Label
var success_label: Label
var login_request: HTTPRequest
var register_request: HTTPRequest
var auth_mode := "login"
var automatic_login_pending := false
var login_attempt_username := ""
var auth_login_url := DEFAULT_AUTH_LOGIN_URL
var auth_register_url := DEFAULT_AUTH_REGISTER_URL
func _ready() -> void:
auth_login_url = EndpointConfig.resolve(DEFAULT_AUTH_LOGIN_URL, "/api/Auth/login")
auth_register_url = EndpointConfig.resolve(DEFAULT_AUTH_REGISTER_URL, "/api/Auth/register")
set_anchors_preset(Control.PRESET_FULL_RECT)
add_theme_constant_override("separation", 18)
var spacer_top := Control.new()
spacer_top.size_flags_vertical = Control.SIZE_EXPAND_FILL
add_child(spacer_top)
var center := CenterContainer.new()
center.size_flags_horizontal = Control.SIZE_EXPAND_FILL
add_child(center)
auth_panel = PanelContainer.new()
auth_panel.custom_minimum_size = Vector2(540, 470)
auth_panel.add_theme_stylebox_override("panel", Style.panel_box(Color("172337"), Style.CYAN))
center.add_child(auth_panel)
var content := VBoxContainer.new()
content.add_theme_constant_override("separation", 14)
auth_panel.add_child(content)
var title := Label.new()
title.text = "RanaZe's Doctor Maker"
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
Style.set_label(title, 34, Style.TEXT)
content.add_child(title)
auth_prompt_label = Label.new()
auth_prompt_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
auth_prompt_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
Style.set_label(auth_prompt_label, 16, Style.MUTED)
content.add_child(auth_prompt_label)
login_fields = VBoxContainer.new()
login_fields.add_theme_constant_override("separation", 10)
content.add_child(login_fields)
username_input = _add_text_field(login_fields, "Username", "enter username")
username_input.text_submitted.connect(_on_login_input_submitted)
password_input = _add_text_field(login_fields, "Password", "enter password", true)
password_input.text_submitted.connect(_on_login_input_submitted)
login_button = Button.new()
login_button.text = "Log In"
login_button.custom_minimum_size = Vector2(0, 50)
Style.style_primary_button(login_button)
login_button.pressed.connect(_on_login_pressed)
login_fields.add_child(login_button)
register_fields = VBoxContainer.new()
register_fields.add_theme_constant_override("separation", 10)
content.add_child(register_fields)
registration_username_input = _add_text_field(register_fields, "Username", "3-24 letters, numbers, or underscores")
registration_email_input = _add_text_field(register_fields, "Email", "[email protected]")
registration_password_input = _add_text_field(register_fields, "Password", "at least 8 characters", true)
registration_confirm_input = _add_text_field(register_fields, "Confirm password", "re-enter password", true)
registration_confirm_input.text_submitted.connect(_on_register_input_submitted)
register_button = Button.new()
register_button.text = "Create Account"
register_button.custom_minimum_size = Vector2(0, 50)
Style.style_primary_button(register_button)
register_button.pressed.connect(_on_register_pressed)
register_fields.add_child(register_button)
success_label = Label.new()
success_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
success_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
Style.set_label(success_label, 15, Style.LIME)
content.add_child(success_label)
error_label = Label.new()
error_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
error_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
Style.set_label(error_label, 15, Style.RED)
content.add_child(error_label)
var switch_spacer := Control.new()
switch_spacer.custom_minimum_size = Vector2(0, 8)
content.add_child(switch_spacer)
switch_mode_button = Button.new()
switch_mode_button.custom_minimum_size = Vector2(0, 46)
Style.style_chip(switch_mode_button, Style.CYAN)
switch_mode_button.pressed.connect(_toggle_auth_mode)
content.add_child(switch_mode_button)
var spacer_bottom := Control.new()
spacer_bottom.size_flags_vertical = Control.SIZE_EXPAND_FILL
add_child(spacer_bottom)
login_request = HTTPRequest.new()
login_request.name = "LoginRequest"
login_request.request_completed.connect(_on_login_request_completed)
add_child(login_request)
register_request = HTTPRequest.new()
register_request.name = "RegisterRequest"
register_request.request_completed.connect(_on_register_request_completed)
add_child(register_request)
_set_auth_mode("login")
func on_show(_payload: Variant = null) -> void:
_set_busy(false)
_set_auth_mode("login")
_clear_messages()
username_input.grab_focus()
func _add_text_field(parent: VBoxContainer, field_name: String, placeholder: String, secret: bool = false) -> LineEdit:
var label := Label.new()
label.text = field_name
Style.set_label(label, 15, Style.CYAN)
parent.add_child(label)
var input := LineEdit.new()
input.placeholder_text = placeholder
input.secret = secret
input.custom_minimum_size = Vector2(0, 42)
parent.add_child(input)
return input
func _set_auth_mode(next_mode: String) -> void:
auth_mode = next_mode
if login_fields == null:
return
login_fields.visible = auth_mode == "login"
register_fields.visible = auth_mode == "register"
auth_panel.custom_minimum_size.y = 535 if auth_mode == "login" else 650
if auth_mode == "login":
auth_prompt_label.text = "Log in to continue studying."
switch_mode_button.text = "Create new account"
username_input.grab_focus()
else:
auth_prompt_label.text = "Create your account"
switch_mode_button.text = "Already have an account? Log in"
registration_username_input.grab_focus()
_clear_messages()
func _toggle_auth_mode() -> void:
_set_auth_mode("register" if auth_mode == "login" else "login")
func _on_login_pressed() -> void:
_send_login(username_input.text.strip_edges(), password_input.text, false)
func _send_login(username: String, password: String, is_automatic: bool) -> void:
if username.is_empty() or password.is_empty():
_show_error("Username and password required.")
return
automatic_login_pending = is_automatic
login_attempt_username = username
_set_busy(true)
login_button.text = "Signing in..."
_clear_messages()
if is_automatic:
success_label.text = "Account created. Signing you in..."
var payload := {"username": username, "password": password}
var headers := PackedStringArray(["Content-Type: application/json"])
var request_error := login_request.request(auth_login_url, headers, HTTPClient.METHOD_POST, JSON.stringify(payload))
if request_error != OK:
_set_busy(false)
login_button.text = "Log In"
_show_error("Failed to send login request.")
func _on_login_request_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
_set_busy(false)
login_button.text = "Log In"
if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300:
if automatic_login_pending:
_set_auth_mode("login")
username_input.text = login_attempt_username
password_input.text = ""
_show_error("Account created, but automatic login failed. Please log in with your new account.")
else:
_show_error("Login failed. Check your credentials and try again.")
automatic_login_pending = false
return
var response: Variant = JSON.parse_string(body.get_string_from_utf8())
if typeof(response) != TYPE_DICTIONARY:
_show_error("Login response was not valid JSON.")
return
var token := String(response.get("accessToken", ""))
var response_username := String(response.get("username", login_attempt_username))
if token.is_empty():
_show_error("Login response did not include an access token.")
return
AuthState.set_session(
response_username,
token,
String(response.get("refreshToken", "")),
String(response.get("role", "")),
String(response.get("exp", ""))
)
UISoundService.play_sound("correct")
password_input.text = ""
registration_password_input.text = ""
registration_confirm_input.text = ""
automatic_login_pending = false
main.show_screen("selection", "exam")
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 confirmation := registration_confirm_input.text
var validation_error := _validate_registration(username, email, password, confirmation)
if validation_error != "":
_show_error(validation_error)
return
_set_busy(true)
register_button.text = "Creating account..."
_clear_messages()
var payload := {"username": username, "email": email, "password": password}
var headers := PackedStringArray(["Content-Type: application/json"])
var request_error := register_request.request(auth_register_url, headers, HTTPClient.METHOD_POST, JSON.stringify(payload))
if request_error != OK:
_set_busy(false)
register_button.text = "Create Account"
_show_error("Failed to send account creation request.")
func _on_register_request_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
_set_busy(false)
register_button.text = "Create Account"
if result != HTTPRequest.RESULT_SUCCESS:
_clear_registration_passwords()
_show_error("Network error. Please try again.")
return
if response_code < 200 or response_code >= 300:
_clear_registration_passwords()
var server_message := _response_message(body)
_show_error(server_message if server_message != "" else "Account creation failed.")
return
var username := registration_username_input.text.strip_edges()
var password := registration_password_input.text
username_input.text = username
_send_login(username, password, true)
func _validate_registration(username: String, email: String, password: String, confirmation: String) -> String:
var username_pattern := RegEx.new()
username_pattern.compile("^[A-Za-z0-9_]{3,24}$")
if username_pattern.search(username) == null:
registration_username_input.grab_focus()
return "Username must be 3-24 characters using only letters, numbers, or underscores."
var email_pattern := RegEx.new()
email_pattern.compile("^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$")
if email_pattern.search(email) == null:
registration_email_input.grab_focus()
return "Enter a valid email address."
if password.length() < 8:
registration_password_input.grab_focus()
return "Password must be at least 8 characters."
if password != confirmation:
registration_confirm_input.grab_focus()
return "Passwords do not match."
return ""
func _set_busy(is_busy: bool) -> void:
if login_button == null:
return
login_button.disabled = is_busy
register_button.disabled = is_busy
switch_mode_button.disabled = is_busy
username_input.editable = not is_busy
password_input.editable = not is_busy
registration_username_input.editable = not is_busy
registration_email_input.editable = not is_busy
registration_password_input.editable = not is_busy
registration_confirm_input.editable = not is_busy
func _clear_registration_passwords() -> void:
registration_password_input.text = ""
registration_confirm_input.text = ""
func _response_message(body: PackedByteArray) -> String:
var raw := body.get_string_from_utf8().strip_edges()
var parsed: Variant = JSON.parse_string(raw)
return String(parsed) if typeof(parsed) == TYPE_STRING else raw
func _on_login_input_submitted(_new_text: String) -> void:
_on_login_pressed()
func _on_register_input_submitted(_new_text: String) -> void:
_on_register_pressed()
func _clear_messages() -> void:
error_label.text = ""
success_label.text = ""
func _show_error(message: String) -> void:
UISoundService.play_sound("error")
success_label.text = ""
error_label.text = message