Add legal consent and reliable external redirects

This commit is contained in:
hz
2026-07-20 02:16:20 -05:00
parent 2615ab4773
commit dae3a31b71
25 changed files with 756 additions and 14 deletions
+203
View File
@@ -0,0 +1,203 @@
{
"operator": "RanaZe",
"contactEmail": "[email protected]",
"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 [email protected] 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 [email protected] 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 [email protected]."
]
}
]
},
"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 [email protected] 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 [email protected] 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 [email protected]."
]
}
]
}
}
+1
View File
@@ -21,6 +21,7 @@ config/custom_user_dir_name="MedMind"
[autoload] [autoload]
AuthState="*res://scripts/autoload/auth_state.gd" AuthState="*res://scripts/autoload/auth_state.gd"
LegalService="*res://scripts/autoload/legal_service.gd"
UIThemeService="*res://scripts/autoload/ui_theme_service.gd" UIThemeService="*res://scripts/autoload/ui_theme_service.gd"
UISoundService="*res://scripts/autoload/ui_sound_service.gd" UISoundService="*res://scripts/autoload/ui_sound_service.gd"
QuestionBankService="*res://scripts/autoload/question_bank_service.gd" QuestionBankService="*res://scripts/autoload/question_bank_service.gd"
+25 -1
View File
@@ -22,6 +22,11 @@ var expires_at := ""
var expiration_unix := 0.0 var expiration_unix := 0.0
var refresh_in_progress := false var refresh_in_progress := false
var warning_active := 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 auth_base_url := DEFAULT_AUTH_BASE_URL
var expiry_timer: Timer var expiry_timer: Timer
var refresh_request: HTTPRequest var refresh_request: HTTPRequest
@@ -42,7 +47,7 @@ func _ready() -> void:
refresh_request.request_completed.connect(_on_refresh_completed) refresh_request.request_completed.connect(_on_refresh_completed)
add_child(refresh_request) 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 session_revision += 1
is_logged_in = not token.is_empty() is_logged_in = not token.is_empty()
username = new_username username = new_username
@@ -54,6 +59,7 @@ func set_session(new_username: String, token: String, new_refresh_token: String
refresh_in_progress = false refresh_in_progress = false
refresh_revision = -1 refresh_revision = -1
warning_active = false warning_active = false
set_legal_state(legal, false)
if is_logged_in and expiration_unix > 0.0: if is_logged_in and expiration_unix > 0.0:
expiry_timer.start() expiry_timer.start()
else: else:
@@ -63,6 +69,16 @@ func set_session(new_username: String, token: String, new_refresh_token: String
_evaluate_expiry.call_deferred() _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: func clear_session() -> void:
_clear_session_fields() _clear_session_fields()
auth_changed.emit() auth_changed.emit()
@@ -175,6 +191,9 @@ func _on_refresh_completed(result: int, response_code: int, _headers: PackedStri
expires_at = new_expiration expires_at = new_expiration
expiration_unix = new_expiration_unix expiration_unix = new_expiration_unix
warning_active = false 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 session_revision += 1
expiry_timer.start() expiry_timer.start()
auth_changed.emit() auth_changed.emit()
@@ -197,6 +216,11 @@ func _clear_session_fields() -> void:
refresh_in_progress = false refresh_in_progress = false
refresh_revision = -1 refresh_revision = -1
warning_active = false warning_active = false
requires_legal_acceptance = false
terms_version = ""
privacy_version = ""
terms_url = ""
privacy_url = ""
func _parse_expiration(value: String, token: String = "") -> float: func _parse_expiration(value: String, token: String = "") -> float:
+3 -3
View File
@@ -5,7 +5,7 @@ const DEFAULT_URL := "https://s3q.ranaze.com/api/donations"
const REQUEST_TIMEOUT_SECONDS := 30.0 const REQUEST_TIMEOUT_SECONDS := 30.0
const POLL_INTERVAL_SECONDS := 3.0 const POLL_INTERVAL_SECONDS := 3.0
const POLL_LIMIT := 200 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 checkout_started(donation_id: String, checkout_url: String)
signal donation_status_changed(status: String, donation: Dictionary) 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: func _prepare_browser_window() -> void:
if OS.has_feature("web"): if OS.has_feature("web"):
JavaScriptBridge.eval( 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 = '<main id=\"medmind-checkout\" style=\"font:16px system-ui;padding:48px;color:#172033\"><strong>Opening secure checkout...</strong><p>This window will continue to Stripe.</p></main>'; medmindWaitingWindow.setInterval(function () { var checkoutUrl = medmindWaitingWindow.localStorage.getItem('%s'); if (checkoutUrl) { var checkout = medmindWaitingWindow.document.getElementById('medmind-checkout'); checkout.innerHTML = '<strong>Secure checkout is ready.</strong><p><a id=\"medmind-checkout-link\" style=\"color:#2563eb;font-weight:600\">Continue to Stripe</a></p>'; 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) true)
func _open_checkout_url(url: String) -> void: func _open_checkout_url(url: String) -> void:
if OS.has_feature("web"): if OS.has_feature("web"):
JavaScriptBridge.eval( 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) true)
else: else:
OS.shell_open(url) OS.shell_open(url)
+75
View File
@@ -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()
+1
View File
@@ -0,0 +1 @@
uid://dp43xyka8queb
+13
View File
@@ -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.") 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) disclosure.add_theme_font_size_override("font_size", 14)
contribution.add_child(disclosure) 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: func on_show(_payload: Variant = null) -> void:
@@ -173,6 +182,10 @@ func on_hide() -> void:
donation_service.stop_polling() donation_service.stop_polling()
func _open_legal_document(kind: String) -> void:
main.open_legal_document(kind)
static func parse_amount_cents(value: String) -> int: static func parse_amount_cents(value: String) -> int:
return DonationAmount.parse_cents(value) return DonationAmount.parse_cents(value)
+83
View File
@@ -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")
+1
View File
@@ -0,0 +1 @@
uid://u0ydfjugfyua
+44
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://b0dukpxdyw2x4
+53
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://bkjd11e1kjp21
+55 -7
View File
@@ -27,6 +27,7 @@ var registration_username_input: LineEdit
var registration_email_input: LineEdit var registration_email_input: LineEdit
var registration_password_input: LineEdit var registration_password_input: LineEdit
var registration_confirm_input: LineEdit var registration_confirm_input: LineEdit
var registration_legal_check: CheckBox
var reset_login_input: LineEdit var reset_login_input: LineEdit
var reset_code_input: LineEdit var reset_code_input: LineEdit
var reset_password_input: LineEdit var reset_password_input: LineEdit
@@ -36,6 +37,8 @@ var steam_action: OptionButton
var steam_username_input: LineEdit var steam_username_input: LineEdit
var steam_email_input: LineEdit var steam_email_input: LineEdit
var steam_password_input: LineEdit var steam_password_input: LineEdit
var steam_legal_check: CheckBox
var panel_gutter: MarginContainer
var submit_buttons: Array[Button] = [] var submit_buttons: Array[Button] = []
var fields: Array[LineEdit] = [] var fields: Array[LineEdit] = []
var requests: Dictionary = {} var requests: Dictionary = {}
@@ -182,10 +185,14 @@ func _build_ui() -> void:
var scroll := ScrollContainer.new() var scroll := ScrollContainer.new()
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
auth_panel.add_child(scroll) 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 = VBoxContainer.new()
panel_content.size_flags_horizontal = Control.SIZE_EXPAND_FILL panel_content.size_flags_horizontal = Control.SIZE_EXPAND_FILL
panel_content.add_theme_constant_override("separation", 24) panel_content.add_theme_constant_override("separation", 24)
scroll.add_child(panel_content) panel_gutter.add_child(panel_content)
var panel_header := HBoxContainer.new() var panel_header := HBoxContainer.new()
panel_header.add_theme_constant_override("separation", 18) 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_size_override("font_size", 14)
message_label.add_theme_font_override("font", Brand.font) message_label.add_theme_font_override("font", Brand.font)
panel_content.add_child(message_label) 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: 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_password_input = _add_field(view, "Password", "At least 8 characters", true)
registration_confirm_input = _add_field(view, "Confirm password", "Re-enter your password", true) registration_confirm_input = _add_field(view, "Confirm password", "Re-enter your password", true)
_add_show_password(view, [registration_password_input, registration_confirm_input]) _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_primary_button(view, "Create Account", _on_register_pressed)
_add_back_button(view, "Already have an account? Sign in", "login") _add_back_button(view, "Already have an account? Sign in", "login")
registration_confirm_input.text_submitted.connect(func(_text: String): _on_register_pressed()) 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", "[email protected]") steam_email_input = _add_field(view, "Email", "[email protected]")
steam_password_input = _add_field(view, "Password", "At least 8 characters", true) steam_password_input = _add_field(view, "Password", "At least 8 characters", true)
_add_show_password(view, [steam_password_input]) _add_show_password(view, [steam_password_input])
steam_legal_check = _add_legal_consent(view)
_add_primary_button(view, "Continue", _complete_steam) _add_primary_button(view, "Continue", _complete_steam)
_add_back_button(view, "Cancel Steam sign in", "login") _add_back_button(view, "Cancel Steam sign in", "login")
return view 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: func _new_form() -> VBoxContainer:
var form := VBoxContainer.new() var form := VBoxContainer.new()
form.add_theme_constant_override("separation", 16) form.add_theme_constant_override("separation", 16)
@@ -446,12 +485,14 @@ func _update_responsive_layout() -> void:
theme_button.position = Vector2(-190, 20) theme_button.position = Vector2(-190, 20)
theme_button.add_theme_font_size_override("font_size", 64) theme_button.add_theme_font_size_override("font_size", 64)
auth_panel.custom_minimum_size = Vector2(1700, 2300) auth_panel.custom_minimum_size = Vector2(1700, 2300)
panel_gutter.add_theme_constant_override("margin_right", 96)
_apply_compact_scale(panel_content, 3.8) _apply_compact_scale(panel_content, 3.8)
else: else:
theme_button.custom_minimum_size = Vector2(44, 44) theme_button.custom_minimum_size = Vector2(44, 44)
theme_button.position = Vector2(-58, 14) theme_button.position = Vector2(-58, 14)
theme_button.add_theme_font_size_override("font_size", 20) 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) 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_compact_scale(panel_content, 1.0)
_apply_theme() _apply_theme()
layout_updating = false layout_updating = false
@@ -559,8 +600,10 @@ func _on_register_pressed() -> void:
var password := registration_password_input.text var password := registration_password_input.text
var error := _validate_account(username, email, password, registration_confirm_input.text) var error := _validate_account(username, email, password, registration_confirm_input.text)
if not error.is_empty(): return _show_error(error) 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 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: 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: func _start_steam() -> void:
if OS.has_feature("web"): if OS.has_feature("web"):
var popup_result: Variant = JavaScriptBridge.eval( 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 = '<main id=\"ranaze-steam-login\" style=\"font:16px system-ui;padding:48px;color:#172033\"><strong>Opening Steam sign in...</strong><p>This window will continue to Steam.</p></main>'; ranazeSteamWaitingWindow.setInterval(function () { var steamAuthUrl = ranazeSteamWaitingWindow.localStorage.getItem('%s'); if (steamAuthUrl) { var steamLogin = ranazeSteamWaitingWindow.document.getElementById('ranaze-steam-login'); steamLogin.innerHTML = '<strong>Steam sign in is ready.</strong><p><a id=\"ranaze-steam-link\" style=\"color:#2563eb;font-weight:600\">Continue to Steam</a></p>'; ranazeSteamWaitingWindow.document.getElementById('ranaze-steam-link').href = steamAuthUrl; ranazeSteamWaitingWindow.localStorage.removeItem('%s'); ranazeSteamWaitingWindow.location.replace(steamAuthUrl); } }, 150); } window.ranazeSteamWindow !== null;" % [STEAM_AUTH_STORAGE_KEY, STEAM_AUTH_STORAGE_KEY, STEAM_AUTH_STORAGE_KEY], "window.localStorage.removeItem('%s'); var ranazeSteamBridge = new URL('/external-redirect.html?key=%s&provider=Steam', window.location.origin).href; window.ranazeSteamWindow = window.open(ranazeSteamBridge, 'ranaze_steam'); window.ranazeSteamWindow !== null;" % [STEAM_AUTH_STORAGE_KEY, STEAM_AUTH_STORAGE_KEY],
true true
) )
steam_popup_opened = bool(popup_result) steam_popup_opened = bool(popup_result)
@@ -630,7 +673,7 @@ func _on_steam_started(result: int, code: int, _headers: PackedStringArray, body
return _show_error("Steam returned an invalid sign-in response.") return _show_error("Steam returned an invalid sign-in response.")
if OS.has_feature("web"): if OS.has_feature("web"):
JavaScriptBridge.eval( JavaScriptBridge.eval(
"var ranazeSteamAuthUrl = %s; window.localStorage.setItem('%s', ranazeSteamAuthUrl); if (window.ranazeSteamWindow && !window.ranazeSteamWindow.closed) { window.ranazeSteamWindow.focus(); } else { var ranazeSteamPopup = window.open(ranazeSteamAuthUrl, 'ranaze_steam'); if (ranazeSteamPopup) { window.ranazeSteamWindow = ranazeSteamPopup; ranazeSteamPopup.focus(); } else { window.location.assign(ranazeSteamAuthUrl); } }" % [JSON.stringify(auth_url), STEAM_AUTH_STORAGE_KEY], "var ranazeSteamAuthUrl = %s; window.localStorage.setItem('%s', ranazeSteamAuthUrl); if (window.ranazeSteamWindow && !window.ranazeSteamWindow.closed) { try { window.ranazeSteamWindow.location.replace(ranazeSteamAuthUrl); } catch (_) {} window.ranazeSteamWindow.focus(); } else { var ranazeSteamPopup = window.open(ranazeSteamAuthUrl, 'ranaze_steam'); if (ranazeSteamPopup) { window.ranazeSteamWindow = ranazeSteamPopup; ranazeSteamPopup.focus(); } else { window.location.assign(ranazeSteamAuthUrl); } }" % [JSON.stringify(auth_url), STEAM_AUTH_STORAGE_KEY],
true true
) )
else: else:
@@ -674,6 +717,7 @@ func _on_steam_status(result: int, code: int, _headers: PackedStringArray, body:
func _on_steam_action_changed(index: int) -> void: func _on_steam_action_changed(index: int) -> void:
steam_email_input.visible = index == 0 steam_email_input.visible = index == 0
steam_legal_check.visible = index == 0
steam_username_input.placeholder_text = "Choose a username" if index == 0 else "Existing email or username" steam_username_input.placeholder_text = "Choose a username" if index == 0 else "Existing email or username"
@@ -682,12 +726,14 @@ func _complete_steam() -> void:
if action == "create": if action == "create":
var validation := _validate_account(steam_username_input.text.strip_edges(), steam_email_input.text.strip_edges(), steam_password_input.text, steam_password_input.text) var validation := _validate_account(steam_username_input.text.strip_edges(), steam_email_input.text.strip_edges(), steam_password_input.text, steam_password_input.text)
if not validation.is_empty(): return _show_error(validation) 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(): elif steam_username_input.text.strip_edges().is_empty() or steam_password_input.text.is_empty():
return _show_error("Enter your existing account credentials.") return _show_error("Enter your existing account credentials.")
_send_json("steam_complete", "/steam/complete", { _send_json("steam_complete", "/steam/complete", {
"transactionId": steam_transaction_id, "action": action, "transactionId": steam_transaction_id, "action": action,
"username": steam_username_input.text.strip_edges(), "email": steam_email_input.text.strip_edges(), "username": steam_username_input.text.strip_edges(), "email": steam_email_input.text.strip_edges(),
"password": steam_password_input.text "password": steam_password_input.text, "acceptLegal": action == "create"
}, "Connecting account...") }, "Connecting account...")
@@ -718,12 +764,14 @@ func _accept_session(body: PackedByteArray, fallback_username: String) -> void:
var response := _parse_dictionary(body) var response := _parse_dictionary(body)
var token := str(response.get("accessToken", "")) var token := str(response.get("accessToken", ""))
if token.is_empty(): return _show_error("Sign in response did not include an access token.") 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, AuthState.set_session(str(response.get("username", fallback_username)), token,
str(response.get("refreshToken", "")), str(response.get("role", "")), str(response.get("exp", ""))) str(response.get("refreshToken", "")), str(response.get("role", "")), str(response.get("exp", "")), legal)
password_input.text = "" password_input.text = ""
_set_busy(false) _set_busy(false)
UISoundService.play_sound("correct") UISoundService.play_sound("correct")
main.show_screen("dashboard") main.show_screen("legal_consent" if AuthState.requires_legal_acceptance else "dashboard")
func _create_request(key: String, callback: Callable) -> void: func _create_request(key: String, callback: Callable) -> void:
+23 -3
View File
@@ -15,6 +15,7 @@ var feedback_button: Button
var feedback_dialog: Control var feedback_dialog: Control
var notes_dialog: Control var notes_dialog: Control
var session_expiry_dialog: Control var session_expiry_dialog: Control
var legal_document_dialog: Control
var theme_service var theme_service
var current_screen_name := "" var current_screen_name := ""
var screens_margin: MarginContainer var screens_margin: MarginContainer
@@ -40,7 +41,7 @@ func _ready() -> void:
theme_service.theme_changed.connect(_on_theme_changed) theme_service.theme_changed.connect(_on_theme_changed)
resized.connect(_update_responsive_layout) resized.connect(_update_responsive_layout)
_update_responsive_layout() _update_responsive_layout()
show_screen("dashboard" if AuthState.is_logged_in else "login") show_screen(("legal_consent" if AuthState.requires_legal_acceptance else "dashboard") if AuthState.is_logged_in else "login")
func _update_responsive_layout() -> void: func _update_responsive_layout() -> void:
@@ -216,6 +217,12 @@ func _build_ui() -> void:
screens_container.add_child(login) screens_container.add_child(login)
screens["login"] = login screens["login"] = login
var legal_consent := preload("res://scripts/ui/legal_consent_screen.gd").new()
legal_consent.name = "LegalConsentScreen"
legal_consent.main = self
screens_container.add_child(legal_consent)
screens["legal_consent"] = legal_consent
var dashboard := preload("res://scripts/ui/dashboard_screen.gd").new() var dashboard := preload("res://scripts/ui/dashboard_screen.gd").new()
dashboard.name = "DashboardScreen" dashboard.name = "DashboardScreen"
dashboard.main = self dashboard.main = self
@@ -330,10 +337,13 @@ func _build_ui() -> void:
session_expiry_dialog.name = "SessionExpiryDialog" session_expiry_dialog.name = "SessionExpiryDialog"
session_expiry_dialog.logout_requested.connect(_logout) session_expiry_dialog.logout_requested.connect(_logout)
add_child(session_expiry_dialog) add_child(session_expiry_dialog)
legal_document_dialog = preload("res://scripts/ui/legal_document_dialog.gd").new()
legal_document_dialog.name = "LegalDocumentDialog"
add_child(legal_document_dialog)
_build_account_menu() _build_account_menu()
_build_drawer() _build_drawer()
AuthState.auth_changed.connect(_refresh_header_controls) AuthState.auth_changed.connect(_on_auth_changed)
AuthState.session_expired.connect(_on_session_expired) AuthState.session_expired.connect(_on_session_expired)
_refresh_header_controls() _refresh_header_controls()
_build_app_menu() _build_app_menu()
@@ -534,7 +544,7 @@ func _close_account_menu() -> void:
func _screen_uses_shell(screen_name: String) -> bool: func _screen_uses_shell(screen_name: String) -> bool:
return AuthState.is_logged_in and screen_name not in ["login", "player", "ccs_player"] return AuthState.is_logged_in and screen_name not in ["login", "legal_consent", "player", "ccs_player"]
func _update_shell_visibility(screen_name: String) -> void: func _update_shell_visibility(screen_name: String) -> void:
@@ -706,6 +716,16 @@ func open_feedback_dialog() -> void:
feedback_dialog.show_dialog(FeedbackService.build_context(current_screen_name, get_viewport_rect().size)) feedback_dialog.show_dialog(FeedbackService.build_context(current_screen_name, get_viewport_rect().size))
func open_legal_document(kind: String) -> void:
legal_document_dialog.show_document(kind)
func _on_auth_changed() -> void:
_refresh_header_controls()
if AuthState.is_logged_in and AuthState.requires_legal_acceptance and current_screen_name not in ["login", "legal_consent"]:
show_screen("legal_consent")
func open_notes_dialog() -> void: func open_notes_dialog() -> void:
if not AuthState.is_logged_in or notes_dialog == null: if not AuthState.is_logged_in or notes_dialog == null:
return return
+28
View File
@@ -52,6 +52,27 @@ func _ready() -> void:
sound_toggle.toggled.connect(UISoundService.set_sounds_enabled) sound_toggle.toggled.connect(UISoundService.set_sounds_enabled)
content.add_child(sound_toggle) content.add_child(sound_toggle)
var legal_panel := PanelContainer.new()
legal_panel.custom_minimum_size = Vector2(520, 0)
legal_panel.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
legal_panel.add_theme_stylebox_override("panel", Brand.panel_box(UIThemeService.is_dark_mode()))
legal_panel.set_meta("legal_panel", true)
add_child(legal_panel)
var legal_content := VBoxContainer.new()
legal_content.add_theme_constant_override("separation", 10)
legal_panel.add_child(legal_content)
var legal_heading := Label.new()
legal_heading.text = "Legal"
legal_heading.add_theme_font_override("font", Brand.font)
legal_heading.add_theme_font_size_override("font_size", 20)
legal_content.add_child(legal_heading)
for spec in [["Terms of Service", "terms"], ["Privacy Policy", "privacy"]]:
var button := Button.new()
button.text = spec[0]
button.alignment = HORIZONTAL_ALIGNMENT_LEFT
button.pressed.connect(_open_legal_document.bind(str(spec[1])))
legal_content.add_child(button)
UIThemeService.theme_changed.connect(_on_theme_changed) UIThemeService.theme_changed.connect(_on_theme_changed)
@@ -64,8 +85,15 @@ func _on_theme_toggled(enabled: bool) -> void:
UIThemeService.set_dark_mode(enabled) UIThemeService.set_dark_mode(enabled)
func _open_legal_document(kind: String) -> void:
main.open_legal_document(kind)
func _on_theme_changed(is_dark: bool) -> void: func _on_theme_changed(is_dark: bool) -> void:
theme_toggle.set_pressed_no_signal(is_dark) theme_toggle.set_pressed_no_signal(is_dark)
var panel := theme_toggle.get_parent().get_parent() as PanelContainer var panel := theme_toggle.get_parent().get_parent() as PanelContainer
if panel != null: if panel != null:
panel.add_theme_stylebox_override("panel", Brand.panel_box(is_dark)) panel.add_theme_stylebox_override("panel", Brand.panel_box(is_dark))
for node in get_children():
if node is PanelContainer and node.has_meta("legal_panel"):
node.add_theme_stylebox_override("panel", Brand.panel_box(is_dark))
+47
View File
@@ -0,0 +1,47 @@
extends SceneTree
var failures := 0
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
var service = root.get_node("LegalService")
var auth = root.get_node("AuthState")
var terms: Dictionary = service.get_document("terms")
var privacy: Dictionary = service.get_document("privacy")
_assert(str(terms.get("version", "")) == "2026-07-20", "the embedded Terms version should match the Auth API policy")
_assert(str(privacy.get("version", "")) == "2026-07-20", "the embedded Privacy version should match the Auth API policy")
_assert(not terms.get("sections", []).is_empty() and not privacy.get("sections", []).is_empty(), "both embedded legal documents should contain reviewable sections")
auth.set_session("tester", "token", "refresh", "USER", "", {
"requiresLegalAcceptance": true,
"termsVersion": "2026-07-20",
"privacyVersion": "2026-07-20",
"termsUrl": "https://doctor.ranaze.com/terms.html",
"privacyUrl": "https://doctor.ranaze.com/privacy.html"
})
_assert(auth.requires_legal_acceptance, "an existing account with stale consent should be gated")
_assert(auth.terms_url.ends_with("/terms.html"), "legal metadata should retain the public Terms URL")
auth.set_legal_state({"requiresLegalAcceptance": false})
_assert(not auth.requires_legal_acceptance, "successful acceptance should release the account gate")
auth.clear_session()
var login = load("res://scripts/ui/login_screen.gd").new()
root.add_child(login)
await process_frame
_assert(login.registration_legal_check != null, "password registration should require explicit legal consent")
_assert(login.steam_legal_check != null, "new Steam-backed accounts should require explicit legal consent")
_assert(login.panel_gutter.get_theme_constant("margin_right") >= 28, "registration content should have a gutter beside the scrollbar")
login.queue_free()
await process_frame
quit(1 if failures > 0 else 0)
func _assert(condition: bool, message: String) -> void:
if condition:
return
failures += 1
push_error(message)
+1
View File
@@ -0,0 +1 @@
uid://dj8jw4ignn8gl
+7
View File
@@ -37,11 +37,18 @@ RUN mkdir -p /build \
&& cp /src/assets/fonts/Inter-Variable.ttf /build/medmind-inter.ttf \ && cp /src/assets/fonts/Inter-Variable.ttf /build/medmind-inter.ttf \
&& cp /src/web/donation-complete.html /build/donation-complete.html \ && cp /src/web/donation-complete.html /build/donation-complete.html \
&& cp /src/web/donation-canceled.html /build/donation-canceled.html \ && cp /src/web/donation-canceled.html /build/donation-canceled.html \
&& cp /src/web/terms.html /src/web/privacy.html /src/web/legal-page.css /src/web/legal-page.js /src/web/external-redirect.html /build/ \
&& mkdir -p /build/legal \
&& cp /src/legal/legal_documents.json /build/legal/legal_documents.json \
&& test -s /build/index.html \ && test -s /build/index.html \
&& test -s /build/ranaze-loader-icon.png \ && test -s /build/ranaze-loader-icon.png \
&& test -s /build/medmind-inter.ttf \ && test -s /build/medmind-inter.ttf \
&& test -s /build/donation-complete.html \ && test -s /build/donation-complete.html \
&& test -s /build/donation-canceled.html \ && test -s /build/donation-canceled.html \
&& test -s /build/terms.html \
&& test -s /build/privacy.html \
&& test -s /build/external-redirect.html \
&& test -s /build/legal/legal_documents.json \
&& test -s /build/index.wasm \ && test -s /build/index.wasm \
&& test -s /build/index.pck && test -s /build/index.pck
+31
View File
@@ -0,0 +1,31 @@
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Opening secure service | RanaZe</title><style>body{font:16px system-ui;background:#081324;color:#f8fafc;display:grid;place-items:center;min-height:100vh;margin:0}.card{max-width:34rem;padding:2rem;border:1px solid #33415d;border-radius:8px}a{color:#38bdf8}</style></head>
<body><main class="card"><strong id="title">Opening secure service...</strong><p id="message">This window will continue automatically.</p><p><a id="continue" hidden>Continue</a></p></main>
<script>
(() => {
const parameters = new URLSearchParams(location.search);
const key = parameters.get("key") || "";
const provider = parameters.get("provider") || "external service";
const allowedKey = /^ranaze_[a-z0-9_]{1,80}$/i.test(key);
const title = document.getElementById("title");
const message = document.getElementById("message");
const link = document.getElementById("continue");
title.textContent = `Opening ${provider}...`;
message.textContent = `This window will continue to ${provider} automatically.`;
const redirect = () => {
if (!allowedKey) return;
const url = localStorage.getItem(key);
if (!url || !url.startsWith("https://")) return;
link.href = url;
link.hidden = false;
link.textContent = `Continue to ${provider}`;
localStorage.removeItem(key);
location.replace(url);
};
addEventListener("storage", (event) => { if (event.key === key) redirect(); });
addEventListener("focus", redirect);
setInterval(redirect, 500);
redirect();
})();
</script></body></html>
+19
View File
@@ -0,0 +1,19 @@
:root { color-scheme: light dark; font-family: Inter, system-ui, sans-serif; }
* { box-sizing: border-box; }
body { margin: 0; background: #f6f8fc; color: #172033; line-height: 1.65; }
main { width: min(880px, calc(100% - 32px)); margin: 32px auto 72px; }
header { border-bottom: 1px solid #cbd5e1; margin-bottom: 28px; padding-bottom: 22px; }
.brand { color: #2563eb; font-weight: 700; text-decoration: none; }
h1 { font-size: clamp(2rem, 5vw, 3rem); line-height: 1.1; margin: 18px 0 8px; }
.meta, .summary { color: #526078; }
section { margin: 30px 0; }
h2 { font-size: 1.25rem; line-height: 1.35; }
a { color: #2563eb; }
.error { color: #dc2626; font-weight: 600; }
@media (prefers-color-scheme: dark) {
body { background: #081324; color: #f8fafc; }
header { border-color: #33415d; }
.meta, .summary { color: #a9b6cc; }
a, .brand { color: #38bdf8; }
.error { color: #fb7185; }
}
+27
View File
@@ -0,0 +1,27 @@
(() => {
const key = document.body.dataset.document;
const target = document.getElementById("legal-document");
const escapeHtml = (value) => String(value).replace(/[&<>"']/g, (character) => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;"
})[character]);
fetch("/legal/legal_documents.json", { cache: "no-store" })
.then((response) => {
if (!response.ok) throw new Error(`status ${response.status}`);
return response.json();
})
.then((documents) => {
const documentData = documents[key];
if (!documentData) throw new Error("document not found");
document.title = `${documentData.title} | RanaZe`;
const sections = documentData.sections.map((section) => `
<section><h2>${escapeHtml(section.heading)}</h2>
${section.paragraphs.map((paragraph) => `<p>${escapeHtml(paragraph)}</p>`).join("")}</section>`).join("");
target.innerHTML = `
<header><a class="brand" href="/">RanaZe: Step 3 Trainer</a>
<h1>${escapeHtml(documentData.title)}</h1>
<div class="meta">Effective ${escapeHtml(documentData.effectiveDate)} | Version ${escapeHtml(documentData.version)}</div>
<p class="summary">${escapeHtml(documentData.summary)}</p></header>${sections}`;
})
.catch(() => { target.innerHTML = '<p class="error">This document could not be loaded. Contact [email protected].</p>'; });
})();
+6
View File
@@ -41,6 +41,12 @@ server {
try_files $uri =404; try_files $uri =404;
} }
location ~ ^/(terms\.html|privacy\.html|legal-page\.(css|js)|legal/legal_documents\.json|external-redirect\.html)$ {
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
add_header X-Content-Type-Options "nosniff" always;
try_files $uri =404;
}
# Godot exports reuse these filenames across releases. Revalidate them so a # Godot exports reuse these filenames across releases. Revalidate them so a
# newly deployed index never boots an older cached project package. # newly deployed index never boots an older cached project package.
location ~ ^/(index\.(js|pck|wasm)|ranaze-loader-icon\.png|medmind-inter\.ttf)$ { location ~ ^/(index\.(js|pck|wasm)|ranaze-loader-icon\.png|medmind-inter\.ttf)$ {
+4
View File
@@ -0,0 +1,4 @@
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Privacy Policy | RanaZe</title><link rel="stylesheet" href="/legal-page.css"></head>
<body data-document="privacy"><main id="legal-document"><p>Loading Privacy Policy...</p></main><script src="/legal-page.js"></script></body></html>
+4
View File
@@ -0,0 +1,4 @@
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Terms of Service | RanaZe</title><link rel="stylesheet" href="/legal-page.css"></head>
<body data-document="terms"><main id="legal-document"><p>Loading Terms of Service...</p></main><script src="/legal-page.js"></script></body></html>