diff --git a/Gemfile b/Gemfile index cb00ed3..729b30e 100644 --- a/Gemfile +++ b/Gemfile @@ -41,6 +41,8 @@ gem 'bootsnap', require: false # Draper adds an object-oriented layer of presentation logic to your Rails application. gem 'draper' +gem 'passwordless' + group :development, :test do # See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem gem 'debug', platforms: %i[mri mswin mswin64 mingw x64_mingw] @@ -57,6 +59,7 @@ group :development do # gem "spring" gem 'error_highlight', '>= 0.4.0', platforms: [:ruby] + gem 'letter_opener' gem 'pry-byebug' gem 'solargraph', require: false gem 'solargraph-rails', require: false diff --git a/Gemfile.lock b/Gemfile.lock index 04482a0..86c9db4 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -84,6 +84,7 @@ GEM ast (2.4.2) backport (1.2.0) base64 (0.2.0) + bcrypt (3.1.20) benchmark (0.4.0) bigdecimal (3.1.8) bindex (0.8.1) @@ -100,6 +101,8 @@ GEM rack-test (>= 0.6.3) regexp_parser (>= 1.5, < 3.0) xpath (~> 3.2) + childprocess (5.1.0) + logger (~> 1.5) coderay (1.1.3) concurrent-ruby (1.3.3) connection_pool (2.4.1) @@ -143,6 +146,12 @@ GEM kramdown-parser-gfm (1.1.0) kramdown (~> 2.0) language_server-protocol (3.17.0.4) + launchy (3.1.1) + addressable (~> 2.8) + childprocess (~> 5.0) + logger (~> 1.6) + letter_opener (1.10.0) + launchy (>= 2.2, < 4) logger (1.6.0) loofah (2.22.0) crass (~> 1.0.2) @@ -187,6 +196,9 @@ GEM parser (3.3.7.1) ast (~> 2.4.1) racc + passwordless (1.8.1) + bcrypt (>= 3.1.11) + rails (>= 5.1.4) pg (1.5.6) pry (0.14.2) coderay (~> 1.1) @@ -355,6 +367,8 @@ DEPENDENCIES error_highlight (>= 0.4.0) inertia_rails-contrib (~> 0.1.1) jbuilder + letter_opener + passwordless pg (~> 1.1) pry-byebug puma (>= 5.0) diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index a8cd925..695e406 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,13 +1,25 @@ class ApplicationController < ActionController::Base - before_action :initialize_progs + include Passwordless::ControllerHelpers # <-- This! + + before_action :require_user + helper_method :current_user + + inertia_share auth: lambda { + { user: current_user && { email: current_user.email, id: current_user.id } } + }, flash: lambda { + { notice: flash.notice, alert: flash.alert } + } private - def initialize_progs - today = Date.current.to_s - return if session[:initialize_progs] == today + def current_user + @current_user ||= authenticate_by_session(User) + end + + def require_user + return if current_user - ProgService.initialize_progs - session[:initialize_progs] = today + save_passwordless_redirect_location!(User) # <-- optional, see below + redirect_to users_sign_in_path, inertia: { props: { random_prop: 'prop' } } end end diff --git a/app/controllers/concerns/goal_scoped.rb b/app/controllers/concerns/goal_scoped.rb new file mode 100644 index 0000000..2cf64ae --- /dev/null +++ b/app/controllers/concerns/goal_scoped.rb @@ -0,0 +1,25 @@ +module GoalScoped + extend ActiveSupport::Concern + + included do + before_action :set_current_goal + before_action :ensure_daily_prog + end + + private + + def set_current_goal + @current_goal = current_user.current_goal + end + + def ensure_daily_prog + today = Date.current.to_s + return if session[:initialize_progs] == today + + DailyProg.create_or_find_by!( + goal: @current_goal, + date: Date.current + ) + session[:initialize_progs] = today + end +end diff --git a/app/controllers/inertia_example_controller.rb b/app/controllers/inertia_example_controller.rb deleted file mode 100644 index f4a6214..0000000 --- a/app/controllers/inertia_example_controller.rb +++ /dev/null @@ -1,26 +0,0 @@ -class InertiaExampleController < ApplicationController - def index - Counter.create do |counter| - counter.click = 1 - end if Counter.find_by_id(1).nil? - - render inertia: "InertiaExample", props: { - name: params.fetch(:name, "Planet"), - count: Counter.last.click - } - end - - def increase_counter - puts params - counter = Counter.find(1) - counter.click = params["count"].to_int - counter.save! - - render inertia: "InertiaExample", props: { - name: params.fetch(:name, "howdy"), - count: 89 - } - rescue StandardError => e - Rails.logger.info e - end -end diff --git a/app/controllers/progress_controller.rb b/app/controllers/progress_controller.rb new file mode 100644 index 0000000..b4d3d0f --- /dev/null +++ b/app/controllers/progress_controller.rb @@ -0,0 +1,8 @@ +class ProgressController < ApplicationController + include GoalScoped + def index + daily_progs = @current_goal.daily_progs + start_date = @current_goal.start_date + render inertia: 'ProgressPage', props: { progs: daily_progs, startdate: start_date } + end +end diff --git a/app/controllers/progress_page_controller.rb b/app/controllers/progress_page_controller.rb deleted file mode 100644 index 7363e3c..0000000 --- a/app/controllers/progress_page_controller.rb +++ /dev/null @@ -1,5 +0,0 @@ -class ProgressPageController < ApplicationController - def index - render inertia: 'ProgressPage', props: { progs: Goal.last.daily_progs, startdate: Goal.last.start_date } - end -end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb new file mode 100644 index 0000000..b56fbf1 --- /dev/null +++ b/app/controllers/sessions_controller.rb @@ -0,0 +1,19 @@ +class SessionsController < Passwordless::SessionsController + skip_before_action :require_user + + def new + render inertia: 'LoginPage', props: { prop: 'random' } + end + + def show + super + # IMPORTANT: call super to let Passwordless validate token & sign in. + render inertia: 'TokenPage' + end + + private + + def redirect_path_after_failed_sign_in + users_sign_in_path + end +end diff --git a/app/controllers/settings_page_controller.rb b/app/controllers/settings_controller.rb similarity index 80% rename from app/controllers/settings_page_controller.rb rename to app/controllers/settings_controller.rb index d4cba92..517de1f 100644 --- a/app/controllers/settings_page_controller.rb +++ b/app/controllers/settings_controller.rb @@ -1,6 +1,7 @@ -class SettingsPageController < ApplicationController +class SettingsController < ApplicationController + include GoalScoped def index - tasks = TaskService.fetch_today_tasks + tasks = TaskService.fetch_today_tasks(current_user) render inertia: 'SettingsPage', props: { tasks: tasks } end diff --git a/app/controllers/tasks/today_controller.rb b/app/controllers/tasks/today_controller.rb index 90b5cf1..bd3cd88 100644 --- a/app/controllers/tasks/today_controller.rb +++ b/app/controllers/tasks/today_controller.rb @@ -20,7 +20,7 @@ def edit private def collection - @collection ||= TaskService.fetch_today_tasks.select { |task| task[:text].present? } + @collection ||= TaskService.fetch_today_tasks(current_user).select { |task| task[:text].present? } end def resource diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb new file mode 100644 index 0000000..ea03d99 --- /dev/null +++ b/app/controllers/users_controller.rb @@ -0,0 +1,34 @@ +class UsersController < ApplicationController + skip_before_action :require_user, only: %i[new create] + + def new + if current_user + redirect_to root_path + else + render inertia: 'SignUpPage', props: { username: '', email: '', errors: {} } + end + end + + def create + @user = User.new(resource_params) + + if @user.save + # pwless_session = build_passwordless_session(@user) + # pwless_session.save! + # Passwordless::Mailer.sign_in(pwless_session, pwless_session.token).deliver_later + # redirect_to users_sign_in_path(token: pwless_session.token) + sign_in(create_passwordless_session(@user)) # <-- This! + redirect_to root_path, notice: "Welcome!" + else + render( + inertia: 'SignUpPage', + props: { username: @user.username, email: @user.email, errors: @user.errors.to_hash }, + status: :unprocessable_entity + ) + end + end + + def resource_params + params.require(:user).permit(:username, :email) + end +end diff --git a/app/mailers/application_mailer.rb b/app/mailers/application_mailer.rb index 3c34c81..0bca67c 100644 --- a/app/mailers/application_mailer.rb +++ b/app/mailers/application_mailer.rb @@ -1,4 +1,4 @@ class ApplicationMailer < ActionMailer::Base - default from: "from@example.com" - layout "mailer" + default from: 'support@dofivethings.com' + layout 'mailer' end diff --git a/app/models/goal.rb b/app/models/goal.rb index aa66d7f..d4c1c3a 100644 --- a/app/models/goal.rb +++ b/app/models/goal.rb @@ -1,4 +1,6 @@ class Goal < ApplicationRecord + belongs_to :user + has_many :tasks, dependent: :destroy has_many :daily_progs, dependent: :destroy diff --git a/app/models/user.rb b/app/models/user.rb index 379658a..80bc39d 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,2 +1,23 @@ class User < ApplicationRecord + validates :email, + presence: true, + uniqueness: { case_sensitive: false }, + format: { with: URI::MailTo::EMAIL_REGEXP } # <-- validates that the email is in correct format, and that it is unique + + passwordless_with :email # <-- tells Passwordless which field stores the email address + + has_many :goals, dependent: :destroy + + def current_goal + goals.order(created_at: :desc).first || create_default_goal + end + + private + + def create_default_goal + goals.create!( + start_date: Date.current, + end_date: Date.current + 182.days + ) + end end diff --git a/app/services/prog_service.rb b/app/services/prog_service.rb deleted file mode 100644 index 5f6a25d..0000000 --- a/app/services/prog_service.rb +++ /dev/null @@ -1,7 +0,0 @@ -class ProgService - def self.initialize_progs - goal = Goal.last.presence || Goal.create!(start_date: Date.current) - daily_prog = DailyProg.find_or_initialize_by(goal: goal, date: Date.current) - daily_prog.save! if daily_prog.new_record? - end -end diff --git a/app/services/task_service.rb b/app/services/task_service.rb index b102472..ae13106 100644 --- a/app/services/task_service.rb +++ b/app/services/task_service.rb @@ -1,6 +1,6 @@ class TaskService - def self.fetch_today_tasks - goal = Goal.last + def self.fetch_today_tasks(user) + goal = user.current_goal goal.tasks.decorate.map(&:as_json_for_today) end end diff --git a/app/views/passwordless/mailer/sign_in.text.erb b/app/views/passwordless/mailer/sign_in.text.erb new file mode 100644 index 0000000..e5d7850 --- /dev/null +++ b/app/views/passwordless/mailer/sign_in.text.erb @@ -0,0 +1 @@ +<%= t("passwordless.mailer.sign_in.body", token: @token, magic_link: @magic_link) %> diff --git a/config/application.rb b/config/application.rb index 23c1e10..a356d88 100644 --- a/config/application.rb +++ b/config/application.rb @@ -23,5 +23,7 @@ class Application < Rails::Application # config.time_zone = 'Eastern Time (US & Canada)' # config.eager_load_paths << Rails.root.join("extras") + config.action_mailer.default_url_options = { host: 'localhost:3000' } + routes.default_url_options[:host] ||= 'localhost:3000' end end diff --git a/config/environments/development.rb b/config/environments/development.rb index 2e7fb48..3abcc51 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -73,4 +73,11 @@ # Raise error when a before_action's only/except options reference missing actions config.action_controller.raise_on_missing_callback_actions = true + + # mailer + config.action_mailer.delivery_method = :letter_opener + config.action_mailer.perform_deliveries = true + config.action_mailer.default_url_options = { host: 'localhost:3000' } + routes.default_url_options[:host] ||= 'localhost:3000' + config.action_mailer.raise_delivery_errors = true end diff --git a/config/initializers/passwordless.rb b/config/initializers/passwordless.rb new file mode 100644 index 0000000..8ea5c55 --- /dev/null +++ b/config/initializers/passwordless.rb @@ -0,0 +1,10 @@ +Passwordless.configure do |config| + config.after_session_save = lambda do |session, request| + # Default behavior is + Rails.logger.info "Attempting to send passwordless email for session: #{session.inspect}" + Passwordless::Mailer.sign_in(session).deliver_now + + # You can change behavior to do something with session model. For example, + # SmsApi.send_sms(session.authenticatable.phone_number, session.token) + end +end diff --git a/config/locales/en.yml b/config/locales/en.yml index 6c349ae..9bfc5e7 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -28,4 +28,8 @@ # enabled: "ON" en: - hello: "Hello world" + passwordless: + mailer: + sign_in: + subject: "Sign in to Do Five Things" + body: "Click the link below to sign in to Do Five Things:\n\n%{magic_link}\n\nOr enter this code: %{token}\n\nThis link will expire in 15 minutes." diff --git a/config/routes.rb b/config/routes.rb index ca3cfd9..23eb80a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,17 +1,16 @@ Rails.application.routes.draw do - # Defines the root path route ("/") - root 'settings_page#index' - - get 'settings', to: 'settings_page#index' - post 'settings', to: 'settings_page#bulk_update' + passwordless_for :users, controller: 'sessions' + get 'sign_up', to: 'users#new', as: :sign_up + post 'sign_up', to: 'users#create' + # Defines the root path route ("/") + root 'settings#index' + get 'settings', to: 'settings#index' + post 'settings', to: 'settings#bulk_update' get 'today', to: 'tasks/today#index' patch 'today', to: 'tasks/today#edit' + get 'progress', to: 'progress#index' - get 'progress', to: 'progress_page#index' - - get 'inertia-example', to: 'inertia_example#index' - post 'inertia-example', to: 'inertia_example#increase_counter' # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500. diff --git a/db/migrate/20250422001809_create_passwordless_sessions.passwordless_engine.rb b/db/migrate/20250422001809_create_passwordless_sessions.passwordless_engine.rb new file mode 100644 index 0000000..f026007 --- /dev/null +++ b/db/migrate/20250422001809_create_passwordless_sessions.passwordless_engine.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +# This migration comes from passwordless_engine (originally 20171104221735) +class CreatePasswordlessSessions < ActiveRecord::Migration[6.0] + def change + create_table(:passwordless_sessions) do |t| + t.belongs_to( + :authenticatable, + polymorphic: true, + type: :int, # change to e.g. :uuid if your model doesn't use integer IDs + index: { name: "authenticatable" } + ) + + t.datetime(:timeout_at, null: false) + t.datetime(:expires_at, null: false) + t.datetime(:claimed_at) + t.string(:token_digest, null: false) + t.string(:identifier, null: false, index: { unique: true }, length: 36) + + t.timestamps + end + end +end diff --git a/db/migrate/20250422002120_change_users.rb b/db/migrate/20250422002120_change_users.rb new file mode 100644 index 0000000..657342d --- /dev/null +++ b/db/migrate/20250422002120_change_users.rb @@ -0,0 +1,6 @@ +class ChangeUsers < ActiveRecord::Migration[7.1] + def change + add_index :users, 'LOWER(email)', unique: true, name: 'index_users_on_lowercase_email' # <-- prevent duplicate emails + change_column_null :users, :email, false + end +end diff --git a/db/migrate/20250511163006_move_goals_to_users.rb b/db/migrate/20250511163006_move_goals_to_users.rb new file mode 100644 index 0000000..b263c6b --- /dev/null +++ b/db/migrate/20250511163006_move_goals_to_users.rb @@ -0,0 +1,11 @@ +class MoveGoalsToUsers < ActiveRecord::Migration[7.1] + def up + Goal.destroy_all + + add_reference :goals, :user, null: false, foreign_key: true + end + + def down + remove_reference :goals, :user + end +end diff --git a/db/schema.rb b/db/schema.rb index 33f3203..f113e47 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,16 +10,10 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.1].define(version: 2025_02_17_005822) do +ActiveRecord::Schema[7.1].define(version: 2025_05_11_163006) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" - create_table "counters", force: :cascade do |t| - t.integer "click" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - end - create_table "daily_progs", force: :cascade do |t| t.bigint "goal_id", null: false t.date "date" @@ -36,6 +30,22 @@ t.date "end_date" t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.bigint "user_id", null: false + t.index ["user_id"], name: "index_goals_on_user_id" + end + + create_table "passwordless_sessions", force: :cascade do |t| + t.string "authenticatable_type" + t.integer "authenticatable_id" + t.datetime "timeout_at", precision: nil, null: false + t.datetime "expires_at", precision: nil, null: false + t.datetime "claimed_at", precision: nil + t.string "token_digest", null: false + t.string "identifier", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["authenticatable_type", "authenticatable_id"], name: "authenticatable" + t.index ["identifier"], name: "index_passwordless_sessions_on_identifier", unique: true end create_table "task_progs", force: :cascade do |t| @@ -63,13 +73,15 @@ create_table "users", force: :cascade do |t| t.date "timezone" t.string "username" - t.string "email" + t.string "email", null: false t.boolean "show_progress", default: false t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.index "lower((email)::text)", name: "index_users_on_lowercase_email", unique: true end add_foreign_key "daily_progs", "goals" + add_foreign_key "goals", "users" add_foreign_key "task_progs", "daily_progs" add_foreign_key "task_progs", "tasks" add_foreign_key "tasks", "goals"