Compare commits

..

2 Commits

Author SHA1 Message Date
Dan Milne
311ecafb74 More complete oauth. Handle deleted OauthApp by re-registering
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
2025-10-08 08:19:39 +11:00
Dan Milne
6d74e7aff1 Update gems 2025-10-08 08:16:04 +11:00
14 changed files with 292 additions and 124 deletions

View File

@@ -1,29 +1,29 @@
GEM
remote: https://rubygems.org/
specs:
actioncable (8.0.2.1)
actionpack (= 8.0.2.1)
activesupport (= 8.0.2.1)
actioncable (8.0.3)
actionpack (= 8.0.3)
activesupport (= 8.0.3)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6)
actionmailbox (8.0.2.1)
actionpack (= 8.0.2.1)
activejob (= 8.0.2.1)
activerecord (= 8.0.2.1)
activestorage (= 8.0.2.1)
activesupport (= 8.0.2.1)
actionmailbox (8.0.3)
actionpack (= 8.0.3)
activejob (= 8.0.3)
activerecord (= 8.0.3)
activestorage (= 8.0.3)
activesupport (= 8.0.3)
mail (>= 2.8.0)
actionmailer (8.0.2.1)
actionpack (= 8.0.2.1)
actionview (= 8.0.2.1)
activejob (= 8.0.2.1)
activesupport (= 8.0.2.1)
actionmailer (8.0.3)
actionpack (= 8.0.3)
actionview (= 8.0.3)
activejob (= 8.0.3)
activesupport (= 8.0.3)
mail (>= 2.8.0)
rails-dom-testing (~> 2.2)
actionpack (8.0.2.1)
actionview (= 8.0.2.1)
activesupport (= 8.0.2.1)
actionpack (8.0.3)
actionview (= 8.0.3)
activesupport (= 8.0.3)
nokogiri (>= 1.8.5)
rack (>= 2.2.4)
rack-session (>= 1.0.1)
@@ -31,35 +31,35 @@ GEM
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
useragent (~> 0.16)
actiontext (8.0.2.1)
actionpack (= 8.0.2.1)
activerecord (= 8.0.2.1)
activestorage (= 8.0.2.1)
activesupport (= 8.0.2.1)
actiontext (8.0.3)
actionpack (= 8.0.3)
activerecord (= 8.0.3)
activestorage (= 8.0.3)
activesupport (= 8.0.3)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5)
actionview (8.0.2.1)
activesupport (= 8.0.2.1)
actionview (8.0.3)
activesupport (= 8.0.3)
builder (~> 3.1)
erubi (~> 1.11)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
activejob (8.0.2.1)
activesupport (= 8.0.2.1)
activejob (8.0.3)
activesupport (= 8.0.3)
globalid (>= 0.3.6)
activemodel (8.0.2.1)
activesupport (= 8.0.2.1)
activerecord (8.0.2.1)
activemodel (= 8.0.2.1)
activesupport (= 8.0.2.1)
activemodel (8.0.3)
activesupport (= 8.0.3)
activerecord (8.0.3)
activemodel (= 8.0.3)
activesupport (= 8.0.3)
timeout (>= 0.4.0)
activestorage (8.0.2.1)
actionpack (= 8.0.2.1)
activejob (= 8.0.2.1)
activerecord (= 8.0.2.1)
activesupport (= 8.0.2.1)
activestorage (8.0.3)
actionpack (= 8.0.3)
activejob (= 8.0.3)
activerecord (= 8.0.3)
activesupport (= 8.0.3)
marcel (~> 1.0)
activesupport (8.0.2.1)
activesupport (8.0.3)
base64
benchmark (>= 0.3)
bigdecimal
@@ -113,14 +113,14 @@ GEM
addressable (~> 2.8)
drb (2.2.3)
ed25519 (1.4.0)
erb (5.0.2)
erb (5.0.3)
erubi (1.13.1)
et-orbi (1.3.0)
et-orbi (1.4.0)
tzinfo
fugit (1.11.2)
et-orbi (~> 1, >= 1.2.11)
raabro (~> 1.4)
globalid (1.2.1)
globalid (1.3.0)
activesupport (>= 6.1)
i18n (1.14.7)
concurrent-ruby (~> 1.0)
@@ -138,7 +138,7 @@ GEM
activesupport (>= 7.0.0)
jsbundling-rails (1.3.1)
railties (>= 6.0.0)
json (2.13.2)
json (2.15.0)
kamal (2.7.0)
activesupport (>= 7.0)
base64 (~> 0.2)
@@ -161,7 +161,7 @@ GEM
net-imap
net-pop
net-smtp
marcel (1.0.4)
marcel (1.1.0)
matrix (0.4.3)
mini_mime (1.1.5)
minitest (5.25.5)
@@ -176,7 +176,7 @@ GEM
stimulus-rails
turbo-rails
msgpack (1.8.0)
net-imap (0.5.10)
net-imap (0.5.12)
date
net-protocol
net-pop (0.1.2)
@@ -217,11 +217,11 @@ GEM
phlex (~> 2.3.0)
railties (>= 7.1, < 9)
zeitwerk (~> 2.7)
pp (0.6.2)
pp (0.6.3)
prettyprint
prettyprint (0.2.0)
prism (1.5.1)
propshaft (1.2.1)
propshaft (1.3.1)
actionpack (>= 7.0.0)
activesupport (>= 7.0.0)
rack
@@ -229,11 +229,11 @@ GEM
date
stringio
public_suffix (6.0.2)
puma (7.0.3)
puma (7.0.4)
nio4r (~> 2.0)
raabro (1.4.0)
racc (1.8.1)
rack (3.2.1)
rack (3.2.2)
rack-session (2.1.1)
base64 (>= 0.1.0)
rack (>= 3.0.0)
@@ -241,20 +241,20 @@ GEM
rack (>= 1.3)
rackup (2.2.1)
rack (>= 3)
rails (8.0.2.1)
actioncable (= 8.0.2.1)
actionmailbox (= 8.0.2.1)
actionmailer (= 8.0.2.1)
actionpack (= 8.0.2.1)
actiontext (= 8.0.2.1)
actionview (= 8.0.2.1)
activejob (= 8.0.2.1)
activemodel (= 8.0.2.1)
activerecord (= 8.0.2.1)
activestorage (= 8.0.2.1)
activesupport (= 8.0.2.1)
rails (8.0.3)
actioncable (= 8.0.3)
actionmailbox (= 8.0.3)
actionmailer (= 8.0.3)
actionpack (= 8.0.3)
actiontext (= 8.0.3)
actionview (= 8.0.3)
activejob (= 8.0.3)
activemodel (= 8.0.3)
activerecord (= 8.0.3)
activestorage (= 8.0.3)
activesupport (= 8.0.3)
bundler (>= 1.15.0)
railties (= 8.0.2.1)
railties (= 8.0.3)
rails-dom-testing (2.3.0)
activesupport (>= 5.0.0)
minitest
@@ -262,24 +262,26 @@ GEM
rails-html-sanitizer (1.6.2)
loofah (~> 2.21)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
railties (8.0.2.1)
actionpack (= 8.0.2.1)
activesupport (= 8.0.2.1)
railties (8.0.3)
actionpack (= 8.0.3)
activesupport (= 8.0.3)
irb (~> 1.13)
rackup (>= 1.0.0)
rake (>= 12.2)
thor (~> 1.0, >= 1.2.2)
tsort (>= 0.2)
zeitwerk (~> 2.6)
rainbow (3.1.1)
rake (13.3.0)
rdoc (6.14.2)
rdoc (6.15.0)
erb
psych (>= 4.0.0)
tsort
regexp_parser (2.11.3)
reline (0.6.2)
io-console (~> 0.5)
rexml (3.4.4)
rubocop (1.80.2)
rubocop (1.81.1)
json (~> 2.3)
language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0)
@@ -287,17 +289,17 @@ GEM
parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 2.9.3, < 3.0)
rubocop-ast (>= 1.46.0, < 2.0)
rubocop-ast (>= 1.47.1, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.46.0)
rubocop-ast (1.47.1)
parser (>= 3.3.7.2)
prism (~> 1.4)
rubocop-performance (1.26.0)
lint_roller (~> 1.1)
rubocop (>= 1.75.0, < 2.0)
rubocop-ast (>= 1.44.0, < 2.0)
rubocop-rails (2.33.3)
rubocop-rails (2.33.4)
activesupport (>= 4.2.0)
lint_roller (~> 1.1)
rack (>= 1.1)
@@ -308,7 +310,7 @@ GEM
rubocop-performance (>= 1.24)
rubocop-rails (>= 2.30)
ruby-progressbar (1.13.0)
rubyzip (3.1.0)
rubyzip (3.1.1)
securerandom (0.4.1)
selenium-webdriver (4.35.0)
base64 (~> 0.2)
@@ -333,13 +335,13 @@ GEM
railties (>= 7.1)
thor (>= 1.3.1)
sqids (0.2.2)
sqlite3 (2.7.3-aarch64-linux-gnu)
sqlite3 (2.7.3-aarch64-linux-musl)
sqlite3 (2.7.3-arm-linux-gnu)
sqlite3 (2.7.3-arm-linux-musl)
sqlite3 (2.7.3-arm64-darwin)
sqlite3 (2.7.3-x86_64-linux-gnu)
sqlite3 (2.7.3-x86_64-linux-musl)
sqlite3 (2.7.4-aarch64-linux-gnu)
sqlite3 (2.7.4-aarch64-linux-musl)
sqlite3 (2.7.4-arm-linux-gnu)
sqlite3 (2.7.4-arm-linux-musl)
sqlite3 (2.7.4-arm64-darwin)
sqlite3 (2.7.4-x86_64-linux-gnu)
sqlite3 (2.7.4-x86_64-linux-musl)
sshkit (1.24.0)
base64
logger
@@ -365,7 +367,8 @@ GEM
thruster (0.1.15-arm64-darwin)
thruster (0.1.15-x86_64-linux)
timeout (0.4.3)
turbo-rails (2.0.16)
tsort (0.2.0)
turbo-rails (2.0.17)
actionpack (>= 7.1.0)
railties (>= 7.1.0)
tzinfo (2.0.6)
@@ -373,7 +376,7 @@ GEM
unicode-display_width (3.2.0)
unicode-emoji (~> 4.1)
unicode-emoji (4.1.0)
uri (1.0.3)
uri (1.0.4)
useragent (0.16.11)
web-console (4.2.1)
actionview (>= 6.0.0)

