/** * OAuth-Browser โ€” Verwaltung der OAuth-Provider (Spotify + Custom) und ihrer * Credentials. Eingesetzt von SettingsScreen โ†’ Sektion "OAuth-Apps". * * Pro Service: * - Status (verbunden / konfiguriert / leer) * - client_id + client_secret (Passwort-Toggle) * - Bei Custom-Services: auch auth_url + token_url + scopes editierbar * - "Autorisieren โ†—" oeffnet die Provider-Auth-Seite im System-Browser * - "Abmelden" + (bei Custom) "๐Ÿ—‘ Service entfernen" * * Plus: "+ Custom-Service" oeffnet ein Modal fuer name/auth_url/token_url/scopes. * * Hinweis zu Credentials: client_id/client_secret laufen ueber HTTP zur * Bridge, von dort zum Brain. Wenn die App via RVS verbunden ist, geht alles * ueber TLS (wss://) โ€” der Wert ist nie im Klartext im Netz unterwegs. */ import React, { useCallback, useEffect, useState } from 'react'; import { ActivityIndicator, Alert, FlatList, Linking, Modal, ScrollView, StyleSheet, Text, TextInput, TouchableOpacity, View, } from 'react-native'; import brainApi, { OAuthServiceStatus, OAuthAppConfig } from '../services/brainApi'; const COL_OK = '#34C759'; const COL_PENDING = '#FFD60A'; const COL_OFF = '#666680'; const COL_ERR = '#FF6B6B'; function fmtExpiry(secs: number | null | undefined): string { if (secs == null) return ''; if (secs <= 0) return 'abgelaufen'; if (secs < 60) return `${secs}s`; if (secs < 3600) return `${Math.round(secs / 60)} min`; if (secs < 86400) return `${Math.round(secs / 3600)} h`; return `${Math.round(secs / 86400)} Tage`; } interface MergedService extends OAuthServiceStatus { app?: OAuthAppConfig; isDefault: boolean; } export const OAuthBrowser: React.FC = () => { const [services, setServices] = useState([]); const [loading, setLoading] = useState(false); const [err, setErr] = useState(null); const [editService, setEditService] = useState(null); const [showNew, setShowNew] = useState(false); const load = useCallback(() => { setLoading(true); setErr(null); Promise.all([brainApi.listOAuthServices(), brainApi.getOAuthApps()]) .then(([statusRes, appsRes]) => { const apps = appsRes.apps || {}; const defaults = new Set(appsRes.defaults || []); const items: MergedService[] = (statusRes.services || []).map(s => ({ ...s, app: apps[s.service], isDefault: defaults.has(s.service), })); items.sort((a, b) => { if (a.authenticated !== b.authenticated) return a.authenticated ? -1 : 1; if (a.configured !== b.configured) return a.configured ? -1 : 1; return a.service.localeCompare(b.service); }); setServices(items); }) .catch(e => setErr(String(e?.message || e))) .finally(() => setLoading(false)); }, []); useEffect(() => { load(); }, [load]); const renderItem = ({ item }: { item: MergedService }) => { let statusColor: string = COL_OFF; let statusIcon = 'โšซ'; let statusText = 'nicht konfiguriert'; if (item.authenticated) { statusColor = COL_OK; statusIcon = 'โœ…'; statusText = `verbunden${item.expiresInSec != null ? ' ยท noch ' + fmtExpiry(item.expiresInSec) : ''}`; } else if (item.configured) { statusColor = COL_PENDING; statusIcon = '๐ŸŸก'; statusText = 'konfiguriert, nicht autorisiert'; } return ( setEditService(item)}> {item.service} {!item.isDefault ? ( (custom) ) : null} {statusIcon} {statusText} ); }; return ( Verbinde ARIA mit externen Services (Spotify u.a.). {'โ†ป'} setShowNew(true)} style={[s.iconBtn, {backgroundColor: '#0096FF'}]}> + Custom {err ? {err} : null} {loading && services.length === 0 ? ( ) : ( s.service} renderItem={renderItem} nestedScrollEnabled={true} ListEmptyComponent={ (keine OAuth-Services โ€” frag ARIA: "verbinde mich mit X") } contentContainerStyle={{paddingBottom: 20}} /> )} {editService ? ( setEditService(null)} onReload={() => { setEditService(null); load(); }} /> ) : null} {showNew ? ( setShowNew(false)} onCreated={() => { setShowNew(false); load(); }} /> ) : null} ); }; // โ”€โ”€ Edit-Modal (Credentials + Authorize + Revoke + Delete) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ interface EditProps { service: MergedService; onClose: () => void; onReload: () => void; } const OAuthEditModal: React.FC = ({ service: svc, onClose, onReload }) => { const [clientId, setClientId] = useState(svc.app?.client_id || ''); const [clientSecret, setClientSecret] = useState(''); const [showSecret, setShowSecret] = useState(false); const [authUrl, setAuthUrl] = useState(svc.app?.auth_url || ''); const [tokenUrl, setTokenUrl] = useState(svc.app?.token_url || ''); const [scopes, setScopes] = useState((svc.app?.scopes || []).join(' ')); const [saving, setSaving] = useState(false); const [showAdvanced, setShowAdvanced] = useState(false); const save = async () => { if (!clientId.trim()) { Alert.alert('Fehler', 'client_id darf nicht leer sein.'); return; } setSaving(true); const body: any = { service: svc.service, client_id: clientId.trim(), }; if (clientSecret) body.client_secret = clientSecret; if (authUrl.trim()) body.auth_url = authUrl.trim(); if (tokenUrl.trim()) body.token_url = tokenUrl.trim(); if (scopes.trim()) body.scopes = scopes.trim().split(/\s+/).filter(Boolean); try { await brainApi.saveOAuthApp(body); onReload(); } catch (e: any) { Alert.alert('Speichern fehlgeschlagen', String(e?.message || e)); } finally { setSaving(false); } }; const authorize = async () => { if (!svc.configured) { Alert.alert('Erst Credentials eintragen', 'client_id und client_secret muessen vor dem Autorisieren gespeichert sein.'); return; } try { const r = await brainApi.authorizeOAuth(svc.service); // Im System-Browser oeffnen โ€” InAppBrowser wuerde z.T. von Providern blockiert const ok = await Linking.canOpenURL(r.url); if (!ok) { Alert.alert('Browser nicht verfuegbar', 'Konnte die Auth-URL nicht oeffnen.'); return; } Linking.openURL(r.url); Alert.alert( 'Im Browser anmelden', `Bitte stimme bei ${svc.service} zu. Nach dem Redirect zur Callback-Seite kannst du den Tab schliessen โ€” ARIA bekommt das Token automatisch.\n\nDie Status-Anzeige in der App aktualisiert sich nach Refresh.`, [{ text: 'OK', onPress: () => setTimeout(onReload, 8000) }], ); } catch (e: any) { Alert.alert('Authorize fehlgeschlagen', String(e?.message || e)); } }; const revoke = () => { Alert.alert( 'Abmelden?', `Token fuer ${svc.service} entfernen. Du musst danach neu autorisieren.`, [ { text: 'Abbrechen', style: 'cancel' }, { text: 'Abmelden', style: 'destructive', onPress: async () => { try { await brainApi.revokeOAuth(svc.service); onReload(); } catch (e: any) { Alert.alert('Fehler', String(e?.message || e)); } }, }, ], ); }; const removeService = () => { Alert.alert( 'Service komplett entfernen?', `"${svc.service}" wird inkl. client_id/secret und Token geloescht.`, [ { text: 'Abbrechen', style: 'cancel' }, { text: 'Loeschen', style: 'destructive', onPress: async () => { try { await brainApi.deleteOAuthApp(svc.service); onReload(); } catch (e: any) { Alert.alert('Fehler', String(e?.message || e)); } }, }, ], ); }; return ( {svc.service} {'โœ•'} {svc.authenticated ? ( โœ… verbunden{svc.expiresInSec != null ? ` ยท Token noch ${fmtExpiry(svc.expiresInSec)}` : ''} {svc.hasRefresh ? refresh_token vorhanden โ€” auto-renew aktiv : KEIN refresh_token โ€” Token verfaellt komplett} {svc.scope ? scopes: {svc.scope} : null} ) : null} client_id client_secret {svc.app?.has_client_secret ? 'โ€” gespeichert (leer = behalten)' : 'โ€” fehlt'} setShowSecret(v => !v)} > {showSecret ? '๐Ÿ™ˆ' : '๐Ÿ‘'} {/* URLs/Scopes: bei Defaults hinter "advanced" versteckt damit Stefan nicht ausversehen die Spotify-URLs ueberschreibt. */} {svc.isDefault ? ( setShowAdvanced(v => !v)} style={{marginTop: 12}}> {showAdvanced ? 'โ–ผ' : 'โ–ถ'} Default-URLs ueberschreiben (advanced) ) : null} {(!svc.isDefault || showAdvanced) ? ( auth_url token_url scopes (space-separated) ) : null} {saving ? 'speichert...' : 'Speichern'} Autorisieren โ†— {svc.authenticated ? ( Abmelden (Token loeschen) ) : null} {!svc.isDefault ? ( ๐Ÿ—‘ Service komplett entfernen ) : null} ); }; // โ”€โ”€ Neuer Custom-Provider โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ interface NewProps { onClose: () => void; onCreated: () => void; } const OAuthCustomNewModal: React.FC = ({ onClose, onCreated }) => { const [name, setName] = useState(''); const [authUrl, setAuthUrl] = useState('https://'); const [tokenUrl, setTokenUrl] = useState('https://'); const [scopes, setScopes] = useState(''); const [creating, setCreating] = useState(false); const create = async () => { const svc = name.trim().toLowerCase(); if (!/^[a-z0-9_-]+$/.test(svc)) { Alert.alert('Ungueltiger Name', 'Erlaubt: a-z 0-9 _ -'); return; } if (!authUrl.startsWith('http') || !tokenUrl.startsWith('http')) { Alert.alert('Ungueltige URLs', 'auth_url und token_url muessen http(s):// sein.'); return; } setCreating(true); try { const body: any = { service: svc, auth_url: authUrl.trim(), token_url: tokenUrl.trim() }; if (scopes.trim()) body.scopes = scopes.trim().split(/\s+/).filter(Boolean); await brainApi.saveOAuthApp(body); onCreated(); } catch (e: any) { Alert.alert('Anlegen fehlgeschlagen', String(e?.message || e)); } finally { setCreating(false); } }; return ( Custom OAuth-Provider {'โœ•'} Trag die OAuth2-Endpunkte des Anbieters ein. client_id + client_secret kommen anschliessend ins Edit-Formular. Die Callback-URL die du beim Anbieter eintragen musst, zeigt dir der OAuth-Block im Brain-System-Prompt. Service-Name (z.B. dropbox, discord) auth_url token_url scopes (space-separated, optional) Abbrechen {creating ? '...' : 'Anlegen'} ); }; // โ”€โ”€ Styles โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ const s = StyleSheet.create({ toolbar: { flexDirection: 'row', alignItems: 'center', gap: 8, paddingHorizontal: 10, paddingVertical: 8, backgroundColor: '#0D0D1A', borderBottomWidth: 1, borderBottomColor: '#1E1E2E', }, iconBtn: { paddingHorizontal: 10, paddingVertical: 5, borderRadius: 6, backgroundColor: '#1A1A2E', }, row: { paddingVertical: 12, paddingHorizontal: 14, backgroundColor: '#0D0D1A', borderBottomWidth: 1, borderBottomColor: '#1E1E2E', }, err: { color: '#FF6B6B', padding: 12, fontSize: 12, }, modal: { flex: 1, backgroundColor: '#0D0D1A', }, modalHeader: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: 16, paddingVertical: 12, borderBottomWidth: 1, borderBottomColor: '#1E1E2E', }, modalTitle: { color: '#E0E0F0', fontSize: 16, fontWeight: '700', flex: 1, marginRight: 12, textTransform: 'capitalize', }, label: { color: '#8888AA', fontSize: 11, fontWeight: '700', textTransform: 'uppercase', letterSpacing: 0.5, marginTop: 12, marginBottom: 4, }, input: { backgroundColor: '#1A1A2E', borderWidth: 1, borderColor: '#1E1E2E', borderRadius: 6, color: '#E0E0F0', padding: 10, fontSize: 14, fontFamily: 'monospace', }, metaBox: { backgroundColor: '#1A1A2E', borderRadius: 6, padding: 10, gap: 4, }, meta: { color: '#8888AA', fontSize: 12, }, btn: { paddingHorizontal: 14, paddingVertical: 10, borderRadius: 6, borderWidth: 1, borderColor: 'transparent', }, }); export default OAuthBrowser;