diff --git a/MOBILE_API.md b/MOBILE_API.md new file mode 100644 index 00000000..100a8409 --- /dev/null +++ b/MOBILE_API.md @@ -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 +``` + +### 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 +``` + +**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 +``` + +**Response (200 OK):** +```json +{ + "success": true +} +``` + +## Data Endpoints + +All data endpoints require authentication via the `Authorization: Bearer ` 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(response.body()!!.string()) + sessionToken = result.session_token + return result + } + + // Fetch organizations + suspend fun fetchOrganizations(): List { + 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(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 diff --git a/config/runtime.exs b/config/runtime.exs index bec1b9fd..d308bbc9 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -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 diff --git a/lib/towerops/alerts.ex b/lib/towerops/alerts.ex index e25ecf3c..23e573a1 100644 --- a/lib/towerops/alerts.ex +++ b/lib/towerops/alerts.ex @@ -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 diff --git a/lib/towerops/equipment.ex b/lib/towerops/equipment.ex index 45574f08..843751f0 100644 --- a/lib/towerops/equipment.ex +++ b/lib/towerops/equipment.ex @@ -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. """ diff --git a/lib/towerops/mobile_sessions.ex b/lib/towerops/mobile_sessions.ex new file mode 100644 index 00000000..952a3b90 --- /dev/null +++ b/lib/towerops/mobile_sessions.ex @@ -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 diff --git a/lib/towerops/mobile_sessions/mobile_session.ex b/lib/towerops/mobile_sessions/mobile_session.ex new file mode 100644 index 00000000..47a299c8 --- /dev/null +++ b/lib/towerops/mobile_sessions/mobile_session.ex @@ -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 diff --git a/lib/towerops/mobile_sessions/qr_login_token.ex b/lib/towerops/mobile_sessions/qr_login_token.ex new file mode 100644 index 00000000..8c8857e8 --- /dev/null +++ b/lib/towerops/mobile_sessions/qr_login_token.ex @@ -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 diff --git a/lib/towerops/organizations.ex b/lib/towerops/organizations.ex index 09d58b47..aca55206 100644 --- a/lib/towerops/organizations.ex +++ b/lib/towerops/organizations.ex @@ -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. """ diff --git a/lib/towerops/sites.ex b/lib/towerops/sites.ex index 13c5064b..81311850 100644 --- a/lib/towerops/sites.ex +++ b/lib/towerops/sites.ex @@ -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. """ diff --git a/lib/towerops_web/controllers/api/agent_controller.ex b/lib/towerops_web/controllers/api/agent_controller.ex index 3d2aafd4..be96291b 100644 --- a/lib/towerops_web/controllers/api/agent_controller.ex +++ b/lib/towerops_web/controllers/api/agent_controller.ex @@ -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 diff --git a/lib/towerops_web/controllers/api/mobile_auth_controller.ex b/lib/towerops_web/controllers/api/mobile_auth_controller.ex new file mode 100644 index 00000000..77bbea69 --- /dev/null +++ b/lib/towerops_web/controllers/api/mobile_auth_controller.ex @@ -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 diff --git a/lib/towerops_web/controllers/api/mobile_controller.ex b/lib/towerops_web/controllers/api/mobile_controller.ex new file mode 100644 index 00000000..73cd6794 --- /dev/null +++ b/lib/towerops_web/controllers/api/mobile_controller.ex @@ -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 diff --git a/lib/towerops_web/controllers/user_settings_controller.ex b/lib/towerops_web/controllers/user_settings_controller.ex index af43b41d..481edf46 100644 --- a/lib/towerops_web/controllers/user_settings_controller.ex +++ b/lib/towerops_web/controllers/user_settings_controller.ex @@ -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 diff --git a/lib/towerops_web/controllers/user_settings_html/edit.html.heex b/lib/towerops_web/controllers/user_settings_html/edit.html.heex index a21d1d11..47db5831 100644 --- a/lib/towerops_web/controllers/user_settings_html/edit.html.heex +++ b/lib/towerops_web/controllers/user_settings_html/edit.html.heex @@ -40,6 +40,111 @@
+
+

+ Alert Notification Devices +

+

+ Manage mobile devices that receive push notifications for alerts +

+ +
+ <%= if Enum.empty?(@mobile_sessions) do %> +
+ <.icon name="hero-device-phone-mobile" class="mx-auto h-12 w-12 text-zinc-400" /> +

+ No mobile devices registered +

+

+ Add a mobile device to receive push notifications for alerts +

+
+ <% else %> +
+ <%= for session <- @mobile_sessions do %> +
+
+ <.icon name="hero-device-phone-mobile" class="h-5 w-5 text-zinc-400" /> +
+

+ {session.device_name || "Unknown Device"} +

+

+ {session.device_os} • {session.app_version} +

+

+ Last used {Calendar.strftime(session.last_used_at, "%B %d, %Y")} +

+
+
+
+ <.form + :let={_f} + for={%{}} + action={~p"/users/settings"} + method="put" + class="inline" + > + + + + + + <.form + :let={_f} + for={%{}} + action={~p"/users/settings"} + method="put" + class="inline" + > + + + + +
+
+ <% end %> +
+ <% 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 + +
+
+ +
+

Passkeys diff --git a/lib/towerops_web/live/mobile_qr_live.ex b/lib/towerops_web/live/mobile_qr_live.ex new file mode 100644 index 00000000..04aa80cd --- /dev/null +++ b/lib/towerops_web/live/mobile_qr_live.ex @@ -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""" + +
+ <.header> + Mobile App Login + <:subtitle>Scan this QR code with your Towerops mobile app to log in + + +
+
+
+
+

+ Scan with Mobile App +

+

+ Open the Towerops mobile app and scan this QR code to log in +

+
+ +
+ QR Code for mobile login +
+ +
+

+ This QR code expires in 5 minutes +

+

+ Waiting for mobile app to scan... +

+
+ +
+
+ Checking... +
+
+
+ +
+
+
+ <.icon name="hero-information-circle" class="h-5 w-5 text-blue-400" /> +
+
+

+ Don't have the mobile app yet? +

+
+

Download the Towerops mobile app from:

+
    +
  • App Store (iOS)
  • +
  • Google Play (Android - coming soon)
  • +
+
+
+
+
+
+ +
+
+
+
+ <.icon name="hero-check" class="h-10 w-10 text-green-600 dark:text-green-400" /> +
+ +
+

+ Mobile Device Authenticated! +

+

+ Your mobile device has been successfully authenticated. +

+
+ +
+
+
+
Device Name
+
+ {@mobile_session.device_name || "Unknown"} +
+
+
+
Device OS
+
+ {@mobile_session.device_os || "Unknown"} +
+
+
+
App Version
+
+ {@mobile_session.app_version || "Unknown"} +
+
+
+
+ + <.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 + 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 + +
+
+
+ """ + end +end diff --git a/lib/towerops_web/plugs/mobile_auth.ex b/lib/towerops_web/plugs/mobile_auth.ex new file mode 100644 index 00000000..f850a8ec --- /dev/null +++ b/lib/towerops_web/plugs/mobile_auth.ex @@ -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 " + + 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 diff --git a/lib/towerops_web/router.ex b/lib/towerops_web/router.ex index cbf4c7e9..6a25ea6b 100644 --- a/lib/towerops_web/router.ex +++ b/lib/towerops_web/router.ex @@ -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 diff --git a/lib/towerops_web/telemetry.ex b/lib/towerops_web/telemetry.ex index 01b40bfe..b77de05d 100644 --- a/lib/towerops_web/telemetry.ex +++ b/lib/towerops_web/telemetry.ex @@ -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 diff --git a/priv/repo/migrations/20260115194425_create_mobile_sessions.exs b/priv/repo/migrations/20260115194425_create_mobile_sessions.exs new file mode 100644 index 00000000..e0bbf8f2 --- /dev/null +++ b/priv/repo/migrations/20260115194425_create_mobile_sessions.exs @@ -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 diff --git a/priv/repo/migrations/20260115212928_add_alert_preferences_to_mobile_sessions.exs b/priv/repo/migrations/20260115212928_add_alert_preferences_to_mobile_sessions.exs new file mode 100644 index 00000000..fdb588d7 --- /dev/null +++ b/priv/repo/migrations/20260115212928_add_alert_preferences_to_mobile_sessions.exs @@ -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 diff --git a/test/towerops/alerts_test.exs b/test/towerops/alerts_test.exs index 0cc71369..ceecda2c 100644 --- a/test/towerops/alerts_test.exs +++ b/test/towerops/alerts_test.exs @@ -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