diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..57a8363 --- /dev/null +++ b/.env.example @@ -0,0 +1,26 @@ +# Rails Configuration +SECRET_KEY_BASE=generate-with-bin-rails-secret +RAILS_ENV=development + +# Database +# SQLite database files are stored in the storage/ directory +# In production with Docker, mount this as a persistent volume + +# SMTP Configuration (for sending emails) +SMTP_ADDRESS=smtp.example.com +SMTP_PORT=587 +SMTP_DOMAIN=example.com +SMTP_USERNAME=your-username +SMTP_PASSWORD=your-password +SMTP_AUTHENTICATION=plain +SMTP_ENABLE_STARTTLS=true + +# Application Configuration +CLINCH_HOST=http://localhost:9000 +CLINCH_FROM_EMAIL=noreply@example.com + +# Optional: Force SSL in production +# FORCE_SSL=true + +# Optional: Set custom port +# PORT=9000 diff --git a/Procfile.dev b/Procfile.dev index da151fe..f805780 100644 --- a/Procfile.dev +++ b/Procfile.dev @@ -1,2 +1,2 @@ -web: bin/rails server +web: bin/rails server -b 0.0.0.0 -p 3035 css: bin/rails tailwindcss:watch diff --git a/README.md b/README.md index 6ace77b..2cc989f 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Clinch -**A lightweight, self-hosted identity & SSO portal for home-labs** +**A lightweight, self-hosted identity & SSO portal** Clinch gives you one place to manage users and lets any web app authenticate against it without maintaining its own user table. diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb new file mode 100644 index 0000000..ce1b60d --- /dev/null +++ b/app/controllers/dashboard_controller.rb @@ -0,0 +1,12 @@ +class DashboardController < ApplicationController + def index + # First run: redirect to signup + if User.count.zero? + redirect_to signup_path + return + end + + # User must be authenticated + @user = Current.session.user + end +end diff --git a/app/controllers/profiles_controller.rb b/app/controllers/profiles_controller.rb new file mode 100644 index 0000000..f915c45 --- /dev/null +++ b/app/controllers/profiles_controller.rb @@ -0,0 +1,45 @@ +class ProfilesController < ApplicationController + def show + @user = Current.session.user + @active_sessions = @user.sessions.active.order(last_activity_at: :desc) + end + + def update + @user = Current.session.user + + if params[:user][:password].present? + # Updating password - requires current password + unless @user.authenticate(params[:user][:current_password]) + @user.errors.add(:current_password, "is incorrect") + @active_sessions = @user.sessions.active.order(last_activity_at: :desc) + render :show, status: :unprocessable_entity + return + end + + if @user.update(password_params) + redirect_to profile_path, notice: "Password updated successfully." + else + @active_sessions = @user.sessions.active.order(last_activity_at: :desc) + render :show, status: :unprocessable_entity + end + else + # Updating email + if @user.update(email_params) + redirect_to profile_path, notice: "Email updated successfully." + else + @active_sessions = @user.sessions.active.order(last_activity_at: :desc) + render :show, status: :unprocessable_entity + end + end + end + + private + + def email_params + params.require(:user).permit(:email_address) + end + + def password_params + params.require(:user).permit(:password, :password_confirmation) + end +end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index cf7fccd..ebc5fa8 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -1,21 +1,47 @@ class SessionsController < ApplicationController allow_unauthenticated_access only: %i[ new create ] - rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_session_path, alert: "Try again later." } + rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to signin_path, alert: "Too many attempts. Try again later." } def new + # Redirect to signup if this is first run + redirect_to signup_path if User.count.zero? end def create - if user = User.authenticate_by(params.permit(:email_address, :password)) - start_new_session_for user - redirect_to after_authentication_url - else - redirect_to new_session_path, alert: "Try another email address or password." + user = User.authenticate_by(params.permit(:email_address, :password)) + + if user.nil? + redirect_to signin_path, alert: "Invalid email address or password." + return end + + # Check if user is active + unless user.status == "active" + redirect_to signin_path, alert: "Your account is not active. Please contact an administrator." + return + end + + # Check if TOTP is required + if user.totp_enabled? + # TODO: Implement TOTP verification flow + # For now, reject login if TOTP is enabled + redirect_to signin_path, alert: "Two-factor authentication is enabled but not yet implemented. Please contact an administrator." + return + end + + # Sign in successful + start_new_session_for user + redirect_to after_authentication_url, notice: "Signed in successfully." end def destroy terminate_session - redirect_to new_session_path, status: :see_other + redirect_to signin_path, status: :see_other, notice: "Signed out successfully." + end + + def destroy_other + session = Current.session.user.sessions.find(params[:id]) + session.destroy + redirect_to profile_path, notice: "Session revoked successfully." end end diff --git a/app/controllers/totp_controller.rb b/app/controllers/totp_controller.rb new file mode 100644 index 0000000..dfa15c3 --- /dev/null +++ b/app/controllers/totp_controller.rb @@ -0,0 +1,84 @@ +class TotpController < ApplicationController + before_action :set_user + before_action :redirect_if_totp_enabled, only: [:new, :create] + before_action :require_totp_enabled, only: [:backup_codes, :verify_password, :destroy] + + # GET /totp/new - Show QR code to set up TOTP + def new + # Generate TOTP secret but don't save yet + @totp_secret = ROTP::Base32.random + @provisioning_uri = ROTP::TOTP.new(@totp_secret, issuer: "Clinch").provisioning_uri(@user.email_address) + + # Generate QR code + require "rqrcode" + @qr_code = RQRCode::QRCode.new(@provisioning_uri) + end + + # POST /totp - Verify TOTP code and enable 2FA + def create + totp_secret = params[:totp_secret] + code = params[:code] + + # Verify the code works + totp = ROTP::TOTP.new(totp_secret) + if totp.verify(code, drift_behind: 30, drift_ahead: 30) + # Save the secret and generate backup codes + @user.totp_secret = totp_secret + @user.backup_codes = generate_backup_codes + @user.save! + + # Redirect to backup codes page with success message + redirect_to backup_codes_totp_path, notice: "Two-factor authentication has been enabled successfully! Save these backup codes now." + else + redirect_to new_totp_path, alert: "Invalid verification code. Please try again." + end + end + + # GET /totp/backup_codes - Show backup codes (requires password) + def backup_codes + # This will be shown after password verification + @backup_codes = @user.parsed_backup_codes + end + + # POST /totp/verify_password - Verify password before showing backup codes + def verify_password + if @user.authenticate(params[:password]) + redirect_to backup_codes_totp_path + else + redirect_to profile_path, alert: "Incorrect password." + end + end + + # DELETE /totp - Disable TOTP (requires password) + def destroy + unless @user.authenticate(params[:password]) + redirect_to profile_path, alert: "Incorrect password. Could not disable 2FA." + return + end + + @user.disable_totp! + redirect_to profile_path, notice: "Two-factor authentication has been disabled." + end + + private + + def set_user + @user = Current.session.user + end + + def redirect_if_totp_enabled + if @user.totp_enabled? + redirect_to profile_path, alert: "Two-factor authentication is already enabled." + end + end + + def require_totp_enabled + unless @user.totp_enabled? + redirect_to profile_path, alert: "Two-factor authentication is not enabled." + end + end + + def generate_backup_codes + Array.new(10) { SecureRandom.alphanumeric(8).upcase }.to_json + end +end diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb new file mode 100644 index 0000000..a7fc08c --- /dev/null +++ b/app/controllers/users_controller.rb @@ -0,0 +1,36 @@ +class UsersController < ApplicationController + allow_unauthenticated_access only: %i[ new create ] + before_action :ensure_first_run, only: %i[ new create ] + + def new + @user = User.new + end + + def create + @user = User.new(user_params) + + # First user becomes admin automatically + @user.admin = true if User.count.zero? + @user.status = "active" + + if @user.save + start_new_session_for @user + redirect_to root_path, notice: "Welcome to Clinch! Your account has been created." + else + render :new, status: :unprocessable_entity + end + end + + private + + def user_params + params.require(:user).permit(:email_address, :password, :password_confirmation) + end + + def ensure_first_run + # Only allow signup if there are no users (first-run scenario) + if User.exists? + redirect_to signin_path, alert: "Registration is closed. Please sign in." + end + end +end diff --git a/app/models/user.rb b/app/models/user.rb index e67b459..cee6788 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -13,6 +13,7 @@ class User < ApplicationRecord validates :email_address, presence: true, uniqueness: { case_sensitive: false }, format: { with: URI::MailTo::EMAIL_REGEXP } + validates :password, length: { minimum: 8 }, allow_nil: true validates :status, presence: true, inclusion: { in: %w[active disabled pending_invitation] } diff --git a/app/views/dashboard/index.html.erb b/app/views/dashboard/index.html.erb new file mode 100644 index 0000000..b474e6e --- /dev/null +++ b/app/views/dashboard/index.html.erb @@ -0,0 +1,116 @@ +
+

