diff --git a/app/controllers/admin/editor_tokens_controller.rb b/app/controllers/admin/editor_tokens_controller.rb new file mode 100644 index 000000000..de23b425f --- /dev/null +++ b/app/controllers/admin/editor_tokens_controller.rb @@ -0,0 +1,13 @@ +class Admin::EditorTokensController < AdminController + def create + @editor = Editor.find(params[:editor_id]) + + @editor.editor_tokens.create!( + iat: Time.zone.now.to_i, + exp: 18.months.from_now.to_i + ) + + success_message(title: 'Jeton éditeur créé avec succès') + redirect_to edit_admin_editor_path(@editor) + end +end diff --git a/app/controllers/editor/delegations_controller.rb b/app/controllers/editor/delegations_controller.rb index 1b78fec2a..c03682c8e 100644 --- a/app/controllers/editor/delegations_controller.rb +++ b/app/controllers/editor/delegations_controller.rb @@ -2,6 +2,7 @@ class Editor::DelegationsController < EditorController before_action :ensure_delegations_enabled def index + @editor_tokens = current_editor.editor_tokens.order(created_at: :desc) @delegations = current_editor .editor_delegations .includes(authorization_request: %i[organization demandeur]) diff --git a/app/models/editor.rb b/app/models/editor.rb index f901878c5..a2e5e6079 100644 --- a/app/models/editor.rb +++ b/app/models/editor.rb @@ -3,6 +3,8 @@ class Editor < ApplicationRecord dependent: :nullify has_many :editor_delegations, dependent: :destroy + has_many :editor_tokens, + dependent: :destroy validates :name, presence: true diff --git a/app/models/editor_token.rb b/app/models/editor_token.rb new file mode 100644 index 000000000..e5b1cc4b5 --- /dev/null +++ b/app/models/editor_token.rb @@ -0,0 +1,37 @@ +class EditorToken < ApplicationRecord + belongs_to :editor + + validates :exp, presence: true + + scope :active, -> { where(blacklisted_at: nil).or(where('blacklisted_at > ?', Time.zone.now)).where('exp > ?', Time.zone.now.to_i) } + + def rehash + AccessToken.create(jwt_data) + end + + def expired? + exp < Time.zone.now.to_i + end + + def blacklisted? + blacklisted_at.present? && blacklisted_at < Time.zone.now + end + + def active? + !blacklisted? && !expired? + end + + private + + def jwt_data + { + uid: id, + jti: id, + sub: editor.name, + version: '1.0', + iat: iat, + exp: exp, + editor: true + } + end +end diff --git a/app/views/admin/editors/edit.html.erb b/app/views/admin/editors/edit.html.erb index 1ad7e9171..025bc026c 100644 --- a/app/views/admin/editors/edit.html.erb +++ b/app/views/admin/editors/edit.html.erb @@ -21,3 +21,48 @@ <%= f.submit 'Sauvegarder', class: %w[fr-btn] %> <% end %> + +

Jetons éditeur

+ +<% if @editor.editor_tokens.any? %> +
+
+
+
+ + + + + + + + + + + + <% @editor.editor_tokens.order(created_at: :desc).each do |editor_token| %> + + + + + + + <% end %> + +
Jetons éditeur
IDCréé leExpirationStatut
<%= editor_token.id.first(8) %>…<%= l(editor_token.created_at, format: :short) %><%= friendly_date_from_timestamp(editor_token.exp) %> + <% if editor_token.active? %> +

Actif

+ <% else %> +

Inactif

+ <% end %> +
+
+
+
+
+<% end %> + +<%= button_to 'Générer un nouveau jeton éditeur', + admin_editor_editor_tokens_path(@editor), + method: :post, + class: 'fr-btn fr-btn--secondary' %> diff --git a/app/views/editor/delegations/index.html.erb b/app/views/editor/delegations/index.html.erb index 2a533e998..993836099 100644 --- a/app/views/editor/delegations/index.html.erb +++ b/app/views/editor/delegations/index.html.erb @@ -1,3 +1,51 @@ +<% if @editor_tokens.any? %> +
+
+
+
+ + + + + + + + + + + <% @editor_tokens.each do |editor_token| %> + + + + + + <% end %> + +
<%= t('.editor_tokens_title') %>
<%= t('.editor_tokens_table.token') %><%= t('.editor_tokens_table.status') %><%= t('.editor_tokens_table.expiration') %>
+
+ + +
+
+ <% if editor_token.active? %> +

<%= t('.status_active') %>

+ <% else %> +

<%= t('.editor_tokens_table.inactive') %>

+ <% end %> +
<%= friendly_date_from_timestamp(editor_token.exp) %>
+
+
+
+
+<% end %> +
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