[feat](client): scan an instance-relative join payload
The service mints /api/alarm/a/{alertId}#{token} with no host, so the QR renders against the configured instance and keeps working when the instance moves or is reached by another name. The scanner now matches path shape and fragment and never the host, so legacy alarm.dcntrlized.org codes and other instances codes still join; error copy distinguishes a malformed code from a wrong-alert token.
This commit is contained in:
@@ -19,9 +19,7 @@ function stubFetch(body: string, status = 400): void {
|
||||
|
||||
beforeEach(() => {
|
||||
configureApi({
|
||||
baseUrl: 'http://alarm.test',
|
||||
mode: 'direct',
|
||||
serviceRef: null,
|
||||
baseUrl: 'http://instance.test',
|
||||
token: null,
|
||||
refreshToken: null,
|
||||
onUnauthorized: () => {},
|
||||
@@ -59,13 +57,15 @@ describe('apiClient error unwrapping', () => {
|
||||
});
|
||||
|
||||
describe('joinErrorMessage', () => {
|
||||
it('renders a token problem as a remedy, never as "expired"', () => {
|
||||
// JoinAlert returns this one code for a rotated token AND for a malformed
|
||||
// one, so the client cannot say which happened.
|
||||
it('says the code is not valid for this alert, without inventing a replacement', () => {
|
||||
// JoinAlert returns this one code for any code that does not belong to the
|
||||
// alert, so the client cannot say which happened — and with rotation gone
|
||||
// there is no "new one" to ask for.
|
||||
const err = new ApiError(400, 'invalid subscribe token', 'ERR_ALERT_TOKEN_INVALID');
|
||||
const copy = joinErrorMessage(err);
|
||||
expect(copy).toBe('This code is no longer valid — ask the owner for a new one.');
|
||||
expect(copy).toBe('This code is not valid for this alert.');
|
||||
expect(copy).not.toMatch(/expired/i);
|
||||
expect(copy).not.toMatch(/new one|rotate|rotated/i);
|
||||
});
|
||||
|
||||
it('passes through any other server message', () => {
|
||||
|
||||
+55
-55
@@ -1,6 +1,10 @@
|
||||
/**
|
||||
* QR payload parser tests — the frozen payload contract.
|
||||
* https://alarm.dcntrlized.org/a/{alertId}#{subscribeToken}
|
||||
* QR payload tests — instance-relative contract.
|
||||
*
|
||||
* The service mints `/api/alarm/a/{alertId}#{token}` (no host) and the client
|
||||
* composes `{configuredOrigin}{qrPayload}`. The parser matches path shape +
|
||||
* fragment, never host, so legacy `alarm.dcntrlized.org` codes and other
|
||||
* instances' codes still work.
|
||||
*/
|
||||
import {
|
||||
buildQrPayload,
|
||||
@@ -9,98 +13,96 @@ import {
|
||||
parseQrPayload,
|
||||
qrErrorMessage,
|
||||
truncateToken,
|
||||
QR_HOST,
|
||||
} from '../src/lib/qrPayload';
|
||||
|
||||
const ID = 'alr_7f3c9a1b2d';
|
||||
const TOKEN = 'sub_9Qx2mZp4Lk7VeR1sTn0';
|
||||
const ORIGIN = 'https://instance.example';
|
||||
|
||||
describe('buildQrPayload', () => {
|
||||
it('renders the canonical contract payload', () => {
|
||||
expect(buildQrPayload(ID, TOKEN)).toBe(`https://${QR_HOST}/a/${ID}#${TOKEN}`);
|
||||
it('composes the service path under the configured origin', () => {
|
||||
expect(buildQrPayload(ID, TOKEN, ORIGIN)).toBe(`${ORIGIN}/api/alarm/a/${ID}#${TOKEN}`);
|
||||
});
|
||||
|
||||
it('renders the custom-scheme variant', () => {
|
||||
it('strips a trailing slash on the origin', () => {
|
||||
expect(buildQrPayload(ID, TOKEN, `${ORIGIN}/`)).toBe(`${ORIGIN}/api/alarm/a/${ID}#${TOKEN}`);
|
||||
});
|
||||
|
||||
it('renders a host-free relative path when no origin is configured', () => {
|
||||
expect(buildQrPayload(ID, TOKEN)).toBe(`/api/alarm/a/${ID}#${TOKEN}`);
|
||||
});
|
||||
|
||||
it('keeps the pre-existing scheme builder for old codes but mints no new host', () => {
|
||||
expect(buildQrSchemePayload(ID, TOKEN)).toBe(`dcntrlized-alarm://a/${ID}#${TOKEN}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseQrPayload', () => {
|
||||
it('parses the canonical payload', () => {
|
||||
const res = parseQrPayload(buildQrPayload(ID, TOKEN));
|
||||
describe('parseQrPayload — path shape, never host', () => {
|
||||
it('parses the composed payload', () => {
|
||||
const res = parseQrPayload(buildQrPayload(ID, TOKEN, ORIGIN));
|
||||
expect(res).toEqual({
|
||||
ok: true,
|
||||
payload: { alertId: ID, subscribeToken: TOKEN, source: 'url' },
|
||||
});
|
||||
});
|
||||
|
||||
it('parses the custom scheme', () => {
|
||||
const res = parseQrPayload(buildQrSchemePayload(ID, TOKEN));
|
||||
it('accepts a relative /api/alarm/a/{id}#{token}', () => {
|
||||
const res = parseQrPayload(`/api/alarm/a/${ID}#${TOKEN}`);
|
||||
expect(res.ok && res.payload.alertId).toBe(ID);
|
||||
expect(res.ok && res.payload.subscribeToken).toBe(TOKEN);
|
||||
expect(res.ok && res.payload.source).toBe('scheme');
|
||||
});
|
||||
|
||||
it('accepts a relative a/{id}#{token} payload', () => {
|
||||
const res = parseQrPayload(`a/${ID}#${TOKEN}`);
|
||||
expect(res.ok && res.payload.source).toBe('relative');
|
||||
expect(res.ok && res.payload.alertId).toBe(ID);
|
||||
});
|
||||
|
||||
it('accepts a raw {id}#{token} payload (what the owner reads off the panel)', () => {
|
||||
const res = parseQrPayload(`${ID}#${TOKEN}`);
|
||||
expect(res.ok && res.payload.source).toBe('raw');
|
||||
expect(res.ok && res.payload.subscribeToken).toBe(TOKEN);
|
||||
});
|
||||
|
||||
it('tolerates surrounding whitespace and a trailing slash', () => {
|
||||
const res = parseQrPayload(` https://${QR_HOST}/a/${ID}/#${TOKEN} `);
|
||||
expect(res.ok && res.payload.alertId).toBe(ID);
|
||||
});
|
||||
|
||||
it('tolerates query noise before the fragment', () => {
|
||||
const res = parseQrPayload(`https://${QR_HOST}/a/${ID}?utm=x#${TOKEN}`);
|
||||
it('accepts a legacy https://alarm.dcntrlized.org/a/{id}#{token}', () => {
|
||||
const res = parseQrPayload(`https://alarm.dcntrlized.org/a/${ID}#${TOKEN}`);
|
||||
expect(res.ok && res.payload.alertId).toBe(ID);
|
||||
expect(res.ok && res.payload.subscribeToken).toBe(TOKEN);
|
||||
});
|
||||
|
||||
it('accepts a subdomain of the alarm host (staging)', () => {
|
||||
const res = parseQrPayload(`https://staging.alarm.dcntrlized.org/a/${ID}#${TOKEN}`);
|
||||
expect(res.ok).toBe(true);
|
||||
it('accepts a full http(s) URL on any host (host grants nothing)', () => {
|
||||
expect(parseQrPayload(`https://evil.example.com/a/${ID}#${TOKEN}`).ok).toBe(true);
|
||||
expect(parseQrPayload(`http://127.0.0.1:8080/api/alarm/a/${ID}#${TOKEN}`).ok).toBe(true);
|
||||
expect(parseQrPayload(`https://staging.alarm.dcntrlized.org/a/${ID}#${TOKEN}`).ok).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts localhost / LAN dev payloads', () => {
|
||||
expect(parseQrPayload(`http://127.0.0.1:8088/a/${ID}#${TOKEN}`).ok).toBe(true);
|
||||
expect(parseQrPayload(`http://192.168.1.232:8088/a/${ID}#${TOKEN}`).ok).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a foreign host', () => {
|
||||
expect(parseQrPayload(`https://evil.example.com/a/${ID}#${TOKEN}`)).toEqual({
|
||||
it('rejects a URL whose path is not an alarm path', () => {
|
||||
expect(parseQrPayload(`https://example.com/foo/bar#${TOKEN}`)).toEqual({
|
||||
ok: false,
|
||||
error: 'not-an-alarm-code',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a foreign scheme', () => {
|
||||
expect(parseQrPayload(`ftp://${QR_HOST}/a/${ID}#${TOKEN}`)).toEqual({
|
||||
expect(parseQrPayload(`ftp://example.com/a/${ID}#${TOKEN}`)).toEqual({
|
||||
ok: false,
|
||||
error: 'not-an-alarm-code',
|
||||
});
|
||||
});
|
||||
|
||||
it('reports an empty payload', () => {
|
||||
expect(parseQrPayload(' ')).toEqual({ ok: false, error: 'empty' });
|
||||
it('parses the pre-existing custom scheme', () => {
|
||||
const res = parseQrPayload(buildQrSchemePayload(ID, TOKEN));
|
||||
expect(res.ok && res.payload.source).toBe('scheme');
|
||||
expect(res.ok && res.payload.alertId).toBe(ID);
|
||||
});
|
||||
|
||||
it('reports a missing token', () => {
|
||||
expect(parseQrPayload(`https://${QR_HOST}/a/${ID}`)).toEqual({
|
||||
it('accepts a relative a/{id}#{token} and raw {id}#{token}', () => {
|
||||
expect(parseQrPayload(`a/${ID}#${TOKEN}`).ok).toBe(true);
|
||||
const raw = parseQrPayload(`${ID}#${TOKEN}`);
|
||||
expect(raw.ok && raw.payload.source).toBe('raw');
|
||||
});
|
||||
|
||||
it('tolerates whitespace, a trailing slash and query noise', () => {
|
||||
const res = parseQrPayload(` https://alarm.dcntrlized.org/a/${ID}/?utm=x#${TOKEN} `);
|
||||
expect(res.ok && res.payload.alertId).toBe(ID);
|
||||
expect(res.ok && res.payload.subscribeToken).toBe(TOKEN);
|
||||
});
|
||||
|
||||
it('reports empty, missing token and missing alert id', () => {
|
||||
expect(parseQrPayload(' ')).toEqual({ ok: false, error: 'empty' });
|
||||
expect(parseQrPayload(`https://alarm.dcntrlized.org/a/${ID}`)).toEqual({
|
||||
ok: false,
|
||||
error: 'missing-token',
|
||||
});
|
||||
});
|
||||
|
||||
it('reports a missing alert id', () => {
|
||||
expect(parseQrPayload(`https://${QR_HOST}/a/#${TOKEN}`)).toEqual({
|
||||
expect(parseQrPayload(`https://alarm.dcntrlized.org/a/#${TOKEN}`)).toEqual({
|
||||
ok: false,
|
||||
error: 'missing-alert-id',
|
||||
});
|
||||
@@ -114,23 +116,21 @@ describe('parseQrPayload', () => {
|
||||
|
||||
it('round-trips ids that need encoding', () => {
|
||||
const weird = 'alr/with space';
|
||||
const built = buildQrPayload(weird, TOKEN);
|
||||
const res = parseQrPayload(built);
|
||||
const res = parseQrPayload(buildQrPayload(weird, TOKEN, ORIGIN));
|
||||
expect(res.ok && res.payload.alertId).toBe(weird);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseManualToken', () => {
|
||||
it('accepts a bare token when the alert id is known', () => {
|
||||
const res = parseManualToken(TOKEN, ID);
|
||||
expect(res).toEqual({
|
||||
expect(parseManualToken(TOKEN, ID)).toEqual({
|
||||
ok: true,
|
||||
payload: { alertId: ID, subscribeToken: TOKEN, source: 'raw' },
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts a full payload even when an id is also supplied', () => {
|
||||
const res = parseManualToken(buildQrPayload(ID, TOKEN), 'other');
|
||||
const res = parseManualToken(buildQrPayload(ID, TOKEN, ORIGIN), 'other');
|
||||
expect(res.ok && res.payload.alertId).toBe(ID);
|
||||
});
|
||||
|
||||
|
||||
+61
-34
@@ -1,25 +1,33 @@
|
||||
/**
|
||||
* QR payload contract for the Alarm client.
|
||||
*
|
||||
* Canonical payload (frozen contract):
|
||||
* https://alarm.dcntrlized.org/a/{alertId}#{subscribeToken}
|
||||
* The payload is **instance-relative**: the service mints
|
||||
* `/api/alarm/a/{alertId}#{subscribeToken}` with no host, and the client renders
|
||||
* it against the origin it already knows (`{configuredOrigin}{qrPayload}`). A
|
||||
* code therefore stays valid wherever the alert is hosted and can never point at
|
||||
* a hostname that does not exist.
|
||||
*
|
||||
* Also accepted:
|
||||
* - the same URL with a trailing slash / query noise,
|
||||
* - a bare custom scheme: `dcntrlized-alarm://a/{alertId}#{token}`,
|
||||
* - a relative `a/{alertId}#{token}`,
|
||||
* - raw `{alertId}#{token}` (what the owner reads off the QR panel),
|
||||
* - a pasted token alone when the caller already knows the alertId
|
||||
* (manual fallback) — see `parseManualToken`.
|
||||
* The parser matches on **path shape + fragment, never on host**, precisely so
|
||||
* old printed codes keep working. Accepted:
|
||||
* - a full `http(s)://<any-host>/api/alarm/a/{alertId}#{token}`,
|
||||
* - the legacy `https://alarm.dcntrlized.org/a/{alertId}#{token}`,
|
||||
* - a relative `/api/alarm/a/{alertId}#{token}` or `/a/{alertId}#{token}`,
|
||||
* - `a/{alertId}#{token}` and raw `{alertId}#{token}` (what the owner reads
|
||||
* off the QR panel),
|
||||
* - the pre-existing `dcntrlized-alarm://a/{alertId}#{token}` deep link (kept
|
||||
* only for codes already in the wild; no new scheme is minted),
|
||||
* - query noise and a trailing slash,
|
||||
* - a pasted token alone when the caller already knows the alertId — see
|
||||
* `parseManualToken`.
|
||||
*
|
||||
* This module is intentionally pure (no React, no RN imports) so it is testable
|
||||
* under jest without a native environment, and so the same parser is used by
|
||||
* the scanner, the deep-link handler and the manual paste field.
|
||||
*/
|
||||
|
||||
export const QR_HOST = 'alarm.dcntrlized.org';
|
||||
export const QR_SCHEME = 'dcntrlized-alarm';
|
||||
export const QR_PATH_PREFIX = '/a/';
|
||||
/** Path the service mints (and the client composes under its origin). */
|
||||
export const QR_PATH_PREFIX = '/api/alarm/a/';
|
||||
|
||||
export interface QrPayload {
|
||||
alertId: string;
|
||||
@@ -41,9 +49,21 @@ export type QrParseResult =
|
||||
const OK = (payload: QrPayload): QrParseResult => ({ ok: true, payload });
|
||||
const FAIL = (error: QrParseError): QrParseResult => ({ ok: false, error });
|
||||
|
||||
/** Canonical payload an owner renders into a QR code. */
|
||||
export function buildQrPayload(alertId: string, subscribeToken: string): string {
|
||||
return `https://${QR_HOST}${QR_PATH_PREFIX}${encodeURIComponent(alertId)}#${subscribeToken}`;
|
||||
/**
|
||||
* Payload an owner renders into a QR code.
|
||||
*
|
||||
* `origin` is the configured instance origin (e.g. `http://127.0.0.1:8080`), so
|
||||
* the QR is `{origin}/api/alarm/a/{id}#{token}`. With no origin configured the
|
||||
* relative service path is rendered instead — still valid, still host-free, and
|
||||
* still parseable by every client.
|
||||
*/
|
||||
export function buildQrPayload(
|
||||
alertId: string,
|
||||
subscribeToken: string,
|
||||
origin = '',
|
||||
): string {
|
||||
const base = origin.trim().replace(/\/+$/, '');
|
||||
return `${base}${QR_PATH_PREFIX}${encodeURIComponent(alertId)}#${subscribeToken}`;
|
||||
}
|
||||
|
||||
/** Custom-scheme form, used by deep links and by the "share" affordance. */
|
||||
@@ -62,18 +82,17 @@ function stripQuery(value: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the alert id from a path. `/a/{id}` is the contract path; the id is
|
||||
* whatever follows the `a/` prefix, so a trailing slash yields "" (missing)
|
||||
* rather than the literal "a".
|
||||
* Extract the alert id from a path. The shape is `…/a/{id}` (`/a/{id}` legacy,
|
||||
* `/api/alarm/a/{id}` current); the segment after the last `a` is the id, so a
|
||||
* missing id yields "" rather than the literal "a". A path with no `a` segment
|
||||
* is not an alarm path even if it has a final segment.
|
||||
*/
|
||||
function alertIdFromPath(path: string): string {
|
||||
const trimmed = path.replace(/^\/+/, '').replace(/\/+$/, '');
|
||||
if (!trimmed) return '';
|
||||
const segments = trimmed.split('/').filter(Boolean);
|
||||
if (segments[0] === 'a') {
|
||||
return segments.length > 1 ? segments[1] : '';
|
||||
}
|
||||
return segments[segments.length - 1] ?? '';
|
||||
const at = segments.lastIndexOf('a');
|
||||
return at !== -1 && at + 1 < segments.length ? segments[at + 1] : '';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -94,23 +113,23 @@ export function parseQrPayload(raw: string): QrParseResult {
|
||||
return FAIL('not-an-alarm-code');
|
||||
}
|
||||
const rest = withoutQuery.slice(schemeMatch[0].length);
|
||||
// For http(s) the first segment is the authority (`host[:port]`); for the
|
||||
// custom scheme `rest` is already a path.
|
||||
// For http(s) the first segment is the authority (`host[:port]`) and the
|
||||
// host grants nothing — **match the path shape, never the host**, so a code
|
||||
// minted on one instance keeps working on another and legacy
|
||||
// `alarm.dcntrlized.org` codes still parse.
|
||||
let path: string;
|
||||
if (scheme === 'http' || scheme === 'https') {
|
||||
const slash = rest.indexOf('/');
|
||||
const authority = (slash === -1 ? rest : rest.slice(0, slash)).split(':')[0].toLowerCase();
|
||||
const hostOk =
|
||||
authority === QR_HOST ||
|
||||
authority.endsWith(`.${QR_HOST}`) ||
|
||||
authority === 'localhost' ||
|
||||
authority === '127.0.0.1' ||
|
||||
/^\d{1,3}(\.\d{1,3}){3}$/.test(authority);
|
||||
if (!hostOk) return FAIL('not-an-alarm-code');
|
||||
path = slash === -1 ? '' : rest.slice(slash);
|
||||
} else {
|
||||
path = rest.startsWith('/') ? rest : `/${rest}`;
|
||||
}
|
||||
// A URL with a path that has no `/a/` segment is not an alarm code; an
|
||||
// alarm-shaped path whose id segment is empty is a specific failure.
|
||||
const alarmPath = /(^|\/)a(\/|$)/.test(path);
|
||||
if (!alarmPath) {
|
||||
return path.replace(/^\/+/, '') ? FAIL('not-an-alarm-code') : FAIL('missing-alert-id');
|
||||
}
|
||||
const seg = alertIdFromPath(path);
|
||||
if (!seg) return FAIL('missing-alert-id');
|
||||
if (!token) return FAIL('missing-token');
|
||||
@@ -121,18 +140,26 @@ export function parseQrPayload(raw: string): QrParseResult {
|
||||
});
|
||||
}
|
||||
|
||||
// No scheme: accept `a/{id}#{token}` or `{id}#{token}`.
|
||||
// No scheme: `api/alarm/a/{id}#{token}`, `a/{id}#{token}`, or raw
|
||||
// `{id}#{token}` (what the owner reads off the panel).
|
||||
const cleaned = withoutQuery.replace(/^\/+/, '');
|
||||
const looksRelative = /(^|\/)a\//.test(cleaned);
|
||||
if (looksRelative) {
|
||||
const seg = alertIdFromPath(cleaned);
|
||||
if (!seg) return FAIL('missing-alert-id');
|
||||
if (!token) return FAIL('missing-token');
|
||||
const looksRelative = cleaned.startsWith('a/') || cleaned.startsWith(`${QR_SCHEME}/`);
|
||||
return OK({
|
||||
alertId: decodeURIComponent(seg),
|
||||
subscribeToken: token,
|
||||
source: looksRelative ? 'relative' : 'raw',
|
||||
source: 'relative',
|
||||
});
|
||||
}
|
||||
// A bare id is the only non-path form; anything with a slash that is not an
|
||||
// alarm path is refused rather than guessed at.
|
||||
if (cleaned.includes('/') || !cleaned) return FAIL('not-an-alarm-code');
|
||||
if (!token) return FAIL('missing-token');
|
||||
return OK({ alertId: decodeURIComponent(cleaned), subscribeToken: token, source: 'raw' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Manual fallback: the user pastes either a full payload or just the token
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { Modal, Pressable, ScrollView, StyleSheet, View } from 'react-native';
|
||||
import { Pressable, ScrollView, StyleSheet, View } from 'react-native';
|
||||
import { useNavigation, useRoute, type RouteProp } from '@react-navigation/native';
|
||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { Copy, Info, RefreshCw, SunMedium } from 'lucide-react-native';
|
||||
import { Copy, Info, SunMedium } from 'lucide-react-native';
|
||||
import Clipboard from '@react-native-clipboard/clipboard';
|
||||
import { QrCanvas } from '../components/QrCanvas';
|
||||
import { AppText, Button, Card, Header } from '../components/ui';
|
||||
import { useAlarm } from '../state/AlarmContext';
|
||||
import * as api from '../services/alarmApi';
|
||||
import { useConnection } from '../state/ConnectionContext';
|
||||
import { buildQrPayload, truncateToken } from '../lib/qrPayload';
|
||||
import type { RootStackParamList } from '../navigation';
|
||||
import {
|
||||
@@ -21,19 +21,16 @@ import {
|
||||
space3,
|
||||
space4,
|
||||
space6,
|
||||
stateAlarm,
|
||||
surface,
|
||||
surfaceRaised,
|
||||
text,
|
||||
textFaint,
|
||||
textSecondary,
|
||||
typeBody,
|
||||
typeCaption,
|
||||
typeCode,
|
||||
typeHeading,
|
||||
typeLabel,
|
||||
typeMicro,
|
||||
typeTitle,
|
||||
} from '../theme/tokens';
|
||||
|
||||
type Nav = NativeStackNavigationProp<RootStackParamList>;
|
||||
@@ -47,27 +44,31 @@ type Rt = RouteProp<RootStackParamList, 'QrDisplay'>;
|
||||
* Brightness is **not** raised: there is no brightness dependency in the tree,
|
||||
* so the hint tells the user to raise it themselves. The spec's "brightness is
|
||||
* raised" claim was removed rather than shipped as a false affordance (D-11).
|
||||
*
|
||||
* There is no token rotation: subscriptions are permanent for the life of the
|
||||
* alert (product ruling), and revoking a device or deleting the alert are the
|
||||
* only lifecycle controls. The rotation flow is deleted, not hidden.
|
||||
*/
|
||||
export default function QrDisplayScreen() {
|
||||
const navigation = useNavigation<Nav>();
|
||||
const { params } = useRoute<Rt>();
|
||||
const alertId = params.alertId;
|
||||
const { getAlert, subscribeTokenFor, adminTokenFor, updateAlertLocal } = useAlarm();
|
||||
const { getAlert, subscribeTokenFor } = useAlarm();
|
||||
|
||||
const alert = getAlert(alertId);
|
||||
const adminToken = adminTokenFor(alertId);
|
||||
const { resolved } = useConnection();
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [rotatedToken, setRotatedToken] = useState<string | null>(null);
|
||||
const [confirmRotate, setConfirmRotate] = useState(false);
|
||||
const [rotating, setRotating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const storedToken = subscribeTokenFor(alertId);
|
||||
const token = rotatedToken ?? storedToken;
|
||||
// Subscriptions are permanent for the life of the alert; there is no rotation,
|
||||
// so the stored token is the only token this screen will ever show.
|
||||
const token = subscribeTokenFor(alertId);
|
||||
|
||||
// The service mints a host-free path; the QR is composed against the origin
|
||||
// this device already knows, so the code can never point at a hostname that
|
||||
// does not exist. With no origin yet, the relative path is rendered instead.
|
||||
const payload = useMemo(
|
||||
() => (token ? buildQrPayload(alertId, token) : null),
|
||||
[alertId, token],
|
||||
() => (token ? buildQrPayload(alertId, token, resolved?.baseUrl ?? '') : null),
|
||||
[alertId, token, resolved?.baseUrl],
|
||||
);
|
||||
|
||||
const copy = () => {
|
||||
@@ -80,33 +81,6 @@ export default function QrDisplayScreen() {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Rotating invalidates the QR/join path only: people who already joined stay
|
||||
* subscribed (verified in `Alarm/src/actions/alerts.go` rotateSubscribeToken).
|
||||
* Two-step confirm, same shape as the trigger, because it silently makes every
|
||||
* printed code useless.
|
||||
*/
|
||||
const rotate = async () => {
|
||||
if (!adminToken) {
|
||||
setError('Only the alert owner can rotate the subscribe token.');
|
||||
setConfirmRotate(false);
|
||||
return;
|
||||
}
|
||||
setRotating(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await api.rotateSubscribeToken({ alertId, adminToken });
|
||||
setRotatedToken(res.subscribeToken);
|
||||
updateAlertLocal(alertId, { subscribeToken: res.subscribeToken, qrPayload: res.qrPayload });
|
||||
setNotice('Token rotated. Codes you shared before now no longer work.');
|
||||
setConfirmRotate(false);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Could not rotate the token.');
|
||||
} finally {
|
||||
setRotating(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<View style={styles.root}>
|
||||
@@ -167,29 +141,6 @@ export default function QrDisplayScreen() {
|
||||
{notice}
|
||||
</AppText>
|
||||
) : null}
|
||||
{error ? (
|
||||
<AppText style={{ ...typeCaption, color: stateAlarm }} accessibilityLiveRegion="polite">
|
||||
{error}
|
||||
</AppText>
|
||||
) : null}
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
{/* Rotation: revocability is one of the product's four promises. */}
|
||||
<Card>
|
||||
<View style={{ gap: space3 }}>
|
||||
<AppText style={{ ...typeHeading, color: text }}>Rotate the token</AppText>
|
||||
<AppText style={styles.para}>
|
||||
Rotating makes every code you shared before stop working. Devices that already joined
|
||||
stay subscribed — you would have to revoke those individually.
|
||||
</AppText>
|
||||
<Button
|
||||
label={adminToken ? 'Rotate token' : 'Only the owner can rotate'}
|
||||
variant="secondary"
|
||||
icon={RefreshCw}
|
||||
disabled={!adminToken || rotating}
|
||||
onPress={() => setConfirmRotate(true)}
|
||||
/>
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
@@ -208,36 +159,6 @@ export default function QrDisplayScreen() {
|
||||
<Button label="Done" variant="secondary" onPress={() => navigation.goBack()} />
|
||||
</ScrollView>
|
||||
|
||||
<Modal
|
||||
visible={confirmRotate}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={() => setConfirmRotate(false)}
|
||||
statusBarTranslucent
|
||||
>
|
||||
<View style={styles.scrim}>
|
||||
<View style={styles.sheet}>
|
||||
<AppText style={{ ...typeTitle, color: text }}>Rotate the subscribe token?</AppText>
|
||||
<AppText style={{ ...typeBody, color: textSecondary }}>
|
||||
New devices can no longer join with the current QR. Devices already subscribed keep
|
||||
working — remove them individually, or delete the alert to end it for everyone.
|
||||
</AppText>
|
||||
<Button
|
||||
label={rotating ? 'Rotating…' : 'Rotate token'}
|
||||
variant="alarm"
|
||||
height={56}
|
||||
disabled={rotating}
|
||||
onPress={rotate}
|
||||
/>
|
||||
<Button
|
||||
label="Cancel"
|
||||
variant="secondary"
|
||||
height={48}
|
||||
onPress={() => setConfirmRotate(false)}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,9 @@ import { ScanLine, X } from 'lucide-react-native';
|
||||
import { AppText, Button, ErrorBanner, Field, Header, TextInputBox } from '../components/ui';
|
||||
import { useAlarm } from '../state/AlarmContext';
|
||||
import { joinErrorMessage } from '../services/alarmApi';
|
||||
import { buildDeviceLabel } from '../lib/deviceLabel';
|
||||
import { canDeliverRealAlarm } from '../lib/takeoverReadiness';
|
||||
import { useAlarmPermissions } from '../state/useAlarmPermissions';
|
||||
import { parseQrPayload, parseManualToken, qrErrorMessage } from '../lib/qrPayload';
|
||||
import { openAppSettings } from '../services/alarmNative';
|
||||
import type { RootStackParamList } from '../navigation';
|
||||
@@ -64,7 +67,7 @@ type Rt = RouteProp<RootStackParamList, 'Scanner'>;
|
||||
export default function ScannerScreen() {
|
||||
const navigation = useNavigation<Nav>();
|
||||
const { params } = useRoute<Rt>();
|
||||
const { joinAlert, deviceId } = useAlarm();
|
||||
const { joinAlert } = useAlarm();
|
||||
const { hasPermission, requestPermission, canRequestPermission } = useCameraPermission();
|
||||
const device = useCameraDevice('back');
|
||||
|
||||
@@ -117,10 +120,19 @@ export default function ScannerScreen() {
|
||||
}, [reduceMotion, sweepValue]);
|
||||
|
||||
const targetAlertId = params?.alertId;
|
||||
const { perms } = useAlarmPermissions();
|
||||
|
||||
const complete = useCallback(
|
||||
async (alertId: string, token: string) => {
|
||||
if (busy || handled.current) return;
|
||||
// Subscribing on a device that cannot fire a real alarm would present as
|
||||
// armed while being unable to ring. Block it and name the fix.
|
||||
if (perms && !canDeliverRealAlarm(perms)) {
|
||||
setError(
|
||||
'This device cannot deliver a real alarm yet. Allow exact alarms and the full-screen takeover in Settings, then scan again.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
handled.current = true;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
@@ -128,7 +140,8 @@ export default function ScannerScreen() {
|
||||
const record = await joinAlert({
|
||||
alertId,
|
||||
subscribeToken: token,
|
||||
deviceLabel: `Android ${deviceId?.slice(-4) ?? ''}`.trim(),
|
||||
// Device *role*, never a slice of the identity token.
|
||||
deviceLabel: buildDeviceLabel(),
|
||||
});
|
||||
setJoinedName(record.name || alertId);
|
||||
// Give the confirmation a beat, then return to the detail screen.
|
||||
@@ -142,7 +155,7 @@ export default function ScannerScreen() {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[busy, deviceId, joinAlert, navigation],
|
||||
[busy, joinAlert, navigation, perms],
|
||||
);
|
||||
|
||||
const onScanned = useCallback(
|
||||
@@ -287,7 +300,7 @@ export default function ScannerScreen() {
|
||||
onChangeText={setManual}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
placeholder="https://alarm.dcntrlized.org/a/…#token"
|
||||
placeholder="/api/alarm/a/…#token or https://your-instance/…"
|
||||
accessibilityLabel="Alert code or link"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
Reference in New Issue
Block a user