feat(oauth): ARIA kann Provider selbst registrieren + Custom-Provider in Diagnostic & App
ARIA hat jetzt das META-Tool oauth_register_provider. Wenn Stefan einen Service nutzen will, der nicht in den (auf Spotify reduzierten) Defaults ist, kann sie auth_url/token_url/scopes/client_auth selbst eintragen — ARIA kennt typische OAuth-Endpunkte (Dropbox, Discord, Notion, Slack, Zoom, Trello, LinkedIn, Reddit, Twitch) aus ihrem Training. Sie traegt NUR die URLs ein, client_id/secret bleiben Stefans Job (Diagnostic / App-UI) — bewusste Trennung damit Credentials nicht im Chat-Verlauf landen. DEFAULT_PROVIDERS auf Spotify reduziert — Rest war aktuell ungenutzt und macht den Code unnoetig "groß". ARIA registriert on-demand. Diagnostic-UI: - Custom-Provider zeigen auth_url/token_url/scopes als sichtbare Felder - Defaults verstecken die Felder hinter "Default-URLs ueberschreiben (advanced)" damit man die Spotify-URLs nicht versehentlich loescht - "+ Custom OAuth-Provider hinzufuegen" Button mit Prompts fuer Name/URLs/Scopes - 🗑-Icon bei Custom-Services (Service komplett entfernen) App-UI (neu fuer unterwegs): - Settings → Sektion 🔑 "OAuth-Apps" zwischen Skills und Protokoll - OAuthBrowser-Komponente analog zu Trigger/Skill-Browser: Liste mit Status, Tap → Edit-Modal mit client_id/secret + Advanced-Toggle fuer URLs. "Autorisieren ↗" oeffnet System-Browser via Linking.openURL, redirected zur RVS-Callback-Page, Status-Refresh nach 8s. - "+ Custom"-Button → Full-Screen-Modal fuer Service-Anlage. - brainApi um listOAuthServices/getOAuthApps/saveOAuthApp/ deleteOAuthApp/authorizeOAuth/revokeOAuth erweitert. Workflow ist jetzt: "verbinde mich mit Dropbox" → ARIA registriert Provider → "trag client_id/secret in Settings ein" → Stefan macht das in App oder Diagnostic → "Autorisieren ↗" → fertig. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,614 @@
|
||||
/**
|
||||
* 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<MergedService[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [editService, setEditService] = useState<MergedService | null>(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 (
|
||||
<TouchableOpacity style={s.row} onPress={() => setEditService(item)}>
|
||||
<View style={{flex: 1, marginRight: 8}}>
|
||||
<View style={{flexDirection: 'row', alignItems: 'center', gap: 6, marginBottom: 2}}>
|
||||
<Text style={{color: '#E0E0F0', fontWeight: '600', fontSize: 14, textTransform: 'capitalize'}}>{item.service}</Text>
|
||||
{!item.isDefault ? (
|
||||
<Text style={{color: '#8888AA', fontSize: 10}}>(custom)</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<Text style={{color: statusColor, fontSize: 12}}>{statusIcon} {statusText}</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={{flex: 1}}>
|
||||
<View style={s.toolbar}>
|
||||
<Text style={{color: '#8888AA', fontSize: 11, flex: 1}}>
|
||||
Verbinde ARIA mit externen Services (Spotify u.a.).
|
||||
</Text>
|
||||
<TouchableOpacity onPress={load} style={s.iconBtn}>
|
||||
<Text style={{fontSize: 16}}>{'↻'}</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={() => setShowNew(true)} style={[s.iconBtn, {backgroundColor: '#0096FF'}]}>
|
||||
<Text style={{fontSize: 13, color: '#fff', fontWeight: '700'}}>+ Custom</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{err ? <Text style={s.err}>{err}</Text> : null}
|
||||
|
||||
{loading && services.length === 0 ? (
|
||||
<ActivityIndicator color="#0096FF" style={{marginTop: 20}} />
|
||||
) : (
|
||||
<FlatList
|
||||
data={services}
|
||||
keyExtractor={s => s.service}
|
||||
renderItem={renderItem}
|
||||
nestedScrollEnabled={true}
|
||||
ListEmptyComponent={
|
||||
<Text style={{color: '#555570', textAlign: 'center', padding: 20, fontStyle: 'italic'}}>
|
||||
(keine OAuth-Services — frag ARIA: "verbinde mich mit X")
|
||||
</Text>
|
||||
}
|
||||
contentContainerStyle={{paddingBottom: 20}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{editService ? (
|
||||
<OAuthEditModal
|
||||
service={editService}
|
||||
onClose={() => setEditService(null)}
|
||||
onReload={() => { setEditService(null); load(); }}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{showNew ? (
|
||||
<OAuthCustomNewModal
|
||||
onClose={() => setShowNew(false)}
|
||||
onCreated={() => { setShowNew(false); load(); }}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Edit-Modal (Credentials + Authorize + Revoke + Delete) ──────────
|
||||
|
||||
interface EditProps {
|
||||
service: MergedService;
|
||||
onClose: () => void;
|
||||
onReload: () => void;
|
||||
}
|
||||
|
||||
const OAuthEditModal: React.FC<EditProps> = ({ 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 (
|
||||
<Modal visible animationType="slide" onRequestClose={onClose} transparent={false}>
|
||||
<View style={s.modal}>
|
||||
<View style={s.modalHeader}>
|
||||
<Text style={s.modalTitle} numberOfLines={1}>{svc.service}</Text>
|
||||
<TouchableOpacity onPress={onClose} hitSlop={{top:8,bottom:8,left:8,right:8}}>
|
||||
<Text style={{color: '#8888AA', fontSize: 18}}>{'✕'}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<ScrollView style={{flex: 1}} contentContainerStyle={{padding: 16}}>
|
||||
{svc.authenticated ? (
|
||||
<View style={[s.metaBox, {borderLeftWidth: 3, borderLeftColor: COL_OK, marginBottom: 12}]}>
|
||||
<Text style={[s.meta, {color: COL_OK, fontWeight: '700'}]}>
|
||||
✅ verbunden{svc.expiresInSec != null ? ` · Token noch ${fmtExpiry(svc.expiresInSec)}` : ''}
|
||||
</Text>
|
||||
{svc.hasRefresh ? <Text style={s.meta}>refresh_token vorhanden — auto-renew aktiv</Text>
|
||||
: <Text style={[s.meta, {color: COL_ERR}]}>KEIN refresh_token — Token verfaellt komplett</Text>}
|
||||
{svc.scope ? <Text style={s.meta}>scopes: {svc.scope}</Text> : null}
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<Text style={s.label}>client_id</Text>
|
||||
<TextInput
|
||||
style={s.input}
|
||||
value={clientId}
|
||||
onChangeText={setClientId}
|
||||
placeholder="aus dem Provider-Developer-Dashboard"
|
||||
placeholderTextColor="#444460"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
|
||||
<Text style={s.label}>
|
||||
client_secret {svc.app?.has_client_secret ? '— gespeichert (leer = behalten)' : '— fehlt'}
|
||||
</Text>
|
||||
<View style={{flexDirection: 'row', gap: 6}}>
|
||||
<TextInput
|
||||
style={[s.input, {flex: 1}]}
|
||||
value={clientSecret}
|
||||
onChangeText={setClientSecret}
|
||||
placeholder={svc.app?.has_client_secret ? '(neuen eintragen oder leer lassen)' : 'aus dem Dashboard'}
|
||||
placeholderTextColor="#444460"
|
||||
secureTextEntry={!showSecret}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
<TouchableOpacity
|
||||
style={[s.btn, {backgroundColor: '#1A1A2E', justifyContent: 'center'}]}
|
||||
onPress={() => setShowSecret(v => !v)}
|
||||
>
|
||||
<Text style={{color: '#8888AA', fontSize: 14}}>{showSecret ? '🙈' : '👁'}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* URLs/Scopes: bei Defaults hinter "advanced" versteckt damit Stefan
|
||||
nicht ausversehen die Spotify-URLs ueberschreibt. */}
|
||||
{svc.isDefault ? (
|
||||
<TouchableOpacity onPress={() => setShowAdvanced(v => !v)} style={{marginTop: 12}}>
|
||||
<Text style={{color: '#666680', fontSize: 11, fontStyle: 'italic'}}>
|
||||
{showAdvanced ? '▼' : '▶'} Default-URLs ueberschreiben (advanced)
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
) : null}
|
||||
|
||||
{(!svc.isDefault || showAdvanced) ? (
|
||||
<View style={{marginTop: 8}}>
|
||||
<Text style={s.label}>auth_url</Text>
|
||||
<TextInput
|
||||
style={s.input}
|
||||
value={authUrl}
|
||||
onChangeText={setAuthUrl}
|
||||
placeholder="https://provider.com/oauth/authorize"
|
||||
placeholderTextColor="#444460"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
<Text style={s.label}>token_url</Text>
|
||||
<TextInput
|
||||
style={s.input}
|
||||
value={tokenUrl}
|
||||
onChangeText={setTokenUrl}
|
||||
placeholder="https://provider.com/oauth/token"
|
||||
placeholderTextColor="#444460"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
<Text style={s.label}>scopes (space-separated)</Text>
|
||||
<TextInput
|
||||
style={s.input}
|
||||
value={scopes}
|
||||
onChangeText={setScopes}
|
||||
placeholder="read write user.email"
|
||||
placeholderTextColor="#444460"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<View style={{flexDirection: 'row', gap: 8, marginTop: 16}}>
|
||||
<TouchableOpacity
|
||||
style={[s.btn, {backgroundColor: '#0096FF', flex: 1}]}
|
||||
onPress={save}
|
||||
disabled={saving}
|
||||
>
|
||||
<Text style={{color: '#fff', textAlign: 'center', fontWeight: '700'}}>
|
||||
{saving ? 'speichert...' : 'Speichern'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[s.btn, {backgroundColor: svc.configured ? '#34C759' : '#1E1E2E', flex: 1}]}
|
||||
onPress={authorize}
|
||||
disabled={!svc.configured}
|
||||
>
|
||||
<Text style={{color: svc.configured ? '#fff' : '#555570', textAlign: 'center', fontWeight: '700'}}>
|
||||
Autorisieren ↗
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{svc.authenticated ? (
|
||||
<TouchableOpacity
|
||||
style={[s.btn, {backgroundColor: '#3A1F1F', borderColor: COL_ERR, marginTop: 12}]}
|
||||
onPress={revoke}
|
||||
>
|
||||
<Text style={{color: COL_ERR, textAlign: 'center', fontWeight: '700'}}>Abmelden (Token loeschen)</Text>
|
||||
</TouchableOpacity>
|
||||
) : null}
|
||||
|
||||
{!svc.isDefault ? (
|
||||
<TouchableOpacity
|
||||
style={[s.btn, {backgroundColor: '#3A1F1F', borderColor: COL_ERR, marginTop: 8}]}
|
||||
onPress={removeService}
|
||||
>
|
||||
<Text style={{color: COL_ERR, textAlign: 'center', fontWeight: '700'}}>🗑 Service komplett entfernen</Text>
|
||||
</TouchableOpacity>
|
||||
) : null}
|
||||
|
||||
<View style={{height: 30}} />
|
||||
</ScrollView>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Neuer Custom-Provider ──────────────────────────────────────────
|
||||
|
||||
interface NewProps {
|
||||
onClose: () => void;
|
||||
onCreated: () => void;
|
||||
}
|
||||
|
||||
const OAuthCustomNewModal: React.FC<NewProps> = ({ 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 (
|
||||
<Modal visible animationType="slide" onRequestClose={onClose} transparent={false}>
|
||||
<View style={s.modal}>
|
||||
<View style={s.modalHeader}>
|
||||
<Text style={s.modalTitle}>Custom OAuth-Provider</Text>
|
||||
<TouchableOpacity onPress={onClose} hitSlop={{top:8,bottom:8,left:8,right:8}}>
|
||||
<Text style={{color: '#8888AA', fontSize: 18}}>{'✕'}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<ScrollView style={{flex: 1}} contentContainerStyle={{padding: 16}}>
|
||||
<Text style={{color: '#8888AA', fontSize: 12, marginBottom: 12}}>
|
||||
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.
|
||||
</Text>
|
||||
|
||||
<Text style={s.label}>Service-Name (z.B. dropbox, discord)</Text>
|
||||
<TextInput
|
||||
style={s.input}
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
placeholder="kurz, a-z 0-9 _ -"
|
||||
placeholderTextColor="#444460"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
|
||||
<Text style={s.label}>auth_url</Text>
|
||||
<TextInput
|
||||
style={s.input}
|
||||
value={authUrl}
|
||||
onChangeText={setAuthUrl}
|
||||
placeholder="https://provider.com/oauth/authorize"
|
||||
placeholderTextColor="#444460"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
|
||||
<Text style={s.label}>token_url</Text>
|
||||
<TextInput
|
||||
style={s.input}
|
||||
value={tokenUrl}
|
||||
onChangeText={setTokenUrl}
|
||||
placeholder="https://provider.com/oauth/token"
|
||||
placeholderTextColor="#444460"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
|
||||
<Text style={s.label}>scopes (space-separated, optional)</Text>
|
||||
<TextInput
|
||||
style={s.input}
|
||||
value={scopes}
|
||||
onChangeText={setScopes}
|
||||
placeholder="read write user.email"
|
||||
placeholderTextColor="#444460"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
|
||||
<View style={{flexDirection: 'row', gap: 8, marginTop: 20}}>
|
||||
<TouchableOpacity style={[s.btn, {backgroundColor: '#1A1A2E', flex: 1}]} onPress={onClose}>
|
||||
<Text style={{color: '#8888AA', textAlign: 'center'}}>Abbrechen</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={[s.btn, {backgroundColor: '#0096FF', flex: 1}]} onPress={create} disabled={creating}>
|
||||
<Text style={{color: '#fff', textAlign: 'center', fontWeight: '700'}}>
|
||||
{creating ? '...' : 'Anlegen'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
// ── 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;
|
||||
@@ -57,6 +57,7 @@ import { acquireBackgroundAudio, releaseBackgroundAudio } from '../services/back
|
||||
import MemoryBrowser from '../components/MemoryBrowser';
|
||||
import TriggerBrowser from '../components/TriggerBrowser';
|
||||
import SkillBrowser from '../components/SkillBrowser';
|
||||
import OAuthBrowser from '../components/OAuthBrowser';
|
||||
import { isVerboseLogging, setVerboseLogging } from '../services/logger';
|
||||
import {
|
||||
isWakeReadySoundEnabled,
|
||||
@@ -108,6 +109,7 @@ const SETTINGS_SECTIONS = [
|
||||
{ id: 'memory', icon: '🧠', label: 'Gedächtnis', desc: 'ARIA-Memories durchsuchen, anlegen, bearbeiten, löschen' },
|
||||
{ id: 'triggers', icon: '⏰', label: 'Trigger', desc: 'Timer + Watcher anlegen, bearbeiten, löschen' },
|
||||
{ id: 'skills', icon: '🛠️', label: 'Skills', desc: 'Skills ausführen, aktivieren, Logs ansehen, löschen' },
|
||||
{ id: 'oauth', icon: '🔑', label: 'OAuth-Apps', desc: 'Spotify, Dropbox, ... — client_id/secret, autorisieren, abmelden' },
|
||||
{ id: 'protocol', icon: '📜', label: 'Protokoll', desc: 'Privatsphaere, Backup' },
|
||||
{ id: 'about', icon: 'ℹ️', label: 'Ueber', desc: 'App-Version, Update' },
|
||||
] as const;
|
||||
@@ -930,7 +932,7 @@ const SettingsScreen: React.FC = () => {
|
||||
// Wenn eine Section eine eigene voll-hoch-scrollende Sub-Liste hat
|
||||
// (Memory, Trigger), den outer Scroll deaktivieren — Android-nested-
|
||||
// scrolling laesst sonst nur in eine Richtung scrollen.
|
||||
scrollEnabled={currentSection !== 'memory' && currentSection !== 'triggers' && currentSection !== 'skills'}
|
||||
scrollEnabled={currentSection !== 'memory' && currentSection !== 'triggers' && currentSection !== 'skills' && currentSection !== 'oauth'}
|
||||
>
|
||||
|
||||
{currentSection === null && (
|
||||
@@ -1824,6 +1826,20 @@ const SettingsScreen: React.FC = () => {
|
||||
</View>
|
||||
</>)}
|
||||
|
||||
{/* === OAuth-Apps === */}
|
||||
{currentSection === 'oauth' && (<>
|
||||
<Text style={styles.sectionTitle}>OAuth-Apps</Text>
|
||||
<Text style={{color: '#8888AA', fontSize: 12, marginBottom: 8, paddingHorizontal: 4}}>
|
||||
Verbinde ARIA mit externen Services (Spotify, Dropbox, Discord, ...).
|
||||
Trag client_id + client_secret aus dem Developer-Dashboard des Anbieters ein,
|
||||
dann "Autorisieren ↗" tippen. Custom-Services kannst Du via "+ Custom" anlegen —
|
||||
ARIA kann das auch selbst per Chat ("verbinde mich mit X").
|
||||
</Text>
|
||||
<View style={{height: winDims.height - 220, marginBottom: 8}}>
|
||||
<OAuthBrowser />
|
||||
</View>
|
||||
</>)}
|
||||
|
||||
{/* === Logs === */}
|
||||
{currentSection === 'protocol' && (<>
|
||||
<Text style={styles.sectionTitle}>Protokoll</Text>
|
||||
|
||||
@@ -121,6 +121,27 @@ export interface Memory {
|
||||
attachments?: MemoryAttachment[];
|
||||
}
|
||||
|
||||
/** OAuth-Service-Status wie aus Brain `/oauth/services` zurueckkommt. */
|
||||
export interface OAuthServiceStatus {
|
||||
service: string;
|
||||
configured: boolean;
|
||||
authenticated: boolean;
|
||||
expiresAt?: number | null;
|
||||
expiresInSec?: number | null;
|
||||
hasRefresh: boolean;
|
||||
scope?: string;
|
||||
isDefault: boolean;
|
||||
}
|
||||
|
||||
/** OAuth-App-Config (client_id/scopes/URLs) — client_secret kommt NIE rausgegeben. */
|
||||
export interface OAuthAppConfig {
|
||||
client_id: string;
|
||||
has_client_secret: boolean;
|
||||
scopes?: string[] | null;
|
||||
auth_url?: string | null;
|
||||
token_url?: string | null;
|
||||
}
|
||||
|
||||
/** Skill-Manifest wie aus Brain `/skills/list` zurueckkommt. */
|
||||
export interface Skill {
|
||||
name: string;
|
||||
@@ -376,6 +397,62 @@ export const brainApi = {
|
||||
getSkillLogs(name: string, limit: number = 20): Promise<any[]> {
|
||||
return _send(`/skills/${encodeURIComponent(name)}/logs?limit=${limit}`);
|
||||
},
|
||||
|
||||
// ── OAuth ────────────────────────────────────────────────────────
|
||||
|
||||
/** Liste aller Services mit Auth-Status (configured/authenticated/expires). */
|
||||
listOAuthServices(): Promise<{ services: OAuthServiceStatus[] }> {
|
||||
return _send('/oauth/services');
|
||||
},
|
||||
|
||||
/** Persistierte Provider-Configs (URLs/scopes/client_id, KEIN client_secret). */
|
||||
getOAuthApps(): Promise<{ apps: Record<string, OAuthAppConfig>; defaults: string[] }> {
|
||||
return _send('/oauth/apps');
|
||||
},
|
||||
|
||||
/** Provider-Config setzen/aktualisieren. Leerer client_secret laesst
|
||||
* den bestehenden Wert stehen. */
|
||||
saveOAuthApp(body: {
|
||||
service: string;
|
||||
client_id?: string;
|
||||
client_secret?: string;
|
||||
scopes?: string[];
|
||||
auth_url?: string;
|
||||
token_url?: string;
|
||||
}): Promise<{ ok: boolean; service: string }> {
|
||||
return _send('/oauth/apps', {
|
||||
method: 'POST',
|
||||
body,
|
||||
timeoutMs: 15000,
|
||||
});
|
||||
},
|
||||
|
||||
/** Service-Eintrag komplett entfernen (incl. Token). */
|
||||
deleteOAuthApp(service: string): Promise<{ ok: boolean }> {
|
||||
return _send(`/oauth/apps/${encodeURIComponent(service)}`, {
|
||||
method: 'DELETE',
|
||||
timeoutMs: 15000,
|
||||
});
|
||||
},
|
||||
|
||||
/** Authorize-URL bauen (Brain speichert state, gibt url + redirect_uri zurueck). */
|
||||
authorizeOAuth(service: string, scopes?: string[]): Promise<{
|
||||
url: string; state: string; redirect_uri: string; service: string;
|
||||
}> {
|
||||
return _send('/oauth/authorize', {
|
||||
method: 'POST',
|
||||
body: { service, scopes },
|
||||
timeoutMs: 15000,
|
||||
});
|
||||
},
|
||||
|
||||
/** Token loeschen (lokal — kein Provider-Revoke). */
|
||||
revokeOAuth(service: string): Promise<{ ok: boolean }> {
|
||||
return _send(`/oauth/${encodeURIComponent(service)}/revoke`, {
|
||||
method: 'POST',
|
||||
timeoutMs: 15000,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default brainApi;
|
||||
|
||||
Reference in New Issue
Block a user