From 878f0a4e282e195290ad7c220763110dee6de555 Mon Sep 17 00:00:00 2001 From: flemming-it Date: Sat, 9 May 2026 13:38:47 +0200 Subject: [PATCH] feat(studio): registry credentials panel in Settings (v0.44.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hub install path now reads `~/.fai/registry-token` as a fallback when `FAI_REGISTRY_TOKEN` is unset (platform v0.10.92). Studio now writes that file directly: a fresh operator pastes the PAT into Settings → Registry credentials, hits Save, and the next install attempt resolves the auth wall without any shell or env-var setup. The token never round-trips back into Studio after save — status is shown only as "Configured (40 chars)" / "Not set" with no display of the secret itself. The Clear action deletes the file. On Unix the file is chmod-ed to 0600 (owner-only read/write); Windows leaves the default user ACL in place. Storage is strictly local: `~/.fai/registry-token`, never sent to a remote service. The hint text under the field says so to make the data flow explicit. Signed-off-by: flemming-it --- lib/data/registry_token.dart | 66 +++++++++ lib/l10n/app_de.arb | 16 ++ lib/l10n/app_en.arb | 16 ++ lib/l10n/app_localizations.dart | 72 +++++++++ lib/l10n/app_localizations_de.dart | 42 ++++++ lib/l10n/app_localizations_en.dart | 43 ++++++ lib/widgets/fai_settings_dialog.dart | 213 ++++++++++++++++++++++++++- pubspec.yaml | 2 +- 8 files changed, 464 insertions(+), 6 deletions(-) create mode 100644 lib/data/registry_token.dart diff --git a/lib/data/registry_token.dart b/lib/data/registry_token.dart new file mode 100644 index 0000000..359d11f --- /dev/null +++ b/lib/data/registry_token.dart @@ -0,0 +1,66 @@ +import 'dart:io'; +import 'package:path/path.dart' as p; + +/// Operator-managed registry auth token, kept at +/// `~/.fai/registry-token` (mode 0600 on Unix). The hub reads +/// this file at install time when `FAI_REGISTRY_TOKEN` is unset +/// — see `download_to_temp` in `crates/fai_hub/src/lib.rs`. +/// +/// Studio writes the file directly so a fresh install never +/// requires the operator to fiddle with shell env vars. +class RegistryToken { + 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(), 'registry-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; + } + + /// Persist [token] to disk, creating `~/.fai/` if needed. + /// On Unix the file is chmod-ed to 0600 (owner read/write + /// only). On Windows the default user-ACL is left alone — + /// best-effort, no PowerShell handshake. + 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; if chmod is unavailable the file still + // exists with default perms (usually 0644, world-readable + // but only on the operator's own machine). + } + } + } + + /// Delete the token file. No-op when the file doesn't exist. + static Future delete() async { + final f = File(path); + if (await f.exists()) await f.delete(); + } +} diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index d4f9030..14897c3 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -433,6 +433,22 @@ "@flowsOpenInEditorFailed": { "placeholders": { "error": { "type": "String" } } }, + "registryCredentialsHeader": "REGISTRY-ZUGANGSDATEN", + "registryCredentialsBlurb": "Token zum Herunterladen von .fai-Modulen aus einer Registry mit Anmeldepflicht (Forgejo, private GitHub-Repos). Gespeichert in ~/.fai/registry-token, Modus 0600. Die Umgebungsvariable FAI_REGISTRY_TOKEN hat weiterhin Vorrang, wenn gesetzt.", + "registryTokenStatusConfigured": "Konfiguriert ({chars} Zeichen)", + "@registryTokenStatusConfigured": { "placeholders": { "chars": { "type": "int" } } }, + "registryTokenStatusNotSet": "Nicht gesetzt", + "registryTokenFieldLabel": "Token", + "registryTokenFieldHint": "Forgejo- oder GitHub-PAT einfügen", + "registryTokenSaveButton": "Speichern", + "registryTokenClearButton": "Entfernen", + "registryTokenStorageHint": "Lokal gespeichert in ~/.fai/registry-token. Wird nie an einen Remote-Dienst übertragen.", + "registryTokenSavedToast": "Registry-Token gespeichert.", + "registryTokenClearedToast": "Registry-Token entfernt.", + "registryTokenSaveFailedToast": "Speichern fehlgeschlagen: {error}", + "@registryTokenSaveFailedToast": { + "placeholders": { "error": { "type": "String" } } + }, "welcomeChecklistAllSetTitle": "Du bist eingerichtet.", "welcomeChecklistAllSetBody": "Drei Stränge, an denen du als nächstes ziehen kannst:", "welcomeChecklistNextAuditTitle": "Audit-Log lesen", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 8ff24a5..0b099a4 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -434,6 +434,22 @@ "@flowsOpenInEditorFailed": { "placeholders": { "error": { "type": "String" } } }, + "registryCredentialsHeader": "REGISTRY CREDENTIALS", + "registryCredentialsBlurb": "Token used to download .fai modules from a registry behind a signin wall (Forgejo, GitHub-private). Stored at ~/.fai/registry-token, mode 0600. The FAI_REGISTRY_TOKEN env var still wins when set.", + "registryTokenStatusConfigured": "Configured ({chars} chars)", + "@registryTokenStatusConfigured": { "placeholders": { "chars": { "type": "int" } } }, + "registryTokenStatusNotSet": "Not set", + "registryTokenFieldLabel": "Token", + "registryTokenFieldHint": "Paste your Forgejo or GitHub personal access token", + "registryTokenSaveButton": "Save", + "registryTokenClearButton": "Clear", + "registryTokenStorageHint": "Saved locally to ~/.fai/registry-token. Never sent to a remote service.", + "registryTokenSavedToast": "Registry token saved.", + "registryTokenClearedToast": "Registry token cleared.", + "registryTokenSaveFailedToast": "Could not save: {error}", + "@registryTokenSaveFailedToast": { + "placeholders": { "error": { "type": "String" } } + }, "welcomeChecklistAllSetTitle": "You're set up.", "welcomeChecklistAllSetBody": "Three threads to pull on next:", "welcomeChecklistNextAuditTitle": "Read the audit log", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index c5984f3..7ab7671 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1970,6 +1970,78 @@ abstract class AppLocalizations { /// **'Could not open file: {error}'** String flowsOpenInEditorFailed(String error); + /// No description provided for @registryCredentialsHeader. + /// + /// In en, this message translates to: + /// **'REGISTRY CREDENTIALS'** + String get registryCredentialsHeader; + + /// No description provided for @registryCredentialsBlurb. + /// + /// In en, this message translates to: + /// **'Token used to download .fai modules from a registry behind a signin wall (Forgejo, GitHub-private). Stored at ~/.fai/registry-token, mode 0600. The FAI_REGISTRY_TOKEN env var still wins when set.'** + String get registryCredentialsBlurb; + + /// No description provided for @registryTokenStatusConfigured. + /// + /// In en, this message translates to: + /// **'Configured ({chars} chars)'** + String registryTokenStatusConfigured(int chars); + + /// No description provided for @registryTokenStatusNotSet. + /// + /// In en, this message translates to: + /// **'Not set'** + String get registryTokenStatusNotSet; + + /// No description provided for @registryTokenFieldLabel. + /// + /// In en, this message translates to: + /// **'Token'** + String get registryTokenFieldLabel; + + /// No description provided for @registryTokenFieldHint. + /// + /// In en, this message translates to: + /// **'Paste your Forgejo or GitHub personal access token'** + String get registryTokenFieldHint; + + /// No description provided for @registryTokenSaveButton. + /// + /// In en, this message translates to: + /// **'Save'** + String get registryTokenSaveButton; + + /// No description provided for @registryTokenClearButton. + /// + /// In en, this message translates to: + /// **'Clear'** + String get registryTokenClearButton; + + /// No description provided for @registryTokenStorageHint. + /// + /// In en, this message translates to: + /// **'Saved locally to ~/.fai/registry-token. Never sent to a remote service.'** + String get registryTokenStorageHint; + + /// No description provided for @registryTokenSavedToast. + /// + /// In en, this message translates to: + /// **'Registry token saved.'** + String get registryTokenSavedToast; + + /// No description provided for @registryTokenClearedToast. + /// + /// In en, this message translates to: + /// **'Registry token cleared.'** + String get registryTokenClearedToast; + + /// No description provided for @registryTokenSaveFailedToast. + /// + /// In en, this message translates to: + /// **'Could not save: {error}'** + String registryTokenSaveFailedToast(String error); + /// No description provided for @welcomeChecklistAllSetTitle. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index d126c58..7d4f4a9 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -1117,6 +1117,48 @@ class AppLocalizationsDe extends AppLocalizations { return 'Konnte Datei nicht öffnen: $error'; } + @override + String get registryCredentialsHeader => 'REGISTRY-ZUGANGSDATEN'; + + @override + String get registryCredentialsBlurb => + 'Token zum Herunterladen von .fai-Modulen aus einer Registry mit Anmeldepflicht (Forgejo, private GitHub-Repos). Gespeichert in ~/.fai/registry-token, Modus 0600. Die Umgebungsvariable FAI_REGISTRY_TOKEN hat weiterhin Vorrang, wenn gesetzt.'; + + @override + String registryTokenStatusConfigured(int chars) { + return 'Konfiguriert ($chars Zeichen)'; + } + + @override + String get registryTokenStatusNotSet => 'Nicht gesetzt'; + + @override + String get registryTokenFieldLabel => 'Token'; + + @override + String get registryTokenFieldHint => 'Forgejo- oder GitHub-PAT einfügen'; + + @override + String get registryTokenSaveButton => 'Speichern'; + + @override + String get registryTokenClearButton => 'Entfernen'; + + @override + String get registryTokenStorageHint => + 'Lokal gespeichert in ~/.fai/registry-token. Wird nie an einen Remote-Dienst übertragen.'; + + @override + String get registryTokenSavedToast => 'Registry-Token gespeichert.'; + + @override + String get registryTokenClearedToast => 'Registry-Token entfernt.'; + + @override + String registryTokenSaveFailedToast(String error) { + return 'Speichern fehlgeschlagen: $error'; + } + @override String get welcomeChecklistAllSetTitle => 'Du bist eingerichtet.'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index c2f0d59..1a17e23 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1131,6 +1131,49 @@ class AppLocalizationsEn extends AppLocalizations { return 'Could not open file: $error'; } + @override + String get registryCredentialsHeader => 'REGISTRY CREDENTIALS'; + + @override + String get registryCredentialsBlurb => + 'Token used to download .fai modules from a registry behind a signin wall (Forgejo, GitHub-private). Stored at ~/.fai/registry-token, mode 0600. The FAI_REGISTRY_TOKEN env var still wins when set.'; + + @override + String registryTokenStatusConfigured(int chars) { + return 'Configured ($chars chars)'; + } + + @override + String get registryTokenStatusNotSet => 'Not set'; + + @override + String get registryTokenFieldLabel => 'Token'; + + @override + String get registryTokenFieldHint => + 'Paste your Forgejo or GitHub personal access token'; + + @override + String get registryTokenSaveButton => 'Save'; + + @override + String get registryTokenClearButton => 'Clear'; + + @override + String get registryTokenStorageHint => + 'Saved locally to ~/.fai/registry-token. Never sent to a remote service.'; + + @override + String get registryTokenSavedToast => 'Registry token saved.'; + + @override + String get registryTokenClearedToast => 'Registry token cleared.'; + + @override + String registryTokenSaveFailedToast(String error) { + return 'Could not save: $error'; + } + @override String get welcomeChecklistAllSetTitle => 'You\'re set up.'; diff --git a/lib/widgets/fai_settings_dialog.dart b/lib/widgets/fai_settings_dialog.dart index 8b659e4..ac00dd8 100644 --- a/lib/widgets/fai_settings_dialog.dart +++ b/lib/widgets/fai_settings_dialog.dart @@ -6,6 +6,7 @@ import 'package:fai_dart_sdk/fai_dart_sdk.dart'; import 'package:flutter/material.dart'; import '../data/hub.dart'; +import '../data/registry_token.dart'; import '../data/system_actions.dart'; import '../l10n/app_localizations.dart'; import '../theme/theme.dart'; @@ -41,6 +42,10 @@ class _FaiSettingsDialogState extends State { SystemAiStatus? _aiStatus; List? _mcpClients; List? _n8nEndpoints; + /// Length of the configured registry token, or null when the + /// `~/.fai/registry-token` file is missing / empty. Reloaded + /// after save / clear. + int? _registryTokenChars; @override void initState() { @@ -53,6 +58,51 @@ class _FaiSettingsDialogState extends State { _loadAiStatus(); _loadMcpClients(); _loadN8nEndpoints(); + _loadRegistryToken(); + } + + Future _loadRegistryToken() async { + try { + final n = await RegistryToken.charCount(); + if (!mounted) return; + setState(() => _registryTokenChars = n); + } catch (_) {/* fail-quiet */} + } + + Future _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 _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 _loadN8nEndpoints() async { @@ -453,6 +503,12 @@ class _FaiSettingsDialogState extends State { onRefresh: _refreshN8nEndpoints, ), ], + const SizedBox(height: FaiSpace.lg), + _RegistryCredentialsPanel( + configuredChars: _registryTokenChars, + onSave: _saveRegistryToken, + onClear: _clearRegistryToken, + ), ], ), ), @@ -1381,12 +1437,159 @@ class _AddN8nEndpointDialogState extends State<_AddN8nEndpointDialog> { /// metadata; nothing is auto-spawned. Trust model: /// referencing public projects with their canonical install /// command is the same legal / security shape as a package -/// manager listing a third-party repo. +/// 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. /// -/// All entries point at official Anthropic MCP servers from -/// `github.com/modelcontextprotocol/servers` plus a few -/// community-curated standards. Refresh the list when the -/// upstream README changes. +/// 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 onSave; + final Future 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, + ), + ), + ], + ); + } +} + class _McpSuggestion { final String name; final IconData icon; diff --git a/pubspec.yaml b/pubspec.yaml index 0aa64ba..f5ca94f 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.43.0 +version: 0.44.0 environment: sdk: ^3.11.0-200.1.beta