@multiplatform.one/frappe-ui 7.19.0 → 7.21.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 (117) hide show
  1. package/dist/cjs/FrappeForm.cjs +80 -50
  2. package/dist/cjs/FrappeForm.native.js +82 -55
  3. package/dist/cjs/FrappeForm.native.js.map +1 -1
  4. package/dist/cjs/FrappeListView.cjs +6 -1
  5. package/dist/cjs/FrappeListView.native.js +6 -1
  6. package/dist/cjs/FrappeListView.native.js.map +1 -1
  7. package/dist/cjs/FrappeTable.cjs +10 -1
  8. package/dist/cjs/FrappeTable.native.js +10 -1
  9. package/dist/cjs/FrappeTable.native.js.map +1 -1
  10. package/dist/cjs/formEngine/docPermissions.cjs +83 -0
  11. package/dist/cjs/formEngine/docPermissions.native.js +155 -0
  12. package/dist/cjs/formEngine/docPermissions.native.js.map +1 -0
  13. package/dist/cjs/formEngine/index.cjs +9 -1
  14. package/dist/cjs/formEngine/index.native.js +8 -0
  15. package/dist/cjs/formEngine/index.native.js.map +1 -1
  16. package/dist/cjs/formEngine/useDocRights.cjs +54 -0
  17. package/dist/cjs/formEngine/useDocRights.native.js +60 -0
  18. package/dist/cjs/formEngine/useDocRights.native.js.map +1 -0
  19. package/dist/cjs/formEngine/useSessionRoles.cjs +47 -18
  20. package/dist/cjs/formEngine/useSessionRoles.native.js +53 -19
  21. package/dist/cjs/formEngine/useSessionRoles.native.js.map +1 -1
  22. package/dist/cjs/index.cjs +6 -0
  23. package/dist/cjs/index.native.js +6 -0
  24. package/dist/cjs/index.native.js.map +1 -1
  25. package/dist/esm/FrappeForm.mjs +82 -52
  26. package/dist/esm/FrappeForm.mjs.map +1 -1
  27. package/dist/esm/FrappeForm.native.js +84 -57
  28. package/dist/esm/FrappeForm.native.js.map +1 -1
  29. package/dist/esm/FrappeListView.mjs +6 -1
  30. package/dist/esm/FrappeListView.mjs.map +1 -1
  31. package/dist/esm/FrappeListView.native.js +6 -1
  32. package/dist/esm/FrappeListView.native.js.map +1 -1
  33. package/dist/esm/FrappeTable.mjs +10 -1
  34. package/dist/esm/FrappeTable.mjs.map +1 -1
  35. package/dist/esm/FrappeTable.native.js +10 -1
  36. package/dist/esm/FrappeTable.native.js.map +1 -1
  37. package/dist/esm/formEngine/docPermissions.mjs +55 -0
  38. package/dist/esm/formEngine/docPermissions.mjs.map +1 -0
  39. package/dist/esm/formEngine/docPermissions.native.js +124 -0
  40. package/dist/esm/formEngine/docPermissions.native.js.map +1 -0
  41. package/dist/esm/formEngine/index.mjs +4 -2
  42. package/dist/esm/formEngine/index.mjs.map +1 -1
  43. package/dist/esm/formEngine/index.native.js +4 -2
  44. package/dist/esm/formEngine/index.native.js.map +1 -1
  45. package/dist/esm/formEngine/useDocRights.mjs +29 -0
  46. package/dist/esm/formEngine/useDocRights.mjs.map +1 -0
  47. package/dist/esm/formEngine/useDocRights.native.js +32 -0
  48. package/dist/esm/formEngine/useDocRights.native.js.map +1 -0
  49. package/dist/esm/formEngine/useSessionRoles.mjs +47 -19
  50. package/dist/esm/formEngine/useSessionRoles.mjs.map +1 -1
  51. package/dist/esm/formEngine/useSessionRoles.native.js +53 -20
  52. package/dist/esm/formEngine/useSessionRoles.native.js.map +1 -1
  53. package/dist/esm/index.mjs +2 -2
  54. package/dist/esm/index.mjs.map +1 -1
  55. package/dist/esm/index.native.js +2 -2
  56. package/dist/esm/index.native.js.map +1 -1
  57. package/dist/jsx/FrappeForm.mjs +82 -52
  58. package/dist/jsx/FrappeForm.mjs.map +1 -1
  59. package/dist/jsx/FrappeForm.native.js +82 -55
  60. package/dist/jsx/FrappeForm.native.js.map +1 -1
  61. package/dist/jsx/FrappeListView.mjs +6 -1
  62. package/dist/jsx/FrappeListView.mjs.map +1 -1
  63. package/dist/jsx/FrappeListView.native.js +6 -1
  64. package/dist/jsx/FrappeListView.native.js.map +1 -1
  65. package/dist/jsx/FrappeTable.mjs +10 -1
  66. package/dist/jsx/FrappeTable.mjs.map +1 -1
  67. package/dist/jsx/FrappeTable.native.js +10 -1
  68. package/dist/jsx/FrappeTable.native.js.map +1 -1
  69. package/dist/jsx/formEngine/docPermissions.mjs +55 -0
  70. package/dist/jsx/formEngine/docPermissions.mjs.map +1 -0
  71. package/dist/jsx/formEngine/docPermissions.native.js +155 -0
  72. package/dist/jsx/formEngine/docPermissions.native.js.map +1 -0
  73. package/dist/jsx/formEngine/index.mjs +4 -2
  74. package/dist/jsx/formEngine/index.mjs.map +1 -1
  75. package/dist/jsx/formEngine/index.native.js +8 -0
  76. package/dist/jsx/formEngine/index.native.js.map +1 -1
  77. package/dist/jsx/formEngine/useDocRights.mjs +29 -0
  78. package/dist/jsx/formEngine/useDocRights.mjs.map +1 -0
  79. package/dist/jsx/formEngine/useDocRights.native.js +60 -0
  80. package/dist/jsx/formEngine/useDocRights.native.js.map +1 -0
  81. package/dist/jsx/formEngine/useSessionRoles.mjs +47 -19
  82. package/dist/jsx/formEngine/useSessionRoles.mjs.map +1 -1
  83. package/dist/jsx/formEngine/useSessionRoles.native.js +53 -19
  84. package/dist/jsx/formEngine/useSessionRoles.native.js.map +1 -1
  85. package/dist/jsx/index.js +2 -2
  86. package/dist/jsx/index.js.map +1 -1
  87. package/dist/jsx/index.mjs +2 -2
  88. package/dist/jsx/index.mjs.map +1 -1
  89. package/dist/jsx/index.native.js +6 -0
  90. package/dist/jsx/index.native.js.map +1 -1
  91. package/package.json +16 -16
  92. package/src/FrappeForm.tsx +94 -62
  93. package/src/FrappeListView.tsx +12 -7
  94. package/src/FrappeTable.rights.spec.tsx +154 -0
  95. package/src/FrappeTable.tsx +21 -2
  96. package/src/formEngine/docPermissions.spec.ts +145 -0
  97. package/src/formEngine/docPermissions.ts +128 -0
  98. package/src/formEngine/index.ts +14 -1
  99. package/src/formEngine/useDocRights.ts +41 -0
  100. package/src/formEngine/useSessionRoles.ts +55 -29
  101. package/src/frappeRoutes.spec.tsx +116 -4
  102. package/src/index.ts +14 -0
  103. package/types/FrappeForm.d.ts.map +1 -1
  104. package/types/FrappeListView.d.ts +3 -2
  105. package/types/FrappeListView.d.ts.map +1 -1
  106. package/types/FrappeTable.d.ts +11 -2
  107. package/types/FrappeTable.d.ts.map +1 -1
  108. package/types/formEngine/docPermissions.d.ts +54 -0
  109. package/types/formEngine/docPermissions.d.ts.map +1 -0
  110. package/types/formEngine/index.d.ts +6 -1
  111. package/types/formEngine/index.d.ts.map +1 -1
  112. package/types/formEngine/useDocRights.d.ts +24 -0
  113. package/types/formEngine/useDocRights.d.ts.map +1 -0
  114. package/types/formEngine/useSessionRoles.d.ts +12 -2
  115. package/types/formEngine/useSessionRoles.d.ts.map +1 -1
  116. package/types/index.d.ts +2 -2
  117. package/types/index.d.ts.map +1 -1
