add api for mobile login

This commit is contained in:
Graham McIntire 2026-01-15 15:36:14 -06:00
parent 0a410ecf81
commit c7f02eac24
No known key found for this signature in database
21 changed files with 2161 additions and 9 deletions

539
MOBILE_API.md Normal file
View 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

View file

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

View file

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

View file

@ -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.
"""

View 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

View 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

View 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

View file

@ -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.
"""

View file

@ -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.
"""

View file

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

View 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

View 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

View file

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

View file

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

View 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

View 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

View file

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

View file

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

View file

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

View file

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

View file

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