Adds optional SSO via Authentik (or any OIDC provider) alongside the existing password flow, and pulls profile images from the provider's `picture` claim or Gravatar so the sharing UI stops looking anonymous. Password login stays available as a recovery path; JIT provisioning and admin-group mapping are env-configurable. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
320 lines
11 KiB
TypeScript
320 lines
11 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react'
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
|
import { Users, Eye, Pencil, X, Loader2 } from 'lucide-react'
|
|
import { cn } from '@/lib/utils'
|
|
import { Avatar } from './Avatar'
|
|
import {
|
|
sharing,
|
|
type ShareInfo,
|
|
type ShareableUser,
|
|
} from '../../services/api'
|
|
import { formatApiError } from '../../lib/apiError'
|
|
import {
|
|
SHARED_HEAPS_KEY,
|
|
SHARED_FOLDERS_KEY,
|
|
} from '../../hooks/useSharingQueries'
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog'
|
|
import { Button } from '@/components/ui/button'
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@/components/ui/select'
|
|
import { Alert, AlertDescription } from '@/components/ui/alert'
|
|
import { Separator } from '@/components/ui/separator'
|
|
|
|
interface ShareDialogProps {
|
|
isOpen: boolean
|
|
type: 'heap' | 'folder'
|
|
targetId: string
|
|
targetName: string
|
|
onClose: () => void
|
|
}
|
|
|
|
export function ShareDialog({
|
|
isOpen,
|
|
type,
|
|
targetId,
|
|
targetName,
|
|
onClose,
|
|
}: ShareDialogProps) {
|
|
const [username, setUsername] = useState('')
|
|
const [permission, setPermission] = useState<'read' | 'write'>('read')
|
|
const [error, setError] = useState<string | null>(null)
|
|
const queryClient = useQueryClient()
|
|
|
|
const sharesQueryKey = [type, 'shares', targetId]
|
|
|
|
const { data: shares = [], isLoading } = useQuery<ShareInfo[]>({
|
|
queryKey: sharesQueryKey,
|
|
queryFn: () =>
|
|
type === 'heap'
|
|
? sharing.heapShares(targetId)
|
|
: sharing.folderShares(targetId),
|
|
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'
|
|
? sharing.shareHeap(targetId, username, permission)
|
|
: sharing.shareFolder(targetId, username, permission),
|
|
onSuccess: () => {
|
|
setUsername('')
|
|
setPermission('read')
|
|
setError(null)
|
|
queryClient.invalidateQueries({ queryKey: sharesQueryKey })
|
|
queryClient.invalidateQueries({
|
|
queryKey: type === 'heap' ? SHARED_HEAPS_KEY : SHARED_FOLDERS_KEY,
|
|
})
|
|
},
|
|
onError: (err) => {
|
|
setError(formatApiError(err, 'Failed to share'))
|
|
},
|
|
})
|
|
|
|
const revokeMutation = useMutation({
|
|
mutationFn: (shareId: string) =>
|
|
type === 'heap'
|
|
? sharing.revokeHeapShare(targetId, shareId)
|
|
: sharing.revokeFolderShare(targetId, shareId),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: sharesQueryKey })
|
|
queryClient.invalidateQueries({
|
|
queryKey: type === 'heap' ? SHARED_HEAPS_KEY : SHARED_FOLDERS_KEY,
|
|
})
|
|
},
|
|
})
|
|
|
|
useEffect(() => {
|
|
if (isOpen) {
|
|
setUsername('')
|
|
setPermission('read')
|
|
setError(null)
|
|
}
|
|
}, [isOpen])
|
|
|
|
const invitePlaceholder = isLoadingUsers
|
|
? 'Loading users…'
|
|
: availableUsers.length === 0
|
|
? shares.length > 0
|
|
? 'Everyone already has access'
|
|
: 'No users to share with'
|
|
: 'Add people…'
|
|
|
|
return (
|
|
<Dialog open={isOpen} onOpenChange={(o) => !o && onClose()}>
|
|
{/* flex flex-col so the gap-4 between header → invite row →
|
|
* separator → list actually applies (DialogContent isn't a flex
|
|
* container by default, which is what made the spacing feel
|
|
* random on the previous pass). */}
|
|
<DialogContent className="flex max-w-md flex-col gap-4">
|
|
{/* Header — target name inlines into the title so there's no
|
|
* separate "chip" container that reads like an empty input.
|
|
* Google Drive / Notion / Linear all follow this pattern. */}
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-2 pr-6">
|
|
<Users className="h-4 w-4 shrink-0 text-primary" />
|
|
<span className="truncate">
|
|
Share
|
|
<span className="ml-1 font-normal text-text-muted">
|
|
“{targetName}”
|
|
</span>
|
|
</span>
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
Invite people to view or edit this {type}.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
{/* Invite row — single compact line: user picker (flex-1) +
|
|
* permission + primary action. Mirrors the Drive/Notion
|
|
* invitation bar, where the whole flow is reachable without
|
|
* scanning multiple labeled sections. */}
|
|
<form
|
|
onSubmit={(e) => {
|
|
e.preventDefault()
|
|
if (username.trim()) addMutation.mutate()
|
|
}}
|
|
className="space-y-2"
|
|
>
|
|
<div className="flex gap-2">
|
|
<Select
|
|
value={username}
|
|
onValueChange={(v) => {
|
|
setUsername(v)
|
|
setError(null)
|
|
}}
|
|
disabled={isLoadingUsers || availableUsers.length === 0}
|
|
>
|
|
<SelectTrigger className="flex-1">
|
|
<SelectValue placeholder={invitePlaceholder} />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{availableUsers.map((u) => (
|
|
<SelectItem key={u.id} value={u.username}>
|
|
<span className="flex items-center gap-2">
|
|
<Avatar name={u.username} imageUrl={u.avatar_url} size="sm" />
|
|
{u.display_name || u.username}
|
|
</span>
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<Select
|
|
value={permission}
|
|
onValueChange={(v) => setPermission(v as 'read' | 'write')}
|
|
>
|
|
<SelectTrigger className="w-[112px]">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="read">
|
|
<span className="flex items-center gap-2">
|
|
<Eye className="h-3.5 w-3.5" />
|
|
Viewer
|
|
</span>
|
|
</SelectItem>
|
|
<SelectItem value="write">
|
|
<span className="flex items-center gap-2">
|
|
<Pencil className="h-3.5 w-3.5" />
|
|
Editor
|
|
</span>
|
|
</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
<Button
|
|
type="submit"
|
|
disabled={!username.trim() || addMutation.isPending}
|
|
>
|
|
{addMutation.isPending ? (
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
) : (
|
|
'Share'
|
|
)}
|
|
</Button>
|
|
</div>
|
|
{error && (
|
|
<Alert variant="destructive">
|
|
<AlertDescription>{error}</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
</form>
|
|
|
|
<Separator />
|
|
|
|
{/* People list — hoverable rows, each with avatar + name +
|
|
* permission subtitle + X to revoke. This is the same layout
|
|
* Drive/Notion use: one primary line per person, permission
|
|
* relegated to a subtle subtitle rather than a loud pill. */}
|
|
<section className="space-y-2">
|
|
<div className="flex items-baseline justify-between">
|
|
<h3 className="text-sm font-medium text-text">People with access</h3>
|
|
{shares.length > 0 && (
|
|
<span className="text-[11px] text-text-faint">
|
|
{shares.length}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{isLoading ? (
|
|
<div className="flex items-center gap-2 px-1 py-2 text-xs text-text-muted">
|
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
Loading…
|
|
</div>
|
|
) : shares.length === 0 ? (
|
|
<p className="px-1 py-1 text-xs text-text-muted">
|
|
Only you can access this {type}.
|
|
</p>
|
|
) : (
|
|
<ul className="-mx-2">
|
|
{shares.map((share) => (
|
|
<li
|
|
key={share.id}
|
|
className="group flex items-center gap-3 rounded-md px-2 py-1.5 hover:bg-surface-2"
|
|
>
|
|
<Avatar
|
|
name={share.shared_with_username}
|
|
imageUrl={share.shared_with_avatar_url}
|
|
/>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center gap-1.5">
|
|
<span className="truncate text-sm font-medium text-text">
|
|
{share.shared_with_display_name || share.shared_with_username}
|
|
</span>
|
|
{share.status === 'pending' && (
|
|
<span
|
|
className="rounded-sm bg-amber-500/15 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-amber-300"
|
|
title="Waiting for them to accept"
|
|
>
|
|
Invited
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-1 text-[11px] text-text-muted">
|
|
{share.permission === 'write' ? (
|
|
<>
|
|
<Pencil className="h-2.5 w-2.5" />
|
|
Can edit
|
|
</>
|
|
) : (
|
|
<>
|
|
<Eye className="h-2.5 w-2.5" />
|
|
Can view
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => revokeMutation.mutate(share.id)}
|
|
disabled={revokeMutation.isPending}
|
|
className={cn(
|
|
'h-7 w-7 text-text-muted transition-opacity hover:bg-reject/10 hover:text-reject',
|
|
// Hover-revealed on pointer devices, always
|
|
// visible on touch (focus-within covers keyboard
|
|
// nav as well).
|
|
'opacity-0 group-hover:opacity-100 group-focus-within:opacity-100'
|
|
)}
|
|
title="Revoke access"
|
|
>
|
|
<X className="h-3.5 w-3.5" />
|
|
</Button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</section>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
}
|
|
|