diff --git a/app/assets/tailwind/application.css b/app/assets/tailwind/application.css index e2152a86..74eb3f36 100644 --- a/app/assets/tailwind/application.css +++ b/app/assets/tailwind/application.css @@ -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); + } +} diff --git a/app/controllers/beacons_controller.rb b/app/controllers/beacons_controller.rb new file mode 100644 index 00000000..7f6d2854 --- /dev/null +++ b/app/controllers/beacons_controller.rb @@ -0,0 +1,81 @@ +class BeaconsController < ApplicationController + before_action :redirect_contributors + 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] + flash[:notice] = "Beacon was successfully provisioned. API Key: #{api_key}" + redirect_to @beacon + when [false, _, _] + 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 + + def redirect_contributors + redirect_to root_path, alert: "You don't have permission to access this page." unless Current.user&.is_admin? + end +end diff --git a/app/models/beacon.rb b/app/models/beacon.rb index d613ae38..6a61617c 100644 --- a/app/models/beacon.rb +++ b/app/models/beacon.rb @@ -41,6 +41,12 @@ 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 @@ -48,4 +54,51 @@ def revoke! def revoked? revoked_at.present? end + + # Get count of topics that match this beacon's configuration + def document_count + 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 diff --git a/app/views/beacons/_form.html.erb b/app/views/beacons/_form.html.erb new file mode 100644 index 00000000..995fca14 --- /dev/null +++ b/app/views/beacons/_form.html.erb @@ -0,0 +1,128 @@ +<%= form_with model: beacon, local: true do |form| %> + <% if beacon.errors.any? %> +
+
+
+ + + +
+
+

+ <%= pluralize(beacon.errors.count, "error") %> prohibited this beacon from being saved: +

+
+
    + <% beacon.errors.full_messages.each do |message| %> +
  • <%= message %>
  • + <% end %> +
+
+
+
+
+ <% end %> + +
+ +
+

Basic Information

+
+ +
+ <%= form.label :name, class: "form-label" do %> + Beacon Name + * + <% end %> + <%= form.text_field :name, class: "form-input", placeholder: "e.g., Kampala-Central", required: true %> +

A descriptive name for this beacon deployment

+
+ + +
+

+ Document Access Filters +

+

+ Select which documents this beacon can access. Leave filters blank to allow access to all documents. +

+
+ +
+ <%= form.label :language_id, "Language", class: "form-label" do %> + Language + * + <% end %> +
+ <%= 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" %> +
+

Language for documents this beacon will access

+
+ +
+ <%= form.label :region_id, "Region", class: "form-label" do %> + Region + * + <% end %> +
+ <%= 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" %> +
+

Geographic region for this beacon

+
+ +
+ <%= 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" + } + } %> +

Select which content providers this beacon can access

