diff --git a/backend/app/routers/sharing.py b/backend/app/routers/sharing.py index 1602b68..9620f4e 100644 --- a/backend/app/routers/sharing.py +++ b/backend/app/routers/sharing.py @@ -59,6 +59,34 @@ class SharedFolderResponse(BaseModel): photo_count: int +class ShareableUser(BaseModel): + id: str + username: str + + +# ── Shareable users ────────────────────────────────────────────────────── + +@router.get("/users", response_model=list[ShareableUser]) +async def list_shareable_users( + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """List every active user other than the caller, for the share-picker + dropdown. Sharing only requires knowing a username today, so surfacing + the list is no wider an attack surface than the free-text input it + replaces. Inactive accounts are filtered out.""" + result = await db.execute( + select(User) + .where(User.id != current_user.id) + .where(User.is_active.is_(True)) + .order_by(User.username) + ) + return [ + ShareableUser(id=str(u.id), username=u.username) + for u in result.scalars().all() + ] + + # ── Heap sharing ───────────────────────────────────────────────────────── @router.get("/heaps/shared-with-me") diff --git a/frontend/src/components/sharing/ShareDialog.tsx b/frontend/src/components/sharing/ShareDialog.tsx index 47a4695..c84988f 100644 --- a/frontend/src/components/sharing/ShareDialog.tsx +++ b/frontend/src/components/sharing/ShareDialog.tsx @@ -1,7 +1,12 @@ -import { useEffect, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { Users, Trash2 } from 'lucide-react' -import { sharing, type ShareInfo } from '../../services/api' +import { + sharing, + type ShareInfo, + type ShareableUser, +} from '../../services/api' +import { formatApiError } from '../../lib/apiError' import { SHARED_HEAPS_KEY, SHARED_FOLDERS_KEY, @@ -13,7 +18,6 @@ import { DialogTitle, } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' import { Select, SelectContent, @@ -54,6 +58,24 @@ export function ShareDialog({ enabled: isOpen, }) + // All users that can be shared with (the caller is filtered out + // server-side). Only fetched while the dialog is open. Cached for 60s + // because the user directory changes slowly. + const { data: allUsers = [], isLoading: isLoadingUsers } = useQuery< + ShareableUser[] + >({ + queryKey: ['sharing', 'users'], + queryFn: sharing.listUsers, + enabled: isOpen, + staleTime: 60_000, + }) + + // Users who don't already have a share on this target. + const availableUsers = useMemo(() => { + const taken = new Set(shares.map((s) => s.shared_with_username)) + return allUsers.filter((u) => !taken.has(u.username)) + }, [allUsers, shares]) + const addMutation = useMutation({ mutationFn: () => type === 'heap' @@ -68,8 +90,8 @@ export function ShareDialog({ queryKey: type === 'heap' ? SHARED_HEAPS_KEY : SHARED_FOLDERS_KEY, }) }, - onError: (err: any) => { - setError(err?.response?.data?.detail || 'Failed to share') + onError: (err) => { + setError(formatApiError(err, 'Failed to share')) }, }) @@ -149,17 +171,33 @@ export function ShareDialog({ className="space-y-3" >