@@ -24,6 +24,8 @@ import { FilterManager, SavedFilterDropdown, useSavedFilters } from "@multiplatf
24
24
  import { useFrappeConfig } from "./FrappeProvider";
25
25
  import { FrappeReconnectIndicator } from "./FrappeReconnectIndicator";
26
26
  import { docFieldsToFilterFields } from "./filterFields";
27
+ import type { DocRights } from "./formEngine/docPermissions";
28
+ import { useDocRights } from "./formEngine/useDocRights";
27
29
  import { useFrappeRows } from "./useFrappeRows";
28
30
 
29
31
  /**
@@ -136,9 +138,17 @@ export interface FrappeTableProps<TDoc extends { name: string; doctype: string }
136
138
  * meta-derived menu offers the doctype's fields, including ones the
137
139
  * server refuses to filter on, so a live app should pass the honoured
138
140
  * set by hand. Passing it skips the meta read entirely, which also drops
139
- * title-field row labels back to the document name.
141
+ * title-field row labels back to the document name, unless the table
142
+ * still reads meta for `rights`.
140
143
  */
141
144
  filterableFields?: FilterableField[];
145
+ /**
146
+ * The session's rights on the doctype. Without write the cells render
147
+ * read-only, without create there is no add row, without delete no row
148
+ * delete. Omitted, the table resolves them from the doctype's meta and the
149
+ * session's roles; a fixtures connection withholds nothing.
150
+ */
151
+ rights?: Pick<DocRights, "create" | "write" | "delete">;
142
152
  }
