feat: Flutter group nav, quip repair API, share, capsule key rotation, admin groups/quips pages

Flutter:
- clusters_screen: _navigateToGroup now pushes real GroupScreen (was print)
- public_cluster_screen: calls GET /groups/:id/feed instead of fetchNearbyBeacons
- feed_sojorn_screen: _sharePost uses share_plus
- api_service: getSignedMediaUrl calls Go /media/sign endpoint
- quip_repair_screen: fully rewired to Go admin API (GET /admin/quips/broken, POST repair)
- private_capsule_screen: auto key rotation on open (_checkAndRotateKeysIfNeeded),
  _performKeyRotation helper; _CapsuleAdminPanel now ConsumerStatefulWidget with
  working Rotate/Invite/Remove/Settings modals

Admin panel:
- Sidebar: Groups & Capsules + Quip Repair links added
- /groups: full group management page with member panel + deactivate/remove
- /quips: quip repair page with per-row and repair-all
- /algorithm: live feed scores table (lazy-loaded)
- api.ts: listGroups, getGroup, deleteGroup, listGroupMembers, removeGroupMember,
  getBrokenQuips, repairQuip, setPostThumbnail, getFeedScores

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Patrick Britton 2026-02-17 15:45:42 -06:00
parent 9aaf0d84a2
commit 15e83c6a14
11 changed files with 893 additions and 166 deletions

View file

