Files
boc/iom/quixzoom-app/src/features/auth/usePasswordlessAuth.ts
T
Bernt 6989a98d75 feat: Passwordless cross-device authentication
- Arkitektur: docs/auth/passwordless-architecture.md
- Backend: iom/quixzoom-auth-service/ (FastAPI + Redis)
- Webb: quixzoom-market-pages/se/login/ (QR-kod + polling)
- App: iom/quixzoom-app/src/features/auth/ (push + deep links)

Flöde: QR-kod → app-godkännande → webb-inloggad
2026-07-07 07:11:50 +00:00

179 lines
5.1 KiB
TypeScript

/**
* usePasswordlessAuth — hanterar push-notifieringar och deep links
* för passwordless cross-device authentication
*/
import { useState, useEffect, useCallback, useRef } from 'react'
import { Platform } from 'react-native'
import * as Notifications from 'expo-notifications'
import * as Linking from 'expo-linking'
import type { Subscription } from 'expo-notifications'
import type { EventType } from 'expo-linking'
import { passwordless } from '../../../lib/api'
export interface AuthRequest {
requestToken: string
deviceInfo?: {
name?: string
browser?: string
type?: string
}
}
export function usePasswordlessAuth(
onRequest: (request: AuthRequest) => void,
onApproved: () => void,
onDenied: () => void,
) {
const [pushToken, setPushToken] = useState<string | null>(null)
const notificationListener = useRef<Subscription | null>(null)
const responseListener = useRef<Subscription | null>(null)
const urlListener = useRef<{ remove: () => void } | null>(null)
// Register for push notifications
useEffect(() => {
registerForPushNotifications()
return () => {
if (notificationListener.current) {
Notifications.removeNotificationSubscription(notificationListener.current)
}
if (responseListener.current) {
Notifications.removeNotificationSubscription(responseListener.current)
}
if (urlListener.current) {
urlListener.current.remove()
}
}
}, [])
// Listen for incoming notifications
useEffect(() => {
// Foreground notification received
notificationListener.current = Notifications.addNotificationReceivedListener(
(notification: Notifications.Notification) => {
handleNotification(notification.request.content.data as Record<string, unknown>)
},
)
// Notification tapped
responseListener.current = Notifications.addNotificationResponseReceivedListener(
(response: Notifications.NotificationResponse) => {
handleNotification(response.notification.request.content.data as Record<string, unknown>)
},
)
// Deep links
urlListener.current = Linking.addEventListener('url', (event: EventType) => {
handleDeepLink(event.url)
})
// Check initial URL (app opened via deep link)
Linking.getInitialURL().then((url: string | null) => {
if (url) handleDeepLink(url)
})
return () => {
if (notificationListener.current) {
Notifications.removeNotificationSubscription(notificationListener.current)
}
if (responseListener.current) {
Notifications.removeNotificationSubscription(responseListener.current)
}
if (urlListener.current) {
urlListener.current.remove()
}
}
}, [onRequest, onApproved, onDenied])
const handleNotification = useCallback(
(data: Record<string, unknown> | undefined) => {
if (!data) return
if (data.type === 'passwordless_auth') {
const requestToken = data.request_token as string
const deviceInfo = data.device_info as AuthRequest['deviceInfo'] | undefined
if (requestToken) {
onRequest({ requestToken, deviceInfo })
}
}
if (data.type === 'passwordless_approved') {
onApproved()
}
if (data.type === 'passwordless_denied') {
onDenied()
}
},
[onRequest, onApproved, onDenied],
)
const handleDeepLink = useCallback(
(url: string) => {
const parsed = Linking.parse(url)
if (parsed.scheme === 'quixzoom' && parsed.path === 'auth/approve') {
const requestToken = parsed.queryParams?.token as string | undefined
const deviceName = parsed.queryParams?.device as string | undefined
if (requestToken) {
onRequest({
requestToken,
deviceInfo: {
name: deviceName || 'quixzoom.se',
type: 'web',
},
})
}
}
},
[onRequest],
)
const registerForPushNotifications = async () => {
try {
const { status: existingStatus } = await Notifications.getPermissionsAsync()
let finalStatus = existingStatus
if (existingStatus !== 'granted') {
const { status } = await Notifications.requestPermissionsAsync()
finalStatus = status
}
if (finalStatus !== 'granted') {
console.log('[usePasswordlessAuth] Push permission not granted')
return
}
const token = await Notifications.getExpoPushTokenAsync({
projectId: 'quixzoom',
})
setPushToken(token.data)
// Register push token with backend
await registerPushToken(token.data)
} catch (error) {
console.error('[usePasswordlessAuth] Failed to register push:', error)
}
}
const registerPushToken = async (token: string) => {
try {
await fetch('https://api.quixzoom.com/auth/push-token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
token,
platform: Platform.OS,
}),
})
} catch (e) {
console.error('[usePasswordlessAuth] Failed to register push token:', e)
}
}
return { pushToken }
}