View File

@@ -16,6 +16,18 @@ class Components::Libraries::EditView < Components::Base
end
div(class: "bg-white rounded-lg shadow-md p-6") do
# Display errors if any
if @library.errors.any?
div(class: "mb-6 bg-red-50 border border-red-200 rounded-lg p-4") do
h3(class: "text-red-800 font-semibold mb-2") { "Error#{@library.errors.count > 1 ? 's' : ''}" }
ul(class: "list-disc list-inside text-red-700 text-sm") do
@library.errors.full_messages.each do |message|
li { message }
end
end
end
end
form(action: library_path(@library), method: "post") do
input(type: "hidden", name: "_method", value: "patch")
input(type: "hidden", name: "authenticity_token", value: form_authenticity_token)

View File

@@ -101,32 +101,47 @@ class Components::User::ProfileView < Components::Base
# API Configuration Section
div(class: "px-6 py-6 border-t border-gray-200") do
h3(class: "text-lg font-medium text-gray-900 mb-6") { "API Configuration" }
div(class: "space-y-4") do
h3(class: "text-lg font-medium text-gray-900 mb-6") { "TBDB Integration" }
div(class: "space-y-6") do
# OAuth Connection Status
div do
dt(class: "text-sm font-medium text-gray-700 mb-2") { "TheBookDB API Token" }
dd(class: "text-xs text-gray-500 mb-3") { "Personal API token for accessing TheBookDB.info service. Falls back to application default if not set." }
if @user.has_thebookdb_api_token?
div(class: "text-sm text-gray-900 font-mono bg-gray-50 px-3 py-2 rounded border") do
token = @user.thebookdb_api_token
masked_token = token[0..7] + "..." + token[-4..-1]
masked_token
dt(class: "text-sm font-medium text-gray-700 mb-2") { "OAuth Connection" }
dd(class: "text-xs text-gray-500 mb-3") { "Secure OAuth connection to TheBookDB.info for enhanced product data access." }
if @user.has_oauth_connection?
div(class: "flex items-center justify-between p-3 bg-green-50 border border-green-200 rounded") do
div do
div(class: "text-sm font-medium text-green-800") { "Connected to TBDB" }
if @user.oauth_token_expired?
div(class: "text-xs text-amber-600 mt-1") { "Token expired - will refresh automatically" }
else
div(class: "text-xs text-green-600 mt-1") { "Active connection" }
end
end
a(
href: auth_tbdb_disconnect_path,
data: {
turbo_method: "delete",
turbo_confirm: "Are you sure you want to disconnect from TBDB?"
},
class: "text-sm text-red-600 hover:text-red-700 font-medium"
) { "Disconnect" }
end
div(class: "text-xs text-green-600 mt-1") { "Using personal token" }
else
if ENV["TBDB_API_TOKEN"].present?
div(class: "text-sm text-gray-600 bg-gray-50 px-3 py-2 rounded border") do
"Using application default"
end
else
div(class: "text-sm text-red-600 bg-red-50 px-3 py-2 rounded border border-red-200") do
"No token configured"
div(class: "flex items-center justify-between p-3 bg-gray-50 border border-gray-200 rounded") do
div do
div(class: "text-sm font-medium text-gray-700") { "Not Connected" }
div(class: "text-xs text-gray-500 mt-1") { "Connect for seamless API access" }
end
a(
href: auth_tbdb_path,
class: "inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
) { "Connect to TBDB" }
end
end
end
end
end