143
153
 
144
154
  /**
@@ -326,6 +336,7 @@ export function FrappeTable<TDoc extends { name: string; doctype: string }>({
326
336
  paginationMode = "pages",
327
337
  loadPage: loadPageProp,
328
338
  filterableFields: filterableFieldsProp,
339
+ rights: rightsProp,
329
340
  layout,
330
341
  ...dataTableProps
331
342
  }: FrappeTableProps<TDoc>) {
@@ -424,13 +435,18 @@ export function FrappeTable<TDoc extends { name: string; doctype: string }>({
424
435
  // too: the read goes to its "DocType" collection with the baseURL a
425
436
  // fixture host may still carry withheld, so a corpus without the entry
426
437
  // yields no menu instead of dialling getdoctype (MPO-230).
427
- const skipMeta = !!filterableFieldsProp;
438
+ const skipMeta = !!filterableFieldsProp && (!!rightsProp || !!connectionConfig.fixtures);
428
439
  const metaConnection = useMemo(() => {
429
440
  if (skipMeta) return undefined;
430
441
  if (!connectionConfig.fixtures) return connectionConfig;
431
442
  return { ...connectionConfig, baseURL: undefined };
432
443
  }, [skipMeta, connectionConfig]);
433
444
  const { meta } = useDocTypeMeta(metaConnection, doctype);
445
+ const { rights: resolvedRights } = useDocRights(
446
+ rightsProp ? undefined : connectionConfig,
447
+ rightsProp ? undefined : meta,
448
+ );
449
+ const rights = rightsProp ?? resolvedRights;
434
450
  const metaFilterFields = useMemo(
435
451
  () => (meta?.fields ? docFieldsToFilterFields(meta.fields) : undefined),
436
452
  [meta],
@@ -867,6 +883,9 @@ export function FrappeTable<TDoc extends { name: string; doctype: string }>({
867
883
  {...(filterableFields ? { filterableFields } : {})}
868
884
  isFetching={isFetching}
869
885
  {...dataTableProps}
886
+ readOnly={dataTableProps.readOnly || !rights.write}
887
+ showAddRow={dataTableProps.showAddRow && rights.create}
888
+ showDeleteRow={dataTableProps.showDeleteRow && rights.delete}
870
889
  {...(layout ? { layout } : {})}
871
890
  />
872
891
  </YStack>
@@ -0,0 +1,145 @@
1
+ // The session's doc-level rights from getdoctype's DocPerm rows and the
2
+ // session roles (MPO-390), against frappe/permissions.py
3
+ // get_role_permissions: permlevel 0 only, OR across the user's roles,
4
+ // if_owner rows scoped to the user's own records except create.
5
+ import { describe, expect, it } from "vitest";
6
+ import { allDocRights, resolveDocRights, sessionDocRights, type DocRights } from "./docPermissions";
7
+ import type { DocPermissionRule } from "./doctypeMeta";
8
+
9
+ const pokeMove: DocPermissionRule[] = [
10
+ { role: "System Manager", read: 1, write: 1, create: 1, delete: 1, export: 1 },
11
+ { role: "Pokemon Editor", read: 1, write: 1, create: 1, delete: 1 },
12
+ { role: "Guest", read: 1 },
13
+ { role: "Move Author", read: 1, write: 1, create: 1, delete: 1, if_owner: 1 },
14
+ { role: "Move Curator", permlevel: 1, read: 1, write: 1 },
15
+ ];
16
+
17
+ function granted(rights: DocRights): string[] {
18
+ return Object.entries(rights)
19
+ .filter(([, value]) => value)
20
+ .map(([right]) => right)
21
+ .sort();
22
+ }
23
+
24
+ describe("resolveDocRights", () => {
25
+ it("grants a reader only what the Guest row grants", () => {
26
+ const rights = resolveDocRights({ permissions: pokeMove, roles: ["Guest", "All"] });
27
+ expect(granted(rights)).toEqual(["read"]);
28
+ });
29
+
30
+ it("grants create, write and delete through Pokemon Editor", () => {
31
+ const rights = resolveDocRights({
32
+ permissions: pokeMove,
33
+ roles: ["Pokemon Editor", "Guest", "All"],
34
+ user: "editor@pokemon.test",
35
+ owner: "Administrator",
36
+ });
37
+ expect(rights.create).toBe(true);
38
+ expect(rights.write).toBe(true);
39
+ expect(rights.delete).toBe(true);
40
+ expect(rights.export).toBe(false);
41
+ });
42
+
43
+ it("ORs the rights of every role the user holds", () => {
44
+ const rights = resolveDocRights({
45
+ permissions: pokeMove,
46
+ roles: ["Pokemon Editor", "System Manager"],
47
+ });
48
+ expect(rights.export).toBe(true);
49
+ expect(rights.delete).toBe(true);
50
+ });
51
+
52
+ it("ignores rows above permlevel 0", () => {
53
+ const rights = resolveDocRights({ permissions: pokeMove, roles: ["Move Curator"] });
54
+ expect(granted(rights)).toEqual([]);
55
+ });
56
+
57
+ it("scopes if_owner write and delete to the user's own records but keeps create", () => {
58
+ const base = { permissions: pokeMove, roles: ["Move Author"], user: "author@pokemon.test" };
59
+ const own = resolveDocRights({ ...base, owner: "author@pokemon.test" });
60
+ expect([own.create, own.read, own.write, own.delete]).toEqual([true, true, true, true]);
61
+
62
+ const others = resolveDocRights({ ...base, owner: "Administrator" });
63
+ expect([others.create, others.read, others.write, others.delete]).toEqual([
64
+ true,
65
+ false,
66
+ false,
67
+ false,
68
+ ]);
69
+
70
+ const doctype = resolveDocRights(base);
71
+ expect([doctype.create, doctype.read, doctype.write, doctype.delete]).toEqual([
72
+ true,
73
+ true,
74
+ false,
75
+ false,
76
+ ]);
77
+ });
78
+
79
+ it("lets a plain row lift the if_owner scope for the same right", () => {
80
+ const rights = resolveDocRights({
81
+ permissions: pokeMove,
82
+ roles: ["Move Author", "Pokemon Editor"],
83
+ user: "author@pokemon.test",
84
+ owner: "Administrator",
85
+ });
86
+ expect(rights.write).toBe(true);
87
+ expect(rights.delete).toBe(true);
88
+ });
89
+
90
+ it("gives Administrator every right, rows or not", () => {
91
+ expect(resolveDocRights({ permissions: [], roles: [], user: "Administrator" })).toEqual(
92
+ allDocRights(true),
93
+ );
94
+ });
95
+
96
+ it("adds read and write from a share for the user or for everyone, never delete", () => {
97
+ const reader = {
98
+ permissions: pokeMove,
99
+ roles: ["Guest"],
100
+ user: "reader@pokemon.test",
101
+ owner: "Administrator",
102
+ };
103
+ const shared = resolveDocRights({
104
+ ...reader,
105
+ shares: [{ user: "reader@pokemon.test", read: 1, write: 1 }],
106
+ });
107
+ expect([shared.write, shared.delete]).toEqual([true, false]);
108
+
109
+ const everyone = resolveDocRights({ ...reader, shares: [{ everyone: 1, write: 1 }] });
110
+ expect(everyone.write).toBe(true);
111
+
112
+ const someoneElse = resolveDocRights({
113
+ ...reader,
114
+ shares: [{ user: "other@pokemon.test", write: 1 }],
115
+ });
116
+ expect(someoneElse.write).toBe(false);
117
+ });
118
+ });
119
+
120
+ describe("sessionDocRights", () => {
121
+ const meta = { permissions: pokeMove };
122
+
123
+ it("withholds nothing on a fixtures connection", () => {
124
+ expect(sessionDocRights({ fixtures: true, meta: undefined, roles: undefined })).toEqual(
125
+ allDocRights(true),
126
+ );
127
+ });
128
+
129
+ it("grants nothing on a live connection without meta (Guest's getdoctype 403)", () => {
130
+ expect(sessionDocRights({ fixtures: false, meta: undefined, roles: ["Guest"] })).toEqual(
131
+ allDocRights(false),
132
+ );
133
+ });
134
+
135
+ it("grants nothing on a live connection whose roles are loading or unreadable", () => {
136
+ expect(sessionDocRights({ fixtures: false, meta, roles: undefined })).toEqual(
137
+ allDocRights(false),
138
+ );
139
+ });
140
+
141
+ it("resolves the session's roles once both are known", () => {
142
+ const rights = sessionDocRights({ fixtures: false, meta, roles: ["Pokemon Editor"] });
143
+ expect([rights.create, rights.write, rights.delete]).toEqual([true, true, true]);
144
+ });
145
+ });
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Doc-level rights for the session, the permlevel-0 half of desk's
3
+ * permission model (fieldPermissions.ts is the field-level half).
4
+ *
5
+ * Server parity with frappe/permissions.py `get_role_permissions`: a right is
6
+ * granted when a permlevel-0 DocPerm row of one of the session's roles grants
7
+ * it. When every granting row is `if_owner`, the right holds only on records
8
+ * the session user owns, except `create`, which an `if_owner` row still
9
+ * grants, and `read` / `select` at doctype level, which stay open so the list
10
+ * can be read (the server narrows it to the user's own rows). Administrator
11
+ * holds every right, as the server's `allow_everything()` does. A record's
12
+ * DocShare rows for the user or for everyone add read, write and share, as
13
+ * desk perm.js applies `docinfo.shared`.
14
+ */
15
+
16
+ import type { DocPermissionRule, DoctypeMeta } from "./doctypeMeta";
17
+
18
+ export const docRightNames = [
19
+ "select",
20
+ "read",
21
+ "write",
22
+ "create",
23
+ "delete",
24
+ "submit",
25
+ "cancel",
26
+ "amend",
27
+ "print",
28
+ "email",
29
+ "report",
30
+ "import",
31
+ "export",
32
+ "share",
33
+ ] as const;
34
+
35
+ export type DocRight = (typeof docRightNames)[number];
36
+
37
+ export type DocRights = Record<DocRight, boolean>;
38
+
39
+ /** A DocShare row as the live collection or docinfo carries it. */
40
+ export interface DocShareGrant {
41
+ user?: string | null;
42
+ everyone?: number | boolean | null;
43
+ read?: number | boolean | null;
44
+ write?: number | boolean | null;
45
+ share?: number | boolean | null;
46
+ }
47
+
48
+ export interface ResolveDocRightsInput {
49
+ permissions: readonly DocPermissionRule[] | undefined;
50
+ roles: readonly string[];
51
+ user?: string;
52
+ /** The record's owner. Omitted, the rights are the doctype's (a list, a new record). */
53
+ owner?: string;
54
+ shares?: readonly DocShareGrant[];
55
+ }
56
+
57
+ export function allDocRights(granted: boolean): DocRights {
58
+ const rights = {} as DocRights;
59
+ for (const right of docRightNames) rights[right] = granted;
60
+ return rights;
61
+ }
62
+
63
+ const openAtDoctypeLevel = new Set<DocRight>(["read", "select"]);
64
+
65
+ export function resolveDocRights({
66
+ permissions,
67
+ roles,
68
+ user,
69
+ owner,
70
+ shares,
71
+ }: ResolveDocRightsInput): DocRights {
72
+ if (user === "Administrator") return allDocRights(true);
73
+ const roleSet = new Set(roles);
74
+ const rows = (permissions ?? []).filter(
75
+ (row) => !!row.role && roleSet.has(row.role) && !(Number(row.permlevel ?? 0) || 0),
76
+ );
77
+ const isOwner = owner !== undefined && user !== undefined && owner === user;
78
+ const rights = allDocRights(false);
79
+ for (const right of docRightNames) {
80
+ const granting = rows.filter((row) => !!row[right]);
81
+ if (granting.length === 0) continue;
82
+ const ownerOnly = granting.every((row) => !!row.if_owner);
83
+ rights[right] =
84
+ !ownerOnly ||
85
+ right === "create" ||
86
+ isOwner ||
87
+ (owner === undefined && openAtDoctypeLevel.has(right));
88
+ }
89
+ if (user && owner !== undefined) {
90
+ for (const share of shares ?? []) {
91
+ if (share.user !== user && !share.everyone) continue;
92
+ if (share.read) rights.read = true;
93
+ if (share.write) rights.write = true;
94
+ if (share.share) rights.share = true;
95
+ }
96
+ }
97
+ return rights;
98
+ }
99
+
100
+ export interface SessionDocRightsInput {
101
+ /** A fixtures-only connection: no session exists, so nothing is withheld. */
102
+ fixtures: boolean;
103
+ meta: Pick<DoctypeMeta, "permissions"> | undefined;
104
+ /** The session's roles; undefined while they load or when they can't be read. */
105
+ roles: readonly string[] | undefined;
106
+ user?: string;
107
+ owner?: string;
108
+ shares?: readonly DocShareGrant[];
109
+ }
110
+
111
+ /**
112
+ * The rights a surface offers. A fixtures connection keeps every action; a
113
+ * live one grants nothing until both the doctype's meta and the session's
114
+ * roles are known, so a reader who can't load meta (Guest) or whose roles
115
+ * can't be read is shown a read-only surface.
116
+ */
117
+ export function sessionDocRights({
118
+ fixtures,
119
+ meta,
120
+ roles,
121
+ user,
122
+ owner,
123
+ shares,
124
+ }: SessionDocRightsInput): DocRights {
125
+ if (fixtures) return allDocRights(true);
126
+ if (!meta || !roles) return allDocRights(false);
127
+ return resolveDocRights({ permissions: meta.permissions, roles, user, owner, shares });
128
+ }
@@ -32,4 +32,17 @@ export type { BuildTabsOptions, FormTab } from "./formLayout";
32
32
  export { useFormEngine } from "./useFormEngine";
