texlite 0.11.0 → 0.13.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.
Files changed (64) hide show
  1. package/DESIGN.md +64 -12
  2. package/dist/client/assets/AdminUsers-CfKlKMUu.js +1 -0
  3. package/dist/client/assets/{CitationLibraryDialog-tVD3i2gg.js → CitationLibraryDialog-BpnX28Le.js} +1 -1
  4. package/dist/client/assets/Dashboard-nbu54S0M.js +2 -0
  5. package/dist/client/assets/GitDialog-CrLMkNSh.js +3 -0
  6. package/dist/client/assets/GitHubAccountDialog-Bt0c8yBQ.js +1 -0
  7. package/dist/client/assets/HistoryDialog-CNEHivv5.js +7 -0
  8. package/dist/client/assets/LatexEditor-Dq9cqkpP.js +138 -0
  9. package/dist/client/assets/{PdfPreview-CMkO72I5.js → PdfPreview-xiB5Piyv.js} +1 -1
  10. package/dist/client/assets/ProjectIconPickerDialog-BSdiBwtv.js +1 -0
  11. package/dist/client/assets/{ProjectNavigationDialogs-isGMXCfG.js → ProjectNavigationDialogs-C7cD7PvT.js} +1 -1
  12. package/dist/client/assets/ProjectSettings-CRHG-jAZ.js +1 -0
  13. package/dist/client/assets/ProjectWorkspace-BdmKRaK2.js +32 -0
  14. package/dist/client/assets/{SelectionHistoryDialog-ZvAhIJMy.js → SelectionHistoryDialog-JL_2qLGf.js} +1 -1
  15. package/dist/client/assets/SystemMetricsDialog-BIWJwsxD.js +1 -0
  16. package/dist/client/assets/{TagManagementDialog-qhZfHgTr.js → TagManagementDialog-CJXWtqXN.js} +1 -1
  17. package/dist/client/assets/{WordCountDialog-tFhMCGyn.js → WordCountDialog-sr21g-ex.js} +1 -1
  18. package/dist/client/assets/check-CzfkSYah.js +1 -0
  19. package/dist/client/assets/{chktex-tM1XXPk2.js → chktex-BEVGf49u.js} +1 -1
  20. package/dist/client/assets/{editor-B9HNwtsd.js → editor-taw1iptB.js} +1 -1
  21. package/dist/client/assets/external-link-DuxFggPh.js +1 -0
  22. package/dist/client/assets/github-D805ABUR.js +1 -0
  23. package/dist/client/assets/{hard-drive-CkIPwUSV.js → hard-drive-De8wdQpw.js} +1 -1
  24. package/dist/client/assets/index-BYAZKPNO.js +10 -0
  25. package/dist/client/assets/{index-DTYEGzB7.js → index-CXWzCgcU.js} +1 -1
  26. package/dist/client/assets/index-Cc9-JFj7.css +1 -0
  27. package/dist/client/assets/{minimize-2-CLuDohEf.js → minimize-2-DP0ZJHGp.js} +1 -1
  28. package/dist/client/assets/{minus-BpuvzV2m.js → minus-BDK-NFbs.js} +1 -1
  29. package/dist/client/assets/{plus-BFr75l91.js → plus-GB_3iSP8.js} +1 -1
  30. package/dist/client/assets/{refresh-cw-DyA4NKh7.js → refresh-cw-DwUVAz9L.js} +1 -1
  31. package/dist/client/assets/{search-Nu4OcY_n.js → search-BJ0HR5pC.js} +1 -1
  32. package/dist/client/assets/{sigma-B-wjepsY.js → sigma-DOfd27T4.js} +1 -1
  33. package/dist/client/assets/{spellCheck-C-bNJsIU.js → spellCheck-CRN7y5Qf.js} +1 -1
  34. package/dist/client/assets/{tags-BSA08jK3.js → tags-BdjO5Lmr.js} +1 -1
  35. package/dist/client/assets/{type-BCdIbDmF.js → type-qV4gbXDh.js} +1 -1
  36. package/dist/client/assets/{user-plus-gFwY-NA3.js → user-plus-CPne6OnV.js} +1 -1
  37. package/dist/client/assets/x-DPtqmVM9.js +1 -0
  38. package/dist/client/index.html +3 -3
  39. package/dist/server/db.js +30 -1
  40. package/dist/server/git.js +64 -29
  41. package/dist/server/harper.js +14 -0
  42. package/dist/server/history.js +14 -5
  43. package/dist/server/i18n.js +2 -2
  44. package/dist/server/routes/projectGit.js +10 -11
  45. package/dist/server/routes/projectHistory.js +32 -5
  46. package/dist/server/routes/projectMembers.js +0 -5
  47. package/dist/server/routes/projects.js +10 -0
  48. package/dist/server/routes/users.js +0 -2
  49. package/dist/shared/historyFiles.js +6 -0
  50. package/package.json +3 -1
  51. package/dist/client/assets/AdminUsers-BExOMZsW.js +0 -1
  52. package/dist/client/assets/Dashboard-DRt4rjKP.js +0 -2
  53. package/dist/client/assets/GitDialog-CjDCi1uh.js +0 -3
  54. package/dist/client/assets/HistoryDialog-Pa9mK3Ky.js +0 -7
  55. package/dist/client/assets/LatexEditor-8r486VeG.js +0 -138
  56. package/dist/client/assets/ProjectIconPickerDialog-B52xbSyr.js +0 -1
  57. package/dist/client/assets/ProjectSettings-Br_kJ113.js +0 -1
  58. package/dist/client/assets/ProjectWorkspace-CcbrdCy8.js +0 -32
  59. package/dist/client/assets/SystemMetricsDialog-D4ouU4HI.js +0 -1
  60. package/dist/client/assets/check-DKIW1zpR.js +0 -1
  61. package/dist/client/assets/index-90X2GUd_.css +0 -1
  62. package/dist/client/assets/index-CqBmQAzW.js +0 -10
  63. package/dist/client/assets/key-round-BEnQo7IM.js +0 -1
  64. package/dist/client/assets/x-Cspw_pa4.js +0 -1