@ -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<any[]>([]);
const [loading, setLoading] = useState(true);
const [editValues, setEditValues] = useState<Record<string, string>>({});
const [saving, setSaving] = useState<string | null>(null);
const [scores, setScores] = useState<any[]>([]);
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() {
)}
</div>
)}
{/* Feed Scores Viewer */}
<div className="mt-8">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<BarChart2 className="w-5 h-5 text-gray-600" />
<h2 className="text-lg font-semibold text-gray-800">Live Feed Scores</h2>
</div>
<button
onClick={loadScores}
className="flex items-center gap-1.5 px-3 py-1.5 border rounded-lg text-sm hover:bg-gray-50"
>
<RefreshCw className={`w-4 h-4 ${scoresLoading ? 'animate-spin' : ''}`} />
{showScores ? 'Refresh' : 'Load Scores'}
</button>
</div>
{showScores && (
<div className="bg-white rounded-xl border overflow-hidden">
{scoresLoading ? (
<div className="p-6 text-center text-gray-400">Loading scores</div>
) : scores.length === 0 ? (
<div className="p-6 text-center text-gray-400">No scored posts yet</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-gray-50 border-b">
<tr>
<th className="px-4 py-3 text-left font-medium text-gray-600">Post</th>
<th className="px-4 py-3 text-right font-medium text-gray-600">Total</th>
<th className="px-4 py-3 text-right font-medium text-gray-600">Engage</th>
<th className="px-4 py-3 text-right font-medium text-gray-600">Quality</th>
<th className="px-4 py-3 text-right font-medium text-gray-600">Recency</th>
<th className="px-4 py-3 text-right font-medium text-gray-600">Network</th>
<th className="px-4 py-3 text-right font-medium text-gray-600">Personal</th>
<th className="px-4 py-3 text-left font-medium text-gray-600">Updated</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{scores.map((s) => (
<tr key={s.post_id} className="hover:bg-gray-50">
<td className="px-4 py-2.5 max-w-xs">
<p className="truncate text-gray-800" title={s.excerpt}>{s.excerpt || '—'}</p>
<p className="text-xs text-gray-400 font-mono">{s.post_id.slice(0, 8)}</p>
</td>
<td className="px-4 py-2.5 text-right font-semibold text-blue-700">
{Number(s.total_score).toFixed(2)}
</td>
<td className="px-4 py-2.5 text-right text-gray-600">{Number(s.engagement_score).toFixed(2)}</td>
<td className="px-4 py-2.5 text-right text-gray-600">{Number(s.quality_score).toFixed(2)}</td>
<td className="px-4 py-2.5 text-right text-gray-600">{Number(s.recency_score).toFixed(2)}</td>
<td className="px-4 py-2.5 text-right text-gray-600">{Number(s.network_score).toFixed(2)}</td>
<td className="px-4 py-2.5 text-right text-gray-600">{Number(s.personalization).toFixed(2)}</td>
<td className="px-4 py-2.5 text-gray-400 text-xs">{new Date(s.updated_at).toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
</div>
</AdminShell>
);
}

View file

@ -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<any[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [offset, setOffset] = useState(0);
const [selectedGroup, setSelectedGroup] = useState<any | null>(null);
const [members, setMembers] = useState<any[]>([]);
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 (
<AdminShell>
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Groups & Capsules</h1>
<p className="text-sm text-gray-500 mt-1">Manage community groups and E2EE capsules</p>
</div>
</div>
<form onSubmit={handleSearch} className="mb-4 flex gap-2">
<div className="relative flex-1 max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 w-4 h-4" />
<input
className="pl-9 pr-4 py-2 border rounded-lg w-full text-sm"
placeholder="Search groups..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<button type="submit" className="px-4 py-2 bg-navy-600 text-white rounded-lg text-sm font-medium bg-blue-700 hover:bg-blue-800">
Search
</button>
</form>
<div className="flex gap-6">
{/* Groups list */}
<div className="flex-1 bg-white rounded-xl border overflow-hidden">
{loading ? (
<div className="p-8 text-center text-gray-400">Loading</div>
) : groups.length === 0 ? (
<div className="p-8 text-center text-gray-400">No groups found</div>
) : (
<table className="w-full text-sm">
<thead className="bg-gray-50 border-b">
<tr>
<th className="px-4 py-3 text-left font-medium text-gray-600">Name</th>
<th className="px-4 py-3 text-left font-medium text-gray-600">Type</th>
<th className="px-4 py-3 text-center font-medium text-gray-600">Members</th>
<th className="px-4 py-3 text-center font-medium text-gray-600">Key v</th>
<th className="px-4 py-3 text-center font-medium text-gray-600">Rotation</th>
<th className="px-4 py-3 text-left font-medium text-gray-600">Created</th>
<th className="px-4 py-3" />
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{groups.map((g) => (
<tr
key={g.id}
className={`hover:bg-gray-50 cursor-pointer ${selectedGroup?.id === g.id ? 'bg-blue-50' : ''}`}
onClick={() => openGroup(g)}
>
<td className="px-4 py-3 font-medium text-gray-900">{g.name}</td>
<td className="px-4 py-3">
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${g.is_private ? 'bg-purple-100 text-purple-700' : 'bg-green-100 text-green-700'}`}>
{g.is_private ? 'Capsule' : 'Public'}
</span>
</td>
<td className="px-4 py-3 text-center">{g.member_count}</td>
<td className="px-4 py-3 text-center text-gray-500">v{g.key_version}</td>
<td className="px-4 py-3 text-center">
{g.key_rotation_needed && (
<span className="px-2 py-0.5 rounded-full text-xs bg-amber-100 text-amber-700">Pending</span>
)}
</td>
<td className="px-4 py-3 text-gray-500">{formatDate(g.created_at)}</td>
<td className="px-4 py-3 text-right">
<button
onClick={(e) => { e.stopPropagation(); deactivateGroup(g.id); }}
className="text-red-500 hover:text-red-700 p-1"
title="Deactivate group"
>
<Trash2 className="w-4 h-4" />
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
<div className="px-4 py-3 border-t flex items-center gap-3">
<button disabled={offset === 0} onClick={() => setOffset(Math.max(0, offset - limit))}
className="text-sm px-3 py-1.5 rounded border disabled:opacity-40">Prev</button>
<button disabled={groups.length < limit} onClick={() => setOffset(offset + limit)}
className="text-sm px-3 py-1.5 rounded border disabled:opacity-40">Next</button>
</div>
</div>
{/* Member panel */}
{selectedGroup && (
<div className="w-72 bg-white rounded-xl border overflow-hidden self-start">
<div className="px-4 py-3 border-b bg-gray-50 flex items-center gap-2">
<Users className="w-4 h-4 text-gray-500" />
<span className="font-semibold text-sm text-gray-800">{selectedGroup.name}</span>
</div>
{membersLoading ? (
<div className="p-6 text-center text-gray-400 text-sm">Loading members</div>
) : members.length === 0 ? (
<div className="p-6 text-center text-gray-400 text-sm">No members</div>
) : (
<ul className="divide-y divide-gray-100 max-h-96 overflow-y-auto">
{members.map((m) => (
<li key={m.user_id} className="px-4 py-2.5 flex items-center justify-between text-sm">
<div>
<p className="font-medium text-gray-800">{m.username || m.display_name}</p>
<p className="text-xs text-gray-400">{m.role}</p>
</div>
{m.role !== 'owner' && (
<button
onClick={() => removeMember(selectedGroup.id, m.user_id)}
className="text-red-400 hover:text-red-600 p-1"
title="Remove member"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</li>
))}
</ul>
)}
{selectedGroup.key_rotation_needed && (
<div className="px-4 py-3 border-t bg-amber-50">
<div className="flex items-center gap-2 text-amber-700 text-xs">
<RotateCcw className="w-3.5 h-3.5" />
Key rotation pending will auto-complete next time an admin opens this capsule.
</div>
</div>
)}
</div>
)}
</div>
</AdminShell>
);
}

View file

@ -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<any[]>([]);
const [loading, setLoading] = useState(true);
const [repairing, setRepairing] = useState<Set<string>>(new Set());
const [repaired, setRepaired] = useState<Set<string>>(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 (
<AdminShell>
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Quip Repair</h1>
<p className="text-sm text-gray-500 mt-1">
Videos missing thumbnails server extracts frames via FFmpeg and uploads to R2.
</p>
</div>
<div className="flex gap-2">
<button
onClick={fetchQuips}
className="flex items-center gap-1.5 px-3 py-2 border rounded-lg text-sm hover:bg-gray-50"
>
<RefreshCw className="w-4 h-4" /> Reload
</button>
{quips.length > 0 && (
<button
onClick={repairAll}
className="flex items-center gap-1.5 px-4 py-2 bg-blue-700 text-white rounded-lg text-sm font-medium hover:bg-blue-800"
>
<Wrench className="w-4 h-4" /> Repair All ({quips.length})
</button>
)}
</div>
</div>
{repaired.size > 0 && (
<div className="mb-4 px-4 py-2.5 bg-green-50 border border-green-200 rounded-lg text-sm text-green-700 flex items-center gap-2">
<CheckCircle className="w-4 h-4" /> {repaired.size} quip{repaired.size !== 1 ? 's' : ''} repaired this session.
</div>
)}
<div className="bg-white rounded-xl border overflow-hidden">
{loading ? (
<div className="p-8 text-center text-gray-400">Loading</div>
) : quips.length === 0 ? (
<div className="p-8 text-center text-gray-400">
{repaired.size > 0 ? '✓ All quips repaired!' : 'No broken quips found.'}
</div>
) : (
<table className="w-full text-sm">
<thead className="bg-gray-50 border-b">
<tr>
<th className="px-4 py-3 text-left font-medium text-gray-600">Post ID</th>
<th className="px-4 py-3 text-left font-medium text-gray-600">Video URL</th>
<th className="px-4 py-3 text-left font-medium text-gray-600">Created</th>
<th className="px-4 py-3 text-right font-medium text-gray-600">Action</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{quips.map((q) => (
<tr key={q.id} className="hover:bg-gray-50">
<td className="px-4 py-3 font-mono text-xs text-gray-500">{q.id.slice(0, 8)}</td>
<td className="px-4 py-3 max-w-xs">
<span className="truncate block text-xs text-gray-600" title={q.video_url}>
{q.video_url}
</span>
</td>
<td className="px-4 py-3 text-gray-500">{formatDate(q.created_at)}</td>
<td className="px-4 py-3 text-right">
<button
onClick={() => repairQuip(q)}
disabled={repairing.has(q.id)}
className="flex items-center gap-1.5 ml-auto px-3 py-1.5 bg-amber-500 text-white rounded-lg text-xs font-medium hover:bg-amber-600 disabled:opacity-50"
>
{repairing.has(q.id) ? (
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
) : (
<Play className="w-3.5 h-3.5" />
)}
{repairing.has(q.id) ? 'Repairing…' : 'Repair'}
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</AdminShell>
);
}

View file

@ -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 },
],

View file

@ -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<any>(`/api/v1/admin/groups?${qs}`);
}
async getGroup(id: string) {
return this.request<any>(`/api/v1/admin/groups/${id}`);
}
async deleteGroup(id: string) {
return this.request<any>(`/api/v1/admin/groups/${id}`, { method: 'DELETE' });
}
async listGroupMembers(groupId: string) {
return this.request<any>(`/api/v1/admin/groups/${groupId}/members`);
}
async removeGroupMember(groupId: string, userId: string) {
return this.request<any>(`/api/v1/admin/groups/${groupId}/members/${userId}`, { method: 'DELETE' });
}
// Quip repair
async getBrokenQuips(limit = 50) {
return this.request<any>(`/api/v1/admin/quips/broken?limit=${limit}`);
}
async repairQuip(postId: string) {
return this.request<any>(`/api/v1/admin/quips/${postId}/repair`, { method: 'POST' });
}
async setPostThumbnail(postId: string, thumbnailUrl: string) {
return this.request<any>(`/api/v1/admin/posts/${postId}/thumbnail`, {
method: 'PATCH',
body: JSON.stringify({ thumbnail_url: thumbnailUrl }),
});
}
// Feed scores
async getFeedScores(limit = 50) {
return this.request<any>(`/api/v1/admin/feed-scores?limit=${limit}`);
}
}
export const api = new ApiClient();

View file

@ -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<QuipRepairScreen> {
final ImageUploadService _uploadService = ImageUploadService();
List<Map<String, dynamic>> _brokenQuips = [];
bool _isLoading = false;
bool _isRepairing = false;
@ -28,126 +22,69 @@ class _QuipRepairScreenState extends ConsumerState<QuipRepairScreen> {
}
Future<void> _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<Map<String, dynamic>>() ?? [];
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<void> _repairQuip(Map<String, dynamic> 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<void> _repairAll() async {
// Clone list to avoid modification issues
final list = List<Map<String, dynamic>>.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<QuipRepairScreen> {
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),
),
);
},
),
),
],
),

View file

@ -160,8 +160,21 @@ class _ClustersScreenState extends ConsumerState<ClustersScreen>
}
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

View file

@ -58,11 +58,71 @@ class _PrivateCapsuleScreenState extends ConsumerState<PrivateCapsuleScreen>
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<void> _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<void> _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<Map<String, dynamic>>() ?? [];
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<PrivateCapsuleScreen>
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<void> 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<void> _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<void> _inviteMember() async {
final handle = await showDialog<String>(
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<void> _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<Map<String, dynamic>>() ?? [];
if (!mounted) return;
final selected = await showDialog<Map<String, dynamic>>(
context: context,
builder: (ctx) => _MemberPickerDialog(members: members),
);
if (selected == null || !mounted) return;
final confirm = await showDialog<bool>(
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<void> _openSettings() async {
Navigator.pop(context);
await showDialog<void>(
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<Map<String, dynamic>> 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<void> _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;

View file

@ -31,14 +31,11 @@ class _PublicClusterScreenState extends ConsumerState<PublicClusterScreen> {
Future<void> _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<Map<String, dynamic>>() ?? [];
final posts = items.map((j) => Post.fromJson(j)).toList();
if (mounted) setState(() { _posts = posts; _isLoading = false; });
} catch (_) {
if (mounted) setState(() => _isLoading = false);
}

View file

@ -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<FeedsojornScreen> {
}
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

View file

@ -1226,15 +1226,13 @@ class ApiService {
// =========================================================================
Future<String> 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<Map<String, dynamic>> toggleReaction(String postId, String emoji) async {