# Velour Phase 2: Authentication & Multi-User Phase 2 adds user management, authentication, and multi-user support while maintaining the simplicity of the core application. ## Authentication Architecture ### Rails Authentication Generators + OIDC Extension We use Rails' built-in authentication generators as the foundation, extended with OIDC support for enterprise environments. ### User Model ```ruby class User < ApplicationRecord # Include default devise modules or Rails authentication # Devise modules: :database_authenticatable, :registerable, # :recoverable, :rememberable, :validatable has_many :playback_sessions, dependent: :destroy has_many :user_preferences, dependent: :destroy validates :email, presence: true, uniqueness: true enum role: { user: 0, admin: 1 } def admin? role == "admin" || email == ENV.fetch("ADMIN_EMAIL", "").downcase end def can_manage_storage? admin? end def can_manage_users? admin? end end ``` ### Authentication Setup ```bash # Generate Rails authentication rails generate authentication # Add OIDC support gem 'omniauth-openid-connect' bundle install ``` ### OIDC Configuration ```ruby # config/initializers/omniauth.rb Rails.application.config.middleware.use OmniAuth::Builder do provider :openid_connect, { name: :oidc, issuer: ENV['OIDC_ISSUER'], client_id: ENV['OIDC_CLIENT_ID'], client_secret: ENV['OIDC_CLIENT_SECRET'], scope: [:openid, :email, :profile], response_type: :code, client_options: { identifier: ENV['OIDC_CLIENT_ID'], secret: ENV['OIDC_CLIENT_SECRET'], redirect_uri: "#{ENV['RAILS_HOST']}/auth/oidc/callback" } } end ``` ## First User Bootstrap Flow ### Initial Setup ```ruby # db/seeds.rb admin_email = ENV.fetch('ADMIN_EMAIL', 'admin@velour.local') User.find_or_create_by!(email: admin_email) do |user| user.password = SecureRandom.hex(16) user.role = :admin puts "Created admin user: #{admin_email}" puts "Password: #{user.password}" end ``` ### First Login Controller ```ruby class FirstSetupController < ApplicationController before_action :ensure_no_users_exist before_action :require_admin_setup, only: [:create_admin] def show @user = User.new end def create_admin @user = User.new(user_params) @user.role = :admin if @user.save session[:user_id] = @user.id redirect_to root_path, notice: "Admin account created successfully!" else render :show, status: :unprocessable_entity end end private def ensure_no_users_exist redirect_to root_path if User.exists? end def require_admin_setup redirect_to first_setup_path unless ENV.key?('ADMIN_EMAIL') end def user_params params.require(:user).permit(:email, :password, :password_confirmation) end end ``` ## User Management ### User Preferences ```ruby class UserPreference < ApplicationRecord belongs_to :user store :settings, coder: JSON, accessors: [ :default_video_quality, :auto_play_next, :subtitle_language, :theme ] validates :user_id, presence: true, uniqueness: true end ``` ### Per-User Playback Sessions ```ruby class PlaybackSession < ApplicationRecord belongs_to :user belongs_to :video scope :for_user, ->(user) { where(user: user) } scope :recent, -> { order(updated_at: :desc) } def self.resume_position_for(video, user) for_user(user).where(video: video).last&.position || 0 end end ``` ## Authorization & Security ### Model-Level Authorization ```ruby # app/models/concerns/authorizable.rb module Authorizable extend ActiveSupport::Concern def viewable_by?(user) true # All videos viewable by all users end def editable_by?(user) user.admin? end end # Include in Video and Work models class Video < ApplicationRecord include Authorizable # ... rest of model end ``` ### Controller Authorization ```ruby class ApplicationController < ActionController::Base before_action :authenticate_user! before_action :set_current_user private def set_current_user Current.user = current_user end def require_admin redirect_to root_path, alert: "Access denied" unless current_user&.admin? end end class StorageLocationsController < ApplicationController before_action :require_admin, except: [:index, :show] # ... rest of controller end ``` ## Updated Controllers for Multi-User ### Videos Controller ```ruby class VideosController < ApplicationController before_action :set_video, only: [:show, :stream, :update_position] before_action :authorize_video def show @work = @video.work @last_position = PlaybackSession.resume_position_for(@video, current_user) # Create playback session for tracking @playback_session = current_user.playback_sessions.create!( video: @video, position: @last_position ) end def stream unless @video.viewable_by?(current_user) head :forbidden return end send_file @video.web_stream_path, type: "video/mp4", disposition: "inline", range: request.headers['Range'] end def update_position current_user.playback_sessions.where(video: @video).last&.update!( position: params[:position], completed: params[:completed] || false ) head :ok end private def set_video @video = Video.find(params[:id]) end def authorize_video head :forbidden unless @video.viewable_by?(current_user) end end ``` ### Storage Locations Controller (Admin Only) ```ruby class StorageLocationsController < ApplicationController before_action :require_admin, except: [:index, :show] before_action :set_storage_location, only: [:show, :destroy, :scan] def index @storage_locations = StorageLocation.accessible.order(:name) end def show @videos = @storage_location.videos.includes(:work).order(:filename) end def create @storage_location = StorageLocation.new(storage_location_params) if @storage_location.save redirect_to @storage_location, notice: 'Storage location was successfully created.' else render :new, status: :unprocessable_entity end end def destroy if @storage_location.videos.exists? redirect_to @storage_location, alert: 'Cannot delete storage location with videos.' else @storage_location.destroy redirect_to storage_locations_path, notice: 'Storage location was successfully deleted.' end end def scan service = FileScannerService.new(@storage_location) result = service.scan if result[:success] redirect_to @storage_location, notice: result[:message] else redirect_to @storage_location, alert: result[:message] end end private def set_storage_location @storage_location = StorageLocation.find(params[:id]) end def storage_location_params params.require(:storage_location).permit(:name, :path, :storage_type) end end ``` ## User Interface Updates ### User Navigation ```erb ``` ### Admin Panel ```erb