@@ -22,6 +22,7 @@ export class ProjectGitService {
22
22
  await this.ensureGitAvailable();
23
23
  assertNoSourceSymlinks(this.config, project.id);
24
24
  const settings = this.settings(project.id);
25
+ const account = this.userSettings(project.owner_id);
25
26
  const gitDirectory = path.join(sourceRoot(this.config, project.id), ".git");
26
27
  let gitStat = null;
27
28
  try {
@@ -37,8 +38,8 @@ export class ProjectGitService {
37
38
  if (!initialized)
38
39
  return {
39
40
  initialized: false,
40
- tokenConfigured: Boolean(settings?.token_ciphertext),
41
- githubLogin: settings?.github_login ?? null,
41
+ tokenConfigured: Boolean(account?.token_ciphertext),
42
+ githubLogin: account?.github_login ?? null,
42
43
  remoteUrl: settings?.remote_url ?? null,
43
44
  repositoryName: settings?.repository_name ?? null,
44
45
  repositoryHtmlUrl: settings?.repository_html_url ?? null,
@@ -56,11 +57,17 @@ export class ProjectGitService {
56
57
  const branch = branchResult.code === 0 ? branchResult.stdout.trim() : null;
57
58
  const remoteResult = await this.git(root, ["remote", "get-url", "origin"], [2]);
58
59
  const latestResult = await this.git(root, ["log", "-1", "--format=%H%x1f%h%x1f%an%x1f%ae%x1f%aI%x1f%s"], [128]);
59
- const aheadResult = await this.git(root, ["rev-list", "--count", "@{upstream}..HEAD"], [128]);
60
+ const upstreamResult = branch
61
+ ? await this.git(root, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"], [128])
62
+ : null;
63
+ // A newly initialized backup repository has no upstream until its first
64
+ // push. In that case, every local commit is pending for the remote.
65
+ const aheadRevision = upstreamResult?.code === 0 ? "@{upstream}..HEAD" : "HEAD";
66
+ const aheadResult = branch ? await this.git(root, ["rev-list", "--count", aheadRevision], [128]) : null;
60
67
  return {
61
68
  initialized: true,
62
- tokenConfigured: Boolean(settings?.token_ciphertext),
63
- githubLogin: settings?.github_login ?? null,
69
+ tokenConfigured: Boolean(account?.token_ciphertext),
70
+ githubLogin: account?.github_login ?? null,
64
71
  remoteUrl: settings?.remote_url ?? (remoteResult.code === 0 ? remoteResult.stdout.trim() : null),
65
72
  repositoryName: settings?.repository_name ?? null,
66
73
  repositoryHtmlUrl: settings?.repository_html_url ?? null,
@@ -69,41 +76,43 @@ export class ProjectGitService {
69
76
  dirty: Boolean(porcelain),
70
77
  restorable: porcelain.split("\n").some((line) => Boolean(line) && !line.startsWith("??")),
71
78
  changedFiles: porcelain ? porcelain.split("\n").length : 0,
72
- ahead: aheadResult.code === 0 ? Number.parseInt(aheadResult.stdout.trim(), 10) || 0 : 0,
79
+ ahead: aheadResult?.code === 0 ? Number.parseInt(aheadResult.stdout.trim(), 10) || 0 : 0,
73
80
  latestCommit: latestResult.code === 0 ? parseCommit(latestResult.stdout.trim()) : null
74
81
  };
75
82
  }
76
- async configureToken(project, tokenInput) {
83
+ githubAccountStatus(userId) {
84
+ const settings = this.userSettings(userId);
85
+ return { tokenConfigured: Boolean(settings?.token_ciphertext), githubLogin: settings?.github_login ?? null };
86
+ }
87
+ async configureUserToken(userId, tokenInput) {
77
88
  const token = tokenInput.trim();
78
89
  if (token.length < 20 || token.length > 500)
79
90
  throw httpError(400, "GIT_TOKEN_INVALID");
80
- await this.ensureGitAvailable();
81
91
  const account = await this.githubRequest(token, "/user", { method: "GET" });
82
92
  if (typeof account.login !== "string" || !account.login)
83
93
  throw httpError(502, "GIT_ACCOUNT_INVALID");
84
- await this.ensureRepository(project);
85
94
  const timestamp = new Date().toISOString();
86
- this.db.prepare(`INSERT INTO project_git_settings
87
- (project_id, token_ciphertext, github_login, default_branch, created_at, updated_at)
88
- VALUES (?, ?, ?, 'main', ?, ?)
89
- ON CONFLICT(project_id) DO UPDATE SET token_ciphertext = excluded.token_ciphertext,
95
+ this.db.prepare(`INSERT INTO user_github_settings
96
+ (user_id, token_ciphertext, github_login, created_at, updated_at)
97
+ VALUES (?, ?, ?, ?, ?)
98
+ ON CONFLICT(user_id) DO UPDATE SET token_ciphertext = excluded.token_ciphertext,
90
99
  github_login = excluded.github_login, updated_at = excluded.updated_at`)
91
- .run(project.id, encryptToken(this.config, token), account.login, timestamp, timestamp);
92
- return this.status(project);
100
+ .run(userId, encryptToken(this.config, token), account.login, timestamp, timestamp);
101
+ return this.githubAccountStatus(userId);
93
102
  }
94
- async removeToken(project) {
95
- this.db.prepare("UPDATE project_git_settings SET token_ciphertext = NULL, github_login = NULL, updated_at = ? WHERE project_id = ?")
96
- .run(new Date().toISOString(), project.id);
97
- return this.status(project);
103
+ removeUserToken(userId) {
104
+ this.db.prepare("DELETE FROM user_github_settings WHERE user_id = ?").run(userId);
105
+ return this.githubAccountStatus(userId);
98
106
  }
99
107
  async createGitHubRepository(project, name, isPrivate) {
100
108
  if (!/^[A-Za-z0-9._-]{1,100}$/.test(name))
101
109
  throw httpError(400, "GIT_REPOSITORY_NAME_INVALID");
102
110
  await this.ensureGitAvailable();
103
- const settings = this.requireTokenSettings(project.id);
104
- if (settings.remote_url)
111
+ const account = this.requireUserTokenSettings(project.owner_id);
112
+ const settings = this.settings(project.id);
113
+ if (settings?.remote_url)
105
114
  throw httpError(409, "GIT_REMOTE_ALREADY_CONFIGURED");
106
- const token = decryptToken(this.config, settings.token_ciphertext);
115
+ const token = decryptToken(this.config, account.token_ciphertext);
107
116
  const repository = await this.githubRequest(token, "/user/repos", {
108
117
  method: "POST",
109
118
  body: JSON.stringify({ name, private: isPrivate, auto_init: false, description: `Backup of ${project.name}` })
@@ -112,6 +121,7 @@ export class ProjectGitService {
112
121
  throw httpError(502, "GIT_REPOSITORY_RESPONSE_INVALID");
113
122
  }
114
123
  await this.ensureRepository(project);
124
+ this.ensureProjectSettings(project.id);
115
125
  const root = sourceRoot(this.config, project.id);
116
126
  const currentRemote = await this.git(root, ["remote", "get-url", "origin"], [2]);
117
127
  if (currentRemote.code === 0)
@@ -149,8 +159,8 @@ export class ProjectGitService {
149
159
  return parseCommit(result.stdout.trim());
150
160
  }
151
161
  async push(project) {
152
- const settings = this.requireTokenSettings(project.id);
153
- if (!settings.remote_url)
162
+ const account = this.requireUserTokenSettings(project.owner_id);
163
+ if (!this.settings(project.id)?.remote_url)
154
164
  throw httpError(409, "GIT_REMOTE_NOT_CONFIGURED");
155
165
  const root = this.requireRepository(project.id);
156
166
  assertNoSourceSymlinks(this.config, project.id);
@@ -163,7 +173,7 @@ export class ProjectGitService {
163
173
  const branch = branchResult.stdout.trim();
164
174
  if (!/^[A-Za-z0-9._/-]+$/.test(branch))
165
175
  throw httpError(400, "GIT_BRANCH_INVALID");
166
- const token = decryptToken(this.config, settings.token_ciphertext);
176
+ const token = decryptToken(this.config, account.token_ciphertext);
167
177
  await this.git(root, ["push", "--set-upstream", "origin", `HEAD:refs/heads/${branch}`], [], this.authEnvironment(token));
168
178
  return this.status(project);
169
179
  }
@@ -269,12 +279,20 @@ export class ProjectGitService {
269
279
  settings(projectId) {
270
280
  return this.db.prepare("SELECT * FROM project_git_settings WHERE project_id = ?").get(projectId);
271
281
  }
272
- requireTokenSettings(projectId) {
273
- const settings = this.settings(projectId);
282
+ userSettings(userId) {
283
+ return this.db.prepare("SELECT * FROM user_github_settings WHERE user_id = ?").get(userId);
284
+ }
285
+ requireUserTokenSettings(userId) {
286
+ const settings = this.userSettings(userId);
274
287
  if (!settings?.token_ciphertext)
275
288
  throw httpError(409, "GIT_TOKEN_NOT_CONFIGURED");
276
289
  return settings;
277
290
  }
291
+ ensureProjectSettings(projectId) {
292
+ const timestamp = new Date().toISOString();
293
+ this.db.prepare(`INSERT INTO project_git_settings (project_id, default_branch, created_at, updated_at)
294
+ VALUES (?, 'main', ?, ?) ON CONFLICT(project_id) DO NOTHING`).run(projectId, timestamp, timestamp);
295
+ }
278
296
  async verifyCommit(root, revision) {
279
297
  const result = await this.git(root, ["rev-parse", "--verify", `${revision}^{commit}`], [128]);
280
298
  if (result.code !== 0)
@@ -303,7 +321,10 @@ export class ProjectGitService {
303
321
  }
304
322
  });
305
323
  if (!response.ok) {
306
- throw httpError(response.status === 401 || response.status === 403 ? 400 : 502, response.status === 401 || response.status === 403 ? "GIT_TOKEN_REJECTED" : "GIT_OPERATION_FAILED");
324
+ const authError = classifyGitHubAuthFailure(response.status);
325
+ if (authError)
326
+ throw httpError(400, authError);
327
+ throw httpError(502, "GIT_OPERATION_FAILED");
307
328
  }
308
329
  return await response.json();
309
330
  }
@@ -376,13 +397,27 @@ export class ProjectGitService {
376
397
  if (size > MAX_COMMAND_OUTPUT)
377
398
  return reject(httpError(413, "GIT_OUTPUT_TOO_LARGE"));
378
399
  if (result.code !== 0 && !allowedCodes.includes(result.code)) {
379
- return reject(httpError(400, "GIT_COMMAND_FAILED"));
400
+ const pushAuthError = args[0] === "push" ? classifyGitPushFailure(result.stderr) : null;
401
+ return reject(httpError(400, pushAuthError ?? "GIT_COMMAND_FAILED"));
380
402
  }
381
403
  resolve(result);
382
404
  });
383
405
  });
384
406
  }
385
407
  }
408
+ function classifyGitHubAuthFailure(status) {
409
+ // GitHub's 401/403 responses do not reliably distinguish invalid or expired
410
+ // credentials from insufficient repository access (including SSO/policy
411
+ // restrictions), so give users one actionable message for these cases.
412
+ return status === 401 || status === 403 ? "GIT_TOKEN_REJECTED" : null;
413
+ }
414
+ export function classifyGitPushFailure(stderr) {
415
+ if (/saml|single[- ]sign[- ]on|\bsso\b/i.test(stderr) ||
416
+ /invalid username or token|bad credentials|authentication failed|could not read username/i.test(stderr) ||
417
+ /write access.{0,80}(?:not granted|denied)|permission to .{1,200} denied|repository not found|returned error:\s*403/i.test(stderr))
418
+ return "GIT_TOKEN_REJECTED";
419
+ return null;
420
+ }
386
421
  function parseCommit(line) {
387
422
  const [sha = "", shortSha = "", authorName = "", authorEmail = "", authoredAt = "", ...message] = line.split("\x1f");
388
423
  return { sha, shortSha, authorName, authorEmail, authoredAt, message: message.join("\x1f") };
@@ -115,6 +115,20 @@ export class HarperService {
115
115
  async preload() {
116
116
  await this.ensureAvailable();
117
117
  }
118
+ /** Availability probe only; does not run a writing check or join its queue. */
119
+ async isAvailable() {
120
+ if (this.disposed)
121
+ return false;
122
+ try {
123
+ await this.ensureAvailable();
124
+ return !this.disposed;
125
+ }
126
+ catch (error) {
127
+ if (error instanceof HarperUnavailableError)
128
+ return false;
129
+ throw error;
130
+ }
131
+ }
118
132
  /**
119
133
  * Schedule one host-side check. A lane represents one browser page's
120
134
  * current file, so an edit can replace only its own stale waiting request
@@ -4,6 +4,8 @@ import { createHash, randomUUID } from "node:crypto";
4
4
  import { assertNoSourceSymlinks, listProjectFiles, outputRoot, projectRoot, resolveSourcePath, safeRelativePath, sourceRoot } from "./files.js";
5
5
  import { httpError } from "./http.js";
6
6
  import { MAX_TEXT_PREVIEW_BYTES } from "./limits.js";
7
+ import { isUtf8 } from "node:buffer";
8
+ import { isHistoryTextFile } from "../shared/historyFiles.js";
7
9
  const AUTOSAVE_COALESCE_MS = 2 * 60_000;
8
10
  // Logical stored payload; SQLite pages, indexes and WAL are shared database overhead.
9
11
  function historyMetadataBytes(row) {
@@ -39,7 +41,9 @@ export class ProjectHistoryService {
39
41
  }
40
42
  }
41
43
  const changedPaths = previousManifest ? changedManifestPaths(previousManifest, manifest) : Object.keys(manifest.files).sort();
42
- if (previousManifest && changedPaths.length === 0 && JSON.stringify(previousManifest.settings) === JSON.stringify(manifest.settings)) {
44
+ // Settings alone do not create a file-history entry. Keep the baseline
45
+ // current so the next real file change captures the effective settings.
46
+ if (changedPaths.length === 0) {
43
47
  this.saveBaseline(projectId, manifest);
44
48
  return null;
45
49
  }
@@ -72,7 +76,8 @@ export class ProjectHistoryService {
72
76
  list(projectId, limit = 100) {
73
77
  const rows = this.db.prepare(`SELECT history.*, user.username AS author_username, user.display_name AS author_name
74
78
  FROM project_history_versions history LEFT JOIN users user ON user.id = history.author_id
75
- WHERE history.project_id = ? ORDER BY history.created_at DESC, history.rowid DESC LIMIT ?`)
79
+ WHERE history.project_id = ? AND json_array_length(history.changed_paths_json) > 0
80
+ ORDER BY history.created_at DESC, history.rowid DESC LIMIT ?`)
76
81
  .all(projectId, Math.min(200, Math.max(1, limit)));
77
82
  return rows.map((row) => versionJson(row));
78
83
  }
@@ -97,7 +102,7 @@ export class ProjectHistoryService {
97
102
  const rows = this.db.prepare(`SELECT history.*, history.rowid AS history_rowid,
98
103
  user.username AS author_username, user.display_name AS author_name
99
104
  FROM project_history_versions history LEFT JOIN users user ON user.id = history.author_id
100
- WHERE history.project_id = ? ${cursorFilter}
105
+ WHERE history.project_id = ? AND json_array_length(history.changed_paths_json) > 0 ${cursorFilter}
101
106
  ORDER BY history.created_at DESC, history.rowid DESC LIMIT ?`)
102
107
  .all(...values);
103
108
  const pageRows = rows.slice(0, limitValue);
@@ -231,7 +236,8 @@ export class ProjectHistoryService {
231
236
  previousVersion(projectId, versionId) {
232
237
  const row = this.db.prepare(`SELECT older.id FROM project_history_versions older
233
238
  JOIN project_history_versions selected ON selected.id = ? AND selected.project_id = older.project_id
234
- WHERE older.project_id = ? AND (older.created_at < selected.created_at
239
+ WHERE older.project_id = ? AND json_array_length(older.changed_paths_json) > 0
240
+ AND (older.created_at < selected.created_at
235
241
  OR (older.created_at = selected.created_at AND older.rowid < selected.rowid))
236
242
  ORDER BY older.created_at DESC, older.rowid DESC LIMIT 1`).get(versionId, projectId);
237
243
  return row ? this.version(row.id, projectId) : null;
@@ -243,6 +249,8 @@ export class ProjectHistoryService {
243
249
  }
244
250
  readTextFile(projectId, versionId, filePathInput) {
245
251
  const filePath = safeRelativePath(filePathInput);
252
+ if (!isHistoryTextFile(filePath))
253
+ return null;
246
254
  const entry = this.manifest(projectId, versionId)?.files[filePath];
247
255
  if (!entry || entry.size > MAX_TEXT_PREVIEW_BYTES)
248
256
  return null;
@@ -364,7 +372,8 @@ export class ProjectHistoryService {
364
372
  */
365
373
  readStoredObject(projectId, digest, filePath) {
366
374
  try {
367
- return fs.readFileSync(this.objectPath(projectId, digest), "utf8");
375
+ const bytes = fs.readFileSync(this.objectPath(projectId, digest));
376
+ return !bytes.includes(0) && isUtf8(bytes) ? bytes.toString("utf8") : null;
368
377
  }
369
378
  catch (error) {
370
379
  this.rethrowHistoryObjectError(error, filePath);
@@ -46,7 +46,7 @@ const enMessages = {
46
46
  GIT_REVISION_INVALID: "Choose a Git revision to check out.",
47
47
  GIT_TOKEN_INVALID: "Enter a GitHub token.",
48
48
  GIT_TOKEN_NOT_CONFIGURED: "Configure a GitHub token first.",
49
- GIT_TOKEN_REJECTED: "GitHub rejected this token. Check its permissions and try again.",
49
+ GIT_TOKEN_REJECTED: "GitHub rejected this token. It may be invalid, expired, or lack the required permissions.",
50
50
  GIT_UNAVAILABLE: "Git is not available on this server.",
51
51
  HARPER_UNAVAILABLE: "Harper spelling and grammar checking is temporarily unavailable.",
52
52
  HISTORY_FILE_NOT_FOUND: "The historical file does not exist.",
@@ -183,7 +183,7 @@ const zhMessages = {
183
183
  GIT_REVISION_INVALID: "请选择要 checkout 的 Git 版本。",
184
184
  GIT_TOKEN_INVALID: "请输入 GitHub token。",
185
185
  GIT_TOKEN_NOT_CONFIGURED: "请先配置 GitHub token。",
186
- GIT_TOKEN_REJECTED: "GitHub 拒绝了该 token,请检查权限后重试。",
186
+ GIT_TOKEN_REJECTED: "GitHub 拒绝了此 token:它可能无效、已过期或权限不足。",
187
187
  GIT_UNAVAILABLE: "服务器未安装 Git。",
188
188
  HARPER_UNAVAILABLE: "Harper 拼写与语法检查暂不可用。",
189
189
  HISTORY_FILE_NOT_FOUND: "历史文件不存在。",
@@ -23,27 +23,26 @@ export function registerProjectGitRoutes(app, context) {
23
23
  return { status: await projectGit.status(requireGitOwner(id, user)) };
24
24
  }, { preflight: () => { requireGitOwner(id, user); } });
25
25
  });
26
- app.put("/api/projects/:id/git/token", async (request, reply) => {
26
+ app.get("/api/account/github", async (request, reply) => {
27
+ const user = requireUser(request, reply, db);
28
+ if (!user)
29
+ return;
30
+ return { status: projectGit.githubAccountStatus(user.id) };
31
+ });
32
+ app.put("/api/account/github", async (request, reply) => {
27
33
  const user = requireUser(request, reply, db);
28
34
  if (!user)
29
35
  return;
30
- const { id } = request.params;
31
36
  const body = request.body;
32
37
  if (typeof body.token !== "string")
33
38
  return apiError(reply, 400, "GIT_TOKEN_INVALID");
34
- const token = body.token;
35
- return await projectMutations.runSerialized(id, async () => {
36
- return { status: await projectGit.configureToken(requireGitOwner(id, user), token) };
37
- }, { preflight: () => { requireGitOwner(id, user); } });
39
+ return { status: await projectGit.configureUserToken(user.id, body.token) };
38
40
  });
39
- app.delete("/api/projects/:id/git/token", async (request, reply) => {
41
+ app.delete("/api/account/github", async (request, reply) => {
40
42
  const user = requireUser(request, reply, db);
41
43
  if (!user)
42
44
  return;
43
- const { id } = request.params;
44
- return await projectMutations.runSerialized(id, async () => {
45
- return { status: await projectGit.removeToken(requireGitOwner(id, user)) };
46
- }, { preflight: () => { requireGitOwner(id, user); } });
45
+ return { status: projectGit.removeUserToken(user.id) };
47
46
  });
48
47
  app.post("/api/projects/:id/git/repository", async (request, reply) => {
49
48
  const user = requireUser(request, reply, db);
@@ -1,4 +1,5 @@
1
1
  import fs from "node:fs";
2
+ import { isUtf8 } from "node:buffer";
2
3
  import { requireUser } from "../auth.js";
3
4
  import { isCollaborativeTextFile, maxCollaborativeFileBytes } from "../collaboration.js";
4
5
  import { hashText } from "../editHistory.js";
@@ -6,6 +7,7 @@ import { HISTORY_RETENTION_DELAY_MS } from "../historyRetention.js";
6
7
  import { resolveSourcePath, safeRelativePath } from "../files.js";
7
8
  import { apiError, httpError } from "../http.js";
8
9
  import { MAX_TEXT_PREVIEW_BYTES } from "../limits.js";
10
+ import { isHistoryTextFile } from "../../shared/historyFiles.js";
9
11
  import { accessibleProject, canEdit } from "../projects.js";
10
12
  import { commentsSummaryForProject, projectJson, projectTextSnapshot, reanchorProjectSnapshot, requireEditableProject, requireProjectOwnerPermission, tagsForProject, text, touchProject } from "./projectShared.js";
11
13
  /** Register retained project version listing, inspection, restoration, and cleanup routes. */
@@ -112,7 +114,10 @@ export function registerProjectHistoryRoutes(app, context) {
112
114
  return {
113
115
  version,
114
116
  settings: manifest.settings,
115
- files: Object.entries(manifest.files).map(([filePath, file]) => ({ path: filePath, size: file.size })).sort((left, right) => left.path.localeCompare(right.path))
117
+ files: [
118
+ ...Object.entries(manifest.files).map(([filePath, file]) => ({ path: filePath, size: file.size })),
119
+ ...version.changedPaths.filter((filePath) => !manifest.files[filePath]).map((filePath) => ({ path: filePath, size: 0, deleted: true }))
120
+ ].sort((left, right) => left.path.localeCompare(right.path))
116
121
  };
117
122
  });
118
123
  app.get("/api/projects/:id/history/:versionId/file", async (request, reply) => {
@@ -125,19 +130,41 @@ export function registerProjectHistoryRoutes(app, context) {
125
130
  const query = request.query;
126
131
  const filePath = safeRelativePath(query.path ?? "");
127
132
  return await projectMutations.runConsistentRead(id, () => {
128
- const historical = history.readTextFile(id, versionId, filePath);
133
+ const manifest = history.manifest(id, versionId);
134
+ const version = history.version(versionId, id);
135
+ if (!manifest || !version)
136
+ return apiError(reply, 404, "HISTORY_VERSION_NOT_FOUND");
137
+ if (!manifest.files[filePath] && !version.changedPaths.includes(filePath))
138
+ return apiError(reply, 404, "HISTORY_FILE_NOT_FOUND");
139
+ // Never decode attachment bytes as UTF-8, including direct API calls.
140
+ if (!isHistoryTextFile(filePath))
141
+ return apiError(reply, 415, "HISTORY_FILE_PREVIEW_UNSUPPORTED", { path: filePath });
142
+ const historical = manifest.files[filePath] ? history.readTextFile(id, versionId, filePath) : "";
129
143
  if (historical === null)
130
144
  return apiError(reply, 415, "HISTORY_FILE_PREVIEW_UNSUPPORTED", { path: filePath });
131
145
  let comparison = "";
132
146
  const previousVersion = query.against === "__previous__" ? history.previousVersion(id, versionId) : null;
133
147
  const against = query.against === "__previous__" ? previousVersion?.id ?? "__none__" : query.against;
134
148
  if (query.against) {
135
- comparison = history.readTextFile(id, against, filePath) ?? "";
149
+ const comparisonManifest = history.manifest(id, against);
150
+ if (against !== "__none__" && !comparisonManifest)
151
+ return apiError(reply, 404, "HISTORY_VERSION_NOT_FOUND");
152
+ if (comparisonManifest?.files[filePath]) {
153
+ const text = history.readTextFile(id, against, filePath);
154
+ if (text === null)
155
+ return apiError(reply, 415, "HISTORY_FILE_PREVIEW_UNSUPPORTED", { path: filePath });
156
+ comparison = text;
157
+ }
136
158
  }
137
159
  else {
138
160
  const current = resolveSourcePath(config, id, filePath);
139
- if (fs.existsSync(current) && fs.statSync(current).isFile() && fs.statSync(current).size <= MAX_TEXT_PREVIEW_BYTES) {
140
- comparison = fs.readFileSync(current, "utf8");
161
+ if (fs.existsSync(current) && fs.statSync(current).isFile()) {
162
+ if (fs.statSync(current).size > MAX_TEXT_PREVIEW_BYTES)
163
+ return apiError(reply, 415, "HISTORY_FILE_PREVIEW_UNSUPPORTED", { path: filePath });
164
+ const bytes = fs.readFileSync(current);
165
+ if (bytes.includes(0) || !isUtf8(bytes))
166
+ return apiError(reply, 415, "HISTORY_FILE_PREVIEW_UNSUPPORTED", { path: filePath });
167
+ comparison = bytes.toString("utf8");
141
168
  }
142
169
  }
143
170
  return { path: filePath, historical, comparison, against: against ?? "current", previousVersion };
@@ -53,11 +53,6 @@ export function registerProjectMemberRoutes(app, context) {
53
53
  .run(id, user.id, changedAt);
54
54
  db.prepare("UPDATE projects SET owner_id = ?, last_modified_by = ?, updated_at = ? WHERE id = ?")
55
55
  .run(currentTarget.id, user.id, changedAt, id);
56
- // A project token belongs to the account that supplied it. Preserve the
57
- // local repository/remote metadata, but require the new owner to add
58
- // their own credential before the next GitHub operation.
59
- db.prepare("UPDATE project_git_settings SET token_ciphertext = NULL, github_login = NULL, updated_at = ? WHERE project_id = ?")
60
- .run(changedAt, id);
61
56
  db.exec("COMMIT");
62
57
  }
63
58
  catch (error) {
@@ -358,6 +358,16 @@ export function registerProjectCatalogRoutes(app, context) {
358
358
  WHERE project_id = ? ORDER BY word COLLATE NOCASE`).all(id);
359
359
  return { words: rows.map((row) => row.word) };
360
360
  });
361
+ app.get("/api/projects/:id/spellcheck/status", async (request, reply) => {
362
+ const user = requireUser(request, reply, db);
363
+ if (!user)
364
+ return;
365
+ const { id } = request.params;
366
+ if (!accessibleProject(db, id, user))
367
+ return apiError(reply, 404, "PROJECT_NOT_FOUND");
368
+ reply.header("Cache-Control", "no-store");
369
+ return { available: await harper.isAvailable() };
370
+ });
361
371
  app.post("/api/projects/:id/spellcheck", async (request, reply) => {
362
372
  const user = requireUser(request, reply, db);
363
373
  if (!user)
@@ -121,8 +121,6 @@ export function registerUserManagementRoutes(app, context) {
121
121
  db.prepare("DELETE FROM projects WHERE owner_id = ?").run(id);
122
122
  }
123
123
  else {
124
- db.prepare("UPDATE project_git_settings SET token_ciphertext = NULL, github_login = NULL, updated_at = ? WHERE project_id IN (SELECT id FROM projects WHERE owner_id = ?)")
125
- .run(now(), id);
126
124
  db.prepare("DELETE FROM project_members WHERE user_id = ? AND project_id IN (SELECT id FROM projects WHERE owner_id = ?)")
127
125
  .run(admin.id, id);
128
126
  db.prepare("UPDATE projects SET owner_id = ?, last_modified_by = ?, updated_at = ? WHERE owner_id = ?")
@@ -0,0 +1,6 @@
1
+ /** File types for which a source-level snapshot diff is meaningful. */
2
+ export function isHistoryTextFile(filePath) {
3
+ const name = filePath.split("/").at(-1) ?? "";
4
+ return /\.(?:tex|bib|bst|sty|cls|txt|md|rst|dat|csv|tsv|json|ya?ml|toml|xml|html?|css|[cm]?js|tsx?|py|sh|r|log|bbl|aux|idx|ind|gls)$/i.test(name)
5
+ || /^(?:\.?latexmkrc|readme|licen[cs]e|makefile)$/i.test(name);
6
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "texlite",
3
- "version": "0.11.0",
3
+ "version": "0.13.0",
4
4
  "description": "A lightweight local-first collaborative LaTeX workspace",
5
5
  "license": "AGPL-3.0-only",
6
6
  "repository": {
@@ -80,6 +80,8 @@
80
80
  "@lezer/highlight": "^1.2.3",
81
81
  "@lezer/lr": "^1.4.10",
82
82
  "@radix-ui/react-dialog": "^1.1.15",
83
+ "@radix-ui/react-dropdown-menu": "^2.1.25",
84
+ "@radix-ui/react-popover": "^1.2.0",
83
85
  "@replit/codemirror-vim": "^6.4.0",
84
86
  "better-sqlite3": "^13.0.2",
85
87
  "bibtex-tidy": "1.14.0",
@@ -1 +0,0 @@
1
- import{u as Y,r as n,j as e}from"./react-BVUjbTTn.js";import{c as m,M as D,d as _,C as ee,g as u,h}from"./index-CqBmQAzW.js";import{U as se,T as R,X as O}from"./x-Cspw_pa4.js";import{U as $}from"./user-plus-gFwY-NA3.js";import{S as ae}from"./Dashboard-DRt4rjKP.js";import{K as B}from"./key-round-BEnQo7IM.js";import"./hard-drive-CkIPwUSV.js";import"./sigma-B-wjepsY.js";import"./tags-BSA08jK3.js";const re=m("Dices",[["rect",{width:"12",height:"12",x:"2",y:"10",rx:"2",ry:"2",key:"6agr2n"}],["path",{d:"m17.92 14 3.5-3.5a2.24 2.24 0 0 0 0-3l-5-4.92a2.24 2.24 0 0 0-3 0L10 6",key:"1o487t"}],["path",{d:"M6 18h.01",key:"uhywen"}],["path",{d:"M10 14h.01",key:"ssrbsk"}],["path",{d:"M15 6h.01",key:"cblpky"}],["path",{d:"M18 9h.01",key:"2061c0"}]]);const ne=m("FolderCheck",[["path",{d:"M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z",key:"1kt360"}],["path",{d:"m9 13 2 2 4-4",key:"6343dt"}]]);const te=m("FolderX",[["path",{d:"M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z",key:"1kt360"}],["path",{d:"m9.5 10.5 5 5",key:"ra9qjz"}],["path",{d:"m14.5 10.5-5 5",key:"l2rkpq"}]]);const ie=m("ShieldOff",[["path",{d:"m2 2 20 20",key:"1ooewy"}],["path",{d:"M5 5a1 1 0 0 0-1 1v7c0 5 3.5 7.5 7.67 8.94a1 1 0 0 0 .67.01c2.35-.82 4.48-1.97 5.9-3.71",key:"1jlk70"}],["path",{d:"M9.309 3.652A12.252 12.252 0 0 0 11.24 2.28a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1v7a9.784 9.784 0 0 1-.08 1.264",key:"18rp1v"}]]);const oe=m("UserCheck",[["path",{d:"M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2",key:"1yyitq"}],["circle",{cx:"9",cy:"7",r:"4",key:"nufk8"}],["polyline",{points:"16 11 18 13 22 9",key:"1pwet4"}]]);const le=m("UserX",[["path",{d:"M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2",key:"1yyitq"}],["circle",{cx:"9",cy:"7",r:"4",key:"nufk8"}],["line",{x1:"17",x2:"22",y1:"8",y2:"13",key:"3nzzx3"}],["line",{x1:"22",x2:"17",y1:"8",y2:"13",key:"1swrse"}]]);function de(p=10){const t="ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789!@#$%",a=crypto.getRandomValues(new Uint8Array(p));return Array.from(a,S=>t[S%t.length]).join("")}function be({currentUser:p,minPasswordLength:t}){const{t:a}=Y(),[S,J]=n.useState([]),[E,d]=n.useState(""),[V,C]=n.useState(!1),[r,o]=n.useState({username:"",displayName:"",password:"",role:"user",canCreateProjects:!1}),[k,g]=n.useState(null),[j,P]=n.useState(""),[x,w]=n.useState(null),[z,N]=n.useState(!1),[i,F]=n.useState(!1),[U,y]=n.useState(""),[l,f]=n.useState(null),[A,H]=n.useState(!1),[X,c]=n.useState(""),b=()=>u("/api/admin/users").then(({users:s})=>J(s)).catch(s=>d(h(s)));n.useEffect(()=>{b()},[]);const q=async()=>{if(r.password.length<t)return d(a("auth.passwordMinimum",{count:t}));try{await u("/api/admin/users",{method:"POST",body:JSON.stringify({username:r.username,displayName:r.displayName||r.username,password:r.password,role:r.role,canCreateProjects:r.canCreateProjects})}),C(!1),o({username:"",displayName:"",password:"",role:"user",canCreateProjects:!1}),await b()}catch(s){d(h(s))}},M=async(s,v)=>{try{await u(`/api/admin/users/${s.id}`,{method:"PATCH",body:JSON.stringify(v)}),await b()}catch(T){d(h(T))}},K=async s=>{if(!s.disabled){f({target:s,setting:"disable"}),c("");return}await M(s,{disabled:!1})},Z=async s=>{if(s.role==="admin"){f({target:s,setting:"demote"}),c("");return}await M(s,{role:"admin"})},G=async s=>{if(s.canCreateProjects){f({target:s,setting:"denyProjectCreation"}),c("");return}await M(s,{canCreateProjects:!0})},I=async()=>{if(!l)return;const{target:s,setting:v}=l,T=v==="disable"?{disabled:!0}:v==="demote"?{role:"user"}:{canCreateProjects:!1};H(!0),c("");try{await u(`/api/admin/users/${s.id}`,{method:"PATCH",body:JSON.stringify(T)}),await b(),f(null)}catch(W){c(h(W))}finally{H(!1)}},L=async()=>{if(!(!k||!j)){if(j.length<t)return d(a("auth.passwordMinimum",{count:t}));try{await u(`/api/admin/users/${k.id}`,{method:"PATCH",body:JSON.stringify({password:j})}),g(null),P("")}catch(s){d(h(s))}}},Q=async()=>{if(!(!x||i)){F(!0),y("");try{await u(`/api/admin/users/${x.id}`,{method:"DELETE",body:JSON.stringify({deleteProjects:z})}),w(null),N(!1),await b()}catch(s){y(h(s))}finally{F(!1)}}};return e.jsxs("main",{className:"dashboard",children:[e.jsxs("div",{className:"section-title",children:[e.jsxs("div",{children:[e.jsxs("h1",{children:[e.jsx(se,{"aria-hidden":!0,size:25}),a("users.manage")]}),e.jsx("p",{className:"muted",children:a("users.onlyAdmin")})]}),e.jsxs("button",{className:"primary icon-button",onClick:()=>C(!0),children:[e.jsx($,{"aria-hidden":!0,size:15}),a("users.add")]})]}),E&&e.jsx("p",{className:"error",children:E}),e.jsx("div",{className:"table-card",children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:a("common.user")}),e.jsx("th",{children:a("users.role")}),e.jsx("th",{children:a("users.createProjects")}),e.jsx("th",{children:a("users.ownedProjects")}),e.jsx("th",{children:a("users.status")}),e.jsx("th",{children:a("users.actions")})]})}),e.jsx("tbody",{children:S.map(s=>e.jsxs("tr",{children:[e.jsxs("td",{children:[e.jsx("strong",{children:s.displayName}),e.jsxs("small",{children:["@",s.username]})]}),e.jsx("td",{children:s.role==="admin"?a("common.admin"):a("common.user")}),e.jsx("td",{children:s.canCreateProjects?a("users.allow"):a("users.deny")}),e.jsx("td",{children:s.ownedProjects}),e.jsx("td",{children:s.disabled?a("common.disabled"):a("common.normal")}),e.jsxs("td",{children:[e.jsxs("button",{className:"icon-button",disabled:s.id===p.id,onClick:()=>K(s),children:[s.disabled?e.jsx(oe,{"aria-hidden":!0,size:13}):e.jsx(le,{"aria-hidden":!0,size:13}),s.disabled?a("users.enable"):a("users.disable")]}),e.jsxs("button",{className:"icon-button",disabled:s.id===p.id,onClick:()=>Z(s),children:[s.role==="admin"?e.jsx(ie,{"aria-hidden":!0,size:13}):e.jsx(ae,{"aria-hidden":!0,size:13}),s.role==="admin"?a("users.demote"):a("users.promote")]}),e.jsxs("button",{className:"icon-button",disabled:s.role==="admin",onClick:()=>G(s),children:[s.canCreateProjects?e.jsx(te,{"aria-hidden":!0,size:13}):e.jsx(ne,{"aria-hidden":!0,size:13}),s.canCreateProjects?a("users.denyCreate"):a("users.allowCreate")]}),e.jsxs("button",{className:"icon-button",onClick:()=>{g(s),P("")},children:[e.jsx(B,{"aria-hidden":!0,size:13}),a("users.resetPassword")]}),e.jsxs("button",{className:"icon-button danger-text",disabled:s.id===p.id,onClick:()=>{w(s),N(!1),y("")},children:[e.jsx(R,{"aria-hidden":!0,size:13}),a("common.delete")]})]})]},s.id))})]})}),e.jsx(D,{open:V,title:a("users.add"),description:a("users.addDescription"),onOpenChange:C,footer:e.jsxs(e.Fragment,{children:[e.jsxs("button",{className:"icon-button",onClick:()=>C(!1),children:[e.jsx(O,{"aria-hidden":!0,size:14}),a("common.cancel")]}),e.jsxs("button",{className:"primary icon-button",disabled:r.password.length<t,onClick:()=>{q()},children:[e.jsx($,{"aria-hidden":!0,size:14}),a("users.createUser")]})]}),children:e.jsxs("div",{className:"form-stack",children:[e.jsxs("label",{className:"form-field",children:[a("auth.username"),e.jsx("input",{value:r.username,onChange:s=>o({...r,username:s.target.value})})]}),e.jsxs("label",{className:"form-field",children:[a("users.displayName"),e.jsx("input",{value:r.displayName,onChange:s=>o({...r,displayName:s.target.value})})]}),e.jsxs("label",{className:"form-field",children:[a("users.initialPassword"),e.jsxs("span",{className:"password-generator",children:[e.jsx("input",{minLength:t,autoComplete:"new-password",value:r.password,onChange:s=>o({...r,password:s.target.value})}),e.jsxs("button",{type:"button",title:a("users.generatePassword"),onClick:()=>o({...r,password:de()}),children:[e.jsx(re,{size:15}),a("users.randomPassword")]})]}),e.jsx("small",{className:"field-hint",children:a("auth.passwordMinimum",{count:t})})]}),e.jsxs("label",{className:"form-field",children:[a("users.role"),e.jsxs("select",{value:r.role,onChange:s=>o({...r,role:s.target.value}),children:[e.jsx("option",{value:"user",children:a("common.user")}),e.jsx("option",{value:"admin",children:a("common.admin")})]})]}),e.jsxs("label",{className:"checkbox-field",children:[e.jsx("input",{type:"checkbox",checked:r.canCreateProjects||r.role==="admin",disabled:r.role==="admin",onChange:s=>o({...r,canCreateProjects:s.target.checked})})," ",a("users.allowCreate")]})]})}),e.jsx(D,{open:!!k,title:a("users.resetPassword"),description:a("users.resetDescription",{username:k?.username??""}),onOpenChange:s=>{s||g(null)},footer:e.jsxs(e.Fragment,{children:[e.jsxs("button",{className:"icon-button",onClick:()=>g(null),children:[e.jsx(O,{"aria-hidden":!0,size:14}),a("common.cancel")]}),e.jsxs("button",{className:"primary icon-button",disabled:j.length<t,onClick:()=>{L()},children:[e.jsx(B,{"aria-hidden":!0,size:14}),a("users.reset")]})]}),children:e.jsxs("label",{className:"form-field",children:[a("auth.newPassword"),e.jsx("input",{autoFocus:!0,type:"password",minLength:t,autoComplete:"new-password",value:j,onChange:s=>P(s.target.value)}),e.jsx("small",{className:"field-hint",children:a("auth.passwordMinimum",{count:t})})]})}),e.jsx(D,{open:!!x,title:a("users.deleteTitle"),description:a("users.deleteDescription",{username:x?.username??""}),onOpenChange:s=>{!s&&!i&&(w(null),y(""))},footer:e.jsxs(e.Fragment,{children:[e.jsxs("button",{className:"icon-button",disabled:i,onClick:()=>{w(null),y("")},children:[e.jsx(O,{"aria-hidden":!0,size:14}),a("common.cancel")]}),e.jsxs("button",{className:"danger icon-button",disabled:i,"aria-busy":i,onClick:()=>{Q()},children:[i?e.jsx(_,{className:"spin","aria-hidden":!0,size:14}):e.jsx(R,{"aria-hidden":!0,size:14}),a(i?"common.loading":"users.deleteTitle")]})]}),children:e.jsxs(e.Fragment,{children:[U&&e.jsx("p",{className:"error dialog-error",children:U}),e.jsxs("fieldset",{className:"choice-group",disabled:i,children:[e.jsx("legend",{children:a("users.ownedChoice",{count:x?.ownedProjects??0})}),e.jsxs("label",{children:[e.jsx("input",{type:"radio",checked:!z,onChange:()=>N(!1)})," ",a("users.transferProjects")]}),e.jsxs("label",{children:[e.jsx("input",{type:"radio",checked:z,onChange:()=>N(!0)})," ",a("users.deleteProjects")]})]})]})}),e.jsx(ee,{open:!!l,title:a(`userConfirmations.${l?.setting??"disable"}Title`),description:a(`userConfirmations.${l?.setting??"disable"}Description`,{username:l?.target.username??""}),confirmLabel:a(`userConfirmations.${l?.setting??"disable"}Confirm`),danger:!0,busy:A,error:X,onCancel:()=>{A||(f(null),c(""))},onConfirm:()=>{I()}})]})}export{be as AdminUsers};