Much base work started
This commit is contained in:
486
docs/phases/phase_2.md
Normal file
486
docs/phases/phase_2.md
Normal file
@@ -0,0 +1,486 @@
|
||||
# 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
|
||||
<!-- 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
|
||||
```erb
|
||||
<!-- 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
|
||||
```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.
|
||||
Reference in New Issue
Block a user