feat(ntfy): add admin Notifications settings page with test button
This commit is contained in:
41
app/controllers/admin/notifications_controller.rb
Normal file
41
app/controllers/admin/notifications_controller.rb
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
module Admin
|
||||||
|
class NotificationsController < BaseController
|
||||||
|
def show
|
||||||
|
@admin = current_admin
|
||||||
|
end
|
||||||
|
|
||||||
|
def update
|
||||||
|
@admin = current_admin
|
||||||
|
if @admin.update(admin_params)
|
||||||
|
redirect_to admin_notifications_path, notice: "Notification settings saved"
|
||||||
|
else
|
||||||
|
flash.now[:alert] = "Failed to save settings"
|
||||||
|
render :show, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def test
|
||||||
|
admin = current_admin
|
||||||
|
|
||||||
|
if admin.ntfy_configured?
|
||||||
|
SendNtfyNotificationJob.perform_now(
|
||||||
|
admin.id,
|
||||||
|
"test_notification",
|
||||||
|
title: "Test notification from MySMSAPio",
|
||||||
|
message: "If you can read this, your ntfy setup is working! Sent at #{Time.current.strftime('%H:%M:%S')}.",
|
||||||
|
priority: 3,
|
||||||
|
tags: [ "tada", "white_check_mark" ]
|
||||||
|
)
|
||||||
|
redirect_to admin_notifications_path, notice: "Test notification sent"
|
||||||
|
else
|
||||||
|
redirect_to admin_notifications_path, alert: "Configure ntfy topic and token first"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def admin_params
|
||||||
|
params.require(:admin_user).permit(:ntfy_enabled, :ntfy_topic, :ntfy_token, :ntfy_server_url)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
89
app/views/admin/notifications/show.html.erb
Normal file
89
app/views/admin/notifications/show.html.erb
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
<div class="space-y-6">
|
||||||
|
<div class="sm:flex sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl font-bold leading-tight tracking-tight text-gray-900">Notification Settings</h1>
|
||||||
|
<p class="mt-2 text-sm text-gray-600">Configure ntfy push notifications for your phone.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-xl bg-white shadow-sm ring-1 ring-gray-900/5 overflow-hidden">
|
||||||
|
<div class="px-6 py-5 border-b border-gray-200">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 flex items-center gap-2">
|
||||||
|
<i class="fas fa-bell text-blue-500"></i>
|
||||||
|
ntfy Configuration
|
||||||
|
</h2>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">
|
||||||
|
Install the <a href="https://ntfy.sh" class="text-blue-600 hover:underline" target="_blank" rel="noopener">ntfy app</a>
|
||||||
|
on your phone, subscribe to your topic, and enter the details below.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="px-6 py-6">
|
||||||
|
<%= form_with model: @admin, url: admin_notifications_path, method: :patch, local: true, class: "space-y-6" do |f| %>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<%= f.check_box :ntfy_enabled, class: "h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" %>
|
||||||
|
<%= f.label :ntfy_enabled, "Enable ntfy notifications", class: "text-sm font-medium text-gray-700" %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<%= f.label :ntfy_topic, "ntfy Topic", class: "block text-sm font-medium text-gray-700" %>
|
||||||
|
<p class="mt-1 text-xs text-gray-500">The topic name you subscribe to in the ntfy app. Treat this like a password.</p>
|
||||||
|
<%= f.text_field :ntfy_topic,
|
||||||
|
class: "mt-2 block w-full rounded-lg border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm py-2.5",
|
||||||
|
placeholder: "mysmsa-pio-your-secret-topic" %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<%= f.label :ntfy_token, "Access Token (optional)", class: "block text-sm font-medium text-gray-700" %>
|
||||||
|
<p class="mt-1 text-xs text-gray-500">Required only if your ntfy server has access control enabled.</p>
|
||||||
|
<%= f.text_field :ntfy_token,
|
||||||
|
class: "mt-2 block w-full rounded-lg border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm py-2.5",
|
||||||
|
placeholder: "tk_..." %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<%= f.label :ntfy_server_url, "ntfy Server URL", class: "block text-sm font-medium text-gray-700" %>
|
||||||
|
<p class="mt-1 text-xs text-gray-500">Leave blank to use the default (from NTFY_SERVER_URL env var or ntfy.sh).</p>
|
||||||
|
<%= f.text_field :ntfy_server_url,
|
||||||
|
class: "mt-2 block w-full rounded-lg border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm py-2.5",
|
||||||
|
placeholder: "https://ntfy.yourdomain.com" %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pt-2">
|
||||||
|
<%= f.submit "Save Settings",
|
||||||
|
class: "inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 transition-all" %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-xl bg-white shadow-sm ring-1 ring-gray-900/5 overflow-hidden">
|
||||||
|
<div class="px-6 py-5 border-b border-gray-200">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 flex items-center gap-2">
|
||||||
|
<i class="fas fa-paper-plane text-green-500"></i>
|
||||||
|
Send Test Notification
|
||||||
|
</h2>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">Sends a test push to your configured ntfy topic right now.</p>
|
||||||
|
</div>
|
||||||
|
<div class="px-6 py-6">
|
||||||
|
<%= button_to test_admin_notifications_path, method: :post,
|
||||||
|
class: "inline-flex items-center gap-2 rounded-lg bg-green-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-green-500 transition-all" do %>
|
||||||
|
<i class="fas fa-paper-plane"></i>
|
||||||
|
Send Test Notification
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-xl bg-blue-50 p-6 ring-1 ring-blue-900/5">
|
||||||
|
<h3 class="text-sm font-semibold text-blue-900 flex items-center gap-2">
|
||||||
|
<i class="fas fa-info-circle"></i>
|
||||||
|
How ntfy works
|
||||||
|
</h3>
|
||||||
|
<ul class="mt-3 space-y-2 text-sm text-blue-800">
|
||||||
|
<li class="flex gap-2"><i class="fas fa-check mt-1 text-xs"></i> Self-hosted ntfy server runs alongside this app in production.</li>
|
||||||
|
<li class="flex gap-2"><i class="fas fa-check mt-1 text-xs"></i> Each admin gets their own topic — subscribe in the ntfy app to receive pushes.</li>
|
||||||
|
<li class="flex gap-2"><i class="fas fa-check mt-1 text-xs"></i> Events: gateway offline/online, SMS failed/delivered, API key revoked.</li>
|
||||||
|
<li class="flex gap-2"><i class="fas fa-check mt-1 text-xs"></i> Notifications are sent asynchronously and retried on failure.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en" class="h-full bg-gray-50">
|
<html lang="en" class="h-full">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
@@ -12,6 +12,101 @@
|
|||||||
<%= javascript_importmap_tags %>
|
<%= javascript_importmap_tags %>
|
||||||
|
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" />
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Syne:wght@600;700;800&family=Outfit:wght@300;400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--accent: #38bdf8;
|
||||||
|
--accent-deep: #0ea5e9;
|
||||||
|
}
|
||||||
|
.font-display { font-family: 'Syne', sans-serif; }
|
||||||
|
.font-body { font-family: 'Outfit', sans-serif; }
|
||||||
|
.font-mono-tech { font-family: 'JetBrains Mono', ui-monospace, monospace; }
|
||||||
|
|
||||||
|
body { font-family: 'Outfit', sans-serif; }
|
||||||
|
|
||||||
|
/* Login immersive background */
|
||||||
|
.login-stage {
|
||||||
|
position: relative;
|
||||||
|
background: radial-gradient(ellipse 80% 60% at 50% 0%, #0f172a 0%, #020617 55%, #000 100%);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.login-stage::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute; inset: 0;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(rgba(56, 189, 248, 0.07) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, rgba(56, 189, 248, 0.07) 1px, transparent 1px);
|
||||||
|
background-size: 46px 46px;
|
||||||
|
-webkit-mask-image: radial-gradient(ellipse at center, black 20%, transparent 72%);
|
||||||
|
mask-image: radial-gradient(ellipse at center, black 20%, transparent 72%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.login-stage::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute; inset: 0;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 12% 18%, rgba(56, 189, 248, 0.14), transparent 42%),
|
||||||
|
radial-gradient(circle at 88% 82%, rgba(14, 165, 233, 0.11), transparent 42%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.login-card {
|
||||||
|
background: rgba(255, 255, 255, 0.035);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
-webkit-backdrop-filter: blur(20px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.09);
|
||||||
|
box-shadow: 0 0 0 1px rgba(56, 189, 248, 0.08), 0 30px 80px -20px rgba(0, 0, 0, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.beacon-ring {
|
||||||
|
animation: beacon-pulse 2.4s ease-out infinite;
|
||||||
|
}
|
||||||
|
.beacon-ring.delay-1 { animation-delay: 0.8s; }
|
||||||
|
.beacon-ring.delay-2 { animation-delay: 1.6s; }
|
||||||
|
@keyframes beacon-pulse {
|
||||||
|
0% { transform: scale(0.6); opacity: 0.55; }
|
||||||
|
80% { opacity: 0; }
|
||||||
|
100% { transform: scale(1.8); opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-input {
|
||||||
|
background: rgba(15, 23, 42, 0.6);
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||||
|
color: #e2e8f0;
|
||||||
|
transition: border-color .18s ease, box-shadow .18s ease, background .18s ease;
|
||||||
|
}
|
||||||
|
.field-input::placeholder { color: #64748b; }
|
||||||
|
.field-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 3px rgba(56, 189, 248, 0.18);
|
||||||
|
background: rgba(15, 23, 42, 0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-accent {
|
||||||
|
background: linear-gradient(135deg, #38bdf8 0%, #0ea5e9 100%);
|
||||||
|
box-shadow: 0 10px 30px -10px rgba(56, 189, 248, 0.55), inset 0 1px 0 rgba(255,255,255,0.25);
|
||||||
|
transition: transform .15s ease, box-shadow .2s ease, filter .2s ease;
|
||||||
|
}
|
||||||
|
.btn-accent:hover { transform: translateY(-1px); filter: brightness(1.08); box-shadow: 0 16px 40px -10px rgba(56, 189, 248, 0.7), inset 0 1px 0 rgba(255,255,255,0.3); }
|
||||||
|
.btn-accent:active { transform: translateY(0); }
|
||||||
|
|
||||||
|
@keyframes fade-up {
|
||||||
|
from { opacity: 0; transform: translateY(10px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
.fade-up { animation: fade-up .5s cubic-bezier(0.16, 1, 0.3, 1) both; }
|
||||||
|
.fade-up.d-1 { animation-delay: .06s; }
|
||||||
|
.fade-up.d-2 { animation-delay: .14s; }
|
||||||
|
.fade-up.d-3 { animation-delay: .22s; }
|
||||||
|
.fade-up.d-4 { animation-delay: .30s; }
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.beacon-ring, .fade-up { animation: none; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body class="h-full">
|
<body class="h-full">
|
||||||
@@ -54,6 +149,11 @@
|
|||||||
<i class="fas fa-vial w-6 h-6 shrink-0 flex items-center justify-center"></i>
|
<i class="fas fa-vial w-6 h-6 shrink-0 flex items-center justify-center"></i>
|
||||||
API Tester
|
API Tester
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
|
<%= link_to admin_notifications_path, class: "group flex gap-x-3 rounded-md p-3 text-sm leading-6 font-semibold transition-all duration-200 #{current_page?(admin_notifications_path) ? 'bg-gray-700 text-white' : 'text-gray-300 hover:text-white hover:bg-gray-700'}" do %>
|
||||||
|
<i class="fas fa-bell w-6 h-6 shrink-0 flex items-center justify-center"></i>
|
||||||
|
Notifications
|
||||||
|
<% end %>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li class="mt-auto">
|
<li class="mt-auto">
|
||||||
@@ -115,9 +215,24 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<% else %>
|
<% else %>
|
||||||
<!-- Login page without sidebar -->
|
<!-- Login stage (not logged in) — flash IS rendered here -->
|
||||||
<div class="min-h-full flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
|
<div class="login-stage min-h-full flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
|
||||||
<%= yield %>
|
<div class="relative w-full max-w-md">
|
||||||
|
<% if flash[:alert] %>
|
||||||
|
<div class="fade-up mb-6 flex items-start gap-3 rounded-xl border border-red-400/30 bg-red-500/10 p-4 backdrop-blur-md">
|
||||||
|
<i class="fas fa-exclamation-circle mt-0.5 text-red-400"></i>
|
||||||
|
<p class="text-sm font-medium text-red-100"><%= flash[:alert] %></p>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
<% if flash[:notice] %>
|
||||||
|
<div class="fade-up mb-6 flex items-start gap-3 rounded-xl border border-emerald-400/30 bg-emerald-500/10 p-4 backdrop-blur-md">
|
||||||
|
<i class="fas fa-check-circle mt-0.5 text-emerald-400"></i>
|
||||||
|
<p class="text-sm font-medium text-emerald-100"><%= flash[:notice] %></p>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%= yield %>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -66,6 +66,10 @@ Rails.application.routes.draw do
|
|||||||
end
|
end
|
||||||
|
|
||||||
get "api_tester", to: "api_tester#index"
|
get "api_tester", to: "api_tester#index"
|
||||||
|
|
||||||
|
resource :notifications, only: [:show, :update] do
|
||||||
|
post :test
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Root route
|
# Root route
|
||||||
|
|||||||
49
test/integration/admin/notifications_flow_test.rb
Normal file
49
test/integration/admin/notifications_flow_test.rb
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class AdminNotificationsFlowTest < ActionDispatch::IntegrationTest
|
||||||
|
setup do
|
||||||
|
@admin = AdminUser.create!(name: "Flow Admin", email: "flow@example.com", password: "password123")
|
||||||
|
post admin_login_path, params: { email: "flow@example.com", password: "password123" }
|
||||||
|
end
|
||||||
|
|
||||||
|
test "can view notification settings page" do
|
||||||
|
get admin_notifications_path
|
||||||
|
assert_response :success
|
||||||
|
assert_match "Notification Settings", response.body
|
||||||
|
assert_match "ntfy Topic", response.body
|
||||||
|
end
|
||||||
|
|
||||||
|
test "can update ntfy settings" do
|
||||||
|
patch admin_notifications_path, params: {
|
||||||
|
admin_user: {
|
||||||
|
ntfy_enabled: "1",
|
||||||
|
ntfy_topic: "flow-topic",
|
||||||
|
ntfy_token: "tk_flow",
|
||||||
|
ntfy_server_url: "https://ntfy.example.com"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_redirected_to admin_notifications_path
|
||||||
|
@admin.reload
|
||||||
|
assert_equal true, @admin.ntfy_enabled
|
||||||
|
assert_equal "flow-topic", @admin.ntfy_topic
|
||||||
|
assert_equal "tk_flow", @admin.ntfy_token
|
||||||
|
end
|
||||||
|
|
||||||
|
test "test notification redirects with alert when not configured" do
|
||||||
|
post test_admin_notifications_path
|
||||||
|
assert_redirected_to admin_notifications_path
|
||||||
|
follow_redirect!
|
||||||
|
assert_match "Configure ntfy", response.body
|
||||||
|
end
|
||||||
|
|
||||||
|
test "test notification sends and redirects with notice when configured" do
|
||||||
|
@admin.update!(ntfy_enabled: true, ntfy_topic: "t", ntfy_token: "tk", ntfy_server_url: "https://ntfy.example.com")
|
||||||
|
stub_request(:post, "https://ntfy.example.com/t").to_return(status: 200)
|
||||||
|
|
||||||
|
post test_admin_notifications_path
|
||||||
|
assert_redirected_to admin_notifications_path
|
||||||
|
follow_redirect!
|
||||||
|
assert_match "Test notification sent", response.body
|
||||||
|
end
|
||||||
|
end
|
||||||
Reference in New Issue
Block a user