feat(studio): registry credentials panel in Settings (v0.44.0)

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 <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-05-09 13:38:47 +02:00
parent 01f3f773cf
commit 878f0a4e28
8 changed files with 464 additions and 6 deletions

View file

@ -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<FaiSettingsDialog> {
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;
@override
void initState() {
@ -53,6 +58,51 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
_loadAiStatus();
_loadMcpClients();
_loadN8nEndpoints();
_loadRegistryToken();
}
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 {
@ -453,6 +503,12 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
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<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,
),
),
],
);
}
}
class _McpSuggestion {
final String name;
final IconData icon;