@oneuptime/common 12.0.3 → 12.0.4

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 (173) hide show
  1. package/Models/DatabaseModels/CephCluster.ts +14 -0
  2. package/Models/DatabaseModels/CodeRepository.ts +14 -6
  3. package/Models/DatabaseModels/DockerHost.ts +14 -0
  4. package/Models/DatabaseModels/GlobalOidcProject.ts +18 -0
  5. package/Models/DatabaseModels/GlobalSsoProject.ts +18 -0
  6. package/Models/DatabaseModels/IoTFleet.ts +14 -0
  7. package/Models/DatabaseModels/KubernetesCluster.ts +14 -0
  8. package/Models/DatabaseModels/NetworkDevice.ts +14 -0
  9. package/Models/DatabaseModels/NetworkInterface.ts +18 -0
  10. package/Models/DatabaseModels/Project.ts +16 -2
  11. package/Models/DatabaseModels/ProxmoxCluster.ts +14 -0
  12. package/Models/DatabaseModels/Service.ts +30 -0
  13. package/Models/DatabaseModels/StatusPagePrivateUser.ts +30 -0
  14. package/Models/DatabaseModels/User.ts +30 -0
  15. package/Server/API/AIAgentDataAPI.ts +31 -0
  16. package/Server/API/DashboardAPI.ts +419 -12
  17. package/Server/API/GitHubAPI.ts +119 -283
  18. package/Server/API/UserAPI.ts +263 -1
  19. package/Server/EnvironmentConfig.ts +7 -3
  20. package/Server/Infrastructure/GlobalCache.ts +37 -0
  21. package/Server/Infrastructure/Postgres/SchemaMigrations/1786018109307-AddPerUserPasswordSalt.ts +21 -0
  22. package/Server/Infrastructure/Postgres/SchemaMigrations/1786023262402-WidenHashedStringColumnsForScrypt.ts +68 -0
  23. package/Server/Infrastructure/Postgres/SchemaMigrations/1786100000000-RestoreServiceLowerNameIndex.ts +100 -0
  24. package/Server/Infrastructure/Postgres/SchemaMigrations/1786200000000-RestoreDroppedUniqueIndexes.ts +214 -0
  25. package/Server/Infrastructure/Postgres/SchemaMigrations/1786300000000-QuarantineUnboundGitHubInstallations.ts +69 -0
  26. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +10 -0
  27. package/Server/Services/AnalyticsDatabaseService.ts +72 -1
  28. package/Server/Services/CodeRepositoryService.ts +105 -2
  29. package/Server/Services/DatabaseService.ts +185 -2
  30. package/Server/Services/OpenTelemetryIngestService.ts +230 -26
  31. package/Server/Services/UserService.ts +184 -0
  32. package/Server/Utils/AI/Toolbox/CodeTools.ts +45 -4
  33. package/Server/Utils/AnalyticsDatabase/StatementGenerator.ts +13 -0
  34. package/Server/Utils/CodeRepository/GitHub/GitHub.ts +157 -4
  35. package/Server/Utils/CodeRepository/GitHub/GitHubInstallationBinding.ts +130 -0
  36. package/Server/Utils/PasswordHash.ts +306 -0
  37. package/Tests/App/Dashboard/OverviewCustomFields.test.tsx +333 -0
  38. package/Tests/App/Dashboard/UserCustomFields.test.tsx +392 -0
  39. package/Tests/Server/API/AIAgentDataRepositoryToken.test.ts +381 -0
  40. package/Tests/Server/API/DashboardPublicAttributeValuesAPI.test.ts +943 -0
  41. package/Tests/Server/API/DashboardPublicMetricsAggregateAPI.test.ts +1296 -0
  42. package/Tests/Server/API/DashboardPublicTemplatePayloads.test.ts +598 -0
  43. package/Tests/Server/API/GitHubAppInstallationBindingAPI.test.ts +539 -0
  44. package/Tests/Server/API/Helpers.ts +6 -1
  45. package/Tests/Server/API/UserProjectsAPI.test.ts +852 -0
  46. package/Tests/Server/Infrastructure/GlobalCache.test.ts +128 -0
  47. package/Tests/Server/Infrastructure/SemaphoreMutex.test.ts +215 -0
  48. package/Tests/Server/Services/AddPerUserPasswordSaltMigration.test.ts +178 -0
  49. package/Tests/Server/Services/AnalyticsDatabasePaginationStability.test.ts +582 -0
  50. package/Tests/Server/Services/AnalyticsDatabaseService.test.ts +5 -0
  51. package/Tests/Server/Services/CodeRepositoryInstallationBinding.test.ts +305 -0
  52. package/Tests/Server/Services/CodeRepositoryResolutionBinding.test.ts +147 -0
  53. package/Tests/Server/Services/DatabaseServicePerUserPasswordSalt.test.ts +870 -0
  54. package/Tests/Server/Services/OpenTelemetryServiceResolutionCache.test.ts +568 -0
  55. package/Tests/Server/Services/RestoreDroppedUniqueIndexesMigration.test.ts +386 -0
  56. package/Tests/Server/Services/RestoreServiceLowerNameIndexMigration.test.ts +240 -0
  57. package/Tests/Server/Services/UserServiceFirstMasterAdminElection.test.ts +885 -0
  58. package/Tests/Server/Services/WidenHashedStringColumnsForScryptMigration.test.ts +220 -0
  59. package/Tests/Server/Utils/AI/CodeTools.test.ts +47 -1
  60. package/Tests/Server/Utils/AI/CodeWriteTools.test.ts +10 -0
  61. package/Tests/Server/Utils/AnalyticsDatabase/StatementGenerator.test.ts +120 -0
  62. package/Tests/Server/Utils/CodeRepository/GitHubInstallationBinding.test.ts +292 -0
  63. package/Tests/Server/Utils/GitHubInstallationOwnershipVerification.test.ts +340 -0
  64. package/Tests/Server/Utils/GitHubWebhookAndTreeCacheIsolation.test.ts +250 -0
  65. package/Tests/Server/Utils/PasswordHash.test.ts +555 -0
  66. package/Tests/Types/Database/ColumnLength.test.ts +7 -1
  67. package/Tests/Types/HashedStringPerUserSalt.test.ts +476 -0
  68. package/Tests/Types/Monitor/DockerAlertTemplates.test.ts +293 -0
  69. package/Tests/Types/Monitor/HostAlertTemplates.test.ts +249 -0
  70. package/Tests/Types/Monitor/IotAlertTemplates.test.ts +320 -0
  71. package/Tests/Types/Monitor/PodmanAlertTemplates.test.ts +271 -0
  72. package/Tests/UI/Components/CustomFields/CustomFieldsDetail.test.tsx +696 -0
  73. package/Tests/UI/Utils/UserProjectsModelAPI.test.ts +714 -0
  74. package/Tests/Utils/TeamMembersByProject.test.ts +655 -0
  75. package/Types/Database/ColumnLength.ts +9 -1
  76. package/Types/Database/TableColumn.ts +11 -0
  77. package/Types/Database/UnsynchronizedIndex.ts +48 -0
  78. package/Types/HashedString.ts +139 -4
  79. package/UI/Components/CustomFields/CustomFieldsDetail.tsx +85 -11
  80. package/UI/Components/Navbar/NavBar.tsx +12 -2
  81. package/UI/Components/Navbar/NavBarMenuModal.tsx +19 -7
  82. package/UI/Utils/ModelAPI/UserProjectsModelAPI.ts +257 -0
  83. package/Utils/TeamMembersByProject.ts +237 -0
  84. package/build/dist/Models/DatabaseModels/CephCluster.js +16 -1
  85. package/build/dist/Models/DatabaseModels/CephCluster.js.map +1 -1
  86. package/build/dist/Models/DatabaseModels/CodeRepository.js +14 -6
  87. package/build/dist/Models/DatabaseModels/CodeRepository.js.map +1 -1
  88. package/build/dist/Models/DatabaseModels/DockerHost.js +16 -1
  89. package/build/dist/Models/DatabaseModels/DockerHost.js.map +1 -1
  90. package/build/dist/Models/DatabaseModels/GlobalOidcProject.js +16 -1
  91. package/build/dist/Models/DatabaseModels/GlobalOidcProject.js.map +1 -1
  92. package/build/dist/Models/DatabaseModels/GlobalSsoProject.js +16 -1
  93. package/build/dist/Models/DatabaseModels/GlobalSsoProject.js.map +1 -1
  94. package/build/dist/Models/DatabaseModels/IoTFleet.js +16 -1
  95. package/build/dist/Models/DatabaseModels/IoTFleet.js.map +1 -1
  96. package/build/dist/Models/DatabaseModels/KubernetesCluster.js +16 -1
  97. package/build/dist/Models/DatabaseModels/KubernetesCluster.js.map +1 -1
  98. package/build/dist/Models/DatabaseModels/NetworkDevice.js +16 -1
  99. package/build/dist/Models/DatabaseModels/NetworkDevice.js.map +1 -1
  100. package/build/dist/Models/DatabaseModels/NetworkInterface.js +16 -1
  101. package/build/dist/Models/DatabaseModels/NetworkInterface.js.map +1 -1
  102. package/build/dist/Models/DatabaseModels/Project.js +16 -2
  103. package/build/dist/Models/DatabaseModels/Project.js.map +1 -1
  104. package/build/dist/Models/DatabaseModels/ProxmoxCluster.js +16 -1
  105. package/build/dist/Models/DatabaseModels/ProxmoxCluster.js.map +1 -1
  106. package/build/dist/Models/DatabaseModels/Service.js +28 -1
  107. package/build/dist/Models/DatabaseModels/Service.js.map +1 -1
  108. package/build/dist/Models/DatabaseModels/StatusPagePrivateUser.js +32 -0
  109. package/build/dist/Models/DatabaseModels/StatusPagePrivateUser.js.map +1 -1
  110. package/build/dist/Models/DatabaseModels/User.js +32 -0
  111. package/build/dist/Models/DatabaseModels/User.js.map +1 -1
  112. package/build/dist/Server/API/AIAgentDataAPI.js +21 -2
  113. package/build/dist/Server/API/AIAgentDataAPI.js.map +1 -1
  114. package/build/dist/Server/API/DashboardAPI.js +304 -10
  115. package/build/dist/Server/API/DashboardAPI.js.map +1 -1
  116. package/build/dist/Server/API/GitHubAPI.js +94 -167
  117. package/build/dist/Server/API/GitHubAPI.js.map +1 -1
  118. package/build/dist/Server/API/UserAPI.js +187 -2
  119. package/build/dist/Server/API/UserAPI.js.map +1 -1
  120. package/build/dist/Server/EnvironmentConfig.js +7 -3
  121. package/build/dist/Server/EnvironmentConfig.js.map +1 -1
  122. package/build/dist/Server/Infrastructure/GlobalCache.js +29 -0
  123. package/build/dist/Server/Infrastructure/GlobalCache.js.map +1 -1
  124. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786018109307-AddPerUserPasswordSalt.js +14 -0
  125. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786018109307-AddPerUserPasswordSalt.js.map +1 -0
  126. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786023262402-WidenHashedStringColumnsForScrypt.js +59 -0
  127. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786023262402-WidenHashedStringColumnsForScrypt.js.map +1 -0
  128. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786100000000-RestoreServiceLowerNameIndex.js +93 -0
  129. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786100000000-RestoreServiceLowerNameIndex.js.map +1 -0
  130. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786200000000-RestoreDroppedUniqueIndexes.js +101 -0
  131. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786200000000-RestoreDroppedUniqueIndexes.js.map +1 -0
  132. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786300000000-QuarantineUnboundGitHubInstallations.js +66 -0
  133. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786300000000-QuarantineUnboundGitHubInstallations.js.map +1 -0
  134. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +10 -0
  135. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  136. package/build/dist/Server/Services/AnalyticsDatabaseService.js +57 -1
  137. package/build/dist/Server/Services/AnalyticsDatabaseService.js.map +1 -1
  138. package/build/dist/Server/Services/CodeRepositoryService.js +90 -2
  139. package/build/dist/Server/Services/CodeRepositoryService.js.map +1 -1
  140. package/build/dist/Server/Services/DatabaseService.js +160 -2
  141. package/build/dist/Server/Services/DatabaseService.js.map +1 -1
  142. package/build/dist/Server/Services/OpenTelemetryIngestService.js +155 -25
  143. package/build/dist/Server/Services/OpenTelemetryIngestService.js.map +1 -1
  144. package/build/dist/Server/Services/UserService.js +165 -0
  145. package/build/dist/Server/Services/UserService.js.map +1 -1
  146. package/build/dist/Server/Utils/AI/Toolbox/CodeTools.js +31 -1
  147. package/build/dist/Server/Utils/AI/Toolbox/CodeTools.js.map +1 -1
  148. package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js +13 -0
  149. package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js.map +1 -1
  150. package/build/dist/Server/Utils/CodeRepository/GitHub/GitHub.js +126 -4
  151. package/build/dist/Server/Utils/CodeRepository/GitHub/GitHub.js.map +1 -1
  152. package/build/dist/Server/Utils/CodeRepository/GitHub/GitHubInstallationBinding.js +136 -0
  153. package/build/dist/Server/Utils/CodeRepository/GitHub/GitHubInstallationBinding.js.map +1 -0
  154. package/build/dist/Server/Utils/PasswordHash.js +237 -0
  155. package/build/dist/Server/Utils/PasswordHash.js.map +1 -0
  156. package/build/dist/Types/Database/ColumnLength.js +9 -1
  157. package/build/dist/Types/Database/ColumnLength.js.map +1 -1
  158. package/build/dist/Types/Database/TableColumn.js.map +1 -1
  159. package/build/dist/Types/Database/UnsynchronizedIndex.js +46 -0
  160. package/build/dist/Types/Database/UnsynchronizedIndex.js.map +1 -0
  161. package/build/dist/Types/HashedString.js +95 -5
  162. package/build/dist/Types/HashedString.js.map +1 -1
  163. package/build/dist/UI/Components/CustomFields/CustomFieldsDetail.js +51 -10
  164. package/build/dist/UI/Components/CustomFields/CustomFieldsDetail.js.map +1 -1
  165. package/build/dist/UI/Components/Navbar/NavBar.js +3 -1
  166. package/build/dist/UI/Components/Navbar/NavBar.js.map +1 -1
  167. package/build/dist/UI/Components/Navbar/NavBarMenuModal.js +19 -7
  168. package/build/dist/UI/Components/Navbar/NavBarMenuModal.js.map +1 -1
  169. package/build/dist/UI/Utils/ModelAPI/UserProjectsModelAPI.js +156 -0
  170. package/build/dist/UI/Utils/ModelAPI/UserProjectsModelAPI.js.map +1 -0
  171. package/build/dist/Utils/TeamMembersByProject.js +145 -0
  172. package/build/dist/Utils/TeamMembersByProject.js.map +1 -0
  173. package/package.json +1 -1
