diff --git a/config/locales/fr.yml b/config/locales/fr.yml
index 7116a1b66..b03063f59 100644
--- a/config/locales/fr.yml
+++ b/config/locales/fr.yml
@@ -499,6 +499,13 @@ fr:
status: Statut
status_active: Actif
status_revoked: Révoqué
+ editor_tokens_title: Jetons de délégation
+ editor_tokens_table:
+ token: Jeton
+ status: Statut
+ expiration: Expiration
+ copy: Copier le jeton
+ inactive: Inactif
provider:
dashboard:
show:
diff --git a/config/routes.rb b/config/routes.rb
index 79e972ff0..cfe722229 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -15,7 +15,9 @@
resource :ban, only: %i[new create], controller: 'tokens/bans'
end
end
- resources :editors, only: %i[index edit update]
+ resources :editors, only: %i[index edit update] do
+ resources :editor_tokens, only: %i[create]
+ end
resources :provider_dashboards, only: %i[index show], path: 'providers'
resources :audit_notifications, only: %i[index new create]
resources :api_requests, only: %i[index create]
diff --git a/db/migrate/20260318100000_create_editor_tokens.rb b/db/migrate/20260318100000_create_editor_tokens.rb
new file mode 100644
index 000000000..e9a27cd25
--- /dev/null
+++ b/db/migrate/20260318100000_create_editor_tokens.rb
@@ -0,0 +1,13 @@
+class CreateEditorTokens < ActiveRecord::Migration[8.0]
+ def change
+ create_table :editor_tokens, id: :uuid do |t|
+ t.uuid :editor_id, null: false
+ t.integer :iat
+ t.integer :exp, null: false
+ t.datetime :blacklisted_at
+ t.timestamps
+ end
+
+ add_foreign_key :editor_tokens, :editors, validate: false
+ end
+end
diff --git a/db/migrate/20260318100001_validate_editor_tokens_foreign_keys.rb b/db/migrate/20260318100001_validate_editor_tokens_foreign_keys.rb
new file mode 100644
index 000000000..a6e5f587d
--- /dev/null
+++ b/db/migrate/20260318100001_validate_editor_tokens_foreign_keys.rb
@@ -0,0 +1,5 @@
+class ValidateEditorTokensForeignKeys < ActiveRecord::Migration[8.0]
+ def change
+ validate_foreign_key :editor_tokens, :editors
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 2ef3d96b5..0a78d288c 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[8.1].define(version: 2026_03_05_100002) do
+ActiveRecord::Schema[8.1].define(version: 2026_03_18_100001) do
# These are extensions that must be enabled in order to support this database
enable_extension "btree_gin"
enable_extension "pg_catalog.plpgsql"
@@ -73,6 +73,15 @@
t.index ["editor_id", "authorization_request_id"], name: "idx_editor_delegations_editor_ar_active", unique: true, where: "(revoked_at IS NULL)"
end
+ create_table "editor_tokens", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
+ t.datetime "blacklisted_at"
+ t.datetime "created_at", null: false
+ t.uuid "editor_id", null: false
+ t.integer "exp", null: false
+ t.integer "iat"
+ t.datetime "updated_at", null: false
+ end
+
create_table "editors", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.boolean "copy_token", default: false, null: false
t.datetime "created_at", null: false
@@ -193,7 +202,6 @@
t.string "scopes", default: "", null: false
t.string "state"
t.string "token", null: false
- t.jsonb "token_ids", default: [], null: false
t.index ["application_id"], name: "index_oauth_access_grants_on_application_id"
t.index ["resource_owner_id"], name: "index_oauth_access_grants_on_resource_owner_id"
t.index ["token"], name: "index_oauth_access_grants_on_token", unique: true
@@ -209,7 +217,6 @@
t.datetime "revoked_at"
t.string "scopes"
t.string "token", null: false
- t.jsonb "token_ids", default: [], null: false
t.index ["application_id"], name: "index_oauth_access_tokens_on_application_id"
t.index ["refresh_token"], name: "index_oauth_access_tokens_on_refresh_token", unique: true
t.index ["resource_owner_id"], name: "index_oauth_access_tokens_on_resource_owner_id"
@@ -219,12 +226,14 @@
create_table "oauth_applications", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.boolean "confidential", default: true, null: false
t.datetime "created_at", null: false
+ t.uuid "editor_id"
t.string "name", null: false
t.text "redirect_uri", null: false
t.string "scopes", default: "", null: false
t.string "secret", null: false
t.string "uid", null: false
t.datetime "updated_at", null: false
+ t.index ["editor_id"], name: "index_oauth_applications_on_editor_id"
t.index ["uid"], name: "index_oauth_applications_on_uid", unique: true
end
@@ -299,6 +308,7 @@
add_foreign_key "authorization_request_security_settings", "authorization_requests"
add_foreign_key "editor_delegations", "authorization_requests"
add_foreign_key "editor_delegations", "editors"
+ add_foreign_key "editor_tokens", "editors"
add_foreign_key "magic_links", "tokens"
add_foreign_key "oauth_access_grants", "oauth_applications", column: "application_id"
add_foreign_key "oauth_access_grants", "users", column: "resource_owner_id"
diff --git a/spec/factories/editor_tokens.rb b/spec/factories/editor_tokens.rb
new file mode 100644
index 000000000..94200b34e
--- /dev/null
+++ b/spec/factories/editor_tokens.rb
@@ -0,0 +1,15 @@
+FactoryBot.define do
+ factory :editor_token do
+ editor
+ iat { Time.zone.now.to_i }
+ exp { 18.months.from_now.to_i }
+
+ trait :expired do
+ exp { 1.month.ago.to_i }
+ end
+
+ trait :blacklisted do
+ blacklisted_at { 1.month.ago }
+ end
+ end
+end
diff --git a/spec/models/editor_spec.rb b/spec/models/editor_spec.rb
index 6322aeb3b..908226214 100644
--- a/spec/models/editor_spec.rb
+++ b/spec/models/editor_spec.rb
@@ -5,6 +5,7 @@
describe 'associations' do
it { is_expected.to have_many(:editor_delegations).dependent(:destroy) }
+ it { is_expected.to have_many(:editor_tokens).dependent(:destroy) }
end
describe '.delegable' do
diff --git a/spec/models/editor_token_spec.rb b/spec/models/editor_token_spec.rb
new file mode 100644
index 000000000..ea0717252
--- /dev/null
+++ b/spec/models/editor_token_spec.rb
@@ -0,0 +1,104 @@
+RSpec.describe EditorToken do
+ it 'has a valid factory' do
+ expect(build(:editor_token)).to be_valid
+ end
+
+ describe 'associations' do
+ it { is_expected.to belong_to(:editor) }
+ end
+
+ describe 'validations' do
+ it { is_expected.to validate_presence_of(:exp) }
+ end
+
+ describe '#rehash' do
+ subject { editor_token.rehash }
+
+ let(:editor_token) { create(:editor_token) }
+
+ it 'returns a JWT string' do
+ expect(subject).to be_a(String)
+ end
+
+ it 'contains editor: true in payload' do
+ payload = AccessToken.decode(subject)
+
+ expect(payload[:editor]).to be true
+ end
+
+ it 'contains expected fields' do
+ payload = AccessToken.decode(subject)
+
+ expect(payload[:uid]).to eq(editor_token.id)
+ expect(payload[:jti]).to eq(editor_token.id)
+ expect(payload[:sub]).to eq(editor_token.editor.name)
+ expect(payload[:version]).to eq('1.0')
+ expect(payload[:iat]).to eq(editor_token.iat)
+ expect(payload[:exp]).to eq(editor_token.exp)
+ end
+ end
+
+ describe '#expired?' do
+ it 'returns true when exp is in the past' do
+ editor_token = build(:editor_token, :expired)
+
+ expect(editor_token).to be_expired
+ end
+
+ it 'returns false when exp is in the future' do
+ editor_token = build(:editor_token)
+
+ expect(editor_token).not_to be_expired
+ end
+ end
+
+ describe '#blacklisted?' do
+ it 'returns true when blacklisted_at is in the past' do
+ editor_token = build(:editor_token, :blacklisted)
+
+ expect(editor_token).to be_blacklisted
+ end
+
+ it 'returns false when blacklisted_at is nil' do
+ editor_token = build(:editor_token)
+
+ expect(editor_token).not_to be_blacklisted
+ end
+
+ it 'returns false when blacklisted_at is in the future' do
+ editor_token = build(:editor_token, blacklisted_at: 1.month.from_now)
+
+ expect(editor_token).not_to be_blacklisted
+ end
+ end
+
+ describe '#active?' do
+ it 'returns true when not expired and not blacklisted' do
+ editor_token = build(:editor_token)
+
+ expect(editor_token).to be_active
+ end
+
+ it 'returns false when expired' do
+ editor_token = build(:editor_token, :expired)
+
+ expect(editor_token).not_to be_active
+ end
+
+ it 'returns false when blacklisted' do
+ editor_token = build(:editor_token, :blacklisted)
+
+ expect(editor_token).not_to be_active
+ end
+ end
+
+ describe '.active' do
+ let!(:active_token) { create(:editor_token) }
+ let!(:expired_token) { create(:editor_token, :expired) }
+ let!(:blacklisted_token) { create(:editor_token, :blacklisted) }
+
+ it 'returns only active tokens' do
+ expect(described_class.active).to contain_exactly(active_token)
+ end
+ end
+end