diff --git a/.env.example b/.env.example
index 510658e7..9fcb7ecf 100644
--- a/.env.example
+++ b/.env.example
@@ -25,5 +25,34 @@ APP_DOMAIN=
# The app uses Sentry to monitor errors and performance. In reality, you likely don't need this unless you're deploying Maybe to many users.
SENTRY_DSN=
-# Used to enable specific features unique to the hosted version of Maybe. There's a very high likelihood that you don't need to change this value.
-HOSTED=false
\ No newline at end of file
+# If enabled, an invite code generated by `rake invites:create` is required to sign up as a new user.
+# This is useful for controlling who can sign up for your Maybe instance.
+REQUIRE_INVITE_CODE=false
+
+# Enables self hosting features
+SELF_HOSTING_ENABLED=false
+
+# The hosting platform used to deploy the app (e.g. "render")
+# `localhost` (or unset) is used for local development and testing
+HOSTING_PLATFORM=localhost
+
+# ======================================================================================================
+# Upgrades Module - responsible for triggering upgrade alerts, prompts, and auto-upgrade functionality
+# ======================================================================================================
+#
+# UPGRADES_ENABLED: Enables Upgrader class functionality.
+# UPGRADES_MODE: Controls how the app will upgrade. `manual` means the user must manually upgrade the app. `auto` means the app will upgrade automatically (great for self-hosting)
+# UPGRADES_TARGET: Controls what the app will upgrade to. `release` means the app will upgrade to the latest release. `commit` means the app will upgrade to the latest commit.
+#
+UPGRADES_ENABLED=false # unless editing the flow, you should keep this `false` locally in development
+UPGRADES_MODE=manual # `manual` or `auto`
+UPGRADES_TARGET=release # `release` or `commit`
+
+
+# ======================================================================================================
+# Git Repository Module - responsible for fetching latest commit data for upgrades
+# ======================================================================================================
+#
+GITHUB_REPO_OWNER=maybe-finance
+GITHUB_REPO_NAME=maybe
+GITHUB_REPO_BRANCH=main
diff --git a/Gemfile b/Gemfile
index 083f8a91..718e0142 100644
--- a/Gemfile
+++ b/Gemfile
@@ -33,6 +33,8 @@ gem "ransack"
gem "stackprof"
gem "sentry-ruby"
gem "sentry-rails"
+gem "rails-settings-cached"
+gem "octokit"
# Other
gem "bcrypt", "~> 3.1.7"
diff --git a/Gemfile.lock b/Gemfile.lock
index 1b60a41c..fe596b3b 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -254,6 +254,10 @@ GEM
racc (~> 1.4)
nokogiri (1.16.3-x86_64-linux)
racc (~> 1.4)
+ octokit (8.1.0)
+ base64
+ faraday (>= 1, < 3)
+ sawyer (~> 0.9)
pagy (8.0.2)
parallel (1.24.0)
parser (3.3.0.5)
@@ -291,6 +295,9 @@ GEM
rails-i18n (7.0.8)
i18n (>= 0.7, < 2)
railties (>= 6.0.0, < 8)
+ rails-settings-cached (2.9.4)
+ activerecord (>= 5.0.0)
+ railties (>= 5.0.0)
rainbow (3.1.1)
rake (13.2.1)
ransack (4.1.1)
@@ -349,6 +356,9 @@ GEM
ruby-progressbar (1.13.0)
ruby2_keywords (0.0.5)
rubyzip (2.3.2)
+ sawyer (0.9.2)
+ addressable (>= 2.3.5)
+ faraday (>= 0.17.3, < 3)
selenium-webdriver (4.19.0)
base64 (~> 0.2)
rexml (~> 3.2, >= 3.2.5)
@@ -435,11 +445,13 @@ DEPENDENCIES
letter_opener
lucide-rails!
mocha
+ octokit
pagy
pg (~> 1.5)
propshaft
puma (>= 5.0)
rails!
+ rails-settings-cached
ransack
redis (>= 4.0.1)
rubocop-rails-omakase
diff --git a/README.md b/README.md
index 3ed41481..7c3ecb81 100644
--- a/README.md
+++ b/README.md
@@ -17,6 +17,19 @@ We spent the better part of $1,000,000 building the app (employees + contractors
We're now reviving the product as a fully open-source project. The goal is to let you run the app yourself, for free, and use it to manage your own finances and eventually offer a hosted version of the app for a small monthly fee.
+## Self Hosting
+
+You can find [detailed setup guides for self hosting here](docs/self-hosting.md).
+
+### One-Click Render deploy (recommended)
+
+
+
+
+
+1. Click the button above
+2. Follow the instructions in the [Render self-hosting guide](docs/self-hosting/render.md)
+
## Local Development Setup
### Requirements
@@ -80,14 +93,6 @@ Before contributing, you'll likely find it helpful to [understand context and ge
Once you've done that, please visit our [contributing guide](https://github.com/maybe-finance/maybe/blob/main/CONTRIBUTING.md) to get started!
-## Self Hosting
-
-Our long term goal is to make self-hosting as easy as possible. That said, during these early stages of building the product, we are focusing our efforts on development.
-
-We will update this section as we get closer to an initial release.
-
-Please see our [guide on self hosting here](https://github.com/maybe-finance/maybe/wiki/Self-Hosting-Setup-Guide).
-
## Repo Activity

diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index f8d4686a..d37580ac 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -1,5 +1,5 @@
class ApplicationController < ActionController::Base
- include Authentication
+ include Authentication, Invitable, SelfHostable
include Pagy::Backend
before_action :sync_accounts
@@ -11,11 +11,6 @@ class ApplicationController < ActionController::Base
private
- def hosted_app?
- ENV["HOSTED"] == "true"
- end
- helper_method :hosted_app?
-
def sync_accounts
return if Current.user.blank?
diff --git a/app/controllers/concerns/invitable.rb b/app/controllers/concerns/invitable.rb
new file mode 100644
index 00000000..397c7a68
--- /dev/null
+++ b/app/controllers/concerns/invitable.rb
@@ -0,0 +1,12 @@
+module Invitable
+ extend ActiveSupport::Concern
+
+ included do
+ helper_method :invite_code_required?
+ end
+
+ private
+ def invite_code_required?
+ ENV["REQUIRE_INVITE_CODE"] == "true"
+ end
+end
diff --git a/app/controllers/concerns/self_hostable.rb b/app/controllers/concerns/self_hostable.rb
new file mode 100644
index 00000000..1573e533
--- /dev/null
+++ b/app/controllers/concerns/self_hostable.rb
@@ -0,0 +1,12 @@
+module SelfHostable
+ extend ActiveSupport::Concern
+
+ included do
+ helper_method :self_hosted?
+ end
+
+ private
+ def self_hosted?
+ ENV["SELF_HOSTING_ENABLED"] == "true"
+ end
+end
diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb
index f8e84f4b..d1049ff3 100644
--- a/app/controllers/registrations_controller.rb
+++ b/app/controllers/registrations_controller.rb
@@ -4,7 +4,7 @@ class RegistrationsController < ApplicationController
layout "auth"
before_action :set_user, only: :create
- before_action :claim_invite_code, only: :create, if: :hosted_app?
+ before_action :claim_invite_code, only: :create, if: :invite_code_required?
def new
@user = User.new
diff --git a/app/controllers/settings/self_hosting_controller.rb b/app/controllers/settings/self_hosting_controller.rb
new file mode 100644
index 00000000..7bd71791
--- /dev/null
+++ b/app/controllers/settings/self_hosting_controller.rb
@@ -0,0 +1,46 @@
+class Settings::SelfHostingController < ApplicationController
+ before_action :verify_self_hosting_enabled
+
+ def edit
+ end
+
+ def update
+ if all_updates_valid?
+ self_hosting_params.keys.each do |key|
+ Setting.send("#{key}=", self_hosting_params[key].strip)
+ end
+
+ redirect_to edit_settings_self_hosting_path, notice: t(".success")
+ else
+ flash.now[:error] = @errors.first.message
+ render :edit, status: :unprocessable_entity
+ end
+ end
+
+ private
+ def all_updates_valid?
+ @errors = ActiveModel::Errors.new(Setting)
+ self_hosting_params.keys.each do |key|
+ setting = Setting.new(var: key)
+ setting.value = self_hosting_params[key].strip
+
+ unless setting.valid?
+ @errors.merge!(setting.errors)
+ end
+ end
+
+ if self_hosting_params[:upgrades_mode] == "auto" && self_hosting_params[:render_deploy_hook].blank?
+ @errors.add(:render_deploy_hook, t("settings.self_hosting.update.render_deploy_hook_error"))
+ end
+
+ @errors.empty?
+ end
+
+ def self_hosting_params
+ params.require(:setting).permit(:render_deploy_hook, :upgrades_mode, :upgrades_target)
+ end
+
+ def verify_self_hosting_enabled
+ head :not_found unless self_hosted?
+ end
+end
diff --git a/app/controllers/upgrades_controller.rb b/app/controllers/upgrades_controller.rb
new file mode 100644
index 00000000..baf4cd8d
--- /dev/null
+++ b/app/controllers/upgrades_controller.rb
@@ -0,0 +1,56 @@
+class UpgradesController < ApplicationController
+ before_action :verify_upgrades_enabled
+
+ def acknowledge
+ commit_sha = params[:id]
+ upgrade = Upgrader.find_upgrade(commit_sha)
+
+ if upgrade
+ if upgrade.available?
+ Current.user.acknowledge_upgrade_prompt(upgrade.commit_sha)
+ flash[:notice] = t(".upgrade_dismissed")
+ elsif upgrade.complete?
+ Current.user.acknowledge_upgrade_alert(upgrade.commit_sha)
+ flash[:notice] = t(".upgrade_complete_dismiss")
+ else
+ flash[:alert] = t(".upgrade_not_available")
+ end
+ else
+ flash[:alert] = t(".upgrade_not_found")
+ end
+
+ redirect_back(fallback_location: root_path)
+ end
+
+ def deploy
+ commit_sha = params[:id]
+ upgrade = Upgrader.find_upgrade(commit_sha)
+
+ unless upgrade
+ flash[:alert] = t(".upgrade_not_found")
+ return redirect_back(fallback_location: root_path)
+ end
+
+ prior_acknowledged_upgrade_commit_sha = Current.user.last_prompted_upgrade_commit_sha
+
+ # Optimistically acknowledge the upgrade prompt
+ Current.user.acknowledge_upgrade_prompt(upgrade.commit_sha)
+
+ upgrade_result = Upgrader.upgrade_to(upgrade)
+
+ if upgrade_result[:success]
+ flash[:notice] = upgrade_result[:message]
+ else
+ # If the upgrade fails, revert to the prior acknowledged upgrade
+ Current.user.acknowledge_upgrade_prompt(prior_acknowledged_upgrade_commit_sha)
+ flash[:alert] = upgrade_result[:message]
+ end
+
+ redirect_back(fallback_location: root_path)
+ end
+
+ private
+ def verify_upgrades_enabled
+ head :not_found unless ENV["UPGRADES_ENABLED"] == "true"
+ end
+end
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index 17b3be1f..c39022b5 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -37,7 +37,6 @@ module ApplicationHelper
render partial: "shared/sidebar_modal", locals: { content: content }
end
-
def sidebar_link_to(name, path, options = {})
base_class_names = [ "block", "border", "border-transparent", "rounded-xl", "-ml-2", "p-2", "text-sm", "font-medium", "text-gray-500", "flex", "items-center" ]
hover_class_names = [ "hover:bg-white", "hover:border-alpha-black-50", "hover:text-gray-900", "hover:shadow-xs" ]
diff --git a/app/helpers/settings/self_hosting_helper.rb b/app/helpers/settings/self_hosting_helper.rb
new file mode 100644
index 00000000..7240b433
--- /dev/null
+++ b/app/helpers/settings/self_hosting_helper.rb
@@ -0,0 +1,2 @@
+module Settings::SelfHostingHelper
+end
diff --git a/app/helpers/upgrades_helper.rb b/app/helpers/upgrades_helper.rb
new file mode 100644
index 00000000..4361ae90
--- /dev/null
+++ b/app/helpers/upgrades_helper.rb
@@ -0,0 +1,13 @@
+module UpgradesHelper
+ def upgrade_notification
+ return nil unless ENV["UPGRADES_ENABLED"] == "true"
+
+ completed_upgrade = Upgrader.completed_upgrade
+ return completed_upgrade if completed_upgrade && Current.user.last_alerted_upgrade_commit_sha != completed_upgrade.commit_sha
+
+ available_upgrade = Upgrader.available_upgrade
+ if available_upgrade && Setting.upgrades_mode == "manual" && Current.user.last_prompted_upgrade_commit_sha != available_upgrade.commit_sha
+ available_upgrade
+ end
+ end
+end
diff --git a/app/jobs/auto_upgrade_job.rb b/app/jobs/auto_upgrade_job.rb
new file mode 100644
index 00000000..30c10bc7
--- /dev/null
+++ b/app/jobs/auto_upgrade_job.rb
@@ -0,0 +1,31 @@
+class AutoUpgradeJob < ApplicationJob
+ queue_as :default
+
+ def perform(*args)
+ raise_if_disabled
+
+ return Rails.logger.info "Skipping auto-upgrades because app is set to manual upgrades. Please set UPGRADES_MODE=auto to enable auto-upgrades" if Setting.upgrades_mode == "manual"
+
+ Rails.logger.info "Searching for available auto-upgrades..."
+
+ candidate = Upgrader.available_upgrade_by_type(Setting.upgrades_target)
+
+ if candidate
+ if Rails.cache.read("last_auto_upgrade_commit_sha") == candidate.commit_sha
+ Rails.logger.info "Skipping auto upgrade: #{candidate.type} #{candidate.commit_sha} deploy in progress"
+ return
+ end
+
+ Rails.logger.info "Auto upgrading to #{candidate.type} #{candidate.commit_sha}..."
+ Upgrader.upgrade_to(candidate)
+ Rails.cache.write("last_auto_upgrade_commit_sha", candidate.commit_sha, expires_in: 1.day)
+ else
+ Rails.logger.info "No auto upgrade available at this time"
+ end
+ end
+
+ private
+ def raise_if_disabled
+ raise "Upgrades module is disabled. Please set UPGRADES_ENABLED=true to enable upgrade features" unless ENV["UPGRADES_ENABLED"] == "true"
+ end
+end
diff --git a/app/models/concerns/providable.rb b/app/models/concerns/providable.rb
index 24f82402..9b80bbf8 100644
--- a/app/models/concerns/providable.rb
+++ b/app/models/concerns/providable.rb
@@ -9,5 +9,9 @@ module Providable
def exchange_rates_provider
Provider::Synth.new
end
+
+ def git_repository_provider
+ Provider::Github.new
+ end
end
end
diff --git a/app/models/provider/github.rb b/app/models/provider/github.rb
new file mode 100644
index 00000000..ff00794c
--- /dev/null
+++ b/app/models/provider/github.rb
@@ -0,0 +1,47 @@
+class Provider::Github
+ attr_reader :name, :owner, :branch
+
+ def initialize(config = {})
+ @name = config[:name] || ENV.fetch("GITHUB_REPO_NAME", "maybe")
+ @owner = config[:owner] || ENV.fetch("GITHUB_REPO_OWNER", "maybe-finance")
+ @branch = config[:branch] || ENV.fetch("GITHUB_REPO_BRANCH", "main")
+ end
+
+ def fetch_latest_upgrade_candidates
+ Rails.cache.fetch("latest_github_upgrade_candidates", expires_in: 2.minutes) do
+ Rails.logger.info "Fetching latest GitHub upgrade candidates from #{repo} on branch #{branch}..."
+ begin
+ latest_release = Octokit.releases(repo).first
+ latest_version = latest_release ? Semver.from_release_tag(latest_release.tag_name) : Semver.new(Maybe.version)
+ latest_commit = Octokit.branch(repo, branch)
+
+ release_info = if latest_release
+ {
+ version: latest_version,
+ url: latest_release.html_url,
+ commit_sha: Octokit.commit(repo, latest_release.tag_name).sha
+ }
+ end
+
+ commit_info = {
+ version: latest_version,
+ commit_sha: latest_commit.commit.sha,
+ url: latest_commit.commit.html_url
+ }
+
+ {
+ release: release_info,
+ commit: commit_info
+ }
+ rescue => e
+ Rails.logger.error "Failed to fetch latest GitHub commits: #{e.message}"
+ nil
+ end
+ end
+ end
+
+ private
+ def repo
+ "#{owner}/#{name}"
+ end
+end
diff --git a/app/models/setting.rb b/app/models/setting.rb
new file mode 100644
index 00000000..4e73bef2
--- /dev/null
+++ b/app/models/setting.rb
@@ -0,0 +1,19 @@
+# Dynamic settings the user can change within the app (helpful for self-hosting)
+class Setting < RailsSettings::Base
+ cache_prefix { "v1" }
+
+ field :render_deploy_hook,
+ type: :string,
+ default: ENV["RENDER_DEPLOY_HOOK"],
+ validates: { allow_blank: true, format: { with: /\Ahttps:\/\/api\.render\.com\/deploy\/srv-.+\z/ } }
+
+ field :upgrades_mode,
+ type: :string,
+ default: ENV.fetch("UPGRADES_MODE", "manual"),
+ validates: { inclusion: { in: %w[manual auto] } }
+
+ field :upgrades_target,
+ type: :string,
+ default: ENV.fetch("UPGRADES_TARGET", "release"),
+ validates: { inclusion: { in: %w[release commit] } }
+end
diff --git a/app/models/upgrader.rb b/app/models/upgrader.rb
new file mode 100644
index 00000000..605c7da8
--- /dev/null
+++ b/app/models/upgrader.rb
@@ -0,0 +1,57 @@
+class Upgrader
+ include Provided
+
+ class << self
+ attr_writer :config
+
+ def config
+ @config ||= Config.new
+ end
+
+ def upgrade_to(commit_or_upgrade)
+ upgrade = commit_or_upgrade.is_a?(String) ? find_upgrade(commit_or_upgrade) : commit_or_upgrade
+ config.deployer.deploy(upgrade)
+ end
+
+ def find_upgrade(commit)
+ upgrade_candidates.find { |candidate| candidate.commit_sha == commit }
+ end
+
+ def available_upgrade
+ available_upgrades.first
+ end
+
+ # Default to showing releases first, then commits
+ def completed_upgrade
+ completed_upgrades.find { |upgrade| upgrade.type == "release" } || completed_upgrades.first
+ end
+
+ def available_upgrade_by_type(type)
+ if type == "commit"
+ commit_upgrade = available_upgrades.find { |upgrade| upgrade.type == "commit" }
+ commit_upgrade || available_upgrades.first
+ elsif type == "release"
+ available_upgrades.find { |upgrade| upgrade.type == "release" }
+ end
+ end
+
+ private
+ def available_upgrades
+ upgrade_candidates.select(&:available?)
+ end
+
+ def completed_upgrades
+ upgrade_candidates.select(&:complete?)
+ end
+
+ def upgrade_candidates
+ latest_candidates = fetch_latest_upgrade_candidates_from_provider
+ return [] unless latest_candidates
+
+ commit_candidate = Upgrade.new("commit", latest_candidates[:commit])
+ release_candidate = latest_candidates[:release] && Upgrade.new("release", latest_candidates[:release])
+
+ [ release_candidate, commit_candidate ].compact.uniq { |candidate| candidate.commit_sha }
+ end
+ end
+end
diff --git a/app/models/upgrader/config.rb b/app/models/upgrader/config.rb
new file mode 100644
index 00000000..a78e51e7
--- /dev/null
+++ b/app/models/upgrader/config.rb
@@ -0,0 +1,17 @@
+class Upgrader::Config
+ attr_reader :env, :options
+
+ def initialize(options = {}, env: ENV)
+ @env = env
+ @options = options
+ end
+
+ def deployer
+ factory = Upgrader::Deployer
+ factory.for(hosting_platform)
+ end
+
+ def hosting_platform
+ options[:hosting_platform] || env["HOSTING_PLATFORM"]
+ end
+end
diff --git a/app/models/upgrader/deployer.rb b/app/models/upgrader/deployer.rb
new file mode 100644
index 00000000..cb12992e
--- /dev/null
+++ b/app/models/upgrader/deployer.rb
@@ -0,0 +1,12 @@
+class Upgrader::Deployer
+ def self.for(platform)
+ case platform
+ when nil, "localhost"
+ Upgrader::Deployer::Null.new
+ when "render"
+ Upgrader::Deployer::Render.new
+ else
+ raise "Unknown platform: #{platform}"
+ end
+ end
+end
diff --git a/app/models/upgrader/deployer/null.rb b/app/models/upgrader/deployer/null.rb
new file mode 100644
index 00000000..d4d217fa
--- /dev/null
+++ b/app/models/upgrader/deployer/null.rb
@@ -0,0 +1,8 @@
+class Upgrader::Deployer::Null
+ def deploy(upgrade)
+ {
+ success: true,
+ message: I18n.t("upgrader.deployer.null_deployer.success_message")
+ }
+ end
+end
diff --git a/app/models/upgrader/deployer/render.rb b/app/models/upgrader/deployer/render.rb
new file mode 100644
index 00000000..5df86857
--- /dev/null
+++ b/app/models/upgrader/deployer/render.rb
@@ -0,0 +1,41 @@
+class Upgrader::Deployer::Render
+ def deploy(upgrade)
+ if Setting.render_deploy_hook.blank?
+ return {
+ success: false,
+ message: I18n.t("upgrader.deployer.render.error_message_not_set"),
+ troubleshooting_url: "/settings/self_hosting/edit"
+ }
+ end
+
+ Rails.logger.info I18n.t("upgrader.deployer.render.deploy_log_info", type: upgrade.type, commit_sha: upgrade.commit_sha)
+
+ begin
+ uri = URI.parse(Setting.render_deploy_hook)
+ uri.query = [ uri.query, "ref=#{upgrade.commit_sha}" ].compact.join("&")
+ response = Faraday.post(uri.to_s)
+
+ unless response.success?
+ Rails.logger.error I18n.t("upgrader.deployer.render.deploy_log_error", type: upgrade.type, commit_sha: upgrade.commit_sha, error_message: response.body)
+ return default_error_response
+ end
+
+ {
+ success: true,
+ message: I18n.t("upgrader.deployer.render.success_message", commit_sha: upgrade.commit_sha.slice(0, 7))
+ }
+ rescue => e
+ Rails.logger.error I18n.t("upgrader.deployer.render.deploy_log_error", type: upgrade.type, commit_sha: upgrade.commit_sha, error_message: e.message)
+ default_error_response
+ end
+ end
+
+ private
+ def default_error_response
+ {
+ success: false,
+ message: I18n.t("upgrader.deployer.render.error_message_failed_deploy"),
+ troubleshooting_url: I18n.t("upgrader.deployer.render.troubleshooting_url")
+ }
+ end
+end
diff --git a/app/models/upgrader/provided.rb b/app/models/upgrader/provided.rb
new file mode 100644
index 00000000..4b518e51
--- /dev/null
+++ b/app/models/upgrader/provided.rb
@@ -0,0 +1,11 @@
+module Upgrader::Provided
+ extend ActiveSupport::Concern
+ include Providable
+
+ class_methods do
+ private
+ def fetch_latest_upgrade_candidates_from_provider
+ git_repository_provider.fetch_latest_upgrade_candidates
+ end
+ end
+end
diff --git a/app/models/upgrader/upgrade.rb b/app/models/upgrader/upgrade.rb
new file mode 100644
index 00000000..26b09226
--- /dev/null
+++ b/app/models/upgrader/upgrade.rb
@@ -0,0 +1,27 @@
+class Upgrader::Upgrade
+ attr_reader :type, :commit_sha, :version, :url
+
+ def initialize(type, data)
+ @type = %w[release commit].include?(type) ? type : raise(ArgumentError, "Type must be either 'release' or 'commit'")
+ @commit_sha = data[:commit_sha]
+ @version = normalize_version(data[:version])
+ @url = data[:url]
+ end
+
+ def complete?
+ commit_sha == Maybe.commit_sha
+ end
+
+ def available?
+ version > Maybe.version || (version == Maybe.version && commit_sha != Maybe.commit_sha)
+ end
+
+ def to_s
+ type == "release" ? version.to_release_tag : "#{commit_sha.first(7)} (pre-release)"
+ end
+
+ private
+ def normalize_version(version)
+ version.is_a?(Semver) ? version : Semver.new(version)
+ end
+end
diff --git a/app/models/user.rb b/app/models/user.rb
index 97d9d362..a0bd8ada 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -10,4 +10,20 @@ class User < ApplicationRecord
generates_token_for :password_reset, expires_in: 15.minutes do
password_salt&.last(10)
end
+
+ def acknowledge_upgrade_prompt(commit_sha)
+ update!(last_prompted_upgrade_commit_sha: commit_sha)
+ end
+
+ def acknowledge_upgrade_alert(commit_sha)
+ update!(last_alerted_upgrade_commit_sha: commit_sha)
+ end
+
+ def has_seen_upgrade_prompt?(upgrade)
+ last_prompted_upgrade_commit_sha == upgrade.commit_sha
+ end
+
+ def has_seen_upgrade_alert?(upgrade)
+ last_alerted_upgrade_commit_sha == upgrade.commit_sha
+ end
end
diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb
index 64d014f9..6fa4d937 100644
--- a/app/views/layouts/application.html.erb
+++ b/app/views/layouts/application.html.erb
@@ -40,7 +40,13 @@
class="hidden absolute min-w-[200px] z-10 top-10 right-0 bg-white p-1 rounded-sm shadow-xs border border-alpha-black-25 w-fit">
<%= link_to edit_settings_path, class: "flex gap-1 items-center hover:bg-gray-50 rounded-md p-2" do %>
<%= lucide_icon("pencil-line", class: "w-5 h-5 text-gray-500 shrink-0") %>
- Settings
+ General Settings
+ <% end %>
+ <% if self_hosted? %>
+ <%= link_to edit_settings_self_hosting_path, class: "flex gap-1 items-center hover:bg-gray-50 rounded-md p-2" do %>
+ <%= lucide_icon("pencil-line", class: "w-5 h-5 text-gray-500 shrink-0") %>
+ Self Host Settings
+ <% end %>
<% end %>
<%= button_to session_path, method: :delete, class: "w-full text-gray-900 flex gap-1 items-center hover:bg-gray-50 rounded-md p-2" do %>
<%= lucide_icon("log-out", class: "w-5 h-5 shrink-0") %>
@@ -86,5 +92,6 @@
<%= turbo_frame_tag "modal" %>
<%= render "shared/custom_confirm_modal" %>
+ <%= render "shared/upgrade_notification" %>