View File

@@ -90,7 +90,7 @@ class LibrariesController < ApplicationController
private
def library_params
params.require(:library).permit(:name, :description, :bulk_barcodes)
params.expect(library: [:name, :description, :bulk_barcodes])
end
def process_bulk_barcodes(library, bulk_barcodes_text)
@@ -114,8 +114,7 @@ class LibrariesController < ApplicationController
# Create library item
LibraryItem.create!(
library: library,
product: product,
user: Current.user
product: product
)
# Create scan record for the user
@@ -141,14 +140,6 @@ class LibrariesController < ApplicationController
end
def find_or_create_product(gtin)
product = Product.find_by(gtin: gtin)
return product if product
# Create new product with minimal data
Product.create!(
gtin: gtin,
title: "Unknown Product (#{gtin})",
product_type: 'other'
)
Product.findd(gtin, title: "Unknown Product (#{gtin})", product_type: 'other')
end
end

View File

@@ -0,0 +1,69 @@
class OauthController < ApplicationController
before_action :require_authentication
def tbdb
oauth_service = TbdbOauthService.new(Current.user)
begin
authorization_url = oauth_service.authorization_url
redirect_to authorization_url, allow_other_host: true
rescue TbdbOauthService::OAuthError => e
Rails.logger.error "OAuth initiation failed: #{e.message}"
redirect_to profile_path, alert: "Failed to connect to TBDB: #{e.message}"
end
end
def tbdb_callback
code = params[:code]
state = params[:state]
error = params[:error]
error_hint = params[:error_hint]
if error.present?
# Handle case where OAuth client is invalid/not found on TBDB
if error == "invalid_client" && error_hint == "client_not_found"
Rails.logger.info "OAuth client not found on TBDB, clearing credentials and re-registering"
oauth_service = TbdbOauthService.new(Current.user)
begin
# Clear the invalid credentials
oauth_service.clear_client_credentials
# Redirect back to initiate OAuth flow, which will re-register
redirect_to auth_tbdb_path, notice: "Re-registering with TBDB..."
return
rescue => e
Rails.logger.error "Failed to handle client re-registration: #{e.message}"
redirect_to profile_path, alert: "OAuth client not found. Please try connecting again."
return
end
end
Rails.logger.error "OAuth callback error: #{error} (hint: #{error_hint})"
redirect_to profile_path, alert: "TBDB authorization failed: #{params[:error_description] || error}"
return
end
if code.blank?
redirect_to profile_path, alert: "No authorization code received from TBDB"
return
end
oauth_service = TbdbOauthService.new(Current.user)
begin
oauth_service.exchange_code_for_token(code, state)
redirect_to profile_path, notice: "Successfully connected to TBDB!"
rescue TbdbOauthService::OAuthError => e
Rails.logger.error "OAuth token exchange failed: #{e.message}"
redirect_to profile_path, alert: "Failed to complete TBDB connection: #{e.message}"
end
end
def tbdb_disconnect
oauth_service = TbdbOauthService.new(Current.user)
oauth_service.revoke_tokens
redirect_to profile_path, notice: "Disconnected from TBDB"
end
end

