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:
+104
-26
@@ -1,34 +1,70 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
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 AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
import { login, logout, loadAuthState } from '../services/auth';
|
||||||
|
|
||||||
export function SettingsScreen() {
|
export function SettingsScreen() {
|
||||||
const [instanceUrl, setInstanceUrl] = useState('');
|
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 [kmNotifications, setKmNotifications] = useState(true);
|
||||||
const [saved, setSaved] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
const [url, token, km] = await Promise.all([
|
const [auth, km] = await Promise.all([
|
||||||
AsyncStorage.getItem('instanceUrl'),
|
loadAuthState(),
|
||||||
AsyncStorage.getItem('apiToken'),
|
|
||||||
AsyncStorage.getItem('kmNotifications'),
|
AsyncStorage.getItem('kmNotifications'),
|
||||||
]);
|
]);
|
||||||
if (url) setInstanceUrl(url);
|
if (auth) {
|
||||||
if (token) setApiToken(token);
|
setInstanceUrl(auth.instanceUrl);
|
||||||
|
setHandle(auth.handle);
|
||||||
|
setConnectedAs(auth.handle);
|
||||||
|
}
|
||||||
if (km !== null) setKmNotifications(km === 'true');
|
if (km !== null) setKmNotifications(km === 'true');
|
||||||
})();
|
})();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleConnect() {
|
||||||
await Promise.all([
|
if (!instanceUrl.trim()) { Alert.alert('Required', 'Enter the instance URL.'); return; }
|
||||||
AsyncStorage.setItem('instanceUrl', instanceUrl.trim()),
|
if (!handle.trim()) { Alert.alert('Required', 'Enter your handle.'); return; }
|
||||||
AsyncStorage.setItem('apiToken', apiToken.trim()),
|
if (!password) { Alert.alert('Required', 'Enter your password.'); return; }
|
||||||
AsyncStorage.setItem('kmNotifications', String(kmNotifications)),
|
|
||||||
|
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 (
|
return (
|
||||||
@@ -44,29 +80,64 @@ export function SettingsScreen() {
|
|||||||
placeholderTextColor="#555"
|
placeholderTextColor="#555"
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
keyboardType="url"
|
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
|
<TextInput
|
||||||
style={styles.input}
|
style={styles.input}
|
||||||
value={apiToken}
|
value={handle}
|
||||||
onChangeText={setApiToken}
|
onChangeText={setHandle}
|
||||||
placeholder="your-api-token"
|
placeholder="your-handle"
|
||||||
placeholderTextColor="#555"
|
placeholderTextColor="#555"
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Text style={styles.label}>Password</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
value={password}
|
||||||
|
onChangeText={setPassword}
|
||||||
|
placeholder="••••••••"
|
||||||
|
placeholderTextColor="#555"
|
||||||
secureTextEntry
|
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>
|
<Text style={styles.sectionTitle}>Notifications</Text>
|
||||||
|
|
||||||
<View style={styles.row}>
|
<View style={styles.row}>
|
||||||
<Text style={styles.rowLabel}>Kilometre alerts</Text>
|
<Text style={styles.rowLabel}>Kilometre alerts</Text>
|
||||||
<Switch value={kmNotifications} onValueChange={setKmNotifications} trackColor={{ true: '#3b82f6' }} />
|
<Switch value={kmNotifications} onValueChange={handleKmToggle} trackColor={{ true: '#3b82f6' }} />
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<TouchableOpacity style={styles.saveBtn} onPress={handleSave}>
|
|
||||||
<Text style={styles.saveBtnText}>{saved ? 'Saved ✓' : 'Save'}</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -77,8 +148,15 @@ const styles = StyleSheet.create({
|
|||||||
sectionTitle: { color: '#888', fontSize: 13, textTransform: 'uppercase', letterSpacing: 0.8, marginTop: 16 },
|
sectionTitle: { color: '#888', fontSize: 13, textTransform: 'uppercase', letterSpacing: 0.8, marginTop: 16 },
|
||||||
label: { color: '#aaa', fontSize: 14, marginBottom: 4 },
|
label: { color: '#aaa', fontSize: 14, marginBottom: 4 },
|
||||||
input: { backgroundColor: '#1e1e1e', color: '#fff', borderRadius: 10, padding: 14, fontSize: 16 },
|
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 },
|
row: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', backgroundColor: '#1e1e1e', borderRadius: 10, padding: 14 },
|
||||||
rowLabel: { color: '#fff', fontSize: 16 },
|
rowLabel: { color: '#fff', fontSize: 16 },
|
||||||
saveBtn: { backgroundColor: '#3b82f6', borderRadius: 12, padding: 16, alignItems: 'center', marginTop: 8 },
|
|
||||||
saveBtnText: { color: '#fff', fontSize: 16, fontWeight: '700' },
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user