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