33
33
  export type { FormEngine } from "./useFormEngine";
34
34
 
35
- export { useSessionRoles } from "./useSessionRoles";
35
+ export { useFrappeSession, useSessionRoles } from "./useSessionRoles";
36
+ export type { FrappeSession } from "./useSessionRoles";
37
+
38
+ export { allDocRights, docRightNames, resolveDocRights, sessionDocRights } from "./docPermissions";
39
+ export type {
40
+ DocRight,
41
+ DocRights,
42
+ DocShareGrant,
43
+ ResolveDocRightsInput,
44
+ SessionDocRightsInput,
45
+ } from "./docPermissions";
46
+
47
+ export { useDocRights } from "./useDocRights";
48
+ export type { DocRightsOptions, DocRightsResult } from "./useDocRights";
@@ -0,0 +1,41 @@
1
+ import { useMemo } from "react";
2
+ import { sessionDocRights, type DocRights, type DocShareGrant } from "./docPermissions";
3
+ import type { DoctypeMeta } from "./doctypeMeta";
4
+ import { useFrappeSession, type SessionConnection } from "./useSessionRoles";
5
+
6
+ export interface DocRightsOptions {
7
+ /** Roles to resolve against instead of the session's. */
8
+ roles?: string[];
9
+ /** The record's owner; omitted for the doctype's rights. */
10
+ owner?: string;
11
+ shares?: readonly DocShareGrant[];
12
+ }
13
+
14
+ export interface DocRightsResult {
15
+ rights: DocRights;
16
+ /** The session's roles are still loading, so `rights` withholds what they may grant. */
17
+ pending: boolean;
18
+ user?: string;
19
+ roles?: string[];
20
+ }
21
+
22
+ /**
23
+ * The session's rights on a doctype, or on one record of it, from the
24
+ * doctype's DocPerm rows (`meta.permissions`) and the session's roles.
25
+ * See `sessionDocRights` for the fixtures and unknown-session rules.
26
+ */
27
+ export function useDocRights(
28
+ connection: SessionConnection | undefined,
29
+ meta: DoctypeMeta | undefined,
30
+ { roles: rolesOverride, owner, shares }: DocRightsOptions = {},
31
+ ): DocRightsResult {
32
+ const fixtures = Boolean(connection?.fixtures);
33
+ const session = useFrappeSession(meta && !fixtures ? connection : undefined);
34
+ const roles = rolesOverride ?? session.roles;
35
+ const rights = useMemo(
36
+ () => sessionDocRights({ fixtures, meta, roles, user: session.user, owner, shares }),
37
+ [fixtures, meta, roles, session.user, owner, shares],
38
+ );
39
+ const pending = !fixtures && Boolean(meta) && !rolesOverride && session.state === "loading";
40
+ return { rights, pending, user: session.user, roles };
41
+ }
@@ -13,61 +13,87 @@
13
13
  import { useEffect, useRef, useState } from "react";
