- Add curated catalogue of 9 official Anthropic MCP servers as click-to-prefill chips in the Add-MCP-server dialog. Operator still confirms via "Add + discover"; nothing is auto-spawned, preserving the regulated-environment trust model. - Localize Approvals page: pending/history empty states, status pills, payload preview, approve/reject toasts, reject dialog, history detail dialog, expires-in pill. - Localize Audit page: filter chips, clear-log dialog and toasts, no-events / hub-unreachable empty states, live-status bar with pluralized event count, hash-chain-verified badge, System AI panel (cached pill, latency, regenerate tooltip, asking state), event-detail dialog actions. - Localize Settings dialog: hub-endpoint section, channel blurb + popup-menu items, System AI panel headers and off-blurb, MCP/N8N panel headers + pluralized counts + refresh/add/remove tooltips, Add-MCP and Add-n8n dialogs (intro, suggestions label, all field labels and hints, save buttons, toasts). Field labels that map directly to JSON keys (event_id, flow, step, module, provider, endpoint, model) stay English by design — they're technical identifiers, not UI copy. Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
633 lines
20 KiB
Dart
633 lines
20 KiB
Dart
import 'dart:convert';
|
||
import 'dart:io';
|
||
|
||
import 'package:flutter/material.dart';
|
||
|
||
import '../data/hub.dart';
|
||
import '../l10n/app_localizations.dart';
|
||
import '../theme/theme.dart';
|
||
import '../theme/tokens.dart';
|
||
import '../widgets/widgets.dart';
|
||
|
||
class ApprovalsPage extends StatefulWidget {
|
||
const ApprovalsPage({super.key});
|
||
|
||
@override
|
||
State<ApprovalsPage> createState() => _ApprovalsPageState();
|
||
}
|
||
|
||
class _ApprovalsPageState extends State<ApprovalsPage>
|
||
with SingleTickerProviderStateMixin {
|
||
late final TabController _tab;
|
||
late Future<List<ApprovalRecord>> _pendingFuture;
|
||
late Future<List<ApprovalRecord>> _historyFuture;
|
||
// Reviewer identity recorded in the audit log. Defaults to
|
||
// the OS user (closest stable identity Studio has without an
|
||
// auth backend); operators can override it per session.
|
||
late final String _reviewer = _defaultReviewer();
|
||
|
||
static String _defaultReviewer() {
|
||
final user = Platform.environment['USER'] ??
|
||
Platform.environment['USERNAME'] ??
|
||
'studio';
|
||
return '$user@studio';
|
||
}
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_tab = TabController(length: 2, vsync: this);
|
||
_refresh();
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_tab.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
void _refresh() {
|
||
setState(() {
|
||
_pendingFuture = HubService.instance.listApprovalsRecords(
|
||
statuses: const ['pending'],
|
||
);
|
||
_historyFuture = HubService.instance.listApprovalsRecords(
|
||
statuses: const ['approved', 'rejected', 'expired'],
|
||
limit: 200,
|
||
);
|
||
});
|
||
}
|
||
|
||
Future<void> _approve(ApprovalRecord a) async {
|
||
final l = AppLocalizations.of(context)!;
|
||
try {
|
||
await HubService.instance.approve(a.id, _reviewer);
|
||
_toast(l.approvalsApprovedToast(a.flowName, a.stepId));
|
||
_refresh();
|
||
} catch (e) {
|
||
_toast(l.approvalsApproveFailed(e.toString()));
|
||
}
|
||
}
|
||
|
||
Future<void> _reject(ApprovalRecord a) async {
|
||
final l = AppLocalizations.of(context)!;
|
||
final reason = await _promptReason(context);
|
||
if (reason == null || reason.isEmpty) return;
|
||
try {
|
||
await HubService.instance.reject(a.id, _reviewer, reason);
|
||
_toast(l.approvalsRejectedToast(a.flowName, a.stepId));
|
||
_refresh();
|
||
} catch (e) {
|
||
_toast(l.approvalsRejectFailed(e.toString()));
|
||
}
|
||
}
|
||
|
||
void _toast(String msg) {
|
||
if (!mounted) return;
|
||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
|
||
}
|
||
|
||
Future<String?> _promptReason(BuildContext context) async {
|
||
final controller = TextEditingController();
|
||
final l = AppLocalizations.of(context)!;
|
||
return showDialog<String>(
|
||
context: context,
|
||
builder: (ctx) => AlertDialog(
|
||
title: Text(l.approvalsRejectDialogTitle),
|
||
content: TextField(
|
||
controller: controller,
|
||
autofocus: true,
|
||
decoration: InputDecoration(
|
||
labelText: l.approvalsRejectReasonLabel,
|
||
border: const OutlineInputBorder(),
|
||
),
|
||
),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () => Navigator.pop(ctx, null),
|
||
child: Text(l.buttonCancel),
|
||
),
|
||
FilledButton(
|
||
onPressed: () => Navigator.pop(ctx, controller.text),
|
||
style: FilledButton.styleFrom(
|
||
backgroundColor: Theme.of(ctx).colorScheme.error,
|
||
foregroundColor: Theme.of(ctx).colorScheme.onError,
|
||
),
|
||
child: Text(l.approvalsRejectButton),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
return Scaffold(
|
||
backgroundColor: theme.scaffoldBackgroundColor,
|
||
appBar: AppBar(
|
||
title: Text(AppLocalizations.of(context)!.approvalsTitle),
|
||
bottom: TabBar(
|
||
controller: _tab,
|
||
tabs: [
|
||
Tab(text: AppLocalizations.of(context)!.approvalsTabPending),
|
||
Tab(text: AppLocalizations.of(context)!.approvalsTabHistory),
|
||
],
|
||
),
|
||
actions: [
|
||
IconButton(
|
||
icon: const Icon(Icons.refresh, size: 18),
|
||
tooltip: AppLocalizations.of(context)!.approvalsReloadTooltip,
|
||
onPressed: _refresh,
|
||
),
|
||
const SizedBox(width: FaiSpace.sm),
|
||
],
|
||
),
|
||
body: TabBarView(
|
||
controller: _tab,
|
||
children: [
|
||
_PendingList(
|
||
future: _pendingFuture,
|
||
onApprove: _approve,
|
||
onReject: _reject,
|
||
onRetry: _refresh,
|
||
),
|
||
_HistoryList(
|
||
future: _historyFuture,
|
||
onRetry: _refresh,
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _PendingList extends StatelessWidget {
|
||
final Future<List<ApprovalRecord>> future;
|
||
final void Function(ApprovalRecord) onApprove;
|
||
final void Function(ApprovalRecord) onReject;
|
||
final VoidCallback onRetry;
|
||
|
||
const _PendingList({
|
||
required this.future,
|
||
required this.onApprove,
|
||
required this.onReject,
|
||
required this.onRetry,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
final l = AppLocalizations.of(context)!;
|
||
return FutureBuilder<List<ApprovalRecord>>(
|
||
future: future,
|
||
builder: (context, snapshot) {
|
||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||
return const Center(child: CircularProgressIndicator());
|
||
}
|
||
if (snapshot.hasError) {
|
||
return FaiEmptyState(
|
||
icon: Icons.cloud_off_outlined,
|
||
iconColor: theme.colorScheme.error,
|
||
title: l.hubUnreachable,
|
||
hint: l.hubUnreachableHint,
|
||
action: FilledButton.tonal(
|
||
onPressed: onRetry,
|
||
child: Text(l.buttonRetry),
|
||
),
|
||
);
|
||
}
|
||
final pending = snapshot.data ?? [];
|
||
if (pending.isEmpty) {
|
||
return FaiEmptyState(
|
||
icon: Icons.task_alt_outlined,
|
||
title: l.approvalsInboxZero,
|
||
hint: l.approvalsInboxHint,
|
||
);
|
||
}
|
||
return ListView.separated(
|
||
padding: const EdgeInsets.all(FaiSpace.xl),
|
||
itemCount: pending.length,
|
||
separatorBuilder: (_, _) => const SizedBox(height: FaiSpace.md),
|
||
itemBuilder: (context, i) {
|
||
final a = pending[i];
|
||
return _ApprovalCard(
|
||
approval: a,
|
||
onApprove: () => onApprove(a),
|
||
onReject: () => onReject(a),
|
||
);
|
||
},
|
||
);
|
||
},
|
||
);
|
||
}
|
||
}
|
||
|
||
class _HistoryList extends StatelessWidget {
|
||
final Future<List<ApprovalRecord>> future;
|
||
final VoidCallback onRetry;
|
||
|
||
const _HistoryList({required this.future, required this.onRetry});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
final l = AppLocalizations.of(context)!;
|
||
return FutureBuilder<List<ApprovalRecord>>(
|
||
future: future,
|
||
builder: (context, snapshot) {
|
||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||
return const Center(child: CircularProgressIndicator());
|
||
}
|
||
if (snapshot.hasError) {
|
||
return FaiEmptyState(
|
||
icon: Icons.cloud_off_outlined,
|
||
iconColor: theme.colorScheme.error,
|
||
title: l.hubUnreachable,
|
||
hint: l.hubUnreachableHint,
|
||
action: FilledButton.tonal(
|
||
onPressed: onRetry,
|
||
child: Text(l.buttonRetry),
|
||
),
|
||
);
|
||
}
|
||
final decided = snapshot.data ?? [];
|
||
if (decided.isEmpty) {
|
||
return FaiEmptyState(
|
||
icon: Icons.history,
|
||
title: l.approvalsHistoryEmpty,
|
||
hint: l.approvalsHistoryEmptyHint,
|
||
);
|
||
}
|
||
return ListView.separated(
|
||
padding: const EdgeInsets.all(FaiSpace.xl),
|
||
itemCount: decided.length,
|
||
separatorBuilder: (_, _) => const SizedBox(height: FaiSpace.xs),
|
||
itemBuilder: (context, i) {
|
||
final a = decided[i];
|
||
return _HistoryRow(record: a);
|
||
},
|
||
);
|
||
},
|
||
);
|
||
}
|
||
}
|
||
|
||
class _ApprovalCard extends StatelessWidget {
|
||
final ApprovalRecord approval;
|
||
final VoidCallback onApprove;
|
||
final VoidCallback onReject;
|
||
|
||
const _ApprovalCard({
|
||
required this.approval,
|
||
required this.onApprove,
|
||
required this.onReject,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
final l = AppLocalizations.of(context)!;
|
||
return FaiCard(
|
||
accentTop: true,
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
FaiPill(
|
||
label: l.approvalsPillPending,
|
||
tone: FaiPillTone.warning,
|
||
icon: Icons.pending_outlined,
|
||
),
|
||
const SizedBox(width: FaiSpace.sm),
|
||
Expanded(
|
||
child: Text(
|
||
'${approval.flowName} › ${approval.stepId}',
|
||
style: theme.textTheme.titleMedium?.copyWith(
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
),
|
||
if (approval.expiresAt != null)
|
||
FaiPill(
|
||
label: _expiresInLabel(context, approval.expiresAt!),
|
||
tone: FaiPillTone.neutral,
|
||
icon: Icons.schedule,
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: FaiSpace.lg),
|
||
Text(
|
||
approval.prompt,
|
||
style: theme.textTheme.bodyLarge,
|
||
),
|
||
if (approval.payloadPreview != null) ...[
|
||
const SizedBox(height: FaiSpace.md),
|
||
Padding(
|
||
padding: const EdgeInsets.only(bottom: 4),
|
||
child: Text(
|
||
l.approvalsPayloadPreview,
|
||
style: theme.textTheme.labelSmall?.copyWith(
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
letterSpacing: 0.6,
|
||
fontSize: 10,
|
||
),
|
||
),
|
||
),
|
||
Container(
|
||
width: double.infinity,
|
||
padding: const EdgeInsets.all(FaiSpace.md),
|
||
decoration: BoxDecoration(
|
||
color: theme.colorScheme.surfaceContainerHigh,
|
||
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
||
border: Border.all(color: theme.colorScheme.outlineVariant),
|
||
),
|
||
child: SelectableText(
|
||
_prettyPreview(approval.payloadPreview!),
|
||
style: FaiTheme.mono(
|
||
size: 11,
|
||
color: theme.colorScheme.onSurface,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
const SizedBox(height: FaiSpace.lg),
|
||
Row(
|
||
children: [
|
||
Text(
|
||
approval.id,
|
||
style: FaiTheme.mono(
|
||
size: 10,
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
),
|
||
const Spacer(),
|
||
OutlinedButton.icon(
|
||
onPressed: onReject,
|
||
icon: const Icon(Icons.close, size: 16),
|
||
label: Text(l.approvalsRejectButton),
|
||
style: OutlinedButton.styleFrom(
|
||
foregroundColor: theme.colorScheme.error,
|
||
side: BorderSide(
|
||
color: theme.colorScheme.error.withValues(alpha: 0.5),
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(width: FaiSpace.sm),
|
||
FilledButton.icon(
|
||
onPressed: onApprove,
|
||
icon: const Icon(Icons.check, size: 16),
|
||
label: Text(l.approvalsApproveButton),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
String _prettyPreview(String raw) {
|
||
if (raw.isEmpty) return raw;
|
||
try {
|
||
final dynamic parsed = const JsonDecoder().convert(raw);
|
||
return const JsonEncoder.withIndent(' ').convert(parsed);
|
||
} catch (_) {
|
||
return raw;
|
||
}
|
||
}
|
||
|
||
String _expiresInLabel(BuildContext context, DateTime t) {
|
||
final l = AppLocalizations.of(context)!;
|
||
final remaining = t.difference(DateTime.now());
|
||
if (remaining.isNegative) return l.approvalsPillExpired;
|
||
final minutes = remaining.inMinutes;
|
||
if (minutes < 60) return l.approvalsExpiresIn(minutes);
|
||
return l.approvalsExpiresInHours(remaining.inHours);
|
||
}
|
||
}
|
||
|
||
/// Compact one-line entry for a decided approval. Click expands
|
||
/// the prompt, decision metadata, and payload preview in a
|
||
/// dialog. Keeps the History tab scannable when there are many
|
||
/// decisions.
|
||
class _HistoryRow extends StatelessWidget {
|
||
final ApprovalRecord record;
|
||
const _HistoryRow({required this.record});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
final (tone, icon, label) = _statusPresentation(context, record.status);
|
||
final decided = record.decidedAt;
|
||
final timeStr =
|
||
decided == null ? '' : _formatTimestamp(decided.toLocal());
|
||
return InkWell(
|
||
onTap: () => showDialog<void>(
|
||
context: context,
|
||
builder: (_) => _HistoryDialog(record: record),
|
||
),
|
||
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
||
child: Padding(
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: FaiSpace.sm,
|
||
vertical: FaiSpace.sm,
|
||
),
|
||
child: Row(
|
||
children: [
|
||
FaiPill(label: label, tone: tone, icon: icon),
|
||
const SizedBox(width: FaiSpace.md),
|
||
SizedBox(
|
||
width: 150,
|
||
child: Text(
|
||
timeStr,
|
||
style: FaiTheme.mono(
|
||
size: 11,
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
),
|
||
Expanded(
|
||
child: Text(
|
||
'${record.flowName} › ${record.stepId}',
|
||
style: theme.textTheme.bodyMedium?.copyWith(
|
||
fontWeight: FontWeight.w500,
|
||
),
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
),
|
||
const SizedBox(width: FaiSpace.md),
|
||
if (record.decidedBy.isNotEmpty)
|
||
Text(
|
||
record.decidedBy,
|
||
style: theme.textTheme.bodySmall?.copyWith(
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
static (FaiPillTone, IconData, String) _statusPresentation(
|
||
BuildContext context,
|
||
String status,
|
||
) {
|
||
final l = AppLocalizations.of(context)!;
|
||
switch (status) {
|
||
case 'approved':
|
||
return (FaiPillTone.success, Icons.check, l.approvalsPillApproved);
|
||
case 'rejected':
|
||
return (FaiPillTone.danger, Icons.close, l.approvalsPillRejected);
|
||
case 'expired':
|
||
return (
|
||
FaiPillTone.neutral,
|
||
Icons.timer_off_outlined,
|
||
l.approvalsPillExpired,
|
||
);
|
||
default:
|
||
return (FaiPillTone.neutral, Icons.help_outline, status);
|
||
}
|
||
}
|
||
}
|
||
|
||
class _HistoryDialog extends StatelessWidget {
|
||
final ApprovalRecord record;
|
||
const _HistoryDialog({required this.record});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
final (tone, icon, label) =
|
||
_HistoryRow._statusPresentation(context, record.status);
|
||
return AlertDialog(
|
||
title: Row(
|
||
children: [
|
||
FaiPill(label: label, tone: tone, icon: icon),
|
||
const SizedBox(width: FaiSpace.sm),
|
||
Expanded(
|
||
child: Text(
|
||
'${record.flowName} › ${record.stepId}',
|
||
style: theme.textTheme.titleMedium,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
content: ConstrainedBox(
|
||
constraints: const BoxConstraints(maxWidth: 540, maxHeight: 480),
|
||
child: SingleChildScrollView(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
if (record.decidedAt != null)
|
||
_kv(theme, AppLocalizations.of(context)!.approvalsDialogDecided,
|
||
'${_formatTimestamp(record.decidedAt!.toLocal())} '
|
||
'· ${record.decidedBy.isEmpty ? "(unknown)" : record.decidedBy}'),
|
||
_kv(theme, AppLocalizations.of(context)!.approvalsDialogCreated,
|
||
_formatTimestamp(record.createdAt.toLocal())),
|
||
if (record.reason.isNotEmpty)
|
||
_kv(theme, AppLocalizations.of(context)!.approvalsDialogReason,
|
||
record.reason),
|
||
const SizedBox(height: FaiSpace.md),
|
||
Text(
|
||
AppLocalizations.of(context)!.approvalsDialogPrompt,
|
||
style: theme.textTheme.labelSmall?.copyWith(
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
letterSpacing: 0.6,
|
||
),
|
||
),
|
||
const SizedBox(height: 4),
|
||
SelectableText(
|
||
record.prompt,
|
||
style: theme.textTheme.bodyMedium,
|
||
),
|
||
if (record.payloadPreview != null) ...[
|
||
const SizedBox(height: FaiSpace.md),
|
||
Text(
|
||
AppLocalizations.of(context)!.approvalsPayloadPreview,
|
||
style: theme.textTheme.labelSmall?.copyWith(
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
letterSpacing: 0.6,
|
||
),
|
||
),
|
||
const SizedBox(height: 4),
|
||
Container(
|
||
width: double.infinity,
|
||
padding: const EdgeInsets.all(FaiSpace.sm),
|
||
decoration: BoxDecoration(
|
||
color: theme.colorScheme.surfaceContainerHigh,
|
||
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
||
border: Border.all(color: theme.colorScheme.outlineVariant),
|
||
),
|
||
child: SelectableText(
|
||
record.payloadPreview!,
|
||
style: FaiTheme.mono(size: 11),
|
||
),
|
||
),
|
||
],
|
||
const SizedBox(height: FaiSpace.md),
|
||
Text(
|
||
record.id,
|
||
style: FaiTheme.mono(
|
||
size: 10,
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () => Navigator.pop(context),
|
||
child: Text(AppLocalizations.of(context)!.buttonClose),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
Widget _kv(ThemeData theme, String k, String v) {
|
||
return Padding(
|
||
padding: const EdgeInsets.only(bottom: 4),
|
||
child: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
SizedBox(
|
||
width: 70,
|
||
child: Text(
|
||
k,
|
||
style: theme.textTheme.labelSmall?.copyWith(
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
letterSpacing: 0.6,
|
||
),
|
||
),
|
||
),
|
||
Expanded(
|
||
child: SelectableText(
|
||
v,
|
||
style: theme.textTheme.bodySmall,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Locale-unambiguous YYYY-MM-DD HH:mm:ss formatter shared by
|
||
/// the history row + dialog. Always renders in the operator's
|
||
/// local time zone — ISO timestamps from the wire still appear
|
||
/// raw in the per-record dialog for cross-checking.
|
||
String _formatTimestamp(DateTime local) {
|
||
final yyyy = local.year.toString().padLeft(4, '0');
|
||
final mo = local.month.toString().padLeft(2, '0');
|
||
final dd = local.day.toString().padLeft(2, '0');
|
||
final hh = local.hour.toString().padLeft(2, '0');
|
||
final mm = local.minute.toString().padLeft(2, '0');
|
||
final ss = local.second.toString().padLeft(2, '0');
|
||
return '$yyyy-$mo-$dd $hh:$mm:$ss';
|
||
}
|