+ Welcome, <%= @user.email_address %> +

+

+ <% if @user.admin? %> + Administrator + <% else %> + User + <% end %> +

+
+ +
+ +
+
+
+
+ + + +
+
+
+
+ Active Sessions +
+
+ <%= @user.sessions.active.count %> +
+
+
+
+
+
+ <%= link_to "View all sessions", profile_path, class: "text-sm font-medium text-blue-600 hover:text-blue-500" %> +
+
+ + <% if @user.totp_enabled? %> + +
+
+
+
+ + + +
+
+
+
+ Two-Factor Authentication +
+
+ Enabled +
+
+
+
+
+
+ <%= link_to "Manage 2FA", profile_path, class: "text-sm font-medium text-blue-600 hover:text-blue-500" %> +
+
+ <% else %> + +
+
+
+
+ + + +
+
+
+
+ Two-Factor Authentication +
+
+ Not Enabled +
+
+
+
+
+
+ <%= link_to "Enable 2FA", profile_path, class: "text-sm font-medium text-blue-600 hover:text-blue-500" %> +
+
+ <% end %> +
+ +<% if @user.admin? %> +
+

Admin Quick Actions

+
+ <%= link_to admin_users_path, class: "block p-6 bg-white rounded-lg border border-gray-200 shadow-sm hover:bg-gray-50 hover:shadow-md transition" do %> +

