Files
boc/iom/quixzoom-app/lib/api.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

342 lines
10 KiB
TypeScript

/**
* quiXzoom API client
* Auth: identity-core JWT (stored in SecureStore)
*/
import * as SecureStore from 'expo-secure-store'
const API_BASE = process.env.EXPO_PUBLIC_API_URL ?? 'https://api.quixzoom.com'
const TOKEN_KEY = 'qxz_jwt'
const REFRESH_KEY = 'qxz_refresh'
// ─── Token management ──────────────────────────────────────────────────────
export async function getToken(): Promise<string | null> {
return SecureStore.getItemAsync(TOKEN_KEY)
}
export async function setToken(token: string, refreshToken?: string): Promise<void> {
await SecureStore.setItemAsync(TOKEN_KEY, token)
if (refreshToken) await SecureStore.setItemAsync(REFRESH_KEY, refreshToken)
}
export async function clearTokens(): Promise<void> {
await SecureStore.deleteItemAsync(TOKEN_KEY)
await SecureStore.deleteItemAsync(REFRESH_KEY)
}
// ─── Base fetch with JWT ───────────────────────────────────────────────────
async function apiFetch<T>(
path: string,
options: RequestInit = {},
): Promise<T> {
const token = await getToken()
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(options.headers as Record<string, string>),
}
if (token) headers['Authorization'] = `Bearer ${token}`
const res = await fetch(`${API_BASE}${path}`, { ...options, headers })
if (res.status === 401) {
// Try refresh
const refreshed = await attemptRefresh()
if (refreshed) {
headers['Authorization'] = `Bearer ${refreshed}`
const retry = await fetch(`${API_BASE}${path}`, { ...options, headers })
if (!retry.ok) throw new ApiError(retry.status, await retry.text())
return retry.json() as Promise<T>
}
throw new ApiError(401, 'Unauthorized')
}
if (!res.ok) throw new ApiError(res.status, await res.text())
return res.json() as Promise<T>
}
async function attemptRefresh(): Promise<string | null> {
const refresh = await SecureStore.getItemAsync(REFRESH_KEY)
if (!refresh) return null
try {
const res = await fetch(`${API_BASE}/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refresh_token: refresh }),
})
if (!res.ok) return null
const data = await res.json() as { access_token: string; refresh_token?: string }
await setToken(data.access_token, data.refresh_token)
return data.access_token
} catch {
return null
}
}
export class ApiError extends Error {
constructor(public status: number, message: string) {
super(message)
this.name = 'ApiError'
}
}
// ─── Types ─────────────────────────────────────────────────────────────────
export type MissionStatus = 'open' | 'active' | 'completed' | 'cancelled' | 'expired'
export type MissionCategory = 'infrastruktur' | 'vägar' | 'hamnar' | 'bryggor' | 'fritidshus' | 'miljö' | 'övrigt'
export interface Mission {
id: string
title: string
description: string
status: MissionStatus
category: MissionCategory
latitude: number
longitude: number
city: string
reward_amount: number // in öre (SEK * 100)
reward_currency: string // 'SEK'
deadline_at: string | null // ISO
created_at: string
distance_meters?: number // populated when fetching nearby
}
export interface MissionDetail extends Mission {
instructions: string
requirements: string[]
max_submissions: number
submission_count: number
}
export interface Submission {
id: string
mission_id: string
zoomer_id: string
status: 'pending' | 'approved' | 'rejected'
media_urls: string[]
created_at: string
reward_paid: number
}
export interface Zoomer {
id: string
email: string
display_name: string
avatar_url: string | null
level: number
total_earned: number // öre
missions_completed: number
badges: Badge[]
joined_at: string
}
export interface Badge {
id: string
label: string
emoji: string
earned_at: string
}
export interface EarningsDay {
date: string // YYYY-MM-DD
amount: number // öre
missions: number
}
export interface EarningsSummary {
today: number
this_week: number
total: number
pending: number
currency: string
}
export interface Payout {
id: string
amount: number
currency: string
status: 'pending' | 'approved' | 'paid' | 'rejected'
created_at: string
paid_at: string | null
}
export interface AuthResult {
access_token: string
refresh_token: string
zoomer: Zoomer
}
// ─── Auth ──────────────────────────────────────────────────────────────────
export const auth = {
async login(email: string, password: string): Promise<AuthResult> {
const data = await apiFetch<AuthResult>('/auth/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
})
await setToken(data.access_token, data.refresh_token)
return data
},
async register(email: string, password: string, displayName?: string): Promise<AuthResult> {
const data = await apiFetch<AuthResult>('/auth/register', {
method: 'POST',
body: JSON.stringify({ email, password, display_name: displayName }),
})
await setToken(data.access_token, data.refresh_token)
return data
},
async logout(): Promise<void> {
try {
await apiFetch('/auth/logout', { method: 'POST' })
} catch {
// best-effort
}
await clearTokens()
},
async me(): Promise<Zoomer> {
return apiFetch<Zoomer>('/auth/me')
},
}
// ─── Missions ──────────────────────────────────────────────────────────────
export const missions = {
/** Nearby open missions sorted by distance */
async nearby(lat: number, lng: number, radiusKm = 50): Promise<Mission[]> {
const q = new URLSearchParams({
lat: lat.toString(),
lng: lng.toString(),
radius_km: radiusKm.toString(),
status: 'open',
})
return apiFetch<Mission[]>(`/missions/near?${q}`)
},
/** List all open missions (paginated) */
async list(page = 1, category?: MissionCategory): Promise<Mission[]> {
const q = new URLSearchParams({ page: page.toString(), status: 'open' })
if (category) q.set('category', category)
return apiFetch<Mission[]>(`/missions?${q}`)
},
async get(id: string): Promise<MissionDetail> {
return apiFetch<MissionDetail>(`/missions/${id}`)
},
async accept(id: string): Promise<{ ok: boolean }> {
return apiFetch<{ ok: boolean }>(`/missions/${id}/claim`, { method: 'PATCH' })
},
async submit(id: string, mediaUris: string[]): Promise<Submission> {
const form = new FormData()
for (const uri of mediaUris) {
const filename = uri.split('/').pop() ?? 'image.jpg'
form.append('media', {
uri,
name: filename,
type: 'image/jpeg',
} as unknown as Blob)
}
const token = await getToken()
const res = await fetch(`${API_BASE}/missions/${id}/submit`, {
method: 'POST',
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: form,
})
if (!res.ok) throw new ApiError(res.status, await res.text())
return res.json() as Promise<Submission>
},
/** My active/accepted missions */
async myActive(): Promise<Mission[]> {
return apiFetch<Mission[]>('/missions/claims/active')
},
}
// ─── Wallet / Earnings ─────────────────────────────────────────────────────
export interface WalletBalance {
balance: number // öre
currency: string
pending_payouts: number
}
export const wallet = {
async balance(): Promise<WalletBalance> {
return apiFetch<WalletBalance>('/auth/wallet')
},
}
export const payouts = {
async history(): Promise<Payout[]> {
return apiFetch<Payout[]>('/payouts/history')
},
async pending(): Promise<Payout[]> {
return apiFetch<Payout[]>('/payouts/pending')
},
async request(amount: number): Promise<Payout> {
return apiFetch<Payout>('/payouts/request', {
method: 'POST',
body: JSON.stringify({ amount }),
})
},
}
// ─── Passwordless Auth ───────────────────────────────────────────────────
export interface PasswordlessRequest {
request_id: string
request_token: string
status: 'pending' | 'scanned' | 'approved' | 'denied' | 'expired'
device_info?: {
type: string
name: string
browser?: string
}
created_at: string
expires_at: string
}
export const passwordless = {
/** Approve a passwordless login request */
async approve(requestToken: string): Promise<{ ok: boolean; zoomer?: Zoomer }> {
return apiFetch<{ ok: boolean; zoomer?: Zoomer }>('/auth/passwordless/approve', {
method: 'POST',
body: JSON.stringify({ request_token: requestToken }),
})
},
/** Deny a passwordless login request */
async deny(requestToken: string): Promise<{ ok: boolean }> {
return apiFetch<{ ok: boolean }>('/auth/passwordless/deny', {
method: 'POST',
body: JSON.stringify({ request_token: requestToken }),
})
},
/** Get pending auth requests for current user */
async pending(): Promise<PasswordlessRequest[]> {
return apiFetch<PasswordlessRequest[]>('/auth/passwordless/pending')
},
}
// ─── Profile ───────────────────────────────────────────────────────────────
export const profile = {
async get(): Promise<Zoomer> {
return apiFetch<Zoomer>('/auth/me')
},
async update(data: { display_name?: string }): Promise<Zoomer> {
return apiFetch<Zoomer>('/auth/me', {
method: 'PATCH',
body: JSON.stringify(data),
})
},
}