@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.
- package/Models/DatabaseModels/CephCluster.ts +14 -0
- package/Models/DatabaseModels/CodeRepository.ts +14 -6
- package/Models/DatabaseModels/DockerHost.ts +14 -0
- package/Models/DatabaseModels/GlobalOidcProject.ts +18 -0
- package/Models/DatabaseModels/GlobalSsoProject.ts +18 -0
- package/Models/DatabaseModels/IoTFleet.ts +14 -0
- package/Models/DatabaseModels/KubernetesCluster.ts +14 -0
- package/Models/DatabaseModels/NetworkDevice.ts +14 -0
- package/Models/DatabaseModels/NetworkInterface.ts +18 -0
- package/Models/DatabaseModels/Project.ts +16 -2
- package/Models/DatabaseModels/ProxmoxCluster.ts +14 -0
- package/Models/DatabaseModels/Service.ts +30 -0
- package/Models/DatabaseModels/StatusPagePrivateUser.ts +30 -0
- package/Models/DatabaseModels/User.ts +30 -0
- package/Server/API/AIAgentDataAPI.ts +31 -0
- package/Server/API/DashboardAPI.ts +419 -12
- package/Server/API/GitHubAPI.ts +119 -283
- package/Server/API/UserAPI.ts +263 -1
- package/Server/EnvironmentConfig.ts +7 -3
- package/Server/Infrastructure/GlobalCache.ts +37 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1786018109307-AddPerUserPasswordSalt.ts +21 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1786023262402-WidenHashedStringColumnsForScrypt.ts +68 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1786100000000-RestoreServiceLowerNameIndex.ts +100 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1786200000000-RestoreDroppedUniqueIndexes.ts +214 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1786300000000-QuarantineUnboundGitHubInstallations.ts +69 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +10 -0
- package/Server/Services/AnalyticsDatabaseService.ts +72 -1
- package/Server/Services/CodeRepositoryService.ts +105 -2
- package/Server/Services/DatabaseService.ts +185 -2
- package/Server/Services/OpenTelemetryIngestService.ts +230 -26
- package/Server/Services/UserService.ts +184 -0
- package/Server/Utils/AI/Toolbox/CodeTools.ts +45 -4
- package/Server/Utils/AnalyticsDatabase/StatementGenerator.ts +13 -0
- package/Server/Utils/CodeRepository/GitHub/GitHub.ts +157 -4
- package/Server/Utils/CodeRepository/GitHub/GitHubInstallationBinding.ts +130 -0
- package/Server/Utils/PasswordHash.ts +306 -0
- package/Tests/App/Dashboard/OverviewCustomFields.test.tsx +333 -0
- package/Tests/App/Dashboard/UserCustomFields.test.tsx +392 -0
- package/Tests/Server/API/AIAgentDataRepositoryToken.test.ts +381 -0
- package/Tests/Server/API/DashboardPublicAttributeValuesAPI.test.ts +943 -0
- package/Tests/Server/API/DashboardPublicMetricsAggregateAPI.test.ts +1296 -0
- package/Tests/Server/API/DashboardPublicTemplatePayloads.test.ts +598 -0
- package/Tests/Server/API/GitHubAppInstallationBindingAPI.test.ts +539 -0
- package/Tests/Server/API/Helpers.ts +6 -1
- package/Tests/Server/API/UserProjectsAPI.test.ts +852 -0
- package/Tests/Server/Infrastructure/GlobalCache.test.ts +128 -0
- package/Tests/Server/Infrastructure/SemaphoreMutex.test.ts +215 -0
- package/Tests/Server/Services/AddPerUserPasswordSaltMigration.test.ts +178 -0
- package/Tests/Server/Services/AnalyticsDatabasePaginationStability.test.ts +582 -0
- package/Tests/Server/Services/AnalyticsDatabaseService.test.ts +5 -0
- package/Tests/Server/Services/CodeRepositoryInstallationBinding.test.ts +305 -0
- package/Tests/Server/Services/CodeRepositoryResolutionBinding.test.ts +147 -0
- package/Tests/Server/Services/DatabaseServicePerUserPasswordSalt.test.ts +870 -0
- package/Tests/Server/Services/OpenTelemetryServiceResolutionCache.test.ts +568 -0
- package/Tests/Server/Services/RestoreDroppedUniqueIndexesMigration.test.ts +386 -0
- package/Tests/Server/Services/RestoreServiceLowerNameIndexMigration.test.ts +240 -0
- package/Tests/Server/Services/UserServiceFirstMasterAdminElection.test.ts +885 -0
- package/Tests/Server/Services/WidenHashedStringColumnsForScryptMigration.test.ts +220 -0
- package/Tests/Server/Utils/AI/CodeTools.test.ts +47 -1
- package/Tests/Server/Utils/AI/CodeWriteTools.test.ts +10 -0
- package/Tests/Server/Utils/AnalyticsDatabase/StatementGenerator.test.ts +120 -0
- package/Tests/Server/Utils/CodeRepository/GitHubInstallationBinding.test.ts +292 -0
- package/Tests/Server/Utils/GitHubInstallationOwnershipVerification.test.ts +340 -0
- package/Tests/Server/Utils/GitHubWebhookAndTreeCacheIsolation.test.ts +250 -0
- package/Tests/Server/Utils/PasswordHash.test.ts +555 -0
- package/Tests/Types/Database/ColumnLength.test.ts +7 -1
- package/Tests/Types/HashedStringPerUserSalt.test.ts +476 -0
- package/Tests/Types/Monitor/DockerAlertTemplates.test.ts +293 -0
- package/Tests/Types/Monitor/HostAlertTemplates.test.ts +249 -0
- package/Tests/Types/Monitor/IotAlertTemplates.test.ts +320 -0
- package/Tests/Types/Monitor/PodmanAlertTemplates.test.ts +271 -0
- package/Tests/UI/Components/CustomFields/CustomFieldsDetail.test.tsx +696 -0
- package/Tests/UI/Utils/UserProjectsModelAPI.test.ts +714 -0
- package/Tests/Utils/TeamMembersByProject.test.ts +655 -0
- package/Types/Database/ColumnLength.ts +9 -1
- package/Types/Database/TableColumn.ts +11 -0
- package/Types/Database/UnsynchronizedIndex.ts +48 -0
- package/Types/HashedString.ts +139 -4
- package/UI/Components/CustomFields/CustomFieldsDetail.tsx +85 -11
- package/UI/Components/Navbar/NavBar.tsx +12 -2
- package/UI/Components/Navbar/NavBarMenuModal.tsx +19 -7
- package/UI/Utils/ModelAPI/UserProjectsModelAPI.ts +257 -0
- package/Utils/TeamMembersByProject.ts +237 -0
- package/build/dist/Models/DatabaseModels/CephCluster.js +16 -1
- package/build/dist/Models/DatabaseModels/CephCluster.js.map +1 -1
- package/build/dist/Models/DatabaseModels/CodeRepository.js +14 -6
- package/build/dist/Models/DatabaseModels/CodeRepository.js.map +1 -1
- package/build/dist/Models/DatabaseModels/DockerHost.js +16 -1
- package/build/dist/Models/DatabaseModels/DockerHost.js.map +1 -1
- package/build/dist/Models/DatabaseModels/GlobalOidcProject.js +16 -1
- package/build/dist/Models/DatabaseModels/GlobalOidcProject.js.map +1 -1
- package/build/dist/Models/DatabaseModels/GlobalSsoProject.js +16 -1
- package/build/dist/Models/DatabaseModels/GlobalSsoProject.js.map +1 -1
- package/build/dist/Models/DatabaseModels/IoTFleet.js +16 -1
- package/build/dist/Models/DatabaseModels/IoTFleet.js.map +1 -1
- package/build/dist/Models/DatabaseModels/KubernetesCluster.js +16 -1
- package/build/dist/Models/DatabaseModels/KubernetesCluster.js.map +1 -1
- package/build/dist/Models/DatabaseModels/NetworkDevice.js +16 -1
- package/build/dist/Models/DatabaseModels/NetworkDevice.js.map +1 -1
- package/build/dist/Models/DatabaseModels/NetworkInterface.js +16 -1
- package/build/dist/Models/DatabaseModels/NetworkInterface.js.map +1 -1
- package/build/dist/Models/DatabaseModels/Project.js +16 -2
- package/build/dist/Models/DatabaseModels/Project.js.map +1 -1
- package/build/dist/Models/DatabaseModels/ProxmoxCluster.js +16 -1
- package/build/dist/Models/DatabaseModels/ProxmoxCluster.js.map +1 -1
- package/build/dist/Models/DatabaseModels/Service.js +28 -1
- package/build/dist/Models/DatabaseModels/Service.js.map +1 -1
- package/build/dist/Models/DatabaseModels/StatusPagePrivateUser.js +32 -0
- package/build/dist/Models/DatabaseModels/StatusPagePrivateUser.js.map +1 -1
- package/build/dist/Models/DatabaseModels/User.js +32 -0
- package/build/dist/Models/DatabaseModels/User.js.map +1 -1
- package/build/dist/Server/API/AIAgentDataAPI.js +21 -2
- package/build/dist/Server/API/AIAgentDataAPI.js.map +1 -1
- package/build/dist/Server/API/DashboardAPI.js +304 -10
- package/build/dist/Server/API/DashboardAPI.js.map +1 -1
- package/build/dist/Server/API/GitHubAPI.js +94 -167
- package/build/dist/Server/API/GitHubAPI.js.map +1 -1
- package/build/dist/Server/API/UserAPI.js +187 -2
- package/build/dist/Server/API/UserAPI.js.map +1 -1
- package/build/dist/Server/EnvironmentConfig.js +7 -3
- package/build/dist/Server/EnvironmentConfig.js.map +1 -1
- package/build/dist/Server/Infrastructure/GlobalCache.js +29 -0
- package/build/dist/Server/Infrastructure/GlobalCache.js.map +1 -1
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786018109307-AddPerUserPasswordSalt.js +14 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786018109307-AddPerUserPasswordSalt.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786023262402-WidenHashedStringColumnsForScrypt.js +59 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786023262402-WidenHashedStringColumnsForScrypt.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786100000000-RestoreServiceLowerNameIndex.js +93 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786100000000-RestoreServiceLowerNameIndex.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786200000000-RestoreDroppedUniqueIndexes.js +101 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786200000000-RestoreDroppedUniqueIndexes.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786300000000-QuarantineUnboundGitHubInstallations.js +66 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786300000000-QuarantineUnboundGitHubInstallations.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +10 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
- package/build/dist/Server/Services/AnalyticsDatabaseService.js +57 -1
- package/build/dist/Server/Services/AnalyticsDatabaseService.js.map +1 -1
- package/build/dist/Server/Services/CodeRepositoryService.js +90 -2
- package/build/dist/Server/Services/CodeRepositoryService.js.map +1 -1
- package/build/dist/Server/Services/DatabaseService.js +160 -2
- package/build/dist/Server/Services/DatabaseService.js.map +1 -1
- package/build/dist/Server/Services/OpenTelemetryIngestService.js +155 -25
- package/build/dist/Server/Services/OpenTelemetryIngestService.js.map +1 -1
- package/build/dist/Server/Services/UserService.js +165 -0
- package/build/dist/Server/Services/UserService.js.map +1 -1
- package/build/dist/Server/Utils/AI/Toolbox/CodeTools.js +31 -1
- package/build/dist/Server/Utils/AI/Toolbox/CodeTools.js.map +1 -1
- package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js +13 -0
- package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js.map +1 -1
- package/build/dist/Server/Utils/CodeRepository/GitHub/GitHub.js +126 -4
- package/build/dist/Server/Utils/CodeRepository/GitHub/GitHub.js.map +1 -1
- package/build/dist/Server/Utils/CodeRepository/GitHub/GitHubInstallationBinding.js +136 -0
- package/build/dist/Server/Utils/CodeRepository/GitHub/GitHubInstallationBinding.js.map +1 -0
- package/build/dist/Server/Utils/PasswordHash.js +237 -0
- package/build/dist/Server/Utils/PasswordHash.js.map +1 -0
- package/build/dist/Types/Database/ColumnLength.js +9 -1
- package/build/dist/Types/Database/ColumnLength.js.map +1 -1
- package/build/dist/Types/Database/TableColumn.js.map +1 -1
- package/build/dist/Types/Database/UnsynchronizedIndex.js +46 -0
- package/build/dist/Types/Database/UnsynchronizedIndex.js.map +1 -0
- package/build/dist/Types/HashedString.js +95 -5
- package/build/dist/Types/HashedString.js.map +1 -1
- package/build/dist/UI/Components/CustomFields/CustomFieldsDetail.js +51 -10
- package/build/dist/UI/Components/CustomFields/CustomFieldsDetail.js.map +1 -1
- package/build/dist/UI/Components/Navbar/NavBar.js +3 -1
- package/build/dist/UI/Components/Navbar/NavBar.js.map +1 -1
- package/build/dist/UI/Components/Navbar/NavBarMenuModal.js +19 -7
- package/build/dist/UI/Components/Navbar/NavBarMenuModal.js.map +1 -1
- package/build/dist/UI/Utils/ModelAPI/UserProjectsModelAPI.js +156 -0
- package/build/dist/UI/Utils/ModelAPI/UserProjectsModelAPI.js.map +1 -0
- package/build/dist/Utils/TeamMembersByProject.js +145 -0
- package/build/dist/Utils/TeamMembersByProject.js.map +1 -0
- 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;
|
package/Types/HashedString.ts
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 && !
|
|
156
|
-
{!isLoading && !
|
|
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
|
-
{
|
|
160
|
-
{
|
|
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 && !
|
|
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: "
|
|
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
|
-
|
|
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
|
-
|
|
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">
|