diff --git a/legal/legal_documents.json b/legal/legal_documents.json
new file mode 100644
index 0000000..04cd6fb
--- /dev/null
+++ b/legal/legal_documents.json
@@ -0,0 +1,203 @@
+{
+ "operator": "RanaZe",
+ "contactEmail": "support@ranaze.com",
+ "terms": {
+ "title": "Terms of Service",
+ "version": "2026-07-20",
+ "effectiveDate": "July 20, 2026",
+ "summary": "These terms govern your use of RanaZe: Step 3 Trainer.",
+ "sections": [
+ {
+ "heading": "1. Agreement to these Terms",
+ "paragraphs": [
+ "By creating an account, accepting these Terms, or using RanaZe: Step 3 Trainer, you agree to these Terms of Service. If you do not agree, do not create an account or use the service.",
+ "RanaZe may provide the service through a web application, a standalone application, Steam, or other supported platforms. These Terms apply to each version."
+ ]
+ },
+ {
+ "heading": "2. Educational use only",
+ "paragraphs": [
+ "RanaZe is an educational study tool. It does not provide medical advice, diagnosis, treatment, clinical supervision, professional certification, or a guarantee of examination performance.",
+ "Do not use RanaZe to make decisions about an actual patient or medical emergency. Always use qualified clinical judgment, current authoritative sources, and applicable institutional policies.",
+ "RanaZe is independent and is not affiliated with, endorsed by, or sponsored by the USMLE, NBME, FSMB, or any medical licensing authority."
+ ]
+ },
+ {
+ "heading": "3. Accounts and security",
+ "paragraphs": [
+ "You must provide accurate account information and keep your credentials secure. You are responsible for activity performed through your account until you notify RanaZe of unauthorized access.",
+ "Steam authentication may link a verified Steam identifier to your RanaZe account. Your use of Steam remains subject to Valve's terms and privacy practices.",
+ "The service is not directed to children under 13."
+ ]
+ },
+ {
+ "heading": "4. Acceptable use",
+ "paragraphs": [
+ "You may use RanaZe for personal educational purposes. You may not disrupt the service, bypass access controls, probe for vulnerabilities without written authorization, automate abusive requests, impersonate another person, or use the service unlawfully.",
+ "Do not submit real patient names, medical record numbers, contact information, or other identifiable patient information in notes, reports, or feedback."
+ ]
+ },
+ {
+ "heading": "5. Content and intellectual property",
+ "paragraphs": [
+ "RanaZe and its licensors retain ownership of the application, question bank, CCS cases, explanations, branding, software, and other service content. These Terms grant you a limited, revocable, nonexclusive, nontransferable right to use the service for personal study.",
+ "You may not reproduce, scrape, sell, publish, redistribute, or create a competing question bank from RanaZe content except as permitted by law or written permission."
+ ]
+ },
+ {
+ "heading": "6. Your notes and feedback",
+ "paragraphs": [
+ "You retain ownership of text you enter as private notes. You grant RanaZe permission to store and process that text solely to provide and maintain the service.",
+ "If you submit feedback, bug reports, or content reports, you permit RanaZe to use them without restriction or compensation to review content and improve the service."
+ ]
+ },
+ {
+ "heading": "7. Third-party services",
+ "paragraphs": [
+ "RanaZe may rely on third parties such as Steam, Stripe, Cloudflare, and infrastructure providers. Their services are governed by their own terms and privacy policies. RanaZe is not responsible for third-party services outside its control."
+ ]
+ },
+ {
+ "heading": "8. Voluntary contributions",
+ "paragraphs": [
+ "Contributions are voluntary, one-time payments that support development. They do not purchase medical services, examination results, ownership rights, or guaranteed service availability, and they are not represented as tax-deductible charitable donations.",
+ "Payments are processed by Stripe. Contributions are generally final and nonrefundable except where required by law or when RanaZe approves correction of a duplicate or erroneous charge. Contact support@ranaze.com for payment issues."
+ ]
+ },
+ {
+ "heading": "9. Availability, changes, and termination",
+ "paragraphs": [
+ "The service is currently pre-alpha and may contain errors, incomplete content, interruptions, or material changes. RanaZe may modify, suspend, or discontinue features at any time.",
+ "RanaZe may restrict or terminate access for violations of these Terms, security risks, abuse, or legal requirements. You may stop using the service and may request account deletion through the application."
+ ]
+ },
+ {
+ "heading": "10. Disclaimers",
+ "paragraphs": [
+ "To the maximum extent permitted by law, the service is provided as is and as available without warranties of accuracy, completeness, fitness for a particular purpose, noninfringement, availability, or examination success.",
+ "Medical knowledge and testing standards change. Explanations and references may become outdated, and you are responsible for verifying information against current authoritative sources."
+ ]
+ },
+ {
+ "heading": "11. Limitation of liability",
+ "paragraphs": [
+ "To the maximum extent permitted by law, RanaZe will not be liable for indirect, incidental, special, consequential, exemplary, or punitive damages, loss of data, lost opportunities, examination outcomes, or clinical decisions arising from use of the service.",
+ "Nothing in these Terms excludes liability that cannot legally be excluded or limited."
+ ]
+ },
+ {
+ "heading": "12. Disputes and applicable law",
+ "paragraphs": [
+ "Before filing a claim, contact support@ranaze.com and allow a reasonable opportunity to resolve the issue informally. Any unresolved dispute may be brought in a court of competent jurisdiction. These Terms do not require arbitration or waive participation in a class action.",
+ "Applicable law will be determined under ordinary conflict-of-law rules; these Terms do not designate a particular state or country as the exclusive venue."
+ ]
+ },
+ {
+ "heading": "13. Changes and contact",
+ "paragraphs": [
+ "RanaZe may update these Terms. When a material update requires renewed agreement, the application will show the new version before continued use.",
+ "Questions about these Terms may be sent to support@ranaze.com."
+ ]
+ }
+ ]
+ },
+ "privacy": {
+ "title": "Privacy Policy",
+ "version": "2026-07-20",
+ "effectiveDate": "July 20, 2026",
+ "summary": "This policy explains how RanaZe collects, uses, shares, retains, and deletes information.",
+ "sections": [
+ {
+ "heading": "1. Scope",
+ "paragraphs": [
+ "This Privacy Policy applies to RanaZe: Step 3 Trainer on the web, in standalone applications, through Steam, and through related RanaZe APIs and support channels.",
+ "RanaZe is an educational service and is not a health care provider or health plan. Information entered for studying is not intended to create a patient-provider relationship or a HIPAA-regulated medical record."
+ ]
+ },
+ {
+ "heading": "2. Information you provide",
+ "paragraphs": [
+ "Account information may include username, email address, display name, a securely hashed password, and a linked Steam identifier.",
+ "Study information may include question and CCS responses, timing, scores, ratings, simulations, bookmarks, private notes, study goals, target exam date, preparation preferences, and optional official Step 3 score and exam date submitted for calibration.",
+ "Communications may include question reports, CCS reports, feedback, bug reports, and support messages. Do not include identifiable patient information.",
+ "Contribution records may include amount, currency, status, timestamps, and Stripe transaction identifiers. Payment card details and billing information are entered directly with Stripe and are not stored by RanaZe."
+ ]
+ },
+ {
+ "heading": "3. Information collected automatically",
+ "paragraphs": [
+ "RanaZe services receive ordinary network and operational information such as IP address, request time, requested endpoint, error and security events, and basic browser or device information available in server logs.",
+ "The application stores functional session tokens and preferences, such as theme and temporary redirect state, on your device. RanaZe does not currently use advertising cookies or third-party analytics SDKs."
+ ]
+ },
+ {
+ "heading": "4. How information is used",
+ "paragraphs": [
+ "RanaZe uses information to authenticate accounts; provide questions, cases, simulations, notes, review queues, progress statistics, and score estimates; process contributions; respond to reports; secure and troubleshoot the service; and improve educational content.",
+ "Optional official examination outcomes are used only when you separately consent to de-identified score-prediction calibration. You may edit or remove that outcome through your profile."
+ ]
+ },
+ {
+ "heading": "5. How information is shared",
+ "paragraphs": [
+ "Information may be processed by service providers that support hosting, networking, security, authentication, payment processing, and application delivery. These currently include Cloudflare, Stripe, Valve/Steam, and infrastructure providers used to operate RanaZe.",
+ "RanaZe may disclose information when reasonably necessary to comply with law, protect users or the service, investigate abuse or fraud, or complete a business transfer. RanaZe does not sell personal information or share it for cross-context behavioral advertising."
+ ]
+ },
+ {
+ "heading": "6. Stripe and Steam",
+ "paragraphs": [
+ "Stripe independently collects and processes payment and billing information under its privacy policy. RanaZe receives payment status and transaction identifiers needed for reconciliation, support, fraud prevention, and accounting.",
+ "When you use Steam sign-in, Valve verifies your identity and returns a Steam identifier that RanaZe links to your account. RanaZe does not receive your Steam password."
+ ]
+ },
+ {
+ "heading": "7. Storage, cookies, and tracking choices",
+ "paragraphs": [
+ "RanaZe uses local device storage for authentication, interface preferences, and short-lived redirect coordination. These mechanisms are necessary to provide requested features.",
+ "Because RanaZe does not currently perform cross-site behavioral tracking, browser Do Not Track signals do not change service behavior. If analytics or advertising practices change, this policy and any required consent controls will be updated first."
+ ]
+ },
+ {
+ "heading": "8. Retention and deletion",
+ "paragraphs": [
+ "Account and study information is generally retained while your account is active or as needed to provide the service. You may export study data and request account deletion from your profile.",
+ "Account deletion removes the authentication account, legal-acceptance history, and user-linked study records. RanaZe may retain limited contribution records, security logs, and deletion-integrity records when reasonably necessary for accounting, fraud prevention, legal obligations, dispute resolution, or preventing deleted credentials from remaining active.",
+ "Aggregated or de-identified statistics that no longer identify you may remain after account deletion. Backups and operational logs may expire on their normal rotation rather than being removed immediately."
+ ]
+ },
+ {
+ "heading": "9. Your choices and rights",
+ "paragraphs": [
+ "You can review or update profile information, export user-linked study data, delete optional examination outcomes, and request permanent account deletion through the application.",
+ "You may contact support@ranaze.com to ask about access, correction, deletion, or other privacy rights. RanaZe may need to verify your identity before completing a request. Rights vary by location."
+ ]
+ },
+ {
+ "heading": "10. Security",
+ "paragraphs": [
+ "RanaZe uses measures intended to protect information, including encrypted network transport, password hashing, access controls, short-lived access tokens, and restricted service credentials. No storage or transmission system can be guaranteed completely secure."
+ ]
+ },
+ {
+ "heading": "11. Children",
+ "paragraphs": [
+ "RanaZe is not directed to children under 13 and does not knowingly collect personal information from them. If you believe a child under 13 submitted information, contact support@ranaze.com so RanaZe can investigate and remove it as appropriate."
+ ]
+ },
+ {
+ "heading": "12. International access",
+ "paragraphs": [
+ "If you access RanaZe from outside the location where its systems are operated, information may be transferred to and processed in another country whose data-protection laws differ from yours."
+ ]
+ },
+ {
+ "heading": "13. Policy changes and contact",
+ "paragraphs": [
+ "RanaZe may update this policy as the service changes. The application will present material version changes for acknowledgment before continued use when appropriate.",
+ "Privacy questions and requests may be sent to support@ranaze.com."
+ ]
+ }
+ ]
+ }
+}
diff --git a/project.godot b/project.godot
index 2c25cd2..86c15c5 100644
--- a/project.godot
+++ b/project.godot
@@ -21,6 +21,7 @@ config/custom_user_dir_name="MedMind"
[autoload]
AuthState="*res://scripts/autoload/auth_state.gd"
+LegalService="*res://scripts/autoload/legal_service.gd"
UIThemeService="*res://scripts/autoload/ui_theme_service.gd"
UISoundService="*res://scripts/autoload/ui_sound_service.gd"
QuestionBankService="*res://scripts/autoload/question_bank_service.gd"
diff --git a/scripts/autoload/auth_state.gd b/scripts/autoload/auth_state.gd
index efced24..05745a3 100644
--- a/scripts/autoload/auth_state.gd
+++ b/scripts/autoload/auth_state.gd
@@ -22,6 +22,11 @@ var expires_at := ""
var expiration_unix := 0.0
var refresh_in_progress := false
var warning_active := false
+var requires_legal_acceptance := false
+var terms_version := ""
+var privacy_version := ""
+var terms_url := ""
+var privacy_url := ""
var auth_base_url := DEFAULT_AUTH_BASE_URL
var expiry_timer: Timer
var refresh_request: HTTPRequest
@@ -42,7 +47,7 @@ func _ready() -> void:
refresh_request.request_completed.connect(_on_refresh_completed)
add_child(refresh_request)
-func set_session(new_username: String, token: String, new_refresh_token: String = "", new_role: String = "", token_expiration: String = "") -> void:
+func set_session(new_username: String, token: String, new_refresh_token: String = "", new_role: String = "", token_expiration: String = "", legal: Dictionary = {}) -> void:
session_revision += 1
is_logged_in = not token.is_empty()
username = new_username
@@ -54,6 +59,7 @@ func set_session(new_username: String, token: String, new_refresh_token: String
refresh_in_progress = false
refresh_revision = -1
warning_active = false
+ set_legal_state(legal, false)
if is_logged_in and expiration_unix > 0.0:
expiry_timer.start()
else:
@@ -63,6 +69,16 @@ func set_session(new_username: String, token: String, new_refresh_token: String
_evaluate_expiry.call_deferred()
+func set_legal_state(legal: Dictionary, notify := true) -> void:
+ requires_legal_acceptance = bool(legal.get("requiresLegalAcceptance", false))
+ terms_version = str(legal.get("termsVersion", terms_version))
+ privacy_version = str(legal.get("privacyVersion", privacy_version))
+ terms_url = str(legal.get("termsUrl", terms_url))
+ privacy_url = str(legal.get("privacyUrl", privacy_url))
+ if notify:
+ auth_changed.emit()
+
+
func clear_session() -> void:
_clear_session_fields()
auth_changed.emit()
@@ -175,6 +191,9 @@ func _on_refresh_completed(result: int, response_code: int, _headers: PackedStri
expires_at = new_expiration
expiration_unix = new_expiration_unix
warning_active = false
+ var legal_value: Variant = parsed.get("legal", {})
+ if typeof(legal_value) == TYPE_DICTIONARY:
+ set_legal_state(legal_value, false)
session_revision += 1
expiry_timer.start()
auth_changed.emit()
@@ -197,6 +216,11 @@ func _clear_session_fields() -> void:
refresh_in_progress = false
refresh_revision = -1
warning_active = false
+ requires_legal_acceptance = false
+ terms_version = ""
+ privacy_version = ""
+ terms_url = ""
+ privacy_url = ""
func _parse_expiration(value: String, token: String = "") -> float:
diff --git a/scripts/autoload/donation_service.gd b/scripts/autoload/donation_service.gd
index bcc7c39..3dde971 100644
--- a/scripts/autoload/donation_service.gd
+++ b/scripts/autoload/donation_service.gd
@@ -5,7 +5,7 @@ const DEFAULT_URL := "https://s3q.ranaze.com/api/donations"
const REQUEST_TIMEOUT_SECONDS := 30.0
const POLL_INTERVAL_SECONDS := 3.0
const POLL_LIMIT := 200
-const CHECKOUT_STORAGE_KEY := "medmindDonationCheckoutUrl"
+const CHECKOUT_STORAGE_KEY := "ranaze_donation_checkout_url"
signal checkout_started(donation_id: String, checkout_url: String)
signal donation_status_changed(status: String, donation: Dictionary)
@@ -140,14 +140,14 @@ func _on_status_completed(result: int, response_code: int, _headers: PackedStrin
func _prepare_browser_window() -> void:
if OS.has_feature("web"):
JavaScriptBridge.eval(
- "window.localStorage.removeItem('%s'); window.medmindDonationWindow = window.open('about:blank', 'medmind_donation_checkout'); if (window.medmindDonationWindow) { var medmindWaitingWindow = window.medmindDonationWindow; medmindWaitingWindow.document.title = 'RanaZe secure checkout'; medmindWaitingWindow.document.body.innerHTML = 'Opening secure checkout...
This window will continue to Stripe.
'; medmindWaitingWindow.setInterval(function () { var checkoutUrl = medmindWaitingWindow.localStorage.getItem('%s'); if (checkoutUrl) { var checkout = medmindWaitingWindow.document.getElementById('medmind-checkout'); checkout.innerHTML = 'Secure checkout is ready.
'; medmindWaitingWindow.document.getElementById('medmind-checkout-link').href = checkoutUrl; medmindWaitingWindow.localStorage.removeItem('%s'); medmindWaitingWindow.location.replace(checkoutUrl); } }, 150); }" % [CHECKOUT_STORAGE_KEY, CHECKOUT_STORAGE_KEY, CHECKOUT_STORAGE_KEY],
+ "window.localStorage.removeItem('%s'); var ranazeDonationBridge = new URL('/external-redirect.html?key=%s&provider=Stripe', window.location.origin).href; window.medmindDonationWindow = window.open(ranazeDonationBridge, 'medmind_donation_checkout');" % [CHECKOUT_STORAGE_KEY, CHECKOUT_STORAGE_KEY],
true)
func _open_checkout_url(url: String) -> void:
if OS.has_feature("web"):
JavaScriptBridge.eval(
- "var medmindCheckoutUrl = %s; window.localStorage.setItem('%s', medmindCheckoutUrl); if (window.medmindDonationWindow && !window.medmindDonationWindow.closed) { window.medmindDonationWindow.focus(); } else { var medmindCheckoutWindow = window.open(medmindCheckoutUrl, 'medmind_donation_checkout'); if (medmindCheckoutWindow) { window.medmindDonationWindow = medmindCheckoutWindow; medmindCheckoutWindow.focus(); } else { window.location.assign(medmindCheckoutUrl); } }" % [JSON.stringify(url), CHECKOUT_STORAGE_KEY],
+ "var ranazeDonationUrl = %s; window.localStorage.setItem('%s', ranazeDonationUrl); if (window.medmindDonationWindow && !window.medmindDonationWindow.closed) { try { window.medmindDonationWindow.location.replace(ranazeDonationUrl); } catch (_) {} window.medmindDonationWindow.focus(); } else { var ranazeDonationPopup = window.open(ranazeDonationUrl, 'medmind_donation_checkout'); if (ranazeDonationPopup) { window.medmindDonationWindow = ranazeDonationPopup; ranazeDonationPopup.focus(); } else { window.location.assign(ranazeDonationUrl); } }" % [JSON.stringify(url), CHECKOUT_STORAGE_KEY],
true)
else:
OS.shell_open(url)
diff --git a/scripts/autoload/legal_service.gd b/scripts/autoload/legal_service.gd
new file mode 100644
index 0000000..5bafa36
--- /dev/null
+++ b/scripts/autoload/legal_service.gd
@@ -0,0 +1,75 @@
+extends Node
+
+signal acceptance_succeeded
+signal acceptance_failed(message: String)
+
+const EndpointConfig := preload("res://scripts/autoload/api_endpoint_config.gd")
+const DEFAULT_AUTH_BASE_URL := "https://pauth.ranaze.com/api/Auth"
+const DOCUMENT_PATH := "res://legal/legal_documents.json"
+
+var documents: Dictionary = {}
+var auth_base_url := DEFAULT_AUTH_BASE_URL
+var request: HTTPRequest
+
+
+func _ready() -> void:
+ auth_base_url = EndpointConfig.resolve(DEFAULT_AUTH_BASE_URL, "/api/Auth")
+ _load_documents()
+ request = HTTPRequest.new()
+ request.timeout = 20.0
+ request.request_completed.connect(_on_accept_completed)
+ add_child(request)
+
+
+func get_document(kind: String) -> Dictionary:
+ var value: Variant = documents.get(kind, {})
+ return value if typeof(value) == TYPE_DICTIONARY else {}
+
+
+func public_url(kind: String) -> String:
+ if kind == "terms" and not AuthState.terms_url.is_empty():
+ return AuthState.terms_url
+ if kind == "privacy" and not AuthState.privacy_url.is_empty():
+ return AuthState.privacy_url
+ return "https://doctor.ranaze.com/%s.html" % kind
+
+
+func accept_current() -> void:
+ if request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED:
+ return
+ if not AuthState.can_send_authenticated_request():
+ acceptance_failed.emit("Your session expired. Sign in again.")
+ return
+ var headers := PackedStringArray([
+ "Content-Type: application/json",
+ "Authorization: Bearer %s" % AuthState.access_token
+ ])
+ var error := request.request(
+ "%s/legal/accept" % auth_base_url.rstrip("/"),
+ headers,
+ HTTPClient.METHOD_POST,
+ JSON.stringify({"accept": true}))
+ if error != OK:
+ acceptance_failed.emit("Unable to contact the authentication service. Try again.")
+
+
+func _load_documents() -> void:
+ var file := FileAccess.open(DOCUMENT_PATH, FileAccess.READ)
+ if file == null:
+ push_error("Unable to load legal documents")
+ return
+ var parsed: Variant = JSON.parse_string(file.get_as_text())
+ if typeof(parsed) == TYPE_DICTIONARY:
+ documents = parsed
+
+
+func _on_accept_completed(result: int, code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
+ if result != HTTPRequest.RESULT_SUCCESS or code < 200 or code >= 300:
+ acceptance_failed.emit("Unable to save your agreement. Check your connection and try again.")
+ return
+ var parsed: Variant = JSON.parse_string(body.get_string_from_utf8())
+ if typeof(parsed) != TYPE_DICTIONARY:
+ acceptance_failed.emit("The authentication service returned an invalid response.")
+ return
+ AuthState.set_legal_state(parsed)
+ acceptance_succeeded.emit()
diff --git a/scripts/autoload/legal_service.gd.uid b/scripts/autoload/legal_service.gd.uid
new file mode 100644
index 0000000..1ab9531
--- /dev/null
+++ b/scripts/autoload/legal_service.gd.uid
@@ -0,0 +1 @@
+uid://dp43xyka8queb
diff --git a/scripts/ui/donation_screen.gd b/scripts/ui/donation_screen.gd
index 50f30af..0b8a6c2 100644
--- a/scripts/ui/donation_screen.gd
+++ b/scripts/ui/donation_screen.gd
@@ -159,6 +159,15 @@ func _build_ui() -> void:
var disclosure := _body("Contributions are voluntary, one-time, and not tax-deductible. Card details are entered only on Stripe's secure checkout page.")
disclosure.add_theme_font_size_override("font_size", 14)
contribution.add_child(disclosure)
+ var legal_links := HBoxContainer.new()
+ legal_links.add_theme_constant_override("separation", 12)
+ contribution.add_child(legal_links)
+ 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])))
+ legal_links.add_child(link)
func on_show(_payload: Variant = null) -> void:
@@ -173,6 +182,10 @@ func on_hide() -> void:
donation_service.stop_polling()
+func _open_legal_document(kind: String) -> void:
+ main.open_legal_document(kind)
+
+
static func parse_amount_cents(value: String) -> int:
return DonationAmount.parse_cents(value)
diff --git a/scripts/ui/legal_consent_screen.gd b/scripts/ui/legal_consent_screen.gd
new file mode 100644
index 0000000..0d5216f
--- /dev/null
+++ b/scripts/ui/legal_consent_screen.gd
@@ -0,0 +1,83 @@
+extends VBoxContainer
+
+const Brand := preload("res://scripts/ui/medmind_style.gd")
+const Builder := preload("res://scripts/ui/legal_document_builder.gd")
+
+var main: Control
+var document_content: VBoxContainer
+var consent: CheckBox
+var accept_button: Button
+var status: Label
+
+
+func _ready() -> void:
+ set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
+ add_theme_constant_override("separation", 14)
+ var tabs := HBoxContainer.new()
+ add_child(tabs)
+ for kind in ["terms", "privacy"]:
+ var button := Button.new()
+ button.text = "Terms of Service" if kind == "terms" else "Privacy Policy"
+ button.pressed.connect(_show_document.bind(kind))
+ tabs.add_child(button)
+ var scroll := ScrollContainer.new()
+ scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
+ scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
+ add_child(scroll)
+ document_content = VBoxContainer.new()
+ document_content.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ document_content.add_theme_constant_override("separation", 14)
+ scroll.add_child(document_content)
+ consent = CheckBox.new()
+ consent.text = "I agree to the Terms of Service and acknowledge the Privacy Policy."
+ consent.toggled.connect(func(enabled: bool): accept_button.disabled = not enabled)
+ add_child(consent)
+ status = Label.new()
+ status.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
+ add_child(status)
+ var actions := HBoxContainer.new()
+ actions.alignment = BoxContainer.ALIGNMENT_END
+ add_child(actions)
+ var logout := Button.new()
+ logout.text = "Log out"
+ logout.pressed.connect(_logout)
+ actions.add_child(logout)
+ accept_button = Button.new()
+ accept_button.text = "Agree and continue"
+ accept_button.disabled = true
+ accept_button.pressed.connect(_accept)
+ actions.add_child(accept_button)
+ LegalService.acceptance_succeeded.connect(_on_accepted)
+ LegalService.acceptance_failed.connect(_on_failed)
+ _show_document("terms")
+
+
+func on_show(_payload: Variant = null) -> void:
+ consent.set_pressed_no_signal(false)
+ accept_button.disabled = true
+ status.text = ""
+
+
+func _show_document(kind: String) -> void:
+ Builder.populate(document_content, LegalService.get_document(kind))
+
+
+func _accept() -> void:
+ accept_button.disabled = true
+ status.text = "Saving your agreement..."
+ LegalService.accept_current()
+
+
+func _on_accepted() -> void:
+ status.text = ""
+ main.show_screen("dashboard")
+
+
+func _on_failed(message: String) -> void:
+ status.text = message
+ accept_button.disabled = not consent.button_pressed
+
+
+func _logout() -> void:
+ AuthState.clear_session()
+ main.show_screen("login")
diff --git a/scripts/ui/legal_consent_screen.gd.uid b/scripts/ui/legal_consent_screen.gd.uid
new file mode 100644
index 0000000..65bb33c
--- /dev/null
+++ b/scripts/ui/legal_consent_screen.gd.uid
@@ -0,0 +1 @@
+uid://u0ydfjugfyua
diff --git a/scripts/ui/legal_document_builder.gd b/scripts/ui/legal_document_builder.gd
new file mode 100644
index 0000000..d561bc1
--- /dev/null
+++ b/scripts/ui/legal_document_builder.gd
@@ -0,0 +1,44 @@
+class_name LegalDocumentBuilder
+extends RefCounted
+
+const Brand := preload("res://scripts/ui/medmind_style.gd")
+
+
+static func populate(container: VBoxContainer, document: Dictionary) -> void:
+ for child in container.get_children():
+ child.queue_free()
+ var title := Label.new()
+ title.text = str(document.get("title", "Legal document"))
+ title.add_theme_font_override("font", Brand.font)
+ title.add_theme_font_size_override("font_size", 30)
+ container.add_child(title)
+ var metadata := Label.new()
+ metadata.text = "Effective %s | Version %s" % [document.get("effectiveDate", ""), document.get("version", "")]
+ metadata.add_theme_font_override("font", Brand.font)
+ metadata.add_theme_font_size_override("font_size", 14)
+ metadata.add_theme_color_override("font_color", Brand.muted_color(UIThemeService.is_dark_mode()))
+ container.add_child(metadata)
+ var summary := _paragraph(str(document.get("summary", "")))
+ container.add_child(summary)
+ for section_value in document.get("sections", []):
+ if typeof(section_value) != TYPE_DICTIONARY:
+ continue
+ var section: Dictionary = section_value
+ var heading := Label.new()
+ heading.text = str(section.get("heading", ""))
+ heading.add_theme_font_override("font", Brand.font)
+ heading.add_theme_font_size_override("font_size", 20)
+ container.add_child(heading)
+ for paragraph_value in section.get("paragraphs", []):
+ container.add_child(_paragraph(str(paragraph_value)))
+
+
+static func _paragraph(value: String) -> Label:
+ var label := Label.new()
+ label.text = value
+ label.add_theme_font_override("font", Brand.font)
+ label.add_theme_font_size_override("font_size", 16)
+ label.add_theme_color_override("font_color", Brand.muted_color(UIThemeService.is_dark_mode()))
+ label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
+ label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ return label
diff --git a/scripts/ui/legal_document_builder.gd.uid b/scripts/ui/legal_document_builder.gd.uid
new file mode 100644
index 0000000..a0dbc71
--- /dev/null
+++ b/scripts/ui/legal_document_builder.gd.uid
@@ -0,0 +1 @@
+uid://b0dukpxdyw2x4
diff --git a/scripts/ui/legal_document_dialog.gd b/scripts/ui/legal_document_dialog.gd
new file mode 100644
index 0000000..f901e56
--- /dev/null
+++ b/scripts/ui/legal_document_dialog.gd
@@ -0,0 +1,53 @@
+extends Control
+
+const Brand := preload("res://scripts/ui/medmind_style.gd")
+const Builder := preload("res://scripts/ui/legal_document_builder.gd")
+
+var content: VBoxContainer
+var current_kind := "terms"
+
+
+func _ready() -> void:
+ visible = false
+ top_level = true
+ set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
+ mouse_filter = Control.MOUSE_FILTER_STOP
+ var shade := ColorRect.new()
+ shade.color = Color(0, 0, 0, 0.72)
+ shade.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
+ add_child(shade)
+ var center := CenterContainer.new()
+ center.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
+ add_child(center)
+ var panel := PanelContainer.new()
+ panel.custom_minimum_size = Vector2(720, 620)
+ panel.add_theme_stylebox_override("panel", Brand.panel_box(UIThemeService.is_dark_mode()))
+ center.add_child(panel)
+ var layout := VBoxContainer.new()
+ layout.add_theme_constant_override("separation", 14)
+ panel.add_child(layout)
+ var scroll := ScrollContainer.new()
+ scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
+ scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
+ layout.add_child(scroll)
+ content = VBoxContainer.new()
+ content.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+ content.add_theme_constant_override("separation", 14)
+ scroll.add_child(content)
+ var actions := HBoxContainer.new()
+ actions.alignment = BoxContainer.ALIGNMENT_END
+ layout.add_child(actions)
+ var browser := Button.new()
+ browser.text = "Open in browser"
+ browser.pressed.connect(func(): OS.shell_open(LegalService.public_url(current_kind)))
+ actions.add_child(browser)
+ var close := Button.new()
+ close.text = "Close"
+ close.pressed.connect(hide)
+ actions.add_child(close)
+
+
+func show_document(kind: String) -> void:
+ current_kind = kind
+ Builder.populate(content, LegalService.get_document(kind))
+ visible = true
diff --git a/scripts/ui/legal_document_dialog.gd.uid b/scripts/ui/legal_document_dialog.gd.uid
new file mode 100644
index 0000000..2b9359d
--- /dev/null
+++ b/scripts/ui/legal_document_dialog.gd.uid
@@ -0,0 +1 @@
+uid://bkjd11e1kjp21
diff --git a/scripts/ui/login_screen.gd b/scripts/ui/login_screen.gd
index d997fea..d3a4c97 100644
--- a/scripts/ui/login_screen.gd
+++ b/scripts/ui/login_screen.gd
@@ -27,6 +27,7 @@ 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
@@ -36,6 +37,8 @@ var steam_action: OptionButton
var steam_username_input: LineEdit
var steam_email_input: LineEdit
var steam_password_input: LineEdit
+var steam_legal_check: CheckBox
+var panel_gutter: MarginContainer
var submit_buttons: Array[Button] = []
var fields: Array[LineEdit] = []
var requests: Dictionary = {}
@@ -182,10 +185,14 @@ func _build_ui() -> void:
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.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.add_theme_constant_override("separation", 24)
- scroll.add_child(panel_content)
+ panel_gutter.add_child(panel_content)
var panel_header := HBoxContainer.new()
panel_header.add_theme_constant_override("separation", 18)
@@ -221,6 +228,11 @@ func _build_ui() -> void:
message_label.add_theme_font_size_override("font_size", 14)
message_label.add_theme_font_override("font", Brand.font)
panel_content.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:
@@ -259,6 +271,7 @@ func _build_register_view() -> VBoxContainer:
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_show_password(view, [registration_password_input, 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())
@@ -297,11 +310,37 @@ func _build_steam_view() -> VBoxContainer:
steam_email_input = _add_field(view, "Email", "name@example.com")
steam_password_input = _add_field(view, "Password", "At least 8 characters", true)
_add_show_password(view, [steam_password_input])
+ steam_legal_check = _add_legal_consent(view)
_add_primary_button(view, "Continue", _complete_steam)
_add_back_button(view, "Cancel Steam sign in", "login")
return view
+func _add_legal_consent(parent: VBoxContainer) -> CheckBox:
+ var check := CheckBox.new()
+ check.text = "I agree to the Terms of Service and acknowledge the Privacy Policy."
+ check.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
+ parent.add_child(check)
+ var links := HBoxContainer.new()
+ links.add_theme_constant_override("separation", 12)
+ parent.add_child(links)
+ _add_legal_links(links)
+ return check
+
+
+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", 16)
@@ -446,12 +485,14 @@ func _update_responsive_layout() -> void:
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)
auth_panel.custom_minimum_size = Vector2(640, 790) if compact_desktop else Vector2(710, 820)
+ panel_gutter.add_theme_constant_override("margin_right", 28)
_apply_compact_scale(panel_content, 1.0)
_apply_theme()
layout_updating = false
@@ -559,8 +600,10 @@ func _on_register_pressed() -> void:
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}, "Creating account...")
+ _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:
@@ -608,7 +651,7 @@ func _on_reset_confirmed(result: int, code: int, _headers: PackedStringArray, bo
func _start_steam() -> void:
if OS.has_feature("web"):
var popup_result: Variant = JavaScriptBridge.eval(
- "window.localStorage.removeItem('%s'); window.ranazeSteamWindow = window.open('about:blank', 'ranaze_steam'); if (window.ranazeSteamWindow) { var ranazeSteamWaitingWindow = window.ranazeSteamWindow; ranazeSteamWaitingWindow.document.title = 'RanaZe Steam sign in'; ranazeSteamWaitingWindow.document.body.innerHTML = 'Opening Steam sign in...
This window will continue to Steam.
'; ranazeSteamWaitingWindow.setInterval(function () { var steamAuthUrl = ranazeSteamWaitingWindow.localStorage.getItem('%s'); if (steamAuthUrl) { var steamLogin = ranazeSteamWaitingWindow.document.getElementById('ranaze-steam-login'); steamLogin.innerHTML = 'Steam sign in is ready.