// Read-only view of the hub's authentication policy (T4/T5): // which token validator is active, whether anonymous calls are // accepted, and the configured tokens with their scope grants — // surfaced in Settings → Security so security administration is // inspectable without opening config.yaml. Editing stays in the // operator config on purpose (secrets live in env vars, the file // carries only names); the panel says so and offers the live // reload that applies a rotation without a daemon restart. import 'package:flutter/material.dart'; import '../data/hub.dart'; import '../l10n/app_localizations.dart'; import '../theme/tokens.dart'; import 'chain_error_box.dart'; class HubAuthPolicyPanel extends StatefulWidget { /// Test seam: replaces the live [HubService.authStatus] call. @visibleForTesting final Future Function()? loader; /// Test seam: replaces the live [HubService.reloadHubAuth] call. @visibleForTesting final Future Function()? reloader; /// Bumped by the parent whenever an adjacent action changed the /// connection's auth state (token saved or cleared in the panel /// above) — the policy view reloads instead of showing a stale /// "store an admin token" hint next to the just-stored token. final int reloadTick; const HubAuthPolicyPanel({ super.key, this.loader, this.reloader, this.reloadTick = 0, }); @override State createState() => _HubAuthPolicyPanelState(); } class _HubAuthPolicyPanelState extends State { HubAuthPolicy? _policy; Object? _error; bool _loading = true; bool _reloading = false; String? _reloadNote; @override void initState() { super.initState(); _load(); } @override void didUpdateWidget(covariant HubAuthPolicyPanel oldWidget) { super.didUpdateWidget(oldWidget); if (widget.reloadTick != oldWidget.reloadTick) { _load(); } } Future _load() async { setState(() { _loading = true; _error = null; }); try { final load = widget.loader ?? HubService.instance.authStatus; final p = await load(); if (!mounted) return; setState(() { _policy = p; _loading = false; }); } catch (e) { if (!mounted) return; setState(() { _error = e; _loading = false; }); } } Future _reload() async { final l = AppLocalizations.of(context)!; setState(() { _reloading = true; _reloadNote = null; }); try { final reload = widget.reloader ?? HubService.instance.reloadHubAuth; final n = await reload(); if (!mounted) return; setState(() { _reloading = false; _reloadNote = l.authPolicyReloadDone(n); }); await _load(); } catch (e) { if (!mounted) return; setState(() { _reloading = false; _error = e; }); } } /// True when the failure is the hub refusing the caller — /// either no/invalid token (UNAUTHENTICATED) or a token /// without the admin scope (PERMISSION_DENIED). Both get the /// same plain-language fix: store an admin token above. bool _isPermissionDenied(Object e) { final s = e.toString(); return s.contains('PERMISSION_DENIED') || s.contains('code: 7') || s.contains('UNAUTHENTICATED') || s.contains('code: 16'); } /// True when the hub predates the AuthStatus RPC (skew: Studio /// newer than the hub) — gets an update hint, not a raw error. bool _isUnimplemented(Object e) { final s = e.toString(); return s.contains('UNIMPLEMENTED') || s.contains('code: 12'); } @override Widget build(BuildContext context) { final theme = Theme.of(context); final l = AppLocalizations.of(context)!; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( l.authPolicyHeader, style: theme.textTheme.labelSmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, letterSpacing: 0.6, fontSize: 10, ), ), const SizedBox(height: 2), Text( l.authPolicyBlurb, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), const SizedBox(height: ChainSpace.sm), if (_loading) const Padding( padding: EdgeInsets.symmetric(vertical: ChainSpace.md), child: Center( child: SizedBox( width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2), ), ), ) else if (_error != null) ...[ if (_isPermissionDenied(_error!)) _hintRow(theme, Icons.lock_outline, l.authPolicyNeedsAdmin) else if (_isUnimplemented(_error!)) _hintRow(theme, Icons.update, l.authPolicyHubTooOld) else ChainErrorBox(error: _error!, isError: true, maxHeight: 160), const SizedBox(height: ChainSpace.xs), Align( alignment: Alignment.centerLeft, child: OutlinedButton.icon( onPressed: _load, icon: const Icon(Icons.refresh, size: 16), label: Text(l.authPolicyRetry), ), ), ] else if (_policy != null) ..._policyView(theme, l, _policy!), ], ); } List _policyView(ThemeData theme, AppLocalizations l, HubAuthPolicy p) { final isJwt = p.validator == 'jwt-rs256'; return [ Row( children: [ Icon( Icons.verified_user_outlined, size: 16, color: theme.colorScheme.primary, ), const SizedBox(width: ChainSpace.xs), Expanded( child: Text( isJwt ? l.authPolicyValidatorJwt : l.authPolicyValidatorStatic, style: theme.textTheme.bodyMedium?.copyWith( fontWeight: FontWeight.w600, ), ), ), ], ), if (p.anonymousAllowed) ...[ const SizedBox(height: ChainSpace.xs), _hintRow( theme, Icons.warning_amber_outlined, l.authPolicyAnonymous, color: theme.colorScheme.tertiary, ), ], if (p.reloadRequired) ...[ const SizedBox(height: ChainSpace.xs), _hintRow( theme, Icons.sync_problem_outlined, l.authPolicyReloadRequired, color: theme.colorScheme.tertiary, ), ], if (p.tokens.isNotEmpty) ...[ const SizedBox(height: ChainSpace.sm), for (final t in p.tokens) _tokenRow(theme, l, t), ], if (isJwt && p.jwt != null) ...[ const SizedBox(height: ChainSpace.sm), _kvRow(theme, l.authPolicyJwtKeySource, p.jwt!.keySource), _kvRow( theme, l.authPolicyJwtAudience, p.jwt!.audience.isEmpty ? l.authPolicyNotChecked : p.jwt!.audience, ), _kvRow( theme, l.authPolicyJwtIssuer, p.jwt!.issuer.isEmpty ? l.authPolicyNotChecked : p.jwt!.issuer, ), _kvRow(theme, l.authPolicyJwtScopeClaim, p.jwt!.scopeClaim), ], const SizedBox(height: ChainSpace.sm), Text( l.authPolicyEditHint, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), const SizedBox(height: ChainSpace.xs), Row( children: [ OutlinedButton.icon( onPressed: _reloading ? null : _reload, icon: _reloading ? const SizedBox( width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2), ) : const Icon(Icons.refresh, size: 16), label: Text(l.authPolicyReload), ), if (_reloadNote != null) ...[ const SizedBox(width: ChainSpace.sm), Flexible( child: Text( _reloadNote!, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.primary, ), ), ), ], ], ), ]; } Widget _tokenRow(ThemeData theme, AppLocalizations l, HubAuthTokenEntry t) { return Padding( padding: const EdgeInsets.only(bottom: ChainSpace.sm), child: Container( width: double.infinity, padding: const EdgeInsets.symmetric( horizontal: ChainSpace.sm, vertical: ChainSpace.xs, ), decoration: BoxDecoration( color: theme.colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(ChainRadius.sm), border: Border.all(color: theme.colorScheme.outlineVariant), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Expanded( child: Text( t.name, style: theme.textTheme.bodyMedium?.copyWith( fontWeight: FontWeight.w600, ), ), ), if (t.rateLimitPerMinute > 0) Text( l.authPolicyRateLimit(t.rateLimitPerMinute), style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ], ), const SizedBox(height: 4), Wrap( spacing: 4, runSpacing: 4, children: [ for (final s in t.scopes) Container( padding: const EdgeInsets.symmetric( horizontal: 6, vertical: 1, ), decoration: BoxDecoration( color: theme.colorScheme.primary.withValues(alpha: 0.10), borderRadius: BorderRadius.circular(ChainRadius.sm), ), child: Text( s, style: theme.textTheme.labelSmall?.copyWith( color: theme.colorScheme.primary, ), ), ), ], ), const SizedBox(height: 4), Row( children: [ Icon( t.envSet ? Icons.check_circle : Icons.error_outline, size: 13, color: t.envSet ? theme.colorScheme.primary : theme.colorScheme.error, ), const SizedBox(width: 4), Expanded( child: Text( t.envSet ? l.authPolicyEnvSet(t.tokenEnv) : l.authPolicyEnvMissing(t.tokenEnv), style: theme.textTheme.bodySmall?.copyWith( color: t.envSet ? theme.colorScheme.onSurfaceVariant : theme.colorScheme.error, ), ), ), ], ), ], ), ), ); } Widget _kvRow(ThemeData theme, String label, String value) { return Padding( padding: const EdgeInsets.only(bottom: 2), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox( width: 140, child: Text( label, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ), Expanded( child: SelectableText( value, style: theme.textTheme.bodySmall, ), ), ], ), ); } Widget _hintRow( ThemeData theme, IconData icon, String text, { Color? color, }) { final c = color ?? theme.colorScheme.onSurfaceVariant; return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Icon(icon, size: 16, color: c), const SizedBox(width: 6), Expanded( child: Text( text, style: theme.textTheme.bodySmall?.copyWith(color: c), ), ), ], ); } }