6989a98d75
- 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
179 lines
5.1 KiB
TypeScript
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 }
|
|
}
|