View File

@@ -35,4 +35,29 @@ class User < ApplicationRecord
def effective_thebookdb_api_token
has_thebookdb_api_token? ? thebookdb_api_token : ENV["TBDB_API_TOKEN"]
end
# OAuth management
def has_oauth_connection?
oauth_client_id.present? && oauth_access_token.present?
end
def oauth_token_expired?
oauth_expires_at.nil? || oauth_expires_at <= Time.current
end
def oauth_token_valid?
has_oauth_connection? && !oauth_token_expired?
end
def effective_tbdb_token
oauth_token_valid? ? oauth_access_token : effective_thebookdb_api_token
end
def clear_oauth_connection
update!(
oauth_access_token: nil,
oauth_refresh_token: nil,
oauth_expires_at: nil
)
end
end

View File

@@ -84,7 +84,7 @@ class ProductEnrichmentService
# Update basic product info if missing or improve existing
attributes[:title] = tbdb_data["title"] if tbdb_data["title"].present? && (product.title.blank? || product.title.start_with?("Unknown "))
attributes[:subtitle] = tbdb_data["subtitle"] if tbdb_data["subtitle"].present?
attributes[:author] = tbdb_data["authors"]&.first&.dig("name") if product.author.blank?
attributes[:author] = tbdb_data["author"] if product.author.blank?
attributes[:publisher] = tbdb_data["publisher"] if product.publisher.blank?
attributes[:description] = tbdb_data["description"] if product.description.blank?
attributes[:pages] = tbdb_data["pages"] if product.pages.blank?