@@ -0,0 +1,48 @@
1
+ import { IndexOptions } from "typeorm";
2
+
3
+ /*
4
+ * Options for an index whose definition TypeORM cannot express, and which must
5
+ * therefore be owned by a migration rather than by the schema builder.
6
+ *
7
+ * WHY THIS EXISTS
8
+ *
9
+ * TypeORM's schema builder matches database indexes to entity metadata BY NAME
10
+ * (RdbmsSchemaBuilder.dropOldIndices: `metadata.indices.find((i) => i.name ===
11
+ * tableIndex.name)`) and DROPS every index it cannot find a match for. An index
12
+ * created only in a migration is invisible to entity metadata, so the next
13
+ * `npm run generate-postgres-migration` emits a DROP for it — silently, mixed
14
+ * in with whatever change was actually being made.
15
+ *
16
+ * That is not hypothetical. An audit of every hand-named index ever created by
17
+ * a migration against a fully-migrated database found ten that had been deleted
18
+ * this way and never restored, including the unique guards on six `slug`
19
+ * columns and the LOWER(name) index serving the telemetry ingest hot path.
20
+ * Migration 1775735059360 is a representative example: two unrelated column
21
+ * default changes, plus a DROP of an expression index, plus a down() that
22
+ * "restores" it without the expression.
23
+ *
24
+ * HOW THIS FIXES IT
25
+ *
26
+ * Declaring the index on the entity BY NAME with these options makes the schema
27
+ * builder skip it in both directions: `dropOldIndices` returns early on
28
+ * `synchronize === false`, and `createNewIndices` only considers indexes with
29
+ * `synchronize === true`. The migration stays the single source of truth for
30
+ * the real definition, and the declaration exists purely so TypeORM leaves it
31
+ * alone.
32
+ *
33
+ * The columns passed alongside these options are documentation — the schema
34
+ * builder never reads them for an unsynchronized index. Where the real
35
+ * definition is partial or an expression, say so in a comment at the call site.
36
+ *
37
+ * THE CAST
38
+ *
39
+ * `synchronize` is a real, supported option: the @Index decorator forwards it
40
+ * into IndexMetadataArgs (typeorm/decorator/Index.js), IndexMetadataArgs
41
+ * declares it, and RdbmsSchemaBuilder reads it. Only the public IndexOptions
42
+ * type omits it — a gap in TypeORM's own typings rather than a misuse.
43
+ */
44
+ const UNSYNCHRONIZED_INDEX: IndexOptions = {
45
+ synchronize: false,
46
+ } as IndexOptions;
47
+
48
+ export default UNSYNCHRONIZED_INDEX;
@@ -7,6 +7,29 @@ import ObjectID from "./ObjectID";
7
7
  import CryptoJS from "crypto-js";
