Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
035abfa
wip
seanmarcia Feb 3, 2026
27ab546
Merge feature/device-management-api-keys into beacons branch
seanmarcia Feb 3, 2026
1b77f29
Update beacon UI to use API key system from feature/device-management…
seanmarcia Feb 3, 2026
b4cfadd
Fix topic field reference - use 'title' instead of 'name'
seanmarcia Feb 3, 2026
b4540fa
Improve API key display and add regeneration feature
seanmarcia Feb 3, 2026
fb7b72c
Add document and file count to beacon show page
seanmarcia Feb 3, 2026
16a8485
Wip
seanmarcia Feb 3, 2026
ee1549b
Regenerate and Revoke Tokens on Beacon Configuration Admin View
FionaLMcLaren Feb 3, 2026
ca83796
Adds Request Spec for Beacons, and route for revoking a Beacon's API
FionaLMcLaren Feb 4, 2026
0a53932
chore(deps): bump bootsnap from 1.20.1 to 1.22.0 (#576)
dependabot[bot] Feb 3, 2026
9056305
chore(deps): bump tailwindcss-rails from 4.3.0 to 4.4.0 (#577)
dependabot[bot] Feb 3, 2026
dd6b8e9
chore(deps): bump thruster from 0.1.17 to 0.1.18 (#578)
dependabot[bot] Feb 3, 2026
b459b7e
Update Favicon (#563)
seanmarcia Feb 3, 2026
b37d46e
Upgrade to Ruby 4.0.1 (#570)
devjona Feb 3, 2026
4727750
Add macOS 15 platform to Gemfile.lock
oatkins8 Feb 3, 2026
75f1500
chore(deps-dev): bump brakeman from 8.0.1 to 8.0.2 (#583)
dependabot[bot] Feb 4, 2026
07a9fdf
Tailwind migration cleanup – sidebar (#584)
hydrognomik Feb 4, 2026
10a48ea
Fixing error - turns out did not call `set_beacon` for `revoke_key`
FionaLMcLaren Feb 4, 2026
3954d6e
Gets rid of the redundant specs in the `Beacon` request spec
FionaLMcLaren Feb 4, 2026
c125a91
Merge branch 'main' into beacons-regenerate
FionaLMcLaren Feb 4, 2026
a24368c
Review changes
FionaLMcLaren Feb 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions app/assets/tailwind/application.css
Original file line number Diff line number Diff line change
Expand Up @@ -518,3 +518,49 @@ nav.pagy a.current {
.help-text {
@apply mt-2 text-xs text-gray-500 m-0;
}

/* Beacon Configuration Admin View styles */
.beacon-online {
display: inline-block;
width: 10px;
height: 10px;
background-color: #10b981;
border-radius: 50%;
animation: pulse-green 2s infinite;
}

.beacon-online-large {
display: inline-block;
width: 16px;
height: 16px;
background-color: #10b981;
border-radius: 50%;
animation: pulse-green 2s infinite;
}

.beacon-offline {
display: inline-block;
width: 10px;
height: 10px;
background-color: #ef4444;
border-radius: 50%;
}

.beacon-offline-large {
display: inline-block;
width: 16px;
height: 16px;
background-color: #ef4444;
border-radius: 50%;
}

@keyframes pulse-green {
0%, 100% {
opacity: 1;
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.7);
}
50% {
opacity: 0.8;
box-shadow: 0 0 0 6px rgba(16, 185, 129, 0);
}
}
81 changes: 81 additions & 0 deletions app/controllers/beacons_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
class BeaconsController < ApplicationController
before_action :redirect_contributors
Copy link
Collaborator

@dmitrytrager dmitrytrager Feb 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: add base beacons controller and move this before action there. We will reuse it later

before_action :set_beacon, only: %i[show edit update regenerate_key revoke_key]

def index
@beacons = Beacon.includes(:language, :region, :providers, :topics).order(created_at: :desc)
end

def new
prepare_associations
end

def create
success, @beacon, api_key = Beacons::Creator.new.call(beacon_params)

case Beacons::Creator.new.call(beacon_params)
when [true, @beacon, api_key]

Check failure on line 17 in app/controllers/beacons_controller.rb

View workflow job for this annotation

GitHub Actions / lint

Layout/SpaceInsideArrayLiteralBrackets: Use space inside array brackets.

Check failure on line 17 in app/controllers/beacons_controller.rb

View workflow job for this annotation

GitHub Actions / lint

Layout/SpaceInsideArrayLiteralBrackets: Use space inside array brackets.
flash[:notice] = "Beacon was successfully provisioned. API Key: #{api_key}"
redirect_to @beacon
when [false, _, _]

Check failure on line 20 in app/controllers/beacons_controller.rb

View workflow job for this annotation

GitHub Actions / lint

Layout/SpaceInsideArrayLiteralBrackets: Use space inside array brackets.

Check failure on line 20 in app/controllers/beacons_controller.rb

View workflow job for this annotation

GitHub Actions / lint

Layout/SpaceInsideArrayLiteralBrackets: Use space inside array brackets.
prepare_associations
render :new, status: :unprocessable_entity
end
end

def show; end

def edit
prepare_associations
end

def update
if @beacon.update(beacon_params)
redirect_to @beacon, notice: "Beacon was successfully updated."
else
prepare_associations
render :edit, status: :unprocessable_entity
end
end

def regenerate_key
api_key = @beacon.regenerate
flash[:notice] = "API key has been successfully regenerated. API Key: #{api_key}"
redirect_to @beacon

rescue => e
flash[:alert] = "API key could not be regenerated."
redirect_to @beacon
end

def revoke_key
api_key = @beacon.revoke!
flash[:notice] = "API key has been successfully revoked."
redirect_to @beacon

rescue => e
flash[:alert] = "API key could not be revoked."
redirect_to @beacon
end

private

def set_beacon
@beacon = Beacon.find(params[:id])
end

def prepare_associations
@languages = Language.order(:name)
@providers = Provider.order(:name)
@regions = Region.order(:name)
@topics = Topic.active.order(:title)
end

def beacon_params
params.require(:beacon).permit(:name, :language_id, :region_id, provider_ids: [], topic_ids: [])
end

Check failure on line 77 in app/controllers/beacons_controller.rb

View workflow job for this annotation

GitHub Actions / lint

Layout/TrailingWhitespace: Trailing whitespace detected.
def redirect_contributors
redirect_to root_path, alert: "You don't have permission to access this page." unless Current.user&.is_admin?
end
end
53 changes: 53 additions & 0 deletions app/models/beacon.rb
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,64 @@ class Beacon < ApplicationRecord
scope :active, -> { where(revoked_at: nil) }
scope :revoked, -> { where.not(revoked_at: nil) }

def regenerate
_, raw_key = Beacons::KeyRegenerator.new.call(self)

key_result
end

def revoke!
update!(revoked_at: Time.current)
end

def revoked?
revoked_at.present?
end

# Get count of topics that match this beacon's configuration
def document_count
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this data will be mostly available from manifest JSON that we are going to store in beacon

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this then be removed after merging the Beacon manifest feature, in a future PR?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Manifest API is already in main branch

scope = Topic.active

# Filter by beacon's language
scope = scope.where(language_id: language_id) if language_id.present?

# If beacon has specific providers selected, filter by those
if providers.any?
scope = scope.where(provider_id: providers.pluck(:id))
else
# If no providers selected, filter by providers in the beacon's region
if region.present?
provider_ids = region.providers.pluck(:id)
scope = scope.where(provider_id: provider_ids)
end
end

# If beacon has specific topics selected, filter by those
if topics.any?
scope = scope.where(id: topics.pluck(:id))
end

scope.count
end

# Get count of actual document files attached to matching topics
def file_count
scope = Topic.active

scope = scope.where(language_id: language_id) if language_id.present?

if providers.any?
scope = scope.where(provider_id: providers.pluck(:id))
elsif region.present?
provider_ids = region.providers.pluck(:id)
scope = scope.where(provider_id: provider_ids)
end

if topics.any?
scope = scope.where(id: topics.pluck(:id))
end

# Count total attached documents
scope.joins(:documents_attachments).count
end
end
128 changes: 128 additions & 0 deletions app/views/beacons/_form.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
<%= form_with model: beacon, local: true do |form| %>
<% if beacon.errors.any? %>
<div class="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-800">
<%= pluralize(beacon.errors.count, "error") %> prohibited this beacon from being saved:
</h3>
<div class="mt-2 text-sm text-red-700">
<ul class="list-disc pl-5 space-y-1">
<% beacon.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
</div>
</div>
</div>
<% end %>

<div class="form-grid">
<!-- Basic Information Section -->
<div class="form-grid-full">
<h3 class="text-lg font-semibold text-gray-900 mb-4 pb-2 border-b border-gray-200">Basic Information</h3>
</div>

<div>
<%= form.label :name, class: "form-label" do %>
Beacon Name
<span class="form-required">*</span>
<% end %>
<%= form.text_field :name, class: "form-input", placeholder: "e.g., Kampala-Central", required: true %>
<p class="form-help-text">A descriptive name for this beacon deployment</p>
</div>

<!-- Document Access Filters Section -->
<div class="form-grid-full">
<h3 class="text-lg font-semibold text-gray-900 mb-4 pb-2 border-b border-gray-200 mt-4">
Document Access Filters
</h3>
<p class="text-sm text-gray-600 mb-4">
Select which documents this beacon can access. Leave filters blank to allow access to all documents.
</p>
</div>

<div>
<%= form.label :language_id, "Language", class: "form-label" do %>
Language
<span class="form-required">*</span>
<% end %>
<div style="position:relative;">
<%= form.select :language_id,
options_from_collection_for_select(@languages, :id, :name, beacon.language_id),
{ include_blank: "Select a language" },
class: "form-select", required: true %>
<%= render "shared/dropdown_arrow" %>
</div>
<p class="form-help-text">Language for documents this beacon will access</p>
</div>

<div>
<%= form.label :region_id, "Region", class: "form-label" do %>
Region
<span class="form-required">*</span>
<% end %>
<div style="position:relative;">
<%= form.select :region_id,
options_from_collection_for_select(@regions, :id, :name, beacon.region_id),
{ include_blank: "Select a region" },
class: "form-select", required: true %>
<%= render "shared/dropdown_arrow" %>
</div>
<p class="form-help-text">Geographic region for this beacon</p>
</div>

<div class="form-grid-full" data-controller="select-tags" data-select-tags-dropdown-parent-value="body">
<%= form.label :provider_ids, "Providers", class: "form-label" %>
<%= form.select :provider_ids,
options_from_collection_for_select(@providers, :id, :name, beacon.provider_ids),
{ prompt: "Start typing to select providers", include_blank: true },
{ multiple: true,
data: {
"allow-new": "false",
"allow-clear": "true",
"select-tags-target": "tagList",
"dropdown-parent": "body"
}
} %>
<p class="form-help-text">Select which content providers this beacon can access</p>
</div>

<div class="form-grid-full" data-controller="select-tags" data-select-tags-dropdown-parent-value="body">
<%= form.label :topic_ids, "Topics", class: "form-label" %>
<%= form.select :topic_ids,
options_from_collection_for_select(@topics, :id, :title, beacon.topic_ids),
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will selected providers/topics belong to selected language/region?

{ prompt: "Start typing to select topics", include_blank: true },
{ multiple: true,
data: {
"allow-new": "false",
"allow-clear": "true",
"select-tags-target": "tagList",
"dropdown-parent": "body"
}
} %>
<p class="form-help-text">Select which topics this beacon can access. Leave blank to allow access to all topics.</p>
</div>
</div>

<!-- Action Buttons -->
<div class="flex justify-between items-center pt-8 border-t border-gray-100 mt-8">
<div class="flex gap-4">
<%= form.submit beacon.persisted? ? "Update Beacon" : "Provision Beacon", class: "bg-gradient-green text-white font-semibold py-3 px-8 rounded-lg border-0 cursor-pointer text-sm shadow-md transition-all hover:-translate-y-0.5" %>
<%= link_to "Back to Beacons", beacons_path,
class: "bg-gray-100 text-gray-500 font-semibold py-3 px-8 rounded-lg no-underline text-sm transition-all hover:bg-gray-200" %>
</div>

<% if beacon.persisted? %>
<div class="text-gray-500 text-xs">
Last updated: <%= beacon.updated_at.strftime("%B %d, %Y at %I:%M %p") %>
</div>
<% end %>
</div>
<% end %>
41 changes: 41 additions & 0 deletions app/views/beacons/edit.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<% content_for :title, "Edit Beacon" %>

<div class="bg-gradient-page page-container">
<div class="content-wrapper">

<!-- Header Section -->
<div class="section-spacing">
<div class="flex items-center gap-4 mb-6">
<%= link_to beacon_path(@beacon), class: "text-gray-500 hover:text-gray-700 transition-colors" do %>
<svg class="icon-size-lg" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"/>
</svg>
<% end %>
<h1 class="text-3xl font-bold text-gray-800 m-0">
Edit Beacon
</h1>
</div>

<!-- Breadcrumb -->
<nav class="breadcrumb-nav">
<ol class="breadcrumb-list">
<li><%= link_to "Dashboard", root_path, class: "text-gray-500 no-underline hover:text-gray-700" %></li>
<li class="breadcrumb-separator">/</li>
<li><%= link_to "Beacons", beacons_path, class: "text-gray-500 no-underline hover:text-gray-700" %></li>
<li class="breadcrumb-separator">/</li>
<li><%= link_to @beacon.name, beacon_path(@beacon), class: "text-gray-500 no-underline hover:text-gray-700" %></li>
<li class="breadcrumb-separator">/</li>
<li class="breadcrumb-current">Edit</li>
</ol>
</nav>
</div>

<!-- Main Form Card -->
<div class="card-elevated" style="overflow: visible;">
<!-- Form Content -->
<div class="card-body">
<%= render "form", beacon: @beacon %>
</div>
</div>
</div>
</div>
Loading
Loading