@fiduswriter/editor 0.1.74 → 0.1.76

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.
@@ -67,6 +67,7 @@ interface AccessRightsTabOptions {
67
67
  container?: HTMLElement | null
68
68
  documentApi: EditorDocumentApi
69
69
  contactsApi: EditorContactsApi
70
+ isOwner?: boolean
70
71
  }
71
72
 
72
73
  interface AccessRightMenuItem {
@@ -149,6 +150,7 @@ export class AccessRightsTab {
149
150
  dialogTabs!: DialogTabs
150
151
  documentApi: EditorDocumentApi
151
152
  contactsApi: EditorContactsApi
153
+ isOwner: boolean
152
154
 
153
155
  constructor({
154
156
  documentIds,
@@ -160,7 +162,8 @@ export class AccessRightsTab {
160
162
  settings,
161
163
  container = null,
162
164
  documentApi,
163
- contactsApi
165
+ contactsApi,
166
+ isOwner = false
164
167
  }: AccessRightsTabOptions) {
165
168
  this.documentIds = documentIds
166
169
  this.contacts = contacts
@@ -172,6 +175,7 @@ export class AccessRightsTab {
172
175
  this.onShareSuccess = onShareSuccess
173
176
  this.settings = settings
174
177
  this.container = container || null
178
+ this.isOwner = isOwner
175
179
  this.accessRights = []
176
180
  this.documentApi = documentApi
177
181
  this.contactsApi = contactsApi
@@ -205,31 +209,36 @@ export class AccessRightsTab {
205
209
  </div>`
206
210
  : ""
207
211
 
208
- this.dialogTabs = new DialogTabs(
209
- [
210
- {
211
- id: "people",
212
- title: gettext("People"),
213
- template: () =>
214
- peopleTabTemplate({
215
- contacts: this.contacts,
216
- collaborators
217
- })
218
- },
219
- {
220
- id: "sharelink",
221
- title: gettext("Share link"),
222
- template: () => shareLinkTabTemplate()
223
- }
224
- ],
212
+ const tabs = [
225
213
  {
226
- onShow: index => {
227
- if (index === 1 && this.singleDocumentId) {
228
- this.loadShareTokens()
229
- }
214
+ id: "people",
215
+ title: gettext("People"),
216
+ template: () =>
217
+ peopleTabTemplate({
218
+ contacts: this.contacts,
219
+ collaborators
220
+ })
221
+ }
222
+ ]
223
+ if (this.isOwner) {
224
+ tabs.push({
225
+ id: "sharelink",
226
+ title: gettext("Share link"),
227
+ template: () => shareLinkTabTemplate()
228
+ })
229
+ }
230
+
231
+ this.dialogTabs = new DialogTabs(tabs, {
232
+ onShow: index => {
233
+ if (
234
+ this.isOwner &&
235
+ index === 1 &&
236
+ this.singleDocumentId
237
+ ) {
238
+ this.loadShareTokens()
230
239
  }
231
240
  }
232
- )
241
+ })
233
242
 
234
243
  const html = e2eeWarningBanner + this.dialogTabs.render()
235
244
 
@@ -686,6 +695,7 @@ export class DocumentAccessRightsDialog {
686
695
  documentPassword: string
687
696
  onShareSuccess?: (accessRights: AccessRight[]) => void
688
697
  settings: Record<string, unknown>
698
+ isOwner: boolean
689
699
  contactsApi: EditorContactsApi
690
700
  documentApi: EditorDocumentApi
691
701
  tab!: AccessRightsTab
@@ -699,6 +709,7 @@ export class DocumentAccessRightsDialog {
699
709
  documentPassword = "",
700
710
  onShareSuccess?: (accessRights: AccessRight[]) => void,
701
711
  settings: Record<string, unknown> = {},
712
+ isOwner = false,
702
713
  contactsApi?: EditorContactsApi,
703
714
  documentApi?: EditorDocumentApi
704
715
  ) {
@@ -709,6 +720,7 @@ export class DocumentAccessRightsDialog {
709
720
  this.documentPassword = documentPassword
710
721
  this.onShareSuccess = onShareSuccess
711
722
  this.settings = settings
723
+ this.isOwner = isOwner
712
724
  this.contactsApi = contactsApi as EditorContactsApi
713
725
  this.documentApi = documentApi as EditorDocumentApi
714
726
  }
@@ -723,7 +735,8 @@ export class DocumentAccessRightsDialog {
723
735
  onShareSuccess: this.onShareSuccess,
724
736
  settings: this.settings,
725
737
  documentApi: this.documentApi,
726
- contactsApi: this.contactsApi
738
+ contactsApi: this.contactsApi,
739
+ isOwner: this.isOwner
727
740
  })
728
741
  this.tab.load().then(() => this.createDialog())
729
742
  }
@@ -821,8 +834,8 @@ export class DocumentAccessRightsDialog {
821
834
  // Hide the share-link tab when multiple documents are selected
822
835
  if (!this.tab.singleDocumentId) {
823
836
  const shareTab = this.dialog.dialogEl.querySelector(
824
- ".fw-tabs-nav .fw-tab-link:last-child"
825
- )
837
+ ".fw-tabs-nav .fw-tab-link a[href='#sharelink']"
838
+ )?.parentNode
826
839
  if (shareTab) {
827
840
  ;(shareTab as HTMLElement).style.display = "none"
828
841
  }
@@ -0,0 +1,151 @@
1
+ import {Dialog, addAlert} from "fwtoolkit"
2
+
3
+ import type {EditorDocumentApi} from "../../types.js"
4
+
5
+ interface RequestableRight {
6
+ value: string
7
+ label: string
8
+ }
9
+
10
+ const REQUESTABLE_RIGHTS: RequestableRight[] = [
11
+ {value: "read", label: gettext("Read")},
12
+ {value: "comment", label: gettext("Comment")},
13
+ {value: "review", label: gettext("Review")},
14
+ {value: "write-tracked", label: gettext("Write tracked")},
15
+ {value: "write", label: gettext("Write")}
16
+ ]
17
+
18
+ const RIGHT_ORDER = REQUESTABLE_RIGHTS.map(right => right.value)
19
+
20
+ /**
21
+ * Dialog for requesting access to a document.
22
+ *
23
+ * For users who already have some access, only rights that are higher than
24
+ * the current level are offered. Token users and users without any known
25
+ * access can choose from all requestable rights.
26
+ */
27
+ export class RequestAccessDialog {
28
+ documentId: number
29
+ currentRights: string
30
+ documentApi: EditorDocumentApi
31
+ isTokenUser: boolean
32
+
33
+ constructor(
34
+ documentId: number,
35
+ currentRights = "",
36
+ documentApi: EditorDocumentApi,
37
+ isTokenUser = false
38
+ ) {
39
+ this.documentId = documentId
40
+ this.currentRights = currentRights
41
+ this.documentApi = documentApi
42
+ this.isTokenUser = isTokenUser
43
+ }
44
+
45
+ open(): void {
46
+ const rightsOptions = this.getRightsOptions()
47
+ if (!rightsOptions.length) {
48
+ addAlert(
49
+ "error",
50
+ gettext("You already have the highest possible access level.")
51
+ )
52
+ return
53
+ }
54
+ const defaultRights =
55
+ rightsOptions.find(right => right.value === "write")?.value ||
56
+ rightsOptions[rightsOptions.length - 1].value
57
+ const bodyText = this.currentRights
58
+ ? gettext(
59
+ "Select a higher access level to request from the document owner."
60
+ )
61
+ : gettext(
62
+ "You do not have access to this document. Select the access level you would like to request from the document owner."
63
+ )
64
+ const dialog = new Dialog({
65
+ title: gettext("Request Access"),
66
+ id: "request-access-dialog",
67
+ width: 500,
68
+ body: `<p>${bodyText}</p>
69
+ <table class="fw-dialog-table">
70
+ <tbody>
71
+ <tr>
72
+ <th><label for="request-access-rights">${gettext(
73
+ "Access level"
74
+ )}</label></th>
75
+ <td class="entry-field">
76
+ <select id="request-access-rights" class="fw-button fw-light fw-large">
77
+ ${rightsOptions
78
+ .map(
79
+ right =>
80
+ `<option value="${right.value}"${right.value === defaultRights ? " selected" : ""}>${right.label}</option>`
81
+ )
82
+ .join("")}
83
+ </select>
84
+ <div class="fw-select-arrow fa-solid fa-caret-down"></div>
85
+ </td>
86
+ </tr>
87
+ </tbody>
88
+ </table>`,
89
+ buttons: [
90
+ {
91
+ text: gettext("Request"),
92
+ classes: "fw-dark",
93
+ click: () => {
94
+ const rights = (
95
+ dialog.dialogEl.querySelector(
96
+ "#request-access-rights"
97
+ ) as HTMLSelectElement
98
+ ).value
99
+ this.documentApi
100
+ .requestAccess({
101
+ document_id: this.documentId,
102
+ rights
103
+ })
104
+ .then(({json}: {json: unknown}) => {
105
+ const data = json as {
106
+ success?: boolean
107
+ error?: string
108
+ }
109
+ if (data.success) {
110
+ addAlert(
111
+ "success",
112
+ gettext(
113
+ "Your access request has been sent to the document owner."
114
+ )
115
+ )
116
+ dialog.close()
117
+ } else {
118
+ addAlert(
119
+ "error",
120
+ data.error ||
121
+ gettext(
122
+ "Could not send access request."
123
+ )
124
+ )
125
+ }
126
+ })
127
+ .catch(() => {
128
+ addAlert(
129
+ "error",
130
+ gettext("Could not send access request.")
131
+ )
132
+ })
133
+ }
134
+ },
135
+ {type: "cancel"}
136
+ ]
137
+ })
138
+ dialog.open()
139
+ }
140
+
141
+ getRightsOptions(): RequestableRight[] {
142
+ if (this.isTokenUser || !this.currentRights) {
143
+ return REQUESTABLE_RIGHTS
144
+ }
145
+ const currentIndex = RIGHT_ORDER.indexOf(this.currentRights)
146
+ if (currentIndex < 0) {
147
+ return REQUESTABLE_RIGHTS
148
+ }
149
+ return REQUESTABLE_RIGHTS.filter((_, index) => index > currentIndex)
150
+ }
151
+ }
package/src/index.ts CHANGED
@@ -41,6 +41,7 @@ import {ModCollab} from "./collab/index.js"
41
41
  import {ModComments} from "./comments/index.js"
42
42
  import {ModDB} from "./databases/index.js"
43
43
  import {ModDocumentTemplate} from "./document_template/index.js"
44
+ import {RequestAccessDialog} from "./documents/access_rights/request_access_dialog.js"
44
45
  import {E2EESnapshotManager} from "./e2ee/snapshot-manager.js"
45
46
  import {ExportFidusFile} from "./exporter/native/file.js"
46
47
  import {ModFootnotes} from "./footnotes/index.js"
@@ -374,47 +375,68 @@ export class Editor {
374
375
  token: this.docInfo.token
375
376
  })
376
377
  : Promise.resolve({json: {ws_base: ""}})
377
- const stylesPromise = documentApi.getDocumentStyles(
378
- stylesPayload as {id: number; token?: string}
379
- )
380
- const docDataPromise = documentApi.getDocumentData(
381
- stylesPayload as {id: number; token?: string}
382
- )
378
+ const stylesPromise = documentApi
379
+ .getDocumentStyles(
380
+ stylesPayload as {id: number; token?: string}
381
+ )
382
+ .catch(() => {
383
+ return {json: {}}
384
+ })
385
+ const docDataPromise = documentApi
386
+ .getDocumentData(
387
+ stylesPayload as {id: number; token?: string}
388
+ )
389
+ .catch(error => {
390
+ // Only show "Invalid Share Link" for token validation errors.
391
+ // For authenticated users without access, offer to request access.
392
+ // This is also shown when the document does not exist so that we
393
+ // do not reveal whether a document with the given ID exists.
394
+ if (error.message === "Invalid or expired share link") {
395
+ deactivateWait()
396
+ const errorDialog = new Dialog({
397
+ title: gettext("Invalid Share Link"),
398
+ id: "invalid_share_link_dialog",
399
+ body: gettext(
400
+ "This share link has expired or is invalid. Please ask the document owner for a new link."
401
+ ),
402
+ buttons: [
403
+ {
404
+ text: gettext("OK"),
405
+ classes: "fw-dark",
406
+ click: () => {
407
+ window.location.href = "/"
408
+ }
409
+ }
410
+ ],
411
+ canClose: false
412
+ })
413
+ errorDialog.open()
414
+ return Promise.reject(false)
415
+ } else if (
416
+ error.status === 401 &&
417
+ this.user.is_authenticated
418
+ ) {
419
+ deactivateWait()
420
+ const requestAccessDialog = new RequestAccessDialog(
421
+ this.docInfo.id as number,
422
+ "",
423
+ documentApi,
424
+ Boolean(this.docInfo.token)
425
+ )
426
+ requestAccessDialog.open()
427
+ return Promise.reject(false)
428
+ } else {
429
+ deactivateWait()
430
+ console.error("Editor initialization failed:", error)
431
+ }
432
+ return Promise.reject(error)
433
+ })
383
434
  return Promise.all([
384
435
  wsBasePromise,
385
436
  stylesPromise,
386
437
  docDataPromise
387
438
  ])
388
439
  })
389
- .catch(error => {
390
- // Only show "Invalid Share Link" for token validation errors.
391
- // Other errors (REST failures, etc.) should not show this dialog.
392
- if (error.message === "Invalid or expired share link") {
393
- deactivateWait()
394
- const errorDialog = new Dialog({
395
- title: gettext("Invalid Share Link"),
396
- id: "invalid_share_link_dialog",
397
- body: gettext(
398
- "This share link has expired or is invalid. Please ask the document owner for a new link."
399
- ),
400
- buttons: [
401
- {
402
- text: gettext("OK"),
403
- classes: "fw-dark",
404
- click: () => {
405
- window.location.href = "/"
406
- }
407
- }
408
- ],
409
- canClose: false
410
- })
411
- errorDialog.open()
412
- } else {
413
- deactivateWait()
414
- console.error("Editor initialization failed:", error)
415
- }
416
- return Promise.reject(error)
417
- })
418
440
  .then(([wsResult, stylesResult, docResult]) => {
419
441
  let resubScribed = false
420
442
  this.render()
@@ -7,6 +7,7 @@ import {
7
7
  import type {BibDB, ExportDoc, ImageDB} from "@fiduswriter/document"
8
8
  import {CopyrightDialog} from "../../copyright_dialog/index.js"
9
9
  import {DocumentAccessRightsDialog} from "../../documents/access_rights/index.js"
10
+ import {RequestAccessDialog} from "../../documents/access_rights/request_access_dialog.js"
10
11
  import {SaveCopy, SaveRevision} from "../../exporter/native/index.js"
11
12
  import {ExportFidusFile} from "../../exporter/native/file.js"
12
13
  import {LanguageDialog, RevisionDialog} from "../../dialogs/index.js"
@@ -72,6 +73,13 @@ const exportProgress = (doc: {title: string; path?: string}) => {
72
73
  task.update(percentage ?? null, message)
73
74
  }
74
75
 
76
+ const showRequestAccess = (editor: Editor): boolean =>
77
+ editor.user.is_authenticated === true &&
78
+ !editor.docInfo.is_owner &&
79
+ (Boolean(editor.docInfo.token) ||
80
+ (Boolean(editor.docInfo.access_rights) &&
81
+ editor.docInfo.access_rights !== "write"))
82
+
75
83
  const languageItem = (
76
84
  language: string,
77
85
  name: string,
@@ -115,62 +123,26 @@ export const headerbarModel = () => ({
115
123
  content: [
116
124
  {
117
125
  title: (editor: Editor) =>
118
- editor.user.is_authenticated &&
119
- editor.docInfo.token &&
120
- !editor.docInfo.is_owner
126
+ showRequestAccess(editor)
121
127
  ? gettext("Request Access")
122
128
  : gettext("Share"),
123
129
  type: "action",
124
130
  //icon: 'share',
125
131
  tooltip: (editor: Editor) =>
126
- editor.user.is_authenticated &&
127
- editor.docInfo.token &&
128
- !editor.docInfo.is_owner
132
+ showRequestAccess(editor)
129
133
  ? gettext("Request to be added as a collaborator.")
130
134
  : gettext("Share the document with other users."),
131
135
  order: 0,
132
136
  action: (editor: Editor) => {
133
- if (
134
- editor.user.is_authenticated &&
135
- editor.docInfo.token &&
136
- !editor.docInfo.is_owner
137
- ) {
138
- // TokenUser requesting access
139
- editor.app.apiConnectors.document
140
- .requestAccess({
141
- document_id: editor.docInfo.id as number,
142
- rights: "write"
143
- })
144
- .then(({json}: {json: unknown}) => {
145
- const data = json as {
146
- success?: boolean
147
- error?: string
148
- }
149
- if (data.success) {
150
- addAlert(
151
- "success",
152
- gettext(
153
- "Your access request has been sent to the document owner."
154
- )
155
- )
156
- } else {
157
- addAlert(
158
- "error",
159
- data.error ||
160
- gettext(
161
- "Could not send access request."
162
- )
163
- )
164
- }
165
- })
166
- .catch(() => {
167
- addAlert(
168
- "error",
169
- gettext(
170
- "Could not send access request."
171
- )
172
- )
173
- })
137
+ if (showRequestAccess(editor)) {
138
+ // Request higher access rights from the document owner
139
+ const requestAccessDialog = new RequestAccessDialog(
140
+ editor.docInfo.id as number,
141
+ editor.docInfo.access_rights || "",
142
+ editor.app.apiConnectors.document,
143
+ Boolean(editor.docInfo.token)
144
+ )
145
+ requestAccessDialog.open()
174
146
  return
175
147
  }
176
148
  const onShareSuccess = async (
@@ -254,19 +226,40 @@ export const headerbarModel = () => ({
254
226
  editor.e2ee?.password || "",
255
227
  onShareSuccess,
256
228
  editor.app.settings,
229
+ editor.docInfo.is_owner,
257
230
  editor.app.apiConnectors.contacts,
258
231
  editor.app.apiConnectors.document
259
232
  )
260
233
  shareDialog.init()
261
234
  },
235
+ available: (editor: Editor) => {
236
+ if (editor.app.settings.EDITOR_SAVE_MODE === "external") {
237
+ return false
238
+ }
239
+ if (!editor.user.is_authenticated) {
240
+ return true
241
+ }
242
+ if (editor.docInfo.is_owner) {
243
+ return true
244
+ }
245
+ if (editor.docInfo.token) {
246
+ return true
247
+ }
248
+ if (!editor.docInfo.access_rights) {
249
+ return false
250
+ }
251
+ return editor.docInfo.access_rights !== "write"
252
+ },
262
253
  disabled: (editor: Editor) => {
263
254
  return (
264
255
  editor.app.isOffline() ||
265
- !editor.user.is_authenticated
256
+ !editor.user.is_authenticated ||
257
+ !editor.docInfo.owner ||
258
+ (!editor.docInfo.is_owner &&
259
+ !editor.docInfo.token &&
260
+ editor.docInfo.access_rights === "write")
266
261
  )
267
- },
268
- available: (editor: Editor) =>
269
- editor.app.settings.EDITOR_SAVE_MODE !== "external"
262
+ }
270
263
  },
271
264
  {
272
265
  title: (editor: Editor) =>
@@ -200,7 +200,7 @@ export async function createStaticEditor(
200
200
  let csl = config.csl
201
201
  if (!csl) {
202
202
  const {createCSL} = await import(
203
- "@fiduswriter/document/citations/create_csl"
203
+ "@fiduswriter/document/citeproc-plus"
204
204
  )
205
205
  csl = await createCSL()
206
206
  // createCSL replaces getStyle/getLocale with versions that only look at