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
342 lines
10 KiB
TypeScript
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),
|
|
})
|
|
},
|
|
}
|