Manage Users

+

View, edit, and invite users

+ <% end %> + + <%= link_to admin_applications_path, class: "block p-6 bg-white rounded-lg border border-gray-200 shadow-sm hover:bg-gray-50 hover:shadow-md transition" do %> +

Manage Applications

+

Register and configure applications

+ <% end %> + + <%= link_to admin_groups_path, class: "block p-6 bg-white rounded-lg border border-gray-200 shadow-sm hover:bg-gray-50 hover:shadow-md transition" do %> +

Manage Groups

+

Create and organize user groups

+ <% end %> +
+
+<% end %> diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 516626d..f1fd876 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -24,8 +24,51 @@ -
- <%= yield %> -
+ <% if authenticated? %> + <%= render "shared/sidebar" %> +
+ +
+ +
+ +
+
+ <%= render "shared/flash" %> + <%= yield %> +
+
+
+ <% else %> + +
+ <%= render "shared/flash" %> + <%= yield %> +
+ <% end %> + + diff --git a/app/views/profiles/show.html.erb b/app/views/profiles/show.html.erb new file mode 100644 index 0000000..406b488 --- /dev/null +++ b/app/views/profiles/show.html.erb @@ -0,0 +1,246 @@ +
+
+

Profile & Settings

+

Manage your account settings and security preferences.

+
+ + +
+
+

Account Information

+
+ <%= form_with model: @user, url: profile_path, method: :patch, class: "space-y-6" do |form| %> + <% if @user.errors.any? %> +
+

+ <%= pluralize(@user.errors.count, "error") %> prohibited this from being saved: +

+
    + <% @user.errors.each do |error| %> +
  • <%= error.full_message %>
  • + <% end %> +
+
+ <% end %> + +
+ <%= form.label :email_address, "Email Address", class: "block text-sm font-medium text-gray-700" %> + <%= form.email_field :email_address, + required: true, + autocomplete: "email", + class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %> +
+ +
+ <%= form.submit "Update Email", class: "inline-flex justify-center rounded-md border border-transparent bg-blue-600 py-2 px-4 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2" %> +
+ <% end %> +
+
+
+ + +
+
+

Change Password

+
+ <%= form_with model: @user, url: profile_path, method: :patch, class: "space-y-6" do |form| %> +
+ <%= form.label :current_password, "Current Password", class: "block text-sm font-medium text-gray-700" %> + <%= form.password_field :current_password, + autocomplete: "current-password", + placeholder: "Enter current password", + class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %> +
+ +
+ <%= form.label :password, "New Password", class: "block text-sm font-medium text-gray-700" %> + <%= form.password_field :password, + autocomplete: "new-password", + placeholder: "Enter new password", + class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %> +

Must be at least 8 characters

+
+ +
+ <%= form.label :password_confirmation, "Confirm New Password", class: "block text-sm font-medium text-gray-700" %> + <%= form.password_field :password_confirmation, + autocomplete: "new-password", + placeholder: "Confirm new password", + class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %> +
+ +
+ <%= form.submit "Update Password", class: "inline-flex justify-center rounded-md border border-transparent bg-blue-600 py-2 px-4 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2" %> +
+ <% end %> +
+
+
+ + +
+
+

Two-Factor Authentication

+
+

Add an extra layer of security to your account by enabling two-factor authentication.

+
+
+ <% if @user.totp_enabled? %> +
+
+
+ + + +
+
+

+ Two-factor authentication is enabled +

+
+
+
+
+ + +
+ <% else %> + <%= link_to new_totp_path, class: "inline-flex items-center rounded-md border border-transparent bg-blue-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2" do %> + Enable 2FA + <% end %> + <% end %> +
+
+
+ + + + + + + + + + +
+
+

Active Sessions

+
+

These devices are currently signed in to your account. Revoke any sessions that you don't recognize.

+
+
+ <% if @active_sessions.any? %> +
    + <% @active_sessions.each do |session| %> +
  • +
    +
    +

    + <%= session.device_name || "Unknown Device" %> + <% if session.id == Current.session.id %> + + This device + + <% end %> +

    +

    + <%= session.ip_address %> +

    +

    + Last active <%= time_ago_in_words(session.last_activity_at || session.updated_at) %> ago +

    +
    + <% if session.id != Current.session.id %> + <%= button_to "Revoke", session_path(session), method: :delete, + class: "inline-flex items-center rounded-md border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2", + form: { data: { turbo_confirm: "Are you sure you want to revoke this session?" } } %> + <% end %> +
    +
  • + <% end %> +
+ <% else %> +

No other active sessions.