8
8
  import { FindOperator } from "typeorm";
9
9
 
10
+ /*
11
+ * SHA-256 hashing for values that are ALREADY high-entropy: session refresh
12
+ * tokens, email verification tokens, reset tokens, permission fingerprints.
13
+ * Those are random values the server minted, so there is nothing to guess and
14
+ * a fast hash is the right tool — it also keeps them searchable by hash,
15
+ * which is how they are looked up.
16
+ *
17
+ * User passwords are NOT hashed here any more. A password is low-entropy and
18
+ * guessable, so it goes through scrypt in Common/Server/Utils/PasswordHash,
19
+ * which is deliberately slow and memory-hard. What remains in this class is
20
+ * the two SHA-256 schemes passwords used to be stored under, kept so those
21
+ * rows still verify (and are then upgraded) at their owner's next login:
22
+ *
23
+ * no salt `SHA256(secret + value)`, from before per-user salts existed.
24
+ * salted `SHA256("v2:" + salt + ":" + secret + ":" + value)`.
25
+ *
26
+ * Neither records its scheme in the digest — it is a bare hex SHA-256 either
27
+ * way — so they are told apart by whether the row carries a salt. The "v2"
28
+ * prefix and the separators exist for domain separation: without them a
29
+ * crafted salt could make a salted input collide with an unsalted one.
30
+ */
31
+ const SALTED_HASH_SCHEME_VERSION: string = "v2";
32
+
10
33
  export default class HashedString extends DatabaseProperty {
11
34
  private isHashed: boolean = false;
12
35
 
@@ -65,7 +88,62 @@ export default class HashedString extends DatabaseProperty {
65
88
  return this.isHashed;
66
89
  }
67
90
 
68
- public async hashValue(encryptionSecret: ObjectID | null): Promise<string> {
91
+ /**
92
+ * Adopt an already-computed digest.
93
+ *
94
+ * For hashes this class does not compute itself — user passwords go
95
+ * through scrypt in Common/Server/Utils/PasswordHash, which is server-only
96
+ * because this type is also bundled into the browser. The guard is the
97
+ * same one hashValue() carries: a value is hashed once and never re-hashed
98
+ * over the top of itself.
99
+ */
100
+ public setHashedValue(hashedValue: string): void {
101
+ if (this.isHashed) {
102
+ throw new BadOperationException("Value is already hashed");
103
+ }
104
+
105
+ this.isHashed = true;
106
+ this.value = hashedValue;
107
+ }
108
+
109
+ /**
110
+ * The exact byte string fed to SHA-256.
111
+ *
112
+ * The unsalted branch MUST stay byte-for-byte identical forever: every
113
+ * password hashed before per-user salts existed is verified through it.
114
+ */
115
+ private static buildValueToHash(
116
+ value: string,
117
+ encryptionSecret: ObjectID | null,
118
+ salt: string | null,
119
+ ): string {
120
+ if (!salt) {
121
+ return (encryptionSecret || "") + value;
122
+ }
123
+
124
+ return `${SALTED_HASH_SCHEME_VERSION}:${salt}:${
125
+ encryptionSecret || ""
126
+ }:${value}`;
127
+ }
128
+
129
+ public static computeHash(
130
+ value: string,
131
+ encryptionSecret: ObjectID | null,
132
+ salt?: string | null,
133
+ ): string {
134
+ if (!value) {
135
+ return "";
136
+ }
137
+
138
+ return CryptoJS.SHA256(
139
+ HashedString.buildValueToHash(value, encryptionSecret, salt || null),
140
+ ).toString();
141
+ }
142
+
143
+ public async hashValue(
144
+ encryptionSecret: ObjectID | null,
145
+ salt?: string | null,
146
+ ): Promise<string> {
69
147
  if (!this.value) {
70
148
  return "";
71
149
  }
@@ -74,19 +152,76 @@ export default class HashedString extends DatabaseProperty {
74
152
  throw new BadOperationException("Value is already hashed");
75
153
  }
76
154
 
77
- const valueToHash: string = (encryptionSecret || "") + this.value;
78
155
  this.isHashed = true;
79
156
 
80
- this.value = CryptoJS.SHA256(valueToHash).toString();
157
+ this.value = HashedString.computeHash(
158
+ this.value,
159
+ encryptionSecret,
160
+ salt || null,
161
+ );
81
162
  return this.value;
82
163
  }
83
164
 
84
165
  public static async hashValue(
85
166
  value: string,
86
167
  encryptionSecret: ObjectID | null,
168
+ salt?: string | null,
87
169
  ): Promise<string> {
88
170
  const hashstring: HashedString = new HashedString(value, false);
89
- return await hashstring.hashValue(encryptionSecret);
171
+ return await hashstring.hashValue(encryptionSecret, salt || null);
172
+ }
173
+
174
+ /**
175
+ * Compare two already-hashed values without leaking, through timing, how
176
+ * many leading characters matched. Comparing digests with `===` lets an
177
+ * attacker who can measure response time recover a stored hash one
178
+ * character at a time; recovering the hash is what makes offline cracking
179
+ * possible in the first place.
180
+ *
181
+ * Lengths are compared up front. Digests here are always the same length,
182
+ * so an early exit only happens on malformed input, where the length is
183
+ * not the secret.
184
+ */
185
+ public static isEqual(a: string, b: string): boolean {
186
+ if (a.length !== b.length) {
187
+ return false;
188
+ }
189
+
190
+ let difference: number = 0;
191
+
192
+ for (let i: number = 0; i < a.length; i++) {
193
+ difference |= a.charCodeAt(i) ^ b.charCodeAt(i);
194
+ }
195
+
196
+ return difference === 0;
197
+ }
198
+
199
+ /**
200
+ * Verify a plaintext value against a stored hash.
201
+ *
202
+ * Pass the salt stored alongside the hash. Pass null/undefined for records
203
+ * written before per-user salts existed — those verify against the legacy
204
+ * unsalted scheme, and the caller should re-hash them with a fresh salt
205
+ * once the value is known to be correct.
206
+ */
207
+ public static async verifyValue(data: {
208
+ plainValue: string;
209
+ hashedValue: string;
210
+ encryptionSecret: ObjectID | null;
211
+ salt?: string | null | undefined;
212
+ }): Promise<boolean> {
213
+ if (!data.plainValue || !data.hashedValue) {
214
+ return false;
215
+ }
216
+
217
+ return HashedString.isEqual(
218
+ HashedString.computeHash(
219
+ data.plainValue,
220
+ data.encryptionSecret,
221
+ data.salt || null,
222
+ ),
223
+ data.hashedValue,
224
+ );
90
225
  }
91
226
 
92
227
  protected static override fromDatabase(_value: string): HashedString | null {
@@ -51,14 +51,41 @@ export interface ComponentProps {
51
51
  customFieldType: DatabaseBaseModelType;
52
52
  projectId: ObjectID;
53
53
  name: string;
54
- isEditable?: boolean;
54
+ isEditable?: boolean | undefined;
55
+ /*
56
+ * For call sites that mount this card unconditionally - the resource
57
+ * overview pages - where most projects have never defined a custom field.
58
+ *
59
+ * With this set the card renders NOTHING until it knows the project has at
60
+ * least one field of this type: no loader flash and no "no custom fields
61
+ * have been added" placeholder occupying a slot on a page the operator
62
+ * opened to look at something else.
63
+ *
64
+ * It also hides on a load failure, because reading the schema is gated on
65
+ * both a permission and a billing plan. Without that, every incident a
66
+ * project below the custom-fields plan opens would carry an error card it
67
+ * can do nothing about. The dedicated Custom Fields page mounts this same
68
+ * component WITHOUT the flag, so anyone who goes looking still sees the
69
+ * real reason.
70
+ */
71
+ hideIfEmpty?: boolean | undefined;
55
72
  }
56
73
 
57
74
  const CustomFieldsDetail: FunctionComponent<ComponentProps> = (
58
75
  props: ComponentProps,
59
76
  ): ReactElement => {
60
77
  const [schemaList, setSchemaList] = useState<Array<BaseModel>>([]);
61
- const [error, setError] = useState<string>("");
78
+ /*
79
+ * Read failures and write failures are kept apart because hideIfEmpty
80
+ * treats them oppositely. Failing to READ the schema is something an
81
+ * overview page swallows - the project may simply not be on a plan that
82
+ * includes custom fields, and there is nothing the operator can do about it
83
+ * on an incident page. Failing to WRITE is the operator's own edit coming
84
+ * back rejected, and must be reported wherever it happens. Sharing one slot
85
+ * meant a rejected save made the whole card disappear.
86
+ */
87
+ const [loadError, setLoadError] = useState<string>("");
88
+ const [saveError, setSaveError] = useState<string>("");
62
89
  const [isLoading, setIsLoading] = useState<boolean>(false);
63
90
  const [model, setModel] = useState<BaseModel | null>(null);
64
91
  const [showModelForm, setShowModelForm] = useState<boolean>(false);
@@ -67,6 +94,7 @@ const CustomFieldsDetail: FunctionComponent<ComponentProps> = (
67
94
  try {
68
95
  // load schema.
69
96
  setIsLoading(true);
97
+ setLoadError("");
70
98
 
71
99
  const schemaList: ListResult<BaseModel> =
72
100
  await ModelAPI.getList<BaseModel>({
@@ -99,7 +127,7 @@ const CustomFieldsDetail: FunctionComponent<ComponentProps> = (
99
127
  setIsLoading(false);
100
128
  } catch (err) {
101
129
  setIsLoading(false);
102
- setError(API.getFriendlyMessage(err));
130
+ setLoadError(API.getFriendlyMessage(err));
103
131
  }
104
132
  };
105
133
 
@@ -109,6 +137,7 @@ const CustomFieldsDetail: FunctionComponent<ComponentProps> = (
109
137
  try {
110
138
  // load schema.
111
139
  setIsLoading(true);
140
+ setSaveError("");
112
141
  setShowModelForm(false);
113
142
 
114
143
  await ModelAPI.updateById({
@@ -122,7 +151,7 @@ const CustomFieldsDetail: FunctionComponent<ComponentProps> = (
122
151
  await onLoad();
123
152
  } catch (err) {
124
153
  setIsLoading(false);
125
- setError(API.getFriendlyMessage(err));
154
+ setSaveError(API.getFriendlyMessage(err));
126
155
  }
127
156
  };
128
157
 
@@ -132,12 +161,45 @@ const CustomFieldsDetail: FunctionComponent<ComponentProps> = (
132
161
 
133
162
  const isEditable: boolean = props.isEditable !== false;
134
163
 
164
+ /*
165
+ * schemaList is empty both before the first load finishes and when the
166
+ * project genuinely has no fields of this type, which is exactly when an
167
+ * overview page should show nothing - so one check covers both.
168
+ *
169
+ * `!model` covers the record itself coming back empty. On a page that is
170
+ * plainly displaying that record, a card announcing "Item not found" is a
171
+ * contradiction; showing nothing is the honest version.
172
+ *
173
+ * Deliberately NOT saveError: a rejected save has to stay on screen, and it
174
+ * leaves schemaList and model intact, so the card keeps rendering.
175
+ */
176
+ if (
177
+ props.hideIfEmpty &&
178
+ (Boolean(loadError) || schemaList.length === 0 || !model)
179
+ ) {
180
+ return <></>;
181
+ }
182
+
183
+ /*
184
+ * There has to be something to edit before the button is worth offering:
185
+ * the modal reads its initial values off the record and its inputs off the
186
+ * schema, so opening it mid-load threw on a null record, and opening it
187
+ * with no fields defined produced a form with nothing in it.
188
+ *
189
+ * A failed LOAD needs no term of its own - it leaves schemaList empty and
190
+ * model null, which the checks below already cover. A failed SAVE must not
191
+ * take the button away, or the error message is telling the operator to
192
+ * retry something they can no longer reach.
193
+ */
194
+ const canEdit: boolean =
195
+ isEditable && !isLoading && schemaList.length > 0 && Boolean(model);
196
+
135
197
  return (
136
198
  <Card
137
199
  title={props.title}
138
200
  description={props.description}
139
201
  buttons={
140
- isEditable
202
+ canEdit
141
203
  ? [
142
204
  {
143
205
  title: "Edit Fields",
@@ -152,14 +214,26 @@ const CustomFieldsDetail: FunctionComponent<ComponentProps> = (
152
214
  }
153
215
  >
154
216
  <div className="border-t border-gray-200 px-4 py-5 sm:px-6 -m-6 -mt-2">
155
- {isLoading && !error && <ComponentLoader />}
156
- {!isLoading && !error && schemaList.length === 0 && (
217
+ {isLoading && !loadError && <ComponentLoader />}
218
+ {!isLoading && !loadError && schemaList.length === 0 && (
157
219
  <ErrorMessage message="No custom fields have been added for this resource. You may add custom fields in Project Settings." />
158
220
  )}
159
- {error && <ErrorMessage message={error} />}
160
- {!model && <ErrorMessage message={"Item not found"} />}
221
+ {loadError && <ErrorMessage message={loadError} />}
222
+ {/*
223
+ * Above the values, which are still the ones on the record: the save
224
+ * did not happen, so what is on screen below is what is stored.
225
+ */}
226
+ {saveError && <ErrorMessage message={saveError} />}
227
+ {/*
228
+ * Only once the fetch has settled: model is null on the very first
229
+ * render too, and announcing "Item not found" while the request is
230
+ * still in flight reads as a broken record rather than a pending one.
231
+ */}
232
+ {!isLoading && !loadError && schemaList.length > 0 && !model && (
233
+ <ErrorMessage message={"Item not found"} />
234
+ )}
161
235
 
162
- {!isLoading && !error && model && (
236
+ {!isLoading && !loadError && schemaList.length > 0 && model && (
163
237
  <Detail
164
238
  id={props.name}
165
239
  item={(model as any)["customFields"] || {}}
@@ -194,7 +268,7 @@ const CustomFieldsDetail: FunctionComponent<ComponentProps> = (
194
268
  await onSave(data).catch();
195
269
  }}
196
270
  formProps={{
197
- initialValues: (model as any)["customFields"] || {},
271
+ initialValues: (model as any)?.["customFields"] || {},
198
272
  fields: schemaList.map((schemaItem: BaseModel) => {
199
273
  const isDropdown: boolean =
200
274
  (schemaItem as any).customFieldType ===
@@ -13,6 +13,7 @@ import NavBarMenuModal from "./NavBarMenuModal";
13
13
  import Button, { ButtonStyleType } from "../Button/Button";
14
14
  import Navigation from "../../Utils/Navigation";
15
15
  import useComponentOutsideClick from "../../Types/UseComponentOutsideClick";
16
+ import useTranslateValue from "../../Utils/Translation";
16
17
  import Icon, { ThickProp } from "../Icon/Icon";
17
18
 
18
19
  export interface NavItem {
@@ -54,7 +55,7 @@ export interface ComponentProps {
54
55
  items?: NavItem[];
55
56
  rightElement?: NavItem;
56
57
  moreMenuItems?: MoreMenuItem[];
57
- moreMenuTitle?: string; // Title for the more menu (default: "More")
58
+ moreMenuTitle?: string; // Title for the more menu (default: "Products")
58
59
  moreMenuSearchPlaceholder?: string; // Placeholder for the menu search box
59
60
  moreMenuNoResultsText?: string; // Empty-state text when search matches nothing
60
61
  moreMenuKeyboardHint?: string; // Keyboard hint shown in the menu footer
@@ -79,6 +80,7 @@ const MOBILE_MENU_MIN_HEIGHT_IN_PX: number = 160;
79
80
  const Navbar: FunctionComponent<ComponentProps> = (
80
81
  props: ComponentProps,
81
82
  ): ReactElement => {
83
+ const { translateString } = useTranslateValue();
82
84
  const [isMobile, setIsMobile] = useState<boolean>(false);
83
85
  const [isMobileMenuVisible, setIsMobileMenuVisible] =
84
86
  useState<boolean>(false);
@@ -408,7 +410,15 @@ const Navbar: FunctionComponent<ComponentProps> = (
408
410
  className="mr-1.5 h-4 w-4 transition-transform duration-150 group-hover:scale-110 group-hover:text-indigo-600"
409
411
  thick={ThickProp.Thick}
410
412
  />
411
- <span>{props.moreMenuTitle || "Products"}</span>
413
+ {/*
414
+ * Callers pass a plain English literal here (or nothing, and
415
+ * take the default) — run it through the same flat-key lookup
416
+ * the side menu uses so the button is localised like every
417
+ * other label in the nav.
418
+ */}
419
+ <span>
420
+ {translateString(props.moreMenuTitle || "Products")}
421
+ </span>
412
422
  <Icon
413
423
  icon={IconProp.ChevronDown}
414
424
  className={`ml-1.5 h-3 w-3 text-gray-400 transition-transform duration-200 ${
@@ -1,6 +1,7 @@
1
1
  import Icon from "../Icon/Icon";
2
2
  import Link from "../Link/Link";
3
3
  import Navigation from "../../Utils/Navigation";
4
+ import useTranslateValue from "../../Utils/Translation";
4
5
  import IconProp from "../../../Types/Icon/IconProp";
5
6
  import URL from "../../../Types/API/URL";
6
7
  import { isMoreMenuItemActive, type MoreMenuItem } from "./NavBar";
@@ -163,7 +164,18 @@ const NavBarMenuModal: FunctionComponent<ComponentProps> = (
163
164
  Array<HTMLDivElement | null>
164
165
  >([]);
165
166
 
166
- const recentLabel: string = props.recentLabel || "Recent";
167
+ /*
168
+ * Callers hand us plain English literals (or omit them and take the
169
+ * defaults). Run every user-facing string through the same flat-key lookup
170
+ * the rest of the nav uses, so a caller that has not been wired up to i18n
171
+ * still renders localised text instead of English.
172
+ */
173
+ const { translateString } = useTranslateValue();
174
+ const tx: (value: string) => string = (value: string): string => {
175
+ return translateString(value) ?? value;
176
+ };
177
+
178
+ const recentLabel: string = tx(props.recentLabel || "Recent");
167
179
 
168
180
  // Show the OS-appropriate shortcut hint (⌘K on macOS, Ctrl K elsewhere).
169
181
  const shortcutLabel: string = useMemo(() => {
@@ -482,7 +494,7 @@ const NavBarMenuModal: FunctionComponent<ComponentProps> = (
482
494
  ? `navbar-menu-option-${activeIndex}`
483
495
  : undefined
484
496
  }
485
- aria-label={props.searchPlaceholder || "Search products"}
497
+ aria-label={tx(props.searchPlaceholder || "Search products")}
486
498
  autoComplete="off"
487
499
  autoCorrect="off"
488
500
  autoCapitalize="off"
@@ -492,7 +504,7 @@ const NavBarMenuModal: FunctionComponent<ComponentProps> = (
492
504
  setQuery(event.target.value);
493
505
  }}
494
506
  onKeyDown={handleKeyDown}
495
- placeholder={props.searchPlaceholder || "Search…"}
507
+ placeholder={tx(props.searchPlaceholder || "Search…")}
496
508
  className="flex-1 border-0 bg-transparent p-0 text-base text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-0"
497
509
  />
498
510
  {query ? (
@@ -530,7 +542,7 @@ const NavBarMenuModal: FunctionComponent<ComponentProps> = (
530
542
  />
531
543
  </div>
532
544
  <p className="text-sm font-medium text-gray-900">
533
- {props.noResultsText || "No results found."}
545
+ {tx(props.noResultsText || "No results found.")}
534
546
  </p>
535
547
  {query && (
536
548
  <p className="mt-1 text-xs text-gray-500">
@@ -640,20 +652,20 @@ const NavBarMenuModal: FunctionComponent<ComponentProps> = (
640
652
  </div>
641
653
  <div className="min-w-0 text-left">
642
654
  <p className="flex items-center gap-1 text-sm font-medium text-gray-900">
643
- <span className="truncate">{props.footer.title}</span>
655
+ <span className="truncate">{tx(props.footer.title)}</span>
644
656
  <Icon
645
657
  icon={IconProp.ExternalLink}
646
658
  className="h-3 w-3 flex-shrink-0 text-gray-400 opacity-0 transition-opacity duration-150 group-hover:opacity-100 group-focus-visible:opacity-100"
647
659
  />
648
660
  </p>
649
661
  <p className="truncate text-xs text-gray-500">
650
- {props.footer.description}
662
+ {tx(props.footer.description)}
651
663
  </p>
652
664
  </div>
653
665
  </Link>
654
666
  {props.keyboardHint && (
655
667
  <div
656
- aria-label={props.keyboardHint}
668
+ aria-label={tx(props.keyboardHint)}
657
669
  className="hidden flex-shrink-0 items-center gap-2 text-xs text-gray-400 md:flex"
658
670
  >
659
671
  <span className="flex items-center gap-1">