chain-studio/lib/widgets/fai_settings_dialog.dart
flemming-it c5a96bc8d4
Some checks failed
Security / Security check (push) Failing after 1s
feat(settings): hub bearer-token panel + auto-attach on every RPC
Studio gains a new "HUB AUTHENTICATION" panel in Settings
mirroring the existing registry-credentials panel:

- `lib/data/hub_auth_token.dart` — `~/.fai/hub-auth-token`
  helper (read / write / clear, mode 0600 on Unix). Sister
  of `RegistryToken` with the same on-disk hygiene.
- `HubService.loadPersistedEndpoint` now reads the token at
  startup and reconnects with it. `reconnect()` grew an
  `authToken:` parameter with a sentinel that distinguishes
  "keep current" from "drop". `reloadAuthToken()` is the
  one-liner Settings calls after save / clear.
- `_HubAuthTokenPanel` in `fai_settings_dialog.dart` — paste
  with show/hide toggle, save button, clear button, status
  pill ("Configured (40 chars)" / "Not set (anonymous)"),
  storage-location hint. Trimmed token length only — the
  secret never round-trips back into the UI after save.
- EN + DE ARB entries (`hubAuthToken*`) + regenerated
  `app_localizations.dart` keep the bilingual surface
  consistent.

The hub side (auth.tokens config + tower middleware) shipped
on the platform side 2026-05-28 (43a54a2). Until now an
operator had no GUI path to consume it: they had to find the
file in their home dir, paste the token by hand, and bounce
Studio. This panel closes that loop.

Bumped pubspec to 0.47.0. dart analyze clean (No issues
found!); flutter test green (11 tests pass).

Block E item 1 of 4 done. Capability-picker badges,
default_scope editor, multi-version uninstall picker follow.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
2026-05-28 23:24:15 +02:00

2229 lines
70 KiB
Dart

// FaiSettingsDialog — modal for changing the hub endpoint.
// Reads current values from HubService, persists on save via
// HubService.reconnect.
import 'package:fai_client_sdk/fai_client_sdk.dart';
import 'package:flutter/material.dart';
import '../data/hub.dart';
import '../data/hub_auth_token.dart';
import '../data/registry_token.dart';
import '../data/system_actions.dart';
import '../data/theme_plugin.dart';
import '../main.dart';
import '../l10n/app_localizations.dart';
import '../theme/theme.dart';
import '../theme/tokens.dart';
import 'fai_error_box.dart';
import 'fai_pill.dart';
import 'fai_system_ai_editor.dart';
class FaiSettingsDialog extends StatefulWidget {
const FaiSettingsDialog({super.key});
/// Convenience launcher used from the sidebar gear icon.
static Future<bool> show(BuildContext context) async {
final ok = await showDialog<bool>(
context: context,
builder: (_) => const FaiSettingsDialog(),
);
return ok ?? false;
}
@override
State<FaiSettingsDialog> createState() => _FaiSettingsDialogState();
}
class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
late final TextEditingController _host;
late final TextEditingController _port;
bool _secure = false;
bool _saving = false;
String? _error;
String? _channelToast;
ChannelStatusSnapshot? _channels;
SystemAiStatus? _aiStatus;
List<McpClientInfo>? _mcpClients;
List<N8nEndpointInfo>? _n8nEndpoints;
/// Length of the configured registry token, or null when the
/// `~/.fai/registry-token` file is missing / empty. Reloaded
/// after save / clear.
int? _registryTokenChars;
/// Length of the configured hub gRPC bearer token, or null
/// when `~/.fai/hub-auth-token` is missing / empty. Reloaded
/// after save / clear; UI only ever sees the trimmed length.
int? _hubAuthTokenChars;
@override
void initState() {
super.initState();
final ep = HubService.instance.currentEndpoint;
_host = TextEditingController(text: ep.host);
_port = TextEditingController(text: ep.port.toString());
_secure = ep.secure;
_loadChannels();
_loadAiStatus();
_loadMcpClients();
_loadN8nEndpoints();
_loadRegistryToken();
_loadHubAuthToken();
}
Future<void> _loadHubAuthToken() async {
try {
final n = await HubAuthToken.charCount();
if (!mounted) return;
setState(() => _hubAuthTokenChars = n);
} catch (_) {/* fail-quiet */}
}
Future<void> _saveHubAuthToken(String token) async {
try {
await HubAuthToken.write(token);
await HubService.instance.reloadAuthToken();
await _loadHubAuthToken();
if (!mounted) return;
final l = AppLocalizations.of(context)!;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l.hubAuthTokenSavedToast)),
);
} catch (e) {
if (!mounted) return;
final l = AppLocalizations.of(context)!;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l.hubAuthTokenSaveFailedToast(e.toString()))),
);
}
}
Future<void> _clearHubAuthToken() async {
try {
await HubAuthToken.clear();
await HubService.instance.reloadAuthToken();
await _loadHubAuthToken();
if (!mounted) return;
final l = AppLocalizations.of(context)!;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l.hubAuthTokenClearedToast)),
);
} catch (e) {
if (!mounted) return;
final l = AppLocalizations.of(context)!;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l.hubAuthTokenSaveFailedToast(e.toString()))),
);
}
}
Future<void> _loadRegistryToken() async {
try {
final n = await RegistryToken.charCount();
if (!mounted) return;
setState(() => _registryTokenChars = n);
} catch (_) {/* fail-quiet */}
}
Future<void> _saveRegistryToken(String token) async {
try {
await RegistryToken.write(token);
await _loadRegistryToken();
if (!mounted) return;
final l = AppLocalizations.of(context)!;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l.registryTokenSavedToast)),
);
} catch (e) {
if (!mounted) return;
final l = AppLocalizations.of(context)!;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l.registryTokenSaveFailedToast(e.toString()))),
);
}
}
Future<void> _clearRegistryToken() async {
try {
await RegistryToken.delete();
await _loadRegistryToken();
if (!mounted) return;
final l = AppLocalizations.of(context)!;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l.registryTokenClearedToast)),
);
} catch (e) {
if (!mounted) return;
final l = AppLocalizations.of(context)!;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l.registryTokenSaveFailedToast(e.toString()))),
);
}
}
Future<void> _loadN8nEndpoints() async {
try {
final list = await HubService.instance.listN8nEndpoints();
if (!mounted) return;
setState(() => _n8nEndpoints = list);
} catch (_) {/* fail-quiet */}
}
Future<void> _addN8nEndpoint(N8nEndpointDraft draft) async {
try {
final list = await HubService.instance.addN8nEndpoint(
name: draft.name,
baseUrl: draft.baseUrl,
apiKeyEnv: draft.apiKeyEnv,
description: draft.description,
);
if (!mounted) return;
setState(() => _n8nEndpoints = list);
final l = AppLocalizations.of(context)!;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l.addedN8nToast(draft.name))),
);
} catch (e) {
if (!mounted) return;
final l = AppLocalizations.of(context)!;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l.addFailedToast(e.toString()))),
);
}
}
Future<void> _removeN8nEndpoint(String name) async {
try {
final list = await HubService.instance.removeN8nEndpoint(name);
if (!mounted) return;
setState(() => _n8nEndpoints = list);
} catch (e) {
if (!mounted) return;
final l = AppLocalizations.of(context)!;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l.removeFailedToast(e.toString()))),
);
}
}
Future<void> _refreshN8nEndpoints() async {
try {
final list = await HubService.instance.refreshN8nEndpoints();
if (!mounted) return;
setState(() => _n8nEndpoints = list);
} catch (e) {
if (!mounted) return;
final l = AppLocalizations.of(context)!;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l.refreshFailedToast(e.toString()))),
);
}
}
Future<void> _loadMcpClients() async {
try {
final list = await HubService.instance.listMcpClients();
if (!mounted) return;
setState(() => _mcpClients = list);
} catch (_) {
// Same fail-quiet rule as channels.
}
}
Future<void> _addMcpClient(McpClientDraft draft) async {
try {
final list = await HubService.instance.addMcpClient(
name: draft.name,
endpoint: draft.endpoint,
apiKeyEnv: draft.apiKeyEnv,
description: draft.description,
);
if (!mounted) return;
setState(() => _mcpClients = list);
final l = AppLocalizations.of(context)!;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l.addedMcpToast(draft.name))),
);
} catch (e) {
if (!mounted) return;
final l = AppLocalizations.of(context)!;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l.addFailedToast(e.toString()))),
);
}
}
Future<void> _removeMcpClient(String name) async {
try {
final list = await HubService.instance.removeMcpClient(name);
if (!mounted) return;
setState(() => _mcpClients = list);
} catch (e) {
if (!mounted) return;
final l = AppLocalizations.of(context)!;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l.removeFailedToast(e.toString()))),
);
}
}
Future<void> _refreshMcpClients() async {
try {
final list = await HubService.instance.refreshMcpClients();
if (!mounted) return;
setState(() => _mcpClients = list);
} catch (e) {
if (!mounted) return;
final l = AppLocalizations.of(context)!;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l.refreshFailedToast(e.toString()))),
);
}
}
Future<void> _loadChannels() async {
try {
final snap = await HubService.instance.channelStatus();
if (!mounted) return;
setState(() => _channels = snap);
} catch (_) {
// Silent: channels block stays hidden when the hub is
// unreachable (the endpoint section is the operator's
// path back to a working connection).
}
}
Future<void> _loadAiStatus() async {
try {
final s = await HubService.instance.systemAiStatus();
if (!mounted) return;
setState(() => _aiStatus = s);
} catch (_) {
// Same fail-quiet rule as channels.
}
}
Future<void> _switchChannel(String name) async {
setState(() {
_saving = true;
_channelToast = null;
});
final r = await SystemActions.faiChannelSwitch(name);
if (!mounted) return;
setState(() {
_saving = false;
_channelToast = r.ok
? 'Switched active channel to "$name". Daemon restarted.\n${r.stdout.trim()}'
: 'Channel switch failed: ${r.stderr.isEmpty ? r.stdout : r.stderr}';
});
if (r.ok) await _loadChannels();
}
Future<void> _runDaemon(
String label,
Future<({bool ok, String stdout, String stderr})> Function() action,
) async {
setState(() {
_saving = true;
_channelToast = null;
});
final r = await action();
if (!mounted) return;
setState(() {
_saving = false;
_channelToast = r.ok
? 'OK · $label\n${r.stdout.trim()}'
: 'Failed · $label\n${(r.stderr.isEmpty ? r.stdout : r.stderr).trim()}';
});
}
Future<void> _connectToChannel(ChannelInfo ch) async {
setState(() {
_host.text = '127.0.0.1';
_port.text = ch.port.toString();
_secure = false;
});
await _save();
}
@override
void dispose() {
_host.dispose();
_port.dispose();
super.dispose();
}
Future<void> _save() async {
setState(() {
_saving = true;
_error = null;
});
final port = int.tryParse(_port.text.trim());
if (port == null || port <= 0 || port > 65535) {
final l = AppLocalizations.of(context)!;
setState(() {
_saving = false;
_error = l.settingsPortError;
});
return;
}
final endpoint = HubEndpoint(
host: _host.text.trim().isEmpty ? '127.0.0.1' : _host.text.trim(),
port: port,
secure: _secure,
);
try {
await HubService.instance.reconnect(endpoint);
if (!mounted) return;
Navigator.pop(context, true);
} catch (e) {
setState(() {
_saving = false;
_error = e.toString();
});
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
return AlertDialog(
title: Text(l.settingsTitle),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(FaiRadius.md),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: FaiSpace.xl,
vertical: FaiSpace.lg,
),
content: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 520, maxHeight: 640),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l.settingsHubEndpointHint,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: FaiSpace.lg),
TextField(
controller: _host,
decoration: InputDecoration(
labelText: l.settingsHost,
border: const OutlineInputBorder(),
isDense: true,
),
autofocus: true,
),
const SizedBox(height: FaiSpace.md),
Row(
children: [
Expanded(
flex: 2,
child: TextField(
controller: _port,
decoration: InputDecoration(
labelText: l.settingsPort,
border: const OutlineInputBorder(),
isDense: true,
),
keyboardType: TextInputType.number,
),
),
const SizedBox(width: FaiSpace.md),
Expanded(
flex: 3,
child: SwitchListTile(
contentPadding: EdgeInsets.zero,
dense: true,
title: Text(l.settingsTls),
subtitle: Text(
l.settingsTlsSubtitle,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
value: _secure,
onChanged: (v) => setState(() => _secure = v),
),
),
],
),
if (_error != null) ...[
const SizedBox(height: FaiSpace.md),
Text(
_error!,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.error,
),
),
],
const SizedBox(height: FaiSpace.md),
Container(
padding: const EdgeInsets.all(FaiSpace.sm),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(FaiRadius.sm),
),
child: Row(
children: [
Icon(
Icons.link,
size: 14,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: FaiSpace.sm),
Text(
_previewUrl(),
style: FaiTheme.mono(
size: 11,
color: theme.colorScheme.onSurface,
),
),
],
),
),
if (_channels != null) ...[
const SizedBox(height: FaiSpace.lg),
Text(
l.channelsHeader,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
letterSpacing: 0.6,
fontSize: 10,
),
),
const SizedBox(height: 2),
Text(
l.channelsBlurb,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: FaiSpace.sm),
for (final ch in _channels!.channels)
_ChannelRow(
channel: ch,
active: ch.name == _channels!.active,
onConnect: _saving ? null : () => _connectToChannel(ch),
onSwitch: _saving ? null : () => _switchChannel(ch.name),
onEnableAutostart: _saving ? null : () => _runDaemon(
'enable autostart',
() => SystemActions.faiDaemonEnable(ch.name),
),
onDisableAutostart: _saving ? null : () => _runDaemon(
'disable autostart',
() => SystemActions.faiDaemonDisable(ch.name),
),
),
if (_channelToast != null) ...[
const SizedBox(height: FaiSpace.sm),
FaiErrorBox(text: _channelToast!, maxHeight: 200),
],
],
if (_aiStatus != null) ...[
const SizedBox(height: FaiSpace.lg),
_SystemAiPanel(
status: _aiStatus!,
onEdit: () async {
final updated = await FaiSystemAiEditor.show(
context,
_aiStatus!,
);
if (updated != null && mounted) {
setState(() => _aiStatus = updated);
}
},
),
],
if (_mcpClients != null) ...[
const SizedBox(height: FaiSpace.lg),
_McpClientsPanel(
clients: _mcpClients!,
onAdd: _addMcpClient,
onRemove: _removeMcpClient,
onRefresh: _refreshMcpClients,
),
],
if (_n8nEndpoints != null) ...[
const SizedBox(height: FaiSpace.lg),
_N8nEndpointsPanel(
endpoints: _n8nEndpoints!,
onAdd: _addN8nEndpoint,
onRemove: _removeN8nEndpoint,
onRefresh: _refreshN8nEndpoints,
),
],
const SizedBox(height: FaiSpace.lg),
_RegistryCredentialsPanel(
configuredChars: _registryTokenChars,
onSave: _saveRegistryToken,
onClear: _clearRegistryToken,
),
const SizedBox(height: FaiSpace.lg),
_HubAuthTokenPanel(
configuredChars: _hubAuthTokenChars,
onSave: _saveHubAuthToken,
onClear: _clearHubAuthToken,
),
const SizedBox(height: FaiSpace.lg),
const _ThemePluginPanel(),
const SizedBox(height: FaiSpace.lg),
_MaintenancePanel(
onResetDone: () async {
// Daemon just restarted under the same channel +
// port. Bouncing the gRPC connection picks up the
// fresh state without the operator having to
// close+reopen Settings.
await HubService.instance.reconnect(
HubService.instance.currentEndpoint,
);
if (!mounted) return;
await _loadChannels();
await _loadAiStatus();
await _loadMcpClients();
await _loadN8nEndpoints();
await _loadRegistryToken();
},
),
],
),
),
),
actions: [
TextButton(
onPressed: _saving ? null : () => Navigator.pop(context, false),
child: Text(l.buttonCancel),
),
FilledButton(
onPressed: _saving ? null : _save,
child: _saving
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Text(l.settingsSaveAndConnect),
),
],
);
}
String _previewUrl() {
final scheme = _secure ? 'https' : 'http';
final host =
_host.text.trim().isEmpty ? '127.0.0.1' : _host.text.trim();
final port = _port.text.trim().isEmpty ? '50051' : _port.text.trim();
return '$scheme://$host:$port';
}
}
class _ChannelRow extends StatelessWidget {
final ChannelInfo channel;
final bool active;
final VoidCallback? onConnect;
/// Switches the active channel pointer (`fai channel switch`).
/// Distinct from [onConnect], which only re-points Studio's
/// gRPC wire at a different running daemon.
final VoidCallback? onSwitch;
final VoidCallback? onEnableAutostart;
final VoidCallback? onDisableAutostart;
const _ChannelRow({
required this.channel,
required this.active,
required this.onConnect,
required this.onSwitch,
required this.onEnableAutostart,
required this.onDisableAutostart,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
children: [
Icon(
channel.running ? Icons.circle : Icons.circle_outlined,
size: 10,
color: channel.running
? FaiColors.success
: theme.colorScheme.outline,
),
const SizedBox(width: FaiSpace.sm),
SizedBox(
width: 88,
child: Text(
channel.name,
style: FaiTheme.mono(
size: 11,
weight: active ? FontWeight.w600 : FontWeight.w400,
color: theme.colorScheme.onSurface,
),
),
),
SizedBox(
width: 64,
child: Text(
':${channel.port}',
style: FaiTheme.mono(
size: 11,
color: theme.colorScheme.onSurfaceVariant,
),
),
),
Expanded(
child: Text(
channel.running ? l.channelsRunning : l.channelsStopped,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
if (active)
Padding(
padding: const EdgeInsets.only(right: FaiSpace.sm),
child: FaiPill(label: l.channelsActive, tone: FaiPillTone.success),
),
PopupMenuButton<String>(
tooltip: l.channelsActionsTooltip,
icon: const Icon(Icons.more_vert, size: 18),
onSelected: (v) {
switch (v) {
case 'connect':
onConnect?.call();
break;
case 'switch':
onSwitch?.call();
break;
case 'enable':
onEnableAutostart?.call();
break;
case 'disable':
onDisableAutostart?.call();
break;
}
},
itemBuilder: (_) => [
PopupMenuItem(
value: 'connect',
enabled: channel.running && onConnect != null,
child: _MenuRow(
icon: Icons.link,
text: l.channelsConnect,
),
),
PopupMenuItem(
value: 'switch',
enabled: !active && onSwitch != null,
child: _MenuRow(
icon: Icons.swap_horiz,
text: l.channelsSwitch,
),
),
const PopupMenuDivider(),
PopupMenuItem(
value: 'enable',
enabled: onEnableAutostart != null,
child: _MenuRow(
icon: Icons.play_circle_outline,
text: l.channelsEnableAutostart,
),
),
PopupMenuItem(
value: 'disable',
enabled: onDisableAutostart != null,
child: _MenuRow(
icon: Icons.pause_circle_outline,
text: l.channelsDisableAutostart,
),
),
],
),
],
),
);
}
}
class _MenuRow extends StatelessWidget {
final IconData icon;
final String text;
const _MenuRow({required this.icon, required this.text});
@override
Widget build(BuildContext context) {
return Row(
children: [
Icon(icon, size: 14),
const SizedBox(width: FaiSpace.sm),
Text(text),
],
);
}
}
class _SystemAiPanel extends StatelessWidget {
final SystemAiStatus status;
final VoidCallback onEdit;
const _SystemAiPanel({required this.status, required this.onEdit});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
l.systemAiHeader,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
letterSpacing: 0.6,
fontSize: 10,
),
),
const SizedBox(width: FaiSpace.sm),
FaiPill(
label: status.enabled ? l.systemAiEnabled : l.systemAiOff,
tone: status.enabled ? FaiPillTone.success : FaiPillTone.neutral,
),
if (status.enabled) ...[
const SizedBox(width: FaiSpace.xs),
FaiPill(
label: status.privacyMode,
tone: status.privacyMode == 'full'
? FaiPillTone.warning
: FaiPillTone.neutral,
),
],
const Spacer(),
TextButton.icon(
onPressed: onEdit,
icon: const Icon(Icons.edit_outlined, size: 14),
label: Text(status.enabled ? l.systemAiEdit : l.systemAiConfigure),
),
],
),
const SizedBox(height: 4),
if (status.enabled) ...[
_StatRow(label: 'provider', value: status.provider),
_StatRow(label: 'endpoint', value: status.endpoint, mono: true),
_StatRow(label: 'model', value: status.model, mono: true),
if (status.apiKeyEnv.isNotEmpty)
_StatRow(label: 'api_key_env', value: '\$${status.apiKeyEnv}', mono: true),
] else ...[
Padding(
padding: const EdgeInsets.only(top: 2),
child: Text(
l.systemAiOffBlurb,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
],
],
);
}
}
class _StatRow extends StatelessWidget {
final String label;
final String value;
final bool mono;
const _StatRow({
required this.label,
required this.value,
this.mono = false,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 1),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 96,
child: Text(
label,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
Expanded(
child: Text(
value.isEmpty ? '' : value,
style: mono
? FaiTheme.mono(size: 11, color: theme.colorScheme.onSurface)
: theme.textTheme.bodySmall,
),
),
],
),
);
}
}
/// Draft passed back from the add-server dialog. Kept tiny so
/// the panel can stay stateless.
class McpClientDraft {
final String name;
final String endpoint;
final String apiKeyEnv;
final String description;
const McpClientDraft({
required this.name,
required this.endpoint,
required this.apiKeyEnv,
required this.description,
});
}
/// MCP-clients panel — shows every configured server with its
/// last-discovery health, plus Add / Remove / Refresh actions.
/// Configure a server here, the store gains synthetic
/// capabilities for every advertised tool. Mirrors `fai mcp …`
/// on the CLI.
class _McpClientsPanel extends StatelessWidget {
final List<McpClientInfo> clients;
final ValueChanged<McpClientDraft> onAdd;
final ValueChanged<String> onRemove;
final VoidCallback onRefresh;
const _McpClientsPanel({
required this.clients,
required this.onAdd,
required this.onRemove,
required this.onRefresh,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
l.mcpHeader,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
letterSpacing: 0.6,
fontSize: 10,
),
),
const SizedBox(width: FaiSpace.sm),
FaiPill(
label: l.mcpServersCount(clients.length),
tone: FaiPillTone.neutral,
),
const Spacer(),
IconButton(
icon: const Icon(Icons.refresh, size: 16),
tooltip: l.mcpRediscoverTooltip,
visualDensity: VisualDensity.compact,
onPressed: onRefresh,
),
IconButton(
icon: const Icon(Icons.add, size: 16),
tooltip: l.mcpAddTooltip,
visualDensity: VisualDensity.compact,
onPressed: () async {
final draft = await _AddMcpClientDialog.show(context);
if (draft != null) onAdd(draft);
},
),
],
),
const SizedBox(height: 2),
Text(
l.mcpHint,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: FaiSpace.sm),
if (clients.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: FaiSpace.sm),
child: Text(
l.mcpEmpty,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
)
else
for (final c in clients)
_McpClientRow(
client: c,
onRemove: () => onRemove(c.name),
),
],
);
}
}
class _McpClientRow extends StatelessWidget {
final McpClientInfo client;
final VoidCallback onRemove;
const _McpClientRow({required this.client, required this.onRemove});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
final ok = client.healthy;
final dotColor = client.errorKind.isEmpty
? (ok ? FaiColors.success : FaiColors.muted)
: theme.colorScheme.error;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(top: 6),
child: Icon(Icons.circle, size: 8, color: dotColor),
),
const SizedBox(width: FaiSpace.sm),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
client.name,
style: FaiTheme.mono(
size: 12,
weight: FontWeight.w600,
color: theme.colorScheme.onSurface,
),
),
const SizedBox(width: FaiSpace.sm),
if (client.toolCount > 0)
FaiPill(
label: l.mcpToolsCount(client.toolCount),
tone: FaiPillTone.success,
)
else if (client.errorKind.isNotEmpty)
FaiPill(
label: client.errorKind,
tone: FaiPillTone.danger,
),
],
),
Text(
client.endpoint,
style: FaiTheme.mono(
size: 10,
color: theme.colorScheme.onSurfaceVariant,
),
),
if (client.message.isNotEmpty)
Text(
client.message,
style: theme.textTheme.bodySmall?.copyWith(
color: client.errorKind.isEmpty
? theme.colorScheme.onSurfaceVariant
: theme.colorScheme.error,
),
),
],
),
),
IconButton(
icon: const Icon(Icons.delete_outline, size: 14),
tooltip: l.mcpRemoveTooltip,
visualDensity: VisualDensity.compact,
onPressed: onRemove,
),
],
),
);
}
}
class _AddMcpClientDialog extends StatefulWidget {
const _AddMcpClientDialog();
static Future<McpClientDraft?> show(BuildContext context) {
return showDialog<McpClientDraft>(
context: context,
builder: (_) => const _AddMcpClientDialog(),
);
}
@override
State<_AddMcpClientDialog> createState() => _AddMcpClientDialogState();
}
class _AddMcpClientDialogState extends State<_AddMcpClientDialog> {
final _name = TextEditingController();
final _endpoint = TextEditingController();
final _apiKey = TextEditingController();
final _desc = TextEditingController();
/// Pick a suggestion → fill the form. Operators still hit
/// "Add + discover" so nothing happens unprompted.
void _applySuggestion(_McpSuggestion s) {
final l = AppLocalizations.of(context)!;
setState(() {
_name.text = s.name;
_endpoint.text = s.endpoint;
_apiKey.text = s.apiKeyEnv;
_desc.text = s.resolveDescription(l);
});
}
@override
void dispose() {
_name.dispose();
_endpoint.dispose();
_apiKey.dispose();
_desc.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
return AlertDialog(
title: Text(l.addMcpTitle),
content: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 520),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l.addMcpIntro,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: FaiSpace.md),
// Curated suggestions from the official Anthropic
// MCP servers + a few community staples. Click a
// chip to pre-fill the form. The operator still
// confirms via "Add + discover" — no unprompted
// subprocess spawn.
Text(
l.addMcpSuggestionsLabel,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
letterSpacing: 0.5,
),
),
const SizedBox(height: 6),
Wrap(
spacing: FaiSpace.xs,
runSpacing: FaiSpace.xs,
children: [
for (final s in _kMcpSuggestions)
ActionChip(
avatar: Icon(s.icon, size: 14),
label: Text(s.name),
onPressed: () => _applySuggestion(s),
),
],
),
const Divider(height: FaiSpace.xl),
TextField(
controller: _name,
autofocus: true,
decoration: InputDecoration(
labelText: l.addMcpNameLabel,
hintText: l.addMcpNameHint,
border: const OutlineInputBorder(),
isDense: true,
),
),
const SizedBox(height: FaiSpace.sm),
TextField(
controller: _endpoint,
decoration: InputDecoration(
labelText: l.addMcpEndpointLabel,
hintText: l.addMcpEndpointHint,
border: const OutlineInputBorder(),
isDense: true,
),
),
const SizedBox(height: FaiSpace.sm),
TextField(
controller: _apiKey,
decoration: InputDecoration(
labelText: l.addMcpApiKeyLabel,
hintText: l.addMcpApiKeyHint,
prefixText: '\$',
border: const OutlineInputBorder(),
isDense: true,
),
),
const SizedBox(height: FaiSpace.sm),
TextField(
controller: _desc,
decoration: InputDecoration(
labelText: l.addMcpNotesLabel,
border: const OutlineInputBorder(),
isDense: true,
),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, null),
child: Text(l.buttonCancel),
),
FilledButton(
onPressed: () {
if (_name.text.trim().isEmpty || _endpoint.text.trim().isEmpty) {
return;
}
Navigator.pop(
context,
McpClientDraft(
name: _name.text.trim(),
endpoint: _endpoint.text.trim(),
apiKeyEnv: _apiKey.text.trim(),
description: _desc.text.trim(),
),
);
},
child: Text(l.addMcpAddButton),
),
],
);
}
}
class N8nEndpointDraft {
final String name;
final String baseUrl;
final String apiKeyEnv;
final String description;
const N8nEndpointDraft({
required this.name,
required this.baseUrl,
required this.apiKeyEnv,
required this.description,
});
}
/// n8n-endpoints panel — sister of `_McpClientsPanel`. Shows
/// every configured n8n install with last-discovery health
/// (workflow count or error_kind), plus Add / Remove / Refresh.
class _N8nEndpointsPanel extends StatelessWidget {
final List<N8nEndpointInfo> endpoints;
final ValueChanged<N8nEndpointDraft> onAdd;
final ValueChanged<String> onRemove;
final VoidCallback onRefresh;
const _N8nEndpointsPanel({
required this.endpoints,
required this.onAdd,
required this.onRemove,
required this.onRefresh,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
l.n8nHeader,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
letterSpacing: 0.6,
fontSize: 10,
),
),
const SizedBox(width: FaiSpace.sm),
FaiPill(
label: l.n8nEndpointsCount(endpoints.length),
tone: FaiPillTone.neutral,
),
const Spacer(),
IconButton(
icon: const Icon(Icons.refresh, size: 16),
tooltip: l.n8nRediscoverTooltip,
visualDensity: VisualDensity.compact,
onPressed: onRefresh,
),
IconButton(
icon: const Icon(Icons.add, size: 16),
tooltip: l.n8nAddTooltip,
visualDensity: VisualDensity.compact,
onPressed: () async {
final draft = await _AddN8nEndpointDialog.show(context);
if (draft != null) onAdd(draft);
},
),
],
),
const SizedBox(height: 2),
Text(
l.n8nHint,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: FaiSpace.sm),
if (endpoints.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: FaiSpace.sm),
child: Text(
l.n8nEmpty,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
)
else
for (final e in endpoints)
_N8nEndpointRow(
endpoint: e,
onRemove: () => onRemove(e.name),
),
],
);
}
}
class _N8nEndpointRow extends StatelessWidget {
final N8nEndpointInfo endpoint;
final VoidCallback onRemove;
const _N8nEndpointRow({required this.endpoint, required this.onRemove});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
final ok = endpoint.healthy;
final dotColor = endpoint.errorKind.isEmpty
? (ok ? FaiColors.success : FaiColors.muted)
: theme.colorScheme.error;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(top: 6),
child: Icon(Icons.circle, size: 8, color: dotColor),
),
const SizedBox(width: FaiSpace.sm),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
endpoint.name,
style: FaiTheme.mono(
size: 12,
weight: FontWeight.w600,
color: theme.colorScheme.onSurface,
),
),
const SizedBox(width: FaiSpace.sm),
if (endpoint.workflowCount > 0)
FaiPill(
label: l.n8nWorkflowsCount(endpoint.workflowCount),
tone: FaiPillTone.success,
)
else if (endpoint.errorKind.isNotEmpty)
FaiPill(
label: endpoint.errorKind,
tone: FaiPillTone.danger,
),
],
),
Text(
endpoint.baseUrl,
style: FaiTheme.mono(
size: 10,
color: theme.colorScheme.onSurfaceVariant,
),
),
if (endpoint.message.isNotEmpty)
Text(
endpoint.message,
style: theme.textTheme.bodySmall?.copyWith(
color: endpoint.errorKind.isEmpty
? theme.colorScheme.onSurfaceVariant
: theme.colorScheme.error,
),
),
],
),
),
IconButton(
icon: const Icon(Icons.delete_outline, size: 14),
tooltip: l.n8nRemoveTooltip,
visualDensity: VisualDensity.compact,
onPressed: onRemove,
),
],
),
);
}
}
class _AddN8nEndpointDialog extends StatefulWidget {
const _AddN8nEndpointDialog();
static Future<N8nEndpointDraft?> show(BuildContext context) {
return showDialog<N8nEndpointDraft>(
context: context,
builder: (_) => const _AddN8nEndpointDialog(),
);
}
@override
State<_AddN8nEndpointDialog> createState() => _AddN8nEndpointDialogState();
}
class _AddN8nEndpointDialogState extends State<_AddN8nEndpointDialog> {
final _name = TextEditingController();
final _baseUrl = TextEditingController();
final _apiKey = TextEditingController();
final _desc = TextEditingController();
@override
void dispose() {
_name.dispose();
_baseUrl.dispose();
_apiKey.dispose();
_desc.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
return AlertDialog(
title: Text(l.addN8nTitle),
content: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 480),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l.addN8nIntro,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: FaiSpace.md),
TextField(
controller: _name,
autofocus: true,
decoration: InputDecoration(
labelText: l.addN8nNameLabel,
hintText: l.addN8nNameHint,
border: const OutlineInputBorder(),
isDense: true,
),
),
const SizedBox(height: FaiSpace.sm),
TextField(
controller: _baseUrl,
decoration: InputDecoration(
labelText: l.addN8nBaseUrlLabel,
hintText: l.addN8nBaseUrlHint,
border: const OutlineInputBorder(),
isDense: true,
),
),
const SizedBox(height: FaiSpace.sm),
TextField(
controller: _apiKey,
decoration: InputDecoration(
labelText: l.addN8nApiKeyLabel,
hintText: l.addN8nApiKeyHint,
prefixText: '\$',
border: const OutlineInputBorder(),
isDense: true,
),
),
const SizedBox(height: FaiSpace.sm),
TextField(
controller: _desc,
decoration: InputDecoration(
labelText: l.addN8nNotesLabel,
border: const OutlineInputBorder(),
isDense: true,
),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, null),
child: Text(l.buttonCancel),
),
FilledButton(
onPressed: () {
if (_name.text.trim().isEmpty || _baseUrl.text.trim().isEmpty) return;
Navigator.pop(
context,
N8nEndpointDraft(
name: _name.text.trim(),
baseUrl: _baseUrl.text.trim(),
apiKeyEnv: _apiKey.text.trim(),
description: _desc.text.trim(),
),
);
},
child: Text(l.addN8nAddButton),
),
],
);
}
}
/// One curated MCP-server suggestion — the operator clicks
/// the chip to pre-fill the add-dialog. Strictly informational
/// metadata; nothing is auto-spawned. Trust model:
/// referencing public projects with their canonical install
/// command is the same legal / security shape as a package
/// Theme-plugin picker. Lists installed `studio.theme.*`
/// capabilities (via list_capabilities) and lets the operator
/// pick one or fall back to Studio's built-in palette. The
/// choice is persisted in SharedPreferences and applied at
/// the MaterialApp level via `StudioApp.of(context).setThemePlugin`.
///
/// When no theme plugin is installed the panel renders a
/// short hint pointing at the Store rather than a useless
/// empty dropdown.
class _ThemePluginPanel extends StatefulWidget {
const _ThemePluginPanel();
@override
State<_ThemePluginPanel> createState() => _ThemePluginPanelState();
}
class _ThemePluginPanelState extends State<_ThemePluginPanel> {
Future<List<String>>? _capsFuture;
@override
void initState() {
super.initState();
_capsFuture = listThemePluginCapabilities();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
final app = StudioApp.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l.themePluginHeader,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
letterSpacing: 0.6,
fontSize: 10,
),
),
const SizedBox(height: 4),
FutureBuilder<List<String>>(
future: _capsFuture,
builder: (context, snap) {
if (snap.connectionState != ConnectionState.done) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: FaiSpace.sm),
child: SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2),
),
);
}
final caps = snap.data ?? const <String>[];
if (caps.isEmpty) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Text(
l.themePluginEmpty,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
);
}
// Dropdown shows "Built-in" + every installed
// studio.theme.* cap. Selection writes through
// StudioApp's notifier so the MaterialApp rebuilds
// with the new ColorScheme without a restart.
final active = app?.themePluginNotifier.value;
final items = <DropdownMenuItem<String?>>[
DropdownMenuItem<String?>(
value: null,
child: Text(l.themePluginNone),
),
for (final cap in caps)
DropdownMenuItem<String?>(
value: cap,
child: Text(cap, style: FaiTheme.mono(size: 12)),
),
];
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
DropdownButton<String?>(
value: active,
items: items,
isDense: true,
onChanged: app == null
? null
: (v) {
app.setThemePlugin(v);
setState(() {});
},
),
const SizedBox(height: 4),
Text(
l.themePluginHint,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
);
},
),
],
);
}
}
/// Hub maintenance panel — currently one action: reset operator
/// state via `fai reset --yes`. Lives at the bottom of Settings
/// so the everyday config controls aren't crowded by a destructive
/// button. The reset itself is gated by an interactive confirm
/// dialog before the CLI is invoked.
class _MaintenancePanel extends StatefulWidget {
/// Called after a successful reset so the parent Settings dialog
/// can bounce its gRPC channel and reload its panels.
final Future<void> Function() onResetDone;
const _MaintenancePanel({required this.onResetDone});
@override
State<_MaintenancePanel> createState() => _MaintenancePanelState();
}
class _MaintenancePanelState extends State<_MaintenancePanel> {
bool _keepModules = false;
bool _keepData = false;
bool _resetting = false;
String? _resultText;
bool _resultOk = false;
Future<void> _confirmAndReset() async {
final l = AppLocalizations.of(context)!;
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(l.maintenanceResetConfirmTitle),
content: Text(l.maintenanceResetConfirmBody),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: Text(l.buttonCancel),
),
FilledButton(
style: FilledButton.styleFrom(
backgroundColor: Theme.of(ctx).colorScheme.error,
),
onPressed: () => Navigator.pop(ctx, true),
child: Text(l.maintenanceResetConfirmButton),
),
],
),
);
if (confirmed != true || !mounted) return;
setState(() {
_resetting = true;
_resultText = null;
});
final r = await SystemActions.faiReset(
keepModules: _keepModules,
keepData: _keepData,
);
if (!mounted) return;
setState(() {
_resetting = false;
_resultOk = r.ok;
_resultText = r.ok
? r.stdout.trim()
: (r.stderr.isEmpty ? r.stdout : r.stderr).trim();
});
if (r.ok) await widget.onResetDone();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l.maintenanceHeader,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
letterSpacing: 0.6,
fontSize: 10,
),
),
const SizedBox(height: 2),
Text(
l.maintenanceResetBlurb,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: FaiSpace.sm),
CheckboxListTile(
contentPadding: EdgeInsets.zero,
dense: true,
controlAffinity: ListTileControlAffinity.leading,
value: _keepModules,
onChanged: _resetting
? null
: (v) => setState(() => _keepModules = v ?? false),
title: Text(l.maintenanceKeepModulesTitle),
subtitle: Text(
l.maintenanceKeepModulesSubtitle,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
CheckboxListTile(
contentPadding: EdgeInsets.zero,
dense: true,
controlAffinity: ListTileControlAffinity.leading,
value: _keepData,
onChanged: _resetting
? null
: (v) => setState(() => _keepData = v ?? false),
title: Text(l.maintenanceKeepDataTitle),
subtitle: Text(
l.maintenanceKeepDataSubtitle,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
const SizedBox(height: FaiSpace.sm),
Row(
children: [
FilledButton.icon(
icon: _resetting
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.restart_alt, size: 16),
label: Text(
_resetting
? l.maintenanceResetInProgress
: l.maintenanceResetButton,
),
style: FilledButton.styleFrom(
backgroundColor: theme.colorScheme.errorContainer,
foregroundColor: theme.colorScheme.onErrorContainer,
),
onPressed: _resetting ? null : _confirmAndReset,
),
],
),
if (_resultText != null) ...[
const SizedBox(height: FaiSpace.sm),
FaiErrorBox(
text: _resultText!,
isError: !_resultOk,
maxHeight: 240,
),
],
],
);
}
}
/// Credentials panel for the operator's registry auth token.
/// The hub reads the same token via `~/.fai/registry-token`
/// (or the `FAI_REGISTRY_TOKEN` env var, which still wins)
/// when downloading `.fai` bundles from a registry behind a
/// signin wall — Forgejo with REQUIRE_SIGNIN_VIEW=true,
/// GitHub-private releases, etc.
///
/// The token itself is never round-tripped back into Studio
/// after save: `configuredChars` is the trimmed length, used
/// only as a "Configured (40 chars)" status display so the
/// operator knows something landed without seeing the secret.
class _RegistryCredentialsPanel extends StatefulWidget {
/// Trimmed length of the persisted token, or null when none
/// is configured.
final int? configuredChars;
final ValueChanged<String> onSave;
final Future<void> Function() onClear;
const _RegistryCredentialsPanel({
required this.configuredChars,
required this.onSave,
required this.onClear,
});
@override
State<_RegistryCredentialsPanel> createState() =>
_RegistryCredentialsPanelState();
}
class _RegistryCredentialsPanelState
extends State<_RegistryCredentialsPanel> {
final _controller = TextEditingController();
bool _obscured = true;
@override
void initState() {
super.initState();
_controller.addListener(() {
if (mounted) setState(() {});
});
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
final isConfigured = widget.configuredChars != null;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l.registryCredentialsHeader,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
letterSpacing: 0.6,
fontSize: 10,
),
),
const SizedBox(height: 2),
Text(
l.registryCredentialsBlurb,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: FaiSpace.sm),
Row(
children: [
Icon(
isConfigured ? Icons.check_circle : Icons.radio_button_unchecked,
size: 14,
color: isConfigured
? theme.colorScheme.primary
: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: FaiSpace.xs),
Text(
isConfigured
? l.registryTokenStatusConfigured(widget.configuredChars!)
: l.registryTokenStatusNotSet,
style: theme.textTheme.bodySmall?.copyWith(
color: isConfigured
? theme.colorScheme.onSurface
: theme.colorScheme.onSurfaceVariant,
fontWeight: isConfigured ? FontWeight.w600 : null,
),
),
const Spacer(),
if (isConfigured)
TextButton.icon(
icon: const Icon(Icons.delete_outline, size: 14),
onPressed: () async {
await widget.onClear();
_controller.clear();
},
label: Text(l.registryTokenClearButton),
),
],
),
const SizedBox(height: FaiSpace.sm),
Row(
children: [
Expanded(
child: TextField(
controller: _controller,
obscureText: _obscured,
decoration: InputDecoration(
labelText: l.registryTokenFieldLabel,
hintText: l.registryTokenFieldHint,
border: const OutlineInputBorder(),
isDense: true,
suffixIcon: IconButton(
icon: Icon(
_obscured ? Icons.visibility : Icons.visibility_off,
size: 16,
),
onPressed: () => setState(() => _obscured = !_obscured),
),
),
),
),
const SizedBox(width: FaiSpace.sm),
FilledButton.icon(
icon: const Icon(Icons.save_outlined, size: 14),
label: Text(l.registryTokenSaveButton),
onPressed: _controller.text.trim().isEmpty
? null
: () {
widget.onSave(_controller.text);
_controller.clear();
},
),
],
),
const SizedBox(height: 4),
Text(
l.registryTokenStorageHint,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontSize: 11,
),
),
],
);
}
}
/// Operator panel for the hub's gRPC bearer token. When the
/// hub has `auth.tokens:` configured, Studio must present
/// `Authorization: Bearer <token>` on every call. The token
/// is stored at `~/.fai/hub-auth-token` (mode 0600) and read
/// at startup by [HubService]; this panel just lets the
/// operator paste / clear without leaving the GUI.
///
/// Same hygiene as the registry-credentials panel: the token
/// itself never round-trips back into Studio after save;
/// `configuredChars` is just the trimmed length for the
/// status display.
class _HubAuthTokenPanel extends StatefulWidget {
final int? configuredChars;
final ValueChanged<String> onSave;
final Future<void> Function() onClear;
const _HubAuthTokenPanel({
required this.configuredChars,
required this.onSave,
required this.onClear,
});
@override
State<_HubAuthTokenPanel> createState() => _HubAuthTokenPanelState();
}
class _HubAuthTokenPanelState extends State<_HubAuthTokenPanel> {
final _controller = TextEditingController();
bool _obscured = true;
@override
void initState() {
super.initState();
_controller.addListener(() {
if (mounted) setState(() {});
});
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
final isConfigured = widget.configuredChars != null;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l.hubAuthTokenHeader,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
letterSpacing: 0.6,
fontSize: 10,
),
),
const SizedBox(height: 2),
Text(
l.hubAuthTokenBlurb,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: FaiSpace.sm),
Row(
children: [
Icon(
isConfigured
? Icons.check_circle
: Icons.radio_button_unchecked,
size: 14,
color: isConfigured
? theme.colorScheme.primary
: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: FaiSpace.xs),
Text(
isConfigured
? l.hubAuthTokenStatusConfigured(widget.configuredChars!)
: l.hubAuthTokenStatusNotSet,
style: theme.textTheme.bodySmall?.copyWith(
color: isConfigured
? theme.colorScheme.onSurface
: theme.colorScheme.onSurfaceVariant,
fontWeight: isConfigured ? FontWeight.w600 : null,
),
),
const Spacer(),
if (isConfigured)
TextButton.icon(
icon: const Icon(Icons.delete_outline, size: 14),
onPressed: () async {
await widget.onClear();
_controller.clear();
},
label: Text(l.hubAuthTokenClearButton),
),
],
),
const SizedBox(height: FaiSpace.sm),
Row(
children: [
Expanded(
child: TextField(
controller: _controller,
obscureText: _obscured,
decoration: InputDecoration(
labelText: l.hubAuthTokenFieldLabel,
hintText: l.hubAuthTokenFieldHint,
border: const OutlineInputBorder(),
isDense: true,
suffixIcon: IconButton(
icon: Icon(
_obscured ? Icons.visibility : Icons.visibility_off,
size: 16,
),
onPressed: () =>
setState(() => _obscured = !_obscured),
),
),
),
),
const SizedBox(width: FaiSpace.sm),
FilledButton.icon(
icon: const Icon(Icons.save_outlined, size: 14),
label: Text(l.hubAuthTokenSaveButton),
onPressed: _controller.text.trim().isEmpty
? null
: () {
widget.onSave(_controller.text);
_controller.clear();
},
),
],
),
const SizedBox(height: 4),
Text(
l.hubAuthTokenStorageHint,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontSize: 11,
),
),
],
);
}
}
class _McpSuggestion {
final String name;
final IconData icon;
final String endpoint;
final String apiKeyEnv;
/// Localizable description. Use [resolveDescription] to fetch
/// the actual text in the active locale — the const list
/// can't hold a closure that takes [AppLocalizations], and
/// hardcoding English here is what got us into the
/// untranslated-helper-text bug.
String resolveDescription(AppLocalizations l) =>
_suggestionDescription(l, name);
const _McpSuggestion({
required this.name,
required this.icon,
required this.endpoint,
required this.apiKeyEnv,
});
}
/// Localizable lookup of an MCP-suggestion description. Keyed
/// on the same suggestion `name` field as the list below. New
/// suggestions need a matching .arb entry; the default-case
/// returns an empty string so an unconfigured suggestion fails
/// silently instead of leaking a key into the UI.
String _suggestionDescription(AppLocalizations l, String name) {
switch (name) {
case 'deepwiki':
return l.mcpSuggestionDeepwikiDesc;
case 'semgrep':
return l.mcpSuggestionSemgrepDesc;
case 'filesystem':
return l.mcpSuggestionFilesystemDesc;
case 'fetch':
return l.mcpSuggestionFetchDesc;
case 'github':
return l.mcpSuggestionGithubDesc;
case 'puppeteer':
return l.mcpSuggestionPuppeteerDesc;
case 'postgres':
return l.mcpSuggestionPostgresDesc;
case 'sqlite':
return l.mcpSuggestionSqliteDesc;
case 'brave-search':
return l.mcpSuggestionBraveSearchDesc;
case 'memory':
return l.mcpSuggestionMemoryDesc;
case 'time':
return l.mcpSuggestionTimeDesc;
default:
return '';
}
}
const _kMcpSuggestions = <_McpSuggestion>[
// ── HTTPS / streamable-HTTP servers (no Node, no API key) ──
// Descriptions are resolved via [_McpSuggestion.resolveDescription]
// against the active locale — see `mcpSuggestion*Desc` keys in
// app_en.arb / app_de.arb. Adding a suggestion here means
// adding a matching key in both .arb files plus a case to
// `_suggestionDescription` above.
_McpSuggestion(
name: 'deepwiki',
icon: Icons.menu_book_outlined,
endpoint: 'https://mcp.deepwiki.com/mcp',
apiKeyEnv: '',
),
_McpSuggestion(
name: 'semgrep',
icon: Icons.security,
endpoint: 'https://mcp.semgrep.ai/mcp',
apiKeyEnv: '',
),
// ── stdio servers (require Node + npx) ───────────────────
_McpSuggestion(
name: 'filesystem',
icon: Icons.folder_outlined,
endpoint: 'stdio://npx -y @modelcontextprotocol/server-filesystem /tmp',
apiKeyEnv: '',
),
_McpSuggestion(
name: 'fetch',
icon: Icons.cloud_download_outlined,
endpoint: 'stdio://npx -y @modelcontextprotocol/server-fetch',
apiKeyEnv: '',
),
_McpSuggestion(
name: 'github',
icon: Icons.code,
endpoint: 'stdio://npx -y @modelcontextprotocol/server-github',
apiKeyEnv: 'GITHUB_PERSONAL_ACCESS_TOKEN',
),
_McpSuggestion(
name: 'puppeteer',
icon: Icons.web,
endpoint: 'stdio://npx -y @modelcontextprotocol/server-puppeteer',
apiKeyEnv: '',
),
_McpSuggestion(
name: 'postgres',
icon: Icons.storage_outlined,
endpoint:
'stdio://npx -y @modelcontextprotocol/server-postgres postgresql://user:pw@host/db',
apiKeyEnv: '',
),
_McpSuggestion(
name: 'sqlite',
icon: Icons.dataset_outlined,
endpoint:
'stdio://npx -y @modelcontextprotocol/server-sqlite --db-path /tmp/db.sqlite',
apiKeyEnv: '',
),
_McpSuggestion(
name: 'brave-search',
icon: Icons.search,
endpoint: 'stdio://npx -y @modelcontextprotocol/server-brave-search',
apiKeyEnv: 'BRAVE_API_KEY',
),
_McpSuggestion(
name: 'memory',
icon: Icons.psychology_outlined,
endpoint: 'stdio://npx -y @modelcontextprotocol/server-memory',
apiKeyEnv: '',
),
_McpSuggestion(
name: 'time',
icon: Icons.schedule,
endpoint: 'stdio://npx -y @modelcontextprotocol/server-time',
apiKeyEnv: '',
),
];