+ <% end %> +
+
+
+
diff --git a/app/views/sessions/new.html.erb b/app/views/sessions/new.html.erb index 308b04b..11d170b 100644 --- a/app/views/sessions/new.html.erb +++ b/app/views/sessions/new.html.erb @@ -1,31 +1,37 @@
- <% if alert = flash[:alert] %> -

<%= alert %>

- <% end %> +
+

Sign in to Clinch

+
- <% if notice = flash[:notice] %> -

<%= notice %>

- <% end %> - -

Sign in

- - <%= form_with url: session_url, class: "contents" do |form| %> + <%= form_with url: signin_path, class: "contents" do |form| %>
- <%= form.email_field :email_address, required: true, autofocus: true, autocomplete: "username", placeholder: "Enter your email address", value: params[:email_address], class: "block shadow-sm rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full" %> + <%= form.label :email_address, "Email Address", class: "block font-medium text-sm text-gray-700" %> + <%= form.email_field :email_address, + required: true, + autofocus: true, + autocomplete: "username", + placeholder: "your@email.com", + value: params[:email_address], + class: "block shadow-sm rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full" %>
- <%= form.password_field :password, required: true, autocomplete: "current-password", placeholder: "Enter your password", maxlength: 72, class: "block shadow-sm rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full" %> + <%= form.label :password, class: "block font-medium text-sm text-gray-700" %> + <%= form.password_field :password, + required: true, + autocomplete: "current-password", + placeholder: "Enter your password", + maxlength: 72, + class: "block shadow-sm rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full" %>
-
-
- <%= form.submit "Sign in", class: "w-full sm:w-auto text-center rounded-md px-3.5 py-2.5 bg-blue-600 hover:bg-blue-500 text-white inline-block font-medium cursor-pointer" %> -
+
+ <%= form.submit "Sign in", + class: "w-full rounded-md px-3.5 py-2.5 bg-blue-600 hover:bg-blue-500 text-white font-medium cursor-pointer" %> +
-
- <%= link_to "Forgot password?", new_password_path, class: "text-gray-700 underline hover:no-underline" %> -
+
+ <%= link_to "Forgot your password?", new_password_path, class: "text-blue-600 hover:text-blue-500 underline" %>
<% end %>
diff --git a/app/views/shared/_flash.html.erb b/app/views/shared/_flash.html.erb new file mode 100644 index 0000000..1d888e6 --- /dev/null +++ b/app/views/shared/_flash.html.erb @@ -0,0 +1,29 @@ +<% if flash[:alert] %> + +<% end %> + +<% if flash[:notice] %> + +<% end %> diff --git a/app/views/shared/_sidebar.html.erb b/app/views/shared/_sidebar.html.erb new file mode 100644 index 0000000..3336732 --- /dev/null +++ b/app/views/shared/_sidebar.html.erb @@ -0,0 +1,186 @@ +<% if authenticated? %> + <% + current_path = request.path + user = Current.session.user + %> + + + + + + +<% end %> diff --git a/app/views/totp/backup_codes.html.erb b/app/views/totp/backup_codes.html.erb new file mode 100644 index 0000000..7f8b1ef --- /dev/null +++ b/app/views/totp/backup_codes.html.erb @@ -0,0 +1,78 @@ +
+
+

Backup Codes

+

+ Save these backup codes in a safe place. Each code can only be used once. +

+
+ +
+
+
+
+ + + +
+

Save these codes now!

+

Store them somewhere safe. You won't be able to see them again without re-entering your password.

+
+
+
+ +
+ <% @backup_codes.each do |code| %> +
+ <%= code %> +
+ <% end %> +
+ +
+ + + +
+ +
+ <%= link_to "Done", profile_path, + class: "inline-flex justify-center rounded-md border border-transparent bg-blue-600 py-2 px-4 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2" %> +
+
+
+
+ + diff --git a/app/views/totp/new.html.erb b/app/views/totp/new.html.erb new file mode 100644 index 0000000..3d7b55c --- /dev/null +++ b/app/views/totp/new.html.erb @@ -0,0 +1,75 @@ +
+
+

Enable Two-Factor Authentication

+

+ Scan the QR code below with your authenticator app, then enter the verification code to confirm. +

+
+ +
+
+ +
+

Step 1: Scan QR Code

+
+ <%= @qr_code.as_svg( + module_size: 4, + color: "000", + shape_rendering: "crispEdges", + standalone: true + ).html_safe %> +
+

+ Use an authenticator app like Google Authenticator, Authy, or 1Password to scan this code. +

+
+ + +
+

Can't scan the QR code?

+

Enter this key manually in your authenticator app:

+ <%= @totp_secret %> +
+ + +
+

Step 2: Verify

+ <%= form_with url: totp_path, method: :post, class: "space-y-4" do |form| %> + <%= hidden_field_tag :totp_secret, @totp_secret %> + +
+ <%= label_tag :code, "Verification Code", class: "block text-sm font-medium text-gray-700" %> + <%= text_field_tag :code, + nil, + placeholder: "000000", + maxlength: 6, + required: true, + autofocus: true, + autocomplete: "off", + class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-center text-2xl tracking-widest font-mono" %> +

