Skip to content

Commit a78551f

Browse files
authored
Beacon Configuration Admin View, with CRUD and Regenerate and Revoke Tokens Actions (#585)
2 parents 450ea28 + 0b8672f commit a78551f

19 files changed

Lines changed: 1342 additions & 9 deletions

app/assets/tailwind/application.css

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -387,3 +387,49 @@ nav.pagy a.current {
387387
.help-text {
388388
@apply mt-2 text-xs text-gray-500 m-0;
389389
}
390+
391+
/* Beacon Configuration Admin View styles */
392+
.beacon-online {
393+
display: inline-block;
394+
width: 10px;
395+
height: 10px;
396+
background-color: #10b981;
397+
border-radius: 50%;
398+
animation: pulse-green 2s infinite;
399+
}
400+
401+
.beacon-online-large {
402+
display: inline-block;
403+
width: 16px;
404+
height: 16px;
405+
background-color: #10b981;
406+
border-radius: 50%;
407+
animation: pulse-green 2s infinite;
408+
}
409+
410+
.beacon-offline {
411+
display: inline-block;
412+
width: 10px;
413+
height: 10px;
414+
background-color: #ef4444;
415+
border-radius: 50%;
416+
}
417+
418+
.beacon-offline-large {
419+
display: inline-block;
420+
width: 16px;
421+
height: 16px;
422+
background-color: #ef4444;
423+
border-radius: 50%;
424+
}
425+
426+
@keyframes pulse-green {
427+
0%, 100% {
428+
opacity: 1;
429+
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.7);
430+
}
431+
50% {
432+
opacity: 0.8;
433+
box-shadow: 0 0 0 6px rgba(16, 185, 129, 0);
434+
}
435+
}

app/controllers/application_controller.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,8 @@ def current_provider
2020
end
2121
end
2222
helper_method :current_provider
23+
24+
def non_contributor_redirect_path
25+
topics_path
26+
end
2327
end
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
class BeaconsController < ApplicationController
2+
include Authentication
3+
4+
before_action :set_beacon, only: %i[show edit update regenerate_key revoke_key]
5+
before_action :prepare_associations, only: %i[new edit]
6+
7+
def index
8+
@beacons = Beacon.order(created_at: :desc)
9+
end
10+
11+
def new
12+
@beacon = Beacon.new
13+
end
14+
15+
def create
16+
success, @beacon, api_key = Beacons::Creator.new.call(beacon_params)
17+
18+
if success
19+
flash[:notice] = "Beacon was successfully provisioned. API Key: #{api_key}"
20+
redirect_to beacon_path(@beacon, api_key: api_key)
21+
else
22+
prepare_associations
23+
render :new, status: :unprocessable_entity
24+
end
25+
end
26+
27+
def show; end
28+
29+
def edit; end
30+
31+
def update
32+
if @beacon.update(beacon_params)
33+
redirect_to @beacon, notice: "Beacon was successfully updated."
34+
else
35+
prepare_associations
36+
render :edit, status: :unprocessable_entity
37+
end
38+
end
39+
40+
def regenerate_key
41+
_, api_key = Beacons::KeyRegenerator.new.call(@beacon)
42+
flash[:notice] = "API key has been successfully regenerated. API Key: #{api_key}"
43+
redirect_to beacon_path(@beacon, api_key: api_key)
44+
45+
rescue => StandardError
46+
flash[:alert] = "API key could not be regenerated."
47+
redirect_to @beacon
48+
end
49+
50+
def filter_options
51+
topics = if params[:language_id].present?
52+
Topic.active.where(language_id: params[:language_id]).order(:title)
53+
else
54+
Topic.active.order(:title)
55+
end
56+
57+
providers = if params[:region_id].present?
58+
Provider.joins(:branches).where(branches: { region_id: params[:region_id] }).distinct.order(:name)
59+
else
60+
Provider.order(:name)
61+
end
62+
63+
render json: {
64+
topics: topics.select(:id, :title),
65+
providers: providers.select(:id, :name),
66+
}
67+
end
68+
69+
def revoke_key
70+
api_key = @beacon.revoke!
71+
flash[:notice] = "API key has been successfully revoked."
72+
redirect_to @beacon
73+
74+
rescue => StandardError
75+
flash[:alert] = "API key could not be revoked."
76+
redirect_to @beacon
77+
end
78+
79+
def non_contributor_redirect_path
80+
root_path
81+
end
82+
83+
private
84+
85+
def set_beacon
86+
@beacon = Beacon.find(params[:id])
87+
end
88+
89+
def prepare_associations
90+
@languages = Language.order(:name)
91+
@providers = Provider.order(:name)
92+
@regions = Region.order(:name)
93+
@topics = Topic.active.order(:title)
94+
end
95+
96+
def beacon_params
97+
params.require(:beacon).permit(:name, :language_id, :region_id, provider_ids: [], topic_ids: [])
98+
end
99+
end

app/controllers/concerns/authentication.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ def allow_unauthenticated_access(**options)
1313
end
1414

1515
def redirect_contributors
16-
redirect_to topics_path unless Current.user.is_admin?
16+
redirect_to non_contributor_redirect_path unless Current.user.is_admin?
1717
end
1818

1919
private
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { Controller } from "@hotwired/stimulus"
2+
import { get } from "@rails/request.js"
3+
4+
export default class extends Controller {
5+
static targets = ["languageSelect", "regionSelect", "topicsSelect", "providersSelect"]
6+
static values = { filterUrl: String }
7+
8+
connect() {
9+
// Defer until select-tags children have initialized their TomSelect instances
10+
requestAnimationFrame(() => this.fetchOptions())
11+
}
12+
13+
fetchOptions() {
14+
const params = new URLSearchParams()
15+
const languageId = this.languageSelectTarget.value
16+
const regionId = this.regionSelectTarget.value
17+
18+
if (languageId) params.set("language_id", languageId)
19+
if (regionId) params.set("region_id", regionId)
20+
21+
get(`${this.filterUrlValue}?${params}`, { responseKind: "json" })
22+
.then(response => response.json)
23+
.then(data => {
24+
this.#updateSelect(this.topicsSelectTarget, data.topics, "title")
25+
this.#updateSelect(this.providersSelectTarget, data.providers, "name")
26+
})
27+
}
28+
29+
#updateSelect(selectElement, items, textKey) {
30+
const tomSelect = selectElement.tomselect
31+
if (!tomSelect) return
32+
33+
const previousValues = [].concat(tomSelect.getValue())
34+
35+
tomSelect.clear(true)
36+
tomSelect.clearOptions()
37+
38+
items.forEach(item => {
39+
tomSelect.addOption({ value: String(item.id), text: item[textKey] })
40+
})
41+
42+
const stillValidValues = previousValues.filter(v =>
43+
items.some(item => String(item.id) === v)
44+
)
45+
if (stillValidValues.length > 0) {
46+
tomSelect.setValue(stillValidValues, true)
47+
}
48+
49+
tomSelect.refreshOptions(false)
50+
}
51+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { Controller } from "@hotwired/stimulus"
2+
3+
export default class extends Controller {
4+
static targets = ["button", "beaconApiKey"]
5+
6+
copyApiKey() {
7+
const apiKey = this.beaconApiKeyTarget.textContent;
8+
9+
navigator.clipboard.writeText(apiKey).then(() => {
10+
const button = this.buttonTarget;
11+
const originalText = button.textContent;
12+
button.textContent = "Copied!";
13+
button.classList.add("text-green-600");
14+
15+
setTimeout(() => {
16+
button.textContent = originalText;
17+
button.classList.remove("text-green-600");
18+
}, 2000);
19+
}).catch(err => {
20+
console.error("Failed to copy:", err);
21+
alert("Failed to copy API key to clipboard");
22+
});
23+
}
24+
}

app/models/beacon.rb

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ class Beacon < ApplicationRecord
3838
has_many :beacon_topics, dependent: :destroy
3939
has_many :topics, through: :beacon_topics
4040

41+
delegate :name, to: :region, prefix: true
42+
delegate :name, to: :language, prefix: true
43+
4144
validates :name, presence: true
4245
validates :api_key_digest, presence: true, uniqueness: true
4346
validates :api_key_prefix, presence: true
@@ -53,6 +56,20 @@ def revoked?
5356
revoked_at.present?
5457
end
5558

59+
def status_str
60+
revoked? ? "Revoked" : "Active"
61+
end
62+
63+
# Get count of topics that match this beacon's configuration
64+
def document_count
65+
topics.count
66+
end
67+
68+
# Get count of actual document files attached to matching topics
69+
def file_count
70+
topics.joins(:documents_attachments).count
71+
end
72+
5673
def accessible_blobs
5774
ActiveStorage::Blob
5875
.joins(:attachments)

0 commit comments

Comments
 (0)