feat: replace API token field with login flow

New auth.ts service: login() POSTs to /api/auth/token with handle +
password, stores instanceUrl/handle/apiToken in AsyncStorage, password
never persisted. logout() clears all credentials. loadAuthState()
returns stored credentials or null.

Settings screen now shows a login form (URL + handle + password) when
not connected, and a connected state card with Disconnect button when
logged in. km notifications toggle auto-saves without a separate Save
button.
This commit is contained in:
Davide Scaini
2026-06-03 09:43:52 +02:00
parent 9d82084fa1
commit 6e47ced264
2 changed files with 174 additions and 32 deletions
+104 -26
View File
@@ -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<string | null>(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}
/>
<Text style={styles.label}>API Token</Text>
{connectedAs ? (
<View style={styles.connectedBox}>
<View>
<Text style={styles.connectedLabel}>Connected</Text>
<Text style={styles.connectedName}>{connectedAs}</Text>
</View>
<TouchableOpacity style={styles.disconnectBtn} onPress={handleDisconnect}>
<Text style={styles.disconnectBtnText}>Disconnect</Text>
</TouchableOpacity>
</View>
) : (
<>
<Text style={styles.label}>Handle</Text>
<TextInput
style={styles.input}
value={apiToken}
onChangeText={setApiToken}
placeholder="your-api-token"
value={handle}
onChangeText={setHandle}
placeholder="your-handle"
placeholderTextColor="#555"
autoCapitalize="none"
autoCorrect={false}
/>
<Text style={styles.label}>Password</Text>
<TextInput
style={styles.input}
value={password}
onChangeText={setPassword}
placeholder="••••••••"
placeholderTextColor="#555"
secureTextEntry
/>
<Text style={styles.hint}>
Your password is used once to obtain a session token, then forgotten.
</Text>
<TouchableOpacity
style={[styles.connectBtn, connecting && styles.connectBtnDisabled]}
onPress={handleConnect}
disabled={connecting}
>
{connecting
? <ActivityIndicator color="#fff" />
: <Text style={styles.connectBtnText}>Connect</Text>}
</TouchableOpacity>
</>
)}
<Text style={styles.sectionTitle}>Notifications</Text>
<View style={styles.row}>
<Text style={styles.rowLabel}>Kilometre alerts</Text>
<Switch value={kmNotifications} onValueChange={setKmNotifications} trackColor={{ true: '#3b82f6' }} />
<Switch value={kmNotifications} onValueChange={handleKmToggle} trackColor={{ true: '#3b82f6' }} />
</View>
<TouchableOpacity style={styles.saveBtn} onPress={handleSave}>
<Text style={styles.saveBtnText}>{saved ? 'Saved ✓' : 'Save'}</Text>
</TouchableOpacity>
</ScrollView>
);
}
@@ -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' },
});
+64
View File
@@ -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<LoginResult> {
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<void> {
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<AuthState | null> {
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 };
}