import 'package:fl_lib/fl_lib.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:server_box/data/provider/server/single.dart'; import 'package:server_box/data/store/opencode_store.dart'; /// Opencode API 密钥管理页面 class OpencodeKeyManagerPage extends ConsumerStatefulWidget { final String serverId; const OpencodeKeyManagerPage({ super.key, required this.serverId, }); @override ConsumerState createState() => _OpencodeKeyManagerPageState(); } class _OpencodeKeyManagerPageState extends ConsumerState { final _keyController = TextEditingController(); final _descController = TextEditingController(); bool _isSaving = false; bool _obscureKey = true; @override void initState() { super.initState(); _loadExistingKey(); } @override void dispose() { _keyController.dispose(); _descController.dispose(); super.dispose(); } void _loadExistingKey() { final keyInfo = OpencodeKeyStore().getServerKeyInfo(widget.serverId); if (keyInfo != null) { _descController.text = keyInfo['description'] ?? ''; } } Future _saveKey() async { if (_keyController.text.trim().isEmpty) { context.showSnackBar('Please enter an API key'); return; } setState(() { _isSaving = true; }); try { final apiKey = _keyController.text.trim(); // 保存到本地存储(明文) await OpencodeKeyStore().saveServerKey( widget.serverId, apiKey: apiKey, description: _descController.text, ); // 通过 SSH 上传到服务器 final tunnel = ref.read(opencodeConnectionProvider.notifier).tunnel; if (tunnel != null && tunnel.isConnected) { await tunnel.execute('mkdir -p ~/.config/opencode'); final escapedKey = apiKey.replaceAll("'", "'\\''"); await tunnel.execute("echo '$escapedKey' \u003e ~/.config/opencode/api_key.txt"); await tunnel.execute('chmod 600 ~/.config/opencode/api_key.txt'); } if (mounted) { context.showSnackBar('API key saved successfully'); Navigator.pop(context); } } catch (e) { if (mounted) { context.showSnackBar('Failed to save key: $e'); } } finally { if (mounted) { setState(() { _isSaving = false; }); } } } Future _deleteKey() async { final confirmed = await context.showRoundDialog( title: 'Delete API Key', child: const Text('Are you sure you want to delete this API key?'), actions: [ TextButton( onPressed: () => Navigator.pop(context, false), child: const Text('Cancel'), ), TextButton( onPressed: () => Navigator.pop(context, true), style: TextButton.styleFrom(foregroundColor: Colors.red), child: const Text('Delete'), ), ], ); if (confirmed == true) { try { final tunnel = ref.read(opencodeConnectionProvider.notifier).tunnel; if (tunnel != null && tunnel.isConnected) { await tunnel.execute('rm -f ~/.config/opencode/api_key.txt'); } await OpencodeKeyStore().deleteServerKey(widget.serverId); if (mounted) { context.showSnackBar('API key deleted'); setState(() { _keyController.clear(); _descController.clear(); }); } } catch (e) { if (mounted) { context.showSnackBar('Failed to delete key: $e'); } } } } @override Widget build(BuildContext context) { final serverState = ref.watch(serverProvider(widget.serverId)); final existingKey = OpencodeKeyStore().getServerKeyInfo(widget.serverId); return Scaffold( appBar: CustomAppBar( title: Text('Opencode API Key - ${serverState.spi.name}'), actions: [ if (existingKey != null) IconButton( icon: const Icon(Icons.delete), onPressed: _deleteKey, ), ], ), body: SingleChildScrollView( padding: const EdgeInsets.all(24), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _buildSecurityCard(), const SizedBox(height: 24), Text( 'API Key Configuration', style: Theme.of(context).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.bold, ), ), const SizedBox(height: 16), TextFormField( controller: _keyController, obscureText: _obscureKey, decoration: InputDecoration( labelText: 'Opencode API Key', hintText: existingKey != null ? 'Enter new key to replace existing one' : 'opc_...', prefixIcon: const Icon(Icons.vpn_key), suffixIcon: IconButton( icon: Icon( _obscureKey ? Icons.visibility_off : Icons.visibility, ), onPressed: () { setState(() { _obscureKey = !_obscureKey; }); }, ), border: const OutlineInputBorder(), ), ), const SizedBox(height: 16), TextFormField( controller: _descController, decoration: const InputDecoration( labelText: 'Description (Optional)', hintText: 'e.g., Production API key', prefixIcon: Icon(Icons.description), border: OutlineInputBorder(), ), ), const SizedBox(height: 24), if (existingKey != null) _buildStorageInfo(existingKey), const SizedBox(height: 32), SizedBox( width: double.infinity, child: ElevatedButton.icon( onPressed: _isSaving ? null : _saveKey, icon: _isSaving ? const SizedBox( width: 20, height: 20, child: CircularProgressIndicator( strokeWidth: 2, valueColor: AlwaysStoppedAnimation(Colors.white), ), ) : const Icon(Icons.save), label: Text(_isSaving ? 'Saving...' : 'Save API Key'), style: ElevatedButton.styleFrom( padding: const EdgeInsets.symmetric(vertical: 16), ), ), ), ], ), ), ); } Widget _buildSecurityCard() { return Card( elevation: 0, color: Colors.orange.withOpacity(0.1), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), side: BorderSide(color: Colors.orange.withOpacity(0.3)), ), child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon(Icons.security, color: Colors.orange[700]), const SizedBox(width: 8), Text( 'Security Notice', style: TextStyle( fontWeight: FontWeight.bold, color: Colors.orange[700], ), ), ], ), const SizedBox(height: 12), const Text( '• API keys are stored in plaintext on the server\n' '• Keys are saved in ~/.config/opencode/api_key.txt with 600 permissions\n' '• Make sure your server is secure and only accessible to you\n' '• Consider using environment-specific keys', style: TextStyle(fontSize: 13), ), ], ), ), ); } Widget _buildStorageInfo(Map keyInfo) { return Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: Colors.grey[100], borderRadius: BorderRadius.circular(12), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Current Key Info', style: TextStyle( fontWeight: FontWeight.bold, color: Colors.grey[700], ), ), const SizedBox(height: 8), if (keyInfo['description'] != null) _buildInfoRow('Description', keyInfo['description']), _buildInfoRow( 'Last Updated', keyInfo['updated_at'] != null ? keyInfo['updated_at'].toString().substring(0, 16) : 'Unknown', ), ], ), ); } Widget _buildInfoRow(String label, String value) { return Padding( padding: const EdgeInsets.only(bottom: 4), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox( width: 100, child: Text( '$label:', style: TextStyle( color: Colors.grey[600], fontSize: 13, ), ), ), Expanded( child: Text( value, style: const TextStyle(fontSize: 13), ), ), ], ), ); } }