towerops/lib/towerops_web/live/mobile_qr_live.ex
Graham McIntire f2608e8f16
fix: status emoji accumulating in page title and add missing page titles
Fix JS regex in StatusTitle hook to use unicode flag so emoji are
properly matched and replaced instead of accumulating. Add page_title
to 8 LiveViews that were missing it, which caused "TowerOps | TowerOps"
duplication from the live_title suffix.
2026-03-11 09:54:18 -05:00

236 lines
8.2 KiB
Elixir

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
timer_ref =
if connected?(socket) do
schedule_check()
end
socket =
socket
|> assign(:page_title, t("Mobile App"))
|> assign(:qr_token, qr_token)
|> assign(:completed, false)
|> assign(:mobile_session, nil)
|> assign(:timer_ref, timer_ref)
{: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)
timer_ref = schedule_check()
socket =
socket
|> assign(:qr_token, qr_token)
|> assign(:timer_ref, timer_ref)
|> put_flash(:info, t("QR code expired, generated a new one"))
{:noreply, socket}
else
# Still valid, check again
timer_ref = schedule_check()
{:noreply, assign(socket, :timer_ref, timer_ref)}
end
mobile_session ->
# Token was completed! Cancel timer since we're done
cancel_timer(socket.assigns.timer_ref)
socket =
socket
|> assign(:completed, true)
|> assign(:mobile_session, mobile_session)
|> assign(:timer_ref, nil)
|> put_flash(:info, t("Mobile device authenticated successfully!"))
{:noreply, socket}
end
end
# Catch-all for unexpected messages
@impl true
def handle_info(_msg, socket), do: {:noreply, socket}
@impl true
def terminate(_reason, socket) do
cancel_timer(socket.assigns[:timer_ref])
:ok
end
defp schedule_check do
Process.send_after(self(), :check_completion, 2000)
end
defp cancel_timer(nil), do: :ok
defp cancel_timer(timer_ref) when is_reference(timer_ref) do
Process.cancel_timer(timer_ref)
:ok
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} timezone={@timezone}>
<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-gray-200 dark:bg-gray-800/50 dark:ring-white/10">
<div class="flex flex-col items-center space-y-6">
<div class="text-center">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
Scan with Mobile App
</h3>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-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-gray-500 dark:text-gray-400">
This QR code expires in 5 minutes
</p>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-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-gray-600 dark:text-gray-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-gray-800/50">
<dl class="space-y-2 text-sm">
<div>
<dt class="font-medium text-gray-700 dark:text-gray-300">Device Name</dt>
<dd class="mt-1 text-gray-900 dark:text-white">
{@mobile_session.device_name || "Unknown"}
</dd>
</div>
<div>
<dt class="font-medium text-gray-700 dark:text-gray-300">Device OS</dt>
<dd class="mt-1 text-gray-900 dark:text-white">
{@mobile_session.device_os || "Unknown"}
</dd>
</div>
<div>
<dt class="font-medium text-gray-700 dark:text-gray-300">App Version</dt>
<dd class="mt-1 text-gray-900 dark:text-white">
{@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-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white"
>
← Back to Organizations
</.link>
</div>
</div>
</Layouts.app>
"""
end
end