diff --git a/admin/src/app/algorithm/page.tsx b/admin/src/app/algorithm/page.tsx index 10f5f77..bd2cbd7 100644 --- a/admin/src/app/algorithm/page.tsx +++ b/admin/src/app/algorithm/page.tsx @@ -3,13 +3,16 @@ import AdminShell from '@/components/AdminShell'; import { api } from '@/lib/api'; import { useEffect, useState } from 'react'; -import { Sliders, Save, RefreshCw } from 'lucide-react'; +import { Sliders, Save, RefreshCw, BarChart2 } from 'lucide-react'; export default function AlgorithmPage() { const [configs, setConfigs] = useState([]); const [loading, setLoading] = useState(true); const [editValues, setEditValues] = useState>({}); const [saving, setSaving] = useState(null); + const [scores, setScores] = useState([]); + const [scoresLoading, setScoresLoading] = useState(false); + const [showScores, setShowScores] = useState(false); const fetchConfig = () => { setLoading(true); @@ -35,6 +38,15 @@ export default function AlgorithmPage() { setSaving(null); }; + const loadScores = () => { + setScoresLoading(true); + setShowScores(true); + api.getFeedScores() + .then((data) => setScores(data.scores ?? [])) + .catch(() => {}) + .finally(() => setScoresLoading(false)); + }; + const groupedConfigs = { feed: configs.filter((c) => c.key.startsWith('feed_')), moderation: configs.filter((c) => c.key.startsWith('moderation_')), @@ -168,6 +180,68 @@ export default function AlgorithmPage() { )} )} + + {/* Feed Scores Viewer */} +
+
+
+ +

Live Feed Scores