Admin Dashboard

Users

<%= User.count %>

Total users

<%= link_to 'Manage Users', admin_users_path, class: 'mt-2 text-blue-500 hover:text-blue-700' %>

Videos

<%= Video.count %>

Total videos

<%= link_to 'View Library', works_path, class: 'mt-2 text-green-500 hover:text-green-700' %>

Storage

<%= StorageLocation.count %>

Storage locations

<%= link_to 'Manage Storage', storage_locations_path, class: 'mt-2 text-purple-500 hover:text-purple-700' %>

System Status

Background Jobs: <%= SolidQueue::Job.count %>
Processing Jobs: <%= SolidQueue::Job.where(finished_at: nil).count %>
Failed Jobs: <%= SolidQueue::FailedExecution.count %>
``` ## Environment Configuration ### New Environment Variables ```bash # Authentication ADMIN_EMAIL=admin@yourdomain.com RAILS_HOST=https://your-velour-domain.com # OIDC (optional) OIDC_ISSUER=https://your-oidc-provider.com OIDC_CLIENT_ID=your_client_id OIDC_CLIENT_SECRET=your_client_secret # Session management RAILS_SESSION_COOKIE_SECURE=true RAILS_SESSION_COOKIE_SAME_SITE=lax ``` ## Testing for Phase 2 ### Authentication Tests ```ruby # test/integration/authentication_test.rb class AuthenticationTest < ActionDispatch::IntegrationTest test "first user can create admin account" do User.delete_all get first_setup_path assert_response :success post first_setup_path, params: { user: { email: "admin@example.com", password: "password123", password_confirmation: "password123" } } assert_redirected_to root_path assert_equal "admin@example.com", User.last.email assert User.last.admin? end test "regular users cannot access admin features" do user = users(:regular) sign_in user get storage_locations_path assert_redirected_to root_path post storage_locations_path, params: { storage_location: { name: "Test", path: "/test", storage_type: "local" } } assert_redirected_to root_path end end ``` ## Migration from Phase 1 ### Database Migration ```ruby class AddAuthenticationToUsers < ActiveRecord::Migration[7.1] def change create_table :users do |t| t.string :email, null: false, index: { unique: true } t.string :encrypted_password, null: false t.string :role, default: 'user', null: false t.timestamps null: false end create_table :user_preferences do |t| t.references :user, null: false, foreign_key: true t.json :settings, default: {} t.timestamps null: false end # Add user_id to existing playback_sessions add_reference :playback_sessions, :user, null: false, foreign_key: true # Create first admin if ADMIN_EMAIL is set if ENV.key?('ADMIN_EMAIL') User.create!( email: ENV['ADMIN_EMAIL'], password: SecureRandom.hex(16), role: 'admin' ) end end end ``` Phase 2 provides a complete multi-user system while maintaining the simplicity of Phase 1. Users can have personal playback history, and administrators can manage the system through a clean interface.