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:
parent
9aaf0d84a2
commit
15e83c6a14
|
|
@ -3,13 +3,16 @@
|
||||||
import AdminShell from '@/components/AdminShell';
|
import AdminShell from '@/components/AdminShell';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Sliders, Save, RefreshCw } from 'lucide-react';
|
import { Sliders, Save, RefreshCw, BarChart2 } from 'lucide-react';
|
||||||
|
|
||||||
export default function AlgorithmPage() {
|
export default function AlgorithmPage() {
|
||||||
const [configs, setConfigs] = useState<any[]>([]);
|
const [configs, setConfigs] = useState<any[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [editValues, setEditValues] = useState<Record<string, string>>({});
|
const [editValues, setEditValues] = useState<Record<string, string>>({});
|
||||||
const [saving, setSaving] = useState<string | null>(null);
|
const [saving, setSaving] = useState<string | null>(null);
|
||||||
|
const [scores, setScores] = useState<any[]>([]);
|
||||||
|
const [scoresLoading, setScoresLoading] = useState(false);
|
||||||
|
const [showScores, setShowScores] = useState(false);
|
||||||
|
|
||||||
const fetchConfig = () => {
|
const fetchConfig = () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
@ -35,6 +38,15 @@ export default function AlgorithmPage() {
|
||||||
setSaving(null);
|
setSaving(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadScores = () => {
|
||||||
|
setScoresLoading(true);
|
||||||
|
setShowScores(true);
|
||||||
|
api.getFeedScores()
|
||||||
|
.then((data) => setScores(data.scores ?? []))
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setScoresLoading(false));
|
||||||
|
};
|
||||||
|
|
||||||
const groupedConfigs = {
|
const groupedConfigs = {
|
||||||
feed: configs.filter((c) => c.key.startsWith('feed_')),
|
feed: configs.filter((c) => c.key.startsWith('feed_')),
|
||||||
moderation: configs.filter((c) => c.key.startsWith('moderation_')),
|
moderation: configs.filter((c) => c.key.startsWith('moderation_')),
|
||||||
|
|
@ -168,6 +180,68 @@ export default function AlgorithmPage() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</AdminShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
201
admin/src/app/groups/page.tsx
Normal file
201
admin/src/app/groups/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
127
admin/src/app/quips/page.tsx
Normal file
127
admin/src/app/quips/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -8,7 +8,7 @@ import {
|
||||||
LayoutDashboard, Users, FileText, Shield, ShieldCheck, Scale, Flag,
|
LayoutDashboard, Users, FileText, Shield, ShieldCheck, Scale, Flag,
|
||||||
Settings, Activity, LogOut, ChevronLeft, ChevronRight, ChevronDown,
|
Settings, Activity, LogOut, ChevronLeft, ChevronRight, ChevronDown,
|
||||||
Sliders, FolderTree, HardDrive, AtSign, Brain, ScrollText, Wrench, Bot,
|
Sliders, FolderTree, HardDrive, AtSign, Brain, ScrollText, Wrench, Bot,
|
||||||
UserCog, ShieldAlert, Cog, Mail, MapPinned,
|
UserCog, ShieldAlert, Cog, Mail, MapPinned, Users2, Video,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
|
@ -31,6 +31,7 @@ const navigation: NavEntry[] = [
|
||||||
{ href: '/categories', label: 'Categories', icon: FolderTree },
|
{ href: '/categories', label: 'Categories', icon: FolderTree },
|
||||||
{ href: '/neighborhoods', label: 'Neighborhoods', icon: MapPinned },
|
{ href: '/neighborhoods', label: 'Neighborhoods', icon: MapPinned },
|
||||||
{ href: '/official-accounts', label: 'Official Accounts', icon: Bot },
|
{ 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: '/usernames', label: 'Usernames', icon: AtSign },
|
||||||
{ href: '/storage', label: 'Storage', icon: HardDrive },
|
{ href: '/storage', label: 'Storage', icon: HardDrive },
|
||||||
{ href: '/system', label: 'System Health', icon: Activity },
|
{ href: '/system', label: 'System Health', icon: Activity },
|
||||||
|
{ href: '/quips', label: 'Quip Repair', icon: Video },
|
||||||
{ href: '/settings/emails', label: 'Email Templates', icon: Mail },
|
{ href: '/settings/emails', label: 'Email Templates', icon: Mail },
|
||||||
{ href: '/settings', label: 'Settings', icon: Settings },
|
{ href: '/settings', label: 'Settings', icon: Settings },
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -633,6 +633,52 @@ class ApiClient {
|
||||||
body: JSON.stringify({ template_id: templateId, to_email: toEmail }),
|
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();
|
export const api = new ApiClient();
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,5 @@
|
||||||
import 'dart:io';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.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';
|
import '../../providers/api_provider.dart';
|
||||||
|
|
||||||
class QuipRepairScreen extends ConsumerStatefulWidget {
|
class QuipRepairScreen extends ConsumerStatefulWidget {
|
||||||
|
|
@ -14,8 +10,6 @@ class QuipRepairScreen extends ConsumerStatefulWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _QuipRepairScreenState extends ConsumerState<QuipRepairScreen> {
|
class _QuipRepairScreenState extends ConsumerState<QuipRepairScreen> {
|
||||||
final ImageUploadService _uploadService = ImageUploadService();
|
|
||||||
|
|
||||||
List<Map<String, dynamic>> _brokenQuips = [];
|
List<Map<String, dynamic>> _brokenQuips = [];
|
||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
bool _isRepairing = false;
|
bool _isRepairing = false;
|
||||||
|
|
@ -28,126 +22,69 @@ class _QuipRepairScreenState extends ConsumerState<QuipRepairScreen> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _fetchBrokenQuips() async {
|
Future<void> _fetchBrokenQuips() async {
|
||||||
setState(() => _isLoading = true);
|
setState(() { _isLoading = true; _statusMessage = null; });
|
||||||
try {
|
try {
|
||||||
if (mounted) {
|
final api = ref.read(apiServiceProvider);
|
||||||
setState(() {
|
final data = await api.callGoApi('/admin/quips/broken', method: 'GET');
|
||||||
_brokenQuips = [];
|
final quips = (data['quips'] as List?)?.cast<Map<String, dynamic>>() ?? [];
|
||||||
_statusMessage =
|
if (mounted) setState(() => _brokenQuips = quips);
|
||||||
'Quip repair is unavailable (Go API migration pending).';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error: $e')));
|
if (mounted) {
|
||||||
|
setState(() => _statusMessage = 'Error loading broken quips: $e');
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) setState(() => _isLoading = false);
|
if (mounted) setState(() => _isLoading = false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _repairQuip(Map<String, dynamic> quip) async {
|
Future<void> _repairQuip(Map<String, dynamic> quip) async {
|
||||||
setState(() {
|
setState(() => _isRepairing = true);
|
||||||
_isRepairing = false;
|
|
||||||
_statusMessage =
|
|
||||||
'Quip repair is unavailable (Go API migration pending).';
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
|
|
||||||
try {
|
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 api = ref.read(apiServiceProvider);
|
||||||
final signedVideoUrl = await api.getSignedMediaUrl(videoUrl);
|
await api.callGoApi('/admin/quips/${quip['id']}/repair', method: 'POST');
|
||||||
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)
|
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_brokenQuips.removeWhere((q) => q['id'] == quip['id']);
|
_brokenQuips.removeWhere((q) => q['id'] == quip['id']);
|
||||||
_statusMessage = "Fixed ${quip['id']}";
|
_statusMessage = 'Fixed: ${quip['id']}';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Repair Failed: $e')));
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('Repair failed: $e')),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) {
|
if (mounted) setState(() => _isRepairing = false);
|
||||||
setState(() {
|
|
||||||
_isRepairing = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _repairAll() async {
|
Future<void> _repairAll() async {
|
||||||
// Clone list to avoid modification issues
|
|
||||||
final list = List<Map<String, dynamic>>.from(_brokenQuips);
|
final list = List<Map<String, dynamic>>.from(_brokenQuips);
|
||||||
for (final quip in list) {
|
for (final quip in list) {
|
||||||
if (!mounted) break;
|
if (!mounted) break;
|
||||||
await _repairQuip(quip);
|
await _repairQuip(quip);
|
||||||
}
|
}
|
||||||
if (mounted) {
|
if (mounted) setState(() => _statusMessage = 'Repair all complete');
|
||||||
setState(() => _statusMessage = "Repair All Complete");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text("Repair Thumbnails"),
|
title: const Text('Repair Thumbnails'),
|
||||||
actions: [
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.refresh),
|
||||||
|
onPressed: _isLoading ? null : _fetchBrokenQuips,
|
||||||
|
tooltip: 'Reload',
|
||||||
|
),
|
||||||
if (_brokenQuips.isNotEmpty && !_isRepairing)
|
if (_brokenQuips.isNotEmpty && !_isRepairing)
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.build),
|
icon: const Icon(Icons.build),
|
||||||
onPressed: _repairAll,
|
onPressed: _repairAll,
|
||||||
tooltip: "Repair All",
|
tooltip: 'Repair All',
|
||||||
)
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: Column(
|
body: Column(
|
||||||
|
|
@ -160,26 +97,31 @@ class _QuipRepairScreenState extends ConsumerState<QuipRepairScreen> {
|
||||||
child: Text(_statusMessage!, textAlign: TextAlign.center),
|
child: Text(_statusMessage!, textAlign: TextAlign.center),
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _isLoading
|
child: _isLoading
|
||||||
? const Center(child: CircularProgressIndicator())
|
? const Center(child: CircularProgressIndicator())
|
||||||
: _brokenQuips.isEmpty
|
: _brokenQuips.isEmpty
|
||||||
? const Center(child: Text("No missing thumbnails found."))
|
? const Center(child: Text('No missing thumbnails found.'))
|
||||||
: ListView.builder(
|
: ListView.builder(
|
||||||
itemCount: _brokenQuips.length,
|
itemCount: _brokenQuips.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final item = _brokenQuips[index];
|
final item = _brokenQuips[index];
|
||||||
return ListTile(
|
return ListTile(
|
||||||
title: Text(item['body'] ?? "No Caption"),
|
leading: const Icon(Icons.videocam_off),
|
||||||
subtitle: Text(item['created_at'].toString()),
|
title: Text(item['id'] as String? ?? ''),
|
||||||
trailing: _isRepairing
|
subtitle: Text(item['created_at']?.toString() ?? ''),
|
||||||
? null
|
trailing: _isRepairing
|
||||||
: IconButton(
|
? const SizedBox(
|
||||||
icon: const Icon(Icons.refresh),
|
width: 24,
|
||||||
onPressed: () => _repairQuip(item),
|
height: 24,
|
||||||
),
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
);
|
)
|
||||||
},
|
: IconButton(
|
||||||
),
|
icon: const Icon(Icons.auto_fix_high),
|
||||||
|
onPressed: () => _repairQuip(item),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -160,8 +160,21 @@ class _ClustersScreenState extends ConsumerState<ClustersScreen>
|
||||||
}
|
}
|
||||||
|
|
||||||
void _navigateToGroup(group_models.Group group) {
|
void _navigateToGroup(group_models.Group group) {
|
||||||
// TODO: Navigate to group detail screen
|
final cluster = Cluster(
|
||||||
if (kDebugMode) print('Navigate to group: ${group.name}');
|
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
|
@override
|
||||||
|
|
|
||||||
|
|
@ -58,11 +58,71 @@ class _PrivateCapsuleScreenState extends ConsumerState<PrivateCapsuleScreen>
|
||||||
await CapsuleSecurityService.cacheCapsuleKey(widget.capsule.id, key);
|
await CapsuleSecurityService.cacheCapsuleKey(widget.capsule.id, key);
|
||||||
}
|
}
|
||||||
if (mounted) setState(() { _capsuleKey = key; _isUnlocking = false; });
|
if (mounted) setState(() { _capsuleKey = key; _isUnlocking = false; });
|
||||||
|
|
||||||
|
// Silent self-healing: rotate keys if the server flagged it
|
||||||
|
if (key != null) _checkAndRotateKeysIfNeeded();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) setState(() { _unlockError = 'Failed to unlock capsule'; _isUnlocking = false; });
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
|
|
@ -230,7 +290,11 @@ class _PrivateCapsuleScreenState extends ConsumerState<PrivateCapsuleScreen>
|
||||||
context: context,
|
context: context,
|
||||||
backgroundColor: AppTheme.cardSurface,
|
backgroundColor: AppTheme.cardSurface,
|
||||||
isScrollControlled: true,
|
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 ───────────────────────────────────────────────────────────
|
// ── Admin Panel ───────────────────────────────────────────────────────────
|
||||||
class _CapsuleAdminPanel extends StatelessWidget {
|
class _CapsuleAdminPanel extends ConsumerStatefulWidget {
|
||||||
final Cluster capsule;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
|
@ -1021,7 +1217,6 @@ class _CapsuleAdminPanel extends StatelessWidget {
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// Handle
|
|
||||||
Center(
|
Center(
|
||||||
child: Container(
|
child: Container(
|
||||||
width: 40, height: 4,
|
width: 40, height: 4,
|
||||||
|
|
@ -1032,49 +1227,179 @@ class _CapsuleAdminPanel extends StatelessWidget {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text('Capsule Admin',
|
||||||
'Capsule Admin',
|
style: TextStyle(color: AppTheme.navyBlue, fontSize: 18, fontWeight: FontWeight.w700)),
|
||||||
style: TextStyle(color: AppTheme.navyBlue, fontSize: 18, fontWeight: FontWeight.w700),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
|
if (_busy)
|
||||||
_AdminAction(
|
const Center(child: Padding(
|
||||||
icon: Icons.vpn_key,
|
padding: EdgeInsets.symmetric(vertical: 24),
|
||||||
label: 'Rotate Encryption Keys',
|
child: CircularProgressIndicator(),
|
||||||
subtitle: 'Generate new keys and re-encrypt for all members',
|
))
|
||||||
color: const Color(0xFFFFA726),
|
else ...[
|
||||||
onTap: () { Navigator.pop(context); /* TODO: key rotation flow */ },
|
_AdminAction(
|
||||||
),
|
icon: Icons.vpn_key,
|
||||||
const SizedBox(height: 8),
|
label: 'Rotate Encryption Keys',
|
||||||
_AdminAction(
|
subtitle: 'Generate new keys and re-encrypt for all members',
|
||||||
icon: Icons.person_add,
|
color: const Color(0xFFFFA726),
|
||||||
label: 'Invite Member',
|
onTap: _rotateKeys,
|
||||||
subtitle: 'Encrypt the capsule key for a new member',
|
),
|
||||||
color: const Color(0xFF4CAF50),
|
const SizedBox(height: 8),
|
||||||
onTap: () { Navigator.pop(context); /* TODO: invite flow */ },
|
_AdminAction(
|
||||||
),
|
icon: Icons.person_add,
|
||||||
const SizedBox(height: 8),
|
label: 'Invite Member',
|
||||||
_AdminAction(
|
subtitle: 'Encrypt the capsule key for a new member',
|
||||||
icon: Icons.person_remove,
|
color: const Color(0xFF4CAF50),
|
||||||
label: 'Remove Member',
|
onTap: _inviteMember,
|
||||||
subtitle: 'Revoke access (triggers automatic key rotation)',
|
),
|
||||||
color: SojornColors.destructive,
|
const SizedBox(height: 8),
|
||||||
onTap: () { Navigator.pop(context); /* TODO: remove + rotate */ },
|
_AdminAction(
|
||||||
),
|
icon: Icons.person_remove,
|
||||||
const SizedBox(height: 8),
|
label: 'Remove Member',
|
||||||
_AdminAction(
|
subtitle: 'Revoke access and rotate keys automatically',
|
||||||
icon: Icons.settings,
|
color: SojornColors.destructive,
|
||||||
label: 'Capsule Settings',
|
onTap: _removeMember,
|
||||||
subtitle: 'Toggle chat, forum, and vault features',
|
),
|
||||||
color: SojornColors.basicBrightNavy,
|
const SizedBox(height: 8),
|
||||||
onTap: () { Navigator.pop(context); /* TODO: settings */ },
|
_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 {
|
class _AdminAction extends StatelessWidget {
|
||||||
final IconData icon;
|
final IconData icon;
|
||||||
final String label;
|
final String label;
|
||||||
|
|
|
||||||
|
|
@ -31,14 +31,11 @@ class _PublicClusterScreenState extends ConsumerState<PublicClusterScreen> {
|
||||||
Future<void> _loadPosts() async {
|
Future<void> _loadPosts() async {
|
||||||
setState(() => _isLoading = true);
|
setState(() => _isLoading = true);
|
||||||
try {
|
try {
|
||||||
// TODO: Call group-specific feed endpoint when wired
|
|
||||||
final api = ref.read(apiServiceProvider);
|
final api = ref.read(apiServiceProvider);
|
||||||
final beacons = await api.fetchNearbyBeacons(
|
final raw = await api.callGoApi('/groups/${widget.cluster.id}/feed', method: 'GET');
|
||||||
lat: widget.cluster.lat ?? 0,
|
final items = (raw['posts'] as List?)?.cast<Map<String, dynamic>>() ?? [];
|
||||||
long: widget.cluster.lng ?? 0,
|
final posts = items.map((j) => Post.fromJson(j)).toList();
|
||||||
radius: widget.cluster.radiusMeters,
|
if (mounted) setState(() { _posts = posts; _isLoading = false; });
|
||||||
);
|
|
||||||
if (mounted) setState(() { _posts = beacons; _isLoading = false; });
|
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
if (mounted) setState(() => _isLoading = false);
|
if (mounted) setState(() => _isLoading = false);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:share_plus/share_plus.dart';
|
||||||
import '../../providers/api_provider.dart';
|
import '../../providers/api_provider.dart';
|
||||||
import '../../providers/feed_refresh_provider.dart';
|
import '../../providers/feed_refresh_provider.dart';
|
||||||
import '../../models/post.dart';
|
import '../../models/post.dart';
|
||||||
|
|
@ -165,7 +166,8 @@ class _FeedsojornScreenState extends ConsumerState<FeedsojornScreen> {
|
||||||
}
|
}
|
||||||
|
|
||||||
void _sharePost(Post post) {
|
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
|
@override
|
||||||
|
|
|
||||||
|
|
@ -1226,15 +1226,13 @@ class ApiService {
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
||||||
Future<String> getSignedMediaUrl(String path) async {
|
Future<String> getSignedMediaUrl(String path) async {
|
||||||
// For web platform, return the original URL since signing isn't needed
|
if (path.startsWith('http')) return path;
|
||||||
// for public CDN domains
|
try {
|
||||||
if (path.startsWith('http')) {
|
final data = await callGoApi('/media/sign', method: 'GET', queryParams: {'path': path});
|
||||||
|
return data['url'] as String? ?? path;
|
||||||
|
} catch (_) {
|
||||||
return path;
|
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 {
|
Future<Map<String, dynamic>> toggleReaction(String postId, String emoji) async {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue