From c5a96bc8d456aa0f1a811994c03781e09f7bae4a Mon Sep 17 00:00:00 2001 From: flemming-it Date: Thu, 28 May 2026 23:24:15 +0200 Subject: [PATCH] feat(settings): hub bearer-token panel + auto-attach on every RPC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- lib/data/hub.dart | 43 ++++-- lib/data/hub_auth_token.dart | 85 +++++++++++ lib/l10n/app_de.arb | 26 ++++ lib/l10n/app_en.arb | 26 ++++ lib/l10n/app_localizations.dart | 72 +++++++++ lib/l10n/app_localizations_de.dart | 43 ++++++ lib/l10n/app_localizations_en.dart | 43 ++++++ lib/widgets/fai_settings_dialog.dart | 210 +++++++++++++++++++++++++++ pubspec.yaml | 2 +- 9 files changed, 538 insertions(+), 12 deletions(-) create mode 100644 lib/data/hub_auth_token.dart diff --git a/lib/data/hub.dart b/lib/data/hub.dart index 7e6211b..03d8a25 100644 --- a/lib/data/hub.dart +++ b/lib/data/hub.dart @@ -12,6 +12,7 @@ import 'package:flutter/widgets.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'flow_output.dart'; +import 'hub_auth_token.dart'; export 'flow_output.dart'; class HubService { @@ -30,34 +31,54 @@ class HubService { static const _kPortKey = 'hub.port'; static const _kSecureKey = 'hub.secure'; - /// Read persisted endpoint and reconnect if it differs from - /// the default. Called once at app start. + /// Sentinel distinguishing "caller did not pass authToken" + /// from "caller passed null to drop the token". + static const Object _unset = Object(); + + /// Read persisted endpoint + auth token, then reconnect. + /// Called once at app start; safe to call again after the + /// operator updates the token in Settings. Future loadPersistedEndpoint() async { final prefs = await SharedPreferences.getInstance(); final host = prefs.getString(_kHostKey); final port = prefs.getInt(_kPortKey); final secure = prefs.getBool(_kSecureKey); - if (host == null) return; + final token = await HubAuthToken.read(); + if (host == null && token == null) return; final endpoint = HubEndpoint( - host: host, - port: port ?? 50051, - secure: secure ?? false, + host: host ?? _client.endpoint.host, + port: port ?? _client.endpoint.port, + secure: secure ?? _client.endpoint.secure, ); - if (endpoint.toString() != _client.endpoint.toString()) { - await reconnect(endpoint); - } + await reconnect(endpoint, authToken: token); } /// Reconnect to a new endpoint and persist for next launch. - Future reconnect(HubEndpoint endpoint) async { + /// [authToken] is read from `~/.fai/hub-auth-token` by + /// default — pass `null` to drop a previously-loaded token, + /// or omit the parameter to keep the current value. + Future reconnect( + HubEndpoint endpoint, { + Object? authToken = _unset, + }) async { await _client.close(); - _client = HubClient(endpoint: endpoint); + final token = identical(authToken, _unset) + ? await HubAuthToken.read() + : authToken as String?; + _client = HubClient(endpoint: endpoint, authToken: token); final prefs = await SharedPreferences.getInstance(); await prefs.setString(_kHostKey, endpoint.host); await prefs.setInt(_kPortKey, endpoint.port); await prefs.setBool(_kSecureKey, endpoint.secure); } + /// Reload the token from disk and reconnect using the current + /// endpoint. Called by Settings after the operator pastes or + /// clears a token. + Future reloadAuthToken() async { + await reconnect(_client.endpoint); + } + static const _kThemeKey = 'theme.mode'; static const _kLocaleKey = 'locale.code'; diff --git a/lib/data/hub_auth_token.dart b/lib/data/hub_auth_token.dart new file mode 100644 index 0000000..02c522d --- /dev/null +++ b/lib/data/hub_auth_token.dart @@ -0,0 +1,85 @@ +import 'dart:io'; +import 'package:path/path.dart' as p; + +/// Operator-managed gRPC bearer token used to authenticate +/// Studio against a hub that has `auth.tokens:` configured +/// (RBAC Level 2). Stored at `~/.fai/hub-auth-token` (mode +/// 0600 on Unix). Sister of [RegistryToken] — same on-disk +/// hygiene, different secret. +/// +/// When the file is present and non-empty Studio constructs +/// `HubClient(authToken: ...)`, which attaches +/// `Authorization: Bearer ` to every gRPC and +/// gRPC-Web call. When the file is missing Studio stays in +/// anonymous mode; the hub then either accepts or rejects +/// based on its own `auth.tokens:` config. +class HubAuthToken { + static String _faiHome() { + final home = Platform.environment['HOME'] ?? + Platform.environment['USERPROFILE']; + if (home == null || home.isEmpty) { + throw StateError( + 'Cannot resolve home directory (no HOME / USERPROFILE)', + ); + } + return p.join(home, '.fai'); + } + + /// Absolute path to the token file. + static String get path => p.join(_faiHome(), 'hub-auth-token'); + + /// True iff the file exists with a non-empty trimmed body. + static Future isConfigured() async { + final f = File(path); + if (!await f.exists()) return false; + final content = await f.readAsString(); + return content.trim().isNotEmpty; + } + + /// Length of the trimmed token, or null when the file is + /// missing / empty. Used for status display ("Configured + /// (40 chars)") without ever surfacing the secret. + static Future charCount() async { + final f = File(path); + if (!await f.exists()) return null; + final content = await f.readAsString(); + final trimmed = content.trim(); + return trimmed.isEmpty ? null : trimmed.length; + } + + /// Read the trimmed token, or `null` when missing/empty. + /// Studio calls this at startup so [HubService] can supply + /// it to the underlying gRPC client. + static Future read() async { + final f = File(path); + if (!await f.exists()) return null; + final content = (await f.readAsString()).trim(); + return content.isEmpty ? null : content; + } + + /// Persist [token] to disk, creating `~/.fai/` if needed. + /// On Unix the file is chmod-ed to 0600. Windows leaves the + /// default user-ACL alone. + static Future write(String token) async { + final f = File(path); + await f.parent.create(recursive: true); + await f.writeAsString(token.trim(), flush: true); + if (Platform.isLinux || Platform.isMacOS) { + try { + await Process.run('chmod', ['600', f.path]); + } catch (_) { + // best-effort; default perms still confine to the + // operator's machine. + } + } + } + + /// Remove the token file. Returns true iff the file existed + /// before deletion. + static Future clear() async { + final f = File(path); + if (!await f.exists()) return false; + await f.delete(); + return true; + } +} diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index c08aedd..f8af0f6 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -750,6 +750,32 @@ } } }, + "hubAuthTokenHeader": "HUB-AUTHENTIFIZIERUNG", + "hubAuthTokenBlurb": "Bearer-Token für die Studio-Anbindung an einen Hub mit aktivierter auth.tokens-Konfiguration (RBAC Level 2). Gespeichert in ~/.fai/hub-auth-token, Modus 0600. Studio sendet ihn als Authorization: Bearer bei jedem gRPC-Aufruf.", + "hubAuthTokenStatusConfigured": "Eingerichtet ({chars} Zeichen)", + "@hubAuthTokenStatusConfigured": { + "placeholders": { + "chars": { + "type": "int" + } + } + }, + "hubAuthTokenStatusNotSet": "Nicht gesetzt (anonym)", + "hubAuthTokenFieldLabel": "Token", + "hubAuthTokenFieldHint": "Token aus dem auth.tokens-Eintrag des Hub-Operators einfügen", + "hubAuthTokenSaveButton": "Speichern", + "hubAuthTokenClearButton": "Entfernen", + "hubAuthTokenStorageHint": "Lokal gespeichert in ~/.fai/hub-auth-token. Studio verbindet nach dem Speichern automatisch neu, der Token wirkt sofort.", + "hubAuthTokenSavedToast": "Hub-Token gespeichert.", + "hubAuthTokenClearedToast": "Hub-Token entfernt.", + "hubAuthTokenSaveFailedToast": "Speichern fehlgeschlagen: {error}", + "@hubAuthTokenSaveFailedToast": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "maintenanceHeader": "HUB-WARTUNG", "maintenanceResetBlurb": "Operator-Zustand zurücksetzen und mit einem sauberen Hub neu starten. Stoppt jeden laufenden Daemon, sichert ~/.fai/ atomar in ein Backup, legt es dann neu an — Binary, Channel-Pointer, MCP-/n8n-/System-AI-Konfiguration und Registry-Token bleiben erhalten. Wiederherstellen durch Zurückverschieben des Backup-Ordners.", "maintenanceKeepModulesTitle": "Installierte Module behalten", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 480a05d..3fce4d0 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -753,6 +753,32 @@ } } }, + "hubAuthTokenHeader": "HUB AUTHENTICATION", + "hubAuthTokenBlurb": "Bearer token used to authenticate Studio against a hub that has auth.tokens: configured (RBAC Level 2). Stored at ~/.fai/hub-auth-token, mode 0600. Studio sends it as Authorization: Bearer on every gRPC call.", + "hubAuthTokenStatusConfigured": "Configured ({chars} chars)", + "@hubAuthTokenStatusConfigured": { + "placeholders": { + "chars": { + "type": "int" + } + } + }, + "hubAuthTokenStatusNotSet": "Not set (anonymous)", + "hubAuthTokenFieldLabel": "Token", + "hubAuthTokenFieldHint": "Paste a token from the hub operator's auth.tokens entry", + "hubAuthTokenSaveButton": "Save", + "hubAuthTokenClearButton": "Clear", + "hubAuthTokenStorageHint": "Saved locally to ~/.fai/hub-auth-token. Studio reconnects after save so the new token takes effect immediately.", + "hubAuthTokenSavedToast": "Hub auth token saved.", + "hubAuthTokenClearedToast": "Hub auth token cleared.", + "hubAuthTokenSaveFailedToast": "Could not save: {error}", + "@hubAuthTokenSaveFailedToast": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "maintenanceHeader": "HUB MAINTENANCE", "maintenanceResetBlurb": "Wipe operator state and start over with a clean hub. Stops every running daemon, atomically backs ~/.fai/ up, then recreates it with the binary, channel state, MCP / n8n / system-AI config, and registry token preserved. Restore by moving the backup directory back.", "maintenanceKeepModulesTitle": "Keep installed modules", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index ce982f4..0faf680 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -2174,6 +2174,78 @@ abstract class AppLocalizations { /// **'Could not save: {error}'** String registryTokenSaveFailedToast(String error); + /// No description provided for @hubAuthTokenHeader. + /// + /// In en, this message translates to: + /// **'HUB AUTHENTICATION'** + String get hubAuthTokenHeader; + + /// No description provided for @hubAuthTokenBlurb. + /// + /// In en, this message translates to: + /// **'Bearer token used to authenticate Studio against a hub that has auth.tokens: configured (RBAC Level 2). Stored at ~/.fai/hub-auth-token, mode 0600. Studio sends it as Authorization: Bearer on every gRPC call.'** + String get hubAuthTokenBlurb; + + /// No description provided for @hubAuthTokenStatusConfigured. + /// + /// In en, this message translates to: + /// **'Configured ({chars} chars)'** + String hubAuthTokenStatusConfigured(int chars); + + /// No description provided for @hubAuthTokenStatusNotSet. + /// + /// In en, this message translates to: + /// **'Not set (anonymous)'** + String get hubAuthTokenStatusNotSet; + + /// No description provided for @hubAuthTokenFieldLabel. + /// + /// In en, this message translates to: + /// **'Token'** + String get hubAuthTokenFieldLabel; + + /// No description provided for @hubAuthTokenFieldHint. + /// + /// In en, this message translates to: + /// **'Paste a token from the hub operator\'s auth.tokens entry'** + String get hubAuthTokenFieldHint; + + /// No description provided for @hubAuthTokenSaveButton. + /// + /// In en, this message translates to: + /// **'Save'** + String get hubAuthTokenSaveButton; + + /// No description provided for @hubAuthTokenClearButton. + /// + /// In en, this message translates to: + /// **'Clear'** + String get hubAuthTokenClearButton; + + /// No description provided for @hubAuthTokenStorageHint. + /// + /// In en, this message translates to: + /// **'Saved locally to ~/.fai/hub-auth-token. Studio reconnects after save so the new token takes effect immediately.'** + String get hubAuthTokenStorageHint; + + /// No description provided for @hubAuthTokenSavedToast. + /// + /// In en, this message translates to: + /// **'Hub auth token saved.'** + String get hubAuthTokenSavedToast; + + /// No description provided for @hubAuthTokenClearedToast. + /// + /// In en, this message translates to: + /// **'Hub auth token cleared.'** + String get hubAuthTokenClearedToast; + + /// No description provided for @hubAuthTokenSaveFailedToast. + /// + /// In en, this message translates to: + /// **'Could not save: {error}'** + String hubAuthTokenSaveFailedToast(String error); + /// No description provided for @maintenanceHeader. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 2fdbbdc..69a3b5f 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -1241,6 +1241,49 @@ class AppLocalizationsDe extends AppLocalizations { return 'Speichern fehlgeschlagen: $error'; } + @override + String get hubAuthTokenHeader => 'HUB-AUTHENTIFIZIERUNG'; + + @override + String get hubAuthTokenBlurb => + 'Bearer-Token für die Studio-Anbindung an einen Hub mit aktivierter auth.tokens-Konfiguration (RBAC Level 2). Gespeichert in ~/.fai/hub-auth-token, Modus 0600. Studio sendet ihn als Authorization: Bearer bei jedem gRPC-Aufruf.'; + + @override + String hubAuthTokenStatusConfigured(int chars) { + return 'Eingerichtet ($chars Zeichen)'; + } + + @override + String get hubAuthTokenStatusNotSet => 'Nicht gesetzt (anonym)'; + + @override + String get hubAuthTokenFieldLabel => 'Token'; + + @override + String get hubAuthTokenFieldHint => + 'Token aus dem auth.tokens-Eintrag des Hub-Operators einfügen'; + + @override + String get hubAuthTokenSaveButton => 'Speichern'; + + @override + String get hubAuthTokenClearButton => 'Entfernen'; + + @override + String get hubAuthTokenStorageHint => + 'Lokal gespeichert in ~/.fai/hub-auth-token. Studio verbindet nach dem Speichern automatisch neu, der Token wirkt sofort.'; + + @override + String get hubAuthTokenSavedToast => 'Hub-Token gespeichert.'; + + @override + String get hubAuthTokenClearedToast => 'Hub-Token entfernt.'; + + @override + String hubAuthTokenSaveFailedToast(String error) { + return 'Speichern fehlgeschlagen: $error'; + } + @override String get maintenanceHeader => 'HUB-WARTUNG'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 38c2b46..c0190f5 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1256,6 +1256,49 @@ class AppLocalizationsEn extends AppLocalizations { return 'Could not save: $error'; } + @override + String get hubAuthTokenHeader => 'HUB AUTHENTICATION'; + + @override + String get hubAuthTokenBlurb => + 'Bearer token used to authenticate Studio against a hub that has auth.tokens: configured (RBAC Level 2). Stored at ~/.fai/hub-auth-token, mode 0600. Studio sends it as Authorization: Bearer on every gRPC call.'; + + @override + String hubAuthTokenStatusConfigured(int chars) { + return 'Configured ($chars chars)'; + } + + @override + String get hubAuthTokenStatusNotSet => 'Not set (anonymous)'; + + @override + String get hubAuthTokenFieldLabel => 'Token'; + + @override + String get hubAuthTokenFieldHint => + 'Paste a token from the hub operator\'s auth.tokens entry'; + + @override + String get hubAuthTokenSaveButton => 'Save'; + + @override + String get hubAuthTokenClearButton => 'Clear'; + + @override + String get hubAuthTokenStorageHint => + 'Saved locally to ~/.fai/hub-auth-token. Studio reconnects after save so the new token takes effect immediately.'; + + @override + String get hubAuthTokenSavedToast => 'Hub auth token saved.'; + + @override + String get hubAuthTokenClearedToast => 'Hub auth token cleared.'; + + @override + String hubAuthTokenSaveFailedToast(String error) { + return 'Could not save: $error'; + } + @override String get maintenanceHeader => 'HUB MAINTENANCE'; diff --git a/lib/widgets/fai_settings_dialog.dart b/lib/widgets/fai_settings_dialog.dart index 646a450..ed6e8f6 100644 --- a/lib/widgets/fai_settings_dialog.dart +++ b/lib/widgets/fai_settings_dialog.dart @@ -6,6 +6,7 @@ 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'; @@ -48,6 +49,10 @@ class _FaiSettingsDialogState extends State { /// `~/.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() { @@ -61,6 +66,53 @@ class _FaiSettingsDialogState extends State { _loadMcpClients(); _loadN8nEndpoints(); _loadRegistryToken(); + _loadHubAuthToken(); + } + + Future _loadHubAuthToken() async { + try { + final n = await HubAuthToken.charCount(); + if (!mounted) return; + setState(() => _hubAuthTokenChars = n); + } catch (_) {/* fail-quiet */} + } + + Future _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 _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 _loadRegistryToken() async { @@ -512,6 +564,12 @@ class _FaiSettingsDialogState extends State { 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( @@ -1886,6 +1944,158 @@ class _RegistryCredentialsPanelState } } +/// Operator panel for the hub's gRPC bearer token. When the +/// hub has `auth.tokens:` configured, Studio must present +/// `Authorization: Bearer ` 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 onSave; + final Future 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; diff --git a/pubspec.yaml b/pubspec.yaml index 931393a..b2a9e19 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: fai_studio description: "F∆I Studio — desktop GUI for the F∆I hub" publish_to: 'none' -version: 0.46.0 +version: 0.47.0 environment: sdk: ^3.11.0-200.1.beta