add api for mobile login
This commit is contained in:
parent
0a410ecf81
commit
c7f02eac24
21 changed files with 2161 additions and 9 deletions
539
MOBILE_API.md
Normal file
539
MOBILE_API.md
Normal file
|
|
@ -0,0 +1,539 @@
|
|||
# Towerops Mobile API Documentation
|
||||
|
||||
Version: 1.0
|
||||
Base URL: `https://your-towerops-instance.com/api/v1/mobile`
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Authentication](#authentication)
|
||||
- [QR Code Login Flow](#qr-code-login-flow)
|
||||
- [Session Management](#session-management)
|
||||
- [Data Endpoints](#data-endpoints)
|
||||
- [Organizations](#organizations)
|
||||
- [Sites](#sites)
|
||||
- [Equipment](#equipment)
|
||||
- [Alerts](#alerts)
|
||||
- [Error Handling](#error-handling)
|
||||
- [Rate Limiting](#rate-limiting)
|
||||
|
||||
## Authentication
|
||||
|
||||
The Towerops Mobile API uses bearer token authentication. All authenticated requests must include an `Authorization` header:
|
||||
|
||||
```
|
||||
Authorization: Bearer <session_token>
|
||||
```
|
||||
|
||||
### QR Code Login Flow
|
||||
|
||||
The recommended authentication method is QR code login, which allows users to authenticate without entering credentials in the mobile app.
|
||||
|
||||
#### Step 1: User Generates QR Code
|
||||
|
||||
User logs into the web interface at `/mobile/qr-login` and generates a QR code containing a temporary token.
|
||||
|
||||
#### Step 2: Verify QR Token
|
||||
|
||||
**Endpoint:** `POST /api/v1/mobile/auth/qr/verify`
|
||||
|
||||
Verifies that a scanned QR token is valid before proceeding with login.
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"token": "base64-encoded-token-from-qr-code"
|
||||
}
|
||||
```
|
||||
|
||||
**Response (200 OK):**
|
||||
```json
|
||||
{
|
||||
"valid": true,
|
||||
"user_email": "user@example.com"
|
||||
}
|
||||
```
|
||||
|
||||
**Response (401 Unauthorized):**
|
||||
```json
|
||||
{
|
||||
"valid": false,
|
||||
"error": "Invalid or expired token"
|
||||
}
|
||||
```
|
||||
|
||||
#### Step 3: Complete QR Login
|
||||
|
||||
**Endpoint:** `POST /api/v1/mobile/auth/qr/complete`
|
||||
|
||||
Completes the QR login by creating a long-lived mobile session (90 days).
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"token": "base64-encoded-token-from-qr-code",
|
||||
"device_name": "iPhone 15 Pro",
|
||||
"device_os": "iOS 17.2",
|
||||
"app_version": "1.0.0"
|
||||
}
|
||||
```
|
||||
|
||||
**Response (200 OK):**
|
||||
```json
|
||||
{
|
||||
"session_token": "long-lived-bearer-token",
|
||||
"expires_at": "2026-04-15T19:44:25Z",
|
||||
"user": {
|
||||
"id": "user-uuid",
|
||||
"email": "user@example.com"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Response (401 Unauthorized):**
|
||||
```json
|
||||
{
|
||||
"error": "Invalid or expired token"
|
||||
}
|
||||
```
|
||||
|
||||
**Response (422 Unprocessable Entity):**
|
||||
```json
|
||||
{
|
||||
"error": "Failed to create session",
|
||||
"details": {
|
||||
"device_name": ["can't be blank"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Notes:**
|
||||
- QR tokens expire after 5 minutes
|
||||
- QR tokens can only be used once
|
||||
- Session tokens expire after 90 days
|
||||
- Store the `session_token` securely in the device keychain/keystore
|
||||
|
||||
### Session Management
|
||||
|
||||
#### Get Current Session
|
||||
|
||||
**Endpoint:** `GET /api/v1/mobile/auth/session`
|
||||
|
||||
**Headers:**
|
||||
```
|
||||
Authorization: Bearer <session_token>
|
||||
```
|
||||
|
||||
**Response (200 OK):**
|
||||
```json
|
||||
{
|
||||
"id": "session-uuid",
|
||||
"device_name": "iPhone 15 Pro",
|
||||
"device_os": "iOS 17.2",
|
||||
"app_version": "1.0.0",
|
||||
"last_used_at": "2026-01-15T19:44:25Z",
|
||||
"expires_at": "2026-04-15T19:44:25Z"
|
||||
}
|
||||
```
|
||||
|
||||
#### Revoke Session (Logout)
|
||||
|
||||
**Endpoint:** `DELETE /api/v1/mobile/auth/session`
|
||||
|
||||
**Headers:**
|
||||
```
|
||||
Authorization: Bearer <session_token>
|
||||
```
|
||||
|
||||
**Response (200 OK):**
|
||||
```json
|
||||
{
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
|
||||
## Data Endpoints
|
||||
|
||||
All data endpoints require authentication via the `Authorization: Bearer <token>` header.
|
||||
|
||||
### Organizations
|
||||
|
||||
#### List Organizations
|
||||
|
||||
**Endpoint:** `GET /api/v1/mobile/organizations`
|
||||
|
||||
Returns all organizations the authenticated user has access to.
|
||||
|
||||
**Response (200 OK):**
|
||||
```json
|
||||
{
|
||||
"organizations": [
|
||||
{
|
||||
"id": "org-uuid",
|
||||
"name": "Acme Corp",
|
||||
"sites_count": 3,
|
||||
"equipment_count": 15,
|
||||
"active_alerts_count": 2
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Sites
|
||||
|
||||
#### List Sites
|
||||
|
||||
**Endpoint:** `GET /api/v1/mobile/organizations/:organization_id/sites`
|
||||
|
||||
Returns all sites for an organization.
|
||||
|
||||
**Response (200 OK):**
|
||||
```json
|
||||
{
|
||||
"sites": [
|
||||
{
|
||||
"id": "site-uuid",
|
||||
"name": "Main Office",
|
||||
"location": "New York, NY",
|
||||
"equipment_count": 5,
|
||||
"equipment_down_count": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Response (403 Forbidden):**
|
||||
```json
|
||||
{
|
||||
"error": "Access denied to this organization"
|
||||
}
|
||||
```
|
||||
|
||||
### Equipment
|
||||
|
||||
#### List Equipment
|
||||
|
||||
**Endpoint:** `GET /api/v1/mobile/organizations/:organization_id/equipment`
|
||||
|
||||
Returns all equipment for an organization with current status.
|
||||
|
||||
**Query Parameters:**
|
||||
- `site_id` (optional): Filter by specific site UUID
|
||||
- `status` (optional): Filter by status (`up`, `down`, `unknown`)
|
||||
|
||||
**Example:**
|
||||
```
|
||||
GET /api/v1/mobile/organizations/org-123/equipment?status=down&site_id=site-456
|
||||
```
|
||||
|
||||
**Response (200 OK):**
|
||||
```json
|
||||
{
|
||||
"equipment": [
|
||||
{
|
||||
"id": "equipment-uuid",
|
||||
"name": "Core Router",
|
||||
"ip_address": "192.168.1.1",
|
||||
"site_name": "Main Office",
|
||||
"status": "up",
|
||||
"uptime": "15d 4h",
|
||||
"last_seen_at": "2026-01-15T19:44:25Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### Get Equipment Details
|
||||
|
||||
**Endpoint:** `GET /api/v1/mobile/equipment/:id`
|
||||
|
||||
Returns detailed equipment information including interfaces and sensors.
|
||||
|
||||
**Response (200 OK):**
|
||||
```json
|
||||
{
|
||||
"id": "equipment-uuid",
|
||||
"name": "Core Router",
|
||||
"ip_address": "192.168.1.1",
|
||||
"status": "up",
|
||||
"uptime": "15d 4h",
|
||||
"site": {
|
||||
"id": "site-uuid",
|
||||
"name": "Main Office"
|
||||
},
|
||||
"interfaces": [
|
||||
{
|
||||
"id": "interface-uuid",
|
||||
"name": "GigabitEthernet0/0",
|
||||
"alias": "WAN",
|
||||
"status": "up",
|
||||
"admin_status": "up",
|
||||
"speed": 1000000000,
|
||||
"mac_address": "00:1A:2B:3C:4D:5E"
|
||||
}
|
||||
],
|
||||
"sensors": [
|
||||
{
|
||||
"id": "sensor-uuid",
|
||||
"name": "CPU Usage",
|
||||
"type": "cpu",
|
||||
"unit": "%",
|
||||
"current_value": 45.5,
|
||||
"status": "ok"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Response (404 Not Found):**
|
||||
```json
|
||||
{
|
||||
"error": "Equipment not found"
|
||||
}
|
||||
```
|
||||
|
||||
**Response (403 Forbidden):**
|
||||
```json
|
||||
{
|
||||
"error": "Access denied to this equipment"
|
||||
}
|
||||
```
|
||||
|
||||
### Alerts
|
||||
|
||||
#### List Alerts
|
||||
|
||||
**Endpoint:** `GET /api/v1/mobile/organizations/:organization_id/alerts`
|
||||
|
||||
Returns alerts for an organization.
|
||||
|
||||
**Query Parameters:**
|
||||
- `severity` (optional): Filter by severity (`critical`, `warning`, `info`)
|
||||
- `status` (optional): Filter by status (`active`, `acknowledged`, `resolved`)
|
||||
- `limit` (optional): Number of alerts to return (default 50, max 200)
|
||||
|
||||
**Example:**
|
||||
```
|
||||
GET /api/v1/mobile/organizations/org-123/alerts?severity=critical&status=active&limit=100
|
||||
```
|
||||
|
||||
**Response (200 OK):**
|
||||
```json
|
||||
{
|
||||
"alerts": [
|
||||
{
|
||||
"id": "alert-uuid",
|
||||
"severity": "critical",
|
||||
"status": "active",
|
||||
"message": "Equipment Down: Core Router",
|
||||
"equipment_name": "Core Router",
|
||||
"equipment_id": "equipment-uuid",
|
||||
"occurred_at": "2026-01-15T19:44:25Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Alert Severity Levels:**
|
||||
- `critical`: Requires immediate attention (equipment down, critical sensor thresholds)
|
||||
- `warning`: Requires attention (sensor warnings, interface changes)
|
||||
- `info`: Informational (equipment up, sensor recovery)
|
||||
|
||||
**Alert Status Values:**
|
||||
- `active`: Alert is active and unacknowledged
|
||||
- `acknowledged`: Alert has been acknowledged but not resolved
|
||||
- `resolved`: Alert has been resolved
|
||||
|
||||
## Error Handling
|
||||
|
||||
All API endpoints follow consistent error response formats.
|
||||
|
||||
### HTTP Status Codes
|
||||
|
||||
- `200 OK`: Request succeeded
|
||||
- `400 Bad Request`: Invalid request parameters
|
||||
- `401 Unauthorized`: Missing or invalid authentication token
|
||||
- `403 Forbidden`: Authenticated but not authorized for this resource
|
||||
- `404 Not Found`: Resource not found
|
||||
- `422 Unprocessable Entity`: Validation errors
|
||||
- `500 Internal Server Error`: Server error
|
||||
|
||||
### Error Response Format
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "Human-readable error message"
|
||||
}
|
||||
```
|
||||
|
||||
For validation errors (422):
|
||||
```json
|
||||
{
|
||||
"error": "Failed to create session",
|
||||
"details": {
|
||||
"field_name": ["error message"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
The API does not currently implement rate limiting, but it may be added in future versions. Implement exponential backoff in your client for failed requests.
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Token Storage
|
||||
|
||||
- Store session tokens securely in the device keychain (iOS) or keystore (Android)
|
||||
- Never log or expose tokens in plaintext
|
||||
- Clear tokens on logout
|
||||
|
||||
### Session Management
|
||||
|
||||
- Check session expiration before making requests
|
||||
- Implement automatic token refresh or re-authentication when sessions expire
|
||||
- Handle 401 responses by prompting for re-authentication
|
||||
|
||||
### Network Requests
|
||||
|
||||
- Implement timeout handling (30 seconds recommended)
|
||||
- Use exponential backoff for retries
|
||||
- Cache responses appropriately (organizations list, equipment details)
|
||||
- Update last_used_at is automatic - no need to ping
|
||||
|
||||
### Error Handling
|
||||
|
||||
- Display user-friendly error messages
|
||||
- Log errors for debugging
|
||||
- Handle network connectivity issues gracefully
|
||||
- Implement offline mode where appropriate
|
||||
|
||||
## Example Client Implementation
|
||||
|
||||
### Swift (iOS)
|
||||
|
||||
```swift
|
||||
import Foundation
|
||||
|
||||
class ToweropsAPI {
|
||||
private let baseURL = "https://your-instance.com/api/v1/mobile"
|
||||
private var sessionToken: String?
|
||||
|
||||
// Authenticate with QR token
|
||||
func completeQRLogin(token: String, deviceInfo: DeviceInfo) async throws -> SessionToken {
|
||||
let url = URL(string: "\(baseURL)/auth/qr/complete")!
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
|
||||
let body = [
|
||||
"token": token,
|
||||
"device_name": deviceInfo.name,
|
||||
"device_os": deviceInfo.os,
|
||||
"app_version": deviceInfo.appVersion
|
||||
]
|
||||
request.httpBody = try JSONEncoder().encode(body)
|
||||
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse,
|
||||
httpResponse.statusCode == 200 else {
|
||||
throw APIError.authenticationFailed
|
||||
}
|
||||
|
||||
let result = try JSONDecoder().decode(SessionToken.self, from: data)
|
||||
self.sessionToken = result.session_token
|
||||
return result
|
||||
}
|
||||
|
||||
// Fetch organizations
|
||||
func fetchOrganizations() async throws -> [Organization] {
|
||||
guard let token = sessionToken else {
|
||||
throw APIError.notAuthenticated
|
||||
}
|
||||
|
||||
let url = URL(string: "\(baseURL)/organizations")!
|
||||
var request = URLRequest(url: url)
|
||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse,
|
||||
httpResponse.statusCode == 200 else {
|
||||
throw APIError.requestFailed
|
||||
}
|
||||
|
||||
let result = try JSONDecoder().decode(OrganizationsResponse.self, from: data)
|
||||
return result.organizations
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Kotlin (Android)
|
||||
|
||||
```kotlin
|
||||
import okhttp3.*
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
class ToweropsAPI(private val baseURL: String = "https://your-instance.com/api/v1/mobile") {
|
||||
private val client = OkHttpClient()
|
||||
private var sessionToken: String? = null
|
||||
|
||||
// Authenticate with QR token
|
||||
suspend fun completeQRLogin(token: String, deviceInfo: DeviceInfo): SessionToken {
|
||||
val json = """
|
||||
{
|
||||
"token": "$token",
|
||||
"device_name": "${deviceInfo.name}",
|
||||
"device_os": "${deviceInfo.os}",
|
||||
"app_version": "${deviceInfo.appVersion}"
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val request = Request.Builder()
|
||||
.url("$baseURL/auth/qr/complete")
|
||||
.post(RequestBody.create(MediaType.parse("application/json"), json))
|
||||
.build()
|
||||
|
||||
val response = client.newCall(request).execute()
|
||||
if (!response.isSuccessful) {
|
||||
throw APIException("Authentication failed")
|
||||
}
|
||||
|
||||
val result = Json.decodeFromString<SessionToken>(response.body()!!.string())
|
||||
sessionToken = result.session_token
|
||||
return result
|
||||
}
|
||||
|
||||
// Fetch organizations
|
||||
suspend fun fetchOrganizations(): List<Organization> {
|
||||
val token = sessionToken ?: throw APIException("Not authenticated")
|
||||
|
||||
val request = Request.Builder()
|
||||
.url("$baseURL/organizations")
|
||||
.header("Authorization", "Bearer $token")
|
||||
.build()
|
||||
|
||||
val response = client.newCall(request).execute()
|
||||
if (!response.isSuccessful) {
|
||||
throw APIException("Request failed")
|
||||
}
|
||||
|
||||
val result = Json.decodeFromString<OrganizationsResponse>(response.body()!!.string())
|
||||
return result.organizations
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Support
|
||||
|
||||
For API support, bug reports, or feature requests:
|
||||
- GitHub Issues: https://github.com/yourusername/towerops
|
||||
- Documentation: https://docs.towerops.net
|
||||
|
||||
## Changelog
|
||||
|
||||
### Version 1.0 (2026-01-15)
|
||||
- Initial release
|
||||
- QR code authentication
|
||||
- Basic data endpoints (organizations, sites, equipment, alerts)
|
||||
- Session management
|
||||
|
|
@ -134,7 +134,19 @@ if config_env() == :prod do
|
|||
# Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access.
|
||||
# See the documentation on https://hexdocs.pm/bandit/Bandit.html#t:options/0
|
||||
# for details about using IPv6 vs IPv4 and loopback vs public addresses.
|
||||
ip: {0, 0, 0, 0, 0, 0, 0, 0}
|
||||
ip: {0, 0, 0, 0, 0, 0, 0, 0},
|
||||
# Bandit HTTP/1 configuration
|
||||
http_1_options: [
|
||||
# Timeout for reading request headers and body (30 seconds)
|
||||
read_timeout: 30_000,
|
||||
# Timeout for sending response (30 seconds)
|
||||
write_timeout: 30_000
|
||||
],
|
||||
# Bandit HTTP/2 configuration
|
||||
http_2_options: [
|
||||
# Timeout for stream idle (60 seconds)
|
||||
stream_idle_timeout: 60_000
|
||||
]
|
||||
],
|
||||
secret_key_base: secret_key_base
|
||||
|
||||
|
|
|
|||
|
|
@ -50,10 +50,39 @@ defmodule Towerops.Alerts do
|
|||
end
|
||||
|
||||
@doc """
|
||||
Returns the list of all alerts for an organization.
|
||||
Returns the count of active (unresolved) alerts for an organization.
|
||||
"""
|
||||
def list_organization_alerts(organization_id, limit \\ 100) do
|
||||
Repo.all(
|
||||
def count_active_alerts(organization_id) do
|
||||
Repo.aggregate(
|
||||
from(a in Alert,
|
||||
join: e in assoc(a, :equipment),
|
||||
join: s in assoc(e, :site),
|
||||
where: s.organization_id == ^organization_id,
|
||||
where: a.alert_type == :equipment_down,
|
||||
where: is_nil(a.resolved_at)
|
||||
),
|
||||
:count
|
||||
)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the list of all alerts for an organization.
|
||||
|
||||
Accepts either an integer limit or a map of filters:
|
||||
- limit (integer): Max number of alerts to return
|
||||
- filters (map):
|
||||
- severity: Filter by severity ("critical", "warning", "info")
|
||||
- status: Filter by status ("active", "acknowledged", "resolved")
|
||||
- limit: Max number of alerts to return
|
||||
"""
|
||||
def list_organization_alerts(organization_id, limit) when is_integer(limit) do
|
||||
list_organization_alerts(organization_id, %{"limit" => limit})
|
||||
end
|
||||
|
||||
def list_organization_alerts(organization_id, filters) when is_map(filters) do
|
||||
limit = filters["limit"] || 100
|
||||
|
||||
query =
|
||||
from(a in Alert,
|
||||
join: e in assoc(a, :equipment),
|
||||
join: s in assoc(e, :site),
|
||||
|
|
@ -62,10 +91,36 @@ defmodule Towerops.Alerts do
|
|||
limit: ^limit,
|
||||
preload: [equipment: {e, site: s}, acknowledged_by: []]
|
||||
)
|
||||
)
|
||||
|
||||
query =
|
||||
if severity = filters["severity"] do
|
||||
where(query, [a], a.severity == ^severity)
|
||||
else
|
||||
query
|
||||
end
|
||||
|
||||
query =
|
||||
case filters["status"] do
|
||||
"active" -> where(query, [a], is_nil(a.resolved_at) and is_nil(a.acknowledged_at))
|
||||
"acknowledged" -> where(query, [a], is_nil(a.resolved_at) and not is_nil(a.acknowledged_at))
|
||||
"resolved" -> where(query, [a], not is_nil(a.resolved_at))
|
||||
_ -> query
|
||||
end
|
||||
|
||||
Repo.all(query)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a single alert.
|
||||
where: s.organization_id == ^organization_id,
|
||||
order_by: [desc: a.triggered_at],
|
||||
limit: ^limit,
|
||||
preload: [equipment: {e, site: s}, acknowledged_by: []]
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
@doc \"""
|
||||
Gets a single alert.
|
||||
"""
|
||||
def get_alert!(id) do
|
||||
|
|
|
|||
|
|
@ -18,15 +18,67 @@ defmodule Towerops.Equipment do
|
|||
|
||||
@doc """
|
||||
Returns the list of all equipment for an organization (via sites).
|
||||
|
||||
Supports filtering by:
|
||||
- site_id: Filter by specific site
|
||||
- status: Filter by status ("up", "down", "unknown")
|
||||
"""
|
||||
def list_organization_equipment(organization_id) do
|
||||
Repo.all(
|
||||
def list_organization_equipment(organization_id, filters \\ %{}) do
|
||||
query =
|
||||
from(e in EquipmentSchema,
|
||||
join: s in assoc(e, :site),
|
||||
where: s.organization_id == ^organization_id,
|
||||
order_by: [asc: e.name],
|
||||
preload: [site: s]
|
||||
)
|
||||
|
||||
query =
|
||||
if site_id = filters["site_id"] do
|
||||
where(query, [e], e.site_id == ^site_id)
|
||||
else
|
||||
query
|
||||
end
|
||||
|
||||
query =
|
||||
if status = filters["status"] do
|
||||
where(query, [e], e.status == ^status)
|
||||
else
|
||||
query
|
||||
end
|
||||
|
||||
Repo.all(query)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the count of equipment for an organization.
|
||||
"""
|
||||
def count_organization_equipment(organization_id) do
|
||||
Repo.aggregate(
|
||||
from(e in EquipmentSchema,
|
||||
join: s in assoc(e, :site),
|
||||
where: s.organization_id == ^organization_id
|
||||
),
|
||||
:count
|
||||
)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the count of equipment for a site.
|
||||
"""
|
||||
def count_site_equipment(site_id) do
|
||||
Repo.aggregate(
|
||||
from(e in EquipmentSchema, where: e.site_id == ^site_id),
|
||||
:count
|
||||
)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the count of equipment that is down for a site.
|
||||
"""
|
||||
def count_site_equipment_down(site_id) do
|
||||
Repo.aggregate(
|
||||
from(e in EquipmentSchema, where: e.site_id == ^site_id and e.status == "down"),
|
||||
:count
|
||||
)
|
||||
end
|
||||
|
||||
|
|
@ -63,6 +115,21 @@ defmodule Towerops.Equipment do
|
|||
|> Repo.preload([:site])
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets equipment with full details including SNMP device, interfaces, and sensors.
|
||||
"""
|
||||
def get_equipment_with_details(id) do
|
||||
EquipmentSchema
|
||||
|> Repo.get(id)
|
||||
|> case do
|
||||
nil ->
|
||||
nil
|
||||
|
||||
equipment ->
|
||||
Repo.preload(equipment, [:site, snmp_device: [:interfaces, :sensors]])
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a single equipment belonging to a specific site.
|
||||
"""
|
||||
|
|
|
|||
227
lib/towerops/mobile_sessions.ex
Normal file
227
lib/towerops/mobile_sessions.ex
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
defmodule Towerops.MobileSessions do
|
||||
@moduledoc """
|
||||
Context for managing mobile app authentication sessions.
|
||||
"""
|
||||
|
||||
import Ecto.Query
|
||||
|
||||
alias Towerops.MobileSessions.MobileSession
|
||||
alias Towerops.MobileSessions.QRLoginToken
|
||||
alias Towerops.Repo
|
||||
|
||||
@doc """
|
||||
Creates a new mobile session for a user.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> create_mobile_session(%{user_id: user.id, device_name: "iPhone 15"})
|
||||
{:ok, %MobileSession{}}
|
||||
"""
|
||||
def create_mobile_session(attrs) do
|
||||
%MobileSession{}
|
||||
|> MobileSession.create_changeset(attrs)
|
||||
|> Repo.insert()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a mobile session by token.
|
||||
|
||||
Returns nil if the session is expired or doesn't exist.
|
||||
"""
|
||||
def get_session_by_token(token) when is_binary(token) do
|
||||
now = DateTime.utc_now()
|
||||
|
||||
MobileSession
|
||||
|> where([s], s.token == ^token)
|
||||
|> where([s], s.expires_at > ^now)
|
||||
|> Repo.one()
|
||||
end
|
||||
|
||||
def get_session_by_token(_), do: nil
|
||||
|
||||
@doc """
|
||||
Updates the last_used_at timestamp for a mobile session.
|
||||
"""
|
||||
def touch_session(%MobileSession{} = session) do
|
||||
session
|
||||
|> MobileSession.touch_changeset()
|
||||
|> Repo.update()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Lists all active mobile sessions for a user.
|
||||
"""
|
||||
def list_user_sessions(user_id) do
|
||||
now = DateTime.utc_now()
|
||||
|
||||
MobileSession
|
||||
|> where([s], s.user_id == ^user_id)
|
||||
|> where([s], s.expires_at > ^now)
|
||||
|> order_by([s], desc: s.last_used_at)
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Revokes a mobile session by ID.
|
||||
"""
|
||||
def revoke_session(session_id) do
|
||||
case Repo.get(MobileSession, session_id) do
|
||||
nil -> {:error, :not_found}
|
||||
session -> Repo.delete(session)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates alert preferences for a mobile session.
|
||||
"""
|
||||
def update_alert_preferences(session_id, attrs) do
|
||||
case Repo.get(MobileSession, session_id) do
|
||||
nil ->
|
||||
{:error, :not_found}
|
||||
|
||||
session ->
|
||||
session
|
||||
|> Ecto.Changeset.cast(attrs, [:alerts_enabled, :push_token, :push_platform])
|
||||
|> Repo.update()
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Lists all devices with alerts enabled for a user.
|
||||
Used for sending push notifications.
|
||||
"""
|
||||
def list_alert_enabled_devices(user_id) do
|
||||
now = DateTime.utc_now()
|
||||
|
||||
MobileSession
|
||||
|> where([s], s.user_id == ^user_id)
|
||||
|> where([s], s.alerts_enabled == true)
|
||||
|> where([s], s.expires_at > ^now)
|
||||
|> where([s], not is_nil(s.push_token))
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Revokes all mobile sessions for a user.
|
||||
"""
|
||||
def revoke_all_user_sessions(user_id) do
|
||||
{count, _} =
|
||||
MobileSession
|
||||
|> where([s], s.user_id == ^user_id)
|
||||
|> Repo.delete_all()
|
||||
|
||||
{:ok, count}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Deletes expired mobile sessions.
|
||||
|
||||
Returns the number of sessions deleted.
|
||||
"""
|
||||
def delete_expired_sessions do
|
||||
now = DateTime.utc_now()
|
||||
|
||||
{count, _} =
|
||||
MobileSession
|
||||
|> where([s], s.expires_at <= ^now)
|
||||
|> Repo.delete_all()
|
||||
|
||||
count
|
||||
end
|
||||
|
||||
# QR Login Token functions
|
||||
|
||||
@doc """
|
||||
Creates a new QR login token for a user.
|
||||
|
||||
Token expires in 5 minutes.
|
||||
"""
|
||||
def create_qr_login_token(user_id) do
|
||||
%QRLoginToken{}
|
||||
|> QRLoginToken.create_changeset(%{user_id: user_id})
|
||||
|> Repo.insert()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a QR login token by token string.
|
||||
|
||||
Returns nil if the token is expired, already used, or doesn't exist.
|
||||
"""
|
||||
def get_qr_login_token(token) when is_binary(token) do
|
||||
now = DateTime.utc_now()
|
||||
|
||||
QRLoginToken
|
||||
|> where([t], t.token == ^token)
|
||||
|> where([t], t.expires_at > ^now)
|
||||
|> where([t], is_nil(t.completed_at))
|
||||
|> Repo.one()
|
||||
end
|
||||
|
||||
def get_qr_login_token(_), do: nil
|
||||
|
||||
@doc """
|
||||
Completes a QR login token by creating a mobile session.
|
||||
|
||||
Returns {:ok, mobile_session} on success.
|
||||
"""
|
||||
def complete_qr_login(token, device_attrs) do
|
||||
Repo.transaction(fn ->
|
||||
case get_qr_login_token(token) do
|
||||
nil ->
|
||||
Repo.rollback(:invalid_token)
|
||||
|
||||
qr_token ->
|
||||
# Create mobile session
|
||||
session_attrs = Map.put(device_attrs, :user_id, qr_token.user_id)
|
||||
|
||||
case create_mobile_session(session_attrs) do
|
||||
{:ok, session} ->
|
||||
# Mark QR token as completed
|
||||
qr_token
|
||||
|> QRLoginToken.complete_changeset(session.id)
|
||||
|> Repo.update!()
|
||||
|
||||
session
|
||||
|
||||
{:error, changeset} ->
|
||||
Repo.rollback(changeset)
|
||||
end
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Deletes expired QR login tokens.
|
||||
|
||||
Returns the number of tokens deleted.
|
||||
"""
|
||||
def delete_expired_qr_tokens do
|
||||
now = DateTime.utc_now()
|
||||
|
||||
{count, _} =
|
||||
QRLoginToken
|
||||
|> where([t], t.expires_at <= ^now)
|
||||
|> Repo.delete_all()
|
||||
|
||||
count
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if a QR login token has been completed.
|
||||
|
||||
Returns the mobile session if completed, nil otherwise.
|
||||
"""
|
||||
def check_qr_login_completed(token) when is_binary(token) do
|
||||
QRLoginToken
|
||||
|> where([t], t.token == ^token)
|
||||
|> where([t], not is_nil(t.completed_at))
|
||||
|> preload(:mobile_session)
|
||||
|> Repo.one()
|
||||
|> case do
|
||||
%QRLoginToken{mobile_session: session} when not is_nil(session) -> session
|
||||
_ -> nil
|
||||
end
|
||||
end
|
||||
|
||||
def check_qr_login_completed(_), do: nil
|
||||
end
|
||||
93
lib/towerops/mobile_sessions/mobile_session.ex
Normal file
93
lib/towerops/mobile_sessions/mobile_session.ex
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
defmodule Towerops.MobileSessions.MobileSession do
|
||||
@moduledoc """
|
||||
Schema for mobile app sessions.
|
||||
|
||||
Long-lived authentication tokens for mobile apps. Users can manage
|
||||
their active mobile sessions from the web interface.
|
||||
"""
|
||||
use Ecto.Schema
|
||||
|
||||
import Ecto.Changeset
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
@foreign_key_type :binary_id
|
||||
|
||||
schema "mobile_sessions" do
|
||||
field :token, :string
|
||||
field :device_name, :string
|
||||
field :device_os, :string
|
||||
field :app_version, :string
|
||||
field :last_used_at, :utc_datetime
|
||||
field :expires_at, :utc_datetime
|
||||
field :alerts_enabled, :boolean, default: true
|
||||
field :push_token, :string
|
||||
field :push_platform, :string
|
||||
|
||||
belongs_to :user, Towerops.Accounts.User
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Creates a changeset for a new mobile session.
|
||||
|
||||
Token is automatically generated if not provided.
|
||||
Default expiration is 90 days from now.
|
||||
"""
|
||||
def create_changeset(mobile_session, attrs) do
|
||||
mobile_session
|
||||
|> cast(attrs, [
|
||||
:user_id,
|
||||
:device_name,
|
||||
:device_os,
|
||||
:app_version,
|
||||
:token,
|
||||
:expires_at,
|
||||
:push_token,
|
||||
:push_platform,
|
||||
:alerts_enabled
|
||||
])
|
||||
|> validate_required([:user_id])
|
||||
|> validate_inclusion(:push_platform, ["apns", "fcm", nil])
|
||||
|> put_token()
|
||||
|> put_timestamps()
|
||||
|> unique_constraint(:token)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates the last_used_at timestamp for a session.
|
||||
"""
|
||||
def touch_changeset(mobile_session) do
|
||||
change(mobile_session, last_used_at: DateTime.utc_now())
|
||||
end
|
||||
|
||||
defp put_token(changeset) do
|
||||
if get_field(changeset, :token) do
|
||||
changeset
|
||||
else
|
||||
put_change(changeset, :token, generate_token())
|
||||
end
|
||||
end
|
||||
|
||||
defp put_timestamps(changeset) do
|
||||
now = DateTime.utc_now()
|
||||
default_expiration = DateTime.add(now, 90, :day)
|
||||
|
||||
changeset
|
||||
|> put_change(:last_used_at, now)
|
||||
|> put_default_expiration(default_expiration)
|
||||
end
|
||||
|
||||
defp put_default_expiration(changeset, default) do
|
||||
if get_field(changeset, :expires_at) do
|
||||
changeset
|
||||
else
|
||||
put_change(changeset, :expires_at, default)
|
||||
end
|
||||
end
|
||||
|
||||
# Generate a secure random token (64 bytes = 512 bits)
|
||||
defp generate_token do
|
||||
64 |> :crypto.strong_rand_bytes() |> Base.url_encode64(padding: false)
|
||||
end
|
||||
end
|
||||
69
lib/towerops/mobile_sessions/qr_login_token.ex
Normal file
69
lib/towerops/mobile_sessions/qr_login_token.ex
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
defmodule Towerops.MobileSessions.QRLoginToken do
|
||||
@moduledoc """
|
||||
Schema for QR code login tokens.
|
||||
|
||||
These are short-lived tokens (5 minutes) that are displayed as QR codes
|
||||
in the web interface. Mobile apps scan the QR code and use the token
|
||||
to create a new mobile session.
|
||||
"""
|
||||
use Ecto.Schema
|
||||
|
||||
import Ecto.Changeset
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
@foreign_key_type :binary_id
|
||||
|
||||
schema "qr_login_tokens" do
|
||||
field :token, :string
|
||||
field :expires_at, :utc_datetime
|
||||
field :completed_at, :utc_datetime
|
||||
|
||||
belongs_to :user, Towerops.Accounts.User
|
||||
belongs_to :mobile_session, Towerops.MobileSessions.MobileSession
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Creates a changeset for a new QR login token.
|
||||
|
||||
Token is automatically generated and expires in 5 minutes.
|
||||
"""
|
||||
def create_changeset(qr_login_token, attrs) do
|
||||
qr_login_token
|
||||
|> cast(attrs, [:user_id, :token, :expires_at])
|
||||
|> validate_required([:user_id])
|
||||
|> put_token()
|
||||
|> put_expiration()
|
||||
|> unique_constraint(:token)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Marks a QR login token as completed.
|
||||
"""
|
||||
def complete_changeset(qr_login_token, mobile_session_id) do
|
||||
change(qr_login_token, completed_at: DateTime.utc_now(), mobile_session_id: mobile_session_id)
|
||||
end
|
||||
|
||||
defp put_token(changeset) do
|
||||
if get_field(changeset, :token) do
|
||||
changeset
|
||||
else
|
||||
put_change(changeset, :token, generate_token())
|
||||
end
|
||||
end
|
||||
|
||||
defp put_expiration(changeset) do
|
||||
if get_field(changeset, :expires_at) do
|
||||
changeset
|
||||
else
|
||||
expires_at = DateTime.add(DateTime.utc_now(), 5, :minute)
|
||||
put_change(changeset, :expires_at, expires_at)
|
||||
end
|
||||
end
|
||||
|
||||
# Generate a shorter token for QR codes (32 bytes = 256 bits)
|
||||
defp generate_token do
|
||||
32 |> :crypto.strong_rand_bytes() |> Base.url_encode64(padding: false)
|
||||
end
|
||||
end
|
||||
|
|
@ -28,6 +28,16 @@ defmodule Towerops.Organizations do
|
|||
)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if a user has access to an organization.
|
||||
"""
|
||||
def user_has_access?(user_id, organization_id) do
|
||||
Repo.exists?(
|
||||
from m in Membership,
|
||||
where: m.user_id == ^user_id and m.organization_id == ^organization_id
|
||||
)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a single organization by ID.
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -17,6 +17,16 @@ defmodule Towerops.Sites do
|
|||
)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the count of sites for an organization.
|
||||
"""
|
||||
def count_organization_sites(organization_id) do
|
||||
Repo.aggregate(
|
||||
from(s in Site, where: s.organization_id == ^organization_id),
|
||||
:count
|
||||
)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the list of root sites (sites without a parent) for an organization.
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -90,9 +90,20 @@ defmodule ToweropsWeb.Api.AgentController do
|
|||
Supports both JSON and Protocol Buffers formats.
|
||||
"""
|
||||
def heartbeat(conn, params) do
|
||||
require Logger
|
||||
|
||||
agent_token = conn.assigns.current_agent_token
|
||||
ip = get_client_ip(conn)
|
||||
|
||||
# Log heartbeat request for debugging intermittent 502 errors
|
||||
Logger.debug(
|
||||
"Heartbeat received from agent",
|
||||
agent_token_id: agent_token.id,
|
||||
client_ip: ip,
|
||||
content_type: List.first(get_req_header(conn, "content-type")),
|
||||
request_id: conn.assigns[:request_id]
|
||||
)
|
||||
|
||||
# Parse metadata based on content type
|
||||
metadata =
|
||||
case get_req_header(conn, "content-type") do
|
||||
|
|
@ -139,7 +150,17 @@ defmodule ToweropsWeb.Api.AgentController do
|
|||
Agents.update_agent_token_heartbeat(agent_token.id, ip, metadata)
|
||||
else
|
||||
Task.start(fn ->
|
||||
Agents.update_agent_token_heartbeat(agent_token.id, ip, metadata)
|
||||
try do
|
||||
Agents.update_agent_token_heartbeat(agent_token.id, ip, metadata)
|
||||
rescue
|
||||
e ->
|
||||
Logger.error(
|
||||
"Failed to update agent heartbeat",
|
||||
agent_token_id: agent_token.id,
|
||||
error: inspect(e),
|
||||
stacktrace: Exception.format_stacktrace(__STACKTRACE__)
|
||||
)
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
|
|
|
|||
184
lib/towerops_web/controllers/api/mobile_auth_controller.ex
Normal file
184
lib/towerops_web/controllers/api/mobile_auth_controller.ex
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
defmodule ToweropsWeb.Api.MobileAuthController do
|
||||
@moduledoc """
|
||||
API controller for mobile app authentication.
|
||||
|
||||
Handles QR code-based authentication flow:
|
||||
1. Web app creates QR token (handled by LiveView)
|
||||
2. Mobile app scans QR code and calls verify_qr_token
|
||||
3. Mobile app calls complete_qr_login to get session token
|
||||
"""
|
||||
use ToweropsWeb, :controller
|
||||
|
||||
alias Towerops.MobileSessions
|
||||
|
||||
@doc """
|
||||
POST /api/v1/mobile/auth/qr/verify
|
||||
|
||||
Verifies a QR login token is valid (not expired, not used).
|
||||
|
||||
Request body:
|
||||
{
|
||||
"token": "base64-encoded-token"
|
||||
}
|
||||
|
||||
Response:
|
||||
{
|
||||
"valid": true,
|
||||
"user_email": "user@example.com"
|
||||
}
|
||||
"""
|
||||
def verify_qr_token(conn, %{"token" => token}) do
|
||||
case MobileSessions.get_qr_login_token(token) do
|
||||
nil ->
|
||||
conn
|
||||
|> put_status(:unauthorized)
|
||||
|> json(%{valid: false, error: "Invalid or expired token"})
|
||||
|
||||
qr_token ->
|
||||
# Preload user to return email
|
||||
qr_token = Towerops.Repo.preload(qr_token, :user)
|
||||
|
||||
json(conn, %{
|
||||
valid: true,
|
||||
user_email: qr_token.user.email
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
def verify_qr_token(conn, _params) do
|
||||
conn
|
||||
|> put_status(:bad_request)
|
||||
|> json(%{error: "Missing token parameter"})
|
||||
end
|
||||
|
||||
@doc """
|
||||
POST /api/v1/mobile/auth/qr/complete
|
||||
|
||||
Completes QR login by creating a mobile session.
|
||||
|
||||
Request body:
|
||||
{
|
||||
"token": "base64-encoded-qr-token",
|
||||
"device_name": "iPhone 15 Pro",
|
||||
"device_os": "iOS 17.2",
|
||||
"app_version": "1.0.0"
|
||||
}
|
||||
|
||||
Response:
|
||||
{
|
||||
"session_token": "long-lived-session-token",
|
||||
"expires_at": "2026-04-15T19:44:25Z",
|
||||
"user": {
|
||||
"id": "uuid",
|
||||
"email": "user@example.com"
|
||||
}
|
||||
}
|
||||
"""
|
||||
def complete_qr_login(conn, params) do
|
||||
token = params["token"]
|
||||
device_name = params["device_name"]
|
||||
device_os = params["device_os"]
|
||||
app_version = params["app_version"]
|
||||
push_token = params["push_token"]
|
||||
push_platform = params["push_platform"]
|
||||
|
||||
if token do
|
||||
device_attrs = %{
|
||||
device_name: device_name,
|
||||
device_os: device_os,
|
||||
app_version: app_version,
|
||||
push_token: push_token,
|
||||
push_platform: push_platform
|
||||
}
|
||||
|
||||
case MobileSessions.complete_qr_login(token, device_attrs) do
|
||||
{:ok, session} ->
|
||||
session = Towerops.Repo.preload(session, :user)
|
||||
|
||||
json(conn, %{
|
||||
session_token: session.token,
|
||||
expires_at: session.expires_at,
|
||||
user: %{
|
||||
id: session.user.id,
|
||||
email: session.user.email
|
||||
}
|
||||
})
|
||||
|
||||
{:error, :invalid_token} ->
|
||||
conn
|
||||
|> put_status(:unauthorized)
|
||||
|> json(%{error: "Invalid or expired token"})
|
||||
|
||||
{:error, changeset} ->
|
||||
conn
|
||||
|> put_status(:unprocessable_entity)
|
||||
|> json(%{error: "Failed to create session", details: translate_errors(changeset)})
|
||||
end
|
||||
else
|
||||
conn
|
||||
|> put_status(:bad_request)
|
||||
|> json(%{error: "Missing token parameter"})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
GET /api/v1/mobile/auth/session
|
||||
|
||||
Returns current session info (requires mobile auth).
|
||||
|
||||
Response:
|
||||
{
|
||||
"id": "session-uuid",
|
||||
"device_name": "iPhone 15 Pro",
|
||||
"device_os": "iOS 17.2",
|
||||
"app_version": "1.0.0",
|
||||
"last_used_at": "2026-01-15T19:44:25Z",
|
||||
"expires_at": "2026-04-15T19:44:25Z"
|
||||
}
|
||||
"""
|
||||
def get_session(conn, _params) do
|
||||
session = conn.assigns.current_mobile_session
|
||||
|
||||
json(conn, %{
|
||||
id: session.id,
|
||||
device_name: session.device_name,
|
||||
device_os: session.device_os,
|
||||
app_version: session.app_version,
|
||||
last_used_at: session.last_used_at,
|
||||
expires_at: session.expires_at
|
||||
})
|
||||
end
|
||||
|
||||
@doc """
|
||||
DELETE /api/v1/mobile/auth/session
|
||||
|
||||
Revokes the current mobile session (logout).
|
||||
|
||||
Response:
|
||||
{
|
||||
"success": true
|
||||
}
|
||||
"""
|
||||
def revoke_session(conn, _params) do
|
||||
session = conn.assigns.current_mobile_session
|
||||
|
||||
case MobileSessions.revoke_session(session.id) do
|
||||
{:ok, _} ->
|
||||
json(conn, %{success: true})
|
||||
|
||||
{:error, _} ->
|
||||
conn
|
||||
|> put_status(:internal_server_error)
|
||||
|> json(%{error: "Failed to revoke session"})
|
||||
end
|
||||
end
|
||||
|
||||
# Helper to translate changeset errors
|
||||
defp translate_errors(changeset) do
|
||||
Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} ->
|
||||
Regex.replace(~r"%{(\w+)}", msg, fn _, key ->
|
||||
opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string()
|
||||
end)
|
||||
end)
|
||||
end
|
||||
end
|
||||
313
lib/towerops_web/controllers/api/mobile_controller.ex
Normal file
313
lib/towerops_web/controllers/api/mobile_controller.ex
Normal file
|
|
@ -0,0 +1,313 @@
|
|||
defmodule ToweropsWeb.Api.MobileController do
|
||||
@moduledoc """
|
||||
API controller for mobile app data access.
|
||||
|
||||
All endpoints require mobile authentication via bearer token.
|
||||
"""
|
||||
use ToweropsWeb, :controller
|
||||
|
||||
alias Towerops.Alerts
|
||||
alias Towerops.Equipment
|
||||
alias Towerops.Organizations
|
||||
alias Towerops.Sites
|
||||
|
||||
@doc """
|
||||
GET /api/v1/mobile/organizations
|
||||
|
||||
Returns list of organizations the user has access to.
|
||||
|
||||
Response:
|
||||
{
|
||||
"organizations": [
|
||||
{
|
||||
"id": "uuid",
|
||||
"name": "Acme Corp",
|
||||
"sites_count": 3,
|
||||
"equipment_count": 15,
|
||||
"active_alerts_count": 2
|
||||
}
|
||||
]
|
||||
}
|
||||
"""
|
||||
def list_organizations(conn, _params) do
|
||||
user = conn.assigns.current_user
|
||||
|
||||
organizations =
|
||||
user
|
||||
|> Organizations.list_user_organizations()
|
||||
|> Enum.map(fn org ->
|
||||
%{
|
||||
id: org.id,
|
||||
name: org.name,
|
||||
sites_count: Sites.count_organization_sites(org.id),
|
||||
equipment_count: Equipment.count_organization_equipment(org.id),
|
||||
active_alerts_count: Alerts.count_active_alerts(org.id)
|
||||
}
|
||||
end)
|
||||
|
||||
json(conn, %{organizations: organizations})
|
||||
end
|
||||
|
||||
@doc """
|
||||
GET /api/v1/mobile/organizations/:id/sites
|
||||
|
||||
Returns list of sites for an organization.
|
||||
|
||||
Response:
|
||||
{
|
||||
"sites": [
|
||||
{
|
||||
"id": "uuid",
|
||||
"name": "Main Office",
|
||||
"location": "New York, NY",
|
||||
"equipment_count": 5,
|
||||
"equipment_down_count": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
"""
|
||||
def list_sites(conn, %{"organization_id" => org_id}) do
|
||||
user = conn.assigns.current_user
|
||||
|
||||
case verify_organization_access(user, org_id) do
|
||||
{:ok, _org} ->
|
||||
sites =
|
||||
org_id
|
||||
|> Sites.list_organization_sites()
|
||||
|> Enum.map(fn site ->
|
||||
%{
|
||||
id: site.id,
|
||||
name: site.name,
|
||||
location: site.location,
|
||||
equipment_count: Equipment.count_site_equipment(site.id),
|
||||
equipment_down_count: Equipment.count_site_equipment_down(site.id)
|
||||
}
|
||||
end)
|
||||
|
||||
json(conn, %{sites: sites})
|
||||
|
||||
{:error, :unauthorized} ->
|
||||
conn
|
||||
|> put_status(:forbidden)
|
||||
|> json(%{error: "Access denied to this organization"})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
GET /api/v1/mobile/organizations/:id/equipment
|
||||
|
||||
Returns list of equipment for an organization with current status.
|
||||
|
||||
Query params:
|
||||
- site_id: Filter by site (optional)
|
||||
- status: Filter by status: "up", "down", "unknown" (optional)
|
||||
|
||||
Response:
|
||||
{
|
||||
"equipment": [
|
||||
{
|
||||
"id": "uuid",
|
||||
"name": "Core Router",
|
||||
"ip_address": "192.168.1.1",
|
||||
"site_name": "Main Office",
|
||||
"status": "up",
|
||||
"uptime": "15 days, 4 hours",
|
||||
"last_seen_at": "2026-01-15T19:44:25Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
"""
|
||||
def list_equipment(conn, %{"organization_id" => org_id} = params) do
|
||||
user = conn.assigns.current_user
|
||||
|
||||
case verify_organization_access(user, org_id) do
|
||||
{:ok, _org} ->
|
||||
equipment_list =
|
||||
org_id
|
||||
|> Equipment.list_organization_equipment(params)
|
||||
|> Enum.map(&format_equipment/1)
|
||||
|
||||
json(conn, %{equipment: equipment_list})
|
||||
|
||||
{:error, :unauthorized} ->
|
||||
conn
|
||||
|> put_status(:forbidden)
|
||||
|> json(%{error: "Access denied to this organization"})
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
GET /api/v1/mobile/equipment/:id
|
||||
|
||||
Returns detailed equipment information including interfaces and sensors.
|
||||
|
||||
Response:
|
||||
{
|
||||
"id": "uuid",
|
||||
"name": "Core Router",
|
||||
"ip_address": "192.168.1.1",
|
||||
"status": "up",
|
||||
"uptime": "15 days, 4 hours",
|
||||
"site": {"id": "uuid", "name": "Main Office"},
|
||||
"interfaces": [...],
|
||||
"sensors": [...]
|
||||
}
|
||||
"""
|
||||
def get_equipment(conn, %{"id" => equipment_id}) do
|
||||
user = conn.assigns.current_user
|
||||
|
||||
case Equipment.get_equipment_with_details(equipment_id) do
|
||||
nil ->
|
||||
conn
|
||||
|> put_status(:not_found)
|
||||
|> json(%{error: "Equipment not found"})
|
||||
|
||||
equipment ->
|
||||
case verify_organization_access(user, equipment.organization_id) do
|
||||
{:ok, _org} ->
|
||||
json(conn, format_equipment_details(equipment))
|
||||
|
||||
{:error, :unauthorized} ->
|
||||
conn
|
||||
|> put_status(:forbidden)
|
||||
|> json(%{error: "Access denied to this equipment"})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
GET /api/v1/mobile/organizations/:id/alerts
|
||||
|
||||
Returns list of alerts for an organization.
|
||||
|
||||
Query params:
|
||||
- severity: Filter by severity: "critical", "warning", "info" (optional)
|
||||
- status: Filter by status: "active", "acknowledged", "resolved" (optional)
|
||||
- limit: Number of alerts to return (default 50, max 200)
|
||||
|
||||
Response:
|
||||
{
|
||||
"alerts": [
|
||||
{
|
||||
"id": "uuid",
|
||||
"severity": "critical",
|
||||
"status": "active",
|
||||
"message": "Equipment Down: Core Router",
|
||||
"equipment_name": "Core Router",
|
||||
"equipment_id": "uuid",
|
||||
"occurred_at": "2026-01-15T19:44:25Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
"""
|
||||
def list_alerts(conn, %{"organization_id" => org_id} = params) do
|
||||
user = conn.assigns.current_user
|
||||
|
||||
case verify_organization_access(user, org_id) do
|
||||
{:ok, _org} ->
|
||||
limit = min(String.to_integer(params["limit"] || "50"), 200)
|
||||
|
||||
alerts =
|
||||
org_id
|
||||
|> Alerts.list_organization_alerts(Map.put(params, "limit", limit))
|
||||
|> Enum.map(&format_alert/1)
|
||||
|
||||
json(conn, %{alerts: alerts})
|
||||
|
||||
{:error, :unauthorized} ->
|
||||
conn
|
||||
|> put_status(:forbidden)
|
||||
|> json(%{error: "Access denied to this organization"})
|
||||
end
|
||||
end
|
||||
|
||||
# Private helpers
|
||||
|
||||
defp verify_organization_access(user, org_id) do
|
||||
if Organizations.user_has_access?(user.id, org_id) do
|
||||
{:ok, org_id}
|
||||
else
|
||||
{:error, :unauthorized}
|
||||
end
|
||||
end
|
||||
|
||||
defp format_equipment(equipment) do
|
||||
%{
|
||||
id: equipment.id,
|
||||
name: equipment.name,
|
||||
ip_address: equipment.ip_address,
|
||||
site_name: equipment.site && equipment.site.name,
|
||||
status: equipment.status || "unknown",
|
||||
uptime: format_uptime(equipment),
|
||||
last_seen_at: equipment.last_seen_at
|
||||
}
|
||||
end
|
||||
|
||||
defp format_equipment_details(equipment) do
|
||||
%{
|
||||
id: equipment.id,
|
||||
name: equipment.name,
|
||||
ip_address: equipment.ip_address,
|
||||
status: equipment.status || "unknown",
|
||||
uptime: format_uptime(equipment),
|
||||
site: %{
|
||||
id: equipment.site.id,
|
||||
name: equipment.site.name
|
||||
},
|
||||
interfaces:
|
||||
Enum.map(equipment.snmp_device.interfaces || [], fn interface ->
|
||||
%{
|
||||
id: interface.id,
|
||||
name: interface.if_name,
|
||||
alias: interface.if_alias,
|
||||
status: interface.if_oper_status,
|
||||
admin_status: interface.if_admin_status,
|
||||
speed: interface.if_speed,
|
||||
mac_address: interface.if_phys_address
|
||||
}
|
||||
end),
|
||||
sensors:
|
||||
Enum.map(equipment.snmp_device.sensors || [], fn sensor ->
|
||||
%{
|
||||
id: sensor.id,
|
||||
name: sensor.name,
|
||||
type: sensor.sensor_type,
|
||||
unit: sensor.unit,
|
||||
current_value: sensor.current_value,
|
||||
status: sensor.status
|
||||
}
|
||||
end)
|
||||
}
|
||||
end
|
||||
|
||||
defp format_alert(alert) do
|
||||
%{
|
||||
id: alert.id,
|
||||
severity: alert.severity,
|
||||
status: alert.status,
|
||||
message: alert.message,
|
||||
equipment_name: alert.equipment && alert.equipment.name,
|
||||
equipment_id: alert.equipment_id,
|
||||
occurred_at: alert.occurred_at
|
||||
}
|
||||
end
|
||||
|
||||
defp format_uptime(equipment) do
|
||||
if equipment.snmp_device && equipment.snmp_device.sys_uptime do
|
||||
timeticks_to_string(equipment.snmp_device.sys_uptime)
|
||||
end
|
||||
end
|
||||
|
||||
defp timeticks_to_string(timeticks) do
|
||||
seconds = div(timeticks, 100)
|
||||
days = div(seconds, 86_400)
|
||||
hours = div(rem(seconds, 86_400), 3600)
|
||||
minutes = div(rem(seconds, 3600), 60)
|
||||
|
||||
cond do
|
||||
days > 0 -> "#{days}d #{hours}h"
|
||||
hours > 0 -> "#{hours}h #{minutes}m"
|
||||
true -> "#{minutes}m"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -54,6 +54,38 @@ defmodule ToweropsWeb.UserSettingsController do
|
|||
end
|
||||
end
|
||||
|
||||
def update(conn, %{"action" => "revoke_mobile_device", "session_id" => session_id}) do
|
||||
case Towerops.MobileSessions.revoke_session(session_id) do
|
||||
{:ok, _} ->
|
||||
conn
|
||||
|> put_flash(:info, "Mobile device removed successfully.")
|
||||
|> redirect(to: ~p"/users/settings")
|
||||
|
||||
{:error, _} ->
|
||||
conn
|
||||
|> put_flash(:error, "Failed to remove mobile device.")
|
||||
|> redirect(to: ~p"/users/settings")
|
||||
end
|
||||
end
|
||||
|
||||
def update(conn, %{"action" => "toggle_device_alerts", "session_id" => session_id, "enabled" => enabled}) do
|
||||
enabled_bool = enabled == "true"
|
||||
|
||||
case Towerops.MobileSessions.update_alert_preferences(session_id, %{alerts_enabled: enabled_bool}) do
|
||||
{:ok, _} ->
|
||||
message = if enabled_bool, do: "Alerts enabled for device", else: "Alerts disabled for device"
|
||||
|
||||
conn
|
||||
|> put_flash(:info, message)
|
||||
|> redirect(to: ~p"/users/settings")
|
||||
|
||||
{:error, _} ->
|
||||
conn
|
||||
|> put_flash(:error, "Failed to update alert preferences.")
|
||||
|> redirect(to: ~p"/users/settings")
|
||||
end
|
||||
end
|
||||
|
||||
def confirm_email(conn, %{"token" => token}) do
|
||||
case Accounts.update_user_email(conn.assigns.current_scope.user, token) do
|
||||
{:ok, _user} ->
|
||||
|
|
@ -76,5 +108,6 @@ defmodule ToweropsWeb.UserSettingsController do
|
|||
|> assign(:password_changeset, Accounts.change_user_password(user))
|
||||
|> assign(:credentials, Accounts.list_user_credentials(user.id))
|
||||
|> assign(:can_register_passkey, Accounts.passkey_registration_allowed?(user))
|
||||
|> assign(:mobile_sessions, Towerops.MobileSessions.list_user_sessions(user.id))
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -40,6 +40,111 @@
|
|||
|
||||
<div class="divider" />
|
||||
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold leading-6 text-zinc-900 dark:text-zinc-100">
|
||||
Alert Notification Devices
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
|
||||
Manage mobile devices that receive push notifications for alerts
|
||||
</p>
|
||||
|
||||
<div class="mt-6 space-y-4">
|
||||
<%= if Enum.empty?(@mobile_sessions) do %>
|
||||
<div class="rounded-lg border border-dashed border-zinc-300 p-8 text-center dark:border-zinc-700">
|
||||
<.icon name="hero-device-phone-mobile" class="mx-auto h-12 w-12 text-zinc-400" />
|
||||
<h3 class="mt-2 text-sm font-semibold text-zinc-900 dark:text-zinc-100">
|
||||
No mobile devices registered
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-zinc-500 dark:text-zinc-400">
|
||||
Add a mobile device to receive push notifications for alerts
|
||||
</p>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="space-y-3">
|
||||
<%= for session <- @mobile_sessions do %>
|
||||
<div class="flex items-center justify-between rounded-lg border border-zinc-200 p-4 dark:border-zinc-800">
|
||||
<div class="flex items-center gap-3">
|
||||
<.icon name="hero-device-phone-mobile" class="h-5 w-5 text-zinc-400" />
|
||||
<div>
|
||||
<p class="font-medium text-zinc-900 dark:text-zinc-100">
|
||||
{session.device_name || "Unknown Device"}
|
||||
</p>
|
||||
<p class="text-sm text-zinc-500 dark:text-zinc-400">
|
||||
{session.device_os} • {session.app_version}
|
||||
</p>
|
||||
<p class="text-sm text-zinc-500 dark:text-zinc-400">
|
||||
Last used {Calendar.strftime(session.last_used_at, "%B %d, %Y")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<.form
|
||||
:let={_f}
|
||||
for={%{}}
|
||||
action={~p"/users/settings"}
|
||||
method="put"
|
||||
class="inline"
|
||||
>
|
||||
<input type="hidden" name="action" value="toggle_device_alerts" />
|
||||
<input type="hidden" name="session_id" value={session.id} />
|
||||
<input
|
||||
type="hidden"
|
||||
name="enabled"
|
||||
value={if session.alerts_enabled, do: "false", else: "true"}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
class={[
|
||||
"inline-flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-semibold shadow-sm",
|
||||
if(session.alerts_enabled,
|
||||
do:
|
||||
"bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/20 dark:text-green-400 dark:hover:bg-green-900/30",
|
||||
else:
|
||||
"bg-zinc-100 text-zinc-700 hover:bg-zinc-200 dark:bg-zinc-800 dark:text-zinc-400 dark:hover:bg-zinc-700"
|
||||
)
|
||||
]}
|
||||
>
|
||||
<%= if session.alerts_enabled do %>
|
||||
<.icon name="hero-bell" class="h-4 w-4" /> Alerts On
|
||||
<% else %>
|
||||
<.icon name="hero-bell-slash" class="h-4 w-4" /> Alerts Off
|
||||
<% end %>
|
||||
</button>
|
||||
</.form>
|
||||
<.form
|
||||
:let={_f}
|
||||
for={%{}}
|
||||
action={~p"/users/settings"}
|
||||
method="put"
|
||||
class="inline"
|
||||
>
|
||||
<input type="hidden" name="action" value="revoke_mobile_device" />
|
||||
<input type="hidden" name="session_id" value={session.id} />
|
||||
<button
|
||||
type="submit"
|
||||
data-confirm="Are you sure you want to remove this device?"
|
||||
class="text-sm font-semibold text-red-600 hover:text-red-500 dark:text-red-400 dark:hover:text-red-300"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</.form>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<.link
|
||||
navigate={~p"/mobile/qr-login"}
|
||||
class="inline-flex items-center gap-2 rounded-lg bg-zinc-900 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-zinc-700 dark:bg-zinc-50 dark:text-zinc-900 dark:hover:bg-zinc-300"
|
||||
>
|
||||
<.icon name="hero-qr-code" class="h-4 w-4" /> Add Mobile Device
|
||||
</.link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divider" />
|
||||
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold leading-6 text-zinc-900 dark:text-zinc-100">
|
||||
Passkeys
|
||||
|
|
|
|||
211
lib/towerops_web/live/mobile_qr_live.ex
Normal file
211
lib/towerops_web/live/mobile_qr_live.ex
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
defmodule ToweropsWeb.MobileQRLive do
|
||||
@moduledoc """
|
||||
LiveView for displaying QR code for mobile app authentication.
|
||||
|
||||
Shows a QR code containing a temporary token that can be scanned by the mobile app.
|
||||
Automatically polls to detect when the token has been used and shows success message.
|
||||
"""
|
||||
use ToweropsWeb, :live_view
|
||||
|
||||
alias Towerops.MobileSessions
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
user = socket.assigns.current_scope.user
|
||||
|
||||
# Create QR login token
|
||||
{:ok, qr_token} = MobileSessions.create_qr_login_token(user.id)
|
||||
|
||||
# Start polling to check if token has been completed
|
||||
if connected?(socket) do
|
||||
schedule_check()
|
||||
end
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:qr_token, qr_token)
|
||||
|> assign(:completed, false)
|
||||
|> assign(:mobile_session, nil)
|
||||
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info(:check_completion, socket) do
|
||||
token = socket.assigns.qr_token.token
|
||||
|
||||
case MobileSessions.check_qr_login_completed(token) do
|
||||
nil ->
|
||||
# Not completed yet, check if expired
|
||||
if DateTime.after?(DateTime.utc_now(), socket.assigns.qr_token.expires_at) do
|
||||
# Token expired, create a new one
|
||||
user = socket.assigns.current_scope.user
|
||||
{:ok, qr_token} = MobileSessions.create_qr_login_token(user.id)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:qr_token, qr_token)
|
||||
|> put_flash(:info, "QR code expired, generated a new one")
|
||||
|
||||
schedule_check()
|
||||
{:noreply, socket}
|
||||
else
|
||||
# Still valid, check again
|
||||
schedule_check()
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
mobile_session ->
|
||||
# Token was completed!
|
||||
socket =
|
||||
socket
|
||||
|> assign(:completed, true)
|
||||
|> assign(:mobile_session, mobile_session)
|
||||
|> put_flash(:info, "Mobile device authenticated successfully!")
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
defp schedule_check do
|
||||
Process.send_after(self(), :check_completion, 2000)
|
||||
end
|
||||
|
||||
# Generate QR code data URL using qrcode.show API (public service)
|
||||
defp qr_code_url(token) do
|
||||
# Base URL for the mobile app to handle the token
|
||||
# In production, this would be your custom URL scheme like towerops://qr-login?token=...
|
||||
# For now, we'll just use the token directly
|
||||
data = URI.encode(token)
|
||||
"https://qrcode.tec-it.com/API/QRCode?data=#{data}&backcolor=%23ffffff"
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<Layouts.app flash={@flash}>
|
||||
<div class="mx-auto max-w-4xl">
|
||||
<.header>
|
||||
Mobile App Login
|
||||
<:subtitle>Scan this QR code with your Towerops mobile app to log in</:subtitle>
|
||||
</.header>
|
||||
|
||||
<div :if={!@completed} class="mt-8">
|
||||
<div class="rounded-lg bg-white p-8 shadow-sm ring-1 ring-zinc-200 dark:bg-zinc-900 dark:ring-zinc-700">
|
||||
<div class="flex flex-col items-center space-y-6">
|
||||
<div class="text-center">
|
||||
<h3 class="text-lg font-semibold text-zinc-900 dark:text-zinc-100">
|
||||
Scan with Mobile App
|
||||
</h3>
|
||||
<p class="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
|
||||
Open the Towerops mobile app and scan this QR code to log in
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg bg-white p-4">
|
||||
<img
|
||||
src={qr_code_url(@qr_token.token)}
|
||||
alt="QR Code for mobile login"
|
||||
class="h-64 w-64"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="text-center">
|
||||
<p class="text-xs text-zinc-500 dark:text-zinc-400">
|
||||
This QR code expires in 5 minutes
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-zinc-500 dark:text-zinc-400">
|
||||
Waiting for mobile app to scan...
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="h-2 w-2 animate-pulse rounded-full bg-blue-500"></div>
|
||||
<span class="text-sm text-zinc-600 dark:text-zinc-400">Checking...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 rounded-lg bg-blue-50 p-4 dark:bg-blue-900/20">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<.icon name="hero-information-circle" class="h-5 w-5 text-blue-400" />
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-blue-800 dark:text-blue-300">
|
||||
Don't have the mobile app yet?
|
||||
</h3>
|
||||
<div class="mt-2 text-sm text-blue-700 dark:text-blue-400">
|
||||
<p>Download the Towerops mobile app from:</p>
|
||||
<ul class="mt-2 list-inside list-disc space-y-1">
|
||||
<li>App Store (iOS)</li>
|
||||
<li>Google Play (Android - coming soon)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div :if={@completed} class="mt-8">
|
||||
<div class="rounded-lg bg-green-50 p-8 shadow-sm ring-1 ring-green-200 dark:bg-green-900/20 dark:ring-green-700">
|
||||
<div class="flex flex-col items-center space-y-4">
|
||||
<div class="flex h-16 w-16 items-center justify-center rounded-full bg-green-100 dark:bg-green-900">
|
||||
<.icon name="hero-check" class="h-10 w-10 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
|
||||
<div class="text-center">
|
||||
<h3 class="text-lg font-semibold text-green-900 dark:text-green-100">
|
||||
Mobile Device Authenticated!
|
||||
</h3>
|
||||
<p class="mt-2 text-sm text-green-700 dark:text-green-300">
|
||||
Your mobile device has been successfully authenticated.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 rounded-lg bg-white p-4 dark:bg-zinc-800">
|
||||
<dl class="space-y-2 text-sm">
|
||||
<div>
|
||||
<dt class="font-medium text-zinc-700 dark:text-zinc-300">Device Name</dt>
|
||||
<dd class="mt-1 text-zinc-900 dark:text-zinc-100">
|
||||
{@mobile_session.device_name || "Unknown"}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="font-medium text-zinc-700 dark:text-zinc-300">Device OS</dt>
|
||||
<dd class="mt-1 text-zinc-900 dark:text-zinc-100">
|
||||
{@mobile_session.device_os || "Unknown"}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="font-medium text-zinc-700 dark:text-zinc-300">App Version</dt>
|
||||
<dd class="mt-1 text-zinc-900 dark:text-zinc-100">
|
||||
{@mobile_session.app_version || "Unknown"}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<.link
|
||||
navigate={~p"/users/settings"}
|
||||
class="text-sm font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400"
|
||||
>
|
||||
Manage mobile sessions →
|
||||
</.link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6">
|
||||
<.link
|
||||
navigate={~p"/orgs"}
|
||||
class="text-sm font-medium text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100"
|
||||
>
|
||||
← Back to Organizations
|
||||
</.link>
|
||||
</div>
|
||||
</div>
|
||||
</Layouts.app>
|
||||
"""
|
||||
end
|
||||
end
|
||||
63
lib/towerops_web/plugs/mobile_auth.ex
Normal file
63
lib/towerops_web/plugs/mobile_auth.ex
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
defmodule ToweropsWeb.Plugs.MobileAuth do
|
||||
@moduledoc """
|
||||
Plug for authenticating mobile app requests using bearer tokens.
|
||||
|
||||
Expects an Authorization header with format: "Bearer <token>"
|
||||
|
||||
On success, assigns :current_user and :current_mobile_session to the conn.
|
||||
On failure, returns 401 Unauthorized.
|
||||
"""
|
||||
|
||||
import Phoenix.Controller, only: [json: 2]
|
||||
import Plug.Conn
|
||||
|
||||
alias Towerops.Accounts
|
||||
alias Towerops.MobileSessions
|
||||
|
||||
def init(opts), do: opts
|
||||
|
||||
def call(conn, _opts) do
|
||||
with {:ok, token} <- extract_token(conn),
|
||||
{:ok, session} <- validate_session(token),
|
||||
{:ok, user} <- get_user(session.user_id) do
|
||||
# Touch the session to update last_used_at
|
||||
Task.start(fn -> MobileSessions.touch_session(session) end)
|
||||
|
||||
conn
|
||||
|> assign(:current_user, user)
|
||||
|> assign(:current_mobile_session, session)
|
||||
else
|
||||
{:error, reason} ->
|
||||
conn
|
||||
|> put_status(:unauthorized)
|
||||
|> json(%{error: error_message(reason)})
|
||||
|> halt()
|
||||
end
|
||||
end
|
||||
|
||||
defp extract_token(conn) do
|
||||
case get_req_header(conn, "authorization") do
|
||||
["Bearer " <> token] -> {:ok, token}
|
||||
_ -> {:error, :missing_token}
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_session(token) do
|
||||
case MobileSessions.get_session_by_token(token) do
|
||||
nil -> {:error, :invalid_token}
|
||||
session -> {:ok, session}
|
||||
end
|
||||
end
|
||||
|
||||
defp get_user(user_id) do
|
||||
case Accounts.get_user(user_id) do
|
||||
nil -> {:error, :user_not_found}
|
||||
user -> {:ok, user}
|
||||
end
|
||||
end
|
||||
|
||||
defp error_message(:missing_token), do: "Authorization header is missing or invalid"
|
||||
defp error_message(:invalid_token), do: "Invalid or expired authentication token"
|
||||
defp error_message(:user_not_found), do: "User not found"
|
||||
defp error_message(_), do: "Authentication failed"
|
||||
end
|
||||
|
|
@ -23,6 +23,11 @@ defmodule ToweropsWeb.Router do
|
|||
plug ToweropsWeb.Plugs.AgentAuth
|
||||
end
|
||||
|
||||
pipeline :mobile_api do
|
||||
plug :accepts, ["json"]
|
||||
plug ToweropsWeb.Plugs.MobileAuth
|
||||
end
|
||||
|
||||
# Health check endpoint for Kubernetes probes (no authentication required)
|
||||
scope "/", ToweropsWeb do
|
||||
get "/health", HealthController, :index
|
||||
|
|
@ -43,6 +48,30 @@ defmodule ToweropsWeb.Router do
|
|||
post "/heartbeat", AgentController, :heartbeat
|
||||
end
|
||||
|
||||
# Mobile Auth API routes (no authentication required for login flow)
|
||||
scope "/api/v1/mobile/auth", ToweropsWeb.Api do
|
||||
pipe_through :api
|
||||
|
||||
post "/qr/verify", MobileAuthController, :verify_qr_token
|
||||
post "/qr/complete", MobileAuthController, :complete_qr_login
|
||||
end
|
||||
|
||||
# Mobile API routes (requires mobile authentication)
|
||||
scope "/api/v1/mobile", ToweropsWeb.Api do
|
||||
pipe_through :mobile_api
|
||||
|
||||
# Auth session management
|
||||
get "/auth/session", MobileAuthController, :get_session
|
||||
delete "/auth/session", MobileAuthController, :revoke_session
|
||||
|
||||
# Data endpoints
|
||||
get "/organizations", MobileController, :list_organizations
|
||||
get "/organizations/:organization_id/sites", MobileController, :list_sites
|
||||
get "/organizations/:organization_id/equipment", MobileController, :list_equipment
|
||||
get "/organizations/:organization_id/alerts", MobileController, :list_alerts
|
||||
get "/equipment/:id", MobileController, :get_equipment
|
||||
end
|
||||
|
||||
# WebAuthn API routes
|
||||
scope "/api/webauthn", ToweropsWeb do
|
||||
pipe_through [:browser]
|
||||
|
|
@ -131,6 +160,7 @@ defmodule ToweropsWeb.Router do
|
|||
scope "/", ToweropsWeb do
|
||||
pipe_through [:browser, :require_authenticated_user]
|
||||
|
||||
live "/mobile/qr-login", MobileQRLive, :index
|
||||
live "/orgs", OrgLive.Index, :index
|
||||
live "/orgs/new", OrgLive.New, :new
|
||||
end
|
||||
|
|
|
|||
|
|
@ -10,6 +10,21 @@ defmodule ToweropsWeb.Telemetry do
|
|||
|
||||
@impl true
|
||||
def init(_arg) do
|
||||
# Attach telemetry handlers for logging request failures
|
||||
:telemetry.attach(
|
||||
"towerops-router-exception",
|
||||
[:phoenix, :router_dispatch, :exception],
|
||||
&__MODULE__.handle_router_exception/4,
|
||||
nil
|
||||
)
|
||||
|
||||
:telemetry.attach(
|
||||
"towerops-endpoint-stop",
|
||||
[:phoenix, :endpoint, :stop],
|
||||
&__MODULE__.handle_endpoint_stop/4,
|
||||
nil
|
||||
)
|
||||
|
||||
children = [
|
||||
# Telemetry poller will execute the given period measurements
|
||||
# every 10_000ms. Learn more here: https://hexdocs.pm/telemetry_metrics
|
||||
|
|
@ -91,4 +106,45 @@ defmodule ToweropsWeb.Telemetry do
|
|||
# {ToweropsWeb, :count_users, []}
|
||||
]
|
||||
end
|
||||
|
||||
# Telemetry handler for router exceptions
|
||||
def handle_router_exception(_event, _measurements, metadata, _config) do
|
||||
require Logger
|
||||
|
||||
Logger.error(
|
||||
"Router exception on #{metadata.plug} #{metadata.conn.method} #{metadata.conn.request_path}",
|
||||
kind: metadata.kind,
|
||||
reason: metadata.reason,
|
||||
stacktrace: metadata.stacktrace,
|
||||
request_id: metadata.conn.assigns[:request_id]
|
||||
)
|
||||
end
|
||||
|
||||
# Telemetry handler for endpoint stop events (log slow requests and errors)
|
||||
def handle_endpoint_stop(_event, measurements, metadata, _config) do
|
||||
require Logger
|
||||
|
||||
duration_ms = System.convert_time_unit(measurements.duration, :native, :millisecond)
|
||||
|
||||
# Log slow requests (over 5 seconds)
|
||||
if duration_ms > 5_000 do
|
||||
Logger.warning(
|
||||
"Slow request: #{metadata.conn.method} #{metadata.conn.request_path} took #{duration_ms}ms",
|
||||
request_id: metadata.conn.assigns[:request_id],
|
||||
duration_ms: duration_ms
|
||||
)
|
||||
end
|
||||
|
||||
# Log requests with non-2xx status codes
|
||||
status = metadata.conn.status
|
||||
|
||||
if status >= 500 do
|
||||
Logger.error(
|
||||
"Server error: #{metadata.conn.method} #{metadata.conn.request_path} returned #{status}",
|
||||
request_id: metadata.conn.assigns[:request_id],
|
||||
status: status,
|
||||
duration_ms: duration_ms
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -0,0 +1,41 @@
|
|||
defmodule Towerops.Repo.Migrations.CreateMobileSessions do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
# Mobile sessions - long-lived tokens for mobile app authentication
|
||||
create table(:mobile_sessions, primary_key: false) do
|
||||
add :id, :binary_id, primary_key: true
|
||||
add :user_id, references(:users, type: :binary_id, on_delete: :delete_all), null: false
|
||||
add :token, :string, null: false
|
||||
add :device_name, :string
|
||||
add :device_os, :string
|
||||
add :app_version, :string
|
||||
add :last_used_at, :utc_datetime, null: false
|
||||
add :expires_at, :utc_datetime, null: false
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
create index(:mobile_sessions, [:user_id])
|
||||
create unique_index(:mobile_sessions, [:token])
|
||||
create index(:mobile_sessions, [:expires_at])
|
||||
|
||||
# QR login tokens - temporary tokens for QR code authentication
|
||||
create table(:qr_login_tokens, primary_key: false) do
|
||||
add :id, :binary_id, primary_key: true
|
||||
add :user_id, references(:users, type: :binary_id, on_delete: :delete_all), null: false
|
||||
add :token, :string, null: false
|
||||
add :expires_at, :utc_datetime, null: false
|
||||
add :completed_at, :utc_datetime
|
||||
|
||||
add :mobile_session_id,
|
||||
references(:mobile_sessions, type: :binary_id, on_delete: :nilify_all)
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
create unique_index(:qr_login_tokens, [:token])
|
||||
create index(:qr_login_tokens, [:user_id])
|
||||
create index(:qr_login_tokens, [:expires_at])
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
defmodule Towerops.Repo.Migrations.AddAlertPreferencesToMobileSessions do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
alter table(:mobile_sessions) do
|
||||
add :alerts_enabled, :boolean, default: true, null: false
|
||||
add :push_token, :text
|
||||
add :push_platform, :string
|
||||
end
|
||||
|
||||
create index(:mobile_sessions, [:user_id, :alerts_enabled])
|
||||
end
|
||||
end
|
||||
|
|
@ -77,7 +77,7 @@ defmodule Towerops.AlertsTest do
|
|||
{:ok, _alert1} = Alerts.create_alert(attrs)
|
||||
{:ok, _alert2} = Alerts.create_alert(Map.put(attrs, :alert_type, :equipment_up))
|
||||
|
||||
alerts = Alerts.list_organization_alerts(organization.id)
|
||||
alerts = Alerts.list_organization_alerts(organization.id, 100)
|
||||
assert length(alerts) == 2
|
||||
end
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue