Files
velour/docs/phases/phase_2.md
Dan Milne 88a906064f
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
Much base work started
2025-10-31 14:36:14 +11:00

13 KiB

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

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

# Generate Rails authentication
rails generate authentication

# Add OIDC support
gem 'omniauth-openid-connect'
bundle install

OIDC Configuration

# 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

# 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

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

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

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

# 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

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

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)

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

<!-- app/views/layouts/application.html.erb -->
<nav class="bg-gray-800 text-white p-4">
  <div class="container mx-auto flex justify-between items-center">
    <%= link_to 'Velour', root_path, class: 'text-xl font-bold' %>

    <div class="flex items-center space-x-4">
      <%= link_to 'Library', works_path, class: 'hover:text-gray-300' %>
      <%= link_to 'Storage', storage_locations_path, class: 'hover:text-gray-300' if current_user.admin? %>

      <div class="relative">
        <button data-action="click->dropdown#toggle" class="flex items-center">
          <%= current_user.email %>
          <svg class="w-4 h-4 ml-1" fill="currentColor" viewBox="0 0 20 20">
            <path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd"/>
          </svg>
        </button>

        <div data-dropdown-target="menu" class="hidden absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg py-1 z-50">
          <%= link_to 'Settings', edit_user_registration_path, class: 'block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100' %>
          <%= link_to 'Admin Panel', admin_path, class: 'block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100' if current_user.admin? %>
          <%= button_to 'Sign Out', destroy_user_session_path, method: :delete, class: 'block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100' %>
        </div>
      </div>
    </div>
  </div>
</nav>

Admin Panel

<!-- app/views/admin/dashboard.html.erb -->
<div class="container mx-auto p-6">
  <h1 class="text-3xl font-bold mb-6">Admin Dashboard</h1>

  <div class="grid grid-cols-1 md:grid-cols-3 gap-6">
    <div class="bg-white p-6 rounded-lg shadow">
      <h2 class="text-xl font-semibold mb-4">Users</h2>
      <p class="text-3xl font-bold text-blue-600"><%= User.count %></p>
      <p class="text-gray-600">Total users</p>
      <%= link_to 'Manage Users', admin_users_path, class: 'mt-2 text-blue-500 hover:text-blue-700' %>
    </div>

    <div class="bg-white p-6 rounded-lg shadow">
      <h2 class="text-xl font-semibold mb-4">Videos</h2>
      <p class="text-3xl font-bold text-green-600"><%= Video.count %></p>
      <p class="text-gray-600">Total videos</p>
      <%= link_to 'View Library', works_path, class: 'mt-2 text-green-500 hover:text-green-700' %>
    </div>

    <div class="bg-white p-6 rounded-lg shadow">
      <h2 class="text-xl font-semibold mb-4">Storage</h2>
      <p class="text-3xl font-bold text-purple-600"><%= StorageLocation.count %></p>
      <p class="text-gray-600">Storage locations</p>
      <%= link_to 'Manage Storage', storage_locations_path, class: 'mt-2 text-purple-500 hover:text-purple-700' %>
    </div>
  </div>

  <div class="mt-8 bg-white p-6 rounded-lg shadow">
    <h2 class="text-xl font-semibold mb-4">System Status</h2>
    <div class="space-y-2">
      <div class="flex justify-between">
        <span>Background Jobs:</span>
        <span class="font-mono"><%= SolidQueue::Job.count %></span>
      </div>
      <div class="flex justify-between">
        <span>Processing Jobs:</span>
        <span class="font-mono"><%= SolidQueue::Job.where(finished_at: nil).count %></span>
      </div>
      <div class="flex justify-between">
        <span>Failed Jobs:</span>
        <span class="font-mono"><%= SolidQueue::FailedExecution.count %></span>
      </div>
    </div>
  </div>
</div>

Environment Configuration

New Environment Variables

# 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

# 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

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.