diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index c7c7cc88..4f7214fe 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -37,4 +37,9 @@ class ApplicationController < ActionController::Base
Current.user = nil
reset_session
end
+
+ def hosted_app?
+ ENV["HOSTED"] == "true"
+ end
+ helper_method :hosted_app?
end
diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb
index cecad15a..3973ddbf 100644
--- a/app/controllers/registrations_controller.rb
+++ b/app/controllers/registrations_controller.rb
@@ -1,13 +1,14 @@
class RegistrationsController < ApplicationController
layout "auth"
+ before_action :set_user, only: :create
+ before_action :claim_invite_code, only: :create, if: :hosted_app?
+
def new
@user = User.new
end
def create
- @user = User.new(user_params)
-
family = Family.new
@user.family = family
@@ -23,7 +24,17 @@ class RegistrationsController < ApplicationController
private
+ def set_user
+ @user = User.new user_params.except(:invite_code)
+ end
+
def user_params
- params.require(:user).permit(:name, :email, :password, :password_confirmation)
+ params.require(:user).permit(:name, :email, :password, :password_confirmation, :invite_code)
+ end
+
+ def claim_invite_code
+ unless InviteCode.claim! params[:user][:invite_code]
+ redirect_to new_registration_path, alert: "Invalid invite code, please try again."
+ end
end
end
diff --git a/app/models/invite_code.rb b/app/models/invite_code.rb
new file mode 100644
index 00000000..59ebf855
--- /dev/null
+++ b/app/models/invite_code.rb
@@ -0,0 +1,25 @@
+class InviteCode < ApplicationRecord
+ before_validation :generate_token, on: :create
+
+ class << self
+ def claim!(token)
+ if invite_code = find_by(token: token&.downcase)
+ invite_code.destroy!
+ true
+ end
+ end
+
+ def generate!
+ create!.token
+ end
+ end
+
+ private
+
+ def generate_token
+ loop do
+ self.token = SecureRandom.hex(4)
+ break token unless self.class.exists?(token: token)
+ end
+ end
+end
diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb
index 893c7c40..ba49490e 100644
--- a/app/views/layouts/application.html.erb
+++ b/app/views/layouts/application.html.erb
@@ -66,7 +66,7 @@
<% current_family.accounts.depository.each do |account| %>
- <%= account.name %>
+ <%= account.name %>
<%= number_to_currency account.balance %>
diff --git a/app/views/layouts/auth.html.erb b/app/views/layouts/auth.html.erb
index 668dd47a..e6720d52 100644
--- a/app/views/layouts/auth.html.erb
+++ b/app/views/layouts/auth.html.erb
@@ -6,7 +6,7 @@
-
+
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<%= stylesheet_link_tag "tailwind", "inter-font", "data-turbo-track": "reload" %>
@@ -18,15 +18,21 @@
+ <% flash.each do |type, msg| %>
+
+ <%= msg %>
+
+ <% end %>
+
<%= render "shared/logo" %>
-
+
<%= content_for?(:header_title) ? yield(:header_title).html_safe : "Your account" %>
-
+
<% if params[:controller] == "devise/sessions" && params[:action] == "new" %>
or <%= link_to "create an account", new_user_registration_path, class: 'font-medium text-candlelight-600 hover:text-candlelight-500' %>
diff --git a/app/views/registrations/new.html.erb b/app/views/registrations/new.html.erb
index 0545b464..d762f0e0 100644
--- a/app/views/registrations/new.html.erb
+++ b/app/views/registrations/new.html.erb
@@ -24,6 +24,13 @@
<%= form.password_field :password_confirmation, autocomplete: "new-password", required: 'required', class: 'p-0 mt-1 bg-transparent border-none opacity-50 focus:outline-none focus:ring-0 focus-within:opacity-100 w-full' %>
+ <% if hosted_app? %>
+
+ <%= form.label :invite_code, "Invite code", class: 'block text-sm font-medium text-gray-700' %>
+ <%= form.password_field :invite_code, required: 'required', class: 'p-0 mt-1 bg-transparent border-none opacity-50 focus:outline-none focus:ring-0 focus-within:opacity-100 w-full' %>
+
+ <% end %>
+
<%= form.submit "Continue", class: 'flex justify-center w-full px-4 py-3 text-sm font-medium text-white bg-black rounded-xl hover:bg-black focus:outline-none focus:ring-2 focus:ring-gray-200 shadow' %>
@@ -31,4 +38,4 @@
Already have an account? <%= link_to "Sign in", new_session_path, class: 'font-medium text-candlelight-600 hover:text-candlelight-500' %>
-
\ No newline at end of file
+
diff --git a/db/migrate/20240202230325_create_invite_codes.rb b/db/migrate/20240202230325_create_invite_codes.rb
new file mode 100644
index 00000000..0ed9fb45
--- /dev/null
+++ b/db/migrate/20240202230325_create_invite_codes.rb
@@ -0,0 +1,9 @@
+class CreateInviteCodes < ActiveRecord::Migration[7.2]
+ def change
+ create_table :invite_codes, id: :uuid do |t|
+ t.string :token, null: false
+
+ t.timestamps
+ end
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 90da754d..1a132587 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[7.2].define(version: 2024_02_02_015428) do
+ActiveRecord::Schema[7.2].define(version: 2024_02_02_230325) do
# These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto"
enable_extension "plpgsql"
@@ -34,6 +34,12 @@ ActiveRecord::Schema[7.2].define(version: 2024_02_02_015428) do
t.datetime "updated_at", null: false
end
+ create_table "invite_codes", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
+ t.string "token", null: false
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ end
+
create_table "users", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.uuid "family_id", null: false
t.string "first_name"
diff --git a/lib/tasks/.keep b/lib/tasks/.keep
deleted file mode 100644
index e69de29b..00000000
diff --git a/lib/tasks/invites.rake b/lib/tasks/invites.rake
new file mode 100644
index 00000000..61e385cf
--- /dev/null
+++ b/lib/tasks/invites.rake
@@ -0,0 +1,6 @@
+namespace :invites do
+ desc "Create an invite code"
+ task create: :environment do
+ puts InviteCode.generate!
+ end
+end
diff --git a/test/controllers/.keep b/test/controllers/.keep
deleted file mode 100644
index e69de29b..00000000
diff --git a/test/controllers/registrations_controller_test.rb b/test/controllers/registrations_controller_test.rb
new file mode 100644
index 00000000..cbf3b6c4
--- /dev/null
+++ b/test/controllers/registrations_controller_test.rb
@@ -0,0 +1,54 @@
+require "test_helper"
+
+class RegistrationsControllerTest < ActionDispatch::IntegrationTest
+ test "new" do
+ get new_registration_url
+ assert_response :success
+ end
+
+ test "create" do
+ post registration_url, params: { user: {
+ email: "john@example.com",
+ password: "password",
+ password_confirmation: "password" } }
+
+ assert_redirected_to root_url
+ end
+
+ test "create when hosted requires an invite code" do
+ in_hosted_app do
+ assert_no_difference "User.count" do
+ post registration_url, params: { user: {
+ email: "john@example.com",
+ password: "password",
+ password_confirmation: "password" } }
+ assert_redirected_to new_registration_url
+
+ post registration_url, params: { user: {
+ email: "john@example.com",
+ password: "password",
+ password_confirmation: "password",
+ invite_code: "foo" } }
+ assert_redirected_to new_registration_url
+ end
+
+ assert_difference "User.count", +1 do
+ post registration_url, params: { user: {
+ email: "john@example.com",
+ password: "password",
+ password_confirmation: "password",
+ invite_code: InviteCode.generate! } }
+ assert_redirected_to root_url
+ end
+ end
+ end
+
+ private
+
+ def in_hosted_app
+ ENV["HOSTED"] = "true"
+ yield
+ ensure
+ ENV["HOSTED"] = nil
+ end
+end
diff --git a/test/models/invite_code_test.rb b/test/models/invite_code_test.rb
new file mode 100644
index 00000000..b95c0271
--- /dev/null
+++ b/test/models/invite_code_test.rb
@@ -0,0 +1,25 @@
+require "test_helper"
+
+class InviteCodeTest < ActiveSupport::TestCase
+ test "claim! destroys the invite token" do
+ code = InviteCode.generate!
+
+ assert_difference "InviteCode.count", -1 do
+ InviteCode.claim! code
+ end
+ end
+
+ test "claim! returns true if valid" do
+ assert InviteCode.claim!(InviteCode.generate!)
+ end
+
+ test "claim! is falsy if invalid" do
+ assert_not InviteCode.claim!("invalid")
+ end
+
+ test "generate! creates a new invite and returns its token" do
+ assert_difference "InviteCode.count", +1 do
+ assert_equal InviteCode.generate!, InviteCode.last.token
+ end
+ end
+end