deepspace 0.33.1 → 0.34.0
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.
- package/CHANGELOG.md +35 -0
- package/README.md +5 -1
- package/dist/build.js.map +1 -1
- package/dist/chunk-KELQ7OD5.js +1957 -0
- package/dist/chunk-KELQ7OD5.js.map +1 -0
- package/dist/{chunk-PZDVKSLI.js → chunk-SINBB5BQ.js} +1 -1
- package/dist/{chunk-PZDVKSLI.js.map → chunk-SINBB5BQ.js.map} +1 -1
- package/dist/cli.js +74 -15
- package/dist/cli.js.map +1 -1
- package/dist/documentation-client-core.js +1 -1
- package/dist/documentation-default-renderer.cjs +1 -1
- package/dist/documentation-react.js +1 -1
- package/dist/documentation-runtime.js +10 -10
- package/dist/documentation-server-core.js +1 -1
- package/dist/documentation.js +47 -22
- package/dist/documentation.js.map +1 -1
- package/dist/expo.d.ts +73 -0
- package/dist/expo.js +233 -0
- package/dist/expo.js.map +1 -0
- package/dist/index.d.ts +105 -22
- package/dist/index.js +50 -23
- package/dist/index.js.map +1 -1
- package/dist/server.d.ts +205 -37
- package/dist/server.js +290 -97
- package/dist/server.js.map +1 -1
- package/dist/worker.d.ts +312 -48
- package/dist/worker.js +499 -110
- package/dist/worker.js.map +1 -1
- package/features/documents/feature.json +15 -0
- package/features/documents/src/DocumentsListPage.tsx +22 -8
- package/features/documents/src/InviteDialog.tsx +63 -42
- package/features/documents/src/__tests__/documents-find-invitee-action.test.ts +131 -0
- package/features/documents/src/__tests__/documents-invitee-lookup.test.ts +67 -0
- package/features/documents/src/documents-find-invitee-action.ts +56 -0
- package/features/documents/src/documents-invitee-lookup.ts +38 -0
- package/features/documents/src/documents-ui.css +10 -0
- package/package.json +28 -8
|
@@ -56,6 +56,14 @@
|
|
|
56
56
|
{
|
|
57
57
|
"src": "src/editor/DocumentsTiptapToolbar.tsx",
|
|
58
58
|
"dest": "src/pages/documents/editor/DocumentsTiptapToolbar.tsx"
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
"src": "src/documents-invitee-lookup.ts",
|
|
62
|
+
"dest": "src/pages/documents/documents-invitee-lookup.ts"
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
"src": "src/documents-find-invitee-action.ts",
|
|
66
|
+
"dest": "src/actions/documents-find-invitee.ts"
|
|
59
67
|
}
|
|
60
68
|
],
|
|
61
69
|
"schema": {
|
|
@@ -63,6 +71,13 @@
|
|
|
63
71
|
"importPath": "./schemas/documents-schema",
|
|
64
72
|
"spreadOperator": true
|
|
65
73
|
},
|
|
74
|
+
"actions": [
|
|
75
|
+
{
|
|
76
|
+
"exportName": "documentsFindInviteeAction",
|
|
77
|
+
"registerAs": "documents-find-invitee",
|
|
78
|
+
"importPath": "./documents-find-invitee"
|
|
79
|
+
}
|
|
80
|
+
],
|
|
66
81
|
"route": {
|
|
67
82
|
"path": "/documents",
|
|
68
83
|
"protected": true
|
|
@@ -10,6 +10,7 @@ import { useUser } from 'deepspace'
|
|
|
10
10
|
import { useQuery } from 'deepspace'
|
|
11
11
|
import { useMutations } from 'deepspace'
|
|
12
12
|
import { ROLES, type Role } from 'deepspace'
|
|
13
|
+
import { useToast } from '@/components/ui'
|
|
13
14
|
import {
|
|
14
15
|
FileText,
|
|
15
16
|
Plus,
|
|
@@ -81,6 +82,7 @@ function formatDate(iso: string): string {
|
|
|
81
82
|
export default function DocumentsListPage() {
|
|
82
83
|
const { user } = useUser()
|
|
83
84
|
const navigate = useNavigate()
|
|
85
|
+
const toast = useToast()
|
|
84
86
|
const userRole = (user?.role ?? ROLES.VIEWER) as Role
|
|
85
87
|
const roleCanCreate = userRole === ROLES.MEMBER || userRole === ROLES.ADMIN
|
|
86
88
|
|
|
@@ -233,15 +235,27 @@ export default function DocumentsListPage() {
|
|
|
233
235
|
|
|
234
236
|
const handleNewDocument = useCallback(async () => {
|
|
235
237
|
if (!user || !canCreate) return
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
238
|
+
// A confirmed write answers with an ack the provider-level onWriteError
|
|
239
|
+
// never sees, so a refusal or timeout has to be reported here — otherwise
|
|
240
|
+
// the click does nothing and the rejection is unhandled.
|
|
241
|
+
let id: string
|
|
242
|
+
try {
|
|
243
|
+
id = await createConfirmed({
|
|
244
|
+
title: UNTITLED,
|
|
245
|
+
ownerId: user.id,
|
|
246
|
+
collaborators: '[]',
|
|
247
|
+
editors: '[]',
|
|
248
|
+
folderId: folderIdForNew,
|
|
249
|
+
})
|
|
250
|
+
} catch (err) {
|
|
251
|
+
toast.error(
|
|
252
|
+
'Document not created',
|
|
253
|
+
err instanceof Error ? err.message : 'DeepSpace did not confirm the write.',
|
|
254
|
+
)
|
|
255
|
+
return
|
|
256
|
+
}
|
|
243
257
|
navigate(`/documents/${id}`)
|
|
244
|
-
}, [canCreate, createConfirmed, folderIdForNew, navigate, user])
|
|
258
|
+
}, [canCreate, createConfirmed, folderIdForNew, navigate, toast, user])
|
|
245
259
|
|
|
246
260
|
const handleDelete = useCallback(
|
|
247
261
|
async (docId: string) => {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useMemo, useState } from 'react'
|
|
2
2
|
import type { RecordData } from 'deepspace'
|
|
3
|
-
import { useMutations,
|
|
3
|
+
import { useMutations, useUser, useUserLookup } from 'deepspace'
|
|
4
4
|
import { Mail, ShieldCheck, UserMinus, Users } from 'lucide-react'
|
|
5
5
|
import { Modal, useToast } from '@/components/ui'
|
|
6
6
|
import {
|
|
@@ -8,10 +8,15 @@ import {
|
|
|
8
8
|
type DocumentsDocumentFields,
|
|
9
9
|
type InviteAclDiff,
|
|
10
10
|
} from './documents-library-types'
|
|
11
|
+
import { findDocumentsInvitee } from './documents-invitee-lookup'
|
|
12
|
+
import type { DocumentsInviteeLookup } from '@/actions/documents-find-invitee'
|
|
11
13
|
|
|
12
|
-
|
|
14
|
+
/** One row of "People with access", drawn from the roster every member receives. */
|
|
15
|
+
interface PersonRow {
|
|
16
|
+
id: string
|
|
17
|
+
name: string
|
|
18
|
+
/** Only admins receive emails in the roster. */
|
|
13
19
|
email?: string
|
|
14
|
-
name?: string
|
|
15
20
|
imageUrl?: string
|
|
16
21
|
}
|
|
17
22
|
|
|
@@ -46,14 +51,18 @@ function initialsFor(name: string): string {
|
|
|
46
51
|
|
|
47
52
|
export function InviteDialog({ open, onOpenChange, doc, isOwner, onAclChange }: InviteDialogProps) {
|
|
48
53
|
const { user } = useUser()
|
|
49
|
-
|
|
54
|
+
// The roster names everyone who has signed in to the app, projected to
|
|
55
|
+
// public identity for members. A plain `useQuery('users')` is self-only
|
|
56
|
+
// under the scaffold's `member: { read: 'own' }` policy, so it cannot name
|
|
57
|
+
// a collaborator — or resolve an email — for an ordinary owner.
|
|
58
|
+
const { getUser } = useUserLookup()
|
|
50
59
|
const { putConfirmed, ready: documentsMutationsReady } =
|
|
51
60
|
useMutations<DocumentsDocumentFields>('documents')
|
|
52
61
|
const toast = useToast()
|
|
53
62
|
const [email, setEmail] = useState('')
|
|
54
63
|
const [role, setRole] = useState<InviteRole>('editor')
|
|
55
64
|
const [saving, setSaving] = useState(false)
|
|
56
|
-
const ready =
|
|
65
|
+
const ready = documentsMutationsReady
|
|
57
66
|
|
|
58
67
|
const collaborators = useMemo(
|
|
59
68
|
() => parseDocumentsIdList(doc.data.collaborators),
|
|
@@ -61,17 +70,22 @@ export function InviteDialog({ open, onOpenChange, doc, isOwner, onAclChange }:
|
|
|
61
70
|
)
|
|
62
71
|
const editors = useMemo(() => parseDocumentsIdList(doc.data.editors), [doc.data.editors])
|
|
63
72
|
|
|
64
|
-
const ownerRecord = useMemo(
|
|
65
|
-
() => users.find((u) => u.recordId === doc.data.ownerId),
|
|
66
|
-
[doc.data.ownerId, users],
|
|
67
|
-
)
|
|
73
|
+
const ownerRecord = useMemo(() => getUser(doc.data.ownerId), [doc.data.ownerId, getUser])
|
|
68
74
|
|
|
69
|
-
|
|
75
|
+
// Every collaborator id stays listed even before the roster names it, so
|
|
76
|
+
// access can always be changed or removed.
|
|
77
|
+
const collaboratorRecords = useMemo<PersonRow[]>(
|
|
70
78
|
() =>
|
|
71
|
-
collaborators
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
79
|
+
collaborators.map((id) => {
|
|
80
|
+
const u = getUser(id)
|
|
81
|
+
return {
|
|
82
|
+
id,
|
|
83
|
+
name: u?.name?.trim() || 'Collaborator',
|
|
84
|
+
email: u?.email,
|
|
85
|
+
imageUrl: u?.imageUrl,
|
|
86
|
+
}
|
|
87
|
+
}),
|
|
88
|
+
[collaborators, getUser],
|
|
75
89
|
)
|
|
76
90
|
|
|
77
91
|
if (!isOwner) return null
|
|
@@ -122,30 +136,41 @@ export function InviteDialog({ open, onOpenChange, doc, isOwner, onAclChange }:
|
|
|
122
136
|
|
|
123
137
|
const addInvite = async () => {
|
|
124
138
|
const normalized = email.trim().toLowerCase()
|
|
125
|
-
if (!normalized) return
|
|
139
|
+
if (!normalized || !ready) return
|
|
126
140
|
|
|
127
|
-
|
|
128
|
-
|
|
141
|
+
// The email resolves server-side (owner-only action): members cannot read
|
|
142
|
+
// other users' rows, and the roster carries no emails for them.
|
|
143
|
+
let target: DocumentsInviteeLookup
|
|
144
|
+
setSaving(true)
|
|
145
|
+
try {
|
|
146
|
+
target = await findDocumentsInvitee(doc.recordId, normalized)
|
|
147
|
+
} catch (err) {
|
|
148
|
+
toast.error('Could not look up that email', err instanceof Error ? err.message : undefined)
|
|
149
|
+
return
|
|
150
|
+
} finally {
|
|
151
|
+
setSaving(false)
|
|
152
|
+
}
|
|
153
|
+
if (!target.found) {
|
|
129
154
|
toast.error(
|
|
130
155
|
'User not found',
|
|
131
156
|
'Ask them to sign in to this app once, then invite the same email again.',
|
|
132
157
|
)
|
|
133
158
|
return
|
|
134
159
|
}
|
|
135
|
-
if (target.
|
|
160
|
+
if (target.userId === doc.data.ownerId || target.userId === user?.id) {
|
|
136
161
|
toast.info('Already has access', 'That user is the document owner.')
|
|
137
162
|
return
|
|
138
163
|
}
|
|
139
|
-
if (collaborators.includes(target.
|
|
140
|
-
toast.info('Already invited', `${
|
|
164
|
+
if (collaborators.includes(target.userId)) {
|
|
165
|
+
toast.info('Already invited', `${normalized} already has access.`)
|
|
141
166
|
return
|
|
142
167
|
}
|
|
143
168
|
|
|
144
|
-
const nextCollaborators = [...collaborators, target.
|
|
145
|
-
const nextEditors = role === 'editor' ? [...editors, target.
|
|
169
|
+
const nextCollaborators = [...collaborators, target.userId]
|
|
170
|
+
const nextEditors = role === 'editor' ? [...editors, target.userId] : editors
|
|
146
171
|
if (!(await saveAccess(nextCollaborators, nextEditors))) return
|
|
147
172
|
setEmail('')
|
|
148
|
-
toast.success('Invite added', `${
|
|
173
|
+
toast.success('Invite added', `${normalized} now has ${role} access.`)
|
|
149
174
|
}
|
|
150
175
|
|
|
151
176
|
const setCollaboratorRole = async (userId: string, nextRole: InviteRole) => {
|
|
@@ -163,12 +188,7 @@ export function InviteDialog({ open, onOpenChange, doc, isOwner, onAclChange }:
|
|
|
163
188
|
)
|
|
164
189
|
}
|
|
165
190
|
|
|
166
|
-
const ownerName =
|
|
167
|
-
ownerRecord?.data.name?.trim() ||
|
|
168
|
-
ownerRecord?.data.email?.trim() ||
|
|
169
|
-
user?.name ||
|
|
170
|
-
user?.email ||
|
|
171
|
-
'Owner'
|
|
191
|
+
const ownerName = ownerRecord?.name?.trim() || user?.name || user?.email || 'Owner'
|
|
172
192
|
|
|
173
193
|
return (
|
|
174
194
|
<Modal
|
|
@@ -216,6 +236,10 @@ export function InviteDialog({ open, onOpenChange, doc, isOwner, onAclChange }:
|
|
|
216
236
|
<select
|
|
217
237
|
value={role}
|
|
218
238
|
onChange={(e) => setRole(e.target.value as InviteRole)}
|
|
239
|
+
// addInvite grants the role captured when Add was pressed; lock
|
|
240
|
+
// the select while the lookup and save run so the UI cannot show
|
|
241
|
+
// one role and grant another.
|
|
242
|
+
disabled={!ready || saving}
|
|
219
243
|
className="h-10 rounded-lg border bg-transparent px-3 text-sm font-medium outline-none focus-visible:ring-2 focus-visible:ring-ring/30"
|
|
220
244
|
style={{ borderColor: 'var(--documents-el-line)', color: 'var(--documents-el-text)' }}
|
|
221
245
|
>
|
|
@@ -251,7 +275,7 @@ export function InviteDialog({ open, onOpenChange, doc, isOwner, onAclChange }:
|
|
|
251
275
|
<div className="min-w-0 flex-1">
|
|
252
276
|
<div className="truncate text-sm font-medium">{ownerName}</div>
|
|
253
277
|
<div className="truncate text-xs" style={{ color: 'var(--documents-el-muted)' }}>
|
|
254
|
-
{ownerRecord?.
|
|
278
|
+
{ownerRecord?.email ?? user?.email ?? 'Owner'}
|
|
255
279
|
</div>
|
|
256
280
|
</div>
|
|
257
281
|
<span className="inline-flex items-center gap-1 rounded-full border px-2.5 py-1 text-xs font-medium">
|
|
@@ -261,39 +285,36 @@ export function InviteDialog({ open, onOpenChange, doc, isOwner, onAclChange }:
|
|
|
261
285
|
</div>
|
|
262
286
|
|
|
263
287
|
{collaboratorRecords.map((u) => {
|
|
264
|
-
const
|
|
265
|
-
const userRole: InviteRole = editors.includes(u.recordId) ? 'editor' : 'viewer'
|
|
288
|
+
const userRole: InviteRole = editors.includes(u.id) ? 'editor' : 'viewer'
|
|
266
289
|
return (
|
|
267
290
|
<div
|
|
268
|
-
key={u.
|
|
291
|
+
key={u.id}
|
|
269
292
|
className="flex items-center gap-3 rounded-xl border p-3"
|
|
270
293
|
style={{ borderColor: 'var(--documents-el-line)' }}
|
|
271
294
|
>
|
|
272
|
-
{u.
|
|
295
|
+
{u.imageUrl ? (
|
|
273
296
|
<img
|
|
274
|
-
src={u.
|
|
297
|
+
src={u.imageUrl}
|
|
275
298
|
alt=""
|
|
276
299
|
className="h-9 w-9 shrink-0 rounded-full object-cover"
|
|
277
300
|
/>
|
|
278
301
|
) : (
|
|
279
302
|
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-black/5 text-xs font-bold dark:bg-white/10">
|
|
280
|
-
{initialsFor(name)}
|
|
303
|
+
{initialsFor(u.name)}
|
|
281
304
|
</div>
|
|
282
305
|
)}
|
|
283
306
|
<div className="min-w-0 flex-1">
|
|
284
|
-
<div className="truncate text-sm font-medium">{name}</div>
|
|
307
|
+
<div className="truncate text-sm font-medium">{u.name}</div>
|
|
285
308
|
<div
|
|
286
309
|
className="truncate text-xs"
|
|
287
310
|
style={{ color: 'var(--documents-el-muted)' }}
|
|
288
311
|
>
|
|
289
|
-
{u.
|
|
312
|
+
{u.email ?? (userRole === 'editor' ? 'Can edit' : 'Can view')}
|
|
290
313
|
</div>
|
|
291
314
|
</div>
|
|
292
315
|
<select
|
|
293
316
|
value={userRole}
|
|
294
|
-
onChange={(e) =>
|
|
295
|
-
void setCollaboratorRole(u.recordId, e.target.value as InviteRole)
|
|
296
|
-
}
|
|
317
|
+
onChange={(e) => void setCollaboratorRole(u.id, e.target.value as InviteRole)}
|
|
297
318
|
disabled={!ready || saving}
|
|
298
319
|
className="h-8 rounded-lg border bg-transparent px-2 text-xs font-medium outline-none"
|
|
299
320
|
style={{
|
|
@@ -306,7 +327,7 @@ export function InviteDialog({ open, onOpenChange, doc, isOwner, onAclChange }:
|
|
|
306
327
|
</select>
|
|
307
328
|
<button
|
|
308
329
|
type="button"
|
|
309
|
-
onClick={() => void removeCollaborator(u.
|
|
330
|
+
onClick={() => void removeCollaborator(u.id)}
|
|
310
331
|
disabled={!ready || saving}
|
|
311
332
|
className="rounded-lg p-1.5 text-red-600 transition-colors hover:bg-red-500/10 disabled:opacity-50"
|
|
312
333
|
title="Remove access"
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import type { ActionContext, ActionTools } from 'deepspace/worker'
|
|
3
|
+
import { documentsFindInviteeAction } from '../documents-find-invitee-action'
|
|
4
|
+
|
|
5
|
+
type Doc = { ownerId: string }
|
|
6
|
+
type UserRow = { email: string; name: string }
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The action's tools run RBAC-off, so the fake answers whatever the handler
|
|
10
|
+
* asks for; the assertions are about what the handler asks for and returns.
|
|
11
|
+
*/
|
|
12
|
+
function fakeTools(opts: {
|
|
13
|
+
document?: Doc | null
|
|
14
|
+
users?: Array<{ recordId: string; data: UserRow }>
|
|
15
|
+
usersQueryFails?: boolean
|
|
16
|
+
}): ActionTools & { query: ReturnType<typeof vi.fn> } {
|
|
17
|
+
const get = vi.fn(async () =>
|
|
18
|
+
opts.document
|
|
19
|
+
? {
|
|
20
|
+
success: true as const,
|
|
21
|
+
data: {
|
|
22
|
+
record: {
|
|
23
|
+
recordId: 'doc-1',
|
|
24
|
+
data: opts.document,
|
|
25
|
+
createdBy: opts.document.ownerId,
|
|
26
|
+
createdAt: 't',
|
|
27
|
+
updatedAt: 't',
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
}
|
|
31
|
+
: { success: false as const, error: 'Record not found' },
|
|
32
|
+
)
|
|
33
|
+
const query = vi.fn(async () =>
|
|
34
|
+
opts.usersQueryFails
|
|
35
|
+
? { success: false as const, error: 'boom' }
|
|
36
|
+
: {
|
|
37
|
+
success: true as const,
|
|
38
|
+
data: {
|
|
39
|
+
records: (opts.users ?? []).map((u) => ({
|
|
40
|
+
...u,
|
|
41
|
+
createdBy: u.recordId,
|
|
42
|
+
createdAt: 't',
|
|
43
|
+
updatedAt: 't',
|
|
44
|
+
})),
|
|
45
|
+
count: (opts.users ?? []).length,
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
)
|
|
49
|
+
return { get, query } as unknown as ActionTools & { query: ReturnType<typeof vi.fn> }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function ctx(userId: string, params: Record<string, unknown>, tools: ActionTools): ActionContext {
|
|
53
|
+
return { userId, params, tools, env: {}, callerJwt: 'jwt' }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
describe('documentsFindInviteeAction', () => {
|
|
57
|
+
it('refuses a call without a document id or an email', async () => {
|
|
58
|
+
const tools = fakeTools({ document: { ownerId: 'owner' } })
|
|
59
|
+
expect(await documentsFindInviteeAction(ctx('owner', { email: 'b@x.test' }, tools))).toEqual({
|
|
60
|
+
success: false,
|
|
61
|
+
error: 'docId and email are required',
|
|
62
|
+
})
|
|
63
|
+
expect(await documentsFindInviteeAction(ctx('owner', { docId: 'doc-1' }, tools))).toEqual({
|
|
64
|
+
success: false,
|
|
65
|
+
error: 'docId and email are required',
|
|
66
|
+
})
|
|
67
|
+
expect(tools.query).not.toHaveBeenCalled()
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('refuses an unknown document with the same answer as a foreign one (no existence oracle)', async () => {
|
|
71
|
+
const tools = fakeTools({ document: null })
|
|
72
|
+
const result = await documentsFindInviteeAction(
|
|
73
|
+
ctx('owner', { docId: 'missing', email: 'b@x.test' }, tools),
|
|
74
|
+
)
|
|
75
|
+
expect(result).toEqual({ success: false, error: 'Only the document owner can share it' })
|
|
76
|
+
expect(tools.query).not.toHaveBeenCalled()
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('refuses a caller who is not the document owner without touching the users table', async () => {
|
|
80
|
+
const tools = fakeTools({
|
|
81
|
+
document: { ownerId: 'owner' },
|
|
82
|
+
users: [{ recordId: 'u-b', data: { email: 'b@x.test', name: 'B' } }],
|
|
83
|
+
})
|
|
84
|
+
const result = await documentsFindInviteeAction(
|
|
85
|
+
ctx('editor', { docId: 'doc-1', email: 'b@x.test' }, tools),
|
|
86
|
+
)
|
|
87
|
+
expect(result).toEqual({ success: false, error: 'Only the document owner can share it' })
|
|
88
|
+
expect(tools.query).not.toHaveBeenCalled()
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('resolves an exact email to the public user id for the document owner', async () => {
|
|
92
|
+
const tools = fakeTools({
|
|
93
|
+
document: { ownerId: 'owner' },
|
|
94
|
+
users: [{ recordId: 'u-b', data: { email: 'b@x.test', name: 'B' } }],
|
|
95
|
+
})
|
|
96
|
+
const result = await documentsFindInviteeAction(
|
|
97
|
+
ctx('owner', { docId: 'doc-1', email: 'b@x.test' }, tools),
|
|
98
|
+
)
|
|
99
|
+
// Exactly the id: no email, name, or other column leaves the action.
|
|
100
|
+
expect(result).toEqual({ success: true, data: { found: true, userId: 'u-b' } })
|
|
101
|
+
expect(tools.query).toHaveBeenCalledTimes(1)
|
|
102
|
+
expect(tools.query).toHaveBeenCalledWith('users', { where: { email: 'b@x.test' }, limit: 1 })
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('normalizes the email the way the auth plane stores it before matching', async () => {
|
|
106
|
+
const tools = fakeTools({
|
|
107
|
+
document: { ownerId: 'owner' },
|
|
108
|
+
users: [{ recordId: 'u-b', data: { email: 'b@x.test', name: 'B' } }],
|
|
109
|
+
})
|
|
110
|
+
await documentsFindInviteeAction(
|
|
111
|
+
ctx('owner', { docId: 'doc-1', email: ' B@X.Test \n' }, tools),
|
|
112
|
+
)
|
|
113
|
+
expect(tools.query).toHaveBeenCalledWith('users', { where: { email: 'b@x.test' }, limit: 1 })
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('reports found: false when nobody with that email has signed in', async () => {
|
|
117
|
+
const tools = fakeTools({ document: { ownerId: 'owner' }, users: [] })
|
|
118
|
+
const result = await documentsFindInviteeAction(
|
|
119
|
+
ctx('owner', { docId: 'doc-1', email: 'nobody@x.test' }, tools),
|
|
120
|
+
)
|
|
121
|
+
expect(result).toEqual({ success: true, data: { found: false } })
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('surfaces a failed users query instead of reporting not found', async () => {
|
|
125
|
+
const tools = fakeTools({ document: { ownerId: 'owner' }, usersQueryFails: true })
|
|
126
|
+
const result = await documentsFindInviteeAction(
|
|
127
|
+
ctx('owner', { docId: 'doc-1', email: 'b@x.test' }, tools),
|
|
128
|
+
)
|
|
129
|
+
expect(result).toEqual({ success: false, error: 'User lookup failed' })
|
|
130
|
+
})
|
|
131
|
+
})
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
|
|
3
|
+
// The lookup signs its request with the app session's bearer token; the
|
|
4
|
+
// dialog imports it from the public `deepspace` entry, so mock that boundary.
|
|
5
|
+
vi.mock('deepspace', () => ({ getAuthToken: async () => 'jwt-123' }))
|
|
6
|
+
|
|
7
|
+
import { findDocumentsInvitee } from '../documents-invitee-lookup'
|
|
8
|
+
|
|
9
|
+
function jsonResponse(status: number, body: unknown): Response {
|
|
10
|
+
return new Response(JSON.stringify(body), {
|
|
11
|
+
status,
|
|
12
|
+
headers: { 'Content-Type': 'application/json' },
|
|
13
|
+
})
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe('findDocumentsInvitee', () => {
|
|
17
|
+
const fetchMock = vi.fn<typeof fetch>()
|
|
18
|
+
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
fetchMock.mockReset()
|
|
21
|
+
vi.stubGlobal('fetch', fetchMock)
|
|
22
|
+
})
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
vi.unstubAllGlobals()
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('posts the document id and email to the feature action with the session bearer', async () => {
|
|
28
|
+
fetchMock.mockResolvedValue(
|
|
29
|
+
jsonResponse(200, { success: true, data: { found: true, userId: 'u-b' } }),
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
const result = await findDocumentsInvitee('doc-1', 'b@x.test')
|
|
33
|
+
|
|
34
|
+
expect(result).toEqual({ found: true, userId: 'u-b' })
|
|
35
|
+
expect(fetchMock).toHaveBeenCalledTimes(1)
|
|
36
|
+
const [url, init] = fetchMock.mock.calls[0]
|
|
37
|
+
expect(url).toBe('/api/actions/documents-find-invitee')
|
|
38
|
+
expect(init?.method).toBe('POST')
|
|
39
|
+
expect(new Headers(init?.headers).get('Authorization')).toBe('Bearer jwt-123')
|
|
40
|
+
expect(new Headers(init?.headers).get('Content-Type')).toBe('application/json')
|
|
41
|
+
expect(JSON.parse(String(init?.body))).toEqual({ docId: 'doc-1', email: 'b@x.test' })
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('passes a not-found answer through unchanged', async () => {
|
|
45
|
+
fetchMock.mockResolvedValue(jsonResponse(200, { success: true, data: { found: false } }))
|
|
46
|
+
expect(await findDocumentsInvitee('doc-1', 'nobody@x.test')).toEqual({ found: false })
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it("throws the action's own refusal text so the dialog can show it", async () => {
|
|
50
|
+
fetchMock.mockResolvedValue(
|
|
51
|
+
jsonResponse(200, { success: false, error: 'Only the document owner can share it' }),
|
|
52
|
+
)
|
|
53
|
+
await expect(findDocumentsInvitee('doc-1', 'b@x.test')).rejects.toThrow(
|
|
54
|
+
'Only the document owner can share it',
|
|
55
|
+
)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('throws a status-bearing error when the route itself fails', async () => {
|
|
59
|
+
fetchMock.mockResolvedValue(new Response('<html>', { status: 502 }))
|
|
60
|
+
await expect(findDocumentsInvitee('doc-1', 'b@x.test')).rejects.toThrow('Lookup failed (502)')
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('throws when the action route is missing (feature installed without its action)', async () => {
|
|
64
|
+
fetchMock.mockResolvedValue(jsonResponse(404, { error: 'Action not found' }))
|
|
65
|
+
await expect(findDocumentsInvitee('doc-1', 'b@x.test')).rejects.toThrow('Action not found')
|
|
66
|
+
})
|
|
67
|
+
})
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Documents feature — owner-only invitee lookup.
|
|
3
|
+
*
|
|
4
|
+
* The Share dialog resolves an email to a user id. The client cannot do that
|
|
5
|
+
* itself: the scaffold's users schema is `member: { read: 'own' }`, so an
|
|
6
|
+
* ordinary member's `useQuery('users')` holds only their own row, and the
|
|
7
|
+
* `useUsers()` roster is projected to public identity (no email). Only the
|
|
8
|
+
* app owner (admin) reads full rows.
|
|
9
|
+
*
|
|
10
|
+
* Tools run RBAC-off, so this action is the authorization boundary: only the
|
|
11
|
+
* owner of `docId` may ask, only by exact email, and the answer is the public
|
|
12
|
+
* id alone — the same id every signed-in member already sees in the roster.
|
|
13
|
+
*
|
|
14
|
+
* What it deliberately adds: any member who owns a document (members can
|
|
15
|
+
* create one) can learn whether an exact address has signed in to this app.
|
|
16
|
+
* That is the price of invite-by-email; there is no list surface and no rate
|
|
17
|
+
* limit here. Being RBAC-off, the query also ignores a users schema's
|
|
18
|
+
* `roster: 'read-policy'` partitioning — an app that must not resolve
|
|
19
|
+
* addresses across tenants should remove or adapt this action.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { ActionHandler } from 'deepspace/worker'
|
|
23
|
+
|
|
24
|
+
export type DocumentsInviteeLookup = { found: false } | { found: true; userId: string }
|
|
25
|
+
|
|
26
|
+
type DocumentRow = { ownerId?: string }
|
|
27
|
+
type UserRow = { email?: string }
|
|
28
|
+
|
|
29
|
+
// `<unknown>`: the lookup never reads `env`, so it registers under any app's
|
|
30
|
+
// `Record<string, ActionHandler<Env>>` without naming that app's bindings.
|
|
31
|
+
export const documentsFindInviteeAction: ActionHandler<unknown> = async ({
|
|
32
|
+
userId,
|
|
33
|
+
params,
|
|
34
|
+
tools,
|
|
35
|
+
}) => {
|
|
36
|
+
const docId = typeof params.docId === 'string' ? params.docId : ''
|
|
37
|
+
// The room stores emails lowercased (registerUser), so exact equality on the
|
|
38
|
+
// normalized input is the whole match.
|
|
39
|
+
const email = typeof params.email === 'string' ? params.email.trim().toLowerCase() : ''
|
|
40
|
+
if (!docId || !email) return { success: false, error: 'docId and email are required' }
|
|
41
|
+
|
|
42
|
+
// One refusal for "no such document" and "not yours": a distinct not-found
|
|
43
|
+
// answer would let any member probe which document ids exist.
|
|
44
|
+
const doc = await tools.get<DocumentRow>('documents', docId)
|
|
45
|
+
if (!doc.success || doc.data.record.data.ownerId !== userId) {
|
|
46
|
+
return { success: false, error: 'Only the document owner can share it' }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const users = await tools.query<UserRow>('users', { where: { email }, limit: 1 })
|
|
50
|
+
if (!users.success) return { success: false, error: 'User lookup failed' }
|
|
51
|
+
const match = users.data.records[0]
|
|
52
|
+
const data: DocumentsInviteeLookup = match
|
|
53
|
+
? { found: true, userId: match.recordId }
|
|
54
|
+
: { found: false }
|
|
55
|
+
return { success: true, data }
|
|
56
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client half of the Share dialog's invitee lookup: calls the feature's
|
|
3
|
+
* owner-only server action (`src/actions/documents-find-invitee.ts`) with the
|
|
4
|
+
* app session's bearer token and hands back its answer.
|
|
5
|
+
*
|
|
6
|
+
* Kept apart from the dialog so the wire contract is testable without
|
|
7
|
+
* rendering the modal.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { getAuthToken } from 'deepspace'
|
|
11
|
+
import type { ActionResult } from 'deepspace/worker'
|
|
12
|
+
import type { DocumentsInviteeLookup } from '@/actions/documents-find-invitee'
|
|
13
|
+
|
|
14
|
+
const ACTION_PATH = '/api/actions/documents-find-invitee'
|
|
15
|
+
|
|
16
|
+
/** The action's own result envelope, or the route's `{ error }` for 401/404. */
|
|
17
|
+
type ActionEnvelope = ActionResult<DocumentsInviteeLookup> | { error?: string }
|
|
18
|
+
|
|
19
|
+
/** Throws with the action's own refusal text, or a status-bearing message. */
|
|
20
|
+
export async function findDocumentsInvitee(
|
|
21
|
+
docId: string,
|
|
22
|
+
email: string,
|
|
23
|
+
): Promise<DocumentsInviteeLookup> {
|
|
24
|
+
const token = await getAuthToken()
|
|
25
|
+
const res = await fetch(ACTION_PATH, {
|
|
26
|
+
method: 'POST',
|
|
27
|
+
headers: {
|
|
28
|
+
'Content-Type': 'application/json',
|
|
29
|
+
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
30
|
+
},
|
|
31
|
+
body: JSON.stringify({ docId, email }),
|
|
32
|
+
})
|
|
33
|
+
const body = (await res.json().catch(() => null)) as ActionEnvelope | null
|
|
34
|
+
if (!res.ok || !body || !('success' in body) || !body.success) {
|
|
35
|
+
throw new Error((body && 'error' in body && body.error) || `Lookup failed (${res.status})`)
|
|
36
|
+
}
|
|
37
|
+
return body.data
|
|
38
|
+
}
|
|
@@ -178,12 +178,22 @@
|
|
|
178
178
|
color: var(--documents-editor-paper-muted);
|
|
179
179
|
}
|
|
180
180
|
|
|
181
|
+
/* Tailwind's preflight resets `ul, ol { list-style: none }`; the editor's
|
|
182
|
+
lists need their markers back. The task-list rule below opts back out. */
|
|
181
183
|
.documents-paged-editor .ProseMirror ul,
|
|
182
184
|
.documents-paged-editor .ProseMirror ol {
|
|
183
185
|
padding-left: 1.4em;
|
|
184
186
|
margin: 0 0 0.45em;
|
|
185
187
|
}
|
|
186
188
|
|
|
189
|
+
.documents-paged-editor .ProseMirror ul {
|
|
190
|
+
list-style-type: disc;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
.documents-paged-editor .ProseMirror ol {
|
|
194
|
+
list-style-type: decimal;
|
|
195
|
+
}
|
|
196
|
+
|
|
187
197
|
.documents-paged-editor .ProseMirror ul[data-type='taskList'] {
|
|
188
198
|
list-style: none;
|
|
189
199
|
padding: 0;
|