Enter the 6-digit code from your authenticator app

+
+ +
+ <%= form.submit "Verify and Enable 2FA", + class: "inline-flex justify-center rounded-md border border-transparent bg-blue-600 py-2 px-4 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2" %> + <%= link_to "Cancel", profile_path, + class: "inline-flex justify-center rounded-md border border-gray-300 bg-white py-2 px-4 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2" %> +
+ <% end %> +
+
+
+ +
+
+ + + +
+

Important: Save your backup codes

+

After verifying, you'll be shown backup codes. Save these in a safe place - they can be used to access your account if you lose your device.

+
+
+
+
diff --git a/app/views/users/new.html.erb b/app/views/users/new.html.erb new file mode 100644 index 0000000..0308adc --- /dev/null +++ b/app/views/users/new.html.erb @@ -0,0 +1,62 @@ +
+
+

Welcome to Clinch

+

Create your admin account to get started

+
+ + <%= form_with model: @user, url: signup_path, class: "contents" do |form| %> + <% if @user.errors.any? %> +
+

<%= pluralize(@user.errors.count, "error") %> prohibited this account from being saved:

+
    + <% @user.errors.each do |error| %> +
  • <%= error.full_message %>
  • + <% end %> +
+
+ <% end %> + +
+ <%= form.label :email_address, class: "block font-medium text-sm text-gray-700" %> + <%= form.email_field :email_address, + required: true, + autofocus: true, + autocomplete: "email", + placeholder: "admin@example.com", + class: "block shadow-sm rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full" %> +
+ +
+ <%= form.label :password, class: "block font-medium text-sm text-gray-700" %> + <%= form.password_field :password, + required: true, + autocomplete: "new-password", + placeholder: "Enter a strong password", + maxlength: 72, + class: "block shadow-sm rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full" %> +

Must be at least 8 characters

+
+ +
+ <%= form.label :password_confirmation, "Confirm Password", class: "block font-medium text-sm text-gray-700" %> + <%= form.password_field :password_confirmation, + required: true, + autocomplete: "new-password", + placeholder: "Re-enter your password", + maxlength: 72, + class: "block shadow-sm rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full" %> +
+ +
+ <%= form.submit "Create Admin Account", + class: "w-full rounded-md px-3.5 py-2.5 bg-blue-600 hover:bg-blue-500 text-white font-medium cursor-pointer" %> +
+ +
+

+ Note: This is a first-run setup. You're creating the initial administrator account. + After this, you'll be able to invite other users from the admin dashboard. +