View File

@@ -28,14 +28,23 @@ module ShelfLife
def get_or_create_client(token, user)
# Create a cache key based on user ID or 'system' for ENV token
# Include OAuth status in cache key to avoid conflicts
cache_key = if user&.id
"tbdb_client:#{user.id}"
oauth_status = user.has_oauth_connection? ? "oauth" : "api"
"tbdb_client:#{user.id}:#{oauth_status}"
else
"tbdb_client:system"
end
Rails.cache.fetch(cache_key, expires_in: 25.minutes) do
Tbdb::Client.new(api_token: token)
if user&.has_oauth_connection?
Tbdb::Client.new(
oauth_token: user.oauth_access_token,
user: user
)
else
Tbdb::Client.new(api_token: token)
end
end
end

View File

@@ -13,16 +13,21 @@ module Tbdb
# Use production TBDB API by default (will move to api.tbdb.info soon)
DEFAULT_BASE_URI = ENV.fetch("TBDB_API_URI", "https://api.thebookdb.info").freeze
attr_reader :api_token, :jwt_token, :jwt_expires_at, :base_uri, :last_request_time
attr_reader :api_token, :oauth_token, :user, :jwt_token, :jwt_expires_at, :base_uri, :last_request_time
def initialize(api_token: ENV["TBDB_API_TOKEN"], base_uri: DEFAULT_BASE_URI)
def initialize(api_token: nil, oauth_token: nil, user: nil, base_uri: DEFAULT_BASE_URI)
@api_token = api_token
@oauth_token = oauth_token
@user = user
@base_uri = URI(base_uri)
@jwt_token = nil
@jwt_expires_at = nil
@last_request_time = nil
validate_api_token!
# Determine the token to use
@effective_token = determine_effective_token
validate_token!
ensure_valid_jwt
end
@@ -54,9 +59,36 @@ module Tbdb
private
def validate_api_token!
if @api_token.nil? || @api_token.empty?
raise ArgumentError, "TBDB_API_TOKEN environment variable is required"
def determine_effective_token
# Priority: OAuth token > API token > ENV token
if @oauth_token.present?
refresh_oauth_token_if_needed
@oauth_token
elsif @api_token.present?
@api_token
else
ENV["TBDB_API_TOKEN"]
end
end
def refresh_oauth_token_if_needed
return unless @user&.oauth_token_expired?
Rails.logger.debug "OAuth token expired, attempting refresh..."
oauth_service = TbdbOauthService.new(@user)
if oauth_service.refresh_access_token
@oauth_token = @user.oauth_access_token
Rails.logger.debug "OAuth token refreshed successfully"
else
Rails.logger.warn "OAuth token refresh failed, falling back to API token"
@oauth_token = nil
end
end
def validate_token!
if @effective_token.nil? || @effective_token.empty?
raise ArgumentError, "No valid token available (OAuth, API, or ENV)"
end
end
@@ -77,7 +109,7 @@ module Tbdb
uri = URI.join(@base_uri.to_s.chomp("/") + "/", "api/tokens/exchange")
request = Net::HTTP::Post.new(uri)
request["Authorization"] = "Bearer #{@api_token}"
request["Authorization"] = "Bearer #{@effective_token}"
request["Content-Type"] = "application/json"
request["Accept"] = "application/json"
request["User-Agent"] = user_agent