14
14
  import { createHttpClient, type FrappeConnection } from "./formActions";
15
15
 
16
- const rolesCache = new Map<string, Promise<string[]>>();
16
+ interface SessionIdentity {
17
+ user: string;
18
+ roles: string[];
19
+ }
20
+
21
+ const sessionCache = new Map<string, Promise<SessionIdentity>>();
17
22
 
18
- function fetchSessionRoles(connection: FrappeConnection): Promise<string[]> {
23
+ function fetchSession(connection: FrappeConnection): Promise<SessionIdentity> {
19
24
  const http = createHttpClient(connection);
20
- return http
21
- .get<{ message?: string }>("/api/method/frappe.auth.get_logged_user")
22
- .then((res) => {
23
- const user = res.payload?.message;
24
- if (!user) throw new Error("No session user");
25
- return http.get<{ message?: string[] }>(
26
- "/api/method/frappe.core.doctype.user.user.get_roles",
27
- { uid: user },
28
- );
29
- })
30
- .then((res) => {
31
- const list = res.payload?.message;
32
- if (!Array.isArray(list)) throw new Error("No roles returned");
33
- return list;
34
- });
25
+ return http.get<{ message?: string }>("/api/method/frappe.auth.get_logged_user").then((res) => {
26
+ const user = res.payload?.message;
27
+ if (!user || typeof user !== "string") throw new Error("No session user");
28
+ return http
29
+ .get<{ message?: string[] }>("/api/method/frappe.core.doctype.user.user.get_roles", {
30
+ uid: user,
31
+ })
32
+ .then((rolesRes) => {
33
+ const roles = rolesRes.payload?.message;
34
+ if (!Array.isArray(roles)) throw new Error("No roles returned");
35
+ return { user, roles };
36
+ });
37
+ });
35
38
  }
36
39
 
37
40
  /** A connection that may not name a bench yet (a provider's config before it resolves). */
38
- type SessionConnection = Omit<FrappeConnection, "baseURL"> & { baseURL?: string };
41
+ export type SessionConnection = Omit<FrappeConnection, "baseURL"> & { baseURL?: string };
39
42
 
40
- export function useSessionRoles(connection: SessionConnection | undefined): string[] | undefined {
43
+ export interface FrappeSession {
44
+ user?: string;
45
+ roles?: string[];
46
+ /**
47
+ * `idle`: no bench to ask (no connection, a fixtures-only one, or none
48
+ * given). `loading`: asked, not answered. `ready`: user and roles known.
49
+ * `failed`: the bench answered without a session (Guest) or refused.
50
+ */
51
+ state: "idle" | "loading" | "ready" | "failed";
52
+ }
53
+
54
+ const idleSession: FrappeSession = { state: "idle" };
55
+ const loadingSession: FrappeSession = { state: "loading" };
56
+
57
+ export function useFrappeSession(connection: SessionConnection | undefined): FrappeSession {
41
58
  const baseURL = connection?.baseURL;
42
59
  const fixtures = connection?.fixtures;
43
- const [roles, setRoles] = useState<string[] | undefined>(undefined);
60
+ const active = Boolean(connection) && !fixtures && Boolean(baseURL);
61
+ const [session, setSession] = useState<FrappeSession>(() =>
62
+ active ? { state: "loading" } : idleSession,
63
+ );
44
64
  const connectionRef = useRef(connection);
45
65
  connectionRef.current = connection;
46
66
 
47
67
  useEffect(() => {
48
68
  const current = connectionRef.current;
49
69
  if (!current || fixtures || !baseURL) {
50
- setRoles(undefined);
70
+ setSession(idleSession);
51
71
  return;
52
72
  }
53
73
  let cancelled = false;
54
- let promise = rolesCache.get(baseURL);
74
+ setSession((prev) => (prev.state === "loading" ? prev : { state: "loading" }));
75
+ let promise = sessionCache.get(baseURL);
55
76
  if (!promise) {
56
- promise = fetchSessionRoles({ ...current, baseURL });
57
- rolesCache.set(baseURL, promise);
58
- promise.catch(() => rolesCache.delete(baseURL));
77
+ promise = fetchSession({ ...current, baseURL });
78
+ sessionCache.set(baseURL, promise);
79
+ promise.catch(() => sessionCache.delete(baseURL));
59
80
  }
60
81
  promise
61
- .then((list) => {
62
- if (!cancelled) setRoles(list);
82
+ .then(({ user, roles }) => {
83
+ if (!cancelled) setSession({ user, roles, state: "ready" });
63
84
  })
64
85
  .catch(() => {
65
- if (!cancelled) setRoles(undefined);
86
+ if (!cancelled) setSession({ state: "failed" });
66
87
  });
67
88
  return () => {
68
89
  cancelled = true;
69
90
  };
70
91
  }, [baseURL, fixtures]);
71
92
 
72
- return roles;
93
+ if (!active) return idleSession;
94
+ return session.state === "idle" ? loadingSession : session;
95
+ }
96
+
97
+ export function useSessionRoles(connection: SessionConnection | undefined): string[] | undefined {
98
+ return useFrappeSession(connection).roles;
73
99
  }