diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx index 34872fd..433e201 100644 --- a/src/screens/SettingsScreen.tsx +++ b/src/screens/SettingsScreen.tsx @@ -1,34 +1,70 @@ import React, { useEffect, useState } from 'react'; -import { View, Text, TextInput, StyleSheet, Switch, TouchableOpacity, Alert, ScrollView } from 'react-native'; +import { + View, Text, TextInput, StyleSheet, Switch, + TouchableOpacity, Alert, ScrollView, ActivityIndicator, +} from 'react-native'; import AsyncStorage from '@react-native-async-storage/async-storage'; +import { login, logout, loadAuthState } from '../services/auth'; export function SettingsScreen() { const [instanceUrl, setInstanceUrl] = useState(''); - const [apiToken, setApiToken] = useState(''); + const [handle, setHandle] = useState(''); + const [password, setPassword] = useState(''); + const [connectedAs, setConnectedAs] = useState(null); + const [connecting, setConnecting] = useState(false); const [kmNotifications, setKmNotifications] = useState(true); - const [saved, setSaved] = useState(false); useEffect(() => { (async () => { - const [url, token, km] = await Promise.all([ - AsyncStorage.getItem('instanceUrl'), - AsyncStorage.getItem('apiToken'), + const [auth, km] = await Promise.all([ + loadAuthState(), AsyncStorage.getItem('kmNotifications'), ]); - if (url) setInstanceUrl(url); - if (token) setApiToken(token); + if (auth) { + setInstanceUrl(auth.instanceUrl); + setHandle(auth.handle); + setConnectedAs(auth.handle); + } if (km !== null) setKmNotifications(km === 'true'); })(); }, []); - async function handleSave() { - await Promise.all([ - AsyncStorage.setItem('instanceUrl', instanceUrl.trim()), - AsyncStorage.setItem('apiToken', apiToken.trim()), - AsyncStorage.setItem('kmNotifications', String(kmNotifications)), + async function handleConnect() { + if (!instanceUrl.trim()) { Alert.alert('Required', 'Enter the instance URL.'); return; } + if (!handle.trim()) { Alert.alert('Required', 'Enter your handle.'); return; } + if (!password) { Alert.alert('Required', 'Enter your password.'); return; } + + setConnecting(true); + const result = await login(instanceUrl.trim(), handle.trim(), password); + setConnecting(false); + + if (result.ok) { + setConnectedAs(result.displayName || handle.trim()); + setPassword(''); + } else { + Alert.alert('Login failed', result.error ?? 'Unknown error'); + } + } + + async function handleDisconnect() { + Alert.alert('Disconnect', 'Remove saved credentials?', [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Disconnect', + style: 'destructive', + onPress: async () => { + await logout(); + setConnectedAs(null); + setHandle(''); + setPassword(''); + }, + }, ]); - setSaved(true); - setTimeout(() => setSaved(false), 2000); + } + + async function handleKmToggle(value: boolean) { + setKmNotifications(value); + await AsyncStorage.setItem('kmNotifications', String(value)); } return ( @@ -44,29 +80,64 @@ export function SettingsScreen() { placeholderTextColor="#555" autoCapitalize="none" keyboardType="url" + editable={!connectedAs} /> - API Token - + {connectedAs ? ( + + + Connected + {connectedAs} + + + Disconnect + + + ) : ( + <> + Handle + + + Password + + + + Your password is used once to obtain a session token, then forgotten. + + + + {connecting + ? + : Connect} + + + )} Notifications Kilometre alerts - + - - - {saved ? 'Saved ✓' : 'Save'} - ); } @@ -77,8 +148,15 @@ const styles = StyleSheet.create({ sectionTitle: { color: '#888', fontSize: 13, textTransform: 'uppercase', letterSpacing: 0.8, marginTop: 16 }, label: { color: '#aaa', fontSize: 14, marginBottom: 4 }, input: { backgroundColor: '#1e1e1e', color: '#fff', borderRadius: 10, padding: 14, fontSize: 16 }, + hint: { color: '#555', fontSize: 13, lineHeight: 18 }, + connectedBox: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', backgroundColor: '#1e1e1e', borderRadius: 10, padding: 14 }, + connectedLabel: { color: '#22c55e', fontSize: 12, textTransform: 'uppercase', letterSpacing: 0.6 }, + connectedName: { color: '#fff', fontSize: 16, fontWeight: '600', marginTop: 2 }, + disconnectBtn: { paddingVertical: 6, paddingHorizontal: 12, borderRadius: 8, borderWidth: 1, borderColor: '#ef4444' }, + disconnectBtnText: { color: '#ef4444', fontWeight: '600' }, + connectBtn: { backgroundColor: '#3b82f6', borderRadius: 12, padding: 16, alignItems: 'center', marginTop: 4 }, + connectBtnDisabled: { opacity: 0.6 }, + connectBtnText: { color: '#fff', fontSize: 16, fontWeight: '700' }, row: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', backgroundColor: '#1e1e1e', borderRadius: 10, padding: 14 }, rowLabel: { color: '#fff', fontSize: 16 }, - saveBtn: { backgroundColor: '#3b82f6', borderRadius: 12, padding: 16, alignItems: 'center', marginTop: 8 }, - saveBtnText: { color: '#fff', fontSize: 16, fontWeight: '700' }, }); diff --git a/src/services/auth.ts b/src/services/auth.ts new file mode 100644 index 0000000..e955c7b --- /dev/null +++ b/src/services/auth.ts @@ -0,0 +1,64 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; + +interface LoginResult { + ok: boolean; + displayName?: string; + error?: string; +} + +export async function login( + instanceUrl: string, + handle: string, + password: string, +): Promise { + const url = instanceUrl.replace(/\/$/, '') + '/api/auth/token'; + try { + const resp = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ handle, password }), + }); + + if (!resp.ok) { + const text = await resp.text().catch(() => ''); + return { ok: false, error: text || `HTTP ${resp.status}` }; + } + + const data = await resp.json(); + if (!data.token) return { ok: false, error: 'No token in response' }; + + await Promise.all([ + AsyncStorage.setItem('instanceUrl', instanceUrl.trim()), + AsyncStorage.setItem('handle', handle.trim()), + AsyncStorage.setItem('apiToken', data.token), + ]); + + return { ok: true, displayName: data.display_name }; + } catch (e: any) { + return { ok: false, error: e?.message ?? 'Connection failed' }; + } +} + +export async function logout(): Promise { + await Promise.all([ + AsyncStorage.removeItem('instanceUrl'), + AsyncStorage.removeItem('handle'), + AsyncStorage.removeItem('apiToken'), + ]); +} + +export interface AuthState { + instanceUrl: string; + handle: string; + apiToken: string; +} + +export async function loadAuthState(): Promise { + const [instanceUrl, handle, apiToken] = await Promise.all([ + AsyncStorage.getItem('instanceUrl'), + AsyncStorage.getItem('handle'), + AsyncStorage.getItem('apiToken'), + ]); + if (!instanceUrl || !apiToken) return null; + return { instanceUrl, handle: handle ?? '', apiToken }; +}