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:
parent
01f3f773cf
commit
878f0a4e28
8 changed files with 464 additions and 6 deletions
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue