feat(settings): hub bearer-token panel + auto-attach on every RPC
Some checks failed
Security / Security check (push) Failing after 1s
Some checks failed
Security / Security check (push) Failing after 1s
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 <stefan.a.flemming@googlemail.com>
This commit is contained in:
parent
3237c4415a
commit
c5a96bc8d4
9 changed files with 538 additions and 12 deletions
|
|
@ -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<FaiSettingsDialog> {
|
|||
/// `~/.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<FaiSettingsDialog> {
|
|||
_loadMcpClients();
|
||||
_loadN8nEndpoints();
|
||||
_loadRegistryToken();
|
||||
_loadHubAuthToken();
|
||||
}
|
||||
|
||||
Future<void> _loadHubAuthToken() async {
|
||||
try {
|
||||
final n = await HubAuthToken.charCount();
|
||||
if (!mounted) return;
|
||||
setState(() => _hubAuthTokenChars = n);
|
||||
} catch (_) {/* fail-quiet */}
|
||||
}
|
||||
|
||||
Future<void> _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<void> _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<void> _loadRegistryToken() async {
|
||||
|
|
@ -512,6 +564,12 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
|
|||
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 <token>` 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<String> onSave;
|
||||
final Future<void> 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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue