completed SMS gateway project

This commit is contained in:
Min Zeya Phyo
2025-10-22 17:22:17 +08:00
commit c883fa7128
190 changed files with 16294 additions and 0 deletions

0
app/assets/builds/.keep Normal file
View File

0
app/assets/images/.keep Normal file
View File

View 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.
*/

View 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;
}

View File

@@ -0,0 +1,4 @@
module ApplicationCable
class Channel < ActionCable::Channel::Base
end
end

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View File

@@ -0,0 +1,10 @@
module Api
module V1
module Gateway
class BaseController < ApplicationController
include ApiAuthenticatable
include RateLimitable
end
end
end
end

View 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

View 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

View 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

View 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

View 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

View 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

View File

View 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

View 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

View 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

View 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

View 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"

View 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 }

View File

@@ -0,0 +1,7 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
connect() {
this.element.textContent = "Hello World!"
}
}

View 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)

View 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

View 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

View 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

View 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

View 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

View 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
View 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

View 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

View 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
View 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
View 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

View File

@@ -0,0 +1,3 @@
class ApplicationRecord < ActiveRecord::Base
primary_abstract_class
end

View File

View 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
View 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
View 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
View 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

View 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

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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 %>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -0,0 +1 @@
<%= yield %>

View 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"
}

View 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)
// }
// })
// )
// })