commit d04a8dd9b3ba14b3aa25a28fd3556020847e876d
Author: Celina <57647158+celina-lopez@users.noreply.github.com>
Date: Wed Sep 25 11:20:22 2024 -0700
Add files via upload
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 00000000..97dec376
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,44 @@
+### 2021-12-27
+
+* Support Rails 7.0
+* Drop support for Rails 5.2 and earlier
+
+### 2021-05-13
+
+* Upgrade to Bootstrap 5
+* Remove data-confirm-modal, not yet bootstrap 5 compatible
+* Drop jQuery
+* Include devise views directly
+
+### 2021-03-04
+
+* Switch to Madmin for admin area
+
+### 2020-10-24
+
+* Rescue from git configuration exception
+
+### 2020-08-20
+
+* Add tests for generating postgres and mysql apps
+
+### 2020-08-07
+
+* Refactor notifications to use the [Noticed gem](https://github.com/excid3/noticed)
+
+### 2019-02-28
+
+* Adds support for Rails 6.0
+* Move all Javascript to Webpacker for Rails 5.2 and 6.0
+ * Use Bootstrap, data-confirm-modal, and local-time from NPM packages
+ * ProvidePlugin sets jQuery, $, and Rails variables for webpacker
+* Use https://github.com/excid3/administrate fork of Administrate
+ * Adds fix for zeitwerk autoloader in Rails 6
+ * Adds support for virtual attributes
+* Add Procfile, Procfile.dev and .foreman configs
+* Add welcome message and instructions after completion
+
+### 2019-01-02 and before
+
+* Original version of Jumpstart
+* Supported Rails 5.2 only
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 00000000..67b94c37
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2018 Chris Oliver
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/Procfile b/Procfile
new file mode 100644
index 00000000..a9adec8f
--- /dev/null
+++ b/Procfile
@@ -0,0 +1,2 @@
+web: bin/rails server
+worker: bundle exec sidekiq
diff --git a/Procfile.dev b/Procfile.dev
new file mode 100644
index 00000000..d44b54d3
--- /dev/null
+++ b/Procfile.dev
@@ -0,0 +1,4 @@
+web: bin/rails server -p 3000
+worker: bundle exec sidekiq
+js: yarn build --reload
+css: yarn build:css --watch
diff --git a/README.md b/README.md
new file mode 100644
index 00000000..129ed8a4
--- /dev/null
+++ b/README.md
@@ -0,0 +1,111 @@
+> [!NOTE]
+> 👉 We've also built [Jumpstart Pro](https://jumpstartrails.com) which is a version of Jumpstart that includes payments with Stripe & Braintree, team accounts, TailwindCSS, and much more.
+
+# Jumpstart Rails Template
+
+All your Rails apps should start off with a bunch of great defaults. It's like Laravel Spark, for Rails.
+
+Want to see how it works? Check out [the Jumpstart walkthrough video](https://www.youtube.com/watch?v=ssOZpISfIfI):
+
+[](https://www.youtube.com/watch?v=ssOZpISfIfI)
+
+## Getting Started
+
+Jumpstart is a Rails template, so you pass it in as an option when creating a new app.
+
+#### Requirements
+
+You'll need the following installed to run the template successfully:
+
+* Ruby 2.5 or higher
+* bundler - `gem install bundler`
+* rails - `gem install rails`
+* Database - we recommend Postgres, but you can use MySQL, SQLite3, etc
+* Redis - For ActionCable support
+* ImageMagick or libvips for ActiveStorage variants
+* Yarn - `brew install yarn` or [Install Yarn](https://yarnpkg.com/en/docs/install)
+* Foreman (optional) - `gem install foreman` - helps run all your processes in development
+
+#### Creating a new app
+
+```bash
+rails new myapp -d postgresql -m https://raw.githubusercontent.com/excid3/jumpstart/master/template.rb
+```
+
+Or if you have downloaded this repo, you can reference template.rb locally:
+
+```bash
+rails new myapp -d postgresql -m template.rb
+```
+
+❓Having trouble? Try adding `DISABLE_SPRING=1` before `rails new`. Spring will get confused if you create an app with the same name twice.
+
+#### Running your app
+
+```bash
+bin/dev
+```
+
+You can also run them in separate terminals manually if you prefer.
+
+A separate `Procfile` is generated for deploying to production on Heroku.
+
+#### Authenticate with social networks
+
+We use the encrypted Rails Credentials for app_id and app_secrets when it comes to omniauth authentication. Edit them as so:
+
+```
+EDITOR=vim rails credentials:edit
+```
+
+Make sure your file follow this structure:
+
+```yml
+secret_key_base: [your-key]
+development:
+ github:
+ app_id: something
+ app_secret: something
+ options:
+ scope: 'user:email'
+ whatever: true
+production:
+ github:
+ app_id: something
+ app_secret: something
+ options:
+ scope: 'user:email'
+ whatever: true
+```
+
+With the environment, the service and the app_id/app_secret. If this is done correctly, you should see login links
+for the services you have added to the encrypted credentials using `EDITOR=vim rails credentials:edit`
+
+#### Enabling Admin Panel
+App uses `madmin` [gem](https://github.com/excid3/madmin), so you need to run the madmin generator:
+
+```
+rails g madmin:install
+```
+
+This will install Madmin and generate resources for each of the models it finds.
+#### Redis set up
+##### On OSX
+```
+brew update
+brew install redis
+brew services start redis
+```
+##### Ubuntu
+```
+sudo apt-get install redis-server
+```
+
+#### Cleaning up
+
+```bash
+rails db:drop
+spring stop
+cd ..
+rm -rf myapp
+```
diff --git a/Rakefile b/Rakefile
new file mode 100644
index 00000000..e0622f85
--- /dev/null
+++ b/Rakefile
@@ -0,0 +1,9 @@
+require "rake/testtask"
+
+Rake::TestTask.new(:test) do |t|
+ t.libs << "test"
+ t.libs << "lib"
+ t.test_files = FileList["test/**/*_test.rb"]
+end
+
+task :default => :test
diff --git a/app/assets/config/manifest.js b/app/assets/config/manifest.js
new file mode 100644
index 00000000..decf0a9d
--- /dev/null
+++ b/app/assets/config/manifest.js
@@ -0,0 +1,2 @@
+//= link_tree ../builds
+//= link_tree ../images
diff --git a/app/assets/stylesheets/jumpstart/announcements.scss b/app/assets/stylesheets/jumpstart/announcements.scss
new file mode 100644
index 00000000..a228f026
--- /dev/null
+++ b/app/assets/stylesheets/jumpstart/announcements.scss
@@ -0,0 +1,21 @@
+.announcement {
+ strong {
+ color: $gray-700;
+ font-weight: 900;
+ }
+}
+
+.unread-announcements:before {
+ -moz-border-radius: 50%;
+ -webkit-border-radius: 50%;
+ border-radius: 50%;
+ -moz-background-clip: padding-box;
+ -webkit-background-clip: padding-box;
+ background-clip: padding-box;
+ background: $red;
+ content: '';
+ display: inline-block;
+ height: 8px;
+ width: 8px;
+ margin-right: 6px;
+}
diff --git a/app/channels/application_cable/connection.rb b/app/channels/application_cable/connection.rb
new file mode 100644
index 00000000..39ed58a7
--- /dev/null
+++ b/app/channels/application_cable/connection.rb
@@ -0,0 +1,21 @@
+module ApplicationCable
+ class Connection < ActionCable::Connection::Base
+ identified_by :current_user, :true_user
+ impersonates :user
+
+ def connect
+ self.current_user = find_verified_user
+ logger.add_tags "ActionCable", "User #{current_user.id}"
+ end
+
+ protected
+
+ def find_verified_user
+ if (current_user = env['warden'].user)
+ current_user
+ else
+ reject_unauthorized_connection
+ end
+ end
+ end
+end
diff --git a/app/controllers/announcements_controller.rb b/app/controllers/announcements_controller.rb
new file mode 100644
index 00000000..1f671d1c
--- /dev/null
+++ b/app/controllers/announcements_controller.rb
@@ -0,0 +1,13 @@
+class AnnouncementsController < ApplicationController
+ before_action :mark_as_read, if: :user_signed_in?
+
+ def index
+ @announcements = Announcement.order(published_at: :desc)
+ end
+
+ private
+
+ def mark_as_read
+ current_user.update(announcements_last_read_at: Time.zone.now)
+ end
+end
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
new file mode 100644
index 00000000..ce537e97
--- /dev/null
+++ b/app/controllers/application_controller.rb
@@ -0,0 +1,15 @@
+class ApplicationController < ActionController::Base
+ impersonates :user
+ include Pundit::Authorization
+
+ protect_from_forgery with: :exception
+
+ before_action :configure_permitted_parameters, if: :devise_controller?
+
+ protected
+
+ def configure_permitted_parameters
+ devise_parameter_sanitizer.permit(:sign_up, keys: [:name])
+ devise_parameter_sanitizer.permit(:account_update, keys: [:name, :avatar])
+ end
+end
diff --git a/app/controllers/home_controller.rb b/app/controllers/home_controller.rb
new file mode 100644
index 00000000..4bf0ee77
--- /dev/null
+++ b/app/controllers/home_controller.rb
@@ -0,0 +1,10 @@
+class HomeController < ApplicationController
+ def index
+ end
+
+ def terms
+ end
+
+ def privacy
+ end
+end
diff --git a/app/controllers/madmin/impersonates_controller.rb b/app/controllers/madmin/impersonates_controller.rb
new file mode 100644
index 00000000..a329cee7
--- /dev/null
+++ b/app/controllers/madmin/impersonates_controller.rb
@@ -0,0 +1,14 @@
+class Madmin::ImpersonatesController < Madmin::ApplicationController
+ impersonates :user
+
+ def impersonate
+ user = User.find(params[:id])
+ impersonate_user(user)
+ redirect_to root_path
+ end
+
+ def stop_impersonating
+ stop_impersonating_user
+ redirect_to root_path
+ end
+end
diff --git a/app/controllers/notifications_controller.rb b/app/controllers/notifications_controller.rb
new file mode 100644
index 00000000..2a73c5c5
--- /dev/null
+++ b/app/controllers/notifications_controller.rb
@@ -0,0 +1,7 @@
+class NotificationsController < ApplicationController
+ before_action :authenticate_user!
+
+ def index
+ @notifications = current_user.notifications.includes(:event)
+ end
+end
diff --git a/app/controllers/users/omniauth_callbacks_controller.rb b/app/controllers/users/omniauth_callbacks_controller.rb
new file mode 100644
index 00000000..25046f2f
--- /dev/null
+++ b/app/controllers/users/omniauth_callbacks_controller.rb
@@ -0,0 +1,84 @@
+module Users
+ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
+ before_action :set_service, except: [:failure]
+ before_action :set_user, except: [:failure]
+
+ attr_reader :service, :user
+
+ def failure
+ redirect_to root_path, alert: "Something went wrong"
+ end
+
+ def facebook
+ handle_auth "Facebook"
+ end
+
+ def twitter
+ handle_auth "Twitter"
+ end
+
+ def github
+ handle_auth "Github"
+ end
+
+ private
+
+ def handle_auth(kind)
+ if service.present?
+ service.update(service_attrs)
+ else
+ user.services.create(service_attrs)
+ end
+
+ if user_signed_in?
+ flash[:notice] = "Your #{kind} account was connected."
+ redirect_to edit_user_registration_path
+ else
+ sign_in_and_redirect user, event: :authentication
+ set_flash_message :notice, :success, kind: kind
+ end
+ end
+
+ def auth
+ request.env['omniauth.auth']
+ end
+
+ def set_service
+ @service = Service.where(provider: auth.provider, uid: auth.uid).first
+ end
+
+ def set_user
+ if user_signed_in?
+ @user = current_user
+ elsif service.present?
+ @user = service.user
+ elsif User.where(email: auth.info.email).any?
+ # 5. User is logged out and they login to a new account which doesn't match their old one
+ flash[:alert] = "An account with this email already exists. Please sign in with that account before connecting your #{auth.provider.titleize} account."
+ redirect_to new_user_session_path
+ else
+ @user = create_user
+ end
+ end
+
+ def service_attrs
+ expires_at = auth.credentials.expires_at.present? ? Time.at(auth.credentials.expires_at) : nil
+ {
+ provider: auth.provider,
+ uid: auth.uid,
+ expires_at: expires_at,
+ access_token: auth.credentials.token,
+ access_token_secret: auth.credentials.secret,
+ }
+ end
+
+ def create_user
+ User.create(
+ email: auth.info.email,
+ #name: auth.info.name,
+ password: Devise.friendly_token[0,20]
+ )
+ end
+
+ end
+end
diff --git a/app/helpers/announcements_helper.rb b/app/helpers/announcements_helper.rb
new file mode 100644
index 00000000..224e428f
--- /dev/null
+++ b/app/helpers/announcements_helper.rb
@@ -0,0 +1,19 @@
+module AnnouncementsHelper
+ def unread_announcements(user)
+ last_announcement = Announcement.order(published_at: :desc).first
+ return if last_announcement.nil?
+
+ # Highlight announcements for anyone not logged in, cuz tempting
+ if user.nil? || user.announcements_last_read_at.nil? || user.announcements_last_read_at < last_announcement.published_at
+ "unread-announcements"
+ end
+ end
+
+ def announcement_class(type)
+ {
+ "new" => "text-success",
+ "update" => "text-warning",
+ "fix" => "text-danger",
+ }.fetch(type, "text-success")
+ end
+end
diff --git a/app/helpers/avatar_helper.rb b/app/helpers/avatar_helper.rb
new file mode 100644
index 00000000..177dfe9f
--- /dev/null
+++ b/app/helpers/avatar_helper.rb
@@ -0,0 +1,17 @@
+module AvatarHelper
+ def avatar_path(object, options = {})
+ size = options[:size] || 180
+ default_image = options[:default] || "mp"
+ base_url = "https://secure.gravatar.com/avatar"
+ base_url_params = "?s=#{size}&d=#{default_image}"
+
+ if object.respond_to?(:avatar) && object.avatar.attached? && object.avatar.variable?
+ object.avatar.variant(resize_to_fill: [size, size])
+ elsif object.respond_to?(:email) && object.email
+ gravatar_id = Digest::MD5::hexdigest(object.email.downcase)
+ "#{base_url}/#{gravatar_id}#{base_url_params}"
+ else
+ "#{base_url}/00000000000000000000000000000000#{base_url_params}"
+ end
+ end
+end
diff --git a/app/helpers/bootstrap_helper.rb b/app/helpers/bootstrap_helper.rb
new file mode 100644
index 00000000..52127e20
--- /dev/null
+++ b/app/helpers/bootstrap_helper.rb
@@ -0,0 +1,10 @@
+module BootstrapHelper
+ def bootstrap_class_for(flash_type)
+ {
+ success: "alert-success",
+ error: "alert-danger",
+ alert: "alert-warning",
+ notice: "alert-primary"
+ }.stringify_keys[flash_type.to_s] || flash_type.to_s
+ end
+end
diff --git a/app/javascript/application.js b/app/javascript/application.js
new file mode 100644
index 00000000..81d6c6a2
--- /dev/null
+++ b/app/javascript/application.js
@@ -0,0 +1,28 @@
+// This file is automatically compiled by Webpack, along with any other files
+// present in this directory. You're encouraged to place your actual application logic in
+// a relevant structure within app/javascript and only use these pack files to reference
+// that code so it'll be compiled.
+
+import "@hotwired/turbo-rails"
+require("@rails/activestorage").start()
+//require("trix")
+//require("@rails/actiontext")
+require("local-time").start()
+require("@rails/ujs").start()
+
+import './channels/**/*_channel.js'
+import "./controllers"
+
+import * as bootstrap from "bootstrap"
+
+document.addEventListener("turbo:load", () => {
+ var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'))
+ var tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) {
+ return new bootstrap.Tooltip(tooltipTriggerEl)
+ })
+
+ var popoverTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="popover"]'))
+ var popoverList = popoverTriggerList.map(function (popoverTriggerEl) {
+ return new bootstrap.Popover(popoverTriggerEl)
+ })
+})
diff --git a/app/javascript/controllers/index.js b/app/javascript/controllers/index.js
new file mode 100644
index 00000000..a538cbe0
--- /dev/null
+++ b/app/javascript/controllers/index.js
@@ -0,0 +1,6 @@
+import { application } from "./application"
+import controllers from './**/*_controller.js'
+controllers.forEach((controller) => {
+ application.register(controller.name, controller.module.default)
+})
+
diff --git a/app/models/announcement.rb b/app/models/announcement.rb
new file mode 100644
index 00000000..dbeeea2d
--- /dev/null
+++ b/app/models/announcement.rb
@@ -0,0 +1,13 @@
+class Announcement < ApplicationRecord
+ TYPES = %w{ new fix update }
+
+ after_initialize :set_defaults
+
+ validates :announcement_type, :description, :name, :published_at, presence: true
+ validates :announcement_type, inclusion: { in: TYPES }
+
+ def set_defaults
+ self.published_at ||= Time.zone.now
+ self.announcement_type ||= TYPES.first
+ end
+end
diff --git a/app/models/service.rb b/app/models/service.rb
new file mode 100644
index 00000000..fd5f8c5f
--- /dev/null
+++ b/app/models/service.rb
@@ -0,0 +1,32 @@
+class Service < ApplicationRecord
+ belongs_to :user
+
+ Devise.omniauth_configs.keys.each do |provider|
+ scope provider, ->{ where(provider: provider) }
+ end
+
+ def client
+ send("#{provider}_client")
+ end
+
+ def expired?
+ expires_at? && expires_at <= Time.zone.now
+ end
+
+ def access_token
+ send("#{provider}_refresh_token!", super) if expired?
+ super
+ end
+
+
+ def twitter_client
+ Twitter::REST::Client.new do |config|
+ config.consumer_key = Rails.application.secrets.twitter_app_id
+ config.consumer_secret = Rails.application.secrets.twitter_app_secret
+ config.access_token = access_token
+ config.access_token_secret = access_token_secret
+ end
+ end
+
+ def twitter_refresh_token!(token); end
+end
diff --git a/app/models/user.rb b/app/models/user.rb
new file mode 100644
index 00000000..704f4c54
--- /dev/null
+++ b/app/models/user.rb
@@ -0,0 +1,12 @@
+class User < ApplicationRecord
+ # Include default devise modules. Others available are:
+ # :confirmable, :lockable, :timeoutable and :omniauthable
+ devise :database_authenticatable, :registerable, :recoverable, :rememberable, :validatable, :omniauthable
+
+ has_one_attached :avatar
+ has_person_name
+
+ has_many :notifications, as: :recipient, dependent: :destroy, class_name: "Noticed::Notification"
+ has_many :notification_mentions, as: :record, dependent: :destroy, class_name: "Noticed::Event"
+ has_many :services
+end
diff --git a/app/views/announcements/index.html.erb b/app/views/announcements/index.html.erb
new file mode 100644
index 00000000..175ccf35
--- /dev/null
+++ b/app/views/announcements/index.html.erb
@@ -0,0 +1,28 @@
+
What's New
+
+
+
+ <% @announcements.each_with_index do |announcement, index| %>
+ <% if index != 0 %>
+
+ <% end %>
+
+
+
+ <%= link_to announcements_path(anchor: dom_id(announcement)) do %>
+ <%= announcement.published_at.strftime("%b %d") %>
+ <% end %>
+
+
+ <%= announcement.announcement_type.titleize %>:
+ <%= announcement.name %>
+ <%= simple_format announcement.description %>
+
+
+ <% end %>
+
+ <% if @announcements.empty? %>
+
Exciting stuff coming very soon!
+ <% end %>
+
+
diff --git a/app/views/devise/confirmations/new.html.erb b/app/views/devise/confirmations/new.html.erb
new file mode 100644
index 00000000..6575189a
--- /dev/null
+++ b/app/views/devise/confirmations/new.html.erb
@@ -0,0 +1,22 @@
+
+
+
Resend confirmation instructions
+
+ <%= form_for(resource, as: resource_name, url: confirmation_path(resource_name), html: { method: :post }) do |f| %>
+ <%= render "devise/shared/error_messages", resource: resource %>
+
+
+ <%= f.label :email, class: 'form-label' %>
+ <%= f.email_field :email, autofocus: true, class: 'form-control', value: (resource.pending_reconfirmation? ? resource.unconfirmed_email : resource.email) %>
+
+
+
+ <%= f.submit "Resend confirmation instructions", class: 'btn btn-primary btn-lg' %>
+
+ <% end %>
+
+
+ <%= render "devise/shared/links" %>
+
+
+
diff --git a/app/views/devise/mailer/confirmation_instructions.html.erb b/app/views/devise/mailer/confirmation_instructions.html.erb
new file mode 100644
index 00000000..dc55f64f
--- /dev/null
+++ b/app/views/devise/mailer/confirmation_instructions.html.erb
@@ -0,0 +1,5 @@
+Welcome <%= @email %>!
+
+You can confirm your account email through the link below:
+
+<%= link_to 'Confirm my account', confirmation_url(@resource, confirmation_token: @token) %>
diff --git a/app/views/devise/mailer/email_changed.html.erb b/app/views/devise/mailer/email_changed.html.erb
new file mode 100644
index 00000000..c9da7af4
--- /dev/null
+++ b/app/views/devise/mailer/email_changed.html.erb
@@ -0,0 +1,7 @@
+Hello <%= @email %>!
+
+<% if @resource.try(:unconfirmed_email?) %>
+ We're contacting you to notify you that your email is being changed to <%= @resource.unconfirmed_email %>.
+<% else %>
+ We're contacting you to notify you that your email has been changed to <%= @resource.email %>.
+<% end %>
diff --git a/app/views/devise/mailer/password_change.html.erb b/app/views/devise/mailer/password_change.html.erb
new file mode 100644
index 00000000..b41daf47
--- /dev/null
+++ b/app/views/devise/mailer/password_change.html.erb
@@ -0,0 +1,3 @@
+Hello <%= @resource.email %>!
+
+We're contacting you to notify you that your password has been changed.
diff --git a/app/views/devise/mailer/reset_password_instructions.html.erb b/app/views/devise/mailer/reset_password_instructions.html.erb
new file mode 100644
index 00000000..f667dc12
--- /dev/null
+++ b/app/views/devise/mailer/reset_password_instructions.html.erb
@@ -0,0 +1,8 @@
+Hello <%= @resource.email %>!
+
+Someone has requested a link to change your password. You can do this through the link below.
+
+<%= link_to 'Change my password', edit_password_url(@resource, reset_password_token: @token) %>
+
+If you didn't request this, please ignore this email.
+Your password won't change until you access the link above and create a new one.
diff --git a/app/views/devise/mailer/unlock_instructions.html.erb b/app/views/devise/mailer/unlock_instructions.html.erb
new file mode 100644
index 00000000..41e148bf
--- /dev/null
+++ b/app/views/devise/mailer/unlock_instructions.html.erb
@@ -0,0 +1,7 @@
+Hello <%= @resource.email %>!
+
+Your account has been locked due to an excessive number of unsuccessful sign in attempts.
+
+Click the link below to unlock your account:
+
+<%= link_to 'Unlock my account', unlock_url(@resource, unlock_token: @token) %>
diff --git a/app/views/devise/passwords/edit.html.erb b/app/views/devise/passwords/edit.html.erb
new file mode 100644
index 00000000..60ee31db
--- /dev/null
+++ b/app/views/devise/passwords/edit.html.erb
@@ -0,0 +1,29 @@
+
+
+
Change your password
+
+ <%= form_for(resource, as: resource_name, url: password_path(resource_name), html: { method: :put }) do |f| %>
+ <%= render "devise/shared/error_messages", resource: resource %>
+ <%= f.hidden_field :reset_password_token %>
+
+
+ <%= f.password_field :password, autofocus: true, autocomplete: "off", class: 'form-control', placeholder: "Password" %>
+ <% if @minimum_password_length %>
+
<%= @minimum_password_length %> characters minimum
+ <% end %>
+
+
+
+ <%= f.password_field :password_confirmation, autocomplete: "off", class: 'form-control', placeholder: "Confirm Password" %>
+
+
+
+ <%= f.submit "Change my password", class: 'btn btn-primary btn-lg' %>
+
+ <% end %>
+
+
+ <%= render "devise/shared/links" %>
+
+
+
diff --git a/app/views/devise/passwords/new.html.erb b/app/views/devise/passwords/new.html.erb
new file mode 100644
index 00000000..e61d4707
--- /dev/null
+++ b/app/views/devise/passwords/new.html.erb
@@ -0,0 +1,20 @@
+
+
+
Reset your password
+ <%= form_for(resource, as: resource_name, url: password_path(resource_name), html: { method: :post }) do |f| %>
+ <%= render "devise/shared/error_messages", resource: resource %>
+
Enter your email address below and we will send you a link to reset your password.
+
+
+ <%= f.email_field :email, autofocus: true, placeholder: 'Email address', class: 'form-control' %>
+
+
+
+ <%= f.submit "Send password reset email", class: 'btn btn-primary btn-lg' %>
+
+ <% end %>
+
+ <%= render "devise/shared/links" %>
+
+
+
diff --git a/app/views/devise/registrations/edit.html.erb b/app/views/devise/registrations/edit.html.erb
new file mode 100644
index 00000000..cce3da8d
--- /dev/null
+++ b/app/views/devise/registrations/edit.html.erb
@@ -0,0 +1,49 @@
+
+
+
Account
+
+ <%= form_for(resource, as: resource_name, url: registration_path(resource_name), html: { method: :put }) do |f| %>
+ <%= render "devise/shared/error_messages", resource: resource %>
+
+
+ <%= f.text_field :name, autofocus: false, class: 'form-control', placeholder: "Full name" %>
+
+
+
+ <%= f.email_field :email, class: 'form-control', placeholder: 'Email Address' %>
+
+
+
+ <%= f.label :avatar, class: "form-label" %>
+ <%= f.file_field :avatar, accept:'image/*' %>
+
+
+ <%= image_tag avatar_path(f.object), class: "rounded border shadow-sm d-block mx-auto my-3" %>
+
+ <% if devise_mapping.confirmable? && resource.pending_reconfirmation? %>
+
Currently waiting confirmation for: <%= resource.unconfirmed_email %>
+ <% end %>
+
+
+ <%= f.password_field :password, autocomplete: "off", class: 'form-control', placeholder: 'Password' %>
+
Leave password blank if you don't want to change it
+
+
+
+ <%= f.password_field :password_confirmation, autocomplete: "off", class: 'form-control', placeholder: 'Confirm Password' %>
+
+
+
+ <%= f.password_field :current_password, autocomplete: "off", class: 'form-control', placeholder: 'Current Password' %>
+
We need your current password to confirm your changes
+
+
+
+ <%= f.submit "Save Changes", class: 'btn btn-lg btn-primary' %>
+
+ <% end %>
+
+
+
<%= link_to "Deactivate my account", registration_path(resource_name), data: { confirm: "Are you sure? You cannot undo this." }, method: :delete %>
+
+
diff --git a/app/views/devise/registrations/new.html.erb b/app/views/devise/registrations/new.html.erb
new file mode 100644
index 00000000..221762a3
--- /dev/null
+++ b/app/views/devise/registrations/new.html.erb
@@ -0,0 +1,33 @@
+
+
+
Sign Up
+
+ <%= form_for(resource, as: resource_name, url: registration_path(resource_name)) do |f| %>
+ <%= render "devise/shared/error_messages", resource: resource %>
+
+
+ <%= f.text_field :name, autofocus: false, class: 'form-control', placeholder: "Full name" %>
+
+
+
+ <%= f.email_field :email, autofocus: false, class: 'form-control', placeholder: "Email Address" %>
+
+
+
+ <%= f.password_field :password, autocomplete: "off", class: 'form-control', placeholder: 'Password' %>
+
+
+
+ <%= f.password_field :password_confirmation, autocomplete: "off", class: 'form-control', placeholder: 'Confirm Password' %>
+
+
+
+ <%= f.submit "Sign up", class: "btn btn-primary btn-lg" %>
+
+ <% end %>
+
+
+ <%= render "devise/shared/links" %>
+
+
+
diff --git a/app/views/devise/sessions/new.html.erb b/app/views/devise/sessions/new.html.erb
new file mode 100644
index 00000000..cb089411
--- /dev/null
+++ b/app/views/devise/sessions/new.html.erb
@@ -0,0 +1,32 @@
+
+
+
Log in
+
+ <%= form_for(resource, as: resource_name, url: session_path(resource_name)) do |f| %>
+
+ <%= f.email_field :email, autofocus: true, placeholder: 'Email Address', class: 'form-control' %>
+
+
+
+ <%= f.password_field :password, autocomplete: "off", placeholder: 'Password', class: 'form-control' %>
+
+
+ <% if devise_mapping.rememberable? -%>
+
+
+ <%= f.check_box :remember_me, class: "form-check-input" %>
+ Remember me
+
+
+ <% end -%>
+
+
+ <%= f.submit "Log in", class: "btn btn-primary btn-lg" %>
+
+ <% end %>
+
+
+ <%= render "devise/shared/links" %>
+
+
+
diff --git a/app/views/devise/shared/_error_messages.html.erb b/app/views/devise/shared/_error_messages.html.erb
new file mode 100644
index 00000000..cc741938
--- /dev/null
+++ b/app/views/devise/shared/_error_messages.html.erb
@@ -0,0 +1,15 @@
+<% if resource.errors.any? %>
+
+
+ <%= I18n.t("errors.messages.not_saved",
+ count: resource.errors.count,
+ resource: resource.class.model_name.human.downcase)
+ %>
+
+
+ <% resource.errors.full_messages.each do |message| %>
+ <%= message %>
+ <% end %>
+
+
+<% end %>
diff --git a/app/views/devise/shared/_links.html.erb b/app/views/devise/shared/_links.html.erb
new file mode 100644
index 00000000..96a94124
--- /dev/null
+++ b/app/views/devise/shared/_links.html.erb
@@ -0,0 +1,25 @@
+<%- if controller_name != 'sessions' %>
+ <%= link_to "Log in", new_session_path(resource_name) %>
+<% end %>
+
+<%- if devise_mapping.registerable? && controller_name != 'registrations' %>
+ <%= link_to "Sign up", new_registration_path(resource_name) %>
+<% end %>
+
+<%- if devise_mapping.recoverable? && controller_name != 'passwords' && controller_name != 'registrations' %>
+ <%= link_to "Forgot your password?", new_password_path(resource_name) %>
+<% end %>
+
+<%- if devise_mapping.confirmable? && controller_name != 'confirmations' %>
+ <%= link_to "Didn't receive confirmation instructions?", new_confirmation_path(resource_name) %>
+<% end %>
+
+<%- if devise_mapping.lockable? && resource_class.unlock_strategy_enabled?(:email) && controller_name != 'unlocks' %>
+ <%= link_to "Didn't receive unlock instructions?", new_unlock_path(resource_name) %>
+<% end %>
+
+<%- if devise_mapping.omniauthable? %>
+ <%- resource_class.omniauth_providers.each do |provider| %>
+ <%= link_to "Sign in with #{OmniAuth::Utils.camelize(provider)}", omniauth_authorize_path(resource_name, provider), method: :post %>
+ <% end %>
+<% end %>
diff --git a/app/views/devise/unlocks/new.html.erb b/app/views/devise/unlocks/new.html.erb
new file mode 100644
index 00000000..33a4fa72
--- /dev/null
+++ b/app/views/devise/unlocks/new.html.erb
@@ -0,0 +1,20 @@
+
+
+
Resend unlock instructions
+
+ <%= form_for(resource, as: resource_name, url: unlock_path(resource_name), html: { method: :post }) do |f| %>
+ <%= render "devise/shared/error_messages", resource: resource %>
+
+
+ <%= f.label :email, class: "form-label" %>
+ <%= f.email_field :email, autofocus: true, autocomplete: "email", class: "form-control" %>
+
+
+
+ <%= f.submit "Resend unlock instructions", class: "btn btn-lg btn-primary" %>
+
+ <% end %>
+
+ <%= render "devise/shared/links" %>
+
+
diff --git a/app/views/home/index.html.erb b/app/views/home/index.html.erb
new file mode 100644
index 00000000..b4b4788d
--- /dev/null
+++ b/app/views/home/index.html.erb
@@ -0,0 +1,2 @@
+Welcome to Jumpstart!
+Use this document as a way to quickly start any new project. All you get is this text and a mostly barebones HTML document.
diff --git a/app/views/home/privacy.html.erb b/app/views/home/privacy.html.erb
new file mode 100644
index 00000000..a3ee85b6
--- /dev/null
+++ b/app/views/home/privacy.html.erb
@@ -0,0 +1,2 @@
+Privacy Policy
+Use this for your Privacy Policy
diff --git a/app/views/home/terms.html.erb b/app/views/home/terms.html.erb
new file mode 100644
index 00000000..234d2356
--- /dev/null
+++ b/app/views/home/terms.html.erb
@@ -0,0 +1,2 @@
+Terms of Service
+Use this for your Terms of Service
diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb
new file mode 100644
index 00000000..9be6b640
--- /dev/null
+++ b/app/views/layouts/application.html.erb
@@ -0,0 +1,19 @@
+
+
+
+ <%= render 'shared/head' %>
+
+
+
+
+ <%= render 'shared/navbar' %>
+ <%= render 'shared/notices' %>
+
+
+ <%= yield %>
+
+
+
+ <%= render 'shared/footer' %>
+
+
diff --git a/app/views/notifications/index.html.erb b/app/views/notifications/index.html.erb
new file mode 100644
index 00000000..d8069d16
--- /dev/null
+++ b/app/views/notifications/index.html.erb
@@ -0,0 +1,10 @@
+Notifications
+
+
+ <% @notifications.each do |notification| %>
+ <%# Customize your notification format here. We typically recommend a `message` and `url` method on the Notifier classes. %>
+ <%#= link_to notification.message, notification.url %>
+
+ <%= notification.params %>
+ <% end %>
+
diff --git a/app/views/shared/_footer.html.erb b/app/views/shared/_footer.html.erb
new file mode 100644
index 00000000..38604308
--- /dev/null
+++ b/app/views/shared/_footer.html.erb
@@ -0,0 +1,10 @@
+
+
diff --git a/app/views/shared/_head.html.erb b/app/views/shared/_head.html.erb
new file mode 100644
index 00000000..1bc68f44
--- /dev/null
+++ b/app/views/shared/_head.html.erb
@@ -0,0 +1,10 @@
+<%= Rails.configuration.application_name %>
+
+
+
+<%= csrf_meta_tags %>
+<%= csp_meta_tag %>
+
+
+<%= stylesheet_link_tag "application", media: "all", "data-turbo-track": "reload" %>
+<%= javascript_include_tag "application", "data-turbo-track": "reload", defer: true %>
diff --git a/app/views/shared/_navbar.html.erb b/app/views/shared/_navbar.html.erb
new file mode 100644
index 00000000..bd02fa1b
--- /dev/null
+++ b/app/views/shared/_navbar.html.erb
@@ -0,0 +1,52 @@
+<% if current_user != true_user %>
+
+ You're logged in as <%= current_user.name %> (<%= current_user.email %>)
+ <%= link_to stop_impersonating_madmin_impersonates_path, method: :post do %><%= icon("fas", "times") %> Logout <% end %>
+
+<% end %>
+
+
+
+ <%= link_to Rails.configuration.application_name, root_path, class: "navbar-brand" %>
+
+
+
+
+
+
+
+
+
+ <%= link_to "What's New", announcements_path, class: "nav-link #{unread_announcements(current_user)}" %>
+ <% if user_signed_in? %>
+
+
+ <%= link_to notifications_path, class: "nav-link" do %>
+
+ <% end %>
+
+
+
+ <%= link_to "#", id: "navbar-dropdown", class: "nav-link dropdown-toggle", data: { target: "nav-account-dropdown", bs_toggle: "dropdown" }, aria: { haspopup: true, expanded: false } do %>
+ <%= image_tag avatar_path(current_user, size: 40), height: 20, width: 20, class: "rounded" %>
+ <% end %>
+
+
+
+ <% else %>
+ <%= link_to "Sign Up", new_user_registration_path, class: "nav-link" %>
+ <%= link_to "Login", new_user_session_path, class: "nav-link" %>
+ <% end %>
+
+
+
+
diff --git a/app/views/shared/_notices.html.erb b/app/views/shared/_notices.html.erb
new file mode 100644
index 00000000..fca73855
--- /dev/null
+++ b/app/views/shared/_notices.html.erb
@@ -0,0 +1,8 @@
+<% flash.each do |msg_type, message| %>
+
+<% end %>
diff --git a/config/cable.yml b/config/cable.yml
new file mode 100644
index 00000000..02c3701c
--- /dev/null
+++ b/config/cable.yml
@@ -0,0 +1,13 @@
+development:
+ adapter: redis
+ url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %>
+ channel_prefix: streaming_logs_dev
+
+test:
+ adapter: async
+
+production:
+ adapter: redis
+ url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %>
+ channel_prefix: streaming_logs_production
+
diff --git a/esbuild.config.mjs b/esbuild.config.mjs
new file mode 100644
index 00000000..2fe760ba
--- /dev/null
+++ b/esbuild.config.mjs
@@ -0,0 +1,96 @@
+#!/usr/bin/env node
+
+// Esbuild is configured with 3 modes:
+//
+// `yarn build` - Build JavaScript and exit
+// `yarn build --watch` - Rebuild JavaScript on change
+// `yarn build --reload` - Reloads page when views, JavaScript, or stylesheets change
+//
+// Minify is enabled when "RAILS_ENV=production"
+// Sourcemaps are enabled in non-production environments
+
+import * as esbuild from "esbuild"
+import path from "path"
+import rails from "esbuild-rails"
+import chokidar from "chokidar"
+import http from "http"
+import { setTimeout } from "timers/promises"
+
+const clients = []
+const entryPoints = [
+ "application.js"
+]
+const watchDirectories = [
+ "./app/javascript/**/*.js",
+ "./app/views/**/*.erb",
+ "./app/assets/builds/**/*.css", // Wait for cssbundling changes
+]
+const config = {
+ absWorkingDir: path.join(process.cwd(), "app/javascript"),
+ bundle: true,
+ entryPoints: entryPoints,
+ minify: process.env.RAILS_ENV == "production",
+ outdir: path.join(process.cwd(), "app/assets/builds"),
+ plugins: [rails()],
+ sourcemap: process.env.RAILS_ENV != "production"
+}
+
+async function buildAndReload() {
+ // Foreman & Overmind assign a separate PORT for each process
+ const port = parseInt(process.env.PORT)
+ const context = await esbuild.context({
+ ...config,
+ banner: {
+ js: ` (() => new EventSource("http://localhost:${port}").onmessage = () => location.reload())();`,
+ }
+ })
+
+ // Reload uses an HTTP server as an even stream to reload the browser
+ http
+ .createServer((req, res) => {
+ return clients.push(
+ res.writeHead(200, {
+ "Content-Type": "text/event-stream",
+ "Cache-Control": "no-cache",
+ "Access-Control-Allow-Origin": "*",
+ Connection: "keep-alive",
+ })
+ )
+ })
+ .listen(port)
+
+ await context.rebuild()
+ console.log("[reload] initial build succeeded")
+
+ let ready = false
+ chokidar
+ .watch(watchDirectories)
+ .on("ready", () => {
+ console.log("[reload] ready")
+ ready = true
+ })
+ .on("all", async (event, path) => {
+ if (ready === false) return
+
+ if (path.includes("javascript")) {
+ try {
+ await setTimeout(20)
+ await context.rebuild()
+ console.log("[reload] build succeeded")
+ } catch (error) {
+ console.error("[reload] build failed", error)
+ }
+ }
+ clients.forEach((res) => res.write("data: update\n\n"))
+ clients.length = 0
+ })
+}
+
+if (process.argv.includes("--reload")) {
+ buildAndReload()
+} else if (process.argv.includes("--watch")) {
+ let context = await esbuild.context({...config, logLevel: 'info'})
+ context.watch()
+} else {
+ esbuild.build(config)
+}
diff --git a/github/workflows/verify.yml b/github/workflows/verify.yml
new file mode 100644
index 00000000..ee20a8f9
--- /dev/null
+++ b/github/workflows/verify.yml
@@ -0,0 +1,8 @@
+# See https://github.com/andyw8/setup-rails for more information
+
+name: Verify
+on: [push]
+
+jobs:
+ verify:
+ uses: andyw8/setup-rails/.github/workflows/verify.yml@v1
\ No newline at end of file
diff --git a/lib/templates/erb/scaffold/_form.html.erb b/lib/templates/erb/scaffold/_form.html.erb
new file mode 100644
index 00000000..dd385640
--- /dev/null
+++ b/lib/templates/erb/scaffold/_form.html.erb
@@ -0,0 +1,50 @@
+<%%= form_with(model: <%= model_resource_name %>) do |form| %>
+ <%% if <%= singular_table_name %>.errors.any? %>
+
+
<%%= pluralize(<%= singular_table_name %>.errors.count, "error") %> prohibited this <%= singular_table_name %> from being saved:
+
+
+ <%% <%= singular_table_name %>.errors.full_messages.each do |message| %>
+ <%%= message %>
+ <%% end %>
+
+
+ <%% end %>
+
+<% attributes.each do |attribute| -%>
+
+<% if attribute.password_digest? -%>
+ <%%= form.label :password, class: 'form-label' %>
+ <%%= form.password_field :password, class: 'form-control' %>
+
+
+
+ <%%= form.label :password_confirmation, class: 'form-label' %>
+ <%%= form.password_field :password_confirmation, class: 'form-control' %>
+<% else -%>
+ <%%= form.label :<%= attribute.column_name %>, class: 'form-label' %>
+ <% if attribute.field_type == "checkbox" -%>
+ <%%= form.<%= attribute.field_type %> :<%= attribute.column_name %> %>
+ <% else -%>
+ <%%= form.<%= attribute.field_type %> :<%= attribute.column_name %>, class: 'form-control' %>
+ <% end -%>
+<% end -%>
+
+
+<% end -%>
+
+ <%% if <%= model_resource_name %>.persisted? %>
+
+ <%%= link_to 'Destroy', <%= model_resource_name %>, method: :delete, class: "text-danger", data: { confirm: 'Are you sure?' } %>
+
+ <%% end %>
+
+ <%%= form.submit class: 'btn btn-primary' %>
+
+ <%% if <%= model_resource_name %>.persisted? %>
+ <%%= link_to "Cancel", <%= model_resource_name %>, class: "btn btn-link" %>
+ <%% else %>
+ <%%= link_to "Cancel", <%= index_helper %>_path, class: "btn btn-link" %>
+ <%% end %>
+
+<%% end %>
diff --git a/lib/templates/erb/scaffold/edit.html.erb b/lib/templates/erb/scaffold/edit.html.erb
new file mode 100644
index 00000000..50fe8dc5
--- /dev/null
+++ b/lib/templates/erb/scaffold/edit.html.erb
@@ -0,0 +1,3 @@
+Edit <%= singular_table_name.capitalize %>
+
+<%%= render 'form', <%= singular_table_name %>: @<%= singular_table_name %> %>
diff --git a/lib/templates/erb/scaffold/index.html.erb b/lib/templates/erb/scaffold/index.html.erb
new file mode 100644
index 00000000..6c692d0b
--- /dev/null
+++ b/lib/templates/erb/scaffold/index.html.erb
@@ -0,0 +1,51 @@
+<% name_attribute = attributes.find{ |a| a.name == "name" } %>
+<% has_name = !!name_attribute %>
+
+
+
+
<%= plural_table_name.capitalize %>
+
+
+
+ <%%= link_to new_<%= singular_table_name %>_path, class: 'btn btn-primary' do %>
+ Add New <%= human_name %>
+ <%% end %>
+
+
+
+
+
+
+
+ <% if has_name %>
+ Name
+ <% end %>
+
+ <% attributes.without(name_attribute).each do |attribute| -%>
+ <%= attribute.human_name %>
+ <% end -%>
+ <% unless has_name %>
+
+ <% end %>
+
+
+
+
+ <%% @<%= plural_table_name%>.each do |<%= singular_table_name %>| %>
+ <%%= content_tag :tr, id: dom_id(<%= singular_table_name %>), class: dom_class(<%= singular_table_name %>) do %>
+ <% if has_name %>
+ <%%= link_to <%= singular_table_name %>.name, <%= singular_table_name %> %>
+ <% end %>
+
+ <% attributes.without(name_attribute).each do |attribute| -%>
+ <%%= <%= singular_table_name %>.<%= attribute.name %> %>
+ <% end -%>
+
+ <% unless has_name %>
+ <%%= link_to 'Show', <%= singular_table_name %> %>
+ <% end %>
+ <%% end %>
+ <%% end %>
+
+
+
diff --git a/lib/templates/erb/scaffold/new.html.erb b/lib/templates/erb/scaffold/new.html.erb
new file mode 100644
index 00000000..fb7c45f4
--- /dev/null
+++ b/lib/templates/erb/scaffold/new.html.erb
@@ -0,0 +1,3 @@
+New <%= singular_table_name %>
+
+<%%= render 'form', <%= singular_table_name %>: @<%= singular_table_name %> %>
diff --git a/lib/templates/erb/scaffold/show.html.erb b/lib/templates/erb/scaffold/show.html.erb
new file mode 100644
index 00000000..0b4616db
--- /dev/null
+++ b/lib/templates/erb/scaffold/show.html.erb
@@ -0,0 +1,16 @@
+
+
+
+ <%- attributes.each do |attribute| -%>
+ <%= attribute.human_name %>:
+ <%%= @<%= singular_table_name %>.<%= attribute.name %> %>
+ <%- end -%>
+
diff --git a/template.rb b/template.rb
new file mode 100644
index 00000000..d9844575
--- /dev/null
+++ b/template.rb
@@ -0,0 +1,280 @@
+require "fileutils"
+require "shellwords"
+
+# Copied from: https://github.com/mattbrictson/rails-template
+# Add this template directory to source_paths so that Thor actions like
+# copy_file and template resolve against our source files. If this file was
+# invoked remotely via HTTP, that means the files are not present locally.
+# In that case, use `git clone` to download them to a local temporary dir.
+def add_template_repository_to_source_path
+ if __FILE__ =~ %r{\Ahttps?://}
+ require "tmpdir"
+ source_paths.unshift(tempdir = Dir.mktmpdir("jumpstart-"))
+ at_exit { FileUtils.remove_entry(tempdir) }
+ git clone: [
+ "--quiet",
+ "https://github.com/excid3/jumpstart.git",
+ tempdir
+ ].map(&:shellescape).join(" ")
+
+ if (branch = __FILE__[%r{jumpstart/(.+)/template.rb}, 1])
+ Dir.chdir(tempdir) { git checkout: branch }
+ end
+ else
+ source_paths.unshift(File.dirname(__FILE__))
+ end
+end
+
+def read_gemfile?
+ File.open("Gemfile").each_line do |line|
+ return true if line.strip.start_with?("rails") && line.include?("6.")
+ end
+end
+
+def rails_version
+ @rails_version ||= Gem::Version.new(Rails::VERSION::STRING) || read_gemfile?
+end
+
+def rails_7_or_newer?
+ Gem::Requirement.new(">= 7.0.0.alpha").satisfied_by? rails_version
+end
+
+unless rails_7_or_newer?
+ say "\nJumpstart requires Rails 7 or newer. You are using #{rails_version}.", :green
+ say "Please remove partially installed Jumpstart files #{original_app_name} and try again.", :green
+ exit 1
+end
+
+def add_gems
+ add_gem 'cssbundling-rails'
+ add_gem 'devise', '~> 4.9'
+ add_gem 'friendly_id', '~> 5.4'
+ add_gem 'jsbundling-rails'
+ add_gem 'madmin'
+ add_gem 'name_of_person', github: "basecamp/name_of_person" # '~> 1.1'
+ add_gem 'noticed', '~> 2.0'
+ add_gem 'omniauth-facebook', '~> 8.0'
+ add_gem 'omniauth-github', '~> 2.0'
+ add_gem 'omniauth-twitter', '~> 1.4'
+ add_gem 'pretender', '~> 0.3.4'
+ add_gem 'pundit', '~> 2.1'
+ add_gem 'sidekiq', '~> 6.2'
+ add_gem 'sitemap_generator', '~> 6.1'
+ add_gem 'whenever', require: false
+ add_gem 'responders', github: 'heartcombo/responders', branch: 'main'
+end
+
+def set_application_name
+ # Add Application Name to Config
+ environment "config.application_name = Rails.application.class.module_parent_name"
+
+ # Announce the user where they can change the application name in the future.
+ puts "You can change application name inside: ./config/application.rb"
+end
+
+def add_users
+ route "root to: 'home#index'"
+ generate "devise:install"
+
+ environment "config.action_mailer.default_url_options = { host: 'localhost', port: 3000 }", env: 'development'
+ generate :devise, "User", "first_name", "last_name", "announcements_last_read_at:datetime", "admin:boolean"
+
+ # Set admin default to false
+ in_root do
+ migration = Dir.glob("db/migrate/*").max_by { |f| File.mtime(f) }
+ gsub_file migration, /:admin/, ":admin, default: false"
+ end
+
+ gsub_file "config/initializers/devise.rb", / # config.secret_key = .+/, " config.secret_key = Rails.application.credentials.secret_key_base"
+
+ inject_into_file("app/models/user.rb", "omniauthable, :", after: "devise :")
+end
+
+def add_authorization
+ generate 'pundit:install'
+end
+
+def default_to_esbuild
+ return if options[:javascript] == "esbuild"
+ unless options[:skip_javascript]
+ @options = options.merge(javascript: "esbuild")
+ end
+end
+
+def add_javascript
+ run "yarn add local-time esbuild-rails trix @hotwired/stimulus @hotwired/turbo-rails @rails/activestorage @rails/ujs @rails/request.js chokidar"
+end
+
+def copy_templates
+ remove_file "app/assets/stylesheets/application.css"
+ remove_file "app/javascript/application.js"
+ remove_file "app/javascript/controllers/index.js"
+ remove_file "Procfile.dev"
+
+ copy_file "Procfile"
+ copy_file "Procfile.dev"
+ copy_file ".foreman"
+ copy_file "esbuild.config.mjs"
+ copy_file "app/javascript/application.js"
+ copy_file "app/javascript/controllers/index.js"
+
+ directory "app", force: true
+ directory "config", force: true
+ directory "lib", force: true
+
+ route "get '/terms', to: 'home#terms'"
+ route "get '/privacy', to: 'home#privacy'"
+end
+
+def add_sidekiq
+ environment "config.active_job.queue_adapter = :sidekiq"
+
+ insert_into_file "config/routes.rb",
+ "require 'sidekiq/web'\n\n",
+ before: "Rails.application.routes.draw do"
+
+ content = <<~RUBY
+ authenticate :user, lambda { |u| u.admin? } do
+ mount Sidekiq::Web => '/sidekiq'
+
+ namespace :madmin do
+ resources :impersonates do
+ post :impersonate, on: :member
+ post :stop_impersonating, on: :collection
+ end
+ end
+ end
+ RUBY
+ insert_into_file "config/routes.rb", "#{content}\n", after: "Rails.application.routes.draw do\n"
+end
+
+def add_announcements
+ generate "model Announcement published_at:datetime announcement_type name description:text"
+ route "resources :announcements, only: [:index]"
+end
+
+def add_notifications
+ rails_command "noticed:install:migrations"
+ route "resources :notifications, only: [:index]"
+end
+
+def add_multiple_authentication
+ insert_into_file "config/routes.rb", ', controllers: { omniauth_callbacks: "users/omniauth_callbacks" }', after: " devise_for :users"
+
+ generate "model Service user:references provider uid access_token access_token_secret refresh_token expires_at:datetime auth:text"
+
+ template = """
+ env_creds = Rails.application.credentials[Rails.env.to_sym] || {}
+ %i{ facebook twitter github }.each do |provider|
+ if options = env_creds[provider]
+ config.omniauth provider, options[:app_id], options[:app_secret], options.fetch(:options, {})
+ end
+ end
+ """.strip
+
+ insert_into_file "config/initializers/devise.rb", " " + template + "\n\n", before: " # ==> Warden configuration"
+end
+
+def add_whenever
+ run "wheneverize ."
+end
+
+def add_friendly_id
+ generate "friendly_id"
+ insert_into_file(Dir["db/migrate/**/*friendly_id_slugs.rb"].first, "[5.2]", after: "ActiveRecord::Migration")
+end
+
+def add_sitemap
+ rails_command "sitemap:install"
+end
+
+def add_bootstrap
+ rails_command "css:install:bootstrap"
+end
+
+def add_announcements_css
+ insert_into_file 'app/assets/stylesheets/application.bootstrap.scss', '@import "jumpstart/announcements";'
+end
+
+def add_esbuild_script
+ build_script = "node esbuild.config.mjs"
+
+ case `npx -v`.to_f
+ when 7.1...8.0
+ run %(npm set-script build "#{build_script}")
+ run %(yarn build)
+ when (8.0..)
+ run %(npm pkg set scripts.build="#{build_script}")
+ run %(yarn build)
+ else
+ say %(Add "scripts": { "build": "#{build_script}" } to your package.json), :green
+ end
+end
+
+def add_github_actions_ci
+ copy_file "github/workflows/verify.yml", ".github/workflows/verify.yml"
+end
+
+def add_gem(name, *options)
+ gem(name, *options) unless gem_exists?(name)
+end
+
+def gem_exists?(name)
+ IO.read("Gemfile") =~ /^\s*gem ['"]#{name}['"]/
+end
+
+# Main setup
+add_template_repository_to_source_path
+default_to_esbuild
+add_gems
+
+after_bundle do
+ set_application_name
+ add_users
+ add_authorization
+ add_javascript
+ add_announcements
+ add_notifications
+ add_multiple_authentication
+ add_sidekiq
+ add_friendly_id
+ add_bootstrap
+ add_whenever
+ add_sitemap
+ add_announcements_css
+ add_github_actions_ci
+ rails_command "active_storage:install"
+
+ # Make sure Linux is in the Gemfile.lock for deploying
+ run "bundle lock --add-platform x86_64-linux"
+
+ copy_templates
+
+ add_esbuild_script
+
+ # Commit everything to git
+ unless ENV["SKIP_GIT"]
+ git :init
+ git add: "."
+ # git commit will fail if user.email is not configured
+ begin
+ git commit: %( -m 'Initial commit' )
+ rescue StandardError => e
+ puts e.message
+ end
+ end
+
+ say
+ say "Jumpstart app successfully created!", :blue
+ say
+ say "To get started with your new app:", :green
+ say " cd #{original_app_name}"
+ say
+ say " # Update config/database.yml with your database credentials"
+ say
+ say " rails db:create"
+ say " rails db:migrate"
+ say " rails g madmin:install # Generate admin dashboards"
+ say " gem install foreman"
+ say " bin/dev"
+end
diff --git a/test/template_test.rb b/test/template_test.rb
new file mode 100644
index 00000000..234dd1bc
--- /dev/null
+++ b/test/template_test.rb
@@ -0,0 +1,39 @@
+require "minitest/autorun"
+
+class TemplateTest < Minitest::Test
+ def setup
+ system("[ -d test_app ] && rm -rf test_app")
+ end
+
+ def teardown
+ setup
+ end
+
+ def test_generator_succeeds
+ output, _err = capture_subprocess_io do
+ system("DISABLE_SPRING=1 SKIP_GIT=1 rails new test_app -m template.rb")
+ end
+ assert_includes output, "Jumpstart app successfully created!"
+
+ output, _err = capture_subprocess_io do
+ system("cd test_app && yarn build")
+ end
+ assert_includes output, "Done in "
+ end
+
+ # TODO: Fix these tests on CI so they don't fail on db:create
+ #
+ # def test_generator_with_postgres_succeeds
+ # output, err = capture_subprocess_io do
+ # system("DISABLE_SPRING=1 SKIP_GIT=1 rails new test_app -m template.rb -d postgresql")
+ # end
+ # assert_includes output, "Jumpstart app successfully created!"
+ # end
+
+ # def test_generator_with_mysql_succeeds
+ # output, err = capture_subprocess_io do
+ # system("DISABLE_SPRING=1 SKIP_GIT=1 rails new test_app -m template.rb -d mysql")
+ # end
+ # assert_includes output, "Jumpstart app successfully created!"
+ # end
+end