From f45c53d7d4448ec2480a1c1c917cbcf74aa0d494 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 29 Jan 2024 09:52:17 -0500 Subject: [PATCH 1/8] automated update to translation keys (#833) Co-authored-by: Paul's Grist Bot --- static/locales/en.client.json | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/static/locales/en.client.json b/static/locales/en.client.json index 679dbd10..6c43624c 100644 --- a/static/locales/en.client.json +++ b/static/locales/en.client.json @@ -650,7 +650,8 @@ "Submit another response": "Submit another response", "Submit button label": "Submit button label", "Success text": "Success text", - "Table column name": "Table column name" + "Table column name": "Table column name", + "Enter redirect URL": "Enter redirect URL" }, "RowContextMenu": { "Copy anchor link": "Copy anchor link", @@ -869,7 +870,11 @@ "You do not have access to this organization's documents.": "You do not have access to this organization's documents.", "Account deleted{{suffix}}": "Account deleted{{suffix}}", "Sign up": "Sign up", - "Your account has been deleted.": "Your account has been deleted." + "Your account has been deleted.": "Your account has been deleted.", + "An unknown error occurred.": "An unknown error occurred.", + "Build your own form": "Build your own form", + "Form not found": "Form not found", + "Powered by": "Powered by" }, "menus": { "* Workspaces are available on team plans. ": "* Workspaces are available on team plans. ", @@ -901,7 +906,8 @@ "Don't show again.": "Don't show again.", "Don't show tips": "Don't show tips", "Undo to restore": "Undo to restore", - "Got it": "Got it" + "Got it": "Got it", + "Don't show again": "Don't show again" }, "pages": { "Duplicate Page": "Duplicate Page", @@ -1318,7 +1324,8 @@ "Paragraph": "Paragraph", "Paste": "Paste", "Separator": "Separator", - "Unmapped fields": "Unmapped fields" + "Unmapped fields": "Unmapped fields", + "Header": "Header" }, "UnmappedFieldsConfig": { "Clear": "Clear", From 36ade2bfd077a56b40cedee1d416b03a079d1ac4 Mon Sep 17 00:00:00 2001 From: Florent Date: Tue, 30 Jan 2024 16:00:59 +0100 Subject: [PATCH 2/8] Fix docker graceful shutdown (#830) * run.sh: Replace pid with nodejs's one using exec This notably ensures that "docker stop" sends a TERM signal to the node process which can handle it gracefully and avoid document corruption. * Use exec form for CMD in Dockerfile --------- Co-authored-by: Florent FAYOLLE --- Dockerfile | 2 +- sandbox/run.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 1b4002e2..c702e666 100644 --- a/Dockerfile +++ b/Dockerfile @@ -142,4 +142,4 @@ ENV \ EXPOSE 8484 -CMD ./sandbox/run.sh +CMD ["./sandbox/run.sh"] diff --git a/sandbox/run.sh b/sandbox/run.sh index 89e24e00..ce44b25f 100755 --- a/sandbox/run.sh +++ b/sandbox/run.sh @@ -16,4 +16,4 @@ if [[ "$GRIST_SANDBOX_FLAVOR" = "gvisor" ]]; then ./sandbox/gvisor/update_engine_checkpoint.sh fi -NODE_PATH=_build:_build/stubs:_build/ext node _build/stubs/app/server/server.js +exec env NODE_PATH=_build:_build/stubs:_build/ext node _build/stubs/app/server/server.js From 6ff4f43b074ea4986e051dd7d79d8041c25dfb83 Mon Sep 17 00:00:00 2001 From: Vincent Viers <30295971+vviers@users.noreply.github.com> Date: Wed, 31 Jan 2024 19:58:50 +0100 Subject: [PATCH 3/8] Make ISEMAIL and ISURL more flexible for longer TLD (#834) Allow TLD of length up to 24 in ISEMAIL --- app/common/gutil.ts | 2 +- sandbox/grist/functions/info.py | 17 ++++++++++++----- test/common/gutil.js | 1 + 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/app/common/gutil.ts b/app/common/gutil.ts index 10b82fd6..2ff8f3b5 100644 --- a/app/common/gutil.ts +++ b/app/common/gutil.ts @@ -23,7 +23,7 @@ export const UP_TRIANGLE = '\u25B2'; export const DOWN_TRIANGLE = '\u25BC'; const EMAIL_RE = new RegExp("^\\w[\\w%+/='-]*(\\.[\\w%+/='-]+)*@([A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z" + - "0-9])?\\.)+[A-Za-z]{2,6}$", "u"); + "0-9])?\\.)+[A-Za-z]{2,24}$", "u"); // Returns whether str starts with prefix. (Note that this implementation avoids creating a new // string, and only checks a single location.) diff --git a/sandbox/grist/functions/info.py b/sandbox/grist/functions/info.py index a230af30..ae9fef8a 100644 --- a/sandbox/grist/functions/info.py +++ b/sandbox/grist/functions/info.py @@ -257,7 +257,8 @@ _email_regexp = re.compile( ([A-Za-z0-9] # Each part of hostname must start with alphanumeric ([A-Za-z0-9-]*[A-Za-z0-9])?\. # May have dashes inside, but end in alphanumeric )+ - [A-Za-z]{2,6}$ # Restrict top-level domain to length {2,6}. Google seems + [A-Za-z]{2,24}$ # Restrict top-level domain to length {2,24} (theoretically, + # the max length is 63 bytes as per RFC 1034). Google seems # to use a whitelist for TLDs longer than 2 characters. """, re.UNICODE | re.VERBOSE) @@ -289,7 +290,8 @@ def ISEMAIL(value): >>> ISEMAIL("john@aol...com") False - More tests: + More tests: Google Sheets Grist + ------------- ----- >>> ISEMAIL("Abc@example.com") # True, True True >>> ISEMAIL("Abc.123@example.com") # True, True @@ -314,6 +316,10 @@ def ISEMAIL(value): True >>> ISEMAIL("Bob_O'Reilly+tag@example.com") # False, True True + >>> ISEMAIL("marie@isola.corsica") # False, True + True + >>> ISEMAIL("fabio@disapproved.solutions") # False, True + True >>> ISEMAIL(u"фыва@mail.ru") # False, True True >>> ISEMAIL("my@baddash.-.com") # True, False @@ -324,8 +330,6 @@ def ISEMAIL(value): False >>> ISEMAIL("john@-.com") # True, False False - >>> ISEMAIL("fabio@disapproved.solutions") # False, False - False >>> ISEMAIL("!def!xyz%abc@example.com") # False, False False >>> ISEMAIL("!#$%&'*+-/=?^_`.{|}~@example.com") # False, False @@ -391,7 +395,8 @@ _url_regexp = re.compile( ([A-Za-z0-9] # Each part of hostname must start with alphanumeric ([A-Za-z0-9-]*[A-Za-z0-9])?\. # May have dashes inside, but end in alphanumeric )+ - [A-Za-z]{2,6} # Restrict top-level domain to length {2,6}. Google seems + [A-Za-z]{2,24} # Restrict top-level domain to length {2,24} (theoretically, + # the max length is 63 bytes as per RFC 1034). Google seems # to use a whitelist for TLDs longer than 2 characters. ([/?][-\w!#$%&'()*+,./:;=?@~]*)?$ # Notably, this excludes <, >, and ". """, re.VERBOSE) @@ -437,6 +442,8 @@ def ISURL(value): True >>> ISURL("http://foo.com/!#$%25&'()*+,-./=?@_~") True + >>> ISURL("http://collectivite.isla.corsica") + True >>> ISURL("http://../") False >>> ISURL("http://??/") diff --git a/test/common/gutil.js b/test/common/gutil.js index 9755513e..184f6885 100644 --- a/test/common/gutil.js +++ b/test/common/gutil.js @@ -247,6 +247,7 @@ describe('gutil', function() { assert.isTrue(gutil.isEmail('email@subdomain.do-main.com')); assert.isTrue(gutil.isEmail('firstname+lastname@domain.com')); assert.isTrue(gutil.isEmail('email@domain.co.jp')); + assert.isTrue(gutil.isEmail('marie@isola.corsica')); assert.isFalse(gutil.isEmail('plainaddress')); assert.isFalse(gutil.isEmail('@domain.com')); From 866ec66096b1ea71cc63cd2b2ab244ff29042fb6 Mon Sep 17 00:00:00 2001 From: Florent Date: Wed, 31 Jan 2024 20:04:22 +0100 Subject: [PATCH 4/8] Optimize sql query for workspace acl (#824) Without this optimization, we fetched loads of entries from the database, which led to database and nodejs overloads. We could go further, this is a modest patch towards better performance. We use two queries: one fetches the workspaces, the second the organization that the workspace belongs to. --------- Co-authored-by: Florent FAYOLLE --- app/gen-server/lib/HomeDBManager.ts | 155 ++++++++++++++++++---------- test/gen-server/ApiServerAccess.ts | 22 ++++ test/gen-server/apiUtils.ts | 3 +- 3 files changed, 122 insertions(+), 58 deletions(-) diff --git a/app/gen-server/lib/HomeDBManager.ts b/app/gen-server/lib/HomeDBManager.ts index 1e82318f..8b081982 100644 --- a/app/gen-server/lib/HomeDBManager.ts +++ b/app/gen-server/lib/HomeDBManager.ts @@ -813,7 +813,7 @@ export class HomeDBManager extends EventEmitter { .leftJoinAndSelect('org_groups.memberUsers', 'org_member_users'); const result = await orgQuery.getRawAndEntities(); if (result.entities.length === 0) { - // If the query for the doc failed, return the failure result. + // If the query for the org failed, return the failure result. throw new ApiError('org not found', 404); } org = result.entities[0]; @@ -1073,7 +1073,7 @@ export class HomeDBManager extends EventEmitter { needRealOrg: true }); orgQuery = this._addFeatures(orgQuery); - const orgQueryResult = await verifyIsPermitted(orgQuery); + const orgQueryResult = await verifyEntity(orgQuery); const org: Organization = this.unwrapQueryResult(orgQueryResult); const productFeatures = org.billingAccount.product.features; @@ -1589,7 +1589,7 @@ export class HomeDBManager extends EventEmitter { markPermissions, needRealOrg: true }); - const queryResult = await verifyIsPermitted(orgQuery); + const queryResult = await verifyEntity(orgQuery); if (queryResult.status !== 200) { // If the query for the workspace failed, return the failure result. return queryResult; @@ -1657,7 +1657,7 @@ export class HomeDBManager extends EventEmitter { .leftJoinAndSelect('docs.aclRules', 'doc_acl_rules') .leftJoinAndSelect('doc_acl_rules.group', 'doc_group') .leftJoinAndSelect('orgs.billingAccount', 'billing_accounts'); - const queryResult = await verifyIsPermitted(orgQuery); + const queryResult = await verifyEntity(orgQuery); if (queryResult.status !== 200) { // If the query for the org failed, return the failure result. return queryResult; @@ -1710,7 +1710,7 @@ export class HomeDBManager extends EventEmitter { .leftJoinAndSelect('acl_rules.group', 'org_group') .leftJoinAndSelect('orgs.workspaces', 'workspaces'); // we may want to count workspaces. orgQuery = this._addFeatures(orgQuery); // add features to access optional workspace limit. - const queryResult = await verifyIsPermitted(orgQuery); + const queryResult = await verifyEntity(orgQuery); if (queryResult.status !== 200) { // If the query for the organization failed, return the failure result. return queryResult; @@ -1750,7 +1750,7 @@ export class HomeDBManager extends EventEmitter { manager, markPermissions: Permissions.UPDATE }); - const queryResult = await verifyIsPermitted(wsQuery); + const queryResult = await verifyEntity(wsQuery); if (queryResult.status !== 200) { // If the query for the workspace failed, return the failure result. return queryResult; @@ -1782,7 +1782,7 @@ export class HomeDBManager extends EventEmitter { .leftJoinAndSelect('docs.aclRules', 'doc_acl_rules') .leftJoinAndSelect('doc_acl_rules.group', 'doc_groups') .leftJoinAndSelect('workspaces.org', 'orgs'); - const queryResult = await verifyIsPermitted(wsQuery); + const queryResult = await verifyEntity(wsQuery); if (queryResult.status !== 200) { // If the query for the workspace failed, return the failure result. return queryResult; @@ -1835,7 +1835,7 @@ export class HomeDBManager extends EventEmitter { .leftJoinAndSelect('workspaces.aclRules', 'acl_rules') .leftJoinAndSelect('acl_rules.group', 'workspace_group'); wsQuery = this._addFeatures(wsQuery); - const queryResult = await verifyIsPermitted(wsQuery); + const queryResult = await verifyEntity(wsQuery); if (queryResult.status !== 200) { // If the query for the organization failed, return the failure result. return queryResult; @@ -2009,7 +2009,7 @@ export class HomeDBManager extends EventEmitter { markPermissions, }); } - const queryResult = await verifyIsPermitted(query); + const queryResult = await verifyEntity(query); if (queryResult.status !== 200) { // If the query for the doc or fork failed, return the failure result. return queryResult; @@ -2062,7 +2062,7 @@ export class HomeDBManager extends EventEmitter { manager, allowSpecialPermit: true, }); - const queryResult = await verifyIsPermitted(forkQuery); + const queryResult = await verifyEntity(forkQuery); if (queryResult.status !== 200) { // If the query for the fork failed, return the failure result. return queryResult; @@ -2080,7 +2080,7 @@ export class HomeDBManager extends EventEmitter { // Join the workspace and org to get their ids. .leftJoinAndSelect('docs.aclRules', 'acl_rules') .leftJoinAndSelect('acl_rules.group', 'groups'); - const queryResult = await verifyIsPermitted(docQuery); + const queryResult = await verifyEntity(docQuery); if (queryResult.status !== 200) { // If the query for the doc failed, return the failure result. return queryResult; @@ -2218,7 +2218,7 @@ export class HomeDBManager extends EventEmitter { .leftJoinAndSelect('org_groups.memberUsers', 'org_member_users'); orgQuery = this._addFeatures(orgQuery); orgQuery = this._withAccess(orgQuery, userId, 'orgs'); - const queryResult = await verifyIsPermitted(orgQuery); + const queryResult = await verifyEntity(orgQuery); if (queryResult.status !== 200) { // If the query for the organization failed, return the failure result. return queryResult; @@ -2279,7 +2279,7 @@ export class HomeDBManager extends EventEmitter { .leftJoinAndSelect('org_groups.memberUsers', 'org_users'); wsQuery = this._addFeatures(wsQuery, 'org'); wsQuery = this._withAccess(wsQuery, userId, 'workspaces'); - const queryResult = await verifyIsPermitted(wsQuery); + const queryResult = await verifyEntity(wsQuery); if (queryResult.status !== 200) { // If the query for the workspace failed, return the failure result. return queryResult; @@ -2380,17 +2380,7 @@ export class HomeDBManager extends EventEmitter { // Returns UserAccessData for all users with any permissions on the org. public async getOrgAccess(scope: Scope, orgKey: string|number): Promise> { - const orgQuery = this.org(scope, orgKey, { - markPermissions: Permissions.VIEW, - needRealOrg: true, - allowSpecialPermit: true - }) - // Join the org's ACL rules (with 1st level groups/users listed). - .leftJoinAndSelect('orgs.aclRules', 'acl_rules') - .leftJoinAndSelect('acl_rules.group', 'org_groups') - .leftJoinAndSelect('org_groups.memberUsers', 'org_member_users') - .leftJoinAndSelect('org_member_users.logins', 'user_logins'); - const queryResult = await verifyIsPermitted(orgQuery); + const queryResult = await this._getOrgWithACLRules(scope, orgKey); if (queryResult.status !== 200) { // If the query for the doc failed, return the failure result. return queryResult; @@ -2419,33 +2409,41 @@ export class HomeDBManager extends EventEmitter { // maxInheritedRole set on the workspace. Note that information for all users in the org // is given to indicate which users have access to the org but not to this particular workspace. public async getWorkspaceAccess(scope: Scope, wsId: number): Promise> { - const wsQuery = this._workspace(scope, wsId, { - markPermissions: Permissions.VIEW - }) - // Join the workspace's ACL rules (with 1st level groups/users listed). - .leftJoinAndSelect('workspaces.aclRules', 'acl_rules') - .leftJoinAndSelect('acl_rules.group', 'workspace_groups') - .leftJoinAndSelect('workspace_groups.memberUsers', 'workspace_group_users') - .leftJoinAndSelect('workspace_groups.memberGroups', 'workspace_group_groups') - .leftJoinAndSelect('workspace_group_users.logins', 'workspace_user_logins') - // Join the org and groups/users. - .leftJoinAndSelect('workspaces.org', 'org') - .leftJoinAndSelect('org.aclRules', 'org_acl_rules') - .leftJoinAndSelect('org_acl_rules.group', 'org_groups') - .leftJoinAndSelect('org_groups.memberUsers', 'org_group_users') - .leftJoinAndSelect('org_group_users.logins', 'org_user_logins'); - const queryResult = await verifyIsPermitted(wsQuery); - if (queryResult.status !== 200) { - // If the query for the doc failed, return the failure result. - return queryResult; + // Run the query for the workspace and org in a transaction. This brings some isolation protection + // against changes to the workspace or org while we are querying. + const { workspace, org, queryFailure } = await this._connection.transaction(async manager => { + const wsQueryResult = await this._getWorkspaceWithACLRules(scope, wsId, { manager }); + if (wsQueryResult.status !== 200) { + // If the query for the workspace failed, return the failure result. + return { queryFailure: wsQueryResult }; + } + + const orgQuery = this._buildOrgWithACLRulesQuery(scope, wsQueryResult.data.org.id, { manager }); + const orgQueryResult = await verifyEntity(orgQuery, { skipPermissionCheck: true }); + if (orgQueryResult.status !== 200) { + // If the query for the org failed, return the failure result. + return { queryFailure: orgQueryResult }; + } + + return { + workspace: wsQueryResult.data, + org: orgQueryResult.data + }; + }); + if (queryFailure) { + return queryFailure; } - const workspace: Workspace = queryResult.data; + const wsMap = getMemberUserRoles(workspace, this.defaultCommonGroupNames); + + // Also fetch the organization ACLs so we can determine inherited rights. + // The orgMap gives the org access inherited by each user. - const orgMap = getMemberUserRoles(workspace.org, this.defaultBasicGroupNames); - const orgMapWithMembership = getMemberUserRoles(workspace.org, this.defaultGroupNames); + const orgMap = getMemberUserRoles(org, this.defaultBasicGroupNames); + const orgMapWithMembership = getMemberUserRoles(org, this.defaultGroupNames); // Iterate through the org since all users will be in the org. - const users: UserAccessData[] = getResourceUsers([workspace, workspace.org]).map(u => { + + const users: UserAccessData[] = getResourceUsers([workspace, org]).map(u => { const orgAccess = orgMapWithMembership[u.id] || null; return { ...this.makeFullUser(u), @@ -2576,7 +2574,7 @@ export class HomeDBManager extends EventEmitter { .leftJoinAndSelect('orgs.aclRules', 'org_acl_rules') .leftJoinAndSelect('org_acl_rules.group', 'org_groups') .leftJoinAndSelect('org_groups.memberUsers', 'org_users'); - const docQueryResult = await verifyIsPermitted(docQuery); + const docQueryResult = await verifyEntity(docQuery); if (docQueryResult.status !== 200) { // If the query for the doc failed, return the failure result. return docQueryResult; @@ -2603,7 +2601,7 @@ export class HomeDBManager extends EventEmitter { .leftJoinAndSelect('org_acl_rules.group', 'org_groups') .leftJoinAndSelect('org_groups.memberUsers', 'org_users'); wsQuery = this._addFeatures(wsQuery); - const wsQueryResult = await verifyIsPermitted(wsQuery); + const wsQueryResult = await verifyEntity(wsQuery); if (wsQueryResult.status !== 200) { // If the query for the organization failed, return the failure result. return wsQueryResult; @@ -2681,7 +2679,7 @@ export class HomeDBManager extends EventEmitter { manager }) .addSelect(this._markIsPermitted('orgs', scope.userId, 'open', permissions), 'is_permitted'); - const docQueryResult = await verifyIsPermitted(docQuery); + const docQueryResult = await verifyEntity(docQuery); if (docQueryResult.status !== 200) { // If the query for the doc failed, return the failure result. return docQueryResult; @@ -4669,7 +4667,7 @@ export class HomeDBManager extends EventEmitter { // SQL results are flattened, and multiplying the number of rows we have already // by the number of workspace users could get excessive. .leftJoinAndSelect('docs.workspace', 'workspace'); - const queryResult = await verifyIsPermitted(docQuery); + const queryResult = await verifyEntity(docQuery); const doc: Document = this.unwrapQueryResult(queryResult); // Load the workspace's member groups/users. @@ -4777,7 +4775,7 @@ export class HomeDBManager extends EventEmitter { manager, markPermissions: Permissions.REMOVE }); - const workspace: Workspace = this.unwrapQueryResult(await verifyIsPermitted(wsQuery)); + const workspace: Workspace = this.unwrapQueryResult(await verifyEntity(wsQuery)); await manager.createQueryBuilder() .update(Workspace).set({removedAt}).where({id: workspace.id}) .execute(); @@ -4795,7 +4793,7 @@ export class HomeDBManager extends EventEmitter { if (!removedAt) { docQuery = this._addFeatures(docQuery); // pull in billing information for doc count limits } - const doc: Document = this.unwrapQueryResult(await verifyIsPermitted(docQuery)); + const doc: Document = this.unwrapQueryResult(await verifyEntity(docQuery)); if (!removedAt) { await this._checkRoomForAnotherDoc(doc.workspace, manager); } @@ -4829,16 +4827,59 @@ export class HomeDBManager extends EventEmitter { if (thisUser) { users.push(thisUser); } return { personal: true, public: !realAccess }; } + + private _getWorkspaceWithACLRules(scope: Scope, wsId: number, options: Partial = {}) { + const query = this._workspace(scope, wsId, { + markPermissions: Permissions.VIEW, + ...options + }) + // Join the workspace's ACL rules (with 1st level groups/users listed). + .leftJoinAndSelect('workspaces.aclRules', 'acl_rules') + .leftJoinAndSelect('acl_rules.group', 'workspace_groups') + .leftJoinAndSelect('workspace_groups.memberUsers', 'workspace_group_users') + .leftJoinAndSelect('workspace_groups.memberGroups', 'workspace_group_groups') + .leftJoinAndSelect('workspace_group_users.logins', 'workspace_user_logins') + .leftJoinAndSelect('workspaces.org', 'org'); + return verifyEntity(query); + } + + private _buildOrgWithACLRulesQuery(scope: Scope, org: number|string, opts: Partial = {}) { + return this.org(scope, org, { + needRealOrg: true, + ...opts + }) + // Join the org's ACL rules (with 1st level groups/users listed). + .leftJoinAndSelect('orgs.aclRules', 'acl_rules') + .leftJoinAndSelect('acl_rules.group', 'org_groups') + .leftJoinAndSelect('org_groups.memberUsers', 'org_member_users') + .leftJoinAndSelect('org_member_users.logins', 'user_logins'); + } + + private _getOrgWithACLRules(scope: Scope, org: number|string) { + const orgQuery = this._buildOrgWithACLRulesQuery(scope, org, { + markPermissions: Permissions.VIEW, + allowSpecialPermit: true, + }); + return verifyEntity(orgQuery); + } + } // Return a QueryResult reflecting the output of a query builder. // Checks on the "is_permitted" field which select queries set on resources to // indicate whether the user has access. +// // If the output is empty, we signal that the desired resource does not exist. -// If the "is_permitted" field is falsy, we signal that the resource is forbidden. +// +// If we retrieve more than 1 entity, we signal that the request is ambiguous. +// +// If the "is_permitted" field is falsy, we signal that the resource is forbidden, +// unless skipPermissionCheck is set. +// // Returns the resource fetched by the queryBuilder. -async function verifyIsPermitted( - queryBuilder: SelectQueryBuilder +async function verifyEntity( + queryBuilder: SelectQueryBuilder, + options: { skipPermissionCheck?: boolean } = {} ): Promise> { const results = await queryBuilder.getRawAndEntities(); if (results.entities.length === 0) { @@ -4851,7 +4892,7 @@ async function verifyIsPermitted( status: 400, errMessage: `ambiguous ${getFrom(queryBuilder)} request` }; - } else if (!results.raw[0].is_permitted) { + } else if (!options.skipPermissionCheck && !results.raw[0].is_permitted) { return { status: 403, errMessage: "access denied" diff --git a/test/gen-server/ApiServerAccess.ts b/test/gen-server/ApiServerAccess.ts index 987b3880..2742fed5 100644 --- a/test/gen-server/ApiServerAccess.ts +++ b/test/gen-server/ApiServerAccess.ts @@ -924,6 +924,21 @@ describe('ApiServerAccess', function() { isMember: true, }] }); + + const deltaOrg = { + users: { + [kiwiEmail]: "owners", + } + }; + const respDeltaOrg = await axios.patch(`${homeUrl}/api/orgs/${oid}/access`, {delta: deltaOrg}, chimpy); + assert.equal(respDeltaOrg.status, 200); + + const resp3 = await axios.get(`${homeUrl}/api/workspaces/${wid}/access`, chimpy); + assert.include(resp3.data.users.find((user: any) => user.email === kiwiEmail), { + access: "editors", + parentAccess: "owners" + }); + // Reset the access settings const resetDelta = { maxInheritedRole: "owners", @@ -933,6 +948,13 @@ describe('ApiServerAccess', function() { }; const resetResp = await axios.patch(`${homeUrl}/api/workspaces/${wid}/access`, {delta: resetDelta}, chimpy); assert.equal(resetResp.status, 200); + const resetOrgDelta = { + users: { + [kiwiEmail]: "members", + } + }; + const resetOrgResp = await axios.patch(`${homeUrl}/api/orgs/${oid}/access`, {delta: resetOrgDelta}, chimpy); + assert.equal(resetOrgResp.status, 200); // Assert that ws guests are properly displayed. // Tests a minor bug that showed ws guests as having null access. diff --git a/test/gen-server/apiUtils.ts b/test/gen-server/apiUtils.ts index 33976052..f269be63 100644 --- a/test/gen-server/apiUtils.ts +++ b/test/gen-server/apiUtils.ts @@ -22,6 +22,7 @@ import * as path from 'path'; import {createInitialDb, removeConnection, setUpDB} from 'test/gen-server/seed'; import {setPlan} from 'test/gen-server/testUtils'; import {fixturesRoot} from 'test/server/testUtils'; +import {isAffirmative} from 'app/common/gutil'; export class TestServer { public serverUrl: string; @@ -36,7 +37,7 @@ export class TestServer { public async start(servers: ServerType[] = ["home"], options: FlexServerOptions = {}): Promise { await createInitialDb(); - this.server = await mergedServerMain(0, servers, {logToConsole: false, + this.server = await mergedServerMain(0, servers, {logToConsole: isAffirmative(process.env.DEBUG), externalStorage: false, ...options}); this.serverUrl = this.server.getOwnUrl(); From 5e6abeb165ec0f1afc81eddd6cddc217b473f6f3 Mon Sep 17 00:00:00 2001 From: George Gevoian <85144792+georgegevoian@users.noreply.github.com> Date: Thu, 1 Feb 2024 10:45:18 -0500 Subject: [PATCH 5/8] Fix nbrowser test failures (#837) --- app/client/components/GridView.js | 9 +++++++ app/client/components/ViewLayout.css | 1 + test/nbrowser/CellColor.ts | 4 ++++ test/nbrowser/CustomView.ts | 1 + test/nbrowser/GridViewNewColumnMenu.ts | 33 +++++++++++++------------- test/nbrowser/ReferenceColumns.ts | 9 +++++-- test/nbrowser/ReferenceList.ts | 10 ++++++-- 7 files changed, 47 insertions(+), 20 deletions(-) diff --git a/app/client/components/GridView.js b/app/client/components/GridView.js index 633cbb8a..7edafeb6 100644 --- a/app/client/components/GridView.js +++ b/app/client/components/GridView.js @@ -295,6 +295,15 @@ GridView.gridCommands = { } this.cursor.rowIndex(this.cursor.rowIndex() - 1); }, + cursorLeft: function() { + // This conditional exists so that when users have the cursor in the leftmost column but + // are not scrolled to the left i.e. in the case of a wide column, pressing left again will + // scroll the pane to the left. + if (this.cursor.fieldIndex() === 0) { + this.scrollPane.scrollLeft = 0; + } + this.cursor.fieldIndex(this.cursor.fieldIndex() - 1); + }, shiftDown: function() { this._shiftSelect({step: 1, direction: 'down'}); }, shiftUp: function() { this._shiftSelect({step: 1, direction: 'up'}); }, shiftRight: function() { this._shiftSelect({step: 1, direction: 'right'}); }, diff --git a/app/client/components/ViewLayout.css b/app/client/components/ViewLayout.css index bd5cc8a9..72feef0f 100644 --- a/app/client/components/ViewLayout.css +++ b/app/client/components/ViewLayout.css @@ -19,6 +19,7 @@ margin-left: -16px; /* to include drag handle that shows up on hover */ margin-bottom: 4px; white-space: nowrap; + overflow: hidden; } .viewsection_title, .viewsection_title_font { diff --git a/test/nbrowser/CellColor.ts b/test/nbrowser/CellColor.ts index 1e0ea8b5..acb02f18 100644 --- a/test/nbrowser/CellColor.ts +++ b/test/nbrowser/CellColor.ts @@ -5,6 +5,7 @@ import { setupTestSuite } from 'test/nbrowser/testUtils'; describe('CellColor', function() { this.timeout(20000); + gu.bigScreen(); const cleanup = setupTestSuite(); let doc: string; @@ -534,6 +535,9 @@ describe('CellColor', function() { await gu.waitForServer(); await gu.setType(/Toggle/); + // make sure the view pane is scrolled all the way left + await gu.sendKeys(Key.ARROW_LEFT); + // enter 'true' await gu.getCell('E', 1).click(); await gu.enterCell('true'); diff --git a/test/nbrowser/CustomView.ts b/test/nbrowser/CustomView.ts index 59415c70..ed32f62e 100644 --- a/test/nbrowser/CustomView.ts +++ b/test/nbrowser/CustomView.ts @@ -22,6 +22,7 @@ async function setCustomWidget() { describe('CustomView', function() { this.timeout(20000); + gu.bigScreen(); const cleanup = setupTestSuite(); let serving: Serving; diff --git a/test/nbrowser/GridViewNewColumnMenu.ts b/test/nbrowser/GridViewNewColumnMenu.ts index d43df3ca..d9233f3c 100644 --- a/test/nbrowser/GridViewNewColumnMenu.ts +++ b/test/nbrowser/GridViewNewColumnMenu.ts @@ -83,8 +83,9 @@ describe('GridViewNewColumnMenu', function () { it('should create a new column', async function () { await clickAddColumn(); await driver.findWait('.test-new-columns-menu-add-new', 100).click(); + await gu.waitForServer(); //discard rename menu - await driver.findWait('.test-column-title-close', 100).click(); + await driver.find('.test-column-title-close').click(); //check if new column is present const columns = await gu.getColumnNames(); assert.include(columns, 'D', 'new column is not present'); @@ -194,24 +195,24 @@ describe('GridViewNewColumnMenu', function () { "Attachment", ].map((option) => ({type:option, testClass: option.toLowerCase().replace(' ', '-')})); for (const option of optionsToBeDisplayed) { - it(`should allow to select column type ${option.type}`, async function () { - // open add new colum menu - await clickAddColumn(); - // select "Add Column With type" option - await driver.findWait('.test-new-columns-menu-add-with-type', 100).click(); - // wait for submenu to appear - await driver.findWait('.test-new-columns-menu-add-with-type-submenu', 100); - // check if it is present in the menu - const element = await driver.findWait( - `.test-new-columns-menu-add-${option.testClass}`.toLowerCase(), - 100, - `${option.type} option is not present`); - // click on the option and check if column is added with a proper type - await element.click(); + it(`should allow to select column type ${option.type}`, async function () { + // open add new colum menu + await clickAddColumn(); + // select "Add Column With type" option + await driver.findWait('.test-new-columns-menu-add-with-type', 100).click(); + // wait for submenu to appear + await driver.findWait('.test-new-columns-menu-add-with-type-submenu', 100); + // check if it is present in the menu + const element = await driver.findWait( + `.test-new-columns-menu-add-${option.testClass}`.toLowerCase(), + 100, + `${option.type} option is not present`); + // click on the option and check if column is added with a proper type + await element.click(); + await gu.waitForServer(); //discard rename menu await driver.findWait('.test-column-title-close', 100).click(); //check if new column is present - await gu.waitForServer(); await gu.selectColumn('D'); await gu.openColumnPanel(); const type = await gu.getType(); diff --git a/test/nbrowser/ReferenceColumns.ts b/test/nbrowser/ReferenceColumns.ts index bbeafd06..e97d5e96 100644 --- a/test/nbrowser/ReferenceColumns.ts +++ b/test/nbrowser/ReferenceColumns.ts @@ -294,6 +294,10 @@ describe('ReferenceColumns', function() { assert.equal(await driver.find('.celleditor_text_editor').value(), 'da'); assert.equal(await driver.find('.test-ref-editor-item.selected').isPresent(), false); + // Clear the typed-in text temporarily. Something changed in a recent version of Chrome, + // causing the wrong item to be moused over below when the "Add New" option is visible. + await driver.sendKeys(Key.BACK_SPACE, Key.BACK_SPACE); + // Mouse over an item. await driver.findContent('.test-ref-editor-item', /Dark Gray/).mouseMove(); assert.equal(await driver.find('.celleditor_text_editor').value(), 'Dark Gray'); @@ -301,10 +305,11 @@ describe('ReferenceColumns', function() { // Mouse back out of the dropdown await driver.find('.celleditor_text_editor').mouseMove(); - assert.equal(await driver.find('.celleditor_text_editor').value(), 'da'); + assert.equal(await driver.find('.celleditor_text_editor').value(), ''); assert.equal(await driver.find('.test-ref-editor-item.selected').isPresent(), false); - // Click away to save the typed-in text. + // Re-enter the typed-in text and click away to save it. + await driver.sendKeys('da', Key.UP); await gu.getCell({section: 'References', col: 'Color', rowNum: 1}).doClick(); await gu.waitForServer(); assert.equal(await cell.getText(), "da"); diff --git a/test/nbrowser/ReferenceList.ts b/test/nbrowser/ReferenceList.ts index c3342d03..fe379988 100644 --- a/test/nbrowser/ReferenceList.ts +++ b/test/nbrowser/ReferenceList.ts @@ -582,6 +582,10 @@ describe('ReferenceList', function() { assert.equal(await driver.find('.cell_editor .test-tokenfield .test-tokenfield-input').value(), 'da'); assert.equal(await driver.find('.test-ref-editor-item.selected').isPresent(), false); + // Clear the typed-in text temporarily. Something changed in a recent version of Chrome, + // causing the wrong item to be moused over below when the "Add New" option is visible. + await driver.sendKeys(Key.BACK_SPACE, Key.BACK_SPACE); + // Mouse over an item. await driver.findContent('.test-ref-editor-item', /Dark Gray/).mouseMove(); assert.equal(await driver.find('.cell_editor .test-tokenfield .test-tokenfield-input').value(), 'Dark Gray'); @@ -589,10 +593,12 @@ describe('ReferenceList', function() { // Mouse back out of the dropdown await driver.find('.cell_editor .test-tokenfield .test-tokenfield-input').mouseMove(); - assert.equal(await driver.find('.cell_editor .test-tokenfield .test-tokenfield-input').value(), 'da'); + assert.equal(await driver.find('.cell_editor .test-tokenfield .test-tokenfield-input').value(), ''); assert.equal(await driver.find('.test-ref-editor-item.selected').isPresent(), false); - // Click away and check the cell is now empty since no reference items were added. + // Re-enter the typed-in text and click away. Check the cell is now empty since + // no reference items were added. + await driver.sendKeys('da', Key.UP); await gu.getCell({section: 'References', col: 'Colors', rowNum: 1}).doClick(); await gu.waitForServer(); assert.equal(await cell.getText(), ""); From ba407fd1a797c69fe9d8b6e810650cbed9597a04 Mon Sep 17 00:00:00 2001 From: Paul Janzen Date: Thu, 1 Feb 2024 12:17:21 +0000 Subject: [PATCH 6/8] Translated using Weblate (Portuguese (Brazil)) Currently translated at 100.0% (1024 of 1024 strings) Translation: Grist/client Translate-URL: https://hosted.weblate.org/projects/grist/client/pt_BR/ --- static/locales/pt_BR.client.json | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/static/locales/pt_BR.client.json b/static/locales/pt_BR.client.json index d6c4abfe..f03c6b35 100644 --- a/static/locales/pt_BR.client.json +++ b/static/locales/pt_BR.client.json @@ -666,7 +666,8 @@ "Columns_other": "Colunas", "Fields_one": "Campo", "Fields_other": "Campos", - "Add referenced columns": "Adicionar colunas referenciadas" + "Add referenced columns": "Adicionar colunas referenciadas", + "Reset form": "Restaurar formulário" }, "RowContextMenu": { "Copy anchor link": "Copiar o link de ancoragem", @@ -743,7 +744,8 @@ "TOOLS": "FERRAMENTAS", "Tour of this Document": "Tour desse Documento", "Validate Data": "Validar dados", - "Settings": "Configurações" + "Settings": "Configurações", + "API Console": "Consola API" }, "TopBar": { "Manage Team": "Gerenciar Equipe" @@ -1319,5 +1321,8 @@ "Delete card": "Excluir cartão", "Copy anchor link": "Copiar link de ancoragem", "Insert card": "Inserir cartão" + }, + "HiddenQuestionConfig": { + "Hidden fields": "Campos ocultos" } } From 71e1db02782821242d4fc59366ebadc2e8372251 Mon Sep 17 00:00:00 2001 From: Paul Janzen Date: Thu, 1 Feb 2024 12:15:20 +0000 Subject: [PATCH 7/8] Translated using Weblate (German) Currently translated at 100.0% (1024 of 1024 strings) Translation: Grist/client Translate-URL: https://hosted.weblate.org/projects/grist/client/de/ --- static/locales/de.client.json | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/static/locales/de.client.json b/static/locales/de.client.json index fd116102..fde5fbe6 100644 --- a/static/locales/de.client.json +++ b/static/locales/de.client.json @@ -666,7 +666,8 @@ "Columns_other": "Spalten", "Fields_one": "Feld", "Fields_other": "Felder", - "Add referenced columns": "Referenzspalten hinzufügen" + "Add referenced columns": "Referenzspalten hinzufügen", + "Reset form": "Formular zurücksetzen" }, "RowContextMenu": { "Copy anchor link": "Ankerlink kopieren", @@ -743,7 +744,8 @@ "TOOLS": "WERKZEUGE", "Tour of this Document": "Tour durch dieses Dokument", "Validate Data": "Daten validieren", - "Settings": "Einstellungen" + "Settings": "Einstellungen", + "API Console": "API-Konsole" }, "TopBar": { "Manage Team": "Team verwalten" @@ -1319,5 +1321,8 @@ "Delete card": "Karte löschen", "Copy anchor link": "Ankerlink kopieren", "Insert card": "Karte einfügen" + }, + "HiddenQuestionConfig": { + "Hidden fields": "Ausgeblendete Felder" } } From 2750eeae7ee9b092ee74e8972f35edec6c60eb9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C4=8Dek=20Prijatelj?= Date: Sat, 3 Feb 2024 11:26:18 +0000 Subject: [PATCH 8/8] Translated using Weblate (Slovenian) Currently translated at 100.0% (1090 of 1090 strings) Translation: Grist/client Translate-URL: https://hosted.weblate.org/projects/grist/client/sl/ --- static/locales/sl.client.json | 84 ++++++++++++++++++++++++++++++++--- 1 file changed, 79 insertions(+), 5 deletions(-) diff --git a/static/locales/sl.client.json b/static/locales/sl.client.json index 4d094714..6849e2da 100644 --- a/static/locales/sl.client.json +++ b/static/locales/sl.client.json @@ -233,7 +233,19 @@ "Add column": "Dodaj stolpec", "Last updated by": "Nazadnje posodobil", "Detect duplicates in...": "Zaznaj dvojnike v ...", - "Last updated at": "Nazadnje posodobljeno ob" + "Last updated at": "Nazadnje posodobljeno ob", + "Choice": "Izbira", + "Choice List": "Izbirni seznam", + "Reference": "Referenca", + "Reference List": "Referenčni seznam", + "Attachment": "Priponka", + "Any": "Karkoli", + "Toggle": "Preklopi", + "Date": "Datum", + "Numeric": "Numeričen", + "Text": "Tekst", + "Integer": "celo število", + "DateTime": "Datum čas" }, "HomeLeftPane": { "Trash": "Koš", @@ -330,7 +342,8 @@ "Add to page": "Dodaj na stran", "Show raw data": "Prikaži neobdelane podatke", "Copy anchor link": "Kopiraj sidrno povezavo", - "Collapse widget": "Strni gradnik" + "Collapse widget": "Strni gradnik", + "Create a form": "Ustvari obrazec" }, "FieldEditor": { "Unable to finish saving edited cell": "Ni mogoče dokončati shranjevanja urejene celice", @@ -580,7 +593,24 @@ "SELECTOR FOR": "SELEKTOR ZA", "Sort & Filter": "Razvrščanje in filtriranje", "Widget": "Pripomoček", - "Reset form": "Ponastavi obrazec" + "Reset form": "Ponastavi obrazec", + "Default field value": "Privzeta vrednost polja", + "Display button": "Gumb za prikaz", + "Field rules": "Pravila polja", + "Field title": "Naziv polja", + "Hidden field": "Skrito polje", + "Layout": "Postavitev", + "Redirection": "Preusmeritev", + "Required field": "Obvezno polje", + "Submit another response": "Predložite drug odgovor", + "Submission": "Predložitev", + "Success text": "Uspešno besedilo", + "Table column name": "Ime stolpca tabele", + "Configuration": "Konfiguracija", + "Enter text": "Vnesi besedilo", + "Redirect automatically after submission": "Po oddaji samodejno preusmeri", + "Enter redirect URL": "Vnesi preusmeritveni URL", + "Submit button label": "Oznaka gumba za pošiljanje" }, "FloatingPopup": { "Maximize": "Povečajte", @@ -1060,7 +1090,11 @@ "Contact support": "Obrnite se na podporo", "Account deleted{{suffix}}": "Račun je izbrisan{{suffix}}", "Your account has been deleted.": "Vaš račun je bil izbrisan.", - "Sign up": "Prijava" + "Sign up": "Prijava", + "Build your own form": "Ustvari svoj obrazec", + "Powered by": "Poganja ga", + "An unknown error occurred.": "Prišlo je do neznane napake.", + "Form not found": "Ne najdem obrazca" }, "WidgetTitle": { "DATA TABLE NAME": "IME PODATKOVNE TABELE", @@ -1245,7 +1279,17 @@ "modals": { "Save": "Shrani", "Cancel": "Prekliči", - "Ok": "v redu" + "Ok": "v redu", + "Are you sure you want to delete this record?": "Ali si prepričan, da želiš izbrisati ta zapis?", + "Dismiss": "Opusti", + "Don't ask again.": "Ne sprašuj več.", + "Don't show again.": "Ne pokaži več.", + "Don't show tips": "Ne pokaži nasvetov", + "Undo to restore": "Razveljavi obnovitev", + "Got it": "Razumem", + "Don't show again": "Ne pokaži več", + "Are you sure you want to delete these records?": "Ali si prepričan, da želiš izbrisati te zapise?", + "Delete": "Briši" }, "sendToDrive": { "Sending file to Google Drive": "Pošiljanje datoteke v Google Drive" @@ -1260,5 +1304,35 @@ }, "HiddenQuestionConfig": { "Hidden fields": "Skrita polja" + }, + "FormView": { + "Publish": "Objavi", + "Unpublish your form?": "Želiš preklicati objavo obrazca?", + "Publish your form?": "Želiš objaviti obrazec?", + "Unpublish": "Prekliči objavo" + }, + "Menu": { + "Building blocks": "Gradniki", + "Columns": "Stolpci", + "Copy": "Kopiraj", + "Cut": "Izreži", + "Insert question above": "Vstavi vprašanje zgoraj", + "Paste": "Prilepi", + "Separator": "Ločilo", + "Unmapped fields": "Nepreslikana polja", + "Header": "Glava", + "Insert question below": "Vstavi vprašanje spodaj", + "Paragraph": "Odstavek" + }, + "UnmappedFieldsConfig": { + "Clear": "Očisti", + "Mapped": "Preslikano", + "Select All": "Izberi vse", + "Map fields": "Preslikaj polja", + "Unmap fields": "Odstrani preslikavo polj", + "Unmapped": "Nepreslikan" + }, + "Editor": { + "Delete": "Briši" } }