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.

Continue to Stripe

'; 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.

Continue to Steam

'; 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 ) 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.") if OS.has_feature("web"): 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 ) else: @@ -674,6 +717,7 @@ func _on_steam_status(result: int, code: int, _headers: PackedStringArray, body: func _on_steam_action_changed(index: int) -> void: 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" @@ -682,12 +726,14 @@ func _complete_steam() -> void: 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) if not validation.is_empty(): return _show_error(validation) + if not steam_legal_check.button_pressed: + return _show_error("Agree to the Terms of Service and acknowledge the Privacy Policy to create an account.") elif steam_username_input.text.strip_edges().is_empty() or steam_password_input.text.is_empty(): return _show_error("Enter your existing account credentials.") _send_json("steam_complete", "/steam/complete", { "transactionId": steam_transaction_id, "action": action, "username": steam_username_input.text.strip_edges(), "email": steam_email_input.text.strip_edges(), - "password": steam_password_input.text + "password": steam_password_input.text, "acceptLegal": action == "create" }, "Connecting account...") @@ -718,12 +764,14 @@ func _accept_session(body: PackedByteArray, fallback_username: String) -> void: var response := _parse_dictionary(body) var token := str(response.get("accessToken", "")) if token.is_empty(): return _show_error("Sign in response did not include an access token.") + var legal_value: Variant = response.get("legal", {}) + var legal: Dictionary = legal_value if typeof(legal_value) == TYPE_DICTIONARY else {} AuthState.set_session(str(response.get("username", fallback_username)), token, - str(response.get("refreshToken", "")), str(response.get("role", "")), str(response.get("exp", ""))) + str(response.get("refreshToken", "")), str(response.get("role", "")), str(response.get("exp", "")), legal) password_input.text = "" _set_busy(false) UISoundService.play_sound("correct") - main.show_screen("dashboard") + main.show_screen("legal_consent" if AuthState.requires_legal_acceptance else "dashboard") func _create_request(key: String, callback: Callable) -> void: diff --git a/scripts/ui/main.gd b/scripts/ui/main.gd index 581c1fd..c87b4d6 100644 --- a/scripts/ui/main.gd +++ b/scripts/ui/main.gd @@ -15,6 +15,7 @@ var feedback_button: Button var feedback_dialog: Control var notes_dialog: Control var session_expiry_dialog: Control +var legal_document_dialog: Control var theme_service var current_screen_name := "" var screens_margin: MarginContainer @@ -40,7 +41,7 @@ func _ready() -> void: theme_service.theme_changed.connect(_on_theme_changed) resized.connect(_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: @@ -216,6 +217,12 @@ func _build_ui() -> void: screens_container.add_child(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() dashboard.name = "DashboardScreen" dashboard.main = self @@ -330,10 +337,13 @@ func _build_ui() -> void: session_expiry_dialog.name = "SessionExpiryDialog" session_expiry_dialog.logout_requested.connect(_logout) 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_drawer() - AuthState.auth_changed.connect(_refresh_header_controls) + AuthState.auth_changed.connect(_on_auth_changed) AuthState.session_expired.connect(_on_session_expired) _refresh_header_controls() _build_app_menu() @@ -534,7 +544,7 @@ func _close_account_menu() -> void: 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: @@ -706,6 +716,16 @@ func open_feedback_dialog() -> void: 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: if not AuthState.is_logged_in or notes_dialog == null: return diff --git a/scripts/ui/settings_screen.gd b/scripts/ui/settings_screen.gd index ddde742..607f545 100644 --- a/scripts/ui/settings_screen.gd +++ b/scripts/ui/settings_screen.gd @@ -52,6 +52,27 @@ func _ready() -> void: sound_toggle.toggled.connect(UISoundService.set_sounds_enabled) 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) @@ -64,8 +85,15 @@ func _on_theme_toggled(enabled: bool) -> void: 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: theme_toggle.set_pressed_no_signal(is_dark) var panel := theme_toggle.get_parent().get_parent() as PanelContainer if panel != null: 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)) diff --git a/tests/legal_consent_test.gd b/tests/legal_consent_test.gd new file mode 100644 index 0000000..2bcbb94 --- /dev/null +++ b/tests/legal_consent_test.gd @@ -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) diff --git a/tests/legal_consent_test.gd.uid b/tests/legal_consent_test.gd.uid new file mode 100644 index 0000000..17855a6 --- /dev/null +++ b/tests/legal_consent_test.gd.uid @@ -0,0 +1 @@ +uid://dj8jw4ignn8gl diff --git a/web/Dockerfile b/web/Dockerfile index 74260c9..df7381f 100644 --- a/web/Dockerfile +++ b/web/Dockerfile @@ -37,11 +37,18 @@ RUN mkdir -p /build \ && 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-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/ranaze-loader-icon.png \ && test -s /build/medmind-inter.ttf \ && test -s /build/donation-complete.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.pck diff --git a/web/external-redirect.html b/web/external-redirect.html new file mode 100644 index 0000000..aff4572 --- /dev/null +++ b/web/external-redirect.html @@ -0,0 +1,31 @@ + + +Opening secure service | RanaZe +
Opening secure service...

This window will continue automatically.

+ diff --git a/web/legal-page.css b/web/legal-page.css new file mode 100644 index 0000000..44cf64d --- /dev/null +++ b/web/legal-page.css @@ -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; } +} diff --git a/web/legal-page.js b/web/legal-page.js new file mode 100644 index 0000000..b4144e4 --- /dev/null +++ b/web/legal-page.js @@ -0,0 +1,27 @@ +(() => { + const key = document.body.dataset.document; + const target = document.getElementById("legal-document"); + const escapeHtml = (value) => String(value).replace(/[&<>"']/g, (character) => ({ + "&": "&", "<": "<", ">": ">", '"': """, "'": "'" + })[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) => ` +

${escapeHtml(section.heading)}

+ ${section.paragraphs.map((paragraph) => `

${escapeHtml(paragraph)}

`).join("")}
`).join(""); + target.innerHTML = ` +
RanaZe: Step 3 Trainer +

${escapeHtml(documentData.title)}

+
Effective ${escapeHtml(documentData.effectiveDate)} | Version ${escapeHtml(documentData.version)}
+

${escapeHtml(documentData.summary)}

${sections}`; + }) + .catch(() => { target.innerHTML = '

This document could not be loaded. Contact support@ranaze.com.

'; }); +})(); diff --git a/web/nginx.conf b/web/nginx.conf index 61ae1fe..ad7a672 100644 --- a/web/nginx.conf +++ b/web/nginx.conf @@ -41,6 +41,12 @@ server { 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 # newly deployed index never boots an older cached project package. location ~ ^/(index\.(js|pck|wasm)|ranaze-loader-icon\.png|medmind-inter\.ttf)$ { diff --git a/web/privacy.html b/web/privacy.html new file mode 100644 index 0000000..3a6e05a --- /dev/null +++ b/web/privacy.html @@ -0,0 +1,4 @@ + + +Privacy Policy | RanaZe +

Loading Privacy Policy...

diff --git a/web/terms.html b/web/terms.html new file mode 100644 index 0000000..8af415b --- /dev/null +++ b/web/terms.html @@ -0,0 +1,4 @@ + + +Terms of Service | RanaZe +

Loading Terms of Service...