deepspace 0.19.0 → 0.19.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -118,11 +118,11 @@ export default function AdminPage() {
118
118
  interface UsersPanelProps {
119
119
  users: Array<{
120
120
  id: string
121
- email: string
121
+ email?: string
122
122
  name: string
123
123
  imageUrl?: string
124
124
  role: string
125
- lastSeenAt: string
125
+ lastSeenAt?: string
126
126
  }>
127
127
  currentUserId?: string
128
128
  onSetRole: (userId: string, role: string) => void
@@ -132,7 +132,7 @@ function UsersPanel({ users, currentUserId, onSetRole }: UsersPanelProps) {
132
132
  // Sort users by last seen
133
133
  const sortedUsers = useMemo(() => {
134
134
  return [...users].sort((a, b) =>
135
- new Date(b.lastSeenAt).getTime() - new Date(a.lastSeenAt).getTime()
135
+ new Date(b.lastSeenAt ?? '').getTime() - new Date(a.lastSeenAt ?? '').getTime()
136
136
  )
137
137
  }, [users])
138
138
 
@@ -163,7 +163,7 @@ function UsersPanel({ users, currentUserId, onSetRole }: UsersPanelProps) {
163
163
 
164
164
  <div className="flex items-center gap-3">
165
165
  <p className="text-xs text-muted-foreground">
166
- Last seen: {new Date(user.lastSeenAt).toLocaleDateString()}
166
+ Last seen: {user.lastSeenAt ? new Date(user.lastSeenAt).toLocaleDateString() : '—'}
167
167
  </p>
168
168
 
169
169
  <div className="flex items-center gap-2">
@@ -86,7 +86,8 @@
86
86
  "@tiptap/extension-task-item": "^3.22.0"
87
87
  },
88
88
  "instructions": [
89
- "Run `npm install` (or your package manager) to install the Tiptap dependencies that were just added to package.json — the editor imports @tiptap/react and friends, so the dev server will fail to resolve them otherwise."
89
+ "Run `npm install` (or your package manager) to install the Tiptap dependencies that were just added to package.json — the editor imports @tiptap/react and friends, so the dev server will fail to resolve them otherwise.",
90
+ "Before sharing a document by email, ask that person to sign in to the deployed app once. The privacy-preserving invite lookup uses the app-local users collection, not a global user directory."
90
91
  ],
91
92
  "patterns": []
92
93
  }
@@ -17,10 +17,14 @@ import { type Editor, useEditorState } from '@tiptap/react'
17
17
  import { useDocEditor } from './editor/useDocEditor'
18
18
  import { DocEditorSurface, PAGE_HEIGHT_PX, PAGE_WIDTH_PX } from './editor/DocEditorSurface'
19
19
  import { DocumentsTiptapToolbar } from './editor/DocumentsTiptapToolbar'
20
- import { DocumentsOutlinePanel, DOCUMENT_OUTLINE_WIDTH_PX, type OutlineEntry } from './DocumentsOutlinePanel'
20
+ import {
21
+ DocumentsOutlinePanel,
22
+ DOCUMENT_OUTLINE_WIDTH_PX,
23
+ type OutlineEntry,
24
+ } from './DocumentsOutlinePanel'
21
25
  import { DocumentsPresence } from './DocumentsPresence'
22
26
  import { InviteDialog } from './InviteDialog'
23
- import type { DocumentsDocumentFields } from './documents-library-types'
27
+ import { recordsReadyForMutation, type DocumentsDocumentFields } from './documents-library-types'
24
28
  import {
25
29
  useDocumentsEditorPresence,
26
30
  useDocumentsPresenceAccess,
@@ -175,7 +179,10 @@ function AccessChangedOverlay({
175
179
  <AlertTriangle className="h-4 w-4" strokeWidth={2} />
176
180
  </span>
177
181
  <div className="min-w-0 flex-1">
178
- <h2 id="documents-access-change-title" className="text-base font-semibold tracking-tight">
182
+ <h2
183
+ id="documents-access-change-title"
184
+ className="text-base font-semibold tracking-tight"
185
+ >
179
186
  {title}
180
187
  </h2>
181
188
  <p className="mt-1.5 text-sm" style={{ color: 'var(--documents-el-muted)' }}>
@@ -279,7 +286,8 @@ export default function DocumentsEditorPage() {
279
286
  [documents, docId],
280
287
  )
281
288
 
282
- const { put } = useMutations<DocumentsDocumentFields>('documents')
289
+ const { put, ready: documentsMutationsReady } = useMutations<DocumentsDocumentFields>('documents')
290
+ const documentWritesReady = recordsReadyForMutation(status, documentsMutationsReady)
283
291
 
284
292
  // The text helper remains available to consumers of useYjsRoom; this editor
285
293
  // binds Tiptap directly to Y.XmlFragment('default') on the returned document.
@@ -397,10 +405,10 @@ export default function DocumentsEditorPage() {
397
405
  const docTitle = selectedDoc?.data.title?.trim() || 'Untitled Document'
398
406
  const handleTitleSave = useCallback(
399
407
  async (next: string) => {
400
- if (!selectedDoc) return
408
+ if (!documentWritesReady || !selectedDoc) return
401
409
  await put(selectedDoc.recordId, { ...selectedDoc.data, title: next }).catch(() => {})
402
410
  },
403
- [put, selectedDoc],
411
+ [documentWritesReady, put, selectedDoc],
404
412
  )
405
413
 
406
414
  /**
@@ -450,7 +458,11 @@ export default function DocumentsEditorPage() {
450
458
  <ArrowLeft className="h-4 w-4" strokeWidth={2} />
451
459
  </button>
452
460
 
453
- <InlineTitle title={docTitle} canEdit={isOwner ?? false} onSave={handleTitleSave} />
461
+ <InlineTitle
462
+ title={docTitle}
463
+ canEdit={Boolean(isOwner && documentWritesReady)}
464
+ onSave={handleTitleSave}
465
+ />
454
466
 
455
467
  <DocumentsPresence participants={presenceParticipants} typingNames={typingNames} />
456
468
 
@@ -473,7 +485,10 @@ export default function DocumentsEditorPage() {
473
485
  ) : effectiveRole === 'editor' || effectiveRole === 'viewer' ? (
474
486
  <span
475
487
  className="inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium"
476
- style={{ borderColor: 'var(--documents-el-line)', color: 'var(--documents-el-muted)' }}
488
+ style={{
489
+ borderColor: 'var(--documents-el-line)',
490
+ color: 'var(--documents-el-muted)',
491
+ }}
477
492
  >
478
493
  {effectiveRole === 'editor' ? 'Shared editor' : 'Shared viewer'}
479
494
  </span>
@@ -639,7 +654,10 @@ export function ErrorBoundary() {
639
654
  >
640
655
  <div
641
656
  className="w-full max-w-md rounded-xl border p-6 shadow-md"
642
- style={{ borderColor: 'var(--documents-el-line)', backgroundColor: 'var(--documents-el-surface)' }}
657
+ style={{
658
+ borderColor: 'var(--documents-el-line)',
659
+ backgroundColor: 'var(--documents-el-surface)',
660
+ }}
643
661
  >
644
662
  <div className="mb-3 flex items-center justify-center">
645
663
  <span
@@ -654,7 +672,10 @@ export function ErrorBoundary() {
654
672
  </span>
655
673
  </div>
656
674
  <h1 className="text-base font-semibold tracking-tight">This document needs to reload</h1>
657
- <p className="mx-auto mt-1.5 max-w-xs text-sm" style={{ color: 'var(--documents-el-muted)' }}>
675
+ <p
676
+ className="mx-auto mt-1.5 max-w-xs text-sm"
677
+ style={{ color: 'var(--documents-el-muted)' }}
678
+ >
658
679
  Your access to this document just changed. Refresh to load the latest version.
659
680
  </p>
660
681
  <p
@@ -27,6 +27,7 @@ import {
27
27
  import { LibrarySidebar, readSidebarCollapsed, writeSidebarCollapsed } from './LibrarySidebar'
28
28
  import {
29
29
  parseDocumentsIdList,
30
+ recordsReadyForMutation,
30
31
  type DocFolderFields,
31
32
  type DocumentsDocumentFields,
32
33
  type LibraryNavSelection,
@@ -81,7 +82,7 @@ export default function DocumentsListPage() {
81
82
  const { user } = useUser()
82
83
  const navigate = useNavigate()
83
84
  const userRole = (user?.role ?? ROLES.VIEWER) as Role
84
- const canCreate = userRole === ROLES.MEMBER || userRole === ROLES.ADMIN
85
+ const roleCanCreate = userRole === ROLES.MEMBER || userRole === ROLES.ADMIN
85
86
 
86
87
  const [searchQuery, setSearchQuery] = useState('')
87
88
  const [sortBy, setSortBy] = useState<SortOption>('lastEdited')
@@ -101,17 +102,31 @@ export default function DocumentsListPage() {
101
102
  orderBy: 'createdAt',
102
103
  orderDir: 'desc',
103
104
  })
104
- const { create, remove, put } = useMutations<DocumentsDocumentFields>('documents')
105
-
106
- const { records: folderRecords } = useQuery<DocFolderFields>('doc_folders', {
107
- orderBy: 'createdAt',
108
- orderDir: 'asc',
109
- })
105
+ const {
106
+ createConfirmed,
107
+ remove,
108
+ put,
109
+ ready: documentsMutationsReady,
110
+ } = useMutations<DocumentsDocumentFields>('documents')
111
+
112
+ const { records: folderRecords, status: foldersStatus } = useQuery<DocFolderFields>(
113
+ 'doc_folders',
114
+ {
115
+ orderBy: 'createdAt',
116
+ orderDir: 'asc',
117
+ },
118
+ )
110
119
  const {
111
120
  create: createFolder,
112
121
  put: putFolder,
113
122
  remove: removeFolder,
123
+ ready: folderMutationsReady,
114
124
  } = useMutations<DocFolderFields>('doc_folders')
125
+ const documentWritesReady = recordsReadyForMutation(status, documentsMutationsReady)
126
+ const folderWritesReady = recordsReadyForMutation(foldersStatus, folderMutationsReady)
127
+ const canCreate = roleCanCreate && documentWritesReady
128
+ const canManageFolders = canCreate && folderWritesReady
129
+ const canMoveDocuments = canCreate && foldersStatus === 'ready'
115
130
 
116
131
  const myFolders = useMemo(
117
132
  () => (folderRecords ?? []).filter((f) => f.data.ownerId === user?.id),
@@ -218,7 +233,7 @@ export default function DocumentsListPage() {
218
233
 
219
234
  const handleNewDocument = useCallback(async () => {
220
235
  if (!user || !canCreate) return
221
- const id = await create({
236
+ const id = await createConfirmed({
222
237
  title: UNTITLED,
223
238
  ownerId: user.id,
224
239
  collaborators: '[]',
@@ -226,36 +241,42 @@ export default function DocumentsListPage() {
226
241
  folderId: folderIdForNew,
227
242
  })
228
243
  navigate(`/documents/${id}`)
229
- }, [canCreate, create, folderIdForNew, navigate, user])
244
+ }, [canCreate, createConfirmed, folderIdForNew, navigate, user])
230
245
 
231
246
  const handleDelete = useCallback(
232
247
  async (docId: string) => {
248
+ if (!canCreate) return
233
249
  if (confirm('Delete this document?')) await remove(docId)
234
250
  },
235
- [remove],
251
+ [canCreate, remove],
236
252
  )
237
253
 
238
254
  const handleRenameCommit = useCallback(
239
255
  async (docId: string) => {
256
+ if (!canCreate) {
257
+ setRenamingId(null)
258
+ return
259
+ }
240
260
  const rec = documents.find((d) => d.recordId === docId)
241
261
  const t = renameValue.trim()
242
262
  setRenamingId(null)
243
263
  if (!rec || !t || t === rec.data.title) return
244
264
  await put(docId, { ...rec.data, title: t }).catch(() => {})
245
265
  },
246
- [documents, put, renameValue],
266
+ [canCreate, documents, put, renameValue],
247
267
  )
248
268
 
249
269
  const handleCreateFolder = useCallback(
250
270
  async (name: string) => {
251
- if (!user) return
271
+ if (!user || !canManageFolders) return
252
272
  await createFolder({ name, ownerId: user.id })
253
273
  },
254
- [createFolder, user],
274
+ [canManageFolders, createFolder, user],
255
275
  )
256
276
 
257
277
  const handleDeleteFolder = useCallback(
258
278
  async (folderId: string) => {
279
+ if (!canManageFolders) return
259
280
  const owned = (documents ?? []).filter(
260
281
  (d) => d.data.ownerId === user?.id && (d.data.folderId ?? '') === folderId,
261
282
  )
@@ -266,7 +287,7 @@ export default function DocumentsListPage() {
266
287
  nav.kind === 'folder' && nav.folderId === folderId ? { kind: 'all' } : nav,
267
288
  )
268
289
  },
269
- [documents, put, removeFolder, user?.id],
290
+ [canManageFolders, documents, put, removeFolder, user?.id],
270
291
  )
271
292
 
272
293
  const commitRenameFolderRef = useRef<() => Promise<void>>(async () => {})
@@ -274,22 +295,23 @@ export default function DocumentsListPage() {
274
295
  const id = folderRenamingId
275
296
  const trimmed = folderRenameValue.trim()
276
297
  setFolderRenamingId(null)
277
- if (!id || !trimmed) return
298
+ if (!canManageFolders || !id || !trimmed) return
278
299
  const folder = sortedFolders.find((f) => f.recordId === id)
279
300
  if (!folder || trimmed === (folder.data.name ?? '').trim()) return
280
301
  await putFolder(id, { ...folder.data, name: trimmed }).catch(() => {})
281
- }, [folderRenameValue, folderRenamingId, putFolder, sortedFolders])
302
+ }, [canManageFolders, folderRenameValue, folderRenamingId, putFolder, sortedFolders])
282
303
 
283
304
  commitRenameFolderRef.current = commitRenameFolder
284
305
 
285
306
  const handleMoveDoc = useCallback(
286
307
  async (docId: string, folderId: string) => {
308
+ if (!canMoveDocuments) return
287
309
  const rec = documents.find((d) => d.recordId === docId)
288
310
  if (!rec) return
289
311
  await put(docId, { ...rec.data, folderId })
290
312
  setMoveMenuId(null)
291
313
  },
292
- [documents, put],
314
+ [canMoveDocuments, documents, put],
293
315
  )
294
316
 
295
317
  const isLoading = status === 'loading'
@@ -316,6 +338,7 @@ export default function DocumentsListPage() {
316
338
  selection={libraryNav}
317
339
  onSelect={setLibraryNav}
318
340
  folders={myFolders}
341
+ canManageFolders={canManageFolders}
319
342
  collapsed={sidebarCollapsed}
320
343
  onToggleCollapsed={toggleSidebar}
321
344
  onCreateFolder={handleCreateFolder}
@@ -342,7 +365,10 @@ export default function DocumentsListPage() {
342
365
  {greetingForTime()}, {displayFirstName}
343
366
  </h1>
344
367
  </div>
345
- <p className="mb-8 text-[13px] font-medium" style={{ color: 'var(--documents-el-muted)' }}>
368
+ <p
369
+ className="mb-8 text-[13px] font-medium"
370
+ style={{ color: 'var(--documents-el-muted)' }}
371
+ >
346
372
  {ownedDocumentCount} {ownedDocumentCount === 1 ? 'document' : 'documents'} in your
347
373
  documents.
348
374
  </p>
@@ -571,7 +597,10 @@ export default function DocumentsListPage() {
571
597
  aria-hidden
572
598
  />
573
599
  </div>
574
- <h3 className="mb-2 text-lg font-semibold" style={{ color: 'var(--documents-el-text)' }}>
600
+ <h3
601
+ className="mb-2 text-lg font-semibold"
602
+ style={{ color: 'var(--documents-el-text)' }}
603
+ >
575
604
  {searchQuery ? 'No matches' : 'No documents yet'}
576
605
  </h3>
577
606
  <p className="mb-6 max-w-sm text-sm" style={{ color: 'var(--documents-el-muted)' }}>
@@ -591,7 +620,8 @@ export default function DocumentsListPage() {
591
620
  className="group w-full cursor-pointer rounded-xl border-2 border-dashed p-4 text-left shadow-sm transition-all hover:shadow-md"
592
621
  style={{
593
622
  borderColor: 'var(--documents-el-line)',
594
- backgroundColor: 'color-mix(in srgb, var(--documents-el-surface) 20%, transparent)',
623
+ backgroundColor:
624
+ 'color-mix(in srgb, var(--documents-el-surface) 20%, transparent)',
595
625
  }}
596
626
  >
597
627
  <div
@@ -668,6 +698,7 @@ export default function DocumentsListPage() {
668
698
  <div className="min-w-0 flex-1">
669
699
  {renamingId === doc.recordId ? (
670
700
  <input
701
+ disabled={!canCreate}
671
702
  value={renameValue}
672
703
  onChange={(e) => setRenameValue(e.target.value)}
673
704
  onBlur={() => void handleRenameCommit(doc.recordId)}
@@ -676,7 +707,7 @@ export default function DocumentsListPage() {
676
707
  if (e.key === 'Escape') setRenamingId(null)
677
708
  }}
678
709
  onClick={(e) => e.stopPropagation()}
679
- className="mb-0.5 w-full rounded-md border px-2 py-1 text-[13px] font-semibold outline-none focus-visible:ring-2 focus-visible:ring-ring/30"
710
+ className="mb-0.5 w-full rounded-md border px-2 py-1 text-[13px] font-semibold outline-none focus-visible:ring-2 focus-visible:ring-ring/30 disabled:cursor-not-allowed disabled:opacity-50"
680
711
  style={{
681
712
  borderColor: 'var(--documents-el-line)',
682
713
  backgroundColor: 'var(--documents-el-bg)',
@@ -724,6 +755,7 @@ export default function DocumentsListPage() {
724
755
  <button
725
756
  type="button"
726
757
  title="More"
758
+ disabled={!canCreate}
727
759
  onClick={(e) => {
728
760
  e.stopPropagation()
729
761
  setActionsMenuId((id) =>
@@ -731,12 +763,12 @@ export default function DocumentsListPage() {
731
763
  )
732
764
  setMoveMenuId(null)
733
765
  }}
734
- className="rounded-lg p-1.5"
766
+ className="rounded-lg p-1.5 disabled:cursor-not-allowed disabled:opacity-40"
735
767
  style={{ color: 'var(--documents-el-muted)' }}
736
768
  >
737
769
  <MoreVertical className="h-4 w-4" />
738
770
  </button>
739
- {actionsMenuId === doc.recordId ? (
771
+ {canCreate && actionsMenuId === doc.recordId ? (
740
772
  <>
741
773
  <button
742
774
  type="button"
@@ -786,7 +818,7 @@ export default function DocumentsListPage() {
786
818
  <button
787
819
  type="button"
788
820
  className="block w-full rounded px-2 py-1 text-left text-xs hover:bg-black/[0.04]"
789
- disabled={fid === ''}
821
+ disabled={!canMoveDocuments || fid === ''}
790
822
  onClick={() => void handleMoveDoc(doc.recordId, '')}
791
823
  >
792
824
  Uncategorized
@@ -795,7 +827,7 @@ export default function DocumentsListPage() {
795
827
  <button
796
828
  key={f.recordId}
797
829
  type="button"
798
- disabled={fid === f.recordId}
830
+ disabled={!canMoveDocuments || fid === f.recordId}
799
831
  className="block w-full rounded px-2 py-1 text-left text-xs hover:bg-black/[0.04] disabled:opacity-40"
800
832
  onClick={() =>
801
833
  void handleMoveDoc(doc.recordId, f.recordId)
@@ -931,11 +963,12 @@ export default function DocumentsListPage() {
931
963
  </button>
932
964
  <button
933
965
  type="button"
966
+ disabled={!canCreate}
934
967
  onClick={(e) => {
935
968
  e.stopPropagation()
936
969
  void handleDelete(doc.recordId)
937
970
  }}
938
- className="rounded-lg p-1.5 opacity-0 transition-opacity group-hover:opacity-100"
971
+ className="rounded-lg p-1.5 opacity-0 transition-opacity group-hover:opacity-100 disabled:cursor-not-allowed disabled:opacity-0"
939
972
  style={{ color: 'var(--documents-el-muted)' }}
940
973
  >
941
974
  <Trash2 className="h-4 w-4" />
@@ -3,7 +3,11 @@ import type { RecordData } from 'deepspace'
3
3
  import { useMutations, useQuery, useUser } from 'deepspace'
4
4
  import { Mail, ShieldCheck, UserMinus, Users } from 'lucide-react'
5
5
  import { Modal, useToast } from '@/components/ui'
6
- import { parseDocumentsIdList, type DocumentsDocumentFields, type InviteAclDiff } from './documents-library-types'
6
+ import {
7
+ parseDocumentsIdList,
8
+ type DocumentsDocumentFields,
9
+ type InviteAclDiff,
10
+ } from './documents-library-types'
7
11
 
8
12
  interface UserFields {
9
13
  email?: string
@@ -42,12 +46,14 @@ function initialsFor(name: string): string {
42
46
 
43
47
  export function InviteDialog({ open, onOpenChange, doc, isOwner, onAclChange }: InviteDialogProps) {
44
48
  const { user } = useUser()
45
- const { records: users } = useQuery<UserFields>('users')
46
- const { put } = useMutations<DocumentsDocumentFields>('documents')
49
+ const { records: users, status: usersStatus } = useQuery<UserFields>('users')
50
+ const { putConfirmed, ready: documentsMutationsReady } =
51
+ useMutations<DocumentsDocumentFields>('documents')
47
52
  const toast = useToast()
48
53
  const [email, setEmail] = useState('')
49
54
  const [role, setRole] = useState<InviteRole>('editor')
50
55
  const [saving, setSaving] = useState(false)
56
+ const ready = usersStatus === 'ready' && documentsMutationsReady
51
57
 
52
58
  const collaborators = useMemo(
53
59
  () => parseDocumentsIdList(doc.data.collaborators),
@@ -70,7 +76,11 @@ export function InviteDialog({ open, onOpenChange, doc, isOwner, onAclChange }:
70
76
 
71
77
  if (!isOwner) return null
72
78
 
73
- const saveAccess = async (nextCollaborators: string[], nextEditors: string[]) => {
79
+ const saveAccess = async (
80
+ nextCollaborators: string[],
81
+ nextEditors: string[],
82
+ ): Promise<boolean> => {
83
+ if (!ready) return false
74
84
  const prevCollaborators = parseDocumentsIdList(doc.data.collaborators)
75
85
  const prevEditors = parseDocumentsIdList(doc.data.editors)
76
86
  const nextCollabList = uniqueIds(nextCollaborators)
@@ -80,7 +90,7 @@ export function InviteDialog({ open, onOpenChange, doc, isOwner, onAclChange }:
80
90
 
81
91
  setSaving(true)
82
92
  try {
83
- await put(doc.recordId, {
93
+ await putConfirmed(doc.recordId, {
84
94
  ...doc.data,
85
95
  collaborators: JSON.stringify(nextCollabList),
86
96
  editors: JSON.stringify(nextEditorList),
@@ -98,6 +108,13 @@ export function InviteDialog({ open, onOpenChange, doc, isOwner, onAclChange }:
98
108
  onAclChange({ removedUserIds, demotedUserIds, promotedUserIds })
99
109
  }
100
110
  }
111
+ return true
112
+ } catch {
113
+ toast.error(
114
+ 'Access not saved',
115
+ 'DeepSpace did not confirm the change. Reconnect and try again.',
116
+ )
117
+ return false
101
118
  } finally {
102
119
  setSaving(false)
103
120
  }
@@ -109,7 +126,10 @@ export function InviteDialog({ open, onOpenChange, doc, isOwner, onAclChange }:
109
126
 
110
127
  const target = users.find((u) => u.data.email?.trim().toLowerCase() === normalized)
111
128
  if (!target) {
112
- toast.error('User not found', 'No DeepSpace user with that email has used this app yet.')
129
+ toast.error(
130
+ 'User not found',
131
+ 'Ask them to sign in to this app once, then invite the same email again.',
132
+ )
113
133
  return
114
134
  }
115
135
  if (target.recordId === doc.data.ownerId || target.recordId === user?.id) {
@@ -123,7 +143,7 @@ export function InviteDialog({ open, onOpenChange, doc, isOwner, onAclChange }:
123
143
 
124
144
  const nextCollaborators = [...collaborators, target.recordId]
125
145
  const nextEditors = role === 'editor' ? [...editors, target.recordId] : editors
126
- await saveAccess(nextCollaborators, nextEditors)
146
+ if (!(await saveAccess(nextCollaborators, nextEditors))) return
127
147
  setEmail('')
128
148
  toast.success('Invite added', `${target.data.email ?? normalized} now has ${role} access.`)
129
149
  }
@@ -151,7 +171,12 @@ export function InviteDialog({ open, onOpenChange, doc, isOwner, onAclChange }:
151
171
  'Owner'
152
172
 
153
173
  return (
154
- <Modal open={open} onClose={() => onOpenChange(false)} size="lg" className="documents-feature-scope">
174
+ <Modal
175
+ open={open}
176
+ onClose={() => onOpenChange(false)}
177
+ size="lg"
178
+ className="documents-feature-scope"
179
+ >
155
180
  <Modal.Header>
156
181
  <Modal.Title>Share document</Modal.Title>
157
182
  <Modal.Description>
@@ -174,6 +199,7 @@ export function InviteDialog({ open, onOpenChange, doc, isOwner, onAclChange }:
174
199
  style={{ color: 'var(--documents-el-muted)' }}
175
200
  />
176
201
  <input
202
+ disabled={!ready || saving}
177
203
  value={email}
178
204
  onChange={(e) => setEmail(e.target.value)}
179
205
  onKeyDown={(e) => {
@@ -181,7 +207,10 @@ export function InviteDialog({ open, onOpenChange, doc, isOwner, onAclChange }:
181
207
  }}
182
208
  placeholder="person@gmail.com"
183
209
  className="h-10 w-full rounded-lg border bg-transparent pl-9 pr-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring/30"
184
- style={{ borderColor: 'var(--documents-el-line)', color: 'var(--documents-el-text)' }}
210
+ style={{
211
+ borderColor: 'var(--documents-el-line)',
212
+ color: 'var(--documents-el-text)',
213
+ }}
185
214
  />
186
215
  </div>
187
216
  <select
@@ -196,7 +225,7 @@ export function InviteDialog({ open, onOpenChange, doc, isOwner, onAclChange }:
196
225
  <button
197
226
  type="button"
198
227
  onClick={() => void addInvite()}
199
- disabled={saving || !email.trim()}
228
+ disabled={!ready || saving || !email.trim()}
200
229
  className="h-10 rounded-lg px-4 text-sm font-semibold text-white transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50"
201
230
  style={{ backgroundColor: 'var(--documents-el-accent)' }}
202
231
  >
@@ -253,7 +282,10 @@ export function InviteDialog({ open, onOpenChange, doc, isOwner, onAclChange }:
253
282
  )}
254
283
  <div className="min-w-0 flex-1">
255
284
  <div className="truncate text-sm font-medium">{name}</div>
256
- <div className="truncate text-xs" style={{ color: 'var(--documents-el-muted)' }}>
285
+ <div
286
+ className="truncate text-xs"
287
+ style={{ color: 'var(--documents-el-muted)' }}
288
+ >
257
289
  {u.data.email ?? 'No email'}
258
290
  </div>
259
291
  </div>
@@ -262,9 +294,12 @@ export function InviteDialog({ open, onOpenChange, doc, isOwner, onAclChange }:
262
294
  onChange={(e) =>
263
295
  void setCollaboratorRole(u.recordId, e.target.value as InviteRole)
264
296
  }
265
- disabled={saving}
297
+ disabled={!ready || saving}
266
298
  className="h-8 rounded-lg border bg-transparent px-2 text-xs font-medium outline-none"
267
- style={{ borderColor: 'var(--documents-el-line)', color: 'var(--documents-el-text)' }}
299
+ style={{
300
+ borderColor: 'var(--documents-el-line)',
301
+ color: 'var(--documents-el-text)',
302
+ }}
268
303
  >
269
304
  <option value="editor">Editor</option>
270
305
  <option value="viewer">Viewer</option>
@@ -272,7 +307,7 @@ export function InviteDialog({ open, onOpenChange, doc, isOwner, onAclChange }:
272
307
  <button
273
308
  type="button"
274
309
  onClick={() => void removeCollaborator(u.recordId)}
275
- disabled={saving}
310
+ disabled={!ready || saving}
276
311
  className="rounded-lg p-1.5 text-red-600 transition-colors hover:bg-red-500/10 disabled:opacity-50"
277
312
  title="Remove access"
278
313
  >