13e87fb083
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>
615 lines
21 KiB
TypeScript
615 lines
21 KiB
TypeScript
/**
|
|
* 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;
|