diff --git a/app.json b/app.json index 5a8dfa8..6d903e0 100644 --- a/app.json +++ b/app.json @@ -7,7 +7,10 @@ "scheme": "bincio", "userInterfaceStyle": "dark", "newArchEnabled": true, - "platforms": ["ios", "android"], + "platforms": [ + "ios", + "android" + ], "icon": "./assets/icon.png", "splash": { "image": "./assets/splash-icon.png", @@ -39,11 +42,14 @@ "expo-sqlite", [ "expo-document-picker", - { "iCloudContainerEnvironment": "Production" } + { + "iCloudContainerEnvironment": "Production" + } ], "expo-background-fetch", "expo-task-manager", - "@maplibre/maplibre-react-native" + "@maplibre/maplibre-react-native", + "expo-web-browser" ] } } diff --git a/app/(tabs)/settings.tsx b/app/(tabs)/settings.tsx index c38d4d7..e5fcee5 100644 --- a/app/(tabs)/settings.tsx +++ b/app/(tabs)/settings.tsx @@ -1,17 +1,17 @@ import { useSQLiteContext } from 'expo-sqlite'; import { useState } from 'react'; import { - ActivityIndicator, Platform, Pressable, ScrollView, StyleSheet, + ActivityIndicator, Alert, Platform, Pressable, ScrollView, StyleSheet, Text, TextInput, View, } from 'react-native'; -import { deleteRemoteActivities, getSetting, setSetting, useSetting } from '@/db/queries'; +import { deleteRemoteActivities, setSetting, useSetting } from '@/db/queries'; import { PALETTES, type PaletteKey } from '@/theme'; import { useTheme, usePaletteControl } from '@/ThemeContext'; +import { login, logout } from '@/services/auth'; export default function SettingsScreen() { const db = useSQLiteContext(); - const storedUrl = useSetting('instance_url') ?? ''; const storedHandle = useSetting('handle') ?? ''; const storedPath = useSetting('auto_import_path') ?? ''; const storedToken = useSetting('api_token'); @@ -19,66 +19,39 @@ export default function SettingsScreen() { const storedSyncUpload = useSetting('sync_upload') === 'true'; const storedUploadFormat = (useSetting('upload_format') ?? 'raw') as 'raw' | 'bas'; - const [instanceUrl, setInstanceUrl] = useState(storedUrl); - const [handle, setHandle] = useState(storedHandle); const [autoPath, setAutoPath] = useState(storedPath); const [syncMode, setSyncMode] = useState(storedSyncMode); const [syncUpload, setSyncUpload] = useState(storedSyncUpload); const [uploadFormat, setUploadFormat] = useState(storedUploadFormat); - const [saved, setSaved] = useState(false); const theme = useTheme(); const { paletteKey: palette, setPaletteOverride } = usePaletteControl(); - const [password, setPassword] = useState(''); const [connecting, setConnecting] = useState(false); const [connectMsg, setConnectMsg] = useState<{ ok: boolean; text: string } | null>(null); const [resetArmed, setResetArmed] = useState(false); const [resetMsg, setResetMsg] = useState(null); - async function save() { - await setSetting(db, 'instance_url', instanceUrl.trim()); - await setSetting(db, 'handle', handle.trim()); - setSaved(true); - setTimeout(() => setSaved(false), 2000); - } - - async function connect() { - const url = instanceUrl.trim().replace(/\/$/, ''); - const h = handle.trim(); - if (!url || !h || !password) { - setConnectMsg({ ok: false, text: 'Fill in URL, handle, and password first.' }); - return; - } + async function handleConnect() { setConnecting(true); setConnectMsg(null); - try { - const resp = await fetch(`${url}/api/auth/token`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ handle: h, password }), - }); - if (!resp.ok) { - const err = await resp.json().catch(() => ({})); - setConnectMsg({ ok: false, text: err.detail ?? `Error ${resp.status}` }); - return; - } - const data = await resp.json(); - await setSetting(db, 'instance_url', url); - await setSetting(db, 'handle', h); - await setSetting(db, 'api_token', data.token); - setPassword(''); - setConnectMsg({ ok: true, text: `Connected as ${data.display_name || h}` }); - } catch { - setConnectMsg({ ok: false, text: 'Could not reach instance — check the URL.' }); - } finally { - setConnecting(false); + const result = await login(db); + setConnecting(false); + if (result.ok) { + setConnectMsg({ ok: true, text: `Connected as ${result.displayName ?? ''}` }); + } else if (result.error !== 'Cancelled') { + setConnectMsg({ ok: false, text: result.error ?? 'Sign in failed' }); } } - async function disconnect() { - await setSetting(db, 'api_token', ''); - setConnectMsg(null); + async function handleDisconnect() { + Alert.alert('Disconnect', 'Remove saved credentials?', [ + { text: 'Cancel', style: 'cancel' }, + { text: 'Disconnect', style: 'destructive', onPress: async () => { + await logout(db); + setConnectMsg(null); + }}, + ]); } async function resetSyncedData() { @@ -98,78 +71,36 @@ export default function SettingsScreen() { Settings -
- - - - Connect to a Bincio instance to sync your activities. Leave blank to use - the app offline only. - -
- - - - {saved ? '✓ Saved' : 'Save'} - - -
{isConnected ? ( <> - + Disconnect ) : ( - <> - - - {connecting - ? - : Connect} - - + + {connecting + ? + : Sign in with bincio} + )} {connectMsg && ( {connectMsg.text} )} - - Your password is used once to obtain a session token, then forgotten. - The token is stored locally and sent with each sync request. -
{Platform.OS === 'android' && (
- {!storedUrl ? ( + {!isConnected ? ( - Configure and save a Bincio instance URL above first — it's needed to download the extraction engine. + Sign in to a Bincio instance above first — it's needed to download the extraction engine. ) : ( <> @@ -356,11 +287,6 @@ const styles = StyleSheet.create({ }, rowLabel: { color: '#a1a1aa', fontSize: 14 }, rowValue: { color: '#71717a', fontSize: 14 }, - saveButton: { - backgroundColor: '#2563eb', borderRadius: 10, - paddingVertical: 14, alignItems: 'center', marginBottom: 28, - }, - saveButtonText: { color: '#fff', fontWeight: '600', fontSize: 16 }, connectButton: { backgroundColor: '#059669', borderRadius: 8, margin: 12, paddingVertical: 12, alignItems: 'center', diff --git a/package-lock.json b/package-lock.json index d8be60c..e786022 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,8 +10,10 @@ "dependencies": { "@maplibre/maplibre-react-native": "~11.0.0", "expo": "~54.0.33", + "expo-auth-session": "~7.0.11", "expo-background-fetch": "~14.0.9", "expo-constants": "~18.0.13", + "expo-crypto": "~15.0.9", "expo-document-picker": "~14.0.8", "expo-file-system": "~19.0.21", "expo-linking": "~8.0.11", @@ -22,6 +24,7 @@ "expo-status-bar": "~3.0.9", "expo-system-ui": "~6.0.9", "expo-task-manager": "~14.0.9", + "expo-web-browser": "~15.0.11", "react": "19.1.0", "react-dom": "19.1.0", "react-native": "0.81.5", @@ -4561,6 +4564,24 @@ "react-native": "*" } }, + "node_modules/expo-auth-session": { + "version": "7.0.11", + "resolved": "https://registry.npmjs.org/expo-auth-session/-/expo-auth-session-7.0.11.tgz", + "integrity": "sha512-AhWtt/m9rb1Po77X/VBFbeE6UTgbm2vXP2iCblUSRsHCw2qD6lO0ulKUB8Xyxy9FtoI9yrNQ1iwCNgIIgo8VYQ==", + "license": "MIT", + "dependencies": { + "expo-application": "~7.0.8", + "expo-constants": "~18.0.13", + "expo-crypto": "~15.0.9", + "expo-linking": "~8.0.12", + "expo-web-browser": "~15.0.11", + "invariant": "^2.2.4" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/expo-background-fetch": { "version": "14.0.9", "resolved": "https://registry.npmjs.org/expo-background-fetch/-/expo-background-fetch-14.0.9.tgz", @@ -4587,6 +4608,18 @@ "react-native": "*" } }, + "node_modules/expo-crypto": { + "version": "15.0.9", + "resolved": "https://registry.npmjs.org/expo-crypto/-/expo-crypto-15.0.9.tgz", + "integrity": "sha512-SNWKa2fXx7v9gkp1h/7nqXY5XN7qgNDn3yRc2aO0gWGbeMbvob/haMxxsPFe9f51aqH5NjNCqHf2kvLhvAd8KQ==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.0" + }, + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-document-picker": { "version": "14.0.8", "resolved": "https://registry.npmjs.org/expo-document-picker/-/expo-document-picker-14.0.8.tgz", @@ -4631,12 +4664,12 @@ } }, "node_modules/expo-linking": { - "version": "8.0.11", - "resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-8.0.11.tgz", - "integrity": "sha512-+VSaNL5om3kOp/SSKO5qe6cFgfSIWnnQDSbA7XLs3ECkYzXRquk5unxNS3pg7eK5kNUmQ4kgLI7MhTggAEUBLA==", + "version": "8.0.12", + "resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-8.0.12.tgz", + "integrity": "sha512-FpXeIpFgZuxihwT9lBo86YD3y6LphBuAhN680MMxm/Y7fmsc57vimn2d3vFu68VI0+Z9w457t494mu2wvlgWTQ==", "license": "MIT", "dependencies": { - "expo-constants": "~18.0.12", + "expo-constants": "~18.0.13", "invariant": "^2.2.4" }, "peerDependencies": { @@ -5027,6 +5060,16 @@ "react-native": "*" } }, + "node_modules/expo-web-browser": { + "version": "15.0.11", + "resolved": "https://registry.npmjs.org/expo-web-browser/-/expo-web-browser-15.0.11.tgz", + "integrity": "sha512-r2LS4Ro6DgUPZkcaEfgt8mp9eJuoA93x11Jh7S6utFe0FEzvUNn2yFhxg8XVwESaaHGt2k5V8LuK36rsp0BeIw==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, "node_modules/expo/node_modules/@expo/cli": { "version": "54.0.23", "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-54.0.23.tgz", diff --git a/package.json b/package.json index b60a2f1..cab2f1e 100644 --- a/package.json +++ b/package.json @@ -12,8 +12,10 @@ "dependencies": { "@maplibre/maplibre-react-native": "~11.0.0", "expo": "~54.0.33", + "expo-auth-session": "~7.0.11", "expo-background-fetch": "~14.0.9", "expo-constants": "~18.0.13", + "expo-crypto": "~15.0.9", "expo-document-picker": "~14.0.8", "expo-file-system": "~19.0.21", "expo-linking": "~8.0.11", @@ -24,6 +26,7 @@ "expo-status-bar": "~3.0.9", "expo-system-ui": "~6.0.9", "expo-task-manager": "~14.0.9", + "expo-web-browser": "~15.0.11", "react": "19.1.0", "react-dom": "19.1.0", "react-native": "0.81.5", diff --git a/services/auth.ts b/services/auth.ts new file mode 100644 index 0000000..e225ef2 --- /dev/null +++ b/services/auth.ts @@ -0,0 +1,91 @@ +import * as Crypto from 'expo-crypto'; +import * as WebBrowser from 'expo-web-browser'; +import { makeRedirectUri } from 'expo-auth-session'; +import { setSetting } from '@/db/queries'; +import { type SQLiteDatabase } from 'expo-sqlite'; + +const ISSUER = 'https://bincio.org'; +const CLIENT_ID = 'bincio-autarchive'; +const REDIRECT_URI = makeRedirectUri({ scheme: 'bincio', path: 'oauth' }); + +async function generateVerifier(): Promise { + const bytes = await Crypto.getRandomBytesAsync(32); + return btoa(String.fromCharCode(...bytes)) + .replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); +} + +async function deriveChallenge(verifier: string): Promise { + const digest = await Crypto.digestStringAsync( + Crypto.CryptoDigestAlgorithm.SHA256, + verifier, + { encoding: Crypto.CryptoEncoding.BASE64 }, + ); + return digest.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); +} + +export interface LoginResult { + ok: boolean; + displayName?: string; + error?: string; +} + +export async function login(db: SQLiteDatabase): Promise { + const verifier = await generateVerifier(); + const challenge = await deriveChallenge(verifier); + + const authUrl = `${ISSUER}/oauth2/authorize?` + new URLSearchParams({ + client_id: CLIENT_ID, + redirect_uri: REDIRECT_URI, + response_type: 'code', + scope: 'openid profile', + code_challenge: challenge, + code_challenge_method: 'S256', + }); + + const result = await WebBrowser.openAuthSessionAsync(authUrl, REDIRECT_URI); + if (result.type !== 'success') { + return { ok: false, error: result.type === 'cancel' ? 'Cancelled' : 'Browser error' }; + } + + const code = new URL(result.url).searchParams.get('code'); + if (!code) return { ok: false, error: 'No code in redirect' }; + + const tokenResp = await fetch(`${ISSUER}/oauth2/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + code, + redirect_uri: REDIRECT_URI, + client_id: CLIENT_ID, + code_verifier: verifier, + }).toString(), + }); + + if (!tokenResp.ok) { + const text = await tokenResp.text().catch(() => ''); + return { ok: false, error: `Token exchange failed: ${text}` }; + } + + const tokenData = await tokenResp.json(); + const idToken: string = tokenData.id_token; + if (!idToken) return { ok: false, error: 'No id_token in response' }; + + const payload = JSON.parse(atob(idToken.split('.')[1].replace(/-/g, '+').replace(/_/g, '/'))); + + await Promise.all([ + setSetting(db, 'instance_url', ISSUER), + setSetting(db, 'handle', payload.sub ?? ''), + setSetting(db, 'api_token', idToken), + ]); + + return { ok: true, displayName: payload.name ?? payload.preferred_username }; +} + +export async function logout(db: SQLiteDatabase): Promise { + await Promise.all([ + setSetting(db, 'instance_url', ''), + setSetting(db, 'handle', ''), + setSetting(db, 'api_token', ''), + ]); +}