From 2ec8d86edf080e527ceebe88cd7d0aa64015942d Mon Sep 17 00:00:00 2001 From: trisua Date: Thu, 3 Jul 2025 21:56:21 -0400 Subject: [PATCH] add: purchased accounts --- crates/app/src/assets.rs | 1 + crates/app/src/langs/en-US.toml | 4 + crates/app/src/public/html/auth/base.lisp | 2 +- crates/app/src/public/html/auth/login.lisp | 3 +- crates/app/src/public/html/auth/register.lisp | 47 +- crates/app/src/public/html/components.lisp | 77 ++ crates/app/src/public/html/mod/profile.lisp | 10 + .../app/src/public/html/profile/settings.lisp | 120 ++- crates/app/src/public/html/root.lisp | 68 +- crates/app/src/public/js/layout_editor.js | 762 ++++++++++++++++++ .../routes/api/v1/auth/connections/stripe.rs | 21 + crates/app/src/routes/api/v1/auth/mod.rs | 75 +- crates/app/src/routes/api/v1/auth/profile.rs | 84 +- crates/app/src/routes/api/v1/mod.rs | 24 + crates/app/src/routes/assets.rs | 1 + crates/app/src/routes/mod.rs | 4 + crates/core/src/config.rs | 2 + crates/core/src/database/auth.rs | 55 +- .../src/database/drivers/sql/create_users.sql | 4 +- crates/core/src/database/invite_codes.rs | 23 +- crates/core/src/model/auth.rs | 11 + sql_changes/users_awaiting_purchase.sql | 5 + 22 files changed, 1279 insertions(+), 124 deletions(-) create mode 100644 crates/app/src/public/js/layout_editor.js create mode 100644 sql_changes/users_awaiting_purchase.sql diff --git a/crates/app/src/assets.rs b/crates/app/src/assets.rs index 3958f09..89af907 100644 --- a/crates/app/src/assets.rs +++ b/crates/app/src/assets.rs @@ -40,6 +40,7 @@ pub const ATTO_JS: &str = include_str!("./public/js/atto.js"); pub const ME_JS: &str = include_str!("./public/js/me.js"); pub const STREAMS_JS: &str = include_str!("./public/js/streams.js"); pub const CARP_JS: &str = include_str!("./public/js/carp.js"); +pub const LAYOUT_EDITOR_JS: &str = include_str!("./public/js/layout_editor.js"); // html pub const BODY: &str = include_str!("./public/html/body.lisp"); diff --git a/crates/app/src/langs/en-US.toml b/crates/app/src/langs/en-US.toml index f854057..ea87729 100644 --- a/crates/app/src/langs/en-US.toml +++ b/crates/app/src/langs/en-US.toml @@ -44,6 +44,7 @@ version = "1.0.0" "general:label.timeline_end" = "That's a wrap!" "general:label.loading" = "Working on it!" "general:label.send_anonymously" = "Send anonymously" +"general:label.must_activate_account" = "You need to activate your account!" "general:label.supporter_motivation" = "Become a supporter!" "general:action.become_supporter" = "Become supporter" @@ -88,6 +89,9 @@ version = "1.0.0" "auth:action.message" = "Message" "auth:label.banned" = "Banned" "auth:label.banned_message" = "This user has been banned for breaking the site's rules." +"auth:action.create_account" = "Create account" +"auth:action.purchase_account" = "Purchase account" +"auth:action.continue" = "Continue" "communities:action.create" = "Create" "communities:action.select" = "Select" diff --git a/crates/app/src/public/html/auth/base.lisp b/crates/app/src/public/html/auth/base.lisp index c13c336..3ed7b5a 100644 --- a/crates/app/src/public/html/auth/base.lisp +++ b/crates/app/src/public/html/auth/base.lisp @@ -1,7 +1,7 @@ (text "{% extends \"root.html\" %} {% block body %}") (main ("class" "flex flex-col gap-2") - ("style" "max-width: 25rem") + ("style" "max-width: 48ch") (h2 ("class" "w-full text-center") ; block for title diff --git a/crates/app/src/public/html/auth/login.lisp b/crates/app/src/public/html/auth/login.lisp index cb8bfff..e887b2b 100644 --- a/crates/app/src/public/html/auth/login.lisp +++ b/crates/app/src/public/html/auth/login.lisp @@ -48,7 +48,8 @@ ("name" "totp") ("id" "totp")))) (button - (text "Submit"))) + (icon (text "arrow-right")) + (str (text "auth:action.continue")))) (script (text "let flow_page = 1; diff --git a/crates/app/src/public/html/auth/register.lisp b/crates/app/src/public/html/auth/register.lisp index 9e6c22b..aa94c3d 100644 --- a/crates/app/src/public/html/auth/register.lisp +++ b/crates/app/src/public/html/auth/register.lisp @@ -37,16 +37,31 @@ (text "{% if config.security.enable_invite_codes -%}") (div ("class" "flex flex-col gap-1") + ("oninput" "check_should_show_purchase(event)") (label ("for" "invite_code") (b - (text "Invite code"))) + (text "Invite code (optional)"))) (input ("type" "text") ("placeholder" "invite code") - ("required" "") ("name" "invite_code") ("id" "invite_code"))) + + (script + (text "function check_should_show_purchase(e) { + if (e.target.value.length > 0) { + document.querySelector('[ui_ident=purchase_account]').classList.add('hidden'); + document.querySelector('[ui_ident=create_account]').classList.remove('hidden'); + globalThis.DO_PURCHASE = false; + } else { + document.querySelector('[ui_ident=purchase_account]').classList.remove('hidden'); + document.querySelector('[ui_ident=create_account]').classList.add('hidden'); + globalThis.DO_PURCHASE = true; + } + } + + globalThis.DO_PURCHASE = true;")) (text "{%- endif %}") (hr) (div @@ -84,8 +99,33 @@ ("class" "cf-turnstile") ("data-sitekey" "{{ config.turnstile.site_key }}")) (hr) + (text "{% if config.security.enable_invite_codes -%}") + (div + ("class" "w-full flex gap-2 justify-between") + ("ui_ident" "purchase_account") + + (button + (icon (text "credit-card")) + (str (text "auth:action.purchase_account"))) + + (button + ("class" "small square lowered") + ("type" "button") + ("onclick" "document.querySelector('[ui_ident=purchase_help]').classList.toggle('hidden')") + (icon (text "circle-question-mark")))) + + (div + ("class" "hidden lowered card w-full no_p_margin") + ("ui_ident" "purchase_help") + (b (text "What does \"Purchase account\" mean?")) + (p (text "Your account will be created, but you cannot use it until you activate it for {{ config.stripe.supporter_price_text }}.")) + (p (text "Alternatively, you can provide an invite code to create your account for free."))) + (text "{%- endif %}") (button - (text "Submit"))) + ("class" "{% if config.security.enable_invite_codes -%} hidden {%- endif %}") + ("ui_ident" "create_account") + (icon (text "plus")) + (str (text "auth:action.create_account")))) (script (text "async function register(e) { @@ -104,6 +144,7 @@ \"[name=cf-turnstile-response]\", ).value, invite_code: (e.target.invite_code || { value: \"\" }).value, + purchase: globalThis.DO_PURCHASE, }), }) .then((res) => res.json()) diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp index d9001c9..a15fe19 100644 --- a/crates/app/src/public/html/components.lisp +++ b/crates/app/src/public/html/components.lisp @@ -2285,3 +2285,80 @@ (text "{{ self::note_mover_dirs_listing(dir=subdir, dirs=dirs) }}") (text "{%- endif %} {% endfor %}")) (text "{%- endmacro %}") + +(text "{% macro become_supporter_button() -%}") +(p + (text "You're ") + (b + (text "not ")) + (text "currently a supporter! No + pressure, but it helps us do some pretty cool + things! As a supporter, you'll get:")) +(ul + ("style" "margin-bottom: var(--pad-4)") + (li + (text "Vanity badge on profile")) + (li + (text "No more supporter ads (duh)")) + (li + (text "Ability to upload gif avatars/banners")) + (li + (text "Be an admin/owner of up to 10 communities")) + (li + (text "Use custom CSS on your profile")) + (li + (text "Use community emojis outside of + their community")) + (li + (text "Upload and use gif emojis")) + (li + (text "Create infinite stack timelines")) + (li + (text "Upload images to posts")) + (li + (text "Save infinite post drafts")) + (li + (text "Ability to search through all posts")) + (li + (text "Ability to create forges")) + (li + (text "Create more than 1 app")) + (li + (text "Create up to 10 stack blocks")) + (li + (text "Add unlimited users to stacks")) + (li + (text "Increased proxied image size")) + (li + (text "Create infinite journals")) + (li + (text "Create infinite notes in each journal")) + (li + (text "Publish up to 50 notes")) + + (text "{% if config.security.enable_invite_codes -%}") + (li + (text "Create up to 48 invite codes") + (sup (a ("href" "#footnote-1") (text "1")))) + (text "{%- endif %}")) +(a + ("href" "{{ config.stripe.payment_link }}?client_reference_id={{ user.id }}") + ("class" "button") + ("target" "_blank") + (text "Become a supporter ({{ config.stripe.supporter_price_text }})")) +(span + ("class" "fade") + (text "Please use your") + (b + (text " real email ")) + (text "when + completing payment. It is required to manage + your billing settings.")) + +(text "{% if config.security.enable_invite_codes -%}") +(span + ("class" "fade") + ("id" "footnote-1") + (b (text "1: ")) (text "After your account is at least 1 month old")) +(text "{%- endif %}") +(text "{%- endmacro %}") diff --git a/crates/app/src/public/html/mod/profile.lisp b/crates/app/src/public/html/mod/profile.lisp index e64ec63..9fb5ebf 100644 --- a/crates/app/src/public/html/mod/profile.lisp +++ b/crates/app/src/public/html/mod/profile.lisp @@ -168,6 +168,11 @@ \"{{ profile.is_verified }}\", \"checkbox\", ], + [ + [\"awaiting_purchase\", \"Awaiting purchase\"], + \"{{ profile.awaiting_purchase }}\", + \"checkbox\", + ], [ [\"role\", \"Permission level\"], \"{{ profile.permissions }}\", @@ -181,6 +186,11 @@ is_verified: value, }); }, + awaiting_purchase: (value) => { + profile_request(false, \"awaiting_purchase\", { + awaiting_purchase: value, + }); + }, role: (new_role) => { return update_user_role(new_role); }, diff --git a/crates/app/src/public/html/profile/settings.lisp b/crates/app/src/public/html/profile/settings.lisp index dce30d2..8acd9c3 100644 --- a/crates/app/src/public/html/profile/settings.lisp +++ b/crates/app/src/public/html/profile/settings.lisp @@ -671,73 +671,31 @@ ("target" "_blank") (text "Manage billing")) (text "{% else %}") - (p - (text "You're ") - (b - (text "not ")) - (text "currently a supporter! No - pressure, but it helps us do some pretty cool - things! As a supporter, you'll get:")) - (ul - ("style" "margin-bottom: var(--pad-4)") - (li - (text "Vanity badge on profile")) - (li - (text "No more supporter ads (duh)")) - (li - (text "Ability to upload gif avatars/banners")) - (li - (text "Be an admin/owner of up to 10 communities")) - (li - (text "Use custom CSS on your profile")) - (li - (text "Use community emojis outside of - their community")) - (li - (text "Upload and use gif emojis")) - (li - (text "Create infinite stack timelines")) - (li - (text "Upload images to posts")) - (li - (text "Save infinite post drafts")) - (li - (text "Ability to search through all posts")) - (li - (text "Ability to create forges")) - (li - (text "Create more than 1 app")) - (li - (text "Create up to 10 stack blocks")) - (li - (text "Add unlimited users to stacks")) - (li - (text "Increased proxied image size")) - (li - (text "Create infinite journals")) - (li - (text "Create infinite notes in each journal")) - (li - (text "Publish up to 50 notes")) - - (text "{% if config.security.enable_invite_codes -%}") - (li - (text "Create up to 48 invite codes")) - (text "{%- endif %}")) - (a - ("href" "{{ config.stripe.payment_link }}?client_reference_id={{ user.id }}") - ("class" "button") - ("target" "_blank") - (text "Become a supporter")) - (span - ("class" "fade") - (text "Please use your") - (b - (text "real email")) - (text "when - completing payment. It is required to manage - your billing settings.")) + (text "{{ components::become_supporter_button() }}") (text "{%- endif %}"))) + + (text "{% if user.was_purchased and user.invite_code == 0 -%}") + (form + ("class" "card w-full lowered flex flex-col gap-2") + ("onsubmit" "update_invite_code(event)") + (p (text "Your account is currently activated without an invite code. If you stop paying for supporter, your account will be locked again until you renew. You can provide an invite code to avoid this if you're planning on cancelling.")) + + (div + ("class" "flex flex-col gap-1") + (label + ("for" "invite_code") + (b + (text "Invite code"))) + (input + ("type" "text") + ("placeholder" "invite code") + ("name" "invite_code") + ("required" "") + ("id" "invite_code"))) + + (button + (text "Submit"))) + (text "{%- endif %}") (text "{%- endif %}"))))) (div ("class" "w-full hidden flex flex-col gap-2") @@ -1198,6 +1156,11 @@ globalThis.delete_account = async (e) => { e.preventDefault(); + // {% if user.permissions|has_supporter %} + alert(\"Please cancel your membership before deleting your account. You'll have to wait until the next cycle to delete your account after, or you can request support if it is urgent.\"); + return; + // {% endif %} + if ( !(await trigger(\"atto::confirm\", [ \"Are you sure you would like to do this?\", @@ -1381,6 +1344,31 @@ }); }; + globalThis.update_invite_code = async (e) => { + e.preventDefault(); + await trigger(\"atto::debounce\", [\"invite_codes::try\"]); + fetch(\"/api/v1/auth/user/me/invite_code\", { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + invite_code: e.target.invite_code.value, + }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + + if (res.ok) { + window.location.reload(); + } + }); + } + const account_settings = document.getElementById(\"account_settings\"); const profile_settings = diff --git a/crates/app/src/public/html/root.lisp b/crates/app/src/public/html/root.lisp index a4288b3..93312bb 100644 --- a/crates/app/src/public/html/root.lisp +++ b/crates/app/src/public/html/root.lisp @@ -72,7 +72,73 @@ (str (text "general:label.account_banned_body")))))) ; if we aren't banned, just show the page body - (text "{% else %} {% block body %}{% endblock %} {%- endif %}") + (text "{% elif user and user.awaiting_purchase %}") + ; account waiting for payment message + (article + (main + (div + ("class" "card-nest") + (div + ("class" "card small flex items-center gap-2 red") + (icon (text "frown")) + (str (text "general:label.must_activate_account"))) + + (div + ("class" "card no_p_margin flex flex-col gap-2") + (p (text "Since you didn't provide an invite code, you'll need to activate your account to use it.")) + (p (text "Supporter is a recurring membership. If you cancel it, your account will be locked again unless you renew your subscription or provide an invite code.")) + (div + ("class" "card w-full lowered flex flex-col gap-2") + (text "{{ components::become_supporter_button() }}")) + (p (text "Alternatively, you can provide an invite code to activate your account.")) + (form + ("class" "card w-full lowered flex flex-col gap-2") + ("onsubmit" "update_invite_code(event)") + (div + ("class" "flex flex-col gap-1") + (label + ("for" "invite_code") + (b + (text "Invite code"))) + (input + ("type" "text") + ("placeholder" "invite code") + ("name" "invite_code") + ("required" "") + ("id" "invite_code"))) + + (button + (text "Submit"))) + + (script + (text "async function update_invite_code(e) { + e.preventDefault(); + await trigger(\"atto::debounce\", [\"invite_codes::try\"]); + fetch(\"/api/v1/auth/user/me/invite_code\", { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + invite_code: e.target.invite_code.value, + }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + + if (res.ok) { + window.location.reload(); + } + }); + }")))))) + (text "{% else %}") + ; page body + (text "{% block body %}{% endblock %}") + (text "{%- endif %}") (text "")) (text "{% include \"body.html\" %}"))) diff --git a/crates/app/src/public/js/layout_editor.js b/crates/app/src/public/js/layout_editor.js new file mode 100644 index 0000000..13d3d8b --- /dev/null +++ b/crates/app/src/public/js/layout_editor.js @@ -0,0 +1,762 @@ +/// Copy all the fields from one object to another. +function copy_fields(from, to) { + for (const field of Object.entries(from)) { + to[field[0]] = field[1]; + } + + return to; +} + +/// Simple template components. +const COMPONENT_TEMPLATES = { + EMPTY_COMPONENT: { component: "empty", options: {}, children: [] }, + FLEX_DEFAULT: { + component: "flex", + options: { + direction: "row", + gap: "2", + }, + children: [], + }, + FLEX_SIMPLE_ROW: { + component: "flex", + options: { + direction: "row", + gap: "2", + width: "full", + }, + children: [], + }, + FLEX_SIMPLE_COL: { + component: "flex", + options: { + direction: "col", + gap: "2", + width: "full", + }, + children: [], + }, + FLEX_MOBILE_COL: { + component: "flex", + options: { + collapse: "yes", + gap: "2", + width: "full", + }, + children: [], + }, + MARKDOWN_DEFAULT: { + component: "markdown", + options: { + text: "Hello, world!", + }, + }, + MARKDOWN_CARD: { + component: "markdown", + options: { + class: "card w-full", + text: "Hello, world!", + }, + }, +}; + +/// All available components with their label and JSON representation. +const COMPONENTS = [ + [ + "Markdown block", + COMPONENT_TEMPLATES.MARKDOWN_DEFAULT, + [["Card", COMPONENT_TEMPLATES.MARKDOWN_CARD]], + ], + [ + "Flex container", + COMPONENT_TEMPLATES.FLEX_DEFAULT, + [ + ["Simple rows", COMPONENT_TEMPLATES.FLEX_SIMPLE_ROW], + ["Simple columns", COMPONENT_TEMPLATES.FLEX_SIMPLE_COL], + ["Mobile columns", COMPONENT_TEMPLATES.FLEX_MOBILE_COL], + ], + ], + [ + "Profile tabs", + { + component: "tabs", + }, + ], + [ + "Profile feeds", + { + component: "feed", + }, + ], + [ + "Profile banner", + { + component: "banner", + }, + ], + [ + "Question box", + { + component: "ask", + }, + ], + [ + "Name & avatar", + { + component: "name", + }, + ], + [ + "About section", + { + component: "about", + }, + ], + [ + "Action buttons", + { + component: "actions", + }, + ], + [ + "CSS stylesheet", + { + component: "style", + options: { + data: "", + }, + }, + ], +]; + +// preload icons +trigger("app::icon", ["shapes"]); +trigger("app::icon", ["type"]); +trigger("app::icon", ["plus"]); +trigger("app::icon", ["move-up"]); +trigger("app::icon", ["move-down"]); +trigger("app::icon", ["trash"]); +trigger("app::icon", ["arrow-left"]); +trigger("app::icon", ["x"]); + +/// The location of an element as represented by array indexes. +class ElementPointer { + position = []; + + constructor(element) { + if (element) { + const pos = []; + + let target = element; + while (target.parentElement) { + const parent = target.parentElement; + + // push index + pos.push(Array.from(parent.children).indexOf(target) || 0); + + // update target + if (parent.id === "editor") { + break; + } + + target = parent; + } + + this.position = pos.reverse(); // indexes are added in reverse order because of how we traverse + } else { + this.position = []; + } + } + + get() { + return this.position; + } + + resolve(json, minus = 0) { + let out = json; + + if (this.position.length === 1) { + // this is the first element (this.position === [0]) + return out; + } + + const pos = this.position.slice(1, this.position.length); // the first one refers to the root element + + for (let i = 0; i < minus; i++) { + pos.pop(); + } + + for (const idx of pos) { + const child = ((out || { children: [] }).children || [])[idx]; + + if (!child) { + break; + } + + out = child; + } + + return out; + } +} + +/// The layout editor controller. +class LayoutEditor { + element; + json; + tree = ""; + current = { component: "empty" }; + pointer = new ElementPointer(); + + /// Create a new [`LayoutEditor`]. + constructor(element, json) { + this.element = element; + this.json = json; + + if (this.json.json) { + delete this.json.json; + } + + element.addEventListener("click", (e) => this.click(e, this)); + element.addEventListener("mouseover", (e) => { + e.stopImmediatePropagation(); + const ptr = new ElementPointer(e.target); + + if (document.getElementById("position")) { + document.getElementById( + "position", + ).parentElement.style.display = "flex"; + + document.getElementById("position").innerText = ptr + .get() + .join("."); + } + }); + + this.render(); + } + + /// Render layout. + render() { + fetch("/api/v0/auth/render_layout", { + method: "POST", + body: JSON.stringify({ + layout: this.json, + }), + headers: { + "Content-Type": "application/json", + }, + }) + .then((r) => r.json()) + .then((r) => { + this.element.innerHTML = r.block; + this.tree = r.tree; + + if (this.json.component !== "empty") { + // remove all "empty" components (if the root component isn't an empty) + for (const element of document.querySelectorAll( + '[data-component-name="empty"]', + )) { + element.remove(); + } + } + }); + } + + /// Editor clicked. + click(e, self) { + e.stopImmediatePropagation(); + trigger("app::hooks::dropdown.close"); + + const ptr = new ElementPointer(e.target); + self.current = ptr.resolve(self.json); + self.pointer = ptr; + + if (document.getElementById("current_position")) { + document.getElementById( + "current_position", + ).parentElement.style.display = "flex"; + + document.getElementById("current_position").innerText = ptr + .get() + .join("."); + } + + for (const element of document.querySelectorAll( + ".layout_editor_block.active", + )) { + element.classList.remove("active"); + } + + e.target.classList.add("active"); + self.screen("element"); + } + + /// Open sidebar. + open() { + document.getElementById("editor_sidebar").classList.add("open"); + document.getElementById("editor").style.transform = "scale(0.8)"; + } + + /// Close sidebar. + close() { + document.getElementById("editor_sidebar").style.animation = + "0.2s ease-in-out forwards to_left"; + + setTimeout(() => { + document.getElementById("editor_sidebar").classList.remove("open"); + document.getElementById("editor_sidebar").style.animation = + "0.2s ease-in-out forwards from_right"; + }, 250); + + document.getElementById("editor").style.transform = "scale(1)"; + } + + /// Render editor dialog. + screen(page = "element", data = {}) { + this.current.component = this.current.component.toLowerCase(); + + const sidebar = document.getElementById("editor_sidebar"); + sidebar.innerHTML = ""; + + // render page + if ( + page === "add" || + (page === "element" && this.current.component === "empty") + ) { + // add element + sidebar.appendChild( + (() => { + const heading = document.createElement("h3"); + heading.innerText = data.add_title || "Add component"; + return heading; + })(), + ); + + sidebar.appendChild(document.createElement("hr")); + + const container = document.createElement("div"); + container.className = "flex w-full gap-2 flex-wrap"; + + for (const component of data.components || COMPONENTS) { + container.appendChild( + (() => { + const button = document.createElement("button"); + button.classList.add("secondary"); + + trigger("app::icon", [ + data.icon || "shapes", + "icon", + ]).then((icon) => { + button.prepend(icon); + }); + + button.appendChild( + (() => { + const span = document.createElement("span"); + span.innerText = `${component[0]}${component[2] ? ` (${component[2].length + 1})` : ""}`; + return span; + })(), + ); + + button.addEventListener("click", () => { + if (component[2]) { + // render presets + return this.screen(page, { + back: ["add", {}], + add_title: "Select preset", + components: [ + ["Default", component[1]], + ...component[2], + ], + icon: "type", + }); + } + + // no presets + if ( + page === "element" && + this.current.component === "empty" + ) { + // replace with component + copy_fields(component[1], this.current); + } else { + // add component to children + this.current.children.push( + structuredClone(component[1]), + ); + } + + this.render(); + this.close(); + }); + + return button; + })(), + ); + } + + sidebar.appendChild(container); + } else if (page === "element") { + // edit element + const name = document.createElement("div"); + name.className = "flex flex-col gap-2"; + + name.appendChild( + (() => { + const heading = document.createElement("h3"); + heading.innerText = `Edit ${this.current.component}`; + return heading; + })(), + ); + + name.appendChild( + (() => { + const pos = document.createElement("div"); + pos.className = "notification w-content"; + pos.innerText = this.pointer.get().join("."); + return pos; + })(), + ); + + sidebar.appendChild(name); + sidebar.appendChild(document.createElement("hr")); + + // options + const options = document.createElement("div"); + options.className = "card flex flex-col gap-2 w-full"; + + const add_option = ( + label_text, + name, + valid = [], + input_element = "input", + ) => { + const card = document.createElement("details"); + card.className = "w-full"; + + const summary = document.createElement("summary"); + summary.className = "w-full"; + + const label = document.createElement("label"); + label.setAttribute("for", name); + label.className = "w-full"; + label.innerText = label_text; + label.style.cursor = "pointer"; + + label.addEventListener("click", () => { + // bubble to summary click + summary.click(); + }); + + const input_box = document.createElement("div"); + input_box.style.paddingLeft = "1rem"; + input_box.style.borderLeft = + "solid 2px var(--color-super-lowered)"; + + const input = document.createElement(input_element); + input.id = name; + input.setAttribute("name", name); + input.setAttribute("type", "text"); + + if (input_element === "input") { + input.setAttribute( + "value", + // biome-ignore lint/complexity/useOptionalChain: that's an unsafe change which would possibly break code + (this.current.options || {})[name] || "", + ); + } else { + // biome-ignore lint/complexity/useOptionalChain: that's an unsafe change which would possibly break code + input.innerHTML = (this.current.options || {})[name] || ""; + } + + // biome-ignore lint/complexity/useOptionalChain: that's an unsafe change which would possibly break code + if ((this.current.options || {})[name]) { + // open details if a value is set + card.setAttribute("open", ""); + } + + input.addEventListener("change", (e) => { + if ( + valid.length > 0 && + !valid.includes(e.target.value) && + e.target.value.length > 0 // anything can be set to empty + ) { + alert(`Must be one of: ${JSON.stringify(valid)}`); + return; + } + + if (!this.current.options) { + this.current.options = {}; + } + + this.current.options[name] = + e.target.value === "no" ? "" : e.target.value; + }); + + summary.appendChild(label); + card.appendChild(summary); + input_box.appendChild(input); + card.appendChild(input_box); + options.appendChild(card); + }; + + sidebar.appendChild(options); + + if (this.current.component === "flex") { + add_option("Gap", "gap", ["1", "2", "3", "4"]); + add_option("Direction", "direction", ["row", "col"]); + add_option("Do collapse", "collapse", ["yes", "no"]); + add_option("Width", "width", ["full", "content"]); + add_option("Class name", "class"); + add_option("Unique ID", "id"); + add_option("Style", "style", [], "textarea"); + } else if (this.current.component === "markdown") { + add_option("Content", "text", [], "textarea"); + add_option("Class name", "class"); + } else if (this.current.component === "divider") { + add_option("Class name", "class"); + } else if (this.current.component === "style") { + add_option("Style data", "data", [], "textarea"); + } else { + options.remove(); + } + + // action buttons + const buttons = document.createElement("div"); + buttons.className = "card w-full flex flex-wrap gap-2"; + + if (this.current.component === "flex") { + buttons.appendChild( + (() => { + const button = document.createElement("button"); + + trigger("app::icon", ["plus", "icon"]).then((icon) => { + button.prepend(icon); + }); + + button.appendChild( + (() => { + const span = document.createElement("span"); + span.innerText = "Add child"; + return span; + })(), + ); + + button.addEventListener("click", () => { + this.screen("add"); + }); + + return button; + })(), + ); + } + + buttons.appendChild( + (() => { + const button = document.createElement("button"); + + trigger("app::icon", ["move-up", "icon"]).then((icon) => { + button.prepend(icon); + }); + + button.appendChild( + (() => { + const span = document.createElement("span"); + span.innerText = "Move up"; + return span; + })(), + ); + + button.addEventListener("click", () => { + const idx = this.pointer.get().pop(); + const parent_ref = this.pointer.resolve( + this.json, + ).children; + + if (parent_ref[idx - 1] === undefined) { + alert("No space to move element."); + return; + } + + const clone = JSON.parse(JSON.stringify(this.current)); + const other_clone = JSON.parse( + JSON.stringify(parent_ref[idx - 1]), + ); + + copy_fields(clone, parent_ref[idx - 1]); // move here to here + copy_fields(other_clone, parent_ref[idx]); // move there to here + + this.close(); + this.render(); + }); + + return button; + })(), + ); + + buttons.appendChild( + (() => { + const button = document.createElement("button"); + + trigger("app::icon", ["move-down", "icon"]).then((icon) => { + button.prepend(icon); + }); + + button.appendChild( + (() => { + const span = document.createElement("span"); + span.innerText = "Move down"; + return span; + })(), + ); + + button.addEventListener("click", () => { + const idx = this.pointer.get().pop(); + const parent_ref = this.pointer.resolve( + this.json, + ).children; + + if (parent_ref[idx + 1] === undefined) { + alert("No space to move element."); + return; + } + + const clone = JSON.parse(JSON.stringify(this.current)); + const other_clone = JSON.parse( + JSON.stringify(parent_ref[idx + 1]), + ); + + copy_fields(clone, parent_ref[idx + 1]); // move here to here + copy_fields(other_clone, parent_ref[idx]); // move there to here + + this.close(); + this.render(); + }); + + return button; + })(), + ); + + buttons.appendChild( + (() => { + const button = document.createElement("button"); + button.classList.add("red"); + + trigger("app::icon", ["trash", "icon"]).then((icon) => { + button.prepend(icon); + }); + + button.appendChild( + (() => { + const span = document.createElement("span"); + span.innerText = "Delete"; + return span; + })(), + ); + + button.addEventListener("click", async () => { + if ( + !(await trigger("app::confirm", [ + "Are you sure you would like to do this?", + ])) + ) { + return; + } + + if (this.json === this.current) { + // this is the root element; replace with empty + copy_fields( + COMPONENT_TEMPLATES.EMPTY_COMPONENT, + this.current, + ); + } else { + // get parent + const idx = this.pointer.get().pop(); + const ref = this.pointer.resolve(this.json); + // remove element + ref.children.splice(idx, 1); + } + + this.render(); + this.close(); + }); + + return button; + })(), + ); + + sidebar.appendChild(buttons); + } else if (page === "tree") { + sidebar.innerHTML = this.tree; + } + + sidebar.appendChild(document.createElement("hr")); + + const buttons = document.createElement("div"); + buttons.className = "flex gap-2 flex-wrap"; + + if (data.back) { + buttons.appendChild( + (() => { + const button = document.createElement("button"); + button.className = "secondary"; + + trigger("app::icon", ["arrow-left", "icon"]).then( + (icon) => { + button.prepend(icon); + }, + ); + + button.appendChild( + (() => { + const span = document.createElement("span"); + span.innerText = "Back"; + return span; + })(), + ); + + button.addEventListener("click", () => { + this.screen(...data.back); + }); + + return button; + })(), + ); + } + + buttons.appendChild( + (() => { + const button = document.createElement("button"); + button.className = "red secondary"; + + trigger("app::icon", ["x", "icon"]).then((icon) => { + button.prepend(icon); + }); + + button.appendChild( + (() => { + const span = document.createElement("span"); + span.innerText = "Close"; + return span; + })(), + ); + + button.addEventListener("click", () => { + this.render(); + this.close(); + }); + + return button; + })(), + ); + + sidebar.appendChild(buttons); + + // ... + this.open(); + } +} + +define("ElementPointer", ElementPointer); +define("LayoutEditor", LayoutEditor); diff --git a/crates/app/src/routes/api/v1/auth/connections/stripe.rs b/crates/app/src/routes/api/v1/auth/connections/stripe.rs index 6ef6fcd..b5f746c 100644 --- a/crates/app/src/routes/api/v1/auth/connections/stripe.rs +++ b/crates/app/src/routes/api/v1/auth/connections/stripe.rs @@ -138,6 +138,15 @@ pub async fn stripe_webhook( return Json(e.into()); } + if data.0.0.security.enable_invite_codes && user.awaiting_purchase { + if let Err(e) = data + .update_user_awaiting_purchased_status(user.id, false, user.clone(), false) + .await + { + return Json(e.into()); + } + } + if let Err(e) = data .create_notification(Notification::new( "Welcome new supporter!".to_string(), @@ -174,6 +183,18 @@ pub async fn stripe_webhook( return Json(e.into()); } + if data.0.0.security.enable_invite_codes && user.was_purchased && user.invite_code == 0 + { + // user doesn't come from an invite code, and is a purchased account + // this means their account must be locked if they stop paying + if let Err(e) = data + .update_user_awaiting_purchased_status(user.id, true, user.clone(), false) + .await + { + return Json(e.into()); + } + } + if let Err(e) = data .create_notification(Notification::new( "Sorry to see you go... :(".to_string(), diff --git a/crates/app/src/routes/api/v1/auth/mod.rs b/crates/app/src/routes/api/v1/auth/mod.rs index 934f5fc..085844a 100644 --- a/crates/app/src/routes/api/v1/auth/mod.rs +++ b/crates/app/src/routes/api/v1/auth/mod.rs @@ -88,41 +88,46 @@ pub async fn register_request( // check invite code if data.0.0.security.enable_invite_codes { - if props.invite_code.is_empty() { - return ( - None, - Json(Error::MiscError("Missing invite code".to_string()).into()), - ); + if !props.purchase { + if props.invite_code.is_empty() { + return ( + None, + Json(Error::MiscError("Missing invite code".to_string()).into()), + ); + } + + let invite_code = match data.get_invite_code_by_code(&props.invite_code).await { + Ok(c) => c, + Err(e) => return (None, Json(e.into())), + }; + + if invite_code.is_used { + return ( + None, + Json(Error::MiscError("This code has already been used".to_string()).into()), + ); + } + + // let owner = match data.get_user_by_id(invite_code.owner).await { + // Ok(u) => u, + // Err(e) => return (None, Json(e.into())), + // }; + + // if !owner.permissions.check(FinePermission::SUPPORTER) { + // return ( + // None, + // Json( + // Error::MiscError("Invite code owner must be an active supporter".to_string()) + // .into(), + // ), + // ); + // } + + user.invite_code = invite_code.id; + } else { + // this account is being purchased + user.awaiting_purchase = true; } - - let invite_code = match data.get_invite_code_by_code(&props.invite_code).await { - Ok(c) => c, - Err(e) => return (None, Json(e.into())), - }; - - if invite_code.is_used { - return ( - None, - Json(Error::MiscError("This code has already been used".to_string()).into()), - ); - } - - // let owner = match data.get_user_by_id(invite_code.owner).await { - // Ok(u) => u, - // Err(e) => return (None, Json(e.into())), - // }; - - // if !owner.permissions.check(FinePermission::SUPPORTER) { - // return ( - // None, - // Json( - // Error::MiscError("Invite code owner must be an active supporter".to_string()) - // .into(), - // ), - // ); - // } - - user.invite_code = invite_code.id; } // push initial token @@ -133,7 +138,7 @@ pub async fn register_request( match data.create_user(user).await { Ok(_) => { // mark invite as used - if data.0.0.security.enable_invite_codes { + if data.0.0.security.enable_invite_codes && !props.purchase { let invite_code = match data.get_invite_code_by_code(&props.invite_code).await { Ok(c) => c, Err(e) => return (None, Json(e.into())), diff --git a/crates/app/src/routes/api/v1/auth/profile.rs b/crates/app/src/routes/api/v1/auth/profile.rs index f66e9bf..75247f1 100644 --- a/crates/app/src/routes/api/v1/auth/profile.rs +++ b/crates/app/src/routes/api/v1/auth/profile.rs @@ -4,8 +4,8 @@ use crate::{ model::{ApiReturn, Error}, routes::api::v1::{ AppendAssociations, AwardAchievement, DeleteUser, DisableTotp, RefreshGrantToken, - UpdateSecondaryUserRole, UpdateUserIsVerified, UpdateUserPassword, UpdateUserRole, - UpdateUserUsername, + UpdateSecondaryUserRole, UpdateUserAwaitingPurchase, UpdateUserInviteCode, + UpdateUserIsVerified, UpdateUserPassword, UpdateUserRole, UpdateUserUsername, }, State, }; @@ -343,6 +343,34 @@ pub async fn update_user_is_verified_request( } } +/// Update the verification status of the given user. +/// +/// Does not support third-party grants. +pub async fn update_user_awaiting_purchase_request( + jar: CookieJar, + Path(id): Path, + Extension(data): Extension, + Json(req): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data + .update_user_awaiting_purchased_status(id, req.awaiting_purchase, user, true) + .await + { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Awaiting purchase status updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + /// Update the role of the given user. /// /// Does not support third-party grants. @@ -949,3 +977,55 @@ pub async fn self_serve_achievement_request( Err(e) => Json(e.into()), } } + +/// Update the verification status of the given user. +/// +/// Does not support third-party grants. +pub async fn update_user_invite_code_request( + jar: CookieJar, + Extension(data): Extension, + Json(req): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + if req.invite_code.is_empty() { + return Json(Error::MiscError("Missing invite code".to_string()).into()); + } + + let invite_code = match data.get_invite_code_by_code(&req.invite_code).await { + Ok(c) => c, + Err(e) => return Json(e.into()), + }; + + if invite_code.is_used { + return Json(Error::MiscError("This code has already been used".to_string()).into()); + } + + if let Err(e) = data.update_invite_code_is_used(invite_code.id, true).await { + return Json(e.into()); + } + + match data + .update_user_invite_code(user.id, invite_code.id as i64) + .await + { + Ok(_) => { + match data + .update_user_awaiting_purchased_status(user.id, false, user, false) + .await + { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Invite code updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } + } + Err(e) => Json(e.into()), + } +} diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index f207f1c..19a17ca 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -331,6 +331,10 @@ pub fn routes() -> Router { "/auth/user/{id}/verified", post(auth::profile::update_user_is_verified_request), ) + .route( + "/auth/user/{id}/awaiting_purchase", + post(auth::profile::update_user_awaiting_purchase_request), + ) .route( "/auth/user/{id}/totp", post(auth::profile::enable_totp_request), @@ -394,6 +398,10 @@ pub fn routes() -> Router { "/auth/user/me/achievement", post(auth::profile::self_serve_achievement_request), ) + .route( + "/auth/user/me/invite_code", + post(auth::profile::update_user_invite_code_request), + ) // apps .route("/apps", post(apps::create_request)) .route("/apps/{id}/title", post(apps::update_title_request)) @@ -643,6 +651,12 @@ pub struct RegisterProps { pub captcha_response: String, #[serde(default)] pub invite_code: String, + /// If this is true, invite_code should be empty. + /// + /// If invite codes are enabled, but purchase is false, the invite_code MUST + /// be checked and MUST be valid. + #[serde(default)] + pub purchase: bool, } #[derive(Deserialize)] @@ -750,6 +764,11 @@ pub struct UpdateUserIsVerified { pub is_verified: bool, } +#[derive(Deserialize)] +pub struct UpdateUserAwaitingPurchase { + pub awaiting_purchase: bool, +} + #[derive(Deserialize)] pub struct UpdateNotificationRead { pub read: bool, @@ -775,6 +794,11 @@ pub struct UpdateSecondaryUserRole { pub role: SecondaryPermission, } +#[derive(Deserialize)] +pub struct UpdateUserInviteCode { + pub invite_code: String, +} + #[derive(Deserialize)] pub struct DeleteUser { pub password: String, diff --git a/crates/app/src/routes/assets.rs b/crates/app/src/routes/assets.rs index 4a450c5..2e66c19 100644 --- a/crates/app/src/routes/assets.rs +++ b/crates/app/src/routes/assets.rs @@ -19,3 +19,4 @@ serve_asset!(atto_js_request: ATTO_JS("text/javascript")); serve_asset!(me_js_request: ME_JS("text/javascript")); serve_asset!(streams_js_request: STREAMS_JS("text/javascript")); serve_asset!(carp_js_request: CARP_JS("text/javascript")); +serve_asset!(layout_editor_js_request: LAYOUT_EDITOR_JS("text/javascript")); diff --git a/crates/app/src/routes/mod.rs b/crates/app/src/routes/mod.rs index d67dc0c..80f5cbe 100644 --- a/crates/app/src/routes/mod.rs +++ b/crates/app/src/routes/mod.rs @@ -20,6 +20,10 @@ pub fn routes(config: &Config) -> Router { .route("/js/me.js", get(assets::me_js_request)) .route("/js/streams.js", get(assets::streams_js_request)) .route("/js/carp.js", get(assets::carp_js_request)) + .route( + "/js/layout_editor.js", + get(assets::layout_editor_js_request), + ) .nest_service( "/public", get_service(tower_http::services::ServeDir::new(&config.dirs.assets)), diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs index 7de4cfb..3a3e7d6 100644 --- a/crates/core/src/config.rs +++ b/crates/core/src/config.rs @@ -192,6 +192,8 @@ pub struct StripeConfig { /// /// pub billing_portal_url: String, + /// The text representation of the price of supporter. (like `$4 USD`) + pub supporter_price_text: String, } /// Manuals config (search help, etc) diff --git a/crates/core/src/database/auth.rs b/crates/core/src/database/auth.rs index f6fb848..b6a820f 100644 --- a/crates/core/src/database/auth.rs +++ b/crates/core/src/database/auth.rs @@ -112,6 +112,8 @@ impl DataManager { invite_code: get!(x->21(i64)) as usize, secondary_permissions: SecondaryPermission::from_bits(get!(x->22(i32)) as u32).unwrap(), achievements: serde_json::from_str(&get!(x->23(String)).to_string()).unwrap(), + awaiting_purchase: get!(x->24(i32)) as i8 == 1, + was_purchased: get!(x->25(i32)) as i8 == 1, } } @@ -267,7 +269,7 @@ impl DataManager { let res = execute!( &conn, - "INSERT INTO users VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24)", + "INSERT INTO users VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26)", params![ &(data.id as i64), &(data.created as i64), @@ -277,7 +279,7 @@ impl DataManager { &serde_json::to_string(&data.settings).unwrap(), &serde_json::to_string(&data.tokens).unwrap(), &(FinePermission::DEFAULT.bits() as i32), - &(if data.is_verified { 1_i32 } else { 0_i32 }), + &if data.is_verified { 1_i32 } else { 0_i32 }, &0_i32, &0_i32, &0_i32, @@ -293,6 +295,8 @@ impl DataManager { &(data.invite_code as i64), &(SecondaryPermission::DEFAULT.bits() as i32), &serde_json::to_string(&data.achievements).unwrap(), + &if data.awaiting_purchase { 1_i32 } else { 0_i32 }, + &if data.was_purchased { 1_i32 } else { 0_i32 }, ] ); @@ -688,6 +692,52 @@ impl DataManager { Ok(()) } + pub async fn update_user_awaiting_purchased_status( + &self, + id: usize, + x: bool, + user: User, + require_permission: bool, + ) -> Result<()> { + if (user.id != id) | require_permission { + if !user.permissions.check(FinePermission::MANAGE_USERS) { + return Err(Error::NotAllowed); + } + } + + let other_user = self.get_user_by_id(id).await?; + + let conn = match self.0.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = execute!( + &conn, + "UPDATE users SET awaiting_purchase = $1 WHERE id = $2", + params![&{ if x { 1 } else { 0 } }, &(id as i64)] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + self.cache_clear_user(&other_user).await; + + // create audit log entry + self.create_audit_log_entry(AuditLogEntry::new( + user.id, + format!( + "invoked `update_user_purchased_status` with x value `{}` and y value `{}`", + other_user.id, x + ), + )) + .await?; + + // ... + Ok(()) + } + pub async fn seen_user(&self, user: &User) -> Result<()> { let conn = match self.0.connect().await { Ok(c) => c, @@ -923,6 +973,7 @@ impl DataManager { auto_method!(update_user_connections(UserConnections)@get_user_by_id -> "UPDATE users SET connections = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_user); auto_method!(update_user_associated(Vec)@get_user_by_id -> "UPDATE users SET associated = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_user); auto_method!(update_user_achievements(Vec)@get_user_by_id -> "UPDATE users SET achievements = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_user); + auto_method!(update_user_invite_code(i64)@get_user_by_id -> "UPDATE users SET invite_code = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_user); auto_method!(get_user_by_stripe_id(&str)@get_user_from_row -> "SELECT * FROM users WHERE stripe_id = $1" --name="user" --returns=User); auto_method!(update_user_stripe_id(&str)@get_user_by_id -> "UPDATE users SET stripe_id = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_user); diff --git a/crates/core/src/database/drivers/sql/create_users.sql b/crates/core/src/database/drivers/sql/create_users.sql index 9cb0851..3257a2d 100644 --- a/crates/core/src/database/drivers/sql/create_users.sql +++ b/crates/core/src/database/drivers/sql/create_users.sql @@ -21,5 +21,7 @@ CREATE TABLE IF NOT EXISTS users ( grants TEXT NOT NULL, associated TEXT NOT NULL, secondary_permissions INT NOT NULL, - achievements TEXT NOT NULL + achievements TEXT NOT NULL, + awaiting_purchase INT NOT NULL, + was_purchased INT NOT NULL ) diff --git a/crates/core/src/database/invite_codes.rs b/crates/core/src/database/invite_codes.rs index 2c6d950..ba31155 100644 --- a/crates/core/src/database/invite_codes.rs +++ b/crates/core/src/database/invite_codes.rs @@ -20,8 +20,8 @@ impl DataManager { } } - auto_method!(get_invite_code_by_id()@get_invite_code_from_row -> "SELECT * FROM invite_codes WHERE id = $1" --name="invite_code" --returns=InviteCode --cache-key-tmpl="atto.invite_code:{}"); - auto_method!(get_invite_code_by_code(&str)@get_invite_code_from_row -> "SELECT * FROM invite_codes WHERE code = $1" --name="invite_code" --returns=InviteCode); + auto_method!(get_invite_code_by_id()@get_invite_code_from_row -> "SELECT * FROM invite_codes WHERE id = $1" --name="invite code" --returns=InviteCode --cache-key-tmpl="atto.invite_code:{}"); + auto_method!(get_invite_code_by_code(&str)@get_invite_code_from_row -> "SELECT * FROM invite_codes WHERE code = $1" --name="invite code" --returns=InviteCode); /// Get invite_codes by `owner`. pub async fn get_invite_codes_by_owner( @@ -96,23 +96,22 @@ impl DataManager { const MAXIMUM_FREE_INVITE_CODES: usize = 4; const MAXIMUM_SUPPORTER_INVITE_CODES: usize = 48; - const MINIMUM_ACCOUNT_AGE_FOR_FREE_INVITE_CODES: usize = 2_629_800_000; // 1mo + const MINIMUM_ACCOUNT_AGE_FOR_INVITE_CODES: usize = 2_629_800_000; // 1mo /// Create a new invite_code in the database. /// /// # Arguments /// * `data` - a mock [`InviteCode`] object to insert pub async fn create_invite_code(&self, data: InviteCode, user: &User) -> Result { - if !user.permissions.check(FinePermission::SUPPORTER) { - // check account creation date - if unix_epoch_timestamp() - user.created - < Self::MINIMUM_ACCOUNT_AGE_FOR_FREE_INVITE_CODES - { - return Err(Error::MiscError( - "Your account is too young to do this".to_string(), - )); - } + // check account creation date + if unix_epoch_timestamp() - user.created < Self::MINIMUM_ACCOUNT_AGE_FOR_INVITE_CODES { + return Err(Error::MiscError( + "Your account is too young to do this".to_string(), + )); + } + // ... + if !user.permissions.check(FinePermission::SUPPORTER) { // our account is old enough, but we need to make sure we don't already have // 2 invite codes if (self.get_invite_codes_by_owner_count(user.id).await? as usize) diff --git a/crates/core/src/model/auth.rs b/crates/core/src/model/auth.rs index bac4ae6..91b67d9 100644 --- a/crates/core/src/model/auth.rs +++ b/crates/core/src/model/auth.rs @@ -61,6 +61,15 @@ pub struct User { /// Users collect achievements through little actions across the site. #[serde(default)] pub achievements: Vec, + /// If the account was registered as a "bought" account, the user should not + /// be allowed to actually use the account if they haven't paid for supporter yet. + #[serde(default)] + pub awaiting_purchase: bool, + /// This value cannot be changed after account creation. This value is used to + /// lock the user's account again if the subscription is cancelled and they haven't + /// used an invite code. + #[serde(default)] + pub was_purchased: bool, } pub type UserConnections = @@ -319,6 +328,8 @@ impl User { invite_code: 0, secondary_permissions: SecondaryPermission::DEFAULT, achievements: Vec::new(), + awaiting_purchase: false, + was_purchased: false, } } diff --git a/sql_changes/users_awaiting_purchase.sql b/sql_changes/users_awaiting_purchase.sql new file mode 100644 index 0000000..5d2d565 --- /dev/null +++ b/sql_changes/users_awaiting_purchase.sql @@ -0,0 +1,5 @@ +ALTER TABLE users +ADD COLUMN awaiting_purchase INT NOT NULL DEFAULT 0; + +ALTER TABLE users +ADD COLUMN was_purchased INT NOT NULL DEFAULT 0;