+
+ +
+ <%= form.label :topic_ids, "Topics", class: "form-label" %> + <%= form.select :topic_ids, + options_from_collection_for_select(@topics, :id, :title, beacon.topic_ids), + { 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" + } + } %> +

Select which topics this beacon can access. Leave blank to allow access to all topics.

+
+
+ + +
+
+ <%= 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" %> +
+ + <% if beacon.persisted? %> +
+ Last updated: <%= beacon.updated_at.strftime("%B %d, %Y at %I:%M %p") %> +
+ <% end %> +
+<% end %> diff --git a/app/views/beacons/edit.html.erb b/app/views/beacons/edit.html.erb new file mode 100644 index 00000000..afe41e03 --- /dev/null +++ b/app/views/beacons/edit.html.erb @@ -0,0 +1,41 @@ +<% content_for :title, "Edit Beacon" %> + +
+
+ + +
+
+ <%= link_to beacon_path(@beacon), class: "text-gray-500 hover:text-gray-700 transition-colors" do %> + + + + <% end %> +

+ Edit Beacon +

+
+ + + +
+ + +
+ +
+ <%= render "form", beacon: @beacon %> +
+
+
+
diff --git a/app/views/beacons/index.html.erb b/app/views/beacons/index.html.erb new file mode 100644 index 00000000..a63ee2e0 --- /dev/null +++ b/app/views/beacons/index.html.erb @@ -0,0 +1,171 @@ +<% content_for :title, "Beacon Management" %> + +
+
+ + +
+
+
+
+
+ + + +
+

Beacon Management

+
+

Monitor system beacons deployed in the field

+
+ +
+
+
+ Active: <%= @beacons.count { |b| !b.revoked? } %> / + Revoked: <%= @beacons.count { |b| b.revoked? } %> +
+
+ + <%= link_to new_beacon_path, class: "bg-gradient-green text-white py-3.5 px-7 rounded-xl no-underline font-semibold text-sm inline-flex items-center gap-2 shadow-green transition-all hover:-translate-y-0.5", data: {turbo: false } do %> + + + + Provision New Beacon + <% end %> +
+
+
+ + +
+
+ + + + + + + + + + + + + + <% if @beacons.any? %> + <% @beacons.each do |beacon| %> + + + + + + + + + + <% end %> + <% else %> + + + + <% end %> + +
StatusBeacon NameCurrent Manifest VersionLatest Manifest VersionUp to Date?Last Seen atLast Sync at
+
+ + + <%= beacon.revoked? ? 'Revoked' : 'Active' %> + +
+
+ <%= link_to beacon_path(beacon), class: "no-underline" do %> +
+
+ + + +
+
+
<%= beacon.name %>
+
<%= beacon.api_key_prefix %>***
+
+
+ <% end %> +
+ + current manifest + + + + latest manifest + + + + is up to date? + + + + last seen at + + + + last sync at + +
+
+ + + +

No beacons provisioned yet

+

Get started by provisioning your first beacon.

+
+
+
+
+ + +
+
+
+
+

Total Beacons

+

<%= @beacons.count %>

+
+
+ + + +
+
+
+ +
+
+
+

Online Now

+

<%= @beacons.count { |b| b[:online] } %>

+
+
+ + + +
+
+
+ +
+
+
+

Offline

+

<%= @beacons.count { |b| !b[:online] } %>

+
+
+ + + +
+
+
+
+
+
diff --git a/app/views/beacons/new.html.erb b/app/views/beacons/new.html.erb new file mode 100644 index 00000000..c9cca61a --- /dev/null +++ b/app/views/beacons/new.html.erb @@ -0,0 +1,60 @@ +<% content_for :title, "Provision New Beacon" %> + +
+
+ + +
+
+ <%= link_to beacons_path, class: "text-gray-500 hover:text-gray-700 transition-colors" do %> + + + + <% end %> +

+ Provision New Beacon +

+
+ + + +
+ + +
+ +
+ <%= render "form", beacon: @beacon %> +
+
+ + +
+
+
+ + + +
+
+

How Document Filtering Works

+
    +
  • Required fields: Language and Region must be selected for each beacon
  • +
  • Providers: Select specific content providers this beacon can access, or leave blank for all providers
  • +
  • Topics: Select specific topics this beacon can access, or leave blank for all topics
  • +
  • API Key generation: A secure API key will be automatically generated upon provisioning
  • +
  • Security: Keep the API key secure - it will only be shown once during creation
  • +
+
+
+
+
+
diff --git a/app/views/beacons/show.html.erb b/app/views/beacons/show.html.erb new file mode 100644 index 00000000..3b13c77b --- /dev/null +++ b/app/views/beacons/show.html.erb @@ -0,0 +1,308 @@ +<% content_for :title, @beacon.name %> + +
+
+ + +
+
+
+
+
+ + + +
+

<%= @beacon.name %>

+
+

<%= @beacon.region.name %> Region

+
+ +
+ <%= link_to edit_beacon_path(@beacon), class: "bg-gradient-blue text-white py-3.5 px-7 rounded-xl no-underline font-semibold text-sm inline-flex items-center gap-2 shadow-blue transition-all hover:-translate-y-0.5", data: {turbo: false } do %> + + + + Edit Beacon + <% end %> +
+
+ + + +
+ + +
+
+
+
+

Status

+

+ <%= @beacon.revoked? ? 'Revoked' : 'Active' %> +

+
+
+ + <% if @beacon.revoked? %> + + <% else %> + + <% end %> + +
+
+
+ +
+
+
+

Files Included on Beacon

+

<%= @beacon.document_count %>

+
+
+ + + +
+
+
+ +
+
+
+

Topics Included

+

<%= @beacon.file_count %>

+
+
+ + + +
+
+
+
+ + +
+ + +
+
+

+ + + + Beacon Details +

+
+
+
+
+
ID
+
<%= @beacon.id %>
+
+
+
API Key Prefix
+
<%= @beacon.api_key_prefix %>***
+
+
+
Created
+
<%= @beacon.created_at.strftime("%B %d, %Y at %I:%M %p") %>
+
+
+
Updated
+
<%= @beacon.updated_at.strftime("%B %d, %Y at %I:%M %p") %>
+
+
+
+
+ + +
+
+

+ + + + API Key +

+
+
+ <% if @api_key_display %> +
+
+ ✓ Beacon Provisioned - API Key Generated + +
+ <%= @api_key_display %> +
+
+
+
+ + + +
+
+

Save This Key

+

+ Copy this API key and configure it in your beacon deployment. This page will only show the full key immediately after creation. You can regenerate a new key anytime if needed. +

+
+
+
+ <% else %> +
+
+ API Key Prefix +
+ <%= @beacon.api_key_prefix %>*** +
+
+
+
+ + + +
+
+

API Key Active

+

+ The API key for this beacon is active (shown by prefix above). The full key was displayed when the beacon was first created. If you need a new key, use the regenerate button below. +

+
+
+
+
+ <%= button_to regenerate_key_beacon_path(@beacon), method: :post, + class: "w-full bg-amber-600 hover:bg-amber-700 text-white font-semibold py-3 px-4 rounded-lg transition-colors inline-flex items-center justify-center gap-2", + data: { turbo_confirm: "Are you sure? This will invalidate the current API key and generate a new one. Any beacons using the old key will stop working." } do %> + + + + Regenerate API Key + <% end %> + + <%= button_to revoke_key_beacon_path(@beacon), method: :post, + class: "w-full bg-red-600 hover:bg-red-700 text-white font-semibold py-3 px-4 rounded-lg transition-colors inline-flex items-center justify-center gap-2", + data: { turbo_confirm: "Are you sure? This will invalidate the current API key. Any beacons using the old key will stop working." } do %> + + + + Revoke API Key + <% end %> +
+ <% end %> +
+
+
+ + +
+
+

+ + + + Document Access Configuration +

+
+
+
+
+

Basic Configuration

+
+
+
Language
+
+ + <%= @beacon.language.name %> + +
+
+
+
Region
+
+ + <%= @beacon.region.name %> + +
+
+
+
+
+

Access Filters

+
+
+
Providers
+
+ <% if @beacon.providers.any? %> +
+ <% @beacon.providers.each do |provider| %> + + <%= provider.name %> + + <% end %> +
+ <% else %> + All Providers + <% end %> +
+
+
+
Topics
+
+ <% if @beacon.topics.any? %> +
+ <% @beacon.topics.each do |topic| %> + + <%= topic.title %> + + <% end %> +
+ <% else %> + All Topics + <% end %> +
+
+
+
+
+
+
+ +
+
+ + diff --git a/app/views/layouts/_sidebar.html.erb b/app/views/layouts/_sidebar.html.erb index 0a4528c3..66bb9110 100644 --- a/app/views/layouts/_sidebar.html.erb +++ b/app/views/layouts/_sidebar.html.erb @@ -59,6 +59,13 @@ <% end %> +
  • + <%= link_to beacons_path, class: "sidebar__link #{active_link_class(beacons_path)}" do %> + 📡 + Beacon Management + <% end %> +
  • +
  • <%= link_to import_reports_path, class: "sidebar__link #{active_link_class(import_reports_path)}" do %> 📊 diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 0c2f2ef3..d9ab9bbd 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -45,12 +45,7 @@
    - Built with - - - - by - Ruby for Good +

    Built with ❤️ by Ruby for Good

    diff --git a/config/routes.rb b/config/routes.rb index bfa18754..513a604c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -17,6 +17,12 @@ resources :tags, only: %i[index], controller: "topics/tags" end resources :import_reports, only: %i[index show] + resources :beacons, except: :destroy do + member do + post :regenerate_key + post :revoke_key + end + end resource :settings, only: [] do put :provider, on: :collection end diff --git a/db/schema.rb b/db/schema.rb index 46aaf864..d813d56b 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_02_03_100004) do +ActiveRecord::Schema[8.1].define(version: 2026_02_03_100134) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" diff --git a/spec/models/beacon_spec.rb b/spec/models/beacon_spec.rb index f397a629..9a86164c 100644 --- a/spec/models/beacon_spec.rb +++ b/spec/models/beacon_spec.rb @@ -62,6 +62,22 @@ end end + describe "#regenerate" do + it "reassigns the API key's prefix and digest" do + beacon = create(:beacon) + + expect { beacon.regenerate }.to change { beacon.api_key_digest } + .and change { beacon.api_key_prefix } + end + + it "sets revoked_at to nil" do + beacon = create(:beacon, :revoked) + + beacon.regenerate + expect(beacon.revoked_at).to be_nil + end + end + describe "#revoke!" do include ActiveSupport::Testing::TimeHelpers diff --git a/spec/requests/beacons_spec.rb b/spec/requests/beacons_spec.rb new file mode 100644 index 00000000..6bb5cd82 --- /dev/null +++ b/spec/requests/beacons_spec.rb @@ -0,0 +1,173 @@ +require "rails_helper" + +RSpec.describe "/beacons", type: :request do + let(:user) { create(:user, :admin) } + let(:region) { create(:region) } + let(:language) { create(:language) } + let(:valid_attributes) do + { name: "New Beacon", + language_id: language.id, + region_id: region.id, + } + end + let(:invalid_attributes) { { name: "" } } + + before do + sign_in(user) + end + + describe "GET /index" do + it "renders a successful response" do + create(:beacon) + + get beacons_url + + expect(response).to be_successful + end + end + + describe "GET /show" do + it "renders a successful response" do + beacon = create(:beacon) + + get beacon_url(beacon) + + expect(response).to be_successful + end + end + + describe "GET /new" do + it "renders a successful response" do + get new_beacon_url + + expect(response).to be_successful + end + end + + describe "GET /edit" do + let(:beacon) { create(:beacon) } + + it "renders a successful response" do + get edit_beacon_url(beacon) + + expect(response).to be_successful + end + end + + describe "POST /create" do + context "with valid parameters" do + it "creates a new beacon" do + expect { + post beacons_url, params: { beacon: valid_attributes } + }.to change(Beacon, :count).by(1) + end + + it "redirects to the created beacon" do + post beacons_url, params: { beacon: valid_attributes } + + expect(response).to redirect_to(beacon_url(Beacon.last)) + end + end + + context "with invalid parameters" do + it "does not create a new beacon" do + expect { + post beacons_url, params: { beacon: invalid_attributes } + }.to change(Beacon, :count).by(0) + end + + it "renders a response with 422 status (i.e. to display the 'new' template)" do + post beacons_url, params: { beacon: invalid_attributes } + + expect(response).to have_http_status(:unprocessable_content) + end + end + end + + describe "PATCH /update" do + context "with valid parameters" do + let(:updated_region) { create(:region) } + let(:updated_language) { create(:language) } + let(:new_attributes) do + { name: "Updated Beacon", + language_id: updated_language.id, + region_id: updated_region.id, + } + end + + it "updates the requested beacon" do + beacon = create(:beacon) + + patch beacon_url(beacon), params: { beacon: new_attributes } + beacon.reload + + expect(beacon.name).to eq("Updated Beacon") + expect(beacon.language).to eq(updated_language) + expect(beacon.region).to eq(updated_region) + end + + it "redirects to the beacon" do + beacon = create(:beacon) + + patch beacon_url(beacon), params: { beacon: new_attributes } + beacon.reload + + expect(response).to redirect_to(beacon_url(beacon)) + end + end + + context "with invalid parameters" do + it "renders a response with 422 status (i.e. to display the 'edit' template)" do + beacon = create(:beacon) + + patch beacon_url(beacon), params: { beacon: invalid_attributes } + + expect(response).to have_http_status(:unprocessable_content) + end + end + end + + describe "POST /regenerate_key" do + let(:beacon) { create(:beacon) } + subject { post regenerate_key_beacon_url(beacon) } + + it "redirects to the beacon" do + subject + + expect(response).to redirect_to(beacon_url(beacon)) + end + + context "when there is an error" do + before { allow(beacon).to receive(:regenerate).and_raise("Error") } + + it "redirects to the beacon" do + subject + + expect(response).to redirect_to(beacon_url(beacon)) + end + end + end + + describe "POST /revoke_key" do + include ActiveSupport::Testing::TimeHelpers + + let(:beacon) { create(:beacon) } + subject { post revoke_key_beacon_url(beacon) } + + it "redirects to the beacon" do + subject + + expect(response).to redirect_to(beacon_url(beacon)) + end + + context "when there is an error" do + before { allow(beacon).to receive(:revoke!).and_raise("Error") } + + it "redirects to the beacon" do + subject + + expect(response).to redirect_to(beacon_url(beacon)) + end + end + end +end