View File

@@ -62,6 +62,11 @@ Rails.application.routes.draw do
get "/profile/change_password", to: "user#change_password", as: :change_password
patch "/profile/update_password", to: "user#update_password"
# OAuth routes
get "/auth/tbdb", to: "oauth#tbdb", as: :auth_tbdb
get "/auth/tbdb/callback", to: "oauth#tbdb_callback", as: :auth_tbdb_callback
delete "/auth/tbdb/disconnect", to: "oauth#tbdb_disconnect", as: :auth_tbdb_disconnect
# API routes
namespace :api do
namespace :v1 do

View File

@@ -7,6 +7,12 @@ class CreateUsers < ActiveRecord::Migration[8.0]
t.boolean :admin, default: false, null: false
t.json :user_settings, default: {}
# OAuth fields for TBDB integration
t.string :oauth_client_id
t.string :oauth_client_secret
t.string :oauth_access_token
t.string :oauth_refresh_token
t.datetime :oauth_expires_at
t.timestamps
end

View File

@@ -2,6 +2,7 @@ class CreateScans < ActiveRecord::Migration[8.0]
def change
create_table :scans do |t|
t.references :product, null: false, foreign_key: true
t.references :user, null: false, foreign_key: true
t.datetime :scanned_at, null: false
t.timestamps

View File

@@ -1,5 +0,0 @@
class AddUserToScans < ActiveRecord::Migration[8.0]
def change
add_reference :scans, :user, null: false, foreign_key: true
end
end

5
db/schema.rb generated
View File

@@ -164,6 +164,11 @@ ActiveRecord::Schema[8.0].define(version: 2025_08_01_054402) do
t.string "name"
t.boolean "admin", default: false, null: false
t.json "user_settings", default: {}
t.string "oauth_client_id"
t.string "oauth_client_secret"
t.string "oauth_access_token"
t.string "oauth_refresh_token"
t.datetime "oauth_expires_at"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["email_address"], name: "index_users_on_email_address", unique: true