+
+ +
+ {showScores && ( +
+ {scoresLoading ? ( +
Loading scores…
+ ) : scores.length === 0 ? ( +
No scored posts yet
+ ) : ( +
+ + + + + + + + + + + + + + + {scores.map((s) => ( + + + + + + + + + + + ))} + +
PostTotalEngageQualityRecencyNetworkPersonalUpdated
+

{s.excerpt || '—'}

+

{s.post_id.slice(0, 8)}…

+
+ {Number(s.total_score).toFixed(2)} + {Number(s.engagement_score).toFixed(2)}{Number(s.quality_score).toFixed(2)}{Number(s.recency_score).toFixed(2)}{Number(s.network_score).toFixed(2)}{Number(s.personalization).toFixed(2)}{new Date(s.updated_at).toLocaleString()}
+
+ )} +
+ )} +
); } diff --git a/admin/src/app/groups/page.tsx b/admin/src/app/groups/page.tsx new file mode 100644 index 0000000..9cd9610 --- /dev/null +++ b/admin/src/app/groups/page.tsx @@ -0,0 +1,201 @@ +'use client'; + +import AdminShell from '@/components/AdminShell'; +import { api } from '@/lib/api'; +import { formatDate } from '@/lib/utils'; +import { useEffect, useState } from 'react'; +import { Search, Trash2, Users, RotateCcw } from 'lucide-react'; + +export default function GroupsPage() { + const [groups, setGroups] = useState([]); + const [loading, setLoading] = useState(true); + const [search, setSearch] = useState(''); + const [offset, setOffset] = useState(0); + const [selectedGroup, setSelectedGroup] = useState(null); + const [members, setMembers] = useState([]); + const [membersLoading, setMembersLoading] = useState(false); + const limit = 50; + + const fetchGroups = () => { + setLoading(true); + api.listGroups({ search: search || undefined, limit, offset }) + .then((data) => setGroups(data.groups ?? [])) + .catch(() => {}) + .finally(() => setLoading(false)); + }; + + useEffect(() => { fetchGroups(); }, [offset]); + + const handleSearch = (e: React.FormEvent) => { + e.preventDefault(); + setOffset(0); + fetchGroups(); + }; + + const openGroup = async (group: any) => { + setSelectedGroup(group); + setMembersLoading(true); + try { + const data = await api.listGroupMembers(group.id); + setMembers(data.members ?? []); + } catch { + setMembers([]); + } finally { + setMembersLoading(false); + } + }; + + const deactivateGroup = async (id: string) => { + if (!confirm('Deactivate this group?')) return; + try { + await api.deleteGroup(id); + setGroups((prev) => prev.filter((g) => g.id !== id)); + if (selectedGroup?.id === id) setSelectedGroup(null); + } catch (e: any) { + alert(e.message); + } + }; + + const removeMember = async (groupId: string, userId: string) => { + if (!confirm('Remove this member? Key rotation will be triggered.')) return; + try { + await api.removeGroupMember(groupId, userId); + setMembers((prev) => prev.filter((m) => m.user_id !== userId)); + } catch (e: any) { + alert(e.message); + } + }; + + return ( + +
+
+

Groups & Capsules

+

Manage community groups and E2EE capsules

+
+
+ +
+
+ + setSearch(e.target.value)} + /> +
+ +
+ +
+ {/* Groups list */} +
+ {loading ? ( +
Loading…
+ ) : groups.length === 0 ? ( +
No groups found
+ ) : ( + + + + + + + + + + + + + {groups.map((g) => ( + openGroup(g)} + > + + + + + + + + + ))} + +
NameTypeMembersKey vRotationCreated +
{g.name} + + {g.is_private ? 'Capsule' : 'Public'} + + {g.member_count}v{g.key_version} + {g.key_rotation_needed && ( + Pending + )} + {formatDate(g.created_at)} + +
+ )} +
+ + +
+
+ + {/* Member panel */} + {selectedGroup && ( +
+
+ + {selectedGroup.name} +
+ {membersLoading ? ( +
Loading members…
+ ) : members.length === 0 ? ( +
No members
+ ) : ( +
    + {members.map((m) => ( +
  • +
    +

    {m.username || m.display_name}

    +

    {m.role}

    +
    + {m.role !== 'owner' && ( + + )} +
  • + ))} +
+ )} + {selectedGroup.key_rotation_needed && ( +
+
+ + Key rotation pending — will auto-complete next time an admin opens this capsule. +
+
+ )} +
+ )} +
+
+ ); +} diff --git a/admin/src/app/quips/page.tsx b/admin/src/app/quips/page.tsx new file mode 100644 index 0000000..4f30d48 --- /dev/null +++ b/admin/src/app/quips/page.tsx @@ -0,0 +1,127 @@ +'use client'; + +import AdminShell from '@/components/AdminShell'; +import { api } from '@/lib/api'; +import { formatDate } from '@/lib/utils'; +import { useEffect, useState } from 'react'; +import { RefreshCw, Wrench, Play, CheckCircle } from 'lucide-react'; + +export default function QuipsPage() { + const [quips, setQuips] = useState([]); + const [loading, setLoading] = useState(true); + const [repairing, setRepairing] = useState>(new Set()); + const [repaired, setRepaired] = useState>(new Set()); + + const fetchQuips = () => { + setLoading(true); + api.getBrokenQuips() + .then((data) => setQuips(data.quips ?? [])) + .catch(() => {}) + .finally(() => setLoading(false)); + }; + + useEffect(() => { fetchQuips(); }, []); + + const repairQuip = async (quip: any) => { + setRepairing((prev) => new Set(prev).add(quip.id)); + try { + await api.repairQuip(quip.id); + setRepaired((prev) => new Set(prev).add(quip.id)); + setQuips((prev) => prev.filter((q) => q.id !== quip.id)); + } catch (e: any) { + alert(`Repair failed: ${e.message}`); + } finally { + setRepairing((prev) => { const s = new Set(prev); s.delete(quip.id); return s; }); + } + }; + + const repairAll = async () => { + const list = [...quips]; + for (const q of list) { + await repairQuip(q); + } + }; + + return ( + +
+
+

Quip Repair

+

+ Videos missing thumbnails — server extracts frames via FFmpeg and uploads to R2. +

+
+
+ + {quips.length > 0 && ( + + )} +
+
+ + {repaired.size > 0 && ( +
+ {repaired.size} quip{repaired.size !== 1 ? 's' : ''} repaired this session. +
+ )} + +
+ {loading ? ( +
Loading…
+ ) : quips.length === 0 ? ( +
+ {repaired.size > 0 ? '✓ All quips repaired!' : 'No broken quips found.'} +
+ ) : ( + + + + + + + + + + + {quips.map((q) => ( + + + + + + + ))} + +
Post IDVideo URLCreatedAction
{q.id.slice(0, 8)}… + + {q.video_url} + + {formatDate(q.created_at)} + +
+ )} +
+
+ ); +} diff --git a/admin/src/components/Sidebar.tsx b/admin/src/components/Sidebar.tsx index b5c2dd1..af2c20e 100644 --- a/admin/src/components/Sidebar.tsx +++ b/admin/src/components/Sidebar.tsx @@ -8,7 +8,7 @@ import { LayoutDashboard, Users, FileText, Shield, ShieldCheck, Scale, Flag, Settings, Activity, LogOut, ChevronLeft, ChevronRight, ChevronDown, Sliders, FolderTree, HardDrive, AtSign, Brain, ScrollText, Wrench, Bot, - UserCog, ShieldAlert, Cog, Mail, MapPinned, + UserCog, ShieldAlert, Cog, Mail, MapPinned, Users2, Video, } from 'lucide-react'; import { useState } from 'react'; @@ -31,6 +31,7 @@ const navigation: NavEntry[] = [ { href: '/categories', label: 'Categories', icon: FolderTree }, { href: '/neighborhoods', label: 'Neighborhoods', icon: MapPinned }, { href: '/official-accounts', label: 'Official Accounts', icon: Bot }, + { href: '/groups', label: 'Groups & Capsules', icon: Users2 }, ], }, { @@ -55,6 +56,7 @@ const navigation: NavEntry[] = [ { href: '/usernames', label: 'Usernames', icon: AtSign }, { href: '/storage', label: 'Storage', icon: HardDrive }, { href: '/system', label: 'System Health', icon: Activity }, + { href: '/quips', label: 'Quip Repair', icon: Video }, { href: '/settings/emails', label: 'Email Templates', icon: Mail }, { href: '/settings', label: 'Settings', icon: Settings }, ], diff --git a/admin/src/lib/api.ts b/admin/src/lib/api.ts index f6d3a31..27132be 100644 --- a/admin/src/lib/api.ts +++ b/admin/src/lib/api.ts @@ -633,6 +633,52 @@ class ApiClient { body: JSON.stringify({ template_id: templateId, to_email: toEmail }), }); } + + // Groups admin + async listGroups(params: { search?: string; limit?: number; offset?: number } = {}) { + const qs = new URLSearchParams(); + if (params.search) qs.set('search', params.search); + if (params.limit) qs.set('limit', String(params.limit)); + if (params.offset) qs.set('offset', String(params.offset)); + return this.request(`/api/v1/admin/groups?${qs}`); + } + + async getGroup(id: string) { + return this.request(`/api/v1/admin/groups/${id}`); + } + + async deleteGroup(id: string) { + return this.request(`/api/v1/admin/groups/${id}`, { method: 'DELETE' }); + } + + async listGroupMembers(groupId: string) { + return this.request(`/api/v1/admin/groups/${groupId}/members`); + } + + async removeGroupMember(groupId: string, userId: string) { + return this.request(`/api/v1/admin/groups/${groupId}/members/${userId}`, { method: 'DELETE' }); + } + + // Quip repair + async getBrokenQuips(limit = 50) { + return this.request(`/api/v1/admin/quips/broken?limit=${limit}`); + } + + async repairQuip(postId: string) { + return this.request(`/api/v1/admin/quips/${postId}/repair`, { method: 'POST' }); + } + + async setPostThumbnail(postId: string, thumbnailUrl: string) { + return this.request(`/api/v1/admin/posts/${postId}/thumbnail`, { + method: 'PATCH', + body: JSON.stringify({ thumbnail_url: thumbnailUrl }), + }); + } + + // Feed scores + async getFeedScores(limit = 50) { + return this.request(`/api/v1/admin/feed-scores?limit=${limit}`); + } } export const api = new ApiClient(); diff --git a/sojorn_app/lib/screens/admin/quip_repair_screen.dart b/sojorn_app/lib/screens/admin/quip_repair_screen.dart index 97ac1a3..9ebaf78 100644 --- a/sojorn_app/lib/screens/admin/quip_repair_screen.dart +++ b/sojorn_app/lib/screens/admin/quip_repair_screen.dart @@ -1,9 +1,5 @@ -import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../../services/media/ffmpeg.dart'; -import 'package:path_provider/path_provider.dart'; -import '../../services/image_upload_service.dart'; import '../../providers/api_provider.dart'; class QuipRepairScreen extends ConsumerStatefulWidget { @@ -14,8 +10,6 @@ class QuipRepairScreen extends ConsumerStatefulWidget { } class _QuipRepairScreenState extends ConsumerState { - final ImageUploadService _uploadService = ImageUploadService(); - List> _brokenQuips = []; bool _isLoading = false; bool _isRepairing = false; @@ -28,126 +22,69 @@ class _QuipRepairScreenState extends ConsumerState { } Future _fetchBrokenQuips() async { - setState(() => _isLoading = true); + setState(() { _isLoading = true; _statusMessage = null; }); try { - if (mounted) { - setState(() { - _brokenQuips = []; - _statusMessage = - 'Quip repair is unavailable (Go API migration pending).'; - }); - } + final api = ref.read(apiServiceProvider); + final data = await api.callGoApi('/admin/quips/broken', method: 'GET'); + final quips = (data['quips'] as List?)?.cast>() ?? []; + if (mounted) setState(() => _brokenQuips = quips); } catch (e) { - if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error: $e'))); + if (mounted) { + setState(() => _statusMessage = 'Error loading broken quips: $e'); + } } finally { if (mounted) setState(() => _isLoading = false); } } Future _repairQuip(Map quip) async { - setState(() { - _isRepairing = false; - _statusMessage = - 'Quip repair is unavailable (Go API migration pending).'; - }); - return; - + setState(() => _isRepairing = true); try { - final videoUrl = quip['video_url'] as String; - if (videoUrl.isEmpty) throw "No Video URL"; - - // Get signed URL for the video if needed (assuming public/signed handling elsewhere) - // FFmpeg typically handles public URLs. If private R2, we need a signed URL. final api = ref.read(apiServiceProvider); - final signedVideoUrl = await api.getSignedMediaUrl(videoUrl); - if (signedVideoUrl == null) throw "Could not sign video URL"; - - // Generate thumbnail - final tempDir = await getTemporaryDirectory(); - final thumbPath = '${tempDir.path}/repair_thumb_${quip['id']}.jpg'; - - // Use executeWithArguments to handle URLs with special characters safely. - // Added reconnect flags for better handling of network streams. - final session = await FFmpegKit.executeWithArguments([ - '-y', - '-user_agent', 'SojornApp/1.0', - '-reconnect', '1', - '-reconnect_at_eof', '1', - '-reconnect_streamed', '1', - '-reconnect_delay_max', '4294', - '-i', signedVideoUrl, - '-ss', '00:00:01', - '-vframes', '1', - '-q:v', '5', - thumbPath - ]); - - final returnCode = await session.getReturnCode(); - if (!ReturnCode.isSuccess(returnCode)) { - final logs = await session.getAllLogsAsString(); - // Print in chunks if it's too long for some logcats - - // Extract the last error message from logs if possible - String errorDetail = "FFmpeg failed (Code: $returnCode)"; - if (logs != null && logs.contains('Error')) { - errorDetail = logs.substring(logs.lastIndexOf('Error')).split('\n').first; - } - - throw errorDetail; - } - - final thumbFile = File(thumbPath); - if (!await thumbFile.exists()) throw "Thumbnail file creation failed"; - - // Upload - final thumbUrl = await _uploadService.uploadImage(thumbFile); - - // Update Post (TODO: migrate to Go API) - + await api.callGoApi('/admin/quips/${quip['id']}/repair', method: 'POST'); if (mounted) { setState(() { _brokenQuips.removeWhere((q) => q['id'] == quip['id']); - _statusMessage = "Fixed ${quip['id']}"; + _statusMessage = 'Fixed: ${quip['id']}'; }); } - } catch (e) { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Repair Failed: $e'))); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Repair failed: $e')), + ); } } finally { - if (mounted) { - setState(() { - _isRepairing = false; - }); - } + if (mounted) setState(() => _isRepairing = false); } } Future _repairAll() async { - // Clone list to avoid modification issues final list = List>.from(_brokenQuips); for (final quip in list) { if (!mounted) break; await _repairQuip(quip); } - if (mounted) { - setState(() => _statusMessage = "Repair All Complete"); - } + if (mounted) setState(() => _statusMessage = 'Repair all complete'); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - title: const Text("Repair Thumbnails"), + title: const Text('Repair Thumbnails'), actions: [ + IconButton( + icon: const Icon(Icons.refresh), + onPressed: _isLoading ? null : _fetchBrokenQuips, + tooltip: 'Reload', + ), if (_brokenQuips.isNotEmpty && !_isRepairing) IconButton( icon: const Icon(Icons.build), onPressed: _repairAll, - tooltip: "Repair All", - ) + tooltip: 'Repair All', + ), ], ), body: Column( @@ -160,26 +97,31 @@ class _QuipRepairScreenState extends ConsumerState { child: Text(_statusMessage!, textAlign: TextAlign.center), ), Expanded( - child: _isLoading - ? const Center(child: CircularProgressIndicator()) - : _brokenQuips.isEmpty - ? const Center(child: Text("No missing thumbnails found.")) - : ListView.builder( - itemCount: _brokenQuips.length, - itemBuilder: (context, index) { - final item = _brokenQuips[index]; - return ListTile( - title: Text(item['body'] ?? "No Caption"), - subtitle: Text(item['created_at'].toString()), - trailing: _isRepairing - ? null - : IconButton( - icon: const Icon(Icons.refresh), - onPressed: () => _repairQuip(item), - ), - ); - }, - ), + child: _isLoading + ? const Center(child: CircularProgressIndicator()) + : _brokenQuips.isEmpty + ? const Center(child: Text('No missing thumbnails found.')) + : ListView.builder( + itemCount: _brokenQuips.length, + itemBuilder: (context, index) { + final item = _brokenQuips[index]; + return ListTile( + leading: const Icon(Icons.videocam_off), + title: Text(item['id'] as String? ?? ''), + subtitle: Text(item['created_at']?.toString() ?? ''), + trailing: _isRepairing + ? const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : IconButton( + icon: const Icon(Icons.auto_fix_high), + onPressed: () => _repairQuip(item), + ), + ); + }, + ), ), ], ), diff --git a/sojorn_app/lib/screens/clusters/clusters_screen.dart b/sojorn_app/lib/screens/clusters/clusters_screen.dart index a674995..881d578 100644 --- a/sojorn_app/lib/screens/clusters/clusters_screen.dart +++ b/sojorn_app/lib/screens/clusters/clusters_screen.dart @@ -160,8 +160,21 @@ class _ClustersScreenState extends ConsumerState } void _navigateToGroup(group_models.Group group) { - // TODO: Navigate to group detail screen - if (kDebugMode) print('Navigate to group: ${group.name}'); + final cluster = Cluster( + id: group.id, + name: group.name, + description: group.description, + type: group.isPrivate ? 'private_capsule' : 'geo', + privacy: group.isPrivate ? 'private' : 'public', + avatarUrl: group.avatarUrl, + memberCount: group.memberCount, + isEncrypted: false, + category: group.category, + createdAt: group.createdAt, + ); + Navigator.of(context).push( + MaterialPageRoute(builder: (_) => GroupScreen(group: cluster)), + ); } @override diff --git a/sojorn_app/lib/screens/clusters/private_capsule_screen.dart b/sojorn_app/lib/screens/clusters/private_capsule_screen.dart index bef9cba..a7a87e1 100644 --- a/sojorn_app/lib/screens/clusters/private_capsule_screen.dart +++ b/sojorn_app/lib/screens/clusters/private_capsule_screen.dart @@ -58,11 +58,71 @@ class _PrivateCapsuleScreenState extends ConsumerState await CapsuleSecurityService.cacheCapsuleKey(widget.capsule.id, key); } if (mounted) setState(() { _capsuleKey = key; _isUnlocking = false; }); + + // Silent self-healing: rotate keys if the server flagged it + if (key != null) _checkAndRotateKeysIfNeeded(); } catch (e) { if (mounted) setState(() { _unlockError = 'Failed to unlock capsule'; _isUnlocking = false; }); } } + /// Silently check if key rotation is needed and perform it automatically. + Future _checkAndRotateKeysIfNeeded() async { + try { + final api = ref.read(apiServiceProvider); + final status = await api.callGoApi('/groups/${widget.capsule.id}/key-status', method: 'GET'); + final rotationNeeded = status['key_rotation_needed'] as bool? ?? false; + if (!rotationNeeded || !mounted) return; + // Perform rotation silently — user sees nothing + await _performKeyRotation(api, silent: true); + } catch (_) { + // Non-fatal: rotation will be retried on next open + } + } + + /// Full key rotation: fetch member public keys, generate new AES key, + /// encrypt for each member, push to server. + Future _performKeyRotation(ApiService api, {bool silent = false}) async { + // Fetch member public keys + final keysData = await api.callGoApi( + '/groups/${widget.capsule.id}/members/public-keys', + method: 'GET', + ); + final memberKeys = (keysData['keys'] as List?)?.cast>() ?? []; + if (memberKeys.isEmpty) return; + + final pubKeys = memberKeys.map((m) => m['public_key'] as String).toList(); + final userIds = memberKeys.map((m) => m['user_id'] as String).toList(); + + final result = await CapsuleSecurityService.rotateKeys( + memberPublicKeysB64: pubKeys, + memberUserIds: userIds, + ); + + // Determine next key version + final status = await api.callGoApi('/groups/${widget.capsule.id}/key-status', method: 'GET'); + final currentVersion = status['key_version'] as int? ?? 1; + final nextVersion = currentVersion + 1; + + final payload = result.memberKeys.entries.map((e) => { + 'user_id': e.key, + 'encrypted_key': e.value, + 'key_version': nextVersion, + }).toList(); + + await api.callGoApi('/groups/${widget.capsule.id}/keys', method: 'POST', body: {'keys': payload}); + + // Update local cache with new key + await CapsuleSecurityService.cacheCapsuleKey(widget.capsule.id, result.newCapsuleKey); + if (mounted) setState(() => _capsuleKey = result.newCapsuleKey); + + if (!silent && mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Keys rotated successfully')), + ); + } + } + @override Widget build(BuildContext context) { return Scaffold( @@ -230,7 +290,11 @@ class _PrivateCapsuleScreenState extends ConsumerState context: context, backgroundColor: AppTheme.cardSurface, isScrollControlled: true, - builder: (ctx) => _CapsuleAdminPanel(capsule: widget.capsule), + builder: (ctx) => _CapsuleAdminPanel( + capsule: widget.capsule, + capsuleKey: _capsuleKey, + onRotateKeys: () => _performKeyRotation(ref.read(apiServiceProvider)), + ), ); } } @@ -1009,9 +1073,141 @@ class _NewVaultNoteSheetState extends State<_NewVaultNoteSheet> { } // ── Admin Panel ─────────────────────────────────────────────────────────── -class _CapsuleAdminPanel extends StatelessWidget { +class _CapsuleAdminPanel extends ConsumerStatefulWidget { final Cluster capsule; - const _CapsuleAdminPanel({required this.capsule}); + final SecretKey? capsuleKey; + final Future Function() onRotateKeys; + + const _CapsuleAdminPanel({ + required this.capsule, + required this.capsuleKey, + required this.onRotateKeys, + }); + + @override + ConsumerState<_CapsuleAdminPanel> createState() => _CapsuleAdminPanelState(); +} + +class _CapsuleAdminPanelState extends ConsumerState<_CapsuleAdminPanel> { + bool _busy = false; + + Future _rotateKeys() async { + setState(() => _busy = true); + try { + await widget.onRotateKeys(); + if (mounted) Navigator.pop(context); + } catch (e) { + if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Rotation failed: $e'))); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + Future _inviteMember() async { + final handle = await showDialog( + context: context, + builder: (ctx) => _TextInputDialog( + title: 'Invite Member', + label: 'Username or @handle', + action: 'Invite', + ), + ); + if (handle == null || handle.isEmpty) return; + + setState(() => _busy = true); + try { + final api = ref.read(apiServiceProvider); + + // Look up user by handle + final userData = await api.callGoApi( + '/users/by-handle/${handle.replaceFirst('@', '')}', + method: 'GET', + ); + final userId = userData['id'] as String?; + final recipientPubKey = userData['public_key'] as String?; + + if (userId == null) throw 'User not found'; + if (recipientPubKey == null || recipientPubKey.isEmpty) throw 'User has no public key registered'; + if (widget.capsuleKey == null) throw 'Capsule not unlocked'; + + // Encrypt the current group key for the new member + final encryptedKey = await CapsuleSecurityService.encryptCapsuleKeyForUser( + capsuleKey: widget.capsuleKey!, + recipientPublicKeyB64: recipientPubKey, + ); + + await api.callGoApi('/groups/${widget.capsule.id}/invite-member', method: 'POST', body: { + 'user_id': userId, + 'encrypted_key': encryptedKey, + }); + + if (mounted) { + Navigator.pop(context); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('${handle.replaceFirst('@', '')} invited')), + ); + } + } catch (e) { + if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Invite failed: $e'))); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + Future _removeMember() async { + final api = ref.read(apiServiceProvider); + + // Load member list + final data = await api.callGoApi('/groups/${widget.capsule.id}/members', method: 'GET'); + final members = (data['members'] as List?)?.cast>() ?? []; + if (!mounted) return; + + final selected = await showDialog>( + context: context, + builder: (ctx) => _MemberPickerDialog(members: members), + ); + if (selected == null || !mounted) return; + + final confirm = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Remove Member'), + content: Text('Remove ${selected['username']}? This will trigger key rotation.'), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('Cancel')), + TextButton( + onPressed: () => Navigator.pop(ctx, true), + style: TextButton.styleFrom(foregroundColor: SojornColors.destructive), + child: const Text('Remove'), + ), + ], + ), + ); + if (confirm != true) return; + + setState(() => _busy = true); + try { + await api.callGoApi( + '/groups/${widget.capsule.id}/members/${selected['user_id']}', + method: 'DELETE', + ); + // Rotate keys after removal — server already flagged it; do it now + await widget.onRotateKeys(); + if (mounted) Navigator.pop(context); + } catch (e) { + if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Remove failed: $e'))); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + Future _openSettings() async { + Navigator.pop(context); + await showDialog( + context: context, + builder: (ctx) => _CapsuleSettingsDialog(capsule: widget.capsule), + ); + } @override Widget build(BuildContext context) { @@ -1021,7 +1217,6 @@ class _CapsuleAdminPanel extends StatelessWidget { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Handle Center( child: Container( width: 40, height: 4, @@ -1032,49 +1227,179 @@ class _CapsuleAdminPanel extends StatelessWidget { ), ), const SizedBox(height: 16), - Text( - 'Capsule Admin', - style: TextStyle(color: AppTheme.navyBlue, fontSize: 18, fontWeight: FontWeight.w700), - ), + Text('Capsule Admin', + style: TextStyle(color: AppTheme.navyBlue, fontSize: 18, fontWeight: FontWeight.w700)), const SizedBox(height: 20), - - _AdminAction( - icon: Icons.vpn_key, - label: 'Rotate Encryption Keys', - subtitle: 'Generate new keys and re-encrypt for all members', - color: const Color(0xFFFFA726), - onTap: () { Navigator.pop(context); /* TODO: key rotation flow */ }, - ), - const SizedBox(height: 8), - _AdminAction( - icon: Icons.person_add, - label: 'Invite Member', - subtitle: 'Encrypt the capsule key for a new member', - color: const Color(0xFF4CAF50), - onTap: () { Navigator.pop(context); /* TODO: invite flow */ }, - ), - const SizedBox(height: 8), - _AdminAction( - icon: Icons.person_remove, - label: 'Remove Member', - subtitle: 'Revoke access (triggers automatic key rotation)', - color: SojornColors.destructive, - onTap: () { Navigator.pop(context); /* TODO: remove + rotate */ }, - ), - const SizedBox(height: 8), - _AdminAction( - icon: Icons.settings, - label: 'Capsule Settings', - subtitle: 'Toggle chat, forum, and vault features', - color: SojornColors.basicBrightNavy, - onTap: () { Navigator.pop(context); /* TODO: settings */ }, - ), + if (_busy) + const Center(child: Padding( + padding: EdgeInsets.symmetric(vertical: 24), + child: CircularProgressIndicator(), + )) + else ...[ + _AdminAction( + icon: Icons.vpn_key, + label: 'Rotate Encryption Keys', + subtitle: 'Generate new keys and re-encrypt for all members', + color: const Color(0xFFFFA726), + onTap: _rotateKeys, + ), + const SizedBox(height: 8), + _AdminAction( + icon: Icons.person_add, + label: 'Invite Member', + subtitle: 'Encrypt the capsule key for a new member', + color: const Color(0xFF4CAF50), + onTap: _inviteMember, + ), + const SizedBox(height: 8), + _AdminAction( + icon: Icons.person_remove, + label: 'Remove Member', + subtitle: 'Revoke access and rotate keys automatically', + color: SojornColors.destructive, + onTap: _removeMember, + ), + const SizedBox(height: 8), + _AdminAction( + icon: Icons.settings, + label: 'Capsule Settings', + subtitle: 'Toggle chat, forum, and vault features', + color: SojornColors.basicBrightNavy, + onTap: _openSettings, + ), + ], ], ), ); } } +// ── Helper dialogs ───────────────────────────────────────────────────────── + +class _TextInputDialog extends StatefulWidget { + final String title; + final String label; + final String action; + const _TextInputDialog({required this.title, required this.label, required this.action}); + @override + State<_TextInputDialog> createState() => _TextInputDialogState(); +} + +class _TextInputDialogState extends State<_TextInputDialog> { + final _ctrl = TextEditingController(); + @override + void dispose() { _ctrl.dispose(); super.dispose(); } + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text(widget.title), + content: TextField( + controller: _ctrl, + decoration: InputDecoration(labelText: widget.label), + autofocus: true, + onSubmitted: (v) => Navigator.pop(context, v.trim()), + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')), + TextButton( + onPressed: () => Navigator.pop(context, _ctrl.text.trim()), + child: Text(widget.action), + ), + ], + ); + } +} + +class _MemberPickerDialog extends StatelessWidget { + final List> members; + const _MemberPickerDialog({required this.members}); + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Select Member to Remove'), + content: SizedBox( + width: double.maxFinite, + child: ListView.builder( + shrinkWrap: true, + itemCount: members.length, + itemBuilder: (ctx, i) { + final m = members[i]; + return ListTile( + title: Text(m['username'] as String? ?? m['user_id'] as String? ?? ''), + subtitle: Text(m['role'] as String? ?? ''), + onTap: () => Navigator.pop(context, m), + ); + }, + ), + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')), + ], + ); + } +} + +class _CapsuleSettingsDialog extends ConsumerStatefulWidget { + final Cluster capsule; + const _CapsuleSettingsDialog({required this.capsule}); + @override + ConsumerState<_CapsuleSettingsDialog> createState() => _CapsuleSettingsDialogState(); +} + +class _CapsuleSettingsDialogState extends ConsumerState<_CapsuleSettingsDialog> { + bool _chat = true; + bool _forum = true; + bool _vault = true; + bool _saving = false; + + @override + void initState() { + super.initState(); + _chat = widget.capsule.settings.chat; + _forum = widget.capsule.settings.forum; + _vault = widget.capsule.settings.files; + } + + Future _save() async { + setState(() => _saving = true); + try { + final api = ref.read(apiServiceProvider); + await api.callGoApi('/groups/${widget.capsule.id}/settings', method: 'PATCH', body: { + 'chat_enabled': _chat, + 'forum_enabled': _forum, + 'vault_enabled': _vault, + }); + if (mounted) Navigator.pop(context); + } catch (e) { + if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Save failed: $e'))); + } finally { + if (mounted) setState(() => _saving = false); + } + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Capsule Settings'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SwitchListTile(title: const Text('Chat'), value: _chat, onChanged: (v) => setState(() => _chat = v)), + SwitchListTile(title: const Text('Forum'), value: _forum, onChanged: (v) => setState(() => _forum = v)), + SwitchListTile(title: const Text('Vault'), value: _vault, onChanged: (v) => setState(() => _vault = v)), + ], + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')), + ElevatedButton( + onPressed: _saving ? null : _save, + child: _saving ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)) : const Text('Save'), + ), + ], + ); + } +} + class _AdminAction extends StatelessWidget { final IconData icon; final String label; diff --git a/sojorn_app/lib/screens/clusters/public_cluster_screen.dart b/sojorn_app/lib/screens/clusters/public_cluster_screen.dart index e58c121..e07d0ee 100644 --- a/sojorn_app/lib/screens/clusters/public_cluster_screen.dart +++ b/sojorn_app/lib/screens/clusters/public_cluster_screen.dart @@ -31,14 +31,11 @@ class _PublicClusterScreenState extends ConsumerState { Future _loadPosts() async { setState(() => _isLoading = true); try { - // TODO: Call group-specific feed endpoint when wired final api = ref.read(apiServiceProvider); - final beacons = await api.fetchNearbyBeacons( - lat: widget.cluster.lat ?? 0, - long: widget.cluster.lng ?? 0, - radius: widget.cluster.radiusMeters, - ); - if (mounted) setState(() { _posts = beacons; _isLoading = false; }); + final raw = await api.callGoApi('/groups/${widget.cluster.id}/feed', method: 'GET'); + final items = (raw['posts'] as List?)?.cast>() ?? []; + final posts = items.map((j) => Post.fromJson(j)).toList(); + if (mounted) setState(() { _posts = posts; _isLoading = false; }); } catch (_) { if (mounted) setState(() => _isLoading = false); } diff --git a/sojorn_app/lib/screens/home/feed_sojorn_screen.dart b/sojorn_app/lib/screens/home/feed_sojorn_screen.dart index 60f6eec..dbcd10b 100644 --- a/sojorn_app/lib/screens/home/feed_sojorn_screen.dart +++ b/sojorn_app/lib/screens/home/feed_sojorn_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:share_plus/share_plus.dart'; import '../../providers/api_provider.dart'; import '../../providers/feed_refresh_provider.dart'; import '../../models/post.dart'; @@ -165,7 +166,8 @@ class _FeedsojornScreenState extends ConsumerState { } void _sharePost(Post post) { - // TODO: Implement share functionality + final text = post.content.isNotEmpty ? post.content : 'Check this out on Sojorn'; + Share.share(text, subject: 'Shared from Sojorn'); } @override diff --git a/sojorn_app/lib/services/api_service.dart b/sojorn_app/lib/services/api_service.dart index 3423600..b207035 100644 --- a/sojorn_app/lib/services/api_service.dart +++ b/sojorn_app/lib/services/api_service.dart @@ -1226,15 +1226,13 @@ class ApiService { // ========================================================================= Future getSignedMediaUrl(String path) async { - // For web platform, return the original URL since signing isn't needed - // for public CDN domains - if (path.startsWith('http')) { + if (path.startsWith('http')) return path; + try { + final data = await callGoApi('/media/sign', method: 'GET', queryParams: {'path': path}); + return data['url'] as String? ?? path; + } catch (_) { return path; } - - // Migrate to Go API / Nginx Signed URLs - // TODO: Implement proper signed URL generation - return '${ApiConfig.baseUrl}/media/signed?path=$path'; } Future> toggleReaction(String postId, String emoji) async {