[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:
2026-10-19 02:34:20 +01:00
parent fc66b8e088
commit dcfc9a5df8
5 changed files with 163 additions and 202 deletions
+17 -96
View File
@@ -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>
);
}
+17 -4
View File
@@ -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>