completed SMS gateway project
This commit is contained in:
0
app/assets/builds/.keep
Normal file
0
app/assets/builds/.keep
Normal file
0
app/assets/images/.keep
Normal file
0
app/assets/images/.keep
Normal file
10
app/assets/stylesheets/application.css
Normal file
10
app/assets/stylesheets/application.css
Normal file
@@ -0,0 +1,10 @@
|
||||
/*
|
||||
* This is a manifest file that'll be compiled into application.css.
|
||||
*
|
||||
* With Propshaft, assets are served efficiently without preprocessing steps. You can still include
|
||||
* application-wide styles in this file, but keep in mind that CSS precedence will follow the standard
|
||||
* cascading order, meaning styles declared later in the document or manifest will override earlier ones,
|
||||
* depending on specificity.
|
||||
*
|
||||
* Consider organizing styles into separate files for maintainability.
|
||||
*/
|
||||
36
app/assets/tailwind/application.css
Normal file
36
app/assets/tailwind/application.css
Normal file
@@ -0,0 +1,36 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/* Custom Admin Theme */
|
||||
@theme {
|
||||
--color-primary-50: #eff6ff;
|
||||
--color-primary-100: #dbeafe;
|
||||
--color-primary-200: #bfdbfe;
|
||||
--color-primary-300: #93c5fd;
|
||||
--color-primary-400: #60a5fa;
|
||||
--color-primary-500: #3b82f6;
|
||||
--color-primary-600: #2563eb;
|
||||
--color-primary-700: #1d4ed8;
|
||||
--color-primary-800: #1e40af;
|
||||
--color-primary-900: #1e3a8a;
|
||||
|
||||
--font-sans: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #cbd5e1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #94a3b8;
|
||||
}
|
||||
4
app/channels/application_cable/channel.rb
Normal file
4
app/channels/application_cable/channel.rb
Normal file
@@ -0,0 +1,4 @@
|
||||
module ApplicationCable
|
||||
class Channel < ActionCable::Channel::Base
|
||||
end
|
||||
end
|
||||
31
app/channels/application_cable/connection.rb
Normal file
31
app/channels/application_cable/connection.rb
Normal file
@@ -0,0 +1,31 @@
|
||||
module ApplicationCable
|
||||
class Connection < ActionCable::Connection::Base
|
||||
identified_by :current_gateway
|
||||
|
||||
def connect
|
||||
self.current_gateway = find_verified_gateway
|
||||
logger.info "Gateway #{current_gateway.device_id} connected"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def find_verified_gateway
|
||||
# Get API key from request params
|
||||
api_key = request.params[:api_key]
|
||||
|
||||
if api_key.blank?
|
||||
reject_unauthorized_connection
|
||||
end
|
||||
|
||||
# Hash the API key and find gateway
|
||||
api_key_digest = Digest::SHA256.hexdigest(api_key)
|
||||
gateway = Gateway.find_by(api_key_digest: api_key_digest, active: true)
|
||||
|
||||
if gateway
|
||||
gateway
|
||||
else
|
||||
reject_unauthorized_connection
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
99
app/channels/gateway_channel.rb
Normal file
99
app/channels/gateway_channel.rb
Normal file
@@ -0,0 +1,99 @@
|
||||
class GatewayChannel < ApplicationCable::Channel
|
||||
def subscribed
|
||||
# Gateway is already authenticated at the connection level
|
||||
# current_gateway is set by ApplicationCable::Connection
|
||||
@gateway = current_gateway
|
||||
|
||||
unless @gateway
|
||||
reject
|
||||
return
|
||||
end
|
||||
|
||||
# Subscribe to gateway-specific channel
|
||||
stream_for @gateway
|
||||
|
||||
# Update gateway status
|
||||
@gateway.heartbeat!
|
||||
|
||||
Rails.logger.info("Gateway #{@gateway.device_id} subscribed to GatewayChannel")
|
||||
end
|
||||
|
||||
def unsubscribed
|
||||
# Update gateway status when disconnected
|
||||
if @gateway
|
||||
@gateway.mark_offline!
|
||||
Rails.logger.info("Gateway #{@gateway.device_id} disconnected from WebSocket")
|
||||
end
|
||||
end
|
||||
|
||||
def receive(data)
|
||||
return unless @gateway
|
||||
|
||||
# Handle incoming messages from gateway
|
||||
case data["action"]
|
||||
when "heartbeat"
|
||||
handle_heartbeat(data)
|
||||
when "delivery_report"
|
||||
handle_delivery_report(data)
|
||||
when "message_received"
|
||||
handle_message_received(data)
|
||||
else
|
||||
Rails.logger.warn("Unknown action received: #{data['action']}")
|
||||
end
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("Error processing gateway message: #{e.message}")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def handle_heartbeat(data)
|
||||
@gateway.heartbeat!
|
||||
|
||||
# Update metadata if provided
|
||||
metadata = {
|
||||
battery_level: data["battery_level"],
|
||||
signal_strength: data["signal_strength"],
|
||||
messages_in_queue: data["messages_in_queue"]
|
||||
}.compact
|
||||
|
||||
@gateway.update(metadata: metadata) if metadata.any?
|
||||
end
|
||||
|
||||
def handle_delivery_report(data)
|
||||
message_id = data["message_id"]
|
||||
status = data["status"]
|
||||
error_message = data["error_message"]
|
||||
|
||||
sms = SmsMessage.find_by(message_id: message_id)
|
||||
return unless sms
|
||||
|
||||
case status
|
||||
when "delivered"
|
||||
sms.mark_delivered!
|
||||
when "failed"
|
||||
sms.mark_failed!(error_message)
|
||||
RetryFailedSmsJob.perform_later(sms.id) if sms.can_retry?
|
||||
end
|
||||
end
|
||||
|
||||
def handle_message_received(data)
|
||||
sender = data["sender"]
|
||||
message = data["message"]
|
||||
timestamp = data["timestamp"] || Time.current
|
||||
|
||||
# Create inbound SMS
|
||||
sms = SmsMessage.create!(
|
||||
gateway: @gateway,
|
||||
direction: "inbound",
|
||||
phone_number: sender,
|
||||
message_body: message,
|
||||
status: "delivered",
|
||||
delivered_at: timestamp
|
||||
)
|
||||
|
||||
@gateway.increment_received_count!
|
||||
|
||||
# Process inbound message
|
||||
ProcessInboundSmsJob.perform_later(sms.id)
|
||||
end
|
||||
end
|
||||
76
app/controllers/admin/api_keys_controller.rb
Normal file
76
app/controllers/admin/api_keys_controller.rb
Normal file
@@ -0,0 +1,76 @@
|
||||
module Admin
|
||||
class ApiKeysController < BaseController
|
||||
def index
|
||||
@api_keys = ApiKey.order(created_at: :desc)
|
||||
end
|
||||
|
||||
def new
|
||||
@api_key = ApiKey.new
|
||||
end
|
||||
|
||||
def create
|
||||
# Build permissions hash
|
||||
permissions = {}
|
||||
permissions["send_sms"] = params.dig(:api_key, :send_sms) == "1"
|
||||
permissions["receive_sms"] = params.dig(:api_key, :receive_sms) == "1"
|
||||
permissions["manage_gateways"] = params.dig(:api_key, :manage_gateways) == "1"
|
||||
permissions["manage_otp"] = params.dig(:api_key, :manage_otp) == "1"
|
||||
|
||||
# Parse expiration date if provided
|
||||
expires_at = if params.dig(:api_key, :expires_at).present?
|
||||
Time.parse(params[:api_key][:expires_at])
|
||||
else
|
||||
nil
|
||||
end
|
||||
|
||||
# Generate API key
|
||||
result = ApiKey.generate!(
|
||||
name: params[:api_key][:name],
|
||||
permissions: permissions,
|
||||
expires_at: expires_at
|
||||
)
|
||||
|
||||
# Store in session to pass to show action
|
||||
session[:new_api_key_id] = result[:api_key].id
|
||||
session[:new_api_raw_key] = result[:raw_key]
|
||||
|
||||
redirect_to admin_api_key_path(result[:api_key])
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "API Key creation failed: #{e.message}\n#{e.backtrace.join("\n")}"
|
||||
flash.now[:alert] = "Error creating API key: #{e.message}"
|
||||
@api_key = ApiKey.new(name: params.dig(:api_key, :name))
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def show
|
||||
@api_key = ApiKey.find(params[:id])
|
||||
|
||||
# Check if this is a newly created key (from session)
|
||||
if session[:new_api_key_id] == @api_key.id && session[:new_api_raw_key].present?
|
||||
@raw_key = session[:new_api_raw_key]
|
||||
# Clear session data after retrieving
|
||||
session.delete(:new_api_key_id)
|
||||
session.delete(:new_api_raw_key)
|
||||
else
|
||||
# This is an existing key being viewed (shouldn't normally happen)
|
||||
redirect_to admin_api_keys_path, alert: "Cannot view API key details after creation"
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
@api_key = ApiKey.find(params[:id])
|
||||
@api_key.revoke!
|
||||
redirect_to admin_api_keys_path, notice: "API key revoked successfully"
|
||||
rescue => e
|
||||
redirect_to admin_api_keys_path, alert: "Error revoking API key: #{e.message}"
|
||||
end
|
||||
|
||||
def toggle
|
||||
@api_key = ApiKey.find(params[:id])
|
||||
@api_key.update!(active: !@api_key.active)
|
||||
redirect_to admin_api_keys_path, notice: "API key #{@api_key.active? ? 'activated' : 'deactivated'}"
|
||||
rescue => e
|
||||
redirect_to admin_api_keys_path, alert: "Error updating API key: #{e.message}"
|
||||
end
|
||||
end
|
||||
end
|
||||
8
app/controllers/admin/api_tester_controller.rb
Normal file
8
app/controllers/admin/api_tester_controller.rb
Normal file
@@ -0,0 +1,8 @@
|
||||
module Admin
|
||||
class ApiTesterController < BaseController
|
||||
def index
|
||||
@api_keys = ApiKey.active_keys.order(created_at: :desc)
|
||||
@gateways = Gateway.order(created_at: :desc)
|
||||
end
|
||||
end
|
||||
end
|
||||
30
app/controllers/admin/base_controller.rb
Normal file
30
app/controllers/admin/base_controller.rb
Normal file
@@ -0,0 +1,30 @@
|
||||
module Admin
|
||||
class BaseController < ActionController::Base
|
||||
include Pagy::Backend
|
||||
|
||||
# Enable session and flash for admin controllers
|
||||
# (needed because the app is in API-only mode)
|
||||
protect_from_forgery with: :exception
|
||||
|
||||
layout "admin"
|
||||
before_action :require_admin
|
||||
|
||||
private
|
||||
|
||||
def current_admin
|
||||
@current_admin ||= AdminUser.find_by(id: session[:admin_id]) if session[:admin_id]
|
||||
end
|
||||
helper_method :current_admin
|
||||
|
||||
def logged_in?
|
||||
current_admin.present?
|
||||
end
|
||||
helper_method :logged_in?
|
||||
|
||||
def require_admin
|
||||
unless logged_in?
|
||||
redirect_to admin_login_path, alert: "Please log in to continue"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
20
app/controllers/admin/dashboard_controller.rb
Normal file
20
app/controllers/admin/dashboard_controller.rb
Normal file
@@ -0,0 +1,20 @@
|
||||
module Admin
|
||||
class DashboardController < BaseController
|
||||
def index
|
||||
@stats = {
|
||||
total_gateways: Gateway.count,
|
||||
online_gateways: Gateway.online.count,
|
||||
total_api_keys: ApiKey.count,
|
||||
active_api_keys: ApiKey.active_keys.count,
|
||||
messages_today: SmsMessage.where("created_at >= ?", Time.current.beginning_of_day).count,
|
||||
messages_sent_today: SmsMessage.where("created_at >= ? AND direction = ?", Time.current.beginning_of_day, "outbound").count,
|
||||
messages_received_today: SmsMessage.where("created_at >= ? AND direction = ?", Time.current.beginning_of_day, "inbound").count,
|
||||
failed_messages_today: SmsMessage.where("created_at >= ? AND status = ?", Time.current.beginning_of_day, "failed").count,
|
||||
pending_messages: SmsMessage.pending.count
|
||||
}
|
||||
|
||||
@recent_messages = SmsMessage.order(created_at: :desc).limit(10)
|
||||
@recent_gateways = Gateway.order(last_heartbeat_at: :desc).limit(5)
|
||||
end
|
||||
end
|
||||
end
|
||||
152
app/controllers/admin/gateways_controller.rb
Normal file
152
app/controllers/admin/gateways_controller.rb
Normal file
@@ -0,0 +1,152 @@
|
||||
module Admin
|
||||
class GatewaysController < BaseController
|
||||
def index
|
||||
@gateways = Gateway.order(created_at: :desc)
|
||||
end
|
||||
|
||||
def new
|
||||
@gateway = Gateway.new
|
||||
end
|
||||
|
||||
def create
|
||||
@gateway = Gateway.new(
|
||||
device_id: params[:gateway][:device_id],
|
||||
name: params[:gateway][:name],
|
||||
priority: params[:gateway][:priority] || 1,
|
||||
status: "offline"
|
||||
)
|
||||
|
||||
# Generate API key for the gateway
|
||||
raw_key = @gateway.generate_api_key!
|
||||
|
||||
# Store in session to pass to show action
|
||||
session[:new_gateway_id] = @gateway.id
|
||||
session[:new_gateway_raw_key] = raw_key
|
||||
|
||||
redirect_to admin_gateway_path(@gateway)
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "Gateway creation failed: #{e.message}\n#{e.backtrace.join("\n")}"
|
||||
flash.now[:alert] = "Error creating gateway: #{e.message}"
|
||||
@gateway ||= Gateway.new
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def show
|
||||
@gateway = Gateway.find(params[:id])
|
||||
|
||||
# Check if this is a newly created gateway (from session)
|
||||
if session[:new_gateway_id] == @gateway.id && session[:new_gateway_raw_key].present?
|
||||
@raw_key = session[:new_gateway_raw_key]
|
||||
@is_new = true
|
||||
|
||||
# Generate QR code with configuration data
|
||||
@qr_code_data = generate_qr_code_data(@raw_key)
|
||||
|
||||
# Clear session data after retrieving
|
||||
session.delete(:new_gateway_id)
|
||||
session.delete(:new_gateway_raw_key)
|
||||
else
|
||||
@is_new = false
|
||||
@recent_messages = SmsMessage.where(gateway_id: @gateway.id).order(created_at: :desc).limit(20)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def generate_qr_code_data(api_key)
|
||||
require "rqrcode"
|
||||
|
||||
# Determine the base URL and WebSocket URL
|
||||
base_url = request.base_url
|
||||
ws_url = request.base_url.sub(/^http/, "ws") + "/cable"
|
||||
|
||||
# Create JSON configuration for the Android app
|
||||
config_data = {
|
||||
api_key: api_key,
|
||||
api_base_url: base_url,
|
||||
websocket_url: ws_url,
|
||||
version: "1.0"
|
||||
}.to_json
|
||||
|
||||
# Generate QR code
|
||||
qr = RQRCode::QRCode.new(config_data, level: :h)
|
||||
|
||||
# Return as SVG string
|
||||
qr.as_svg(
|
||||
offset: 0,
|
||||
color: "000",
|
||||
shape_rendering: "crispEdges",
|
||||
module_size: 4,
|
||||
standalone: true,
|
||||
use_path: true
|
||||
)
|
||||
end
|
||||
|
||||
def toggle
|
||||
@gateway = Gateway.find(params[:id])
|
||||
@gateway.update!(active: !@gateway.active)
|
||||
redirect_to admin_gateways_path, notice: "Gateway #{@gateway.active? ? 'activated' : 'deactivated'}"
|
||||
rescue => e
|
||||
redirect_to admin_gateways_path, alert: "Error updating gateway: #{e.message}"
|
||||
end
|
||||
|
||||
def test
|
||||
@gateway = Gateway.find(params[:id])
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
redirect_to admin_gateways_path, alert: "Gateway not found"
|
||||
end
|
||||
|
||||
def check_connection
|
||||
@gateway = Gateway.find(params[:id])
|
||||
|
||||
# Check if gateway is online based on recent heartbeat
|
||||
if @gateway.online?
|
||||
render json: {
|
||||
status: "success",
|
||||
message: "Gateway is online",
|
||||
last_heartbeat: @gateway.last_heartbeat_at,
|
||||
time_ago: helpers.time_ago_in_words(@gateway.last_heartbeat_at)
|
||||
}
|
||||
else
|
||||
render json: {
|
||||
status: "error",
|
||||
message: "Gateway is offline",
|
||||
last_heartbeat: @gateway.last_heartbeat_at,
|
||||
time_ago: @gateway.last_heartbeat_at ? helpers.time_ago_in_words(@gateway.last_heartbeat_at) : "never"
|
||||
}
|
||||
end
|
||||
rescue StandardError => e
|
||||
render json: { status: "error", message: e.message }, status: :internal_server_error
|
||||
end
|
||||
|
||||
def send_test_sms
|
||||
@gateway = Gateway.find(params[:id])
|
||||
phone_number = params[:phone_number]
|
||||
message_body = params[:message_body]
|
||||
|
||||
if phone_number.blank? || message_body.blank?
|
||||
render json: { status: "error", message: "Phone number and message are required" }, status: :unprocessable_entity
|
||||
return
|
||||
end
|
||||
|
||||
# Create test SMS message
|
||||
sms = SmsMessage.create!(
|
||||
direction: "outbound",
|
||||
phone_number: phone_number,
|
||||
message_body: message_body,
|
||||
gateway: @gateway,
|
||||
metadata: { test: true, sent_from: "admin_interface" }
|
||||
)
|
||||
|
||||
render json: {
|
||||
status: "success",
|
||||
message: "Test SMS queued for sending",
|
||||
message_id: sms.message_id,
|
||||
sms_status: sms.status
|
||||
}
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "Test SMS failed: #{e.message}\n#{e.backtrace.join("\n")}"
|
||||
render json: { status: "error", message: e.message }, status: :internal_server_error
|
||||
end
|
||||
end
|
||||
end
|
||||
37
app/controllers/admin/logs_controller.rb
Normal file
37
app/controllers/admin/logs_controller.rb
Normal file
@@ -0,0 +1,37 @@
|
||||
module Admin
|
||||
class LogsController < BaseController
|
||||
def index
|
||||
@pagy, @messages = pagy(
|
||||
apply_filters(SmsMessage).order(created_at: :desc),
|
||||
items: 50
|
||||
)
|
||||
|
||||
respond_to do |format|
|
||||
format.html
|
||||
format.turbo_stream
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def apply_filters(scope)
|
||||
scope = scope.where(direction: params[:direction]) if params[:direction].present?
|
||||
scope = scope.where(status: params[:status]) if params[:status].present?
|
||||
scope = scope.where(gateway_id: params[:gateway_id]) if params[:gateway_id].present?
|
||||
|
||||
if params[:phone_number].present?
|
||||
scope = scope.where("phone_number LIKE ?", "%#{params[:phone_number]}%")
|
||||
end
|
||||
|
||||
if params[:start_date].present?
|
||||
scope = scope.where("created_at >= ?", Time.parse(params[:start_date]))
|
||||
end
|
||||
|
||||
if params[:end_date].present?
|
||||
scope = scope.where("created_at <= ?", Time.parse(params[:end_date]).end_of_day)
|
||||
end
|
||||
|
||||
scope
|
||||
end
|
||||
end
|
||||
end
|
||||
38
app/controllers/admin/sessions_controller.rb
Normal file
38
app/controllers/admin/sessions_controller.rb
Normal file
@@ -0,0 +1,38 @@
|
||||
module Admin
|
||||
class SessionsController < ActionController::Base
|
||||
layout "admin"
|
||||
|
||||
# CSRF protection is enabled by default in ActionController::Base
|
||||
# We need it for the create action but not for the new (GET) action
|
||||
protect_from_forgery with: :exception
|
||||
|
||||
def new
|
||||
redirect_to admin_dashboard_path if current_admin
|
||||
end
|
||||
|
||||
def create
|
||||
admin = AdminUser.find_by(email: params[:email]&.downcase)
|
||||
|
||||
if admin&.authenticate(params[:password])
|
||||
session[:admin_id] = admin.id
|
||||
admin.update_last_login!
|
||||
redirect_to admin_dashboard_path, notice: "Welcome back, #{admin.name}!"
|
||||
else
|
||||
flash.now[:alert] = "Invalid email or password"
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
session.delete(:admin_id)
|
||||
redirect_to admin_login_path, notice: "You have been logged out"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def current_admin
|
||||
@current_admin ||= AdminUser.find_by(id: session[:admin_id]) if session[:admin_id]
|
||||
end
|
||||
helper_method :current_admin
|
||||
end
|
||||
end
|
||||
49
app/controllers/api/v1/admin/gateways_controller.rb
Normal file
49
app/controllers/api/v1/admin/gateways_controller.rb
Normal file
@@ -0,0 +1,49 @@
|
||||
module Api
|
||||
module V1
|
||||
module Admin
|
||||
class GatewaysController < ApplicationController
|
||||
include ApiAuthenticatable
|
||||
|
||||
# GET /api/v1/admin/gateways
|
||||
def index
|
||||
gateways = ::Gateway.order(created_at: :desc)
|
||||
|
||||
render json: {
|
||||
gateways: gateways.map { |gateway|
|
||||
{
|
||||
id: gateway.id,
|
||||
device_id: gateway.device_id,
|
||||
name: gateway.name,
|
||||
status: gateway.status,
|
||||
last_heartbeat_at: gateway.last_heartbeat_at,
|
||||
messages_sent_today: gateway.messages_sent_today,
|
||||
messages_received_today: gateway.messages_received_today,
|
||||
total_messages_sent: gateway.total_messages_sent,
|
||||
total_messages_received: gateway.total_messages_received,
|
||||
active: gateway.active,
|
||||
priority: gateway.priority,
|
||||
metadata: gateway.metadata,
|
||||
created_at: gateway.created_at
|
||||
}
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
# POST /api/v1/admin/gateways/:id/toggle
|
||||
def toggle
|
||||
gateway = ::Gateway.find(params[:id])
|
||||
gateway.update!(active: !gateway.active)
|
||||
|
||||
render json: {
|
||||
success: true,
|
||||
gateway: {
|
||||
id: gateway.id,
|
||||
device_id: gateway.device_id,
|
||||
active: gateway.active
|
||||
}
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
60
app/controllers/api/v1/admin/stats_controller.rb
Normal file
60
app/controllers/api/v1/admin/stats_controller.rb
Normal file
@@ -0,0 +1,60 @@
|
||||
module Api
|
||||
module V1
|
||||
module Admin
|
||||
class StatsController < ApplicationController
|
||||
include ApiAuthenticatable
|
||||
|
||||
# GET /api/v1/admin/stats
|
||||
def index
|
||||
today = Time.current.beginning_of_day
|
||||
|
||||
# Gateway stats
|
||||
total_gateways = ::Gateway.count
|
||||
active_gateways = ::Gateway.active.count
|
||||
online_gateways = ::Gateway.online.count
|
||||
|
||||
# Message stats
|
||||
total_messages_sent = ::Gateway.sum(:total_messages_sent)
|
||||
total_messages_received = ::Gateway.sum(:total_messages_received)
|
||||
|
||||
messages_sent_today = ::Gateway.sum(:messages_sent_today)
|
||||
messages_received_today = ::Gateway.sum(:messages_received_today)
|
||||
|
||||
# Pending and failed messages
|
||||
pending_messages = SmsMessage.pending.count
|
||||
failed_messages_today = SmsMessage.failed
|
||||
.where("created_at >= ?", today)
|
||||
.count
|
||||
|
||||
# OTP stats
|
||||
otps_sent_today = OtpCode.where("created_at >= ?", today).count
|
||||
otps_verified_today = OtpCode.where("verified_at >= ?", today).count
|
||||
|
||||
render json: {
|
||||
gateways: {
|
||||
total: total_gateways,
|
||||
active: active_gateways,
|
||||
online: online_gateways,
|
||||
offline: total_gateways - online_gateways
|
||||
},
|
||||
messages: {
|
||||
total_sent: total_messages_sent,
|
||||
total_received: total_messages_received,
|
||||
sent_today: messages_sent_today,
|
||||
received_today: messages_received_today,
|
||||
total_today: messages_sent_today + messages_received_today,
|
||||
pending: pending_messages,
|
||||
failed_today: failed_messages_today
|
||||
},
|
||||
otp: {
|
||||
sent_today: otps_sent_today,
|
||||
verified_today: otps_verified_today,
|
||||
verification_rate: otps_sent_today > 0 ? (otps_verified_today.to_f / otps_sent_today * 100).round(2) : 0
|
||||
},
|
||||
timestamp: Time.current
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
10
app/controllers/api/v1/gateway/base_controller.rb
Normal file
10
app/controllers/api/v1/gateway/base_controller.rb
Normal file
@@ -0,0 +1,10 @@
|
||||
module Api
|
||||
module V1
|
||||
module Gateway
|
||||
class BaseController < ApplicationController
|
||||
include ApiAuthenticatable
|
||||
include RateLimitable
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
30
app/controllers/api/v1/gateway/heartbeats_controller.rb
Normal file
30
app/controllers/api/v1/gateway/heartbeats_controller.rb
Normal file
@@ -0,0 +1,30 @@
|
||||
module Api
|
||||
module V1
|
||||
module Gateway
|
||||
class HeartbeatsController < BaseController
|
||||
def create
|
||||
current_gateway.heartbeat!
|
||||
|
||||
# Update metadata with device info
|
||||
metadata = {
|
||||
battery_level: params[:battery_level],
|
||||
signal_strength: params[:signal_strength],
|
||||
messages_in_queue: params[:messages_in_queue]
|
||||
}.compact
|
||||
|
||||
current_gateway.update(metadata: metadata) if metadata.any?
|
||||
|
||||
# Get count of pending messages for this gateway
|
||||
pending_count = SmsMessage.pending
|
||||
.where(gateway_id: [nil, current_gateway.id])
|
||||
.count
|
||||
|
||||
render json: {
|
||||
success: true,
|
||||
pending_messages: pending_count
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
53
app/controllers/api/v1/gateway/registrations_controller.rb
Normal file
53
app/controllers/api/v1/gateway/registrations_controller.rb
Normal file
@@ -0,0 +1,53 @@
|
||||
module Api
|
||||
module V1
|
||||
module Gateway
|
||||
class RegistrationsController < ApplicationController
|
||||
skip_before_action :authenticate_api_key!, only: [:create]
|
||||
|
||||
def create
|
||||
device_id = params.require(:device_id)
|
||||
name = params[:name] || "Gateway #{device_id[0..7]}"
|
||||
|
||||
# Check if gateway already exists
|
||||
gateway = ::Gateway.find_by(device_id: device_id)
|
||||
|
||||
if gateway
|
||||
render json: {
|
||||
success: false,
|
||||
error: "Gateway already registered"
|
||||
}, status: :conflict
|
||||
return
|
||||
end
|
||||
|
||||
# Create new gateway
|
||||
gateway = ::Gateway.new(
|
||||
device_id: device_id,
|
||||
name: name,
|
||||
status: "offline"
|
||||
)
|
||||
|
||||
raw_key = gateway.generate_api_key!
|
||||
|
||||
render json: {
|
||||
success: true,
|
||||
api_key: raw_key,
|
||||
device_id: gateway.device_id,
|
||||
websocket_url: websocket_url
|
||||
}, status: :created
|
||||
rescue ActionController::ParameterMissing => e
|
||||
render json: { error: e.message }, status: :bad_request
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def websocket_url
|
||||
if Rails.env.production?
|
||||
"wss://#{request.host}/cable"
|
||||
else
|
||||
"ws://#{request.host}:#{request.port}/cable"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
61
app/controllers/api/v1/gateway/sms_controller.rb
Normal file
61
app/controllers/api/v1/gateway/sms_controller.rb
Normal file
@@ -0,0 +1,61 @@
|
||||
module Api
|
||||
module V1
|
||||
module Gateway
|
||||
class SmsController < BaseController
|
||||
# POST /api/v1/gateway/sms/received
|
||||
def received
|
||||
sender = params.require(:sender)
|
||||
message = params.require(:message)
|
||||
timestamp = params[:timestamp] || Time.current
|
||||
|
||||
# Create inbound SMS message
|
||||
sms = SmsMessage.create!(
|
||||
gateway: current_gateway,
|
||||
direction: "inbound",
|
||||
phone_number: sender,
|
||||
message_body: message,
|
||||
status: "delivered",
|
||||
delivered_at: timestamp
|
||||
)
|
||||
|
||||
# Increment received counter
|
||||
current_gateway.increment_received_count!
|
||||
|
||||
# Process inbound message asynchronously
|
||||
ProcessInboundSmsJob.perform_later(sms.id)
|
||||
|
||||
render json: {
|
||||
success: true,
|
||||
message_id: sms.message_id
|
||||
}
|
||||
rescue ActionController::ParameterMissing => e
|
||||
render json: { error: e.message }, status: :bad_request
|
||||
end
|
||||
|
||||
# POST /api/v1/gateway/sms/status
|
||||
def status
|
||||
message_id = params.require(:message_id)
|
||||
status = params.require(:status)
|
||||
error_message = params[:error_message]
|
||||
|
||||
sms = SmsMessage.find_by!(message_id: message_id)
|
||||
|
||||
case status
|
||||
when "delivered"
|
||||
sms.mark_delivered!
|
||||
when "failed"
|
||||
sms.mark_failed!(error_message)
|
||||
# Retry if possible
|
||||
RetryFailedSmsJob.perform_later(sms.id) if sms.can_retry?
|
||||
when "sent"
|
||||
sms.update!(status: "sent", sent_at: Time.current)
|
||||
end
|
||||
|
||||
render json: { success: true }
|
||||
rescue ActionController::ParameterMissing => e
|
||||
render json: { error: e.message }, status: :bad_request
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
86
app/controllers/api/v1/otp_controller.rb
Normal file
86
app/controllers/api/v1/otp_controller.rb
Normal file
@@ -0,0 +1,86 @@
|
||||
module Api
|
||||
module V1
|
||||
class OtpController < ApplicationController
|
||||
include ApiAuthenticatable
|
||||
include RateLimitable
|
||||
|
||||
# POST /api/v1/otp/send
|
||||
def send_otp
|
||||
phone_number = params.require(:phone_number)
|
||||
purpose = params[:purpose] || "authentication"
|
||||
expiry_minutes = params[:expiry_minutes]&.to_i || 5
|
||||
|
||||
# Rate limit by phone number
|
||||
return unless rate_limit_by_phone!(phone_number, limit: 3, period: 1.hour)
|
||||
|
||||
# Validate phone number
|
||||
phone = Phonelib.parse(phone_number)
|
||||
unless phone.valid?
|
||||
render json: { error: "Invalid phone number format" }, status: :unprocessable_entity
|
||||
return
|
||||
end
|
||||
|
||||
# Send OTP
|
||||
result = OtpCode.send_otp(
|
||||
phone.e164,
|
||||
purpose: purpose,
|
||||
expiry_minutes: expiry_minutes,
|
||||
ip_address: request.remote_ip
|
||||
)
|
||||
|
||||
render json: {
|
||||
success: true,
|
||||
expires_at: result[:otp].expires_at,
|
||||
message_id: result[:sms].message_id
|
||||
}
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
# Rate limit error from OTP model
|
||||
if e.record.errors[:base].any?
|
||||
render json: {
|
||||
error: e.record.errors[:base].first
|
||||
}, status: :too_many_requests
|
||||
else
|
||||
render json: {
|
||||
error: e.message,
|
||||
details: e.record.errors.full_messages
|
||||
}, status: :unprocessable_entity
|
||||
end
|
||||
rescue ActionController::ParameterMissing => e
|
||||
render json: { error: e.message }, status: :bad_request
|
||||
end
|
||||
|
||||
# POST /api/v1/otp/verify
|
||||
def verify
|
||||
phone_number = params.require(:phone_number)
|
||||
code = params.require(:code)
|
||||
|
||||
# Validate phone number
|
||||
phone = Phonelib.parse(phone_number)
|
||||
unless phone.valid?
|
||||
render json: { error: "Invalid phone number format" }, status: :unprocessable_entity
|
||||
return
|
||||
end
|
||||
|
||||
# Verify OTP
|
||||
result = OtpCode.verify(phone.e164, code)
|
||||
|
||||
if result[:success]
|
||||
render json: {
|
||||
success: true,
|
||||
verified: true
|
||||
}
|
||||
else
|
||||
attempts_remaining = 3 - (result[:attempts_remaining] || 0)
|
||||
render json: {
|
||||
success: false,
|
||||
verified: false,
|
||||
error: result[:error],
|
||||
attempts_remaining: [attempts_remaining, 0].max
|
||||
}
|
||||
end
|
||||
rescue ActionController::ParameterMissing => e
|
||||
render json: { error: e.message }, status: :bad_request
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
90
app/controllers/api/v1/sms_controller.rb
Normal file
90
app/controllers/api/v1/sms_controller.rb
Normal file
@@ -0,0 +1,90 @@
|
||||
module Api
|
||||
module V1
|
||||
class SmsController < ApplicationController
|
||||
include ApiAuthenticatable
|
||||
include RateLimitable
|
||||
include Pagy::Backend
|
||||
|
||||
# POST /api/v1/sms/send
|
||||
def send_sms
|
||||
return unless rate_limit_by_api_key!(limit: 100, period: 1.minute)
|
||||
|
||||
phone_number = params.require(:to)
|
||||
message_body = params.require(:message)
|
||||
|
||||
# Validate phone number
|
||||
phone = Phonelib.parse(phone_number)
|
||||
unless phone.valid?
|
||||
render json: { error: "Invalid phone number format" }, status: :unprocessable_entity
|
||||
return
|
||||
end
|
||||
|
||||
# Create outbound SMS message
|
||||
sms = SmsMessage.create!(
|
||||
direction: "outbound",
|
||||
phone_number: phone.e164,
|
||||
message_body: message_body,
|
||||
status: "queued"
|
||||
)
|
||||
|
||||
render json: {
|
||||
success: true,
|
||||
message_id: sms.message_id,
|
||||
status: sms.status
|
||||
}, status: :accepted
|
||||
rescue ActionController::ParameterMissing => e
|
||||
render json: { error: e.message }, status: :bad_request
|
||||
end
|
||||
|
||||
# GET /api/v1/sms/status/:message_id
|
||||
def status
|
||||
message_id = params.require(:message_id)
|
||||
sms = SmsMessage.find_by!(message_id: message_id)
|
||||
|
||||
render json: {
|
||||
message_id: sms.message_id,
|
||||
status: sms.status,
|
||||
sent_at: sms.sent_at,
|
||||
delivered_at: sms.delivered_at,
|
||||
failed_at: sms.failed_at,
|
||||
error_message: sms.error_message
|
||||
}
|
||||
end
|
||||
|
||||
# GET /api/v1/sms/received
|
||||
def received
|
||||
query = SmsMessage.inbound.recent
|
||||
|
||||
# Filter by phone number if provided
|
||||
if params[:phone_number].present?
|
||||
query = query.where(phone_number: params[:phone_number])
|
||||
end
|
||||
|
||||
# Filter by date if provided
|
||||
if params[:since].present?
|
||||
since_time = Time.parse(params[:since])
|
||||
query = query.where("created_at >= ?", since_time)
|
||||
end
|
||||
|
||||
# Paginate results
|
||||
pagy, messages = pagy(query, items: params[:limit] || 50)
|
||||
|
||||
render json: {
|
||||
messages: messages.map { |sms|
|
||||
{
|
||||
message_id: sms.message_id,
|
||||
from: sms.phone_number,
|
||||
message: sms.message_body,
|
||||
received_at: sms.created_at
|
||||
}
|
||||
},
|
||||
total: pagy.count,
|
||||
page: pagy.page,
|
||||
pages: pagy.pages
|
||||
}
|
||||
rescue ArgumentError => e
|
||||
render json: { error: "Invalid date format" }, status: :bad_request
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
22
app/controllers/application_controller.rb
Normal file
22
app/controllers/application_controller.rb
Normal file
@@ -0,0 +1,22 @@
|
||||
class ApplicationController < ActionController::API
|
||||
rescue_from ActiveRecord::RecordNotFound, with: :render_not_found
|
||||
rescue_from ActiveRecord::RecordInvalid, with: :render_unprocessable
|
||||
rescue_from ActionController::ParameterMissing, with: :render_bad_request
|
||||
|
||||
private
|
||||
|
||||
def render_not_found(exception)
|
||||
render json: { error: exception.message }, status: :not_found
|
||||
end
|
||||
|
||||
def render_unprocessable(exception)
|
||||
render json: {
|
||||
error: exception.message,
|
||||
details: exception.record.errors.full_messages
|
||||
}, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def render_bad_request(exception)
|
||||
render json: { error: exception.message }, status: :bad_request
|
||||
end
|
||||
end
|
||||
0
app/controllers/concerns/.keep
Normal file
0
app/controllers/concerns/.keep
Normal file
73
app/controllers/concerns/api_authenticatable.rb
Normal file
73
app/controllers/concerns/api_authenticatable.rb
Normal file
@@ -0,0 +1,73 @@
|
||||
module ApiAuthenticatable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
before_action :authenticate_api_key!
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def authenticate_api_key!
|
||||
api_key = extract_api_key
|
||||
return render_unauthorized("Missing API key") unless api_key
|
||||
|
||||
key_digest = Digest::SHA256.hexdigest(api_key)
|
||||
|
||||
if api_key.start_with?("gw_")
|
||||
authenticate_gateway(key_digest)
|
||||
else
|
||||
authenticate_client_api_key(key_digest)
|
||||
end
|
||||
end
|
||||
|
||||
def authenticate_gateway(key_digest)
|
||||
@current_gateway = Gateway.find_by(api_key_digest: key_digest, active: true)
|
||||
|
||||
unless @current_gateway
|
||||
render_unauthorized("Invalid gateway API key")
|
||||
end
|
||||
end
|
||||
|
||||
def authenticate_client_api_key(key_digest)
|
||||
@current_api_key = ApiKey.find_by(key_digest: key_digest, active: true)
|
||||
|
||||
unless @current_api_key
|
||||
render_unauthorized("Invalid API key")
|
||||
return
|
||||
end
|
||||
|
||||
# Check if key has expired
|
||||
if @current_api_key.expires_at.present? && @current_api_key.expires_at < Time.current
|
||||
render_unauthorized("API key has expired")
|
||||
return
|
||||
end
|
||||
|
||||
@current_api_key.touch(:last_used_at)
|
||||
end
|
||||
|
||||
def extract_api_key
|
||||
auth_header = request.headers["Authorization"]
|
||||
return nil unless auth_header
|
||||
|
||||
# Support both "Bearer token" and just "token"
|
||||
auth_header.sub(/^Bearer\s+/, "")
|
||||
end
|
||||
|
||||
def render_unauthorized(message = "Unauthorized")
|
||||
render json: { error: message }, status: :unauthorized
|
||||
end
|
||||
|
||||
def current_gateway
|
||||
@current_gateway
|
||||
end
|
||||
|
||||
def current_api_key
|
||||
@current_api_key
|
||||
end
|
||||
|
||||
def require_permission(permission)
|
||||
unless @current_api_key&.can?(permission)
|
||||
render json: { error: "Insufficient permissions" }, status: :forbidden
|
||||
end
|
||||
end
|
||||
end
|
||||
54
app/controllers/concerns/rate_limitable.rb
Normal file
54
app/controllers/concerns/rate_limitable.rb
Normal file
@@ -0,0 +1,54 @@
|
||||
module RateLimitable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
private
|
||||
|
||||
def rate_limit_check!(key, limit:, period:)
|
||||
cache_key = "rate_limit:#{key}"
|
||||
count = Rails.cache.read(cache_key) || 0
|
||||
|
||||
if count >= limit
|
||||
render_rate_limit_exceeded(period)
|
||||
return false
|
||||
end
|
||||
|
||||
Rails.cache.write(cache_key, count + 1, expires_in: period)
|
||||
true
|
||||
end
|
||||
|
||||
def rate_limit_increment(key, period:)
|
||||
cache_key = "rate_limit:#{key}"
|
||||
current_count = Rails.cache.read(cache_key) || 0
|
||||
Rails.cache.write(cache_key, current_count + 1, expires_in: period)
|
||||
end
|
||||
|
||||
def rate_limit_reset(key)
|
||||
cache_key = "rate_limit:#{key}"
|
||||
Rails.cache.delete(cache_key)
|
||||
end
|
||||
|
||||
def render_rate_limit_exceeded(retry_after)
|
||||
render json: {
|
||||
error: "Rate limit exceeded",
|
||||
retry_after: retry_after.to_i
|
||||
}, status: :too_many_requests
|
||||
end
|
||||
|
||||
# Rate limit based on IP address
|
||||
def rate_limit_by_ip!(limit:, period:)
|
||||
ip_address = request.remote_ip
|
||||
rate_limit_check!("ip:#{ip_address}", limit: limit, period: period)
|
||||
end
|
||||
|
||||
# Rate limit based on API key
|
||||
def rate_limit_by_api_key!(limit:, period:)
|
||||
return true unless @current_api_key
|
||||
|
||||
rate_limit_check!("api_key:#{@current_api_key.id}", limit: limit, period: period)
|
||||
end
|
||||
|
||||
# Rate limit based on phone number
|
||||
def rate_limit_by_phone!(phone_number, limit:, period:)
|
||||
rate_limit_check!("phone:#{phone_number}", limit: limit, period: period)
|
||||
end
|
||||
end
|
||||
9
app/helpers/admin_helper.rb
Normal file
9
app/helpers/admin_helper.rb
Normal file
@@ -0,0 +1,9 @@
|
||||
module AdminHelper
|
||||
def current_admin
|
||||
@current_admin ||= AdminUser.find_by(id: session[:admin_id]) if session[:admin_id]
|
||||
end
|
||||
|
||||
def logged_in?
|
||||
current_admin.present?
|
||||
end
|
||||
end
|
||||
12
app/helpers/application_helper.rb
Normal file
12
app/helpers/application_helper.rb
Normal file
@@ -0,0 +1,12 @@
|
||||
module ApplicationHelper
|
||||
include Pagy::Frontend
|
||||
|
||||
# Admin authentication helpers
|
||||
def current_admin
|
||||
@current_admin ||= AdminUser.find_by(id: session[:admin_id]) if session[:admin_id]
|
||||
end
|
||||
|
||||
def logged_in?
|
||||
current_admin.present?
|
||||
end
|
||||
end
|
||||
3
app/javascript/application.js
Normal file
3
app/javascript/application.js
Normal file
@@ -0,0 +1,3 @@
|
||||
// Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails
|
||||
import "@hotwired/turbo-rails"
|
||||
import "controllers"
|
||||
9
app/javascript/controllers/application.js
Normal file
9
app/javascript/controllers/application.js
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Application } from "@hotwired/stimulus"
|
||||
|
||||
const application = Application.start()
|
||||
|
||||
// Configure Stimulus development experience
|
||||
application.debug = false
|
||||
window.Stimulus = application
|
||||
|
||||
export { application }
|
||||
7
app/javascript/controllers/hello_controller.js
Normal file
7
app/javascript/controllers/hello_controller.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
connect() {
|
||||
this.element.textContent = "Hello World!"
|
||||
}
|
||||
}
|
||||
4
app/javascript/controllers/index.js
Normal file
4
app/javascript/controllers/index.js
Normal file
@@ -0,0 +1,4 @@
|
||||
// Import and register all your controllers from the importmap via controllers/**/*_controller
|
||||
import { application } from "controllers/application"
|
||||
import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading"
|
||||
eagerLoadControllersFrom("controllers", application)
|
||||
7
app/jobs/application_job.rb
Normal file
7
app/jobs/application_job.rb
Normal file
@@ -0,0 +1,7 @@
|
||||
class ApplicationJob < ActiveJob::Base
|
||||
# Automatically retry jobs that encountered a deadlock
|
||||
# retry_on ActiveRecord::Deadlocked
|
||||
|
||||
# Most jobs are safe to ignore if the underlying records are no longer available
|
||||
# discard_on ActiveJob::DeserializationError
|
||||
end
|
||||
14
app/jobs/check_gateway_health_job.rb
Normal file
14
app/jobs/check_gateway_health_job.rb
Normal file
@@ -0,0 +1,14 @@
|
||||
class CheckGatewayHealthJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
def perform
|
||||
# Mark gateways as offline if no heartbeat in last 2 minutes
|
||||
offline_count = Gateway.where("last_heartbeat_at < ?", 2.minutes.ago)
|
||||
.where.not(status: "offline")
|
||||
.update_all(status: "offline")
|
||||
|
||||
if offline_count > 0
|
||||
Rails.logger.warn("Marked #{offline_count} gateways as offline due to missing heartbeat")
|
||||
end
|
||||
end
|
||||
end
|
||||
8
app/jobs/cleanup_expired_otps_job.rb
Normal file
8
app/jobs/cleanup_expired_otps_job.rb
Normal file
@@ -0,0 +1,8 @@
|
||||
class CleanupExpiredOtpsJob < ApplicationJob
|
||||
queue_as :low_priority
|
||||
|
||||
def perform
|
||||
deleted_count = OtpCode.cleanup_expired!
|
||||
Rails.logger.info("Cleaned up #{deleted_count} expired OTP codes")
|
||||
end
|
||||
end
|
||||
57
app/jobs/process_inbound_sms_job.rb
Normal file
57
app/jobs/process_inbound_sms_job.rb
Normal file
@@ -0,0 +1,57 @@
|
||||
class ProcessInboundSmsJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
def perform(sms_message_id)
|
||||
sms = SmsMessage.find(sms_message_id)
|
||||
|
||||
# Skip if not inbound
|
||||
return unless sms.inbound?
|
||||
|
||||
# Trigger webhooks for sms_received event
|
||||
trigger_webhooks(sms)
|
||||
|
||||
# Check if this is an OTP reply
|
||||
check_otp_reply(sms)
|
||||
|
||||
Rails.logger.info("Processed inbound SMS #{sms.message_id} from #{sms.phone_number}")
|
||||
rescue ActiveRecord::RecordNotFound => e
|
||||
Rails.logger.error("SMS message not found: #{e.message}")
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("Failed to process inbound SMS #{sms_message_id}: #{e.message}")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def trigger_webhooks(sms)
|
||||
webhooks = WebhookConfig.for_event_type("sms_received")
|
||||
|
||||
webhooks.each do |webhook|
|
||||
payload = {
|
||||
event: "sms_received",
|
||||
message_id: sms.message_id,
|
||||
from: sms.phone_number,
|
||||
message: sms.message_body,
|
||||
received_at: sms.created_at,
|
||||
gateway_id: sms.gateway&.device_id
|
||||
}
|
||||
|
||||
webhook.trigger(payload)
|
||||
end
|
||||
end
|
||||
|
||||
def check_otp_reply(sms)
|
||||
# Extract potential OTP code from message (6 digits)
|
||||
code_match = sms.message_body.match(/\b\d{6}\b/)
|
||||
return unless code_match
|
||||
|
||||
code = code_match[0]
|
||||
|
||||
# Try to verify if there's a pending OTP for this phone number
|
||||
otp = OtpCode.valid_codes.find_by(phone_number: sms.phone_number, code: code)
|
||||
|
||||
if otp
|
||||
otp.update!(verified: true, verified_at: Time.current)
|
||||
Rails.logger.info("Auto-verified OTP for #{sms.phone_number}")
|
||||
end
|
||||
end
|
||||
end
|
||||
8
app/jobs/reset_daily_counters_job.rb
Normal file
8
app/jobs/reset_daily_counters_job.rb
Normal file
@@ -0,0 +1,8 @@
|
||||
class ResetDailyCountersJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
def perform
|
||||
Gateway.reset_daily_counters!
|
||||
Rails.logger.info("Reset daily counters for all gateways")
|
||||
end
|
||||
end
|
||||
22
app/jobs/retry_failed_sms_job.rb
Normal file
22
app/jobs/retry_failed_sms_job.rb
Normal file
@@ -0,0 +1,22 @@
|
||||
class RetryFailedSmsJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
def perform(sms_message_id)
|
||||
sms = SmsMessage.find(sms_message_id)
|
||||
|
||||
# Only retry if message can be retried
|
||||
return unless sms.can_retry?
|
||||
|
||||
# Reset status to queued and increment retry count
|
||||
sms.increment_retry!
|
||||
sms.update!(status: "queued", error_message: nil)
|
||||
|
||||
# Re-queue for sending with exponential backoff
|
||||
wait_time = (2 ** sms.retry_count).minutes
|
||||
SendSmsJob.set(wait: wait_time).perform_later(sms.id)
|
||||
|
||||
Rails.logger.info("Retrying failed SMS #{sms.message_id} (attempt #{sms.retry_count})")
|
||||
rescue ActiveRecord::RecordNotFound => e
|
||||
Rails.logger.error("SMS message not found: #{e.message}")
|
||||
end
|
||||
end
|
||||
41
app/jobs/send_sms_job.rb
Normal file
41
app/jobs/send_sms_job.rb
Normal file
@@ -0,0 +1,41 @@
|
||||
class SendSmsJob < ApplicationJob
|
||||
queue_as :default
|
||||
retry_on StandardError, wait: :exponentially_longer, attempts: 3
|
||||
|
||||
def perform(sms_message_id)
|
||||
sms = SmsMessage.find(sms_message_id)
|
||||
|
||||
# Skip if already sent or not outbound
|
||||
return unless sms.outbound? && sms.status == "queued"
|
||||
|
||||
# Find available gateway
|
||||
gateway = Gateway.available_for_sending
|
||||
|
||||
unless gateway
|
||||
Rails.logger.warn("No available gateway for message #{sms.message_id}")
|
||||
sms.update!(status: "pending")
|
||||
# Retry later
|
||||
SendSmsJob.set(wait: 1.minute).perform_later(sms_message_id)
|
||||
return
|
||||
end
|
||||
|
||||
# Update message status
|
||||
sms.mark_sent!(gateway)
|
||||
|
||||
# Broadcast message to gateway via WebSocket
|
||||
GatewayChannel.broadcast_to(gateway, {
|
||||
action: "send_sms",
|
||||
message_id: sms.message_id,
|
||||
recipient: sms.phone_number,
|
||||
message: sms.message_body
|
||||
})
|
||||
|
||||
Rails.logger.info("Sent SMS #{sms.message_id} to gateway #{gateway.device_id}")
|
||||
rescue ActiveRecord::RecordNotFound => e
|
||||
Rails.logger.error("SMS message not found: #{e.message}")
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("Failed to send SMS #{sms_message_id}: #{e.message}")
|
||||
sms&.increment_retry!
|
||||
raise
|
||||
end
|
||||
end
|
||||
31
app/jobs/trigger_webhook_job.rb
Normal file
31
app/jobs/trigger_webhook_job.rb
Normal file
@@ -0,0 +1,31 @@
|
||||
class TriggerWebhookJob < ApplicationJob
|
||||
queue_as :default
|
||||
retry_on StandardError, wait: :exponentially_longer, attempts: 3
|
||||
|
||||
def perform(webhook_config_id, payload)
|
||||
webhook = WebhookConfig.find(webhook_config_id)
|
||||
|
||||
# Skip if webhook is not active
|
||||
return unless webhook.active?
|
||||
|
||||
success = webhook.execute(payload)
|
||||
|
||||
if success
|
||||
Rails.logger.info("Webhook #{webhook.name} triggered successfully")
|
||||
else
|
||||
Rails.logger.warn("Webhook #{webhook.name} failed")
|
||||
raise StandardError, "Webhook execution failed" if attempts_count < webhook.retry_count
|
||||
end
|
||||
rescue ActiveRecord::RecordNotFound => e
|
||||
Rails.logger.error("Webhook config not found: #{e.message}")
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("Webhook trigger failed: #{e.message}")
|
||||
raise if attempts_count < (webhook&.retry_count || 3)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def attempts_count
|
||||
executions
|
||||
end
|
||||
end
|
||||
4
app/mailers/application_mailer.rb
Normal file
4
app/mailers/application_mailer.rb
Normal file
@@ -0,0 +1,4 @@
|
||||
class ApplicationMailer < ActionMailer::Base
|
||||
default from: "from@example.com"
|
||||
layout "mailer"
|
||||
end
|
||||
11
app/models/admin_user.rb
Normal file
11
app/models/admin_user.rb
Normal file
@@ -0,0 +1,11 @@
|
||||
class AdminUser < ApplicationRecord
|
||||
has_secure_password
|
||||
|
||||
validates :email, presence: true, uniqueness: true, format: { with: URI::MailTo::EMAIL_REGEXP }
|
||||
validates :name, presence: true
|
||||
validates :password, length: { minimum: 8 }, if: -> { password.present? }
|
||||
|
||||
def update_last_login!
|
||||
update!(last_login_at: Time.current)
|
||||
end
|
||||
end
|
||||
72
app/models/api_key.rb
Normal file
72
app/models/api_key.rb
Normal file
@@ -0,0 +1,72 @@
|
||||
class ApiKey < ApplicationRecord
|
||||
# Normalize permissions to always be a Hash
|
||||
attribute :permissions, :jsonb, default: {}
|
||||
|
||||
before_validation :ensure_permissions_is_hash
|
||||
|
||||
validates :name, presence: true
|
||||
validates :key_digest, presence: true, uniqueness: true
|
||||
validates :key_prefix, presence: true
|
||||
|
||||
scope :active_keys, -> { where(active: true) }
|
||||
scope :expired, -> { where("expires_at IS NOT NULL AND expires_at < ?", Time.current) }
|
||||
scope :valid, -> { active_keys.where("expires_at IS NULL OR expires_at > ?", Time.current) }
|
||||
|
||||
# Generate a new API key
|
||||
def self.generate!(name:, permissions: {}, expires_at: nil)
|
||||
raw_key = "api_live_#{SecureRandom.hex(32)}"
|
||||
key_digest = Digest::SHA256.hexdigest(raw_key)
|
||||
key_prefix = raw_key[0..11] # First 12 chars for identification
|
||||
|
||||
api_key = create!(
|
||||
name: name,
|
||||
key_digest: key_digest,
|
||||
key_prefix: key_prefix,
|
||||
permissions: permissions,
|
||||
expires_at: expires_at
|
||||
)
|
||||
|
||||
{ api_key: api_key, raw_key: raw_key }
|
||||
end
|
||||
|
||||
# Authenticate with a raw key
|
||||
def self.authenticate(raw_key)
|
||||
return nil unless raw_key.present?
|
||||
|
||||
key_digest = Digest::SHA256.hexdigest(raw_key)
|
||||
api_key = valid.find_by(key_digest: key_digest)
|
||||
|
||||
if api_key
|
||||
api_key.touch(:last_used_at)
|
||||
end
|
||||
|
||||
api_key
|
||||
end
|
||||
|
||||
# Check if API key is still active and not expired
|
||||
def active_and_valid?
|
||||
active && (expires_at.nil? || expires_at > Time.current)
|
||||
end
|
||||
|
||||
# Check if API key has specific permission
|
||||
def can?(permission)
|
||||
permissions.fetch(permission.to_s, false)
|
||||
end
|
||||
|
||||
# Revoke API key
|
||||
def revoke!
|
||||
update!(active: false)
|
||||
end
|
||||
|
||||
# Deactivate expired keys
|
||||
def self.deactivate_expired!
|
||||
expired.update_all(active: false)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def ensure_permissions_is_hash
|
||||
self.permissions = {} if permissions.nil?
|
||||
self.permissions = {} unless permissions.is_a?(Hash)
|
||||
end
|
||||
end
|
||||
3
app/models/application_record.rb
Normal file
3
app/models/application_record.rb
Normal file
@@ -0,0 +1,3 @@
|
||||
class ApplicationRecord < ActiveRecord::Base
|
||||
primary_abstract_class
|
||||
end
|
||||
0
app/models/concerns/.keep
Normal file
0
app/models/concerns/.keep
Normal file
81
app/models/concerns/metrics.rb
Normal file
81
app/models/concerns/metrics.rb
Normal file
@@ -0,0 +1,81 @@
|
||||
module Metrics
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
class_methods do
|
||||
def increment_counter(metric_name, amount = 1)
|
||||
cache_key = "metrics:#{metric_name}"
|
||||
current_value = Rails.cache.read(cache_key) || 0
|
||||
Rails.cache.write(cache_key, current_value + amount)
|
||||
end
|
||||
|
||||
def decrement_counter(metric_name, amount = 1)
|
||||
cache_key = "metrics:#{metric_name}"
|
||||
current_value = Rails.cache.read(cache_key) || 0
|
||||
new_value = [current_value - amount, 0].max
|
||||
Rails.cache.write(cache_key, new_value)
|
||||
end
|
||||
|
||||
def get_counter(metric_name)
|
||||
cache_key = "metrics:#{metric_name}"
|
||||
Rails.cache.read(cache_key) || 0
|
||||
end
|
||||
|
||||
def reset_counter(metric_name)
|
||||
cache_key = "metrics:#{metric_name}"
|
||||
Rails.cache.delete(cache_key)
|
||||
end
|
||||
|
||||
def set_gauge(metric_name, value)
|
||||
cache_key = "metrics:gauge:#{metric_name}"
|
||||
Rails.cache.write(cache_key, value)
|
||||
end
|
||||
|
||||
def get_gauge(metric_name)
|
||||
cache_key = "metrics:gauge:#{metric_name}"
|
||||
Rails.cache.read(cache_key)
|
||||
end
|
||||
|
||||
# Record timing metrics
|
||||
def record_timing(metric_name, duration_ms)
|
||||
cache_key = "metrics:timing:#{metric_name}"
|
||||
timings = Rails.cache.read(cache_key) || []
|
||||
timings << duration_ms
|
||||
# Keep only last 100 measurements
|
||||
timings = timings.last(100)
|
||||
Rails.cache.write(cache_key, timings)
|
||||
end
|
||||
|
||||
def get_timing_stats(metric_name)
|
||||
cache_key = "metrics:timing:#{metric_name}"
|
||||
timings = Rails.cache.read(cache_key) || []
|
||||
|
||||
return nil if timings.empty?
|
||||
|
||||
{
|
||||
count: timings.size,
|
||||
avg: timings.sum / timings.size.to_f,
|
||||
min: timings.min,
|
||||
max: timings.max,
|
||||
p95: percentile(timings, 95),
|
||||
p99: percentile(timings, 99)
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def percentile(array, percent)
|
||||
return nil if array.empty?
|
||||
|
||||
sorted = array.sort
|
||||
k = (percent / 100.0) * (sorted.length - 1)
|
||||
f = k.floor
|
||||
c = k.ceil
|
||||
|
||||
if f == c
|
||||
sorted[k]
|
||||
else
|
||||
sorted[f] * (c - k) + sorted[c] * (k - f)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
68
app/models/gateway.rb
Normal file
68
app/models/gateway.rb
Normal file
@@ -0,0 +1,68 @@
|
||||
class Gateway < ApplicationRecord
|
||||
# Normalize metadata to always be a Hash
|
||||
attribute :metadata, :jsonb, default: {}
|
||||
|
||||
has_many :sms_messages, dependent: :nullify
|
||||
|
||||
before_validation :ensure_metadata_is_hash
|
||||
|
||||
validates :device_id, presence: true, uniqueness: true
|
||||
validates :api_key_digest, presence: true
|
||||
validates :status, inclusion: { in: %w[online offline error] }
|
||||
|
||||
scope :online, -> { where(status: "online") }
|
||||
scope :offline, -> { where(status: "offline") }
|
||||
scope :active, -> { where(active: true) }
|
||||
scope :by_priority, -> { order(priority: :desc, id: :asc) }
|
||||
|
||||
# Find the best available gateway for sending messages
|
||||
def self.available_for_sending
|
||||
online.active.by_priority.first
|
||||
end
|
||||
|
||||
# Generate a new API key for the gateway
|
||||
def generate_api_key!
|
||||
raw_key = "gw_live_#{SecureRandom.hex(32)}"
|
||||
self.api_key_digest = Digest::SHA256.hexdigest(raw_key)
|
||||
save!
|
||||
raw_key
|
||||
end
|
||||
|
||||
# Check if gateway is currently online based on heartbeat
|
||||
def online?
|
||||
status == "online" && last_heartbeat_at.present? && last_heartbeat_at > 2.minutes.ago
|
||||
end
|
||||
|
||||
# Update heartbeat timestamp and status
|
||||
def heartbeat!
|
||||
update!(status: "online", last_heartbeat_at: Time.current)
|
||||
end
|
||||
|
||||
# Mark gateway as offline
|
||||
def mark_offline!
|
||||
update!(status: "offline")
|
||||
end
|
||||
|
||||
# Increment message counters
|
||||
def increment_sent_count!
|
||||
increment!(:messages_sent_today)
|
||||
increment!(:total_messages_sent)
|
||||
end
|
||||
|
||||
def increment_received_count!
|
||||
increment!(:messages_received_today)
|
||||
increment!(:total_messages_received)
|
||||
end
|
||||
|
||||
# Reset daily counters (called by scheduled job)
|
||||
def self.reset_daily_counters!
|
||||
update_all(messages_sent_today: 0, messages_received_today: 0)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def ensure_metadata_is_hash
|
||||
self.metadata = {} if metadata.nil?
|
||||
self.metadata = {} unless metadata.is_a?(Hash)
|
||||
end
|
||||
end
|
||||
118
app/models/otp_code.rb
Normal file
118
app/models/otp_code.rb
Normal file
@@ -0,0 +1,118 @@
|
||||
class OtpCode < ApplicationRecord
|
||||
# Normalize metadata to always be a Hash
|
||||
attribute :metadata, :jsonb, default: {}
|
||||
|
||||
validates :phone_number, presence: true
|
||||
validates :code, presence: true, length: { is: 6 }
|
||||
validates :expires_at, presence: true
|
||||
validates :purpose, presence: true
|
||||
|
||||
validate :phone_number_format
|
||||
validate :rate_limit_check, on: :create
|
||||
|
||||
before_validation :ensure_metadata_is_hash
|
||||
before_validation :generate_code, on: :create, unless: :code?
|
||||
before_validation :set_expiry, on: :create, unless: :expires_at?
|
||||
before_validation :normalize_phone_number
|
||||
|
||||
scope :valid_codes, -> { where(verified: false).where("expires_at > ?", Time.current) }
|
||||
scope :expired, -> { where("expires_at <= ?", Time.current) }
|
||||
scope :verified_codes, -> { where(verified: true) }
|
||||
|
||||
# Verify an OTP code
|
||||
def self.verify(phone_number, code)
|
||||
normalized_phone = normalize_phone_number_string(phone_number)
|
||||
otp = valid_codes.find_by(phone_number: normalized_phone, code: code)
|
||||
|
||||
return { success: false, error: "Invalid or expired OTP", attempts_remaining: 0 } unless otp
|
||||
|
||||
otp.increment!(:attempts)
|
||||
|
||||
# Lock out after 3 failed attempts
|
||||
if otp.attempts > 3
|
||||
otp.update!(expires_at: Time.current)
|
||||
return { success: false, error: "Too many attempts. OTP expired.", attempts_remaining: 0 }
|
||||
end
|
||||
|
||||
# Successfully verified
|
||||
otp.update!(verified: true, verified_at: Time.current)
|
||||
{ success: true, verified: true }
|
||||
end
|
||||
|
||||
# Generate and send OTP
|
||||
def self.send_otp(phone_number, purpose: "authentication", expiry_minutes: 5, ip_address: nil)
|
||||
normalized_phone = normalize_phone_number_string(phone_number)
|
||||
|
||||
otp = create!(
|
||||
phone_number: normalized_phone,
|
||||
purpose: purpose,
|
||||
expires_at: expiry_minutes.minutes.from_now,
|
||||
ip_address: ip_address
|
||||
)
|
||||
|
||||
# Create SMS message for sending
|
||||
sms = SmsMessage.create!(
|
||||
direction: "outbound",
|
||||
phone_number: normalized_phone,
|
||||
message_body: "Your OTP code is: #{otp.code}. Valid for #{expiry_minutes} minutes. Do not share this code."
|
||||
)
|
||||
|
||||
{ otp: otp, sms: sms }
|
||||
end
|
||||
|
||||
# Clean up expired OTP codes
|
||||
def self.cleanup_expired!
|
||||
expired.where(verified: false).delete_all
|
||||
end
|
||||
|
||||
# Check if OTP is still active and usable
|
||||
def active_and_usable?
|
||||
!verified && expires_at > Time.current && attempts < 3
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def generate_code
|
||||
self.code = format("%06d", SecureRandom.random_number(1_000_000))
|
||||
end
|
||||
|
||||
def set_expiry
|
||||
self.expires_at = 5.minutes.from_now
|
||||
end
|
||||
|
||||
def normalize_phone_number
|
||||
return unless phone_number.present?
|
||||
self.phone_number = self.class.normalize_phone_number_string(phone_number)
|
||||
end
|
||||
|
||||
def self.normalize_phone_number_string(number)
|
||||
number.gsub(/[^\d+]/, "")
|
||||
end
|
||||
|
||||
def phone_number_format
|
||||
return unless phone_number.present?
|
||||
|
||||
phone = Phonelib.parse(phone_number)
|
||||
unless phone.valid?
|
||||
errors.add(:phone_number, "is not a valid phone number")
|
||||
end
|
||||
end
|
||||
|
||||
def rate_limit_check
|
||||
return unless phone_number.present?
|
||||
|
||||
# Max 3 OTP per phone per hour
|
||||
recent_count = OtpCode.where(phone_number: phone_number)
|
||||
.where("created_at > ?", 1.hour.ago)
|
||||
.count
|
||||
|
||||
if recent_count >= 3
|
||||
errors.add(:base, "Rate limit exceeded. Maximum 3 OTP codes per hour.")
|
||||
end
|
||||
end
|
||||
|
||||
def ensure_metadata_is_hash
|
||||
self.metadata = {} if metadata.nil?
|
||||
self.metadata = {} unless metadata.is_a?(Hash)
|
||||
end
|
||||
end
|
||||
110
app/models/sms_message.rb
Normal file
110
app/models/sms_message.rb
Normal file
@@ -0,0 +1,110 @@
|
||||
class SmsMessage < ApplicationRecord
|
||||
# Normalize metadata to always be a Hash
|
||||
attribute :metadata, :jsonb, default: {}
|
||||
|
||||
belongs_to :gateway, optional: true
|
||||
|
||||
validates :phone_number, presence: true
|
||||
validates :message_body, presence: true, length: { maximum: 1600 }
|
||||
validates :direction, presence: true, inclusion: { in: %w[inbound outbound] }
|
||||
validates :message_id, presence: true, uniqueness: true
|
||||
validates :status, inclusion: { in: %w[pending queued sent delivered failed] }
|
||||
|
||||
validate :phone_number_format
|
||||
|
||||
before_validation :ensure_metadata_is_hash
|
||||
before_validation :generate_message_id, on: :create
|
||||
before_validation :normalize_phone_number
|
||||
after_create_commit :enqueue_sending, if: :outbound?
|
||||
|
||||
scope :pending, -> { where(status: "pending") }
|
||||
scope :queued, -> { where(status: "queued") }
|
||||
scope :sent, -> { where(status: "sent") }
|
||||
scope :delivered, -> { where(status: "delivered") }
|
||||
scope :failed, -> { where(status: "failed") }
|
||||
scope :inbound, -> { where(direction: "inbound") }
|
||||
scope :outbound, -> { where(direction: "outbound") }
|
||||
scope :recent, -> { order(created_at: :desc) }
|
||||
|
||||
# Check message direction
|
||||
def outbound?
|
||||
direction == "outbound"
|
||||
end
|
||||
|
||||
def inbound?
|
||||
direction == "inbound"
|
||||
end
|
||||
|
||||
# Check if message can be retried
|
||||
def can_retry?
|
||||
failed? && retry_count < 3
|
||||
end
|
||||
|
||||
# Mark message as sent
|
||||
def mark_sent!(gateway)
|
||||
update!(
|
||||
status: "sent",
|
||||
gateway: gateway,
|
||||
sent_at: Time.current
|
||||
)
|
||||
gateway.increment_sent_count!
|
||||
end
|
||||
|
||||
# Mark message as delivered
|
||||
def mark_delivered!
|
||||
update!(
|
||||
status: "delivered",
|
||||
delivered_at: Time.current
|
||||
)
|
||||
end
|
||||
|
||||
# Mark message as failed
|
||||
def mark_failed!(error_msg = nil)
|
||||
update!(
|
||||
status: "failed",
|
||||
failed_at: Time.current,
|
||||
error_message: error_msg
|
||||
)
|
||||
end
|
||||
|
||||
# Increment retry counter
|
||||
def increment_retry!
|
||||
increment!(:retry_count)
|
||||
end
|
||||
|
||||
# Check if message status is failed
|
||||
def failed?
|
||||
status == "failed"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def generate_message_id
|
||||
self.message_id ||= "msg_#{SecureRandom.hex(16)}"
|
||||
end
|
||||
|
||||
def normalize_phone_number
|
||||
return unless phone_number.present?
|
||||
|
||||
# Remove spaces and special characters
|
||||
self.phone_number = phone_number.gsub(/[^\d+]/, "")
|
||||
end
|
||||
|
||||
def phone_number_format
|
||||
return unless phone_number.present?
|
||||
|
||||
phone = Phonelib.parse(phone_number)
|
||||
unless phone.valid?
|
||||
errors.add(:phone_number, "is not a valid phone number")
|
||||
end
|
||||
end
|
||||
|
||||
def enqueue_sending
|
||||
SendSmsJob.perform_later(id)
|
||||
end
|
||||
|
||||
def ensure_metadata_is_hash
|
||||
self.metadata = {} if metadata.nil?
|
||||
self.metadata = {} unless metadata.is_a?(Hash)
|
||||
end
|
||||
end
|
||||
54
app/models/webhook_config.rb
Normal file
54
app/models/webhook_config.rb
Normal file
@@ -0,0 +1,54 @@
|
||||
class WebhookConfig < ApplicationRecord
|
||||
validates :name, presence: true
|
||||
validates :url, presence: true, format: { with: URI::DEFAULT_PARSER.make_regexp(%w[http https]) }
|
||||
validates :event_type, presence: true, inclusion: { in: %w[sms_received sms_sent sms_failed] }
|
||||
validates :timeout, numericality: { greater_than: 0, less_than_or_equal_to: 120 }
|
||||
validates :retry_count, numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 5 }
|
||||
|
||||
scope :active_webhooks, -> { where(active: true) }
|
||||
scope :for_event, ->(event_type) { where(event_type: event_type) }
|
||||
|
||||
# Trigger webhook with payload
|
||||
def trigger(payload)
|
||||
return unless active?
|
||||
|
||||
TriggerWebhookJob.perform_later(id, payload)
|
||||
end
|
||||
|
||||
# Execute webhook HTTP request
|
||||
def execute(payload)
|
||||
headers = {
|
||||
"Content-Type" => "application/json",
|
||||
"User-Agent" => "MySMSAPI-Webhook/1.0"
|
||||
}
|
||||
|
||||
# Add signature if secret key is present
|
||||
if secret_key.present?
|
||||
signature = generate_signature(payload)
|
||||
headers["X-Webhook-Signature"] = signature
|
||||
end
|
||||
|
||||
response = HTTParty.post(
|
||||
url,
|
||||
body: payload.to_json,
|
||||
headers: headers,
|
||||
timeout: timeout
|
||||
)
|
||||
|
||||
response.success?
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("Webhook execution failed: #{e.message}")
|
||||
false
|
||||
end
|
||||
|
||||
# Find active webhooks for a specific event
|
||||
def self.for_event_type(event_type)
|
||||
active_webhooks.for_event(event_type)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def generate_signature(payload)
|
||||
OpenSSL::HMAC.hexdigest("SHA256", secret_key, payload.to_json)
|
||||
end
|
||||
end
|
||||
101
app/views/admin/api_keys/index.html.erb
Normal file
101
app/views/admin/api_keys/index.html.erb
Normal file
@@ -0,0 +1,101 @@
|
||||
<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">API Keys</h1>
|
||||
<p class="mt-2 text-sm text-gray-600">Manage API keys for client access and authentication</p>
|
||||
</div>
|
||||
<%= link_to new_admin_api_key_path, class: "mt-4 sm:mt-0 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 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 transition-all" do %>
|
||||
<i class="fas fa-plus"></i>
|
||||
Create New API Key
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl bg-white shadow-sm ring-1 ring-gray-900/5">
|
||||
<% if @api_keys.any? %>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Name</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Key Prefix</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Permissions</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Status</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Last Used</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Created</th>
|
||||
<th scope="col" class="px-6 py-3 text-right text-xs font-medium uppercase tracking-wide text-gray-500">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 bg-white">
|
||||
<% @api_keys.each do |api_key| %>
|
||||
<tr class="hover:bg-gray-50 transition-colors">
|
||||
<td class="px-6 py-4">
|
||||
<div class="text-sm font-medium text-gray-900"><%= api_key.name %></div>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<code class="rounded bg-gray-100 px-2 py-1 text-xs font-mono text-gray-800"><%= api_key.key_prefix %>...</code>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<% perms = api_key.permissions || {} %>
|
||||
<% perms = {} unless perms.is_a?(Hash) %>
|
||||
<% if perms.any? %>
|
||||
<% perms.select { |_, v| v }.keys.each do |perm| %>
|
||||
<span class="inline-flex rounded-full bg-blue-50 px-2 py-1 text-xs font-medium text-blue-700 ring-1 ring-inset ring-blue-700/10">
|
||||
<%= perm.to_s.humanize %>
|
||||
</span>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<span class="inline-flex rounded-full bg-gray-50 px-2 py-1 text-xs font-medium text-gray-700 ring-1 ring-inset ring-gray-700/10">None</span>
|
||||
<% end %>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<% if api_key.active_and_valid? %>
|
||||
<span class="inline-flex items-center gap-1.5 rounded-full bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-700/10">
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-green-500"></span>
|
||||
Active
|
||||
</span>
|
||||
<% elsif !api_key.active %>
|
||||
<span class="inline-flex rounded-full bg-red-50 px-2 py-1 text-xs font-medium text-red-700 ring-1 ring-inset ring-red-700/10">Revoked</span>
|
||||
<% else %>
|
||||
<span class="inline-flex rounded-full bg-yellow-50 px-2 py-1 text-xs font-medium text-yellow-700 ring-1 ring-inset ring-yellow-700/10">Expired</span>
|
||||
<% end %>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm text-gray-500">
|
||||
<% if api_key.last_used_at %>
|
||||
<%= time_ago_in_words(api_key.last_used_at) %> ago
|
||||
<% else %>
|
||||
Never
|
||||
<% end %>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm text-gray-500">
|
||||
<%= api_key.created_at.strftime("%Y-%m-%d") %>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-right text-sm font-medium">
|
||||
<% if api_key.active %>
|
||||
<%= button_to admin_api_key_path(api_key), method: :delete, class: "inline-flex items-center gap-1 text-red-600 hover:text-red-900", data: { confirm: "Are you sure you want to revoke this API key?" } do %>
|
||||
<i class="fas fa-ban"></i>
|
||||
Revoke
|
||||
<% end %>
|
||||
<% end %>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="px-6 py-14 text-center">
|
||||
<i class="fas fa-key text-4xl text-gray-300"></i>
|
||||
<h3 class="mt-4 text-sm font-semibold text-gray-900">No API keys</h3>
|
||||
<p class="mt-2 text-sm text-gray-500">Get started by creating a new API key.</p>
|
||||
<div class="mt-6">
|
||||
<%= link_to new_admin_api_key_path, 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" do %>
|
||||
<i class="fas fa-plus"></i>
|
||||
Create API Key
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
105
app/views/admin/api_keys/new.html.erb
Normal file
105
app/views/admin/api_keys/new.html.erb
Normal file
@@ -0,0 +1,105 @@
|
||||
<div class="space-y-6">
|
||||
<!-- Page header with back link -->
|
||||
<div class="flex items-center gap-4">
|
||||
<%= link_to admin_api_keys_path, class: "inline-flex items-center gap-2 text-sm font-medium text-gray-600 hover:text-gray-900 transition-colors" do %>
|
||||
<i class="fas fa-arrow-left"></i>
|
||||
Back to API Keys
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="border-b border-gray-200 pb-5">
|
||||
<h1 class="text-3xl font-bold leading-tight tracking-tight text-gray-900">Create New API Key</h1>
|
||||
<p class="mt-2 text-sm text-gray-600">Generate a new API key with specific permissions for your application.</p>
|
||||
</div>
|
||||
|
||||
<!-- Form card -->
|
||||
<div class="rounded-xl bg-white shadow-sm ring-1 ring-gray-900/5 px-6 py-8 max-w-2xl">
|
||||
<%= form_with url: admin_api_keys_path, method: :post, local: true, class: "space-y-6" do |f| %>
|
||||
<!-- Name field -->
|
||||
<div>
|
||||
<%= label_tag "api_key[name]", "Name", class: "block text-sm font-medium text-gray-700" %>
|
||||
<div class="mt-1 relative">
|
||||
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<i class="fas fa-tag text-gray-400"></i>
|
||||
</div>
|
||||
<%= text_field_tag "api_key[name]", nil,
|
||||
class: "block w-full pl-10 rounded-lg border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm py-3",
|
||||
placeholder: "My Application",
|
||||
required: true %>
|
||||
</div>
|
||||
<p class="mt-1 text-sm text-gray-500">A descriptive name to identify this API key</p>
|
||||
</div>
|
||||
|
||||
<!-- Permissions checkboxes -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-3">Permissions</label>
|
||||
<div class="space-y-3">
|
||||
<label class="relative flex items-start">
|
||||
<div class="flex items-center h-6">
|
||||
<%= check_box_tag "api_key[send_sms]", "1", true,
|
||||
class: "h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-600" %>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<span class="text-sm font-medium text-gray-900">Send SMS</span>
|
||||
<p class="text-sm text-gray-500">Allow sending outbound SMS messages</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label class="relative flex items-start">
|
||||
<div class="flex items-center h-6">
|
||||
<%= check_box_tag "api_key[receive_sms]", "1", true,
|
||||
class: "h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-600" %>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<span class="text-sm font-medium text-gray-900">Receive SMS</span>
|
||||
<p class="text-sm text-gray-500">Allow receiving and querying inbound SMS</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label class="relative flex items-start">
|
||||
<div class="flex items-center h-6">
|
||||
<%= check_box_tag "api_key[manage_gateways]", "1", false,
|
||||
class: "h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-600" %>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<span class="text-sm font-medium text-gray-900">Manage Gateways</span>
|
||||
<p class="text-sm text-gray-500">Allow managing gateway devices and settings</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label class="relative flex items-start">
|
||||
<div class="flex items-center h-6">
|
||||
<%= check_box_tag "api_key[manage_otp]", "1", true,
|
||||
class: "h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-600" %>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<span class="text-sm font-medium text-gray-900">Manage OTP</span>
|
||||
<p class="text-sm text-gray-500">Allow generating and verifying OTP codes</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Expiration field -->
|
||||
<div>
|
||||
<%= label_tag "api_key[expires_at]", "Expiration Date (Optional)", class: "block text-sm font-medium text-gray-700" %>
|
||||
<div class="mt-1 relative">
|
||||
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<i class="fas fa-calendar text-gray-400"></i>
|
||||
</div>
|
||||
<%= datetime_local_field_tag "api_key[expires_at]", nil,
|
||||
class: "block w-full pl-10 rounded-lg border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm py-3" %>
|
||||
</div>
|
||||
<p class="mt-1 text-sm text-gray-500">Leave empty for no expiration</p>
|
||||
</div>
|
||||
|
||||
<!-- Action buttons -->
|
||||
<div class="flex items-center gap-3 pt-4">
|
||||
<%= submit_tag "Create API Key",
|
||||
class: "inline-flex justify-center items-center gap-2 rounded-lg bg-blue-600 px-6 py-3 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 transition-all duration-200" %>
|
||||
<%= link_to "Cancel", admin_api_keys_path,
|
||||
class: "inline-flex justify-center items-center gap-2 rounded-lg bg-gray-100 px-6 py-3 text-sm font-semibold text-gray-700 hover:bg-gray-200 transition-all duration-200" %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
139
app/views/admin/api_keys/show.html.erb
Normal file
139
app/views/admin/api_keys/show.html.erb
Normal file
@@ -0,0 +1,139 @@
|
||||
<div class="space-y-6">
|
||||
<!-- Page header -->
|
||||
<div class="border-b border-gray-200 pb-5">
|
||||
<h1 class="text-3xl font-bold leading-tight tracking-tight text-gray-900">API Key Created Successfully!</h1>
|
||||
<p class="mt-2 text-sm text-gray-600">Your new API key has been generated and is ready to use.</p>
|
||||
</div>
|
||||
|
||||
<!-- Warning alert -->
|
||||
<div class="rounded-lg bg-yellow-50 px-4 py-4 ring-1 ring-yellow-600/10">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex-shrink-0">
|
||||
<i class="fas fa-exclamation-triangle text-yellow-600 text-xl"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-yellow-800">Important: Save this key now!</h3>
|
||||
<p class="mt-1 text-sm text-yellow-700">
|
||||
This is the only time you'll be able to see the full API key. Make sure to copy it and store it securely.
|
||||
If you lose it, you'll need to generate a new key.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- API Key display card -->
|
||||
<div class="rounded-xl bg-white shadow-sm ring-1 ring-gray-900/5 px-6 py-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900">Your New API Key</h3>
|
||||
<button
|
||||
onclick="copyToClipboard('<%= @raw_key %>')"
|
||||
class="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 transition-all duration-200">
|
||||
<i class="fas fa-copy"></i>
|
||||
Copy to Clipboard
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="relative rounded-lg bg-gray-900 px-4 py-4 overflow-x-auto">
|
||||
<code class="text-sm font-mono text-green-400 break-all"><%= @raw_key %></code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- API Key Details card -->
|
||||
<div class="rounded-xl bg-white shadow-sm ring-1 ring-gray-900/5 px-6 py-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">API Key Details</h3>
|
||||
|
||||
<dl class="divide-y divide-gray-100">
|
||||
<div class="px-4 py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
|
||||
<dt class="text-sm font-medium text-gray-900">Name</dt>
|
||||
<dd class="mt-1 text-sm text-gray-700 sm:col-span-2 sm:mt-0"><%= @api_key.name %></dd>
|
||||
</div>
|
||||
|
||||
<div class="px-4 py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
|
||||
<dt class="text-sm font-medium text-gray-900">Key Prefix</dt>
|
||||
<dd class="mt-1 text-sm sm:col-span-2 sm:mt-0">
|
||||
<code class="rounded bg-gray-100 px-2 py-1 text-xs font-mono text-gray-800"><%= @api_key.key_prefix %>...</code>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div class="px-4 py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
|
||||
<dt class="text-sm font-medium text-gray-900">Permissions</dt>
|
||||
<dd class="mt-1 text-sm sm:col-span-2 sm:mt-0">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<% perms = @api_key.permissions || {} %>
|
||||
<% perms = {} unless perms.is_a?(Hash) %>
|
||||
<% perms.select { |_, v| v }.keys.each do |perm| %>
|
||||
<span class="inline-flex items-center gap-1 rounded-full bg-blue-50 px-3 py-1 text-xs font-medium text-blue-700 ring-1 ring-inset ring-blue-700/10">
|
||||
<i class="fas fa-check"></i>
|
||||
<%= perm.to_s.humanize %>
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div class="px-4 py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
|
||||
<dt class="text-sm font-medium text-gray-900">Expiration</dt>
|
||||
<dd class="mt-1 text-sm text-gray-700 sm:col-span-2 sm:mt-0">
|
||||
<% if @api_key.expires_at %>
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="fas fa-calendar text-gray-400"></i>
|
||||
<%= @api_key.expires_at.strftime("%B %d, %Y at %l:%M %p") %>
|
||||
</div>
|
||||
<% else %>
|
||||
<span class="inline-flex items-center gap-1 rounded-full bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-700/10">
|
||||
<i class="fas fa-infinity"></i>
|
||||
Never expires
|
||||
</span>
|
||||
<% end %>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div class="px-4 py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
|
||||
<dt class="text-sm font-medium text-gray-900">Status</dt>
|
||||
<dd class="mt-1 text-sm sm:col-span-2 sm:mt-0">
|
||||
<span class="inline-flex items-center gap-1.5 rounded-full bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-700/10">
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-green-500"></span>
|
||||
Active
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div class="px-4 py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
|
||||
<dt class="text-sm font-medium text-gray-900">Created</dt>
|
||||
<dd class="mt-1 text-sm text-gray-700 sm:col-span-2 sm:mt-0">
|
||||
<%= @api_key.created_at.strftime("%B %d, %Y at %l:%M %p") %>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<!-- Back link -->
|
||||
<div class="flex items-center gap-3">
|
||||
<%= link_to admin_api_keys_path,
|
||||
class: "inline-flex items-center gap-2 rounded-lg bg-gray-100 px-6 py-3 text-sm font-semibold text-gray-700 hover:bg-gray-200 transition-all duration-200" do %>
|
||||
<i class="fas fa-arrow-left"></i>
|
||||
Back to API Keys
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function copyToClipboard(text) {
|
||||
navigator.clipboard.writeText(text).then(function() {
|
||||
// Show success feedback
|
||||
const button = event.target.closest('button');
|
||||
const originalHTML = button.innerHTML;
|
||||
button.innerHTML = '<i class="fas fa-check"></i> Copied!';
|
||||
button.classList.add('bg-green-600', 'hover:bg-green-500');
|
||||
button.classList.remove('bg-blue-600', 'hover:bg-blue-500');
|
||||
|
||||
setTimeout(function() {
|
||||
button.innerHTML = originalHTML;
|
||||
button.classList.remove('bg-green-600', 'hover:bg-green-500');
|
||||
button.classList.add('bg-blue-600', 'hover:bg-blue-500');
|
||||
}, 2000);
|
||||
}, function(err) {
|
||||
alert('Failed to copy: ' + err);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
466
app/views/admin/api_tester/index.html.erb
Normal file
466
app/views/admin/api_tester/index.html.erb
Normal file
@@ -0,0 +1,466 @@
|
||||
<div class="space-y-6">
|
||||
<!-- Page header -->
|
||||
<div class="border-b border-gray-200 pb-5">
|
||||
<h1 class="text-3xl font-bold leading-tight tracking-tight text-gray-900">API Tester</h1>
|
||||
<p class="mt-2 text-sm text-gray-600">Test all API endpoints with interactive forms. View request/response in real-time.</p>
|
||||
</div>
|
||||
|
||||
<!-- API Key Selection Card -->
|
||||
<div class="rounded-xl bg-white shadow-sm ring-1 ring-gray-900/5 px-6 py-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">Authentication</h3>
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- Available API Keys (for reference) -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Available API Keys (Reference Only)</label>
|
||||
<div class="rounded-lg bg-gray-50 p-4 space-y-2">
|
||||
<% if @api_keys.any? %>
|
||||
<% @api_keys.each do |key| %>
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-gray-700">
|
||||
<i class="fas fa-key text-blue-500"></i> <%= key.name %>
|
||||
</span>
|
||||
<span class="font-mono text-xs text-gray-500"><%= key.key_prefix %>***</span>
|
||||
</div>
|
||||
<% end %>
|
||||
<p class="text-xs text-gray-500 mt-2">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
Raw API keys are only shown once during creation for security. Enter your saved API key below.
|
||||
</p>
|
||||
<% else %>
|
||||
<p class="text-sm text-gray-500">No API keys found. Create one first.</p>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Enter API Key -->
|
||||
<div>
|
||||
<label for="api-key-input" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Enter API Key <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="api-key-input"
|
||||
class="block w-full rounded-lg border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono text-xs"
|
||||
placeholder="api_live_... or gw_live_..."
|
||||
required>
|
||||
<p class="mt-1 text-xs text-gray-500">
|
||||
Client API keys start with <code class="bg-gray-100 px-1 py-0.5 rounded">api_live_</code>,
|
||||
Gateway keys start with <code class="bg-gray-100 px-1 py-0.5 rounded">gw_live_</code>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Base URL -->
|
||||
<div>
|
||||
<label for="base-url" class="block text-sm font-medium text-gray-700 mb-2">Base URL</label>
|
||||
<input
|
||||
type="text"
|
||||
id="base-url"
|
||||
class="block w-full rounded-lg border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono text-xs"
|
||||
value="<%= request.base_url %>">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Endpoint Testing Tabs -->
|
||||
<div class="rounded-xl bg-white shadow-sm ring-1 ring-gray-900/5">
|
||||
<!-- Tab Navigation -->
|
||||
<div class="border-b border-gray-200">
|
||||
<nav class="flex gap-4 px-6 pt-6" aria-label="Tabs">
|
||||
<button onclick="switchTab('send-sms')" id="tab-send-sms" class="tab-button active">
|
||||
<i class="fas fa-paper-plane"></i> Send SMS
|
||||
</button>
|
||||
<button onclick="switchTab('check-status')" id="tab-check-status" class="tab-button">
|
||||
<i class="fas fa-info-circle"></i> Check Status
|
||||
</button>
|
||||
<button onclick="switchTab('send-otp')" id="tab-send-otp" class="tab-button">
|
||||
<i class="fas fa-key"></i> Send OTP
|
||||
</button>
|
||||
<button onclick="switchTab('verify-otp')" id="tab-verify-otp" class="tab-button">
|
||||
<i class="fas fa-check-circle"></i> Verify OTP
|
||||
</button>
|
||||
<button onclick="switchTab('gateway-register')" id="tab-gateway-register" class="tab-button">
|
||||
<i class="fas fa-mobile-alt"></i> Register Gateway
|
||||
</button>
|
||||
<button onclick="switchTab('gateway-heartbeat')" id="tab-gateway-heartbeat" class="tab-button">
|
||||
<i class="fas fa-heartbeat"></i> Heartbeat
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Tab Content -->
|
||||
<div class="px-6 py-6">
|
||||
<!-- Send SMS Tab -->
|
||||
<div id="content-send-sms" class="tab-content active">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">Send SMS</h3>
|
||||
<p class="text-sm text-gray-600 mb-4">POST /api/v1/sms/send</p>
|
||||
|
||||
<form id="form-send-sms" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Phone Number</label>
|
||||
<input type="tel" name="phone_number" class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" placeholder="+959123456789" required>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Message</label>
|
||||
<textarea name="message" rows="3" class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" placeholder="Your message here" required></textarea>
|
||||
</div>
|
||||
<button type="submit" class="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500">
|
||||
<i class="fas fa-paper-plane"></i> Send SMS
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Check Status Tab -->
|
||||
<div id="content-check-status" class="tab-content">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">Check SMS Status</h3>
|
||||
<p class="text-sm text-gray-600 mb-4">GET /api/v1/sms/status/:message_id</p>
|
||||
|
||||
<form id="form-check-status" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Message ID</label>
|
||||
<input type="text" name="message_id" class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono text-xs" placeholder="msg_abc123..." required>
|
||||
</div>
|
||||
<button type="submit" class="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500">
|
||||
<i class="fas fa-search"></i> Check Status
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Send OTP Tab -->
|
||||
<div id="content-send-otp" class="tab-content">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">Send OTP</h3>
|
||||
<p class="text-sm text-gray-600 mb-4">POST /api/v1/otp/send</p>
|
||||
|
||||
<form id="form-send-otp" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Phone Number</label>
|
||||
<input type="tel" name="phone_number" class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" placeholder="+959123456789" required>
|
||||
</div>
|
||||
<button type="submit" class="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500">
|
||||
<i class="fas fa-key"></i> Send OTP
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Verify OTP Tab -->
|
||||
<div id="content-verify-otp" class="tab-content">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">Verify OTP</h3>
|
||||
<p class="text-sm text-gray-600 mb-4">POST /api/v1/otp/verify</p>
|
||||
|
||||
<form id="form-verify-otp" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Phone Number</label>
|
||||
<input type="tel" name="phone_number" class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" placeholder="+959123456789" required>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">OTP Code</label>
|
||||
<input type="text" name="code" class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" placeholder="123456" maxlength="6" required>
|
||||
</div>
|
||||
<button type="submit" class="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500">
|
||||
<i class="fas fa-check-circle"></i> Verify OTP
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Gateway Register Tab -->
|
||||
<div id="content-gateway-register" class="tab-content">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">Register Gateway</h3>
|
||||
<p class="text-sm text-gray-600 mb-4">POST /api/v1/gateway/register</p>
|
||||
|
||||
<form id="form-gateway-register" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Device ID</label>
|
||||
<input type="text" name="device_id" class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" placeholder="android-001" required>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Device Name</label>
|
||||
<input type="text" name="name" class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" placeholder="My Phone" required>
|
||||
</div>
|
||||
<button type="submit" class="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500">
|
||||
<i class="fas fa-mobile-alt"></i> Register Gateway
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Gateway Heartbeat Tab -->
|
||||
<div id="content-gateway-heartbeat" class="tab-content">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">Send Heartbeat</h3>
|
||||
<p class="text-sm text-gray-600 mb-4">POST /api/v1/gateway/heartbeat</p>
|
||||
|
||||
<form id="form-gateway-heartbeat" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Battery Level (%)</label>
|
||||
<input type="number" name="battery_level" class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" placeholder="85" min="0" max="100">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Signal Strength (0-4)</label>
|
||||
<input type="number" name="signal_strength" class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" placeholder="4" min="0" max="4">
|
||||
</div>
|
||||
<button type="submit" class="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500">
|
||||
<i class="fas fa-heartbeat"></i> Send Heartbeat
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Request/Response Display -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- Request -->
|
||||
<div class="rounded-xl bg-white shadow-sm ring-1 ring-gray-900/5 px-6 py-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900">Request</h3>
|
||||
<button onclick="copyRequest()" class="text-sm text-blue-600 hover:text-blue-500">
|
||||
<i class="fas fa-copy"></i> Copy
|
||||
</button>
|
||||
</div>
|
||||
<pre id="request-display" class="bg-gray-50 rounded-lg p-4 text-xs font-mono text-gray-800 overflow-x-auto max-h-96 overflow-y-auto"><span class="text-gray-400">Request will appear here...</span></pre>
|
||||
</div>
|
||||
|
||||
<!-- Response -->
|
||||
<div class="rounded-xl bg-white shadow-sm ring-1 ring-gray-900/5 px-6 py-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900">Response</h3>
|
||||
<button onclick="copyResponse()" class="text-sm text-blue-600 hover:text-blue-500">
|
||||
<i class="fas fa-copy"></i> Copy
|
||||
</button>
|
||||
</div>
|
||||
<pre id="response-display" class="bg-gray-50 rounded-lg p-4 text-xs font-mono text-gray-800 overflow-x-auto max-h-96 overflow-y-auto"><span class="text-gray-400">Response will appear here...</span></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Info Card -->
|
||||
<div class="rounded-lg bg-blue-50 px-4 py-4 ring-1 ring-blue-600/10">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex-shrink-0">
|
||||
<i class="fas fa-info-circle text-blue-600 text-xl"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-blue-800">API Testing Tips</h3>
|
||||
<ul class="mt-2 text-sm text-blue-700 list-disc list-inside space-y-1">
|
||||
<li>Select an API key from the dropdown or enter a custom key</li>
|
||||
<li>All requests use the Authorization header with Bearer token</li>
|
||||
<li>Gateway endpoints require gateway API keys (gw_live_...)</li>
|
||||
<li>Client endpoints require client API keys (api_live_...)</li>
|
||||
<li>View full request and response in real-time</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.tab-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: #6b7280;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.tab-button:hover {
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.tab-button.active {
|
||||
color: #2563eb;
|
||||
border-bottom-color: #2563eb;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab-content.active {
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
let currentRequest = '';
|
||||
let currentResponse = '';
|
||||
|
||||
// Tab switching
|
||||
function switchTab(tabName) {
|
||||
// Hide all tab contents
|
||||
document.querySelectorAll('.tab-content').forEach(content => {
|
||||
content.classList.remove('active');
|
||||
});
|
||||
|
||||
// Deactivate all tab buttons
|
||||
document.querySelectorAll('.tab-button').forEach(button => {
|
||||
button.classList.remove('active');
|
||||
});
|
||||
|
||||
// Show selected tab content
|
||||
document.getElementById('content-' + tabName).classList.add('active');
|
||||
document.getElementById('tab-' + tabName).classList.add('active');
|
||||
}
|
||||
|
||||
// Get API key
|
||||
function getApiKey() {
|
||||
const apiKey = document.getElementById('api-key-input').value.trim();
|
||||
if (!apiKey) {
|
||||
alert('Please enter an API key');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Validate format
|
||||
if (!apiKey.startsWith('api_live_') && !apiKey.startsWith('gw_live_')) {
|
||||
alert('Invalid API key format. Keys should start with "api_live_" or "gw_live_"');
|
||||
return null;
|
||||
}
|
||||
|
||||
return apiKey;
|
||||
}
|
||||
|
||||
// Get base URL
|
||||
function getBaseUrl() {
|
||||
return document.getElementById('base-url').value.trim();
|
||||
}
|
||||
|
||||
// Display request
|
||||
function displayRequest(method, url, headers, body) {
|
||||
let request = `${method} ${url}\n\n`;
|
||||
|
||||
request += 'Headers:\n';
|
||||
Object.entries(headers).forEach(([key, value]) => {
|
||||
request += `${key}: ${value}\n`;
|
||||
});
|
||||
|
||||
if (body) {
|
||||
request += '\nBody:\n';
|
||||
request += JSON.stringify(body, null, 2);
|
||||
}
|
||||
|
||||
currentRequest = request;
|
||||
document.getElementById('request-display').textContent = request;
|
||||
}
|
||||
|
||||
// Display response
|
||||
function displayResponse(status, statusText, data) {
|
||||
let response = `Status: ${status} ${statusText}\n\n`;
|
||||
response += JSON.stringify(data, null, 2);
|
||||
|
||||
currentResponse = response;
|
||||
const displayElement = document.getElementById('response-display');
|
||||
displayElement.textContent = response;
|
||||
|
||||
// Color code based on status
|
||||
if (status >= 200 && status < 300) {
|
||||
displayElement.classList.add('text-green-700');
|
||||
displayElement.classList.remove('text-red-700', 'text-gray-800');
|
||||
} else {
|
||||
displayElement.classList.add('text-red-700');
|
||||
displayElement.classList.remove('text-green-700', 'text-gray-800');
|
||||
}
|
||||
}
|
||||
|
||||
// Copy functions
|
||||
function copyRequest() {
|
||||
navigator.clipboard.writeText(currentRequest);
|
||||
alert('Request copied to clipboard!');
|
||||
}
|
||||
|
||||
function copyResponse() {
|
||||
navigator.clipboard.writeText(currentResponse);
|
||||
alert('Response copied to clipboard!');
|
||||
}
|
||||
|
||||
// Generic API call
|
||||
async function makeApiCall(method, endpoint, body = null) {
|
||||
const apiKey = getApiKey();
|
||||
if (!apiKey) return;
|
||||
|
||||
const baseUrl = getBaseUrl();
|
||||
const url = `${baseUrl}${endpoint}`;
|
||||
|
||||
const headers = {
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
|
||||
displayRequest(method, endpoint, headers, body);
|
||||
|
||||
try {
|
||||
const options = {
|
||||
method: method,
|
||||
headers: headers
|
||||
};
|
||||
|
||||
if (body) {
|
||||
options.body = JSON.stringify(body);
|
||||
}
|
||||
|
||||
const response = await fetch(url, options);
|
||||
const data = await response.json();
|
||||
|
||||
displayResponse(response.status, response.statusText, data);
|
||||
} catch (error) {
|
||||
displayResponse(0, 'Error', { error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// Form handlers
|
||||
document.getElementById('form-send-sms').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.target);
|
||||
const body = {
|
||||
to: formData.get('phone_number'), // API expects 'to' parameter
|
||||
message: formData.get('message')
|
||||
};
|
||||
await makeApiCall('POST', '/api/v1/sms/send', body);
|
||||
});
|
||||
|
||||
document.getElementById('form-check-status').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.target);
|
||||
const messageId = formData.get('message_id');
|
||||
await makeApiCall('GET', `/api/v1/sms/status/${messageId}`);
|
||||
});
|
||||
|
||||
document.getElementById('form-send-otp').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.target);
|
||||
const body = {
|
||||
phone_number: formData.get('phone_number')
|
||||
};
|
||||
await makeApiCall('POST', '/api/v1/otp/send', body);
|
||||
});
|
||||
|
||||
document.getElementById('form-verify-otp').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.target);
|
||||
const body = {
|
||||
phone_number: formData.get('phone_number'),
|
||||
code: formData.get('code')
|
||||
};
|
||||
await makeApiCall('POST', '/api/v1/otp/verify', body);
|
||||
});
|
||||
|
||||
document.getElementById('form-gateway-register').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.target);
|
||||
const body = {
|
||||
device_id: formData.get('device_id'),
|
||||
name: formData.get('name')
|
||||
};
|
||||
await makeApiCall('POST', '/api/v1/gateway/register', body);
|
||||
});
|
||||
|
||||
document.getElementById('form-gateway-heartbeat').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.target);
|
||||
const body = {
|
||||
device_info: {
|
||||
battery_level: parseInt(formData.get('battery_level')) || 0,
|
||||
signal_strength: parseInt(formData.get('signal_strength')) || 0
|
||||
}
|
||||
};
|
||||
await makeApiCall('POST', '/api/v1/gateway/heartbeat', body);
|
||||
});
|
||||
</script>
|
||||
230
app/views/admin/dashboard/index.html.erb
Normal file
230
app/views/admin/dashboard/index.html.erb
Normal file
@@ -0,0 +1,230 @@
|
||||
<div class="space-y-8">
|
||||
<!-- Page header -->
|
||||
<div class="border-b border-gray-200 pb-5">
|
||||
<h1 class="text-3xl font-bold leading-tight tracking-tight text-gray-900">Dashboard</h1>
|
||||
<p class="mt-2 text-sm text-gray-600">Welcome back! Here's what's happening with your SMS gateway today.</p>
|
||||
</div>
|
||||
|
||||
<!-- Stats grid -->
|
||||
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<!-- Gateways stat -->
|
||||
<div class="relative overflow-hidden rounded-xl bg-white px-4 py-5 shadow-sm ring-1 ring-gray-900/5 sm:px-6">
|
||||
<dt>
|
||||
<div class="absolute rounded-lg bg-blue-500 p-3">
|
||||
<i class="fas fa-mobile-alt text-xl text-white"></i>
|
||||
</div>
|
||||
<p class="ml-16 truncate text-sm font-medium text-gray-500">Gateways</p>
|
||||
</dt>
|
||||
<dd class="ml-16 flex items-baseline">
|
||||
<p class="text-3xl font-semibold text-gray-900"><%= @stats[:total_gateways] %></p>
|
||||
<p class="ml-2 flex items-baseline text-sm font-semibold text-green-600">
|
||||
<%= @stats[:online_gateways] %> online
|
||||
</p>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<!-- API Keys stat -->
|
||||
<div class="relative overflow-hidden rounded-xl bg-white px-4 py-5 shadow-sm ring-1 ring-gray-900/5 sm:px-6">
|
||||
<dt>
|
||||
<div class="absolute rounded-lg bg-green-500 p-3">
|
||||
<i class="fas fa-key text-xl text-white"></i>
|
||||
</div>
|
||||
<p class="ml-16 truncate text-sm font-medium text-gray-500">API Keys</p>
|
||||
</dt>
|
||||
<dd class="ml-16 flex items-baseline">
|
||||
<p class="text-3xl font-semibold text-gray-900"><%= @stats[:active_api_keys] %></p>
|
||||
<p class="ml-2 flex items-baseline text-sm text-gray-600">
|
||||
of <%= @stats[:total_api_keys] %> total
|
||||
</p>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<!-- Messages Today stat -->
|
||||
<div class="relative overflow-hidden rounded-xl bg-white px-4 py-5 shadow-sm ring-1 ring-gray-900/5 sm:px-6">
|
||||
<dt>
|
||||
<div class="absolute rounded-lg bg-yellow-500 p-3">
|
||||
<i class="fas fa-paper-plane text-xl text-white"></i>
|
||||
</div>
|
||||
<p class="ml-16 truncate text-sm font-medium text-gray-500">Messages Today</p>
|
||||
</dt>
|
||||
<dd class="ml-16 flex items-baseline">
|
||||
<p class="text-3xl font-semibold text-gray-900"><%= @stats[:messages_today] %></p>
|
||||
</dd>
|
||||
<div class="ml-16 mt-1 flex items-center gap-3 text-xs">
|
||||
<span class="inline-flex items-center gap-1 text-green-600">
|
||||
<i class="fas fa-arrow-up"></i> <%= @stats[:messages_sent_today] %> sent
|
||||
</span>
|
||||
<span class="inline-flex items-center gap-1 text-blue-600">
|
||||
<i class="fas fa-arrow-down"></i> <%= @stats[:messages_received_today] %> received
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Failed Messages stat -->
|
||||
<div class="relative overflow-hidden rounded-xl bg-white px-4 py-5 shadow-sm ring-1 ring-gray-900/5 sm:px-6">
|
||||
<dt>
|
||||
<div class="absolute rounded-lg bg-red-500 p-3">
|
||||
<i class="fas fa-exclamation-triangle text-xl text-white"></i>
|
||||
</div>
|
||||
<p class="ml-16 truncate text-sm font-medium text-gray-500">Failed Today</p>
|
||||
</dt>
|
||||
<dd class="ml-16 flex items-baseline">
|
||||
<p class="text-3xl font-semibold text-gray-900"><%= @stats[:failed_messages_today] %></p>
|
||||
<p class="ml-2 flex items-baseline text-sm text-gray-600">
|
||||
<%= @stats[:pending_messages] %> pending
|
||||
</p>
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Messages -->
|
||||
<div class="rounded-xl bg-white shadow-sm ring-1 ring-gray-900/5">
|
||||
<div class="px-4 py-5 sm:px-6 border-b border-gray-200">
|
||||
<h3 class="text-lg font-semibold leading-6 text-gray-900">Recent Messages</h3>
|
||||
<p class="mt-1 text-sm text-gray-500">Latest SMS activity across all gateways</p>
|
||||
</div>
|
||||
<div class="overflow-hidden">
|
||||
<% if @recent_messages.any? %>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Message ID</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Phone Number</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Direction</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Status</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Gateway</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 bg-white">
|
||||
<% @recent_messages.each do |msg| %>
|
||||
<tr class="hover:bg-gray-50 transition-colors">
|
||||
<td class="whitespace-nowrap px-6 py-4 text-sm">
|
||||
<code class="rounded bg-gray-100 px-2 py-1 text-xs font-mono text-gray-800"><%= msg.message_id[0..15] %>...</code>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-900"><%= msg.phone_number %></td>
|
||||
<td class="whitespace-nowrap px-6 py-4 text-sm">
|
||||
<% if msg.direction == "outbound" %>
|
||||
<span class="inline-flex items-center gap-1 rounded-full bg-blue-50 px-2 py-1 text-xs font-medium text-blue-700 ring-1 ring-inset ring-blue-700/10">
|
||||
<i class="fas fa-arrow-up"></i> Outbound
|
||||
</span>
|
||||
<% else %>
|
||||
<span class="inline-flex items-center gap-1 rounded-full bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-700/10">
|
||||
<i class="fas fa-arrow-down"></i> Inbound
|
||||
</span>
|
||||
<% end %>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-6 py-4 text-sm">
|
||||
<% case msg.status %>
|
||||
<% when "delivered" %>
|
||||
<span class="inline-flex rounded-full bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-700/10">Delivered</span>
|
||||
<% when "sent" %>
|
||||
<span class="inline-flex rounded-full bg-blue-50 px-2 py-1 text-xs font-medium text-blue-700 ring-1 ring-inset ring-blue-700/10">Sent</span>
|
||||
<% when "failed" %>
|
||||
<span class="inline-flex rounded-full bg-red-50 px-2 py-1 text-xs font-medium text-red-700 ring-1 ring-inset ring-red-700/10">Failed</span>
|
||||
<% when "pending" %>
|
||||
<span class="inline-flex rounded-full bg-yellow-50 px-2 py-1 text-xs font-medium text-yellow-700 ring-1 ring-inset ring-yellow-700/10">Pending</span>
|
||||
<% else %>
|
||||
<span class="inline-flex rounded-full bg-gray-50 px-2 py-1 text-xs font-medium text-gray-700 ring-1 ring-inset ring-gray-700/10"><%= msg.status.titleize %></span>
|
||||
<% end %>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-500"><%= msg.gateway&.name || "-" %></td>
|
||||
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-500"><%= time_ago_in_words(msg.created_at) %> ago</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="border-t border-gray-200 px-6 py-4">
|
||||
<%= link_to admin_logs_path, class: "inline-flex items-center gap-2 text-sm font-semibold text-blue-600 hover:text-blue-500" do %>
|
||||
View all logs
|
||||
<i class="fas fa-arrow-right"></i>
|
||||
<% end %>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="px-6 py-14 text-center">
|
||||
<i class="fas fa-inbox text-4xl text-gray-300"></i>
|
||||
<p class="mt-4 text-sm text-gray-500">No messages yet</p>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Gateway Status -->
|
||||
<div class="rounded-xl bg-white shadow-sm ring-1 ring-gray-900/5">
|
||||
<div class="px-4 py-5 sm:px-6 border-b border-gray-200">
|
||||
<h3 class="text-lg font-semibold leading-6 text-gray-900">Gateway Status</h3>
|
||||
<p class="mt-1 text-sm text-gray-500">Active gateway devices and their performance</p>
|
||||
</div>
|
||||
<div class="overflow-hidden">
|
||||
<% if @recent_gateways.any? %>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Name</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Device ID</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Status</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Messages Today</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Last Heartbeat</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 bg-white">
|
||||
<% @recent_gateways.each do |gateway| %>
|
||||
<tr class="hover:bg-gray-50 transition-colors">
|
||||
<td class="whitespace-nowrap px-6 py-4">
|
||||
<%= link_to gateway.name, admin_gateway_path(gateway), class: "text-sm font-medium text-blue-600 hover:text-blue-500" %>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-6 py-4 text-sm">
|
||||
<code class="rounded bg-gray-100 px-2 py-1 text-xs font-mono text-gray-800"><%= gateway.device_id %></code>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-6 py-4 text-sm">
|
||||
<% if gateway.status == "online" %>
|
||||
<span class="inline-flex items-center gap-1.5 rounded-full bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-700/10">
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-green-500"></span>
|
||||
Online
|
||||
</span>
|
||||
<% else %>
|
||||
<span class="inline-flex items-center gap-1.5 rounded-full bg-red-50 px-2 py-1 text-xs font-medium text-red-700 ring-1 ring-inset ring-red-700/10">
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-red-500"></span>
|
||||
Offline
|
||||
</span>
|
||||
<% end %>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
|
||||
<span class="inline-flex items-center gap-1 text-green-600">
|
||||
<i class="fas fa-arrow-up text-xs"></i> <%= gateway.messages_sent_today %>
|
||||
</span>
|
||||
<span class="mx-1 text-gray-300">|</span>
|
||||
<span class="inline-flex items-center gap-1 text-blue-600">
|
||||
<i class="fas fa-arrow-down text-xs"></i> <%= gateway.messages_received_today %>
|
||||
</span>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
|
||||
<% if gateway.last_heartbeat_at %>
|
||||
<%= time_ago_in_words(gateway.last_heartbeat_at) %> ago
|
||||
<% else %>
|
||||
Never
|
||||
<% end %>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="border-t border-gray-200 px-6 py-4">
|
||||
<%= link_to admin_gateways_path, class: "inline-flex items-center gap-2 text-sm font-semibold text-blue-600 hover:text-blue-500" do %>
|
||||
View all gateways
|
||||
<i class="fas fa-arrow-right"></i>
|
||||
<% end %>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="px-6 py-14 text-center">
|
||||
<i class="fas fa-mobile-alt text-4xl text-gray-300"></i>
|
||||
<p class="mt-4 text-sm text-gray-500">No gateways registered yet</p>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
168
app/views/admin/gateways/index.html.erb
Normal file
168
app/views/admin/gateways/index.html.erb
Normal file
@@ -0,0 +1,168 @@
|
||||
<div class="space-y-6">
|
||||
<!-- Page header -->
|
||||
<div class="sm:flex sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold leading-tight tracking-tight text-gray-900">Gateways</h1>
|
||||
<p class="mt-2 text-sm text-gray-600">Manage your SMS gateway devices and monitor their status.</p>
|
||||
</div>
|
||||
<%= link_to new_admin_gateway_path, class: "mt-4 sm:mt-0 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 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 transition-all" do %>
|
||||
<i class="fas fa-plus"></i>
|
||||
Register New Gateway
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<!-- Gateways table card -->
|
||||
<div class="rounded-xl bg-white shadow-sm ring-1 ring-gray-900/5 overflow-hidden">
|
||||
<% if @gateways.any? %>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Name</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Device ID</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Status</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Active</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Priority</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Today</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Total</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Last Heartbeat</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Created</th>
|
||||
<th scope="col" class="px-6 py-3 text-center text-xs font-medium uppercase tracking-wide text-gray-500">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 bg-white">
|
||||
<% @gateways.each do |gateway| %>
|
||||
<tr class="hover:bg-gray-50 transition-colors">
|
||||
<!-- Name -->
|
||||
<td class="whitespace-nowrap px-6 py-4">
|
||||
<%= link_to gateway.name, admin_gateway_path(gateway),
|
||||
class: "text-sm font-semibold text-blue-600 hover:text-blue-500 transition-colors" %>
|
||||
</td>
|
||||
|
||||
<!-- Device ID -->
|
||||
<td class="whitespace-nowrap px-6 py-4">
|
||||
<code class="rounded bg-gray-100 px-2 py-1 text-xs font-mono text-gray-800"><%= gateway.device_id %></code>
|
||||
</td>
|
||||
|
||||
<!-- Status with pulse indicator -->
|
||||
<td class="whitespace-nowrap px-6 py-4 text-sm">
|
||||
<% if gateway.status == "online" %>
|
||||
<span class="inline-flex items-center gap-1.5 rounded-full bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-700/10">
|
||||
<span class="relative flex h-2 w-2">
|
||||
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
|
||||
<span class="relative inline-flex rounded-full h-2 w-2 bg-green-500"></span>
|
||||
</span>
|
||||
Online
|
||||
</span>
|
||||
<% else %>
|
||||
<span class="inline-flex items-center gap-1.5 rounded-full bg-red-50 px-2 py-1 text-xs font-medium text-red-700 ring-1 ring-inset ring-red-700/10">
|
||||
<span class="h-2 w-2 rounded-full bg-red-500"></span>
|
||||
Offline
|
||||
</span>
|
||||
<% end %>
|
||||
</td>
|
||||
|
||||
<!-- Active badge -->
|
||||
<td class="whitespace-nowrap px-6 py-4 text-sm">
|
||||
<% if gateway.active %>
|
||||
<span class="inline-flex items-center rounded-full bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-700/10">
|
||||
<i class="fas fa-check mr-1"></i> Active
|
||||
</span>
|
||||
<% else %>
|
||||
<span class="inline-flex items-center rounded-full bg-gray-50 px-2 py-1 text-xs font-medium text-gray-700 ring-1 ring-inset ring-gray-700/10">
|
||||
<i class="fas fa-times mr-1"></i> Inactive
|
||||
</span>
|
||||
<% end %>
|
||||
</td>
|
||||
|
||||
<!-- Priority -->
|
||||
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-900 font-medium text-center">
|
||||
<%= gateway.priority %>
|
||||
</td>
|
||||
|
||||
<!-- Messages Today -->
|
||||
<td class="whitespace-nowrap px-6 py-4 text-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="inline-flex items-center gap-1 text-green-600">
|
||||
<i class="fas fa-arrow-up text-xs"></i>
|
||||
<span class="font-medium"><%= gateway.messages_sent_today %></span>
|
||||
</span>
|
||||
<span class="text-gray-300">|</span>
|
||||
<span class="inline-flex items-center gap-1 text-blue-600">
|
||||
<i class="fas fa-arrow-down text-xs"></i>
|
||||
<span class="font-medium"><%= gateway.messages_received_today %></span>
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<!-- Total Messages -->
|
||||
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="inline-flex items-center gap-1 text-green-600">
|
||||
<i class="fas fa-arrow-up text-xs"></i>
|
||||
<span><%= gateway.total_messages_sent %></span>
|
||||
</span>
|
||||
<span class="text-gray-300">|</span>
|
||||
<span class="inline-flex items-center gap-1 text-blue-600">
|
||||
<i class="fas fa-arrow-down text-xs"></i>
|
||||
<span><%= gateway.total_messages_received %></span>
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<!-- Last Heartbeat -->
|
||||
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
|
||||
<% if gateway.last_heartbeat_at %>
|
||||
<div class="flex flex-col">
|
||||
<span class="font-medium text-gray-900"><%= time_ago_in_words(gateway.last_heartbeat_at) %> ago</span>
|
||||
<span class="text-xs text-gray-400"><%= gateway.last_heartbeat_at.strftime("%m/%d/%y %H:%M") %></span>
|
||||
</div>
|
||||
<% else %>
|
||||
<span class="text-gray-400 italic">Never</span>
|
||||
<% end %>
|
||||
</td>
|
||||
|
||||
<!-- Created -->
|
||||
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
|
||||
<%= gateway.created_at.strftime("%m/%d/%y") %>
|
||||
</td>
|
||||
|
||||
<!-- Actions -->
|
||||
<td class="whitespace-nowrap px-6 py-4 text-center">
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
<%= link_to test_admin_gateway_path(gateway),
|
||||
class: "inline-flex items-center gap-1 rounded-lg bg-blue-600 px-3 py-1.5 text-xs font-semibold text-white shadow-sm hover:bg-blue-500 transition-all duration-200" do %>
|
||||
<i class="fas fa-vial"></i>
|
||||
Test
|
||||
<% end %>
|
||||
<%= button_to toggle_admin_gateway_path(gateway), method: :post,
|
||||
class: gateway.active ?
|
||||
"inline-flex items-center gap-2 rounded-lg bg-red-600 px-3 py-1.5 text-xs font-semibold text-white shadow-sm hover:bg-red-500 transition-all duration-200" :
|
||||
"inline-flex items-center gap-2 rounded-lg bg-green-600 px-3 py-1.5 text-xs font-semibold text-white shadow-sm hover:bg-green-500 transition-all duration-200" do %>
|
||||
<% if gateway.active %>
|
||||
<i class="fas fa-ban"></i>
|
||||
Deactivate
|
||||
<% else %>
|
||||
<i class="fas fa-check"></i>
|
||||
Activate
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<% else %>
|
||||
<!-- Empty state -->
|
||||
<div class="px-6 py-14 text-center">
|
||||
<div class="mx-auto h-20 w-20 flex items-center justify-center rounded-full bg-gray-100">
|
||||
<i class="fas fa-mobile-alt text-3xl text-gray-400"></i>
|
||||
</div>
|
||||
<p class="mt-4 text-base font-medium text-gray-900">No gateways registered yet</p>
|
||||
<p class="mt-2 text-sm text-gray-500">Gateway devices will appear here once they connect via the API.</p>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
87
app/views/admin/gateways/new.html.erb
Normal file
87
app/views/admin/gateways/new.html.erb
Normal file
@@ -0,0 +1,87 @@
|
||||
<div class="space-y-6">
|
||||
<!-- Page header with back link -->
|
||||
<div class="flex items-center gap-4">
|
||||
<%= link_to admin_gateways_path, class: "inline-flex items-center gap-2 text-sm font-medium text-gray-600 hover:text-gray-900 transition-colors" do %>
|
||||
<i class="fas fa-arrow-left"></i>
|
||||
Back to Gateways
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="border-b border-gray-200 pb-5">
|
||||
<h1 class="text-3xl font-bold leading-tight tracking-tight text-gray-900">Register New Gateway</h1>
|
||||
<p class="mt-2 text-sm text-gray-600">Add a new Android gateway device to your SMS system.</p>
|
||||
</div>
|
||||
|
||||
<!-- Form card -->
|
||||
<div class="rounded-xl bg-white shadow-sm ring-1 ring-gray-900/5 px-6 py-8 max-w-2xl">
|
||||
<%= form_with url: admin_gateways_path, method: :post, local: true, class: "space-y-6" do |f| %>
|
||||
<!-- Device ID field -->
|
||||
<div>
|
||||
<%= label_tag "gateway[device_id]", "Device ID", class: "block text-sm font-medium text-gray-700" %>
|
||||
<div class="mt-1 relative">
|
||||
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<i class="fas fa-mobile-alt text-gray-400"></i>
|
||||
</div>
|
||||
<%= text_field_tag "gateway[device_id]", nil,
|
||||
class: "block w-full pl-10 rounded-lg border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm py-3",
|
||||
placeholder: "device-001",
|
||||
required: true %>
|
||||
</div>
|
||||
<p class="mt-1 text-sm text-gray-500">A unique identifier for this gateway device (e.g., phone model, serial number)</p>
|
||||
</div>
|
||||
|
||||
<!-- Name field -->
|
||||
<div>
|
||||
<%= label_tag "gateway[name]", "Gateway Name", class: "block text-sm font-medium text-gray-700" %>
|
||||
<div class="mt-1 relative">
|
||||
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<i class="fas fa-tag text-gray-400"></i>
|
||||
</div>
|
||||
<%= text_field_tag "gateway[name]", nil,
|
||||
class: "block w-full pl-10 rounded-lg border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm py-3",
|
||||
placeholder: "Office Phone",
|
||||
required: true %>
|
||||
</div>
|
||||
<p class="mt-1 text-sm text-gray-500">A friendly name to identify this gateway</p>
|
||||
</div>
|
||||
|
||||
<!-- Priority field -->
|
||||
<div>
|
||||
<%= label_tag "gateway[priority]", "Priority", class: "block text-sm font-medium text-gray-700" %>
|
||||
<div class="mt-1 relative">
|
||||
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<i class="fas fa-sort-numeric-up text-gray-400"></i>
|
||||
</div>
|
||||
<%= number_field_tag "gateway[priority]", 1,
|
||||
min: 1,
|
||||
max: 10,
|
||||
class: "block w-full pl-10 rounded-lg border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm py-3" %>
|
||||
</div>
|
||||
<p class="mt-1 text-sm text-gray-500">Priority level (1-10). Higher priority gateways are used first for sending messages.</p>
|
||||
</div>
|
||||
|
||||
<!-- Information box -->
|
||||
<div class="rounded-lg bg-blue-50 px-4 py-4 ring-1 ring-blue-600/10">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex-shrink-0">
|
||||
<i class="fas fa-info-circle text-blue-600 text-xl"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-blue-800">Gateway API Key</h3>
|
||||
<p class="mt-1 text-sm text-blue-700">
|
||||
After creating the gateway, you'll receive a unique API key. You'll need to configure this key in your Android gateway app to connect it to the system.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action buttons -->
|
||||
<div class="flex items-center gap-3 pt-4">
|
||||
<%= submit_tag "Register Gateway",
|
||||
class: "inline-flex justify-center items-center gap-2 rounded-lg bg-blue-600 px-6 py-3 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 transition-all duration-200" %>
|
||||
<%= link_to "Cancel", admin_gateways_path,
|
||||
class: "inline-flex justify-center items-center gap-2 rounded-lg bg-gray-100 px-6 py-3 text-sm font-semibold text-gray-700 hover:bg-gray-200 transition-all duration-200" %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
565
app/views/admin/gateways/show.html.erb
Normal file
565
app/views/admin/gateways/show.html.erb
Normal file
@@ -0,0 +1,565 @@
|
||||
<div class="space-y-6">
|
||||
<!-- Back link -->
|
||||
<div class="flex items-center gap-4">
|
||||
<%= link_to admin_gateways_path, class: "inline-flex items-center gap-2 text-sm font-medium text-gray-600 hover:text-gray-900 transition-colors" do %>
|
||||
<i class="fas fa-arrow-left"></i>
|
||||
Back to Gateways
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<% if @is_new && @raw_key.present? %>
|
||||
<!-- New gateway created - show API key -->
|
||||
<div class="border-b border-gray-200 pb-5">
|
||||
<h1 class="text-3xl font-bold leading-tight tracking-tight text-gray-900">Gateway Created Successfully!</h1>
|
||||
<p class="mt-2 text-sm text-gray-600">Your new gateway has been registered and is ready to connect.</p>
|
||||
</div>
|
||||
|
||||
<!-- Warning alert -->
|
||||
<div class="rounded-lg bg-yellow-50 px-4 py-4 ring-1 ring-yellow-600/10">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex-shrink-0">
|
||||
<i class="fas fa-exclamation-triangle text-yellow-600 text-xl"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-yellow-800">Important: Save this API key now!</h3>
|
||||
<p class="mt-1 text-sm text-yellow-700">
|
||||
This is the only time you'll be able to see the full API key. You need to configure this key in your Android gateway app to connect it to the system.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- QR Code and API Key display -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- QR Code Card -->
|
||||
<div class="rounded-xl bg-white shadow-sm ring-1 ring-gray-900/5 px-6 py-6">
|
||||
<div class="text-center">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">Scan QR Code</h3>
|
||||
<p class="text-sm text-gray-600 mb-6">Scan this QR code with your Android gateway app to auto-configure</p>
|
||||
|
||||
<div class="flex justify-center mb-4">
|
||||
<div class="bg-white p-4 rounded-lg shadow-inner border-2 border-gray-200">
|
||||
<%= @qr_code_data.html_safe %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg bg-blue-50 px-4 py-3 ring-1 ring-blue-600/10">
|
||||
<p class="text-xs text-blue-700">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
QR code contains API key, API base URL, and WebSocket URL
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- API Key Manual Entry Card -->
|
||||
<div class="rounded-xl bg-white shadow-sm ring-1 ring-gray-900/5 px-6 py-6">
|
||||
<div class="mb-6">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h3 class="text-lg font-semibold text-gray-900">Manual Configuration</h3>
|
||||
<button
|
||||
onclick="copyAllConfig()"
|
||||
class="inline-flex items-center gap-2 rounded-lg bg-gray-600 px-3 py-1.5 text-xs font-semibold text-white shadow-sm hover:bg-gray-500 transition-all duration-200">
|
||||
<i class="fas fa-copy"></i>
|
||||
Copy All
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600">Or manually enter these details if QR scanning is unavailable</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- API Base URL -->
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 mb-1">API Base URL</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex-1 relative rounded-lg bg-gray-50 px-3 py-2 border border-gray-200">
|
||||
<code class="text-xs font-mono text-gray-800 break-all" id="api-base-url"><%= request.base_url %></code>
|
||||
</div>
|
||||
<button
|
||||
onclick="copyField('api-base-url')"
|
||||
class="flex-shrink-0 inline-flex items-center gap-1 rounded-lg bg-blue-600 px-3 py-2 text-xs font-semibold text-white hover:bg-blue-500">
|
||||
<i class="fas fa-copy"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- WebSocket URL -->
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 mb-1">WebSocket URL</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex-1 relative rounded-lg bg-gray-50 px-3 py-2 border border-gray-200">
|
||||
<code class="text-xs font-mono text-gray-800 break-all" id="ws-url"><%= request.base_url.sub(/^http/, 'ws') %>/cable</code>
|
||||
</div>
|
||||
<button
|
||||
onclick="copyField('ws-url')"
|
||||
class="flex-shrink-0 inline-flex items-center gap-1 rounded-lg bg-blue-600 px-3 py-2 text-xs font-semibold text-white hover:bg-blue-500">
|
||||
<i class="fas fa-copy"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- API Key -->
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 mb-1">API Key</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex-1 relative rounded-lg bg-gray-900 px-3 py-2">
|
||||
<code class="text-xs font-mono text-green-400 break-all" id="api-key"><%= @raw_key %></code>
|
||||
</div>
|
||||
<button
|
||||
onclick="copyField('api-key')"
|
||||
class="flex-shrink-0 inline-flex items-center gap-1 rounded-lg bg-blue-600 px-3 py-2 text-xs font-semibold text-white hover:bg-blue-500">
|
||||
<i class="fas fa-copy"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Gateway Details card -->
|
||||
<div class="rounded-xl bg-white shadow-sm ring-1 ring-gray-900/5 px-6 py-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">Gateway Details</h3>
|
||||
|
||||
<dl class="divide-y divide-gray-100">
|
||||
<div class="px-4 py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
|
||||
<dt class="text-sm font-medium text-gray-900">Device ID</dt>
|
||||
<dd class="mt-1 text-sm sm:col-span-2 sm:mt-0">
|
||||
<code class="rounded bg-gray-100 px-2 py-1 text-xs font-mono text-gray-800"><%= @gateway.device_id %></code>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div class="px-4 py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
|
||||
<dt class="text-sm font-medium text-gray-900">Name</dt>
|
||||
<dd class="mt-1 text-sm text-gray-700 sm:col-span-2 sm:mt-0"><%= @gateway.name %></dd>
|
||||
</div>
|
||||
|
||||
<div class="px-4 py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
|
||||
<dt class="text-sm font-medium text-gray-900">Priority</dt>
|
||||
<dd class="mt-1 text-sm sm:col-span-2 sm:mt-0">
|
||||
<span class="inline-flex items-center rounded-full bg-purple-50 px-3 py-1 text-sm font-medium text-purple-700 ring-1 ring-inset ring-purple-700/10">
|
||||
<%= @gateway.priority %>
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div class="px-4 py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
|
||||
<dt class="text-sm font-medium text-gray-900">Status</dt>
|
||||
<dd class="mt-1 text-sm sm:col-span-2 sm:mt-0">
|
||||
<span class="inline-flex items-center gap-1.5 rounded-full bg-red-50 px-2 py-1 text-xs font-medium text-red-700 ring-1 ring-inset ring-red-700/10">
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-red-500"></span>
|
||||
Offline (Waiting for connection)
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div class="px-4 py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
|
||||
<dt class="text-sm font-medium text-gray-900">Created</dt>
|
||||
<dd class="mt-1 text-sm text-gray-700 sm:col-span-2 sm:mt-0">
|
||||
<%= @gateway.created_at.strftime("%B %d, %Y at %l:%M %p") %>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<!-- Next steps card -->
|
||||
<div class="rounded-lg bg-blue-50 px-6 py-6 ring-1 ring-blue-600/10">
|
||||
<h3 class="text-sm font-semibold text-blue-800 mb-3">Quick Setup Guide</h3>
|
||||
|
||||
<div class="mb-4">
|
||||
<h4 class="text-xs font-semibold text-blue-800 mb-2">Option 1: QR Code (Recommended)</h4>
|
||||
<ol class="list-decimal list-inside space-y-1.5 text-xs text-blue-700 ml-2">
|
||||
<li>Install the Android SMS Gateway app on your device</li>
|
||||
<li>Open the app and look for "Scan QR Code" option</li>
|
||||
<li>Scan the QR code above - configuration will be applied automatically</li>
|
||||
<li>Start the gateway service in the app</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="text-xs font-semibold text-blue-800 mb-2">Option 2: Manual Entry</h4>
|
||||
<ol class="list-decimal list-inside space-y-1.5 text-xs text-blue-700 ml-2">
|
||||
<li>Install the Android SMS Gateway app on your device</li>
|
||||
<li>Open the app and navigate to Settings</li>
|
||||
<li>Copy and paste each field from the "Manual Configuration" section above</li>
|
||||
<li>Save the configuration and start the gateway service</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 pt-4 border-t border-blue-200">
|
||||
<p class="text-xs text-blue-700">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
The gateway will appear as <span class="font-semibold">"Online"</span> once it successfully connects to the server.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function copyField(elementId) {
|
||||
const element = document.getElementById(elementId);
|
||||
const text = element.textContent;
|
||||
|
||||
navigator.clipboard.writeText(text).then(function() {
|
||||
const button = event.target.closest('button');
|
||||
const originalHTML = button.innerHTML;
|
||||
button.innerHTML = '<i class="fas fa-check"></i>';
|
||||
button.classList.add('bg-green-600', 'hover:bg-green-500');
|
||||
button.classList.remove('bg-blue-600', 'hover:bg-blue-500');
|
||||
|
||||
setTimeout(function() {
|
||||
button.innerHTML = originalHTML;
|
||||
button.classList.remove('bg-green-600', 'hover:bg-green-500');
|
||||
button.classList.add('bg-blue-600', 'hover:bg-blue-500');
|
||||
}, 2000);
|
||||
}, function(err) {
|
||||
alert('Failed to copy: ' + err);
|
||||
});
|
||||
}
|
||||
|
||||
function copyAllConfig() {
|
||||
const apiBaseUrl = document.getElementById('api-base-url').textContent;
|
||||
const wsUrl = document.getElementById('ws-url').textContent;
|
||||
const apiKey = document.getElementById('api-key').textContent;
|
||||
|
||||
const configText = `API Base URL: ${apiBaseUrl}\nWebSocket URL: ${wsUrl}\nAPI Key: ${apiKey}`;
|
||||
|
||||
navigator.clipboard.writeText(configText).then(function() {
|
||||
const button = event.target.closest('button');
|
||||
const originalHTML = button.innerHTML;
|
||||
button.innerHTML = '<i class="fas fa-check"></i> Copied!';
|
||||
button.classList.add('bg-green-600', 'hover:bg-green-500');
|
||||
button.classList.remove('bg-gray-600', 'hover:bg-gray-500');
|
||||
|
||||
setTimeout(function() {
|
||||
button.innerHTML = originalHTML;
|
||||
button.classList.remove('bg-green-600', 'hover:bg-green-500');
|
||||
button.classList.add('bg-gray-600', 'hover:bg-gray-500');
|
||||
}, 2000);
|
||||
}, function(err) {
|
||||
alert('Failed to copy: ' + err);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<% else %>
|
||||
<!-- Existing gateway view -->
|
||||
<!-- Page header -->
|
||||
<div class="border-b border-gray-200 pb-5">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold leading-tight tracking-tight text-gray-900"><%= @gateway.name %></h1>
|
||||
<p class="mt-2 text-sm text-gray-600">Gateway device details and statistics</p>
|
||||
</div>
|
||||
<!-- Status indicator -->
|
||||
<div>
|
||||
<% if @gateway.status == "online" %>
|
||||
<span class="inline-flex items-center gap-2 rounded-full bg-green-50 px-4 py-2 text-sm font-medium text-green-700 ring-2 ring-inset ring-green-700/20">
|
||||
<span class="relative flex h-3 w-3">
|
||||
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
|
||||
<span class="relative inline-flex rounded-full h-3 w-3 bg-green-500"></span>
|
||||
</span>
|
||||
Online
|
||||
</span>
|
||||
<% else %>
|
||||
<span class="inline-flex items-center gap-2 rounded-full bg-red-50 px-4 py-2 text-sm font-medium text-red-700 ring-2 ring-inset ring-red-700/20">
|
||||
<span class="h-3 w-3 rounded-full bg-red-500"></span>
|
||||
Offline
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats grid -->
|
||||
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<!-- Status card -->
|
||||
<div class="relative overflow-hidden rounded-xl <%= @gateway.status == 'online' ? 'bg-green-500' : 'bg-red-500' %> px-4 py-5 shadow-sm sm:px-6">
|
||||
<dt>
|
||||
<div class="absolute rounded-lg bg-white/20 p-3">
|
||||
<i class="fas fa-signal text-xl text-white"></i>
|
||||
</div>
|
||||
<p class="ml-16 truncate text-sm font-medium text-white/90">Connection Status</p>
|
||||
</dt>
|
||||
<dd class="ml-16 flex items-baseline">
|
||||
<p class="text-2xl font-semibold text-white"><%= @gateway.status.titleize %></p>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<!-- Active status card -->
|
||||
<div class="relative overflow-hidden rounded-xl <%= @gateway.active ? 'bg-blue-500' : 'bg-gray-400' %> px-4 py-5 shadow-sm sm:px-6">
|
||||
<dt>
|
||||
<div class="absolute rounded-lg bg-white/20 p-3">
|
||||
<i class="fas fa-power-off text-xl text-white"></i>
|
||||
</div>
|
||||
<p class="ml-16 truncate text-sm font-medium text-white/90">Active Status</p>
|
||||
</dt>
|
||||
<dd class="ml-16 flex items-baseline">
|
||||
<p class="text-2xl font-semibold text-white"><%= @gateway.active ? 'Active' : 'Inactive' %></p>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<!-- Priority card -->
|
||||
<div class="relative overflow-hidden rounded-xl bg-purple-500 px-4 py-5 shadow-sm sm:px-6">
|
||||
<dt>
|
||||
<div class="absolute rounded-lg bg-white/20 p-3">
|
||||
<i class="fas fa-sort-amount-up text-xl text-white"></i>
|
||||
</div>
|
||||
<p class="ml-16 truncate text-sm font-medium text-white/90">Priority Level</p>
|
||||
</dt>
|
||||
<dd class="ml-16 flex items-baseline">
|
||||
<p class="text-2xl font-semibold text-white"><%= @gateway.priority %></p>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<!-- Messages sent today -->
|
||||
<div class="relative overflow-hidden rounded-xl bg-white px-4 py-5 shadow-sm ring-1 ring-gray-900/5 sm:px-6">
|
||||
<dt>
|
||||
<div class="absolute rounded-lg bg-green-500 p-3">
|
||||
<i class="fas fa-arrow-up text-xl text-white"></i>
|
||||
</div>
|
||||
<p class="ml-16 truncate text-sm font-medium text-gray-500">Messages Sent Today</p>
|
||||
</dt>
|
||||
<dd class="ml-16 flex items-baseline">
|
||||
<p class="text-2xl font-semibold text-gray-900"><%= @gateway.messages_sent_today %></p>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<!-- Messages received today -->
|
||||
<div class="relative overflow-hidden rounded-xl bg-white px-4 py-5 shadow-sm ring-1 ring-gray-900/5 sm:px-6">
|
||||
<dt>
|
||||
<div class="absolute rounded-lg bg-blue-500 p-3">
|
||||
<i class="fas fa-arrow-down text-xl text-white"></i>
|
||||
</div>
|
||||
<p class="ml-16 truncate text-sm font-medium text-gray-500">Messages Received Today</p>
|
||||
</dt>
|
||||
<dd class="ml-16 flex items-baseline">
|
||||
<p class="text-2xl font-semibold text-gray-900"><%= @gateway.messages_received_today %></p>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<!-- Total messages -->
|
||||
<div class="relative overflow-hidden rounded-xl bg-white px-4 py-5 shadow-sm ring-1 ring-gray-900/5 sm:px-6">
|
||||
<dt>
|
||||
<div class="absolute rounded-lg bg-yellow-500 p-3">
|
||||
<i class="fas fa-paper-plane text-xl text-white"></i>
|
||||
</div>
|
||||
<p class="ml-16 truncate text-sm font-medium text-gray-500">Total Messages</p>
|
||||
</dt>
|
||||
<dd class="ml-16 flex items-baseline">
|
||||
<p class="text-2xl font-semibold text-gray-900"><%= @gateway.total_messages_sent + @gateway.total_messages_received %></p>
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Details card -->
|
||||
<div class="rounded-xl bg-white shadow-sm ring-1 ring-gray-900/5 px-6 py-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">Gateway Details</h3>
|
||||
|
||||
<dl class="divide-y divide-gray-100">
|
||||
<div class="px-4 py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
|
||||
<dt class="text-sm font-medium text-gray-900">Device ID</dt>
|
||||
<dd class="mt-1 text-sm sm:col-span-2 sm:mt-0">
|
||||
<code class="rounded bg-gray-100 px-3 py-1.5 text-sm font-mono text-gray-800"><%= @gateway.device_id %></code>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div class="px-4 py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
|
||||
<dt class="text-sm font-medium text-gray-900">Name</dt>
|
||||
<dd class="mt-1 text-sm text-gray-700 sm:col-span-2 sm:mt-0"><%= @gateway.name %></dd>
|
||||
</div>
|
||||
|
||||
<div class="px-4 py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
|
||||
<dt class="text-sm font-medium text-gray-900">Status</dt>
|
||||
<dd class="mt-1 text-sm sm:col-span-2 sm:mt-0">
|
||||
<% if @gateway.status == "online" %>
|
||||
<span class="inline-flex items-center gap-1.5 rounded-full bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-700/10">
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-green-500"></span>
|
||||
Online
|
||||
</span>
|
||||
<% else %>
|
||||
<span class="inline-flex items-center gap-1.5 rounded-full bg-red-50 px-2 py-1 text-xs font-medium text-red-700 ring-1 ring-inset ring-red-700/10">
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-red-500"></span>
|
||||
Offline
|
||||
</span>
|
||||
<% end %>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div class="px-4 py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
|
||||
<dt class="text-sm font-medium text-gray-900">Active</dt>
|
||||
<dd class="mt-1 text-sm sm:col-span-2 sm:mt-0">
|
||||
<% if @gateway.active %>
|
||||
<span class="inline-flex items-center gap-1 rounded-full bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-700/10">
|
||||
<i class="fas fa-check"></i> Active
|
||||
</span>
|
||||
<% else %>
|
||||
<span class="inline-flex items-center gap-1 rounded-full bg-gray-50 px-2 py-1 text-xs font-medium text-gray-700 ring-1 ring-inset ring-gray-700/10">
|
||||
<i class="fas fa-times"></i> Inactive
|
||||
</span>
|
||||
<% end %>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div class="px-4 py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
|
||||
<dt class="text-sm font-medium text-gray-900">Priority</dt>
|
||||
<dd class="mt-1 text-sm text-gray-700 sm:col-span-2 sm:mt-0">
|
||||
<span class="inline-flex items-center rounded-full bg-purple-50 px-3 py-1 text-sm font-medium text-purple-700 ring-1 ring-inset ring-purple-700/10">
|
||||
<%= @gateway.priority %>
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div class="px-4 py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
|
||||
<dt class="text-sm font-medium text-gray-900">Last Heartbeat</dt>
|
||||
<dd class="mt-1 text-sm text-gray-700 sm:col-span-2 sm:mt-0">
|
||||
<% if @gateway.last_heartbeat_at %>
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="fas fa-heartbeat text-red-500"></i>
|
||||
<span><%= @gateway.last_heartbeat_at.strftime("%B %d, %Y at %l:%M:%S %p") %></span>
|
||||
<span class="text-gray-500">(<%= time_ago_in_words(@gateway.last_heartbeat_at) %> ago)</span>
|
||||
</div>
|
||||
<% else %>
|
||||
<span class="text-gray-400 italic">Never</span>
|
||||
<% end %>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div class="px-4 py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
|
||||
<dt class="text-sm font-medium text-gray-900">Total Messages Sent</dt>
|
||||
<dd class="mt-1 text-sm text-gray-700 sm:col-span-2 sm:mt-0">
|
||||
<span class="inline-flex items-center gap-1 text-green-600 font-semibold">
|
||||
<i class="fas fa-arrow-up"></i>
|
||||
<%= @gateway.total_messages_sent %>
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div class="px-4 py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
|
||||
<dt class="text-sm font-medium text-gray-900">Total Messages Received</dt>
|
||||
<dd class="mt-1 text-sm text-gray-700 sm:col-span-2 sm:mt-0">
|
||||
<span class="inline-flex items-center gap-1 text-blue-600 font-semibold">
|
||||
<i class="fas fa-arrow-down"></i>
|
||||
<%= @gateway.total_messages_received %>
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div class="px-4 py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
|
||||
<dt class="text-sm font-medium text-gray-900">Created</dt>
|
||||
<dd class="mt-1 text-sm text-gray-700 sm:col-span-2 sm:mt-0">
|
||||
<%= @gateway.created_at.strftime("%B %d, %Y at %l:%M %p") %>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div class="px-4 py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
|
||||
<dt class="text-sm font-medium text-gray-900">Last Updated</dt>
|
||||
<dd class="mt-1 text-sm text-gray-700 sm:col-span-2 sm:mt-0">
|
||||
<%= @gateway.updated_at.strftime("%B %d, %Y at %l:%M %p") %>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<!-- Metadata section -->
|
||||
<% if @gateway.metadata.present? %>
|
||||
<div class="mt-6 pt-6 border-t border-gray-200">
|
||||
<h4 class="text-sm font-semibold text-gray-900 mb-3">Device Metadata</h4>
|
||||
<div class="rounded-lg bg-gray-900 px-4 py-4 overflow-x-auto">
|
||||
<pre class="text-xs font-mono text-green-400"><%= JSON.pretty_generate(@gateway.metadata) %></pre>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- Action buttons -->
|
||||
<div class="mt-6 pt-6 border-t border-gray-200">
|
||||
<div class="flex items-center gap-3">
|
||||
<%= link_to test_admin_gateway_path(@gateway),
|
||||
class: "inline-flex items-center gap-2 rounded-lg bg-blue-600 px-6 py-3 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 transition-all duration-200" do %>
|
||||
<i class="fas fa-vial"></i>
|
||||
Test Gateway
|
||||
<% end %>
|
||||
<%= button_to toggle_admin_gateway_path(@gateway), method: :post,
|
||||
class: @gateway.active ?
|
||||
"inline-flex items-center gap-2 rounded-lg bg-red-600 px-6 py-3 text-sm font-semibold text-white shadow-sm hover:bg-red-500 transition-all duration-200" :
|
||||
"inline-flex items-center gap-2 rounded-lg bg-green-600 px-6 py-3 text-sm font-semibold text-white shadow-sm hover:bg-green-500 transition-all duration-200" do %>
|
||||
<% if @gateway.active %>
|
||||
<i class="fas fa-ban"></i>
|
||||
Deactivate Gateway
|
||||
<% else %>
|
||||
<i class="fas fa-check"></i>
|
||||
Activate Gateway
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent messages card -->
|
||||
<div class="rounded-xl bg-white shadow-sm ring-1 ring-gray-900/5 overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<h3 class="text-lg font-semibold text-gray-900">Recent Messages</h3>
|
||||
<p class="mt-1 text-sm text-gray-500">Last <%= @recent_messages.size %> messages from this gateway</p>
|
||||
</div>
|
||||
|
||||
<% if @recent_messages.any? %>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Message ID</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Phone Number</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Direction</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Status</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 bg-white">
|
||||
<% @recent_messages.each do |msg| %>
|
||||
<tr class="hover:bg-gray-50 transition-colors">
|
||||
<td class="whitespace-nowrap px-6 py-4 text-sm">
|
||||
<code class="rounded bg-gray-100 px-2 py-1 text-xs font-mono text-gray-800"><%= msg.message_id[0..15] %>...</code>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-6 py-4 text-sm font-medium text-gray-900">
|
||||
<%= msg.phone_number %>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-6 py-4 text-sm">
|
||||
<% if msg.direction == "outbound" %>
|
||||
<span class="inline-flex items-center gap-1 rounded-full bg-blue-50 px-2 py-1 text-xs font-medium text-blue-700 ring-1 ring-inset ring-blue-700/10">
|
||||
<i class="fas fa-arrow-up"></i> Outbound
|
||||
</span>
|
||||
<% else %>
|
||||
<span class="inline-flex items-center gap-1 rounded-full bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-700/10">
|
||||
<i class="fas fa-arrow-down"></i> Inbound
|
||||
</span>
|
||||
<% end %>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-6 py-4 text-sm">
|
||||
<% case msg.status %>
|
||||
<% when "delivered" %>
|
||||
<span class="inline-flex rounded-full bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-700/10">Delivered</span>
|
||||
<% when "sent" %>
|
||||
<span class="inline-flex rounded-full bg-blue-50 px-2 py-1 text-xs font-medium text-blue-700 ring-1 ring-inset ring-blue-700/10">Sent</span>
|
||||
<% when "failed" %>
|
||||
<span class="inline-flex rounded-full bg-red-50 px-2 py-1 text-xs font-medium text-red-700 ring-1 ring-inset ring-red-700/10">Failed</span>
|
||||
<% when "pending" %>
|
||||
<span class="inline-flex rounded-full bg-yellow-50 px-2 py-1 text-xs font-medium text-yellow-700 ring-1 ring-inset ring-yellow-700/10">Pending</span>
|
||||
<% else %>
|
||||
<span class="inline-flex rounded-full bg-gray-50 px-2 py-1 text-xs font-medium text-gray-700 ring-1 ring-inset ring-gray-700/10"><%= msg.status.titleize %></span>
|
||||
<% end %>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
|
||||
<%= msg.created_at.strftime("%m/%d/%y %H:%M") %>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="px-6 py-14 text-center">
|
||||
<i class="fas fa-inbox text-4xl text-gray-300"></i>
|
||||
<p class="mt-4 text-sm font-medium text-gray-900">No messages yet</p>
|
||||
<p class="mt-2 text-sm text-gray-500">Messages will appear here once this gateway starts processing SMS.</p>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
353
app/views/admin/gateways/test.html.erb
Normal file
353
app/views/admin/gateways/test.html.erb
Normal file
@@ -0,0 +1,353 @@
|
||||
<% if @gateway.nil? %>
|
||||
<div class="rounded-lg bg-red-50 px-4 py-4 ring-1 ring-red-600/10">
|
||||
<p class="text-red-800">Error: Gateway not found</p>
|
||||
<%= link_to "Back to Gateways", admin_gateways_path, class: "text-red-600 underline" %>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="space-y-6">
|
||||
<!-- Back link -->
|
||||
<div class="flex items-center gap-4">
|
||||
<%= link_to admin_gateway_path(@gateway), class: "inline-flex items-center gap-2 text-sm font-medium text-gray-600 hover:text-gray-900 transition-colors" do %>
|
||||
<i class="fas fa-arrow-left"></i>
|
||||
Back to Gateway Details
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<!-- Page header -->
|
||||
<div class="border-b border-gray-200 pb-5">
|
||||
<h1 class="text-3xl font-bold leading-tight tracking-tight text-gray-900">Test Gateway: <%= @gateway.name %></h1>
|
||||
<p class="mt-2 text-sm text-gray-600">Test connection and send test SMS messages to verify gateway functionality.</p>
|
||||
</div>
|
||||
|
||||
<!-- Gateway Status Card -->
|
||||
<div class="rounded-xl bg-white shadow-sm ring-1 ring-gray-900/5 px-6 py-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900">Gateway Status</h3>
|
||||
<button
|
||||
onclick="checkConnection()"
|
||||
class="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 transition-all duration-200">
|
||||
<i class="fas fa-sync-alt"></i>
|
||||
Refresh Status
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="status-container" class="space-y-4">
|
||||
<!-- Status will be loaded here -->
|
||||
<div class="flex items-center justify-center py-8">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Connection Information Card -->
|
||||
<div class="rounded-xl bg-white shadow-sm ring-1 ring-gray-900/5 px-6 py-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">Connection Information</h3>
|
||||
|
||||
<dl class="divide-y divide-gray-100">
|
||||
<div class="px-4 py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
|
||||
<dt class="text-sm font-medium text-gray-900">Device ID</dt>
|
||||
<dd class="mt-1 text-sm sm:col-span-2 sm:mt-0">
|
||||
<code class="rounded bg-gray-100 px-2 py-1 text-xs font-mono text-gray-800"><%= @gateway.device_id %></code>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div class="px-4 py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
|
||||
<dt class="text-sm font-medium text-gray-900">Gateway Name</dt>
|
||||
<dd class="mt-1 text-sm text-gray-700 sm:col-span-2 sm:mt-0"><%= @gateway.name %></dd>
|
||||
</div>
|
||||
|
||||
<div class="px-4 py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
|
||||
<dt class="text-sm font-medium text-gray-900">Priority</dt>
|
||||
<dd class="mt-1 text-sm sm:col-span-2 sm:mt-0">
|
||||
<span class="inline-flex items-center rounded-full bg-purple-50 px-3 py-1 text-sm font-medium text-purple-700 ring-1 ring-inset ring-purple-700/10">
|
||||
<%= @gateway.priority %>
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div class="px-4 py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
|
||||
<dt class="text-sm font-medium text-gray-900">Active</dt>
|
||||
<dd class="mt-1 text-sm sm:col-span-2 sm:mt-0">
|
||||
<% if @gateway.active %>
|
||||
<span class="inline-flex items-center gap-1 rounded-full bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-700/10">
|
||||
<i class="fas fa-check"></i> Active
|
||||
</span>
|
||||
<% else %>
|
||||
<span class="inline-flex items-center gap-1 rounded-full bg-red-50 px-2 py-1 text-xs font-medium text-red-700 ring-1 ring-inset ring-red-700/10">
|
||||
<i class="fas fa-times"></i> Inactive
|
||||
</span>
|
||||
<% end %>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<!-- Send Test SMS Card -->
|
||||
<div class="rounded-xl bg-white shadow-sm ring-1 ring-gray-900/5 px-6 py-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">Send Test SMS</h3>
|
||||
<p class="text-sm text-gray-600 mb-6">Send a test SMS message through this gateway to verify it's working correctly.</p>
|
||||
|
||||
<form id="test-sms-form" class="space-y-6">
|
||||
<!-- Phone Number -->
|
||||
<div>
|
||||
<label for="phone_number" class="block text-sm font-medium text-gray-700">Phone Number</label>
|
||||
<div class="mt-1 relative">
|
||||
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<i class="fas fa-phone text-gray-400"></i>
|
||||
</div>
|
||||
<input
|
||||
type="tel"
|
||||
id="phone_number"
|
||||
name="phone_number"
|
||||
class="block w-full pl-10 rounded-lg border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm py-3"
|
||||
placeholder="+959123456789"
|
||||
required>
|
||||
</div>
|
||||
<p class="mt-1 text-sm text-gray-500">Enter phone number with country code (e.g., +959123456789)</p>
|
||||
</div>
|
||||
|
||||
<!-- Message Body -->
|
||||
<div>
|
||||
<label for="message_body" class="block text-sm font-medium text-gray-700">Message</label>
|
||||
<div class="mt-1">
|
||||
<textarea
|
||||
id="message_body"
|
||||
name="message_body"
|
||||
rows="4"
|
||||
class="block w-full rounded-lg border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm py-3"
|
||||
placeholder="This is a test message from the admin interface."
|
||||
required>This is a test message from MySMSAPio admin interface. Gateway: <%= @gateway.name %></textarea>
|
||||
</div>
|
||||
<p class="mt-1 text-sm text-gray-500" id="char-count">160 characters remaining</p>
|
||||
</div>
|
||||
|
||||
<!-- Result Display -->
|
||||
<div id="sms-result" class="hidden">
|
||||
<!-- Success/Error message will be displayed here -->
|
||||
</div>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
class="inline-flex justify-center items-center gap-2 rounded-lg bg-blue-600 px-6 py-3 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 transition-all duration-200">
|
||||
<i class="fas fa-paper-plane"></i>
|
||||
<span id="submit-text">Send Test SMS</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick="resetForm()"
|
||||
class="inline-flex justify-center items-center gap-2 rounded-lg bg-gray-100 px-6 py-3 text-sm font-semibold text-gray-700 hover:bg-gray-200 transition-all duration-200">
|
||||
<i class="fas fa-redo"></i>
|
||||
Reset Form
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Warning Card -->
|
||||
<div class="rounded-lg bg-yellow-50 px-4 py-4 ring-1 ring-yellow-600/10">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex-shrink-0">
|
||||
<i class="fas fa-exclamation-triangle text-yellow-600 text-xl"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-yellow-800">Important Notes</h3>
|
||||
<ul class="mt-2 text-sm text-yellow-700 list-disc list-inside space-y-1">
|
||||
<li>Test SMS messages will be sent to real phone numbers</li>
|
||||
<li>Ensure the gateway is online and connected before testing</li>
|
||||
<li>Standard SMS charges may apply to the recipient</li>
|
||||
<li>Test messages are marked with metadata for identification</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Check connection on page load
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
checkConnection();
|
||||
updateCharCount();
|
||||
});
|
||||
|
||||
// Update character count
|
||||
const messageBody = document.getElementById('message_body');
|
||||
messageBody.addEventListener('input', updateCharCount);
|
||||
|
||||
function updateCharCount() {
|
||||
const length = messageBody.value.length;
|
||||
const remaining = 160 - length;
|
||||
const charCount = document.getElementById('char-count');
|
||||
|
||||
if (remaining < 0) {
|
||||
charCount.textContent = `${Math.abs(remaining)} characters over limit (message will be split into ${Math.ceil(length / 160)} parts)`;
|
||||
charCount.classList.add('text-red-600');
|
||||
charCount.classList.remove('text-gray-500');
|
||||
} else {
|
||||
charCount.textContent = `${remaining} characters remaining`;
|
||||
charCount.classList.remove('text-red-600');
|
||||
charCount.classList.add('text-gray-500');
|
||||
}
|
||||
}
|
||||
|
||||
// Check gateway connection
|
||||
async function checkConnection() {
|
||||
const container = document.getElementById('status-container');
|
||||
container.innerHTML = '<div class="flex items-center justify-center py-8"><div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div></div>';
|
||||
|
||||
try {
|
||||
const response = await fetch('<%= check_connection_admin_gateway_path(@gateway) %>', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': document.querySelector('[name="csrf-token"]').content
|
||||
}
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === 'success') {
|
||||
container.innerHTML = `
|
||||
<div class="rounded-lg bg-green-50 px-4 py-4 ring-1 ring-green-600/10">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex-shrink-0">
|
||||
<i class="fas fa-check-circle text-green-600 text-2xl"></i>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 class="text-sm font-semibold text-green-800">Gateway is Online</h3>
|
||||
<p class="mt-1 text-sm text-green-700">
|
||||
Last heartbeat: ${data.time_ago} ago
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-green-600">
|
||||
${data.last_heartbeat}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
container.innerHTML = `
|
||||
<div class="rounded-lg bg-red-50 px-4 py-4 ring-1 ring-red-600/10">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex-shrink-0">
|
||||
<i class="fas fa-times-circle text-red-600 text-2xl"></i>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 class="text-sm font-semibold text-red-800">Gateway is Offline</h3>
|
||||
<p class="mt-1 text-sm text-red-700">
|
||||
${data.message}
|
||||
</p>
|
||||
${data.last_heartbeat ? `<p class="mt-1 text-xs text-red-600">Last seen: ${data.time_ago} ago</p>` : '<p class="mt-1 text-xs text-red-600">Never connected</p>'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
} catch (error) {
|
||||
container.innerHTML = `
|
||||
<div class="rounded-lg bg-red-50 px-4 py-4 ring-1 ring-red-600/10">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex-shrink-0">
|
||||
<i class="fas fa-exclamation-circle text-red-600 text-2xl"></i>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 class="text-sm font-semibold text-red-800">Error Checking Status</h3>
|
||||
<p class="mt-1 text-sm text-red-700">${error.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle form submission
|
||||
document.getElementById('test-sms-form').addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const submitButton = e.target.querySelector('button[type="submit"]');
|
||||
const submitText = document.getElementById('submit-text');
|
||||
const resultDiv = document.getElementById('sms-result');
|
||||
|
||||
// Disable button and show loading
|
||||
submitButton.disabled = true;
|
||||
submitText.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Sending...';
|
||||
|
||||
const formData = {
|
||||
phone_number: document.getElementById('phone_number').value,
|
||||
message_body: document.getElementById('message_body').value
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch('<%= send_test_sms_admin_gateway_path(@gateway) %>', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': document.querySelector('[name="csrf-token"]').content
|
||||
},
|
||||
body: JSON.stringify(formData)
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === 'success') {
|
||||
resultDiv.className = 'rounded-lg bg-green-50 px-4 py-4 ring-1 ring-green-600/10';
|
||||
resultDiv.innerHTML = `
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex-shrink-0">
|
||||
<i class="fas fa-check-circle text-green-600 text-xl"></i>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 class="text-sm font-semibold text-green-800">Test SMS Sent Successfully!</h3>
|
||||
<p class="mt-1 text-sm text-green-700">${data.message}</p>
|
||||
<div class="mt-2 text-xs text-green-600">
|
||||
<p>Message ID: <code class="bg-green-100 px-2 py-1 rounded">${data.message_id}</code></p>
|
||||
<p class="mt-1">Status: <span class="font-semibold">${data.sms_status}</span></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
resultDiv.className = 'rounded-lg bg-red-50 px-4 py-4 ring-1 ring-red-600/10';
|
||||
resultDiv.innerHTML = `
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex-shrink-0">
|
||||
<i class="fas fa-times-circle text-red-600 text-xl"></i>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 class="text-sm font-semibold text-red-800">Failed to Send Test SMS</h3>
|
||||
<p class="mt-1 text-sm text-red-700">${data.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
resultDiv.classList.remove('hidden');
|
||||
} catch (error) {
|
||||
resultDiv.className = 'rounded-lg bg-red-50 px-4 py-4 ring-1 ring-red-600/10';
|
||||
resultDiv.innerHTML = `
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex-shrink-0">
|
||||
<i class="fas fa-exclamation-circle text-red-600 text-xl"></i>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 class="text-sm font-semibold text-red-800">Error</h3>
|
||||
<p class="mt-1 text-sm text-red-700">${error.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
resultDiv.classList.remove('hidden');
|
||||
} finally {
|
||||
// Re-enable button
|
||||
submitButton.disabled = false;
|
||||
submitText.innerHTML = '<i class="fas fa-paper-plane"></i> Send Test SMS';
|
||||
}
|
||||
});
|
||||
|
||||
function resetForm() {
|
||||
document.getElementById('test-sms-form').reset();
|
||||
document.getElementById('sms-result').classList.add('hidden');
|
||||
updateCharCount();
|
||||
}
|
||||
</script>
|
||||
<% end %>
|
||||
235
app/views/admin/logs/index.html.erb
Normal file
235
app/views/admin/logs/index.html.erb
Normal file
@@ -0,0 +1,235 @@
|
||||
<div class="space-y-6">
|
||||
<!-- Page header -->
|
||||
<div class="border-b border-gray-200 pb-5">
|
||||
<h1 class="text-3xl font-bold leading-tight tracking-tight text-gray-900">SMS Logs</h1>
|
||||
<p class="mt-2 text-sm text-gray-600">View and filter all SMS messages across your gateways.</p>
|
||||
</div>
|
||||
|
||||
<!-- Filters card -->
|
||||
<div class="rounded-xl bg-white shadow-sm ring-1 ring-gray-900/5 px-6 py-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900">Filters</h3>
|
||||
<i class="fas fa-filter text-gray-400"></i>
|
||||
</div>
|
||||
|
||||
<%= form_with url: admin_logs_path, method: :get, local: true do |f| %>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<!-- Direction filter -->
|
||||
<div>
|
||||
<%= label_tag :direction, "Direction", class: "block text-sm font-medium text-gray-700 mb-1" %>
|
||||
<%= select_tag :direction,
|
||||
options_for_select([["All Directions", ""], ["Inbound", "inbound"], ["Outbound", "outbound"]], params[:direction]),
|
||||
class: "block w-full rounded-lg border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
|
||||
</div>
|
||||
|
||||
<!-- Status filter -->
|
||||
<div>
|
||||
<%= label_tag :status, "Status", class: "block text-sm font-medium text-gray-700 mb-1" %>
|
||||
<%= select_tag :status,
|
||||
options_for_select([["All Statuses", ""], ["Pending", "pending"], ["Queued", "queued"], ["Sent", "sent"], ["Delivered", "delivered"], ["Failed", "failed"]], params[:status]),
|
||||
class: "block w-full rounded-lg border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
|
||||
</div>
|
||||
|
||||
<!-- Phone number filter -->
|
||||
<div>
|
||||
<%= label_tag :phone_number, "Phone Number", class: "block text-sm font-medium text-gray-700 mb-1" %>
|
||||
<div class="relative">
|
||||
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<i class="fas fa-phone text-gray-400"></i>
|
||||
</div>
|
||||
<%= text_field_tag :phone_number, params[:phone_number],
|
||||
class: "block w-full pl-10 rounded-lg border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm",
|
||||
placeholder: "Search phone..." %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Gateway filter -->
|
||||
<div>
|
||||
<%= label_tag :gateway_id, "Gateway", class: "block text-sm font-medium text-gray-700 mb-1" %>
|
||||
<%= select_tag :gateway_id,
|
||||
options_for_select([["All Gateways", ""]] + Gateway.order(:name).pluck(:name, :id), params[:gateway_id]),
|
||||
class: "block w-full rounded-lg border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
|
||||
</div>
|
||||
|
||||
<!-- Start date filter -->
|
||||
<div>
|
||||
<%= label_tag :start_date, "Start Date", class: "block text-sm font-medium text-gray-700 mb-1" %>
|
||||
<div class="relative">
|
||||
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<i class="fas fa-calendar text-gray-400"></i>
|
||||
</div>
|
||||
<%= date_field_tag :start_date, params[:start_date],
|
||||
class: "block w-full pl-10 rounded-lg border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- End date filter -->
|
||||
<div>
|
||||
<%= label_tag :end_date, "End Date", class: "block text-sm font-medium text-gray-700 mb-1" %>
|
||||
<div class="relative">
|
||||
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<i class="fas fa-calendar text-gray-400"></i>
|
||||
</div>
|
||||
<%= date_field_tag :end_date, params[:end_date],
|
||||
class: "block w-full pl-10 rounded-lg border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter buttons -->
|
||||
<div class="flex items-center gap-3 mt-6 pt-4 border-t border-gray-200">
|
||||
<%= submit_tag "Apply Filters",
|
||||
class: "inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 transition-all duration-200" do %>
|
||||
<i class="fas fa-filter"></i>
|
||||
Apply Filters
|
||||
<% end %>
|
||||
<%= link_to admin_logs_path,
|
||||
class: "inline-flex items-center gap-2 rounded-lg bg-gray-100 px-4 py-2 text-sm font-semibold text-gray-700 hover:bg-gray-200 transition-all duration-200" do %>
|
||||
<i class="fas fa-times"></i>
|
||||
Clear Filters
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<!-- Messages table card -->
|
||||
<div class="rounded-xl bg-white shadow-sm ring-1 ring-gray-900/5 overflow-hidden">
|
||||
<% if @messages.any? %>
|
||||
<!-- Table header with count -->
|
||||
<div class="px-6 py-4 border-b border-gray-200 bg-gray-50">
|
||||
<p class="text-sm text-gray-700">
|
||||
Showing <span class="font-semibold"><%= @messages.size %></span> of <span class="font-semibold"><%= @pagy.count %></span> messages
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Message ID</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Phone Number</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Message</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Direction</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Status</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Gateway</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Retries</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Created</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Processed</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 bg-white">
|
||||
<% @messages.each do |msg| %>
|
||||
<tr class="hover:bg-gray-50 transition-colors cursor-pointer" onclick="toggleErrorRow('error-<%= msg.id %>')">
|
||||
<td class="whitespace-nowrap px-6 py-4 text-sm">
|
||||
<code class="rounded bg-gray-100 px-2 py-1 text-xs font-mono text-gray-800"><%= msg.message_id[0..15] %>...</code>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-6 py-4 text-sm font-medium text-gray-900">
|
||||
<%= msg.phone_number %>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm text-gray-700">
|
||||
<div class="max-w-xs truncate"><%= msg.message_body %></div>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-6 py-4 text-sm">
|
||||
<% if msg.direction == "outbound" %>
|
||||
<span class="inline-flex items-center gap-1 rounded-full bg-blue-50 px-2 py-1 text-xs font-medium text-blue-700 ring-1 ring-inset ring-blue-700/10">
|
||||
<i class="fas fa-arrow-up"></i> Outbound
|
||||
</span>
|
||||
<% else %>
|
||||
<span class="inline-flex items-center gap-1 rounded-full bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-700/10">
|
||||
<i class="fas fa-arrow-down"></i> Inbound
|
||||
</span>
|
||||
<% end %>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-6 py-4 text-sm">
|
||||
<% case msg.status %>
|
||||
<% when "delivered" %>
|
||||
<span class="inline-flex rounded-full bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-700/10">Delivered</span>
|
||||
<% when "sent" %>
|
||||
<span class="inline-flex rounded-full bg-blue-50 px-2 py-1 text-xs font-medium text-blue-700 ring-1 ring-inset ring-blue-700/10">Sent</span>
|
||||
<% when "failed" %>
|
||||
<span class="inline-flex items-center gap-1 rounded-full bg-red-50 px-2 py-1 text-xs font-medium text-red-700 ring-1 ring-inset ring-red-700/10">
|
||||
<i class="fas fa-exclamation-circle"></i> Failed
|
||||
</span>
|
||||
<% when "pending" %>
|
||||
<span class="inline-flex rounded-full bg-yellow-50 px-2 py-1 text-xs font-medium text-yellow-700 ring-1 ring-inset ring-yellow-700/10">Pending</span>
|
||||
<% when "queued" %>
|
||||
<span class="inline-flex rounded-full bg-purple-50 px-2 py-1 text-xs font-medium text-purple-700 ring-1 ring-inset ring-purple-700/10">Queued</span>
|
||||
<% else %>
|
||||
<span class="inline-flex rounded-full bg-gray-50 px-2 py-1 text-xs font-medium text-gray-700 ring-1 ring-inset ring-gray-700/10"><%= msg.status.titleize %></span>
|
||||
<% end %>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
|
||||
<%= msg.gateway&.name || "-" %>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-6 py-4 text-sm text-center text-gray-500">
|
||||
<% if msg.retry_count > 0 %>
|
||||
<span class="inline-flex items-center justify-center rounded-full bg-yellow-100 px-2 py-0.5 text-xs font-medium text-yellow-800">
|
||||
<%= msg.retry_count %>
|
||||
</span>
|
||||
<% else %>
|
||||
-
|
||||
<% end %>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
|
||||
<%= msg.created_at.strftime("%m/%d/%y %H:%M") %>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
|
||||
<% if msg.delivered_at %>
|
||||
<span class="text-green-600"><%= msg.delivered_at.strftime("%m/%d/%y %H:%M") %></span>
|
||||
<% elsif msg.sent_at %>
|
||||
<span class="text-blue-600"><%= msg.sent_at.strftime("%m/%d/%y %H:%M") %></span>
|
||||
<% elsif msg.failed_at %>
|
||||
<span class="text-red-600"><%= msg.failed_at.strftime("%m/%d/%y %H:%M") %></span>
|
||||
<% else %>
|
||||
-
|
||||
<% end %>
|
||||
</td>
|
||||
</tr>
|
||||
<% if msg.error_message.present? %>
|
||||
<tr id="error-<%= msg.id %>" class="hidden bg-red-50">
|
||||
<td colspan="9" class="px-6 py-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex-shrink-0">
|
||||
<i class="fas fa-exclamation-triangle text-red-600"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-red-800">Error Message:</p>
|
||||
<p class="text-sm text-red-700 mt-1"><%= msg.error_message %></p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<% if @pagy.pages > 1 %>
|
||||
<div class="border-t border-gray-200 px-6 py-4 bg-gray-50">
|
||||
<div class="flex items-center justify-center">
|
||||
<%== pagy_nav(@pagy) %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<!-- Empty state -->
|
||||
<div class="px-6 py-14 text-center">
|
||||
<i class="fas fa-inbox text-4xl text-gray-300"></i>
|
||||
<p class="mt-4 text-sm font-medium text-gray-900">No messages found</p>
|
||||
<p class="mt-2 text-sm text-gray-500">Try adjusting your filters to see more results.</p>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function toggleErrorRow(id) {
|
||||
const row = document.getElementById(id);
|
||||
if (row) {
|
||||
row.classList.toggle('hidden');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
49
app/views/admin/sessions/new.html.erb
Normal file
49
app/views/admin/sessions/new.html.erb
Normal file
@@ -0,0 +1,49 @@
|
||||
<div class="w-full max-w-md space-y-8">
|
||||
<div class="text-center">
|
||||
<div class="mx-auto h-16 w-16 flex items-center justify-center rounded-full bg-blue-100">
|
||||
<i class="fas fa-sms text-3xl text-blue-600"></i>
|
||||
</div>
|
||||
<h2 class="mt-6 text-3xl font-bold tracking-tight text-gray-900">MySMSAPio Admin</h2>
|
||||
<p class="mt-2 text-sm text-gray-600">Sign in to your admin account</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 bg-white py-8 px-4 shadow-xl rounded-xl sm:px-10">
|
||||
<%= form_with url: admin_login_path, method: :post, local: true, class: "space-y-6" do |f| %>
|
||||
<div>
|
||||
<%= label_tag :email, "Email address", class: "block text-sm font-medium text-gray-700" %>
|
||||
<div class="mt-1 relative">
|
||||
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<i class="fas fa-envelope text-gray-400"></i>
|
||||
</div>
|
||||
<%= email_field_tag :email, params[:email],
|
||||
class: "block w-full pl-10 rounded-lg border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm py-3",
|
||||
placeholder: "admin@example.com",
|
||||
autofocus: true,
|
||||
required: true %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= label_tag :password, "Password", class: "block text-sm font-medium text-gray-700" %>
|
||||
<div class="mt-1 relative">
|
||||
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<i class="fas fa-lock text-gray-400"></i>
|
||||
</div>
|
||||
<%= password_field_tag :password, nil,
|
||||
class: "block w-full pl-10 rounded-lg border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm py-3",
|
||||
placeholder: "Enter your password",
|
||||
required: true %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= submit_tag "Sign in",
|
||||
class: "flex w-full justify-center rounded-lg bg-blue-600 px-4 py-3 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 transition-all duration-200" %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="text-center text-xs text-gray-500">
|
||||
<i class="fas fa-shield-alt"></i> Secure Admin Access
|
||||
</div>
|
||||
</div>
|
||||
124
app/views/layouts/admin.html.erb
Normal file
124
app/views/layouts/admin.html.erb
Normal file
@@ -0,0 +1,124 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="h-full bg-gray-50">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Admin - MySMSAPio</title>
|
||||
<%= csrf_meta_tags %>
|
||||
<%= csp_meta_tag %>
|
||||
|
||||
<%= stylesheet_link_tag "tailwind", "data-turbo-track": "reload" %>
|
||||
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
|
||||
<%= javascript_importmap_tags %>
|
||||
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" />
|
||||
</head>
|
||||
|
||||
<body class="h-full">
|
||||
<% if logged_in? %>
|
||||
<div class="min-h-full">
|
||||
<!-- Sidebar -->
|
||||
<div class="fixed inset-y-0 z-50 flex w-72 flex-col">
|
||||
<div class="flex grow flex-col gap-y-5 overflow-y-auto bg-gradient-to-b from-gray-900 to-gray-800 px-6 pb-4">
|
||||
<div class="flex h-16 shrink-0 items-center border-b border-gray-700">
|
||||
<h1 class="text-2xl font-bold text-white flex items-center gap-2">
|
||||
<i class="fas fa-sms text-blue-400"></i>
|
||||
MySMSAPio
|
||||
</h1>
|
||||
</div>
|
||||
<nav class="flex flex-1 flex-col">
|
||||
<ul role="list" class="flex flex-1 flex-col gap-y-7">
|
||||
<li>
|
||||
<ul role="list" class="-mx-2 space-y-1">
|
||||
<%= link_to admin_dashboard_path, class: "group flex gap-x-3 rounded-md p-3 text-sm leading-6 font-semibold transition-all duration-200 #{current_page?(admin_dashboard_path) ? 'bg-gray-700 text-white' : 'text-gray-300 hover:text-white hover:bg-gray-700'}" do %>
|
||||
<i class="fas fa-home w-6 h-6 shrink-0 flex items-center justify-center"></i>
|
||||
Dashboard
|
||||
<% end %>
|
||||
|
||||
<%= link_to admin_api_keys_path, class: "group flex gap-x-3 rounded-md p-3 text-sm leading-6 font-semibold transition-all duration-200 #{current_page?(admin_api_keys_path) || current_page?(new_admin_api_key_path) ? 'bg-gray-700 text-white' : 'text-gray-300 hover:text-white hover:bg-gray-700'}" do %>
|
||||
<i class="fas fa-key w-6 h-6 shrink-0 flex items-center justify-center"></i>
|
||||
API Keys
|
||||
<% end %>
|
||||
|
||||
<%= link_to admin_logs_path, class: "group flex gap-x-3 rounded-md p-3 text-sm leading-6 font-semibold transition-all duration-200 #{current_page?(admin_logs_path) ? 'bg-gray-700 text-white' : 'text-gray-300 hover:text-white hover:bg-gray-700'}" do %>
|
||||
<i class="fas fa-list w-6 h-6 shrink-0 flex items-center justify-center"></i>
|
||||
SMS Logs
|
||||
<% end %>
|
||||
|
||||
<%= link_to admin_gateways_path, class: "group flex gap-x-3 rounded-md p-3 text-sm leading-6 font-semibold transition-all duration-200 #{current_page?(admin_gateways_path) ? 'bg-gray-700 text-white' : 'text-gray-300 hover:text-white hover:bg-gray-700'}" do %>
|
||||
<i class="fas fa-mobile-alt w-6 h-6 shrink-0 flex items-center justify-center"></i>
|
||||
Gateways
|
||||
<% end %>
|
||||
|
||||
<%= link_to admin_api_tester_path, class: "group flex gap-x-3 rounded-md p-3 text-sm leading-6 font-semibold transition-all duration-200 #{current_page?(admin_api_tester_path) ? 'bg-gray-700 text-white' : 'text-gray-300 hover:text-white hover:bg-gray-700'}" do %>
|
||||
<i class="fas fa-vial w-6 h-6 shrink-0 flex items-center justify-center"></i>
|
||||
API Tester
|
||||
<% end %>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="mt-auto">
|
||||
<div class="rounded-lg bg-gray-700 p-4">
|
||||
<div class="flex items-center gap-x-3">
|
||||
<div class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full bg-blue-500 text-white font-semibold">
|
||||
<%= current_admin.name.split.map(&:first).join.upcase[0..1] %>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-semibold text-white truncate"><%= current_admin.name %></p>
|
||||
<p class="text-xs text-gray-400 truncate"><%= current_admin.email %></p>
|
||||
</div>
|
||||
</div>
|
||||
<%= button_to admin_logout_path, method: :delete, class: "mt-3 w-full inline-flex items-center justify-center gap-2 rounded-md bg-gray-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-gray-500 transition-all duration-200" do %>
|
||||
<i class="fas fa-sign-out-alt"></i>
|
||||
Logout
|
||||
<% end %>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main content -->
|
||||
<div class="pl-72">
|
||||
<main class="py-10">
|
||||
<div class="px-4 sm:px-6 lg:px-8">
|
||||
<!-- Flash messages -->
|
||||
<% if flash[:notice] %>
|
||||
<div class="mb-6 rounded-lg bg-green-50 p-4 border-l-4 border-green-400">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<i class="fas fa-check-circle text-green-400"></i>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p class="text-sm font-medium text-green-800"><%= flash[:notice] %></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% if flash[:alert] %>
|
||||
<div class="mb-6 rounded-lg bg-red-50 p-4 border-l-4 border-red-400">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<i class="fas fa-exclamation-circle text-red-400"></i>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p class="text-sm font-medium text-red-800"><%= flash[:alert] %></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= yield %>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<!-- Login page without sidebar -->
|
||||
<div class="min-h-full flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
|
||||
<%= yield %>
|
||||
</div>
|
||||
<% end %>
|
||||
</body>
|
||||
</html>
|
||||
30
app/views/layouts/application.html.erb
Normal file
30
app/views/layouts/application.html.erb
Normal file
@@ -0,0 +1,30 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title><%= content_for(:title) || "My Smsa Pio" %></title>
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<%= csrf_meta_tags %>
|
||||
<%= csp_meta_tag %>
|
||||
|
||||
<%= yield :head %>
|
||||
|
||||
<%# Enable PWA manifest for installable apps (make sure to enable in config/routes.rb too!) %>
|
||||
<%#= tag.link rel: "manifest", href: pwa_manifest_path(format: :json) %>
|
||||
|
||||
<link rel="icon" href="/icon.png" type="image/png">
|
||||
<link rel="icon" href="/icon.svg" type="image/svg+xml">
|
||||
<link rel="apple-touch-icon" href="/icon.png">
|
||||
|
||||
<%# Includes all stylesheet files in app/assets/stylesheets %>
|
||||
<%= stylesheet_link_tag :app, "data-turbo-track": "reload" %>
|
||||
<%= javascript_importmap_tags %>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<main class="container mx-auto mt-28 px-5 flex">
|
||||
<%= yield %>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
13
app/views/layouts/mailer.html.erb
Normal file
13
app/views/layouts/mailer.html.erb
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
|
||||
<style>
|
||||
/* Email styles need to be inline */
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<%= yield %>
|
||||
</body>
|
||||
</html>
|
||||
1
app/views/layouts/mailer.text.erb
Normal file
1
app/views/layouts/mailer.text.erb
Normal file
@@ -0,0 +1 @@
|
||||
<%= yield %>
|
||||
22
app/views/pwa/manifest.json.erb
Normal file
22
app/views/pwa/manifest.json.erb
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "MySmsaPio",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
},
|
||||
{
|
||||
"src": "/icon.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
],
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"scope": "/",
|
||||
"description": "MySmsaPio.",
|
||||
"theme_color": "red",
|
||||
"background_color": "red"
|
||||
}
|
||||
26
app/views/pwa/service-worker.js
Normal file
26
app/views/pwa/service-worker.js
Normal file
@@ -0,0 +1,26 @@
|
||||
// Add a service worker for processing Web Push notifications:
|
||||
//
|
||||
// self.addEventListener("push", async (event) => {
|
||||
// const { title, options } = await event.data.json()
|
||||
// event.waitUntil(self.registration.showNotification(title, options))
|
||||
// })
|
||||
//
|
||||
// self.addEventListener("notificationclick", function(event) {
|
||||
// event.notification.close()
|
||||
// event.waitUntil(
|
||||
// clients.matchAll({ type: "window" }).then((clientList) => {
|
||||
// for (let i = 0; i < clientList.length; i++) {
|
||||
// let client = clientList[i]
|
||||
// let clientPath = (new URL(client.url)).pathname
|
||||
//
|
||||
// if (clientPath == event.notification.data.path && "focus" in client) {
|
||||
// return client.focus()
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// if (clients.openWindow) {
|
||||
// return clients.openWindow(event.notification.data.path)
|
||||
// }
|
||||
// })
|
||||
// )
|
||||
// })
|
||||
Reference in New Issue
Block a user