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>
158 lines
6.4 KiB
TypeScript
158 lines
6.4 KiB
TypeScript
import { useMemo, useState } from 'react'
|
|
import { Bell, Layers, Folder, Eye, Pencil, Check, X, Loader2 } from 'lucide-react'
|
|
import { cn } from '@/lib/utils'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
|
import {
|
|
usePendingSharesQuery,
|
|
useAcceptShareMutation,
|
|
useDeclineShareMutation,
|
|
} from '../../hooks/useSharingQueries'
|
|
import { toast } from '../ToastContainer'
|
|
import type { PendingShare } from '../../services/api'
|
|
import { Avatar } from './Avatar'
|
|
|
|
/** Share invite notification bell. Sits in the LeftSidebar user
|
|
* section — polls /sharing/pending every 60s and shows a red-orange
|
|
* count badge when there are pending invites. Click opens a popover
|
|
* listing each invite with Accept / Decline inline. */
|
|
export function NotificationBell() {
|
|
const [open, setOpen] = useState(false)
|
|
const { data } = usePendingSharesQuery()
|
|
const acceptMutation = useAcceptShareMutation()
|
|
const declineMutation = useDeclineShareMutation()
|
|
|
|
const invites = useMemo(() => {
|
|
if (!data) return [] as Array<PendingShare & { kind: 'heap' | 'folder' }>
|
|
return [
|
|
...data.heaps.map((h) => ({ ...h, kind: 'heap' as const })),
|
|
...data.folders.map((f) => ({ ...f, kind: 'folder' as const })),
|
|
]
|
|
}, [data])
|
|
|
|
const count = invites.length
|
|
const isMutating = acceptMutation.isPending || declineMutation.isPending
|
|
|
|
const handleAccept = (invite: PendingShare & { kind: 'heap' | 'folder' }) => {
|
|
acceptMutation.mutate(
|
|
{ kind: invite.kind, targetId: invite.target_id },
|
|
{
|
|
onSuccess: () => {
|
|
toast.success(`Joined ${invite.target_name}`)
|
|
},
|
|
},
|
|
)
|
|
}
|
|
const handleDecline = (invite: PendingShare & { kind: 'heap' | 'folder' }) => {
|
|
declineMutation.mutate({ kind: invite.kind, targetId: invite.target_id })
|
|
}
|
|
|
|
return (
|
|
<Popover open={open} onOpenChange={setOpen}>
|
|
<PopoverTrigger asChild>
|
|
<button
|
|
type="button"
|
|
className={cn(
|
|
'relative inline-flex h-7 w-7 items-center justify-center rounded-md text-text-muted transition-colors',
|
|
'hover:bg-surface-2 hover:text-text',
|
|
count > 0 && 'text-text',
|
|
)}
|
|
aria-label={count > 0 ? `${count} pending invite${count === 1 ? '' : 's'}` : 'Notifications'}
|
|
title={count > 0 ? `${count} pending invite${count === 1 ? '' : 's'}` : 'No pending invites'}
|
|
>
|
|
<Bell className="h-4 w-4" />
|
|
{count > 0 && (
|
|
<span className="absolute -right-0.5 -top-0.5 inline-flex min-w-[14px] items-center justify-center rounded-full bg-primary px-1 text-[9px] font-semibold leading-[14px] tabular-nums text-black shadow-[0_0_0_1.5px_var(--bg,theme(colors.bg))]">
|
|
{count}
|
|
</span>
|
|
)}
|
|
</button>
|
|
</PopoverTrigger>
|
|
<PopoverContent align="start" sideOffset={6} className="w-80 p-0">
|
|
<div className="flex items-center justify-between border-b border-border px-3 py-2">
|
|
<div className="text-sm font-semibold text-text">Pending invites</div>
|
|
{count > 0 && (
|
|
<span className="text-[11px] tabular-nums text-text-faint">{count}</span>
|
|
)}
|
|
</div>
|
|
|
|
{count === 0 ? (
|
|
<div className="px-3 py-4 text-xs text-text-muted">
|
|
No pending invites.
|
|
</div>
|
|
) : (
|
|
<ul className="max-h-80 divide-y divide-border overflow-y-auto">
|
|
{invites.map((invite) => {
|
|
const TypeIcon = invite.kind === 'heap' ? Layers : Folder
|
|
return (
|
|
<li
|
|
key={`${invite.kind}:${invite.share_id}`}
|
|
className="flex flex-col gap-2 px-3 py-2.5"
|
|
>
|
|
<div className="flex items-start gap-2.5">
|
|
<Avatar
|
|
name={invite.owner_username}
|
|
imageUrl={invite.owner_avatar_url}
|
|
size="sm"
|
|
/>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="text-sm leading-tight text-text">
|
|
<span className="font-medium">
|
|
{invite.owner_display_name || invite.owner_username}
|
|
</span>
|
|
<span className="text-text-muted"> shared </span>
|
|
<span className="inline-flex items-center gap-1 align-baseline">
|
|
<TypeIcon className="inline h-3 w-3 text-text-muted" />
|
|
<span className="font-medium">{invite.target_name}</span>
|
|
</span>
|
|
</div>
|
|
<div className="mt-0.5 flex items-center gap-1 text-[11px] text-text-muted">
|
|
{invite.permission === 'write' ? (
|
|
<>
|
|
<Pencil className="h-2.5 w-2.5" />
|
|
Can edit
|
|
</>
|
|
) : (
|
|
<>
|
|
<Eye className="h-2.5 w-2.5" />
|
|
Can view
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center justify-end gap-2">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
disabled={isMutating}
|
|
onClick={() => handleDecline(invite)}
|
|
className="h-7 text-text-muted hover:bg-reject/10 hover:text-reject"
|
|
>
|
|
<X className="mr-1 h-3.5 w-3.5" />
|
|
Decline
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
disabled={isMutating}
|
|
onClick={() => handleAccept(invite)}
|
|
className="h-7"
|
|
>
|
|
{acceptMutation.isPending ? (
|
|
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
|
|
) : (
|
|
<Check className="mr-1 h-3.5 w-3.5" />
|
|
)}
|
|
Accept
|
|
</Button>
|
|
</div>
|
|
</li>
|
|
)
|
|
})}
|
|
</ul>
|
|
)}
|
|
</PopoverContent>
|
|
</Popover>
|
|
)
|
|
}
|