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:
2026-05-24 20:16:31 +02:00
parent 30c1dd7473
commit 13e87fb083
6 changed files with 916 additions and 31 deletions
+614
View File
@@ -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;
+17 -1
View File
@@ -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>
+77
View File
@@ -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;
+90
View File
@@ -307,6 +307,65 @@ META_TOOLS = [
},
},
},
{
"type": "function",
"function": {
"name": "oauth_register_provider",
"description": (
"Registriert einen NEUEN OAuth2-Provider in oauth_apps.json — "
"nutze das wenn Stefan einen Service nutzen will, der noch nicht "
"in der Default-Liste (spotify, google, github, strava, microsoft) "
"ist. Du kennst typische OAuth-Endpunkte aus deinem Training "
"(Dropbox, Twitch, Discord, Slack, Reddit, LinkedIn, Notion, "
"Zoom, Trello, ...). Trag NUR die URLs ein — client_id / "
"client_secret bleiben Stefans Job (Diagnostic > OAuth-Apps oder "
"App > Settings > OAuth-Apps).\n\n"
"**Workflow bei neuem Service:**\n"
"1. `oauth_register_provider` mit auth_url + token_url + scopes\n"
"2. Sag Stefan: \"Service '{name}' ist eingerichtet. Trag in "
"Diagnostic/App > OAuth-Apps deine client_id + client_secret aus "
"dem {name}-Developer-Dashboard ein. Plus die Callback-URL "
"{callback} musst Du dort einmal als Redirect-URI eintragen.\"\n"
"3. Warten bis Stefan fertig ist\n"
"4. `oauth_authorize` rufen\n\n"
"**`client_auth`-Wert:** Die meisten Provider wollen client_id+"
"secret im Body (`body`, default). Spotify und manche andere "
"wollen Basic-Auth-Header (`basic`). Wenn du unsicher bist, "
"nimm `body` — schlaegt der Token-Request dann mit 401 fehl, "
"switch auf `basic`.\n\n"
"Bei Provider die du wirklich nicht kennst: frag Stefan oder "
"such die Docs raus statt zu raten."
),
"parameters": {
"type": "object",
"properties": {
"service": {
"type": "string",
"description": "Service-Name (a-z 0-9 _ -, kurz, z.B. 'dropbox', 'discord')",
},
"auth_url": {
"type": "string",
"description": "Authorize-Endpoint, z.B. 'https://www.dropbox.com/oauth2/authorize'",
},
"token_url": {
"type": "string",
"description": "Token-Endpoint, z.B. 'https://api.dropboxapi.com/oauth2/token'",
},
"scopes": {
"type": "array",
"items": {"type": "string"},
"description": "Default-Scopes die der User beim Auth zustimmen muss",
},
"client_auth": {
"type": "string",
"enum": ["body", "basic"],
"description": "Wie der Provider client_id/secret erwartet (Default 'body')",
},
},
"required": ["service", "auth_url", "token_url"],
},
},
},
{
"type": "function",
"function": {
@@ -927,6 +986,37 @@ class Agent:
else:
lines.append(f"- {t['name']} ({t['type']}, {state})")
return "\n".join(lines)
if name == "oauth_register_provider":
svc = (arguments.get("service") or "").strip()
auth_url = (arguments.get("auth_url") or "").strip()
token_url = (arguments.get("token_url") or "").strip()
scopes = arguments.get("scopes") if isinstance(arguments.get("scopes"), list) else None
client_auth = (arguments.get("client_auth") or "body").strip().lower()
if not svc or not auth_url or not token_url:
return "FEHLER: service, auth_url, token_url sind Pflicht."
try:
entry = oauth_mod.register_provider(
svc, auth_url, token_url, scopes=scopes, client_auth=client_auth,
)
except ValueError as exc:
return f"FEHLER: {exc}"
except Exception as exc:
logger.exception("oauth_register_provider fehlgeschlagen")
return f"FEHLER: {exc}"
cb = oauth_mod._callback_url(svc) if os.environ.get("RVS_HOST") else f"<RVS_HOST nicht gesetzt>/oauth/callback/{svc}"
scopes_str = ", ".join(entry.get("scopes") or []) or "(keine)"
return (
f"OK — Provider '{svc}' registriert.\n"
f" auth_url: {entry['auth_url']}\n"
f" token_url: {entry['token_url']}\n"
f" scopes: {scopes_str}\n"
f" client_auth: {entry['client_auth']}\n\n"
f"Sage Stefan: Trag in Diagnostic > OAuth-Apps (oder App > "
f"Settings > OAuth-Apps) deine client_id + client_secret aus "
f"dem {svc}-Developer-Dashboard ein. Plus die Callback-URL "
f"`{cb}` musst Du dort einmal als Redirect-URI registrieren.\n"
f"Sobald Stefan das gemacht hat, rufe `oauth_authorize` auf."
)
if name == "oauth_authorize":
svc = (arguments.get("service") or "").strip()
if not svc:
+44 -28
View File
@@ -40,7 +40,10 @@ APPS_FILE = CONFIG_DIR / "oauth_apps.json"
TOKENS_FILE = CONFIG_DIR / "oauth_tokens.json"
# Default-Provider-Configs. Werden von oauth_apps.json gemergt (User-Config
# uebersteuert). Stefan muss nur client_id + client_secret eintragen.
# uebersteuert). Aktuell nur Spotify als out-of-the-box Service — fuer alles
# andere benutzt ARIA das `oauth_register_provider` Tool (legt Provider on-
# demand mit den jeweiligen Endpunkten an). Stefan muss bei jedem Provider
# danach nur client_id + client_secret in Diagnostic / App eintragen.
DEFAULT_PROVIDERS: dict[str, dict] = {
"spotify": {
"auth_url": "https://accounts.spotify.com/authorize",
@@ -50,33 +53,6 @@ DEFAULT_PROVIDERS: dict[str, dict] = {
"user-library-read"],
"client_auth": "basic", # client_id:client_secret als Basic-Auth-Header
},
"google": {
"auth_url": "https://accounts.google.com/o/oauth2/v2/auth",
"token_url": "https://oauth2.googleapis.com/token",
"scopes": ["openid", "email", "profile"],
"client_auth": "body", # client_id+secret im Body
"extra_auth_params": {"access_type": "offline", "prompt": "consent"},
},
"github": {
"auth_url": "https://github.com/login/oauth/authorize",
"token_url": "https://github.com/login/oauth/access_token",
"scopes": ["read:user"],
"client_auth": "body",
"accept_header": "application/json", # GitHub returns form-urlencoded otherwise
},
"strava": {
"auth_url": "https://www.strava.com/oauth/authorize",
"token_url": "https://www.strava.com/oauth/token",
"scopes": ["read", "activity:read_all"],
"client_auth": "body",
"extra_auth_params": {"approval_prompt": "auto"},
},
"microsoft": {
"auth_url": "https://login.microsoftonline.com/common/oauth2/v2.0/authorize",
"token_url": "https://login.microsoftonline.com/common/oauth2/v2.0/token",
"scopes": ["User.Read", "offline_access"],
"client_auth": "body",
},
}
# Pending Auth-Requests: state → {service, scopes, redirect_uri, created_at}
@@ -149,6 +125,46 @@ def _provider_credentials(service: str) -> tuple[str, str]:
return cid, sec
def register_provider(service: str, auth_url: str, token_url: str,
scopes: Optional[list[str]] = None,
client_auth: str = "body",
extra_auth_params: Optional[dict] = None,
accept_header: Optional[str] = None) -> dict:
"""Schreibt einen neuen Provider-Eintrag in oauth_apps.json. KEINE
Credentials hier — die bleiben Stefans Job (Diagnostic / App-UI). Wird
vom Brain-Tool `oauth_register_provider` gerufen.
Wenn der Service schon existiert: URLs/Scopes werden ueberschrieben,
aber vorhandene client_id/client_secret bleiben unberuehrt.
"""
svc = (service or "").strip()
if not svc or not all(c.isalnum() or c in "_-" for c in svc) or len(svc) > 60:
raise ValueError(f"Ungueltiger service-Name: {service!r}")
if not auth_url.startswith(("http://", "https://")):
raise ValueError(f"auth_url muss http(s):// sein: {auth_url!r}")
if not token_url.startswith(("http://", "https://")):
raise ValueError(f"token_url muss http(s):// sein: {token_url!r}")
if client_auth not in ("body", "basic"):
raise ValueError(f"client_auth muss 'body' oder 'basic' sein: {client_auth!r}")
apps = _load_json(APPS_FILE)
entry = apps.get(svc) or {}
entry["auth_url"] = auth_url.strip()
entry["token_url"] = token_url.strip()
if scopes is not None:
entry["scopes"] = list(scopes)
entry["client_auth"] = client_auth
if extra_auth_params is not None:
entry["extra_auth_params"] = extra_auth_params
if accept_header is not None:
entry["accept_header"] = accept_header
apps[svc] = entry
_save_json(APPS_FILE, apps)
logger.info("[oauth] Provider '%s' registriert (auth=%s, token=%s, scopes=%d)",
svc, auth_url, token_url, len(entry.get("scopes") or []))
return entry
def _cleanup_pending() -> None:
"""Entfernt abgelaufene Pending-Auths."""
now = time.time()
+74 -2
View File
@@ -3900,11 +3900,27 @@
const isCustom = !knownDefaults.includes(svcName);
const customMark = isCustom ? ' <span style="color:#8888AA;font-size:10px;">(custom)</span>' : '';
card.style.cssText = 'background:#0D0D1A;border:1px solid #2A2A3E;border-radius:6px;padding:10px 12px;';
// Custom-Provider zeigen URL/Scope-Felder zum Editieren — Defaults
// verstecken die Felder hinter einem "<details>" damit sie nicht
// ausversehen ueberschrieben werden.
const scopesValue = Array.isArray(app.scopes) ? app.scopes.join(' ') : '';
const urlFieldsHtml = `
<label style="color:#8888AA;font-size:11px;margin-top:6px;">auth_url:</label>
<input type="text" id="oauth-auth-${_ofmt(svcName)}" value="${_ofmt(app.auth_url || '')}" placeholder="https://provider.com/oauth/authorize"
style="background:#1E1E2E;color:#fff;border:1px solid #2A2A3E;border-radius:4px;padding:4px 8px;font-size:11px;font-family:monospace;">
<label style="color:#8888AA;font-size:11px;">token_url:</label>
<input type="text" id="oauth-tok-${_ofmt(svcName)}" value="${_ofmt(app.token_url || '')}" placeholder="https://provider.com/oauth/token"
style="background:#1E1E2E;color:#fff;border:1px solid #2A2A3E;border-radius:4px;padding:4px 8px;font-size:11px;font-family:monospace;">
<label style="color:#8888AA;font-size:11px;">scopes (space-separated):</label>
<input type="text" id="oauth-scopes-${_ofmt(svcName)}" value="${_ofmt(scopesValue)}" placeholder="read write user.email"
style="background:#1E1E2E;color:#fff;border:1px solid #2A2A3E;border-radius:4px;padding:4px 8px;font-size:11px;font-family:monospace;">
`;
card.innerHTML = `
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
<strong style="color:#FFF;text-transform:capitalize;">${_ofmt(svcName)}</strong>${customMark}
<span style="color:${statusColor};font-size:12px;flex:1;">${statusText}</span>
${s.authenticated ? `<button class="btn secondary" onclick="revokeOAuth('${_ofmt(svcName)}')" style="padding:2px 8px;font-size:10px;" title="Token loeschen">Abmelden</button>` : ''}
${isCustom ? `<button class="btn secondary" onclick="deleteOAuthApp('${_ofmt(svcName)}')" style="padding:2px 8px;font-size:10px;background:#3A1F1F;color:#FF6B6B;border-color:#FF6B6B;" title="Service komplett entfernen">🗑</button>` : ''}
</div>
<div style="display:flex;flex-direction:column;gap:6px;">
<label style="color:#8888AA;font-size:11px;">client_id:</label>
@@ -3916,6 +3932,12 @@
style="flex:1;background:#1E1E2E;color:#fff;border:1px solid #2A2A3E;border-radius:4px;padding:4px 8px;font-size:12px;font-family:monospace;">
<button type="button" class="btn secondary" onclick="toggleSecret('oauth-sec-${_ofmt(svcName)}', this)" style="padding:2px 8px;font-size:10px;">👁</button>
</div>
${isCustom ? urlFieldsHtml : `
<details style="margin-top:4px;">
<summary style="color:#666680;font-size:10px;cursor:pointer;">Default-URLs überschreiben (advanced)</summary>
<div style="display:flex;flex-direction:column;gap:6px;margin-top:6px;">${urlFieldsHtml}</div>
</details>
`}
<div style="display:flex;gap:6px;margin-top:4px;">
<button class="btn primary" onclick="saveOAuthApp('${_ofmt(svcName)}')" style="padding:4px 12px;font-size:11px;">Speichern</button>
<button class="btn secondary" onclick="authorizeOAuth('${_ofmt(svcName)}')" style="padding:4px 12px;font-size:11px;" ${!s.configured ? 'disabled title="Erst client_id+secret eintragen"' : ''}>
@@ -3926,25 +3948,75 @@
`;
listEl.appendChild(card);
}
// "+ Custom Service hinzufuegen"-Button am Ende
const addCard = document.createElement('div');
addCard.style.cssText = 'background:#0D0D1A;border:1px dashed #2A2A3E;border-radius:6px;padding:10px 12px;';
addCard.innerHTML = `
<button class="btn secondary" onclick="openOAuthCustomDialog()" style="width:100%;padding:8px;font-size:12px;color:#8888AA;">
Custom OAuth-Provider hinzufuegen (Dropbox, Discord, Notion, ...)
</button>
`;
listEl.appendChild(addCard);
if (allServices.length === 0) {
listEl.innerHTML = '<div style="color:#555570;">Keine Services bekannt.</div>';
// (addCard ist trotzdem schon dran)
}
} catch (e) {
listEl.innerHTML = `<div style="color:#FF6B6B;">Fehler beim Laden: ${_ofmt(e.message)}</div>`;
}
}
function openOAuthCustomDialog() {
const name = (prompt('Service-Name (z.B. dropbox, discord) — a-z 0-9 _ -:') || '').trim().toLowerCase();
if (!name || !/^[a-z0-9_-]+$/.test(name)) {
if (name) alert('Ungueltiger Name. Erlaubt: a-z 0-9 _ -');
return;
}
const authUrl = (prompt(`auth_url fuer ${name}:`, 'https://') || '').trim();
if (!authUrl) return;
const tokenUrl = (prompt(`token_url fuer ${name}:`, 'https://') || '').trim();
if (!tokenUrl) return;
const scopesRaw = (prompt(`scopes (space-separated, optional):`, '') || '').trim();
const scopes = scopesRaw ? scopesRaw.split(/\s+/).filter(Boolean) : undefined;
fetch('/api/brain/oauth/apps', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ service: name, auth_url: authUrl, token_url: tokenUrl, scopes }),
})
.then(r => r.ok ? r.json() : r.text().then(t => Promise.reject(new Error(t))))
.then(() => loadOAuthServices())
.catch(e => alert('Custom-Service anlegen fehlgeschlagen: ' + e.message));
}
async function deleteOAuthApp(service) {
if (!confirm(`Service "${service}" komplett entfernen? client_id/secret + Token werden geloescht.`)) return;
try {
const r = await fetch('/api/brain/oauth/apps/' + encodeURIComponent(service), { method: 'DELETE' });
if (!r.ok) {
alert('Loeschen fehlgeschlagen: ' + (await r.text()));
return;
}
loadOAuthServices();
} catch (e) {
alert('Loeschen fehlgeschlagen: ' + e.message);
}
}
async function saveOAuthApp(service) {
const cid = document.getElementById('oauth-cid-' + service)?.value?.trim() || '';
const sec = document.getElementById('oauth-sec-' + service)?.value || '';
const authUrl = (document.getElementById('oauth-auth-' + service)?.value || '').trim();
const tokenUrl = (document.getElementById('oauth-tok-' + service)?.value || '').trim();
const scopesRaw = (document.getElementById('oauth-scopes-' + service)?.value || '').trim();
if (!cid) {
alert('client_id darf nicht leer sein.');
return;
}
const body = { service, client_id: cid, client_secret: sec };
if (authUrl) body.auth_url = authUrl;
if (tokenUrl) body.token_url = tokenUrl;
if (scopesRaw) body.scopes = scopesRaw.split(/\s+/).filter(Boolean);
try {
const r = await fetch('/api/brain/oauth/apps', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ service, client_id: cid, client_secret: sec }),
body: JSON.stringify(body),
});
if (!r.ok) {
const t = await r.text();