+
+ <% end %> +
diff --git a/config/cable.yml b/config/cable.yml index b9adc5a..d3aa27b 100644 --- a/config/cable.yml +++ b/config/cable.yml @@ -1,9 +1,11 @@ -# Async adapter only works within the same process, so for manually triggering cable updates from a console, -# and seeing results in the browser, you must do so from the web console (running inside the dev process), -# not a terminal started via bin/rails console! Add "console" to any action or any ERB template view -# to make the web console appear. +# Using Solid Cable for development (same as production). development: - adapter: async + adapter: solid_cable + connects_to: + database: + writing: cable + polling_interval: 0.1.seconds + message_retention: 1.day test: adapter: test diff --git a/config/cache.yml b/config/cache.yml index 19d4908..2535887 100644 --- a/config/cache.yml +++ b/config/cache.yml @@ -6,6 +6,7 @@ default: &default namespace: <%= Rails.env %> development: + database: cache <<: *default test: diff --git a/config/database.yml b/config/database.yml index 693252b..1233d7a 100644 --- a/config/database.yml +++ b/config/database.yml @@ -10,8 +10,21 @@ default: &default timeout: 5000 development: - <<: *default - database: storage/development.sqlite3 + primary: + <<: *default + database: storage/development.sqlite3 + cache: + <<: *default + database: storage/development_cache.sqlite3 + migrations_paths: db/cache_migrate + queue: + <<: *default + database: storage/development_queue.sqlite3 + migrations_paths: db/queue_migrate + cable: + <<: *default + database: storage/development_cable.sqlite3 + migrations_paths: db/cable_migrate # Warning: The database defined as "test" will be erased and # re-generated from your development database when you run "rake". diff --git a/config/environments/development.rb b/config/environments/development.rb index 75243c3..da13670 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -25,8 +25,8 @@ Rails.application.configure do config.action_controller.perform_caching = false end - # Change to :null_store to avoid any caching. - config.cache_store = :memory_store + # Use Solid Cache for development (same as production). + config.cache_store = :solid_cache_store # Store uploaded files on the local file system (see config/storage.yml for options). config.active_storage.service = :local @@ -40,6 +40,9 @@ Rails.application.configure do # Set localhost to be used by links generated in mailer templates. config.action_mailer.default_url_options = { host: "localhost", port: 3000 } + # Log with request_id as a tag (same as production). + config.log_tags = [ :request_id ] + # Print deprecation notices to the Rails logger. config.active_support.deprecation = :log @@ -55,6 +58,11 @@ Rails.application.configure do # Highlight code that enqueued background job in logs. config.active_job.verbose_enqueue_logs = true + # Use Solid Queue for background jobs (same as production). + config.active_job.queue_adapter = :solid_queue + config.solid_queue.connects_to = { database: { writing: :queue } } + + # Highlight code that triggered redirect in logs. config.action_dispatch.verbose_redirect_logs = true diff --git a/config/routes.rb b/config/routes.rb index 29b007b..5127eab 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,16 +1,46 @@ Rails.application.routes.draw do resource :session resources :passwords, param: :token + mount ActionCable.server => "/cable" + # 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. # Can be used by load balancers and uptime monitors to verify that the app is live. get "up" => "rails/health#show", as: :rails_health_check + # Authentication routes + get "/signup", to: "users#new", as: :signup + post "/signup", to: "users#create" + get "/signin", to: "sessions#new", as: :signin + post "/signin", to: "sessions#create" + delete "/signout", to: "sessions#destroy", as: :signout + + # Authenticated routes + root "dashboard#index" + resource :profile, only: [:show, :update] + resources :sessions, only: [] do + member do + delete :destroy, action: :destroy_other + end + end + + # TOTP (2FA) routes + get '/totp/new', to: 'totp#new', as: :new_totp + post '/totp', to: 'totp#create', as: :totp + delete '/totp', to: 'totp#destroy' + get '/totp/backup_codes', to: 'totp#backup_codes', as: :backup_codes_totp + post '/totp/verify_password', to: 'totp#verify_password', as: :verify_password_totp + + # Admin routes + namespace :admin do + root "dashboard#index" + resources :users + resources :applications + resources :groups + end + # Render dynamic PWA files from app/views/pwa/* (remember to link manifest in application.html.erb) # get "manifest" => "rails/pwa#manifest", as: :pwa_manifest # get "service-worker" => "rails/pwa#service_worker", as: :pwa_service_worker - - # Defines the root path route ("/") - # root "posts#index" end diff --git a/db/cable_schema.rb b/db/cable_schema.rb index 2366660..3aefc38 100644 --- a/db/cable_schema.rb +++ b/db/cable_schema.rb @@ -1,9 +1,21 @@ -ActiveRecord::Schema[7.1].define(version: 1) do +# This file is auto-generated from the current state of the database. Instead +# of editing this file, please use the migrations feature of Active Record to +# incrementally modify your database, and then regenerate this schema definition. +# +# This file is the source Rails uses to define your schema when running `bin/rails +# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to +# be faster and is potentially less error prone than running all of your +# migrations from scratch. Old migrations may fail to apply correctly if those +# migrations use external dependencies or application code. +# +# It's strongly recommended that you check this file into your version control system. + +ActiveRecord::Schema[8.1].define(version: 1) do create_table "solid_cable_messages", force: :cascade do |t| t.binary "channel", limit: 1024, null: false - t.binary "payload", limit: 536870912, null: false - t.datetime "created_at", null: false t.integer "channel_hash", limit: 8, null: false + t.datetime "created_at", null: false + t.binary "payload", limit: 536870912, null: false t.index ["channel"], name: "index_solid_cable_messages_on_channel" t.index ["channel_hash"], name: "index_solid_cable_messages_on_channel_hash" t.index ["created_at"], name: "index_solid_cable_messages_on_created_at" diff --git a/db/cache_schema.rb b/db/cache_schema.rb index 6005a29..2016467 100644 --- a/db/cache_schema.rb +++ b/db/cache_schema.rb @@ -1,12 +1,22 @@ -# frozen_string_literal: true +# This file is auto-generated from the current state of the database. Instead +# of editing this file, please use the migrations feature of Active Record to +# incrementally modify your database, and then regenerate this schema definition. +# +# This file is the source Rails uses to define your schema when running `bin/rails +# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to +# be faster and is potentially less error prone than running all of your +# migrations from scratch. Old migrations may fail to apply correctly if those +# migrations use external dependencies or application code. +# +# It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 1) do +ActiveRecord::Schema[8.1].define(version: 1) do create_table "solid_cache_entries", force: :cascade do |t| - t.binary "key", limit: 1024, null: false - t.binary "value", limit: 536870912, null: false - t.datetime "created_at", null: false - t.integer "key_hash", limit: 8, null: false t.integer "byte_size", limit: 4, null: false + t.datetime "created_at", null: false + t.binary "key", limit: 1024, null: false + t.integer "key_hash", limit: 8, null: false + t.binary "value", limit: 536870912, null: false t.index ["byte_size"], name: "index_solid_cache_entries_on_byte_size" t.index ["key_hash", "byte_size"], name: "index_solid_cache_entries_on_key_hash_and_byte_size" t.index ["key_hash"], name: "index_solid_cache_entries_on_key_hash", unique: true diff --git a/db/queue_schema.rb b/db/queue_schema.rb index 85194b6..f56798c 100644 --- a/db/queue_schema.rb +++ b/db/queue_schema.rb @@ -1,123 +1,135 @@ -ActiveRecord::Schema[7.1].define(version: 1) do +# This file is auto-generated from the current state of the database. Instead +# of editing this file, please use the migrations feature of Active Record to +# incrementally modify your database, and then regenerate this schema definition. +# +# This file is the source Rails uses to define your schema when running `bin/rails +# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to +# be faster and is potentially less error prone than running all of your +# migrations from scratch. Old migrations may fail to apply correctly if those +# migrations use external dependencies or application code. +# +# It's strongly recommended that you check this file into your version control system. + +ActiveRecord::Schema[8.1].define(version: 1) do create_table "solid_queue_blocked_executions", force: :cascade do |t| - t.bigint "job_id", null: false - t.string "queue_name", null: false - t.integer "priority", default: 0, null: false t.string "concurrency_key", null: false - t.datetime "expires_at", null: false t.datetime "created_at", null: false - t.index [ "concurrency_key", "priority", "job_id" ], name: "index_solid_queue_blocked_executions_for_release" - t.index [ "expires_at", "concurrency_key" ], name: "index_solid_queue_blocked_executions_for_maintenance" - t.index [ "job_id" ], name: "index_solid_queue_blocked_executions_on_job_id", unique: true + t.datetime "expires_at", null: false + t.bigint "job_id", null: false + t.integer "priority", default: 0, null: false + t.string "queue_name", null: false + t.index ["concurrency_key", "priority", "job_id"], name: "index_solid_queue_blocked_executions_for_release" + t.index ["expires_at", "concurrency_key"], name: "index_solid_queue_blocked_executions_for_maintenance" + t.index ["job_id"], name: "index_solid_queue_blocked_executions_on_job_id", unique: true end create_table "solid_queue_claimed_executions", force: :cascade do |t| + t.datetime "created_at", null: false t.bigint "job_id", null: false t.bigint "process_id" - t.datetime "created_at", null: false - t.index [ "job_id" ], name: "index_solid_queue_claimed_executions_on_job_id", unique: true - t.index [ "process_id", "job_id" ], name: "index_solid_queue_claimed_executions_on_process_id_and_job_id" + t.index ["job_id"], name: "index_solid_queue_claimed_executions_on_job_id", unique: true + t.index ["process_id", "job_id"], name: "index_solid_queue_claimed_executions_on_process_id_and_job_id" end create_table "solid_queue_failed_executions", force: :cascade do |t| - t.bigint "job_id", null: false - t.text "error" t.datetime "created_at", null: false - t.index [ "job_id" ], name: "index_solid_queue_failed_executions_on_job_id", unique: true + t.text "error" + t.bigint "job_id", null: false + t.index ["job_id"], name: "index_solid_queue_failed_executions_on_job_id", unique: true end create_table "solid_queue_jobs", force: :cascade do |t| - t.string "queue_name", null: false - t.string "class_name", null: false - t.text "arguments" - t.integer "priority", default: 0, null: false t.string "active_job_id" - t.datetime "scheduled_at" - t.datetime "finished_at" + t.text "arguments" + t.string "class_name", null: false t.string "concurrency_key" t.datetime "created_at", null: false + t.datetime "finished_at" + t.integer "priority", default: 0, null: false + t.string "queue_name", null: false + t.datetime "scheduled_at" t.datetime "updated_at", null: false - t.index [ "active_job_id" ], name: "index_solid_queue_jobs_on_active_job_id" - t.index [ "class_name" ], name: "index_solid_queue_jobs_on_class_name" - t.index [ "finished_at" ], name: "index_solid_queue_jobs_on_finished_at" - t.index [ "queue_name", "finished_at" ], name: "index_solid_queue_jobs_for_filtering" - t.index [ "scheduled_at", "finished_at" ], name: "index_solid_queue_jobs_for_alerting" + t.index ["active_job_id"], name: "index_solid_queue_jobs_on_active_job_id" + t.index ["class_name"], name: "index_solid_queue_jobs_on_class_name" + t.index ["finished_at"], name: "index_solid_queue_jobs_on_finished_at" + t.index ["queue_name", "finished_at"], name: "index_solid_queue_jobs_for_filtering" + t.index ["scheduled_at", "finished_at"], name: "index_solid_queue_jobs_for_alerting" end create_table "solid_queue_pauses", force: :cascade do |t| - t.string "queue_name", null: false t.datetime "created_at", null: false - t.index [ "queue_name" ], name: "index_solid_queue_pauses_on_queue_name", unique: true + t.string "queue_name", null: false + t.index ["queue_name"], name: "index_solid_queue_pauses_on_queue_name", unique: true end create_table "solid_queue_processes", force: :cascade do |t| + t.datetime "created_at", null: false + t.string "hostname" t.string "kind", null: false t.datetime "last_heartbeat_at", null: false - t.bigint "supervisor_id" - t.integer "pid", null: false - t.string "hostname" t.text "metadata" - t.datetime "created_at", null: false t.string "name", null: false - t.index [ "last_heartbeat_at" ], name: "index_solid_queue_processes_on_last_heartbeat_at" - t.index [ "name", "supervisor_id" ], name: "index_solid_queue_processes_on_name_and_supervisor_id", unique: true - t.index [ "supervisor_id" ], name: "index_solid_queue_processes_on_supervisor_id" + t.integer "pid", null: false + t.bigint "supervisor_id" + t.index ["last_heartbeat_at"], name: "index_solid_queue_processes_on_last_heartbeat_at" + t.index ["name", "supervisor_id"], name: "index_solid_queue_processes_on_name_and_supervisor_id", unique: true + t.index ["supervisor_id"], name: "index_solid_queue_processes_on_supervisor_id" end create_table "solid_queue_ready_executions", force: :cascade do |t| - t.bigint "job_id", null: false - t.string "queue_name", null: false - t.integer "priority", default: 0, null: false t.datetime "created_at", null: false - t.index [ "job_id" ], name: "index_solid_queue_ready_executions_on_job_id", unique: true - t.index [ "priority", "job_id" ], name: "index_solid_queue_poll_all" - t.index [ "queue_name", "priority", "job_id" ], name: "index_solid_queue_poll_by_queue" + t.bigint "job_id", null: false + t.integer "priority", default: 0, null: false + t.string "queue_name", null: false + t.index ["job_id"], name: "index_solid_queue_ready_executions_on_job_id", unique: true + t.index ["priority", "job_id"], name: "index_solid_queue_poll_all" + t.index ["queue_name", "priority", "job_id"], name: "index_solid_queue_poll_by_queue" end create_table "solid_queue_recurring_executions", force: :cascade do |t| - t.bigint "job_id", null: false - t.string "task_key", null: false - t.datetime "run_at", null: false t.datetime "created_at", null: false - t.index [ "job_id" ], name: "index_solid_queue_recurring_executions_on_job_id", unique: true - t.index [ "task_key", "run_at" ], name: "index_solid_queue_recurring_executions_on_task_key_and_run_at", unique: true + t.bigint "job_id", null: false + t.datetime "run_at", null: false + t.string "task_key", null: false + t.index ["job_id"], name: "index_solid_queue_recurring_executions_on_job_id", unique: true + t.index ["task_key", "run_at"], name: "index_solid_queue_recurring_executions_on_task_key_and_run_at", unique: true end create_table "solid_queue_recurring_tasks", force: :cascade do |t| - t.string "key", null: false - t.string "schedule", null: false - t.string "command", limit: 2048 - t.string "class_name" t.text "arguments" - t.string "queue_name" - t.integer "priority", default: 0 - t.boolean "static", default: true, null: false - t.text "description" + t.string "class_name" + t.string "command", limit: 2048 t.datetime "created_at", null: false + t.text "description" + t.string "key", null: false + t.integer "priority", default: 0 + t.string "queue_name" + t.string "schedule", null: false + t.boolean "static", default: true, null: false t.datetime "updated_at", null: false - t.index [ "key" ], name: "index_solid_queue_recurring_tasks_on_key", unique: true - t.index [ "static" ], name: "index_solid_queue_recurring_tasks_on_static" + t.index ["key"], name: "index_solid_queue_recurring_tasks_on_key", unique: true + t.index ["static"], name: "index_solid_queue_recurring_tasks_on_static" end create_table "solid_queue_scheduled_executions", force: :cascade do |t| - t.bigint "job_id", null: false - t.string "queue_name", null: false - t.integer "priority", default: 0, null: false - t.datetime "scheduled_at", null: false t.datetime "created_at", null: false - t.index [ "job_id" ], name: "index_solid_queue_scheduled_executions_on_job_id", unique: true - t.index [ "scheduled_at", "priority", "job_id" ], name: "index_solid_queue_dispatch_all" + t.bigint "job_id", null: false + t.integer "priority", default: 0, null: false + t.string "queue_name", null: false + t.datetime "scheduled_at", null: false + t.index ["job_id"], name: "index_solid_queue_scheduled_executions_on_job_id", unique: true + t.index ["scheduled_at", "priority", "job_id"], name: "index_solid_queue_dispatch_all" end create_table "solid_queue_semaphores", force: :cascade do |t| - t.string "key", null: false - t.integer "value", default: 1, null: false - t.datetime "expires_at", null: false t.datetime "created_at", null: false + t.datetime "expires_at", null: false + t.string "key", null: false t.datetime "updated_at", null: false - t.index [ "expires_at" ], name: "index_solid_queue_semaphores_on_expires_at" - t.index [ "key", "value" ], name: "index_solid_queue_semaphores_on_key_and_value" - t.index [ "key" ], name: "index_solid_queue_semaphores_on_key", unique: true + t.integer "value", default: 1, null: false + t.index ["expires_at"], name: "index_solid_queue_semaphores_on_expires_at" + t.index ["key", "value"], name: "index_solid_queue_semaphores_on_key_and_value" + t.index ["key"], name: "index_solid_queue_semaphores_on_key", unique: true end add_foreign_key "solid_queue_blocked_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade