From 4165163e96b2ba16b6a0985ba20479e5aacc8864 Mon Sep 17 00:00:00 2001 From: Alexander Platov Date: Fri, 20 Sep 2024 21:58:02 +0700 Subject: [PATCH 1/6] Planner: updated ToDos layout (#6651) Signed-off-by: Alexander Platov --- packages/theme/styles/components.scss | 14 +++++++++++++- packages/ui/src/components/AccordionItem.svelte | 2 ++ .../src/components/ToDoProjectGroup.svelte | 2 +- plugins/time-resources/src/components/ToDos.svelte | 2 +- 4 files changed, 17 insertions(+), 3 deletions(-) diff --git a/packages/theme/styles/components.scss b/packages/theme/styles/components.scss index 142c807643..72607215e4 100644 --- a/packages/theme/styles/components.scss +++ b/packages/theme/styles/components.scss @@ -577,9 +577,12 @@ } &.second:not(.isOpen), &.border, - &.default:not(.nested:last-child) { + &.default:not(.nested) { border-bottom: 1px solid var(--theme-navpanel-divider); // var(--global-surface-01-BorderColor); } + &.default.nested:not(:last-child) { + border-bottom: 1px dashed var(--theme-navpanel-divider); + } .hulyAccordionItem-header { display: flex; justify-content: space-between; @@ -790,6 +793,12 @@ } } } + &.hiddenHeader { + display: none; + visibility: hidden; + + &.nested + .hulyAccordionItem-content { padding-top: var(--spacing-1); } + } &:hover .hulyAccordionItem-header__chevron { color: var(--button-subtle-IconColor); background-color: var(--global-ui-hover-BackgroundColor); @@ -806,6 +815,9 @@ &.medium.bottomSpace + .hulyAccordionItem-content { padding-bottom: var(--spacing-2); } + &.medium.nested.bottomSpace + .hulyAccordionItem-content { + padding-bottom: var(--spacing-1); + } &.large.bottomSpace + .hulyAccordionItem-content { padding-bottom: var(--spacing-2); } diff --git a/packages/ui/src/components/AccordionItem.svelte b/packages/ui/src/components/AccordionItem.svelte index e2f3b28520..750e0d0d1a 100644 --- a/packages/ui/src/components/AccordionItem.svelte +++ b/packages/ui/src/components/AccordionItem.svelte @@ -39,6 +39,7 @@ export let duration: number | boolean = false export let fixHeader: boolean = false export let categoryHeader: boolean = false + export let hiddenHeader: boolean = false export let background: string | undefined = undefined const dispatch = createEventDispatcher() @@ -74,6 +75,7 @@ class:selectable class:scroller-header={fixHeader} class:categoryHeader + class:hiddenHeader style:background-color={background ?? 'transparent'} on:click|stopPropagation={handleClick} > diff --git a/plugins/time-resources/src/components/ToDoProjectGroup.svelte b/plugins/time-resources/src/components/ToDoProjectGroup.svelte index 3b02c99c06..bcef61dc68 100644 --- a/plugins/time-resources/src/components/ToDoProjectGroup.svelte +++ b/plugins/time-resources/src/components/ToDoProjectGroup.svelte @@ -81,7 +81,7 @@ } - + {#each todos as todo, index} diff --git a/plugins/time-resources/src/components/ToDos.svelte b/plugins/time-resources/src/components/ToDos.svelte index b2858a6beb..81dde73f4f 100644 --- a/plugins/time-resources/src/components/ToDos.svelte +++ b/plugins/time-resources/src/components/ToDos.svelte @@ -237,9 +237,9 @@ active: WithLookup[] ): [IntlString, WithLookup[]][] { const groups = new Map[]>([ + [time.string.Scheduled, []], [time.string.Unplanned, unplanned], [time.string.ToDos, []], - [time.string.Scheduled, []], [time.string.Done, done] ]) const now = Date.now() From 9a35f013ad60cb160bc22a48a0f344be1f51ccf1 Mon Sep 17 00:00:00 2001 From: Alexey Zinoviev Date: Fri, 20 Sep 2024 18:59:23 +0400 Subject: [PATCH 2/6] uberf-8195: support openid auth (#6654) Signed-off-by: Alexey Zinoviev --- common/config/rush/pnpm-lock.yaml | 29 ++++- .../src/components/Providers.svelte | 5 + .../src/components/icons/OpenId.svelte | 15 +++ .../src/components/providers/OpenId.svelte | 10 ++ pods/authProviders/package.json | 1 + pods/authProviders/src/github.ts | 3 +- pods/authProviders/src/google.ts | 3 +- pods/authProviders/src/index.ts | 7 +- pods/authProviders/src/openid.ts | 119 ++++++++++++++++++ pods/authProviders/src/token.ts | 10 +- server/account-service/src/index.ts | 7 +- 11 files changed, 196 insertions(+), 13 deletions(-) create mode 100644 plugins/login-resources/src/components/icons/OpenId.svelte create mode 100644 plugins/login-resources/src/components/providers/OpenId.svelte create mode 100644 pods/authProviders/src/openid.ts diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index 7e335295fa..9a8645a5bf 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -1691,6 +1691,9 @@ dependencies: openai: specifier: ^4.56.0 version: 4.56.0(zod@3.23.8) + openid-client: + specifier: ~5.7.0 + version: 5.7.0 otp-generator: specifier: ^4.0.1 version: 4.0.1 @@ -17193,6 +17196,10 @@ packages: engines: {node: '>= 0.6.0'} dev: false + /jose@4.15.9: + resolution: {integrity: sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==} + dev: false + /jose@5.6.3: resolution: {integrity: sha512-1Jh//hEEwMhNYPDDLwXHa2ePWgWiFNNUadVmguAAw2IJ6sj9mNxV5tGXJNqlMkJAybF6Lgw1mISDxTePP/187g==} dev: false @@ -19076,6 +19083,11 @@ packages: resolution: {integrity: sha512-CsubGNxhIEChNY4cXYuA6KXafztzHqzLLZ/y3Kasf3A+sa3lL9thq3z+7o0pZqzEinjXT6lXDPAfVWI59dUyzQ==} dev: false + /object-hash@2.2.0: + resolution: {integrity: sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==} + engines: {node: '>= 6'} + dev: false + /object-hash@3.0.0: resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} engines: {node: '>= 6'} @@ -19210,6 +19222,11 @@ packages: resolution: {integrity: sha512-zuHHiGTYTA1sYJ/wZN+t5HKZaH23i4yI1HMwbuXm24Nid7Dv0KcuRlKoNKS9UNfAVSBlnGLcuQrnOKWOZoEGaw==} dev: false + /oidc-token-hash@5.0.3: + resolution: {integrity: sha512-IF4PcGgzAr6XXSff26Sk/+P4KZFJVuHAJZj3wgO3vX2bMdNVp/QXTP3P7CEm9V1IdG8lDLY3HhiqpsE/nOwpPw==} + engines: {node: ^10.13.0 || >=12.0.0} + dev: false + /omggif@1.0.10: resolution: {integrity: sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw==} dev: false @@ -19298,6 +19315,15 @@ packages: hasBin: true dev: false + /openid-client@5.7.0: + resolution: {integrity: sha512-4GCCGZt1i2kTHpwvaC/sCpTpQqDnBzDzuJcJMbH+y1Q5qI8U8RBvoSh28svarXszZHR5BAMXbJPX1PGPRE3VOA==} + dependencies: + jose: 4.15.9 + lru-cache: 6.0.0 + object-hash: 2.2.0 + oidc-token-hash: 5.0.3 + dev: false + /option@0.2.4: resolution: {integrity: sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A==} dev: false @@ -25220,7 +25246,7 @@ packages: dev: false file:projects/auth-providers.tgz(@types/node@20.11.19)(esbuild@0.20.1)(ts-node@10.9.2): - resolution: {integrity: sha512-RDD+zkNosRxJuY+bq2skRGbRPr4saOaAvlkKv3gvvIXT5RxnmOXnznS6haxIcXw4VA96oxv08U9Mu8+3PjgpwQ==, tarball: file:projects/auth-providers.tgz} + resolution: {integrity: sha512-0YbyLxnpaSZfdCfdlt5T9LI/TmahO7fbLohsHvXQVFb2J1InAIyu1WWzbS92CLYLHxQtJmSIOAbPVbRV1wPWbw==, tarball: file:projects/auth-providers.tgz} id: file:projects/auth-providers.tgz name: '@rush-temp/auth-providers' version: 0.0.0 @@ -25248,6 +25274,7 @@ packages: koa-router: 12.0.1 koa-session: 6.4.0 mongodb: 6.9.0 + openid-client: 5.7.0 passport-custom: 1.1.1 passport-github2: 0.1.12 passport-google-oauth20: 2.0.0 diff --git a/plugins/login-resources/src/components/Providers.svelte b/plugins/login-resources/src/components/Providers.svelte index 4e60845186..4753592a75 100644 --- a/plugins/login-resources/src/components/Providers.svelte +++ b/plugins/login-resources/src/components/Providers.svelte @@ -7,6 +7,7 @@ import { getProviders } from '../utils' import Github from './providers/Github.svelte' import Google from './providers/Google.svelte' + import OpenId from './providers/OpenId.svelte' interface Provider { name: string @@ -21,6 +22,10 @@ { name: 'github', component: Github + }, + { + name: 'openid', + component: OpenId } ] diff --git a/plugins/login-resources/src/components/icons/OpenId.svelte b/plugins/login-resources/src/components/icons/OpenId.svelte new file mode 100644 index 0000000000..c977eaecc5 --- /dev/null +++ b/plugins/login-resources/src/components/icons/OpenId.svelte @@ -0,0 +1,15 @@ + + + + diff --git a/plugins/login-resources/src/components/providers/OpenId.svelte b/plugins/login-resources/src/components/providers/OpenId.svelte new file mode 100644 index 0000000000..1948f535c8 --- /dev/null +++ b/plugins/login-resources/src/components/providers/OpenId.svelte @@ -0,0 +1,10 @@ + + +
+ +
diff --git a/pods/authProviders/package.json b/pods/authProviders/package.json index 4735797719..20e8c75f2b 100644 --- a/pods/authProviders/package.json +++ b/pods/authProviders/package.json @@ -52,6 +52,7 @@ "passport-custom": "~1.1.1", "passport-google-oauth20": "~2.0.0", "passport-github2": "~0.1.12", + "openid-client": "~5.7.0", "koa-passport": "^6.0.0", "koa": "^2.15.3", "koa-router": "^12.0.1", diff --git a/pods/authProviders/src/github.ts b/pods/authProviders/src/github.ts index 57ffae31db..6fc525cd5f 100644 --- a/pods/authProviders/src/github.ts +++ b/pods/authProviders/src/github.ts @@ -12,7 +12,7 @@ export function registerGithub ( passport: Passport, router: Router, accountsUrl: string, - db: Db, + dbPromise: Promise, frontUrl: string, brandings: BrandingMap ): string | undefined { @@ -69,6 +69,7 @@ export function registerGithub ( let loginInfo: LoginInfo const state = safeParseAuthState(ctx.query?.state) const branding = getBranding(brandings, state?.branding) + const db = await dbPromise if (state.inviteId != null && state.inviteId !== '') { loginInfo = await joinWithProvider(measureCtx, db, null, email, first, last, state.inviteId as any, { githubId: ctx.state.user.id diff --git a/pods/authProviders/src/google.ts b/pods/authProviders/src/google.ts index 6453165d58..be68fe269a 100644 --- a/pods/authProviders/src/google.ts +++ b/pods/authProviders/src/google.ts @@ -12,7 +12,7 @@ export function registerGoogle ( passport: Passport, router: Router, accountsUrl: string, - db: Db, + dbPromise: Promise, frontUrl: string, brandings: BrandingMap ): string | undefined { @@ -74,6 +74,7 @@ export function registerGoogle ( let loginInfo: LoginInfo const state = safeParseAuthState(ctx.query?.state) const branding = getBranding(brandings, state?.branding) + const db = await dbPromise if (state.inviteId != null && state.inviteId !== '') { loginInfo = await joinWithProvider(measureCtx, db, null, email, first, last, state.inviteId as any) } else { diff --git a/pods/authProviders/src/index.ts b/pods/authProviders/src/index.ts index 0ab49993c0..a812761f03 100644 --- a/pods/authProviders/src/index.ts +++ b/pods/authProviders/src/index.ts @@ -5,6 +5,7 @@ import session from 'koa-session' import { Db } from 'mongodb' import { registerGithub } from './github' import { registerGoogle } from './google' +import { registerOpenid } from './openid' import { registerToken } from './token' import { BrandingMap, MeasureContext } from '@hcengineering/core' @@ -15,7 +16,7 @@ export type AuthProvider = ( passport: Passport, router: Router, accountsUrl: string, - db: Db, + db: Promise, frontUrl: string, brandings: BrandingMap ) => string | undefined @@ -24,7 +25,7 @@ export function registerProviders ( ctx: MeasureContext, app: Koa, router: Router, - db: Db, + db: Promise, serverSecret: string, frontUrl: string | undefined, brandings: BrandingMap @@ -60,7 +61,7 @@ export function registerProviders ( registerToken(ctx, passport, router, accountsUrl, db, frontUrl, brandings) const res: string[] = [] - const providers: AuthProvider[] = [registerGoogle, registerGithub] + const providers: AuthProvider[] = [registerGoogle, registerGithub, registerOpenid] for (const provider of providers) { const value = provider(ctx, passport, router, accountsUrl, db, frontUrl, brandings) if (value !== undefined) res.push(value) diff --git a/pods/authProviders/src/openid.ts b/pods/authProviders/src/openid.ts new file mode 100644 index 0000000000..b517c28664 --- /dev/null +++ b/pods/authProviders/src/openid.ts @@ -0,0 +1,119 @@ +// +// Copyright © 2024 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the f. +// +import { joinWithProvider, loginWithProvider, type LoginInfo } from '@hcengineering/account' +import { BrandingMap, concatLink, MeasureContext, getBranding } from '@hcengineering/core' +import Router from 'koa-router' +import { Db } from 'mongodb' +import { Issuer, Strategy } from 'openid-client' +import qs from 'querystringify' + +import { Passport } from '.' +import { getHost, safeParseAuthState } from './utils' + +export function registerOpenid ( + measureCtx: MeasureContext, + passport: Passport, + router: Router, + accountsUrl: string, + dbPromise: Promise, + frontUrl: string, + brandings: BrandingMap +): string | undefined { + const openidClientId = process.env.OPENID_CLIENT_ID + const openidClientSecret = process.env.OPENID_CLIENT_SECRET + const issuer = process.env.OPENID_ISSUER + + const redirectURL = '/auth/openid/callback' + if (openidClientId === undefined || openidClientSecret === undefined || issuer === undefined) return + + void Issuer.discover(issuer).then((issuerObj) => { + const client = new issuerObj.Client({ + client_id: openidClientId, + client_secret: openidClientSecret, + redirect_uris: [concatLink(accountsUrl, redirectURL)], + response_types: ['code'] + }) + + passport.use( + 'oidc', + new Strategy({ client, passReqToCallback: true }, (req: any, tokenSet: any, userinfo: any, done: any) => { + return done(null, userinfo) + }) + ) + }) + + router.get('/auth/openid', async (ctx, next) => { + measureCtx.info('try auth via', { provider: 'openid' }) + const host = getHost(ctx.request.headers) + const brandingKey = host !== undefined ? brandings[host]?.key ?? undefined : undefined + const state = encodeURIComponent( + JSON.stringify({ + inviteId: ctx.query?.inviteId, + branding: brandingKey + }) + ) + + await passport.authenticate('oidc', { + scope: 'openid profile email', + state + })(ctx, next) + }) + + router.get( + redirectURL, + async (ctx, next) => { + const state = safeParseAuthState(ctx.query?.state) + const branding = getBranding(brandings, state?.branding) + + await passport.authenticate('oidc', { + failureRedirect: concatLink(branding?.front ?? frontUrl, '/login') + })(ctx, next) + }, + async (ctx, next) => { + try { + const email = ctx.state.user.email ?? `openid:${ctx.state.user.sub}` + const [first, last] = ctx.state.user.name?.split(' ') ?? [ctx.state.user.username, ''] + measureCtx.info('Provider auth handler', { email, type: 'openid' }) + if (email !== undefined) { + let loginInfo: LoginInfo + const state = safeParseAuthState(ctx.query?.state) + const branding = getBranding(brandings, state?.branding) + const db = await dbPromise + if (state.inviteId != null && state.inviteId !== '') { + loginInfo = await joinWithProvider(measureCtx, db, null, email, first, last, state.inviteId as any, { + openId: ctx.state.user.sub + }) + } else { + loginInfo = await loginWithProvider(measureCtx, db, null, email, first, last, { + openId: ctx.state.user.sub + }) + } + + const origin = concatLink(branding?.front ?? frontUrl, '/login/auth') + const query = encodeURIComponent(qs.stringify({ token: loginInfo.token })) + + measureCtx.info('Success auth, redirect', { email, type: 'openid', target: origin }) + // Successful authentication, redirect to your application + ctx.redirect(`${origin}?${query}`) + } + } catch (err: any) { + measureCtx.error('failed to auth', { err, type: 'openid', user: ctx.state?.user }) + } + await next() + } + ) + + return 'openid' +} diff --git a/pods/authProviders/src/token.ts b/pods/authProviders/src/token.ts index fa2ad0793f..8edbd7d32b 100644 --- a/pods/authProviders/src/token.ts +++ b/pods/authProviders/src/token.ts @@ -12,7 +12,7 @@ export function registerToken ( passport: Passport, router: Router, accountsUrl: string, - db: Db, + dbPromise: Promise, frontUrl: string, brandings: BrandingMap ): string | undefined { @@ -21,9 +21,11 @@ export function registerToken ( new CustomStrategy(function (req: any, done: any) { const token = req.body.token ?? req.query.token - getAccountInfoByToken(measureCtx, db, null, token) - .then((user: any) => done(null, user)) - .catch((err: any) => done(err)) + void dbPromise.then((db) => { + getAccountInfoByToken(measureCtx, db, null, token) + .then((user: any) => done(null, user)) + .catch((err: any) => done(err)) + }) }) ) diff --git a/server/account-service/src/index.ts b/server/account-service/src/index.ts index c57ccaf2a6..03257a6f06 100644 --- a/server/account-service/src/index.ts +++ b/server/account-service/src/index.ts @@ -103,10 +103,11 @@ export function serveAccount (measureCtx: MeasureContext, brandings: BrandingMap ) app.use(bodyParser()) - void client.getClient().then(async (p: MongoClient) => { - const db = p.db(ACCOUNT_DB) - registerProviders(measureCtx, app, router, db, serverSecret, frontURL, brandings) + const mongoClientPromise = client.getClient() + const dbPromise = mongoClientPromise.then((c) => c.db(ACCOUNT_DB)) + registerProviders(measureCtx, app, router, dbPromise, serverSecret, frontURL, brandings) + void dbPromise.then((db) => { setInterval( () => { void cleanExpiredOtp(db) From 25ab61f48896e0535fc236fd776618f98657afd8 Mon Sep 17 00:00:00 2001 From: Alexander Platov Date: Fri, 20 Sep 2024 22:04:06 +0700 Subject: [PATCH 3/6] Added Checking styles in a Document (#6631) Signed-off-by: Alexander Platov --- .../right-panel/DocumentCommentThread.svelte | 6 +- .../model/documents/document-comments-page.ts | 14 ++-- .../tests/documents/documents-content.spec.ts | 66 ++++++++++++++- .../sanity/tests/documents/documents.spec.ts | 2 +- tests/sanity/tests/model/common-page.ts | 4 + .../model/documents/document-content-page.ts | 83 +++++++++++++++++-- .../model/recruiting/talent-details-page.ts | 4 + 7 files changed, 159 insertions(+), 20 deletions(-) diff --git a/plugins/controlled-documents-resources/src/components/document/right-panel/DocumentCommentThread.svelte b/plugins/controlled-documents-resources/src/components/document/right-panel/DocumentCommentThread.svelte index b984c42f75..538667bafb 100644 --- a/plugins/controlled-documents-resources/src/components/document/right-panel/DocumentCommentThread.svelte +++ b/plugins/controlled-documents-resources/src/components/document/right-panel/DocumentCommentThread.svelte @@ -32,13 +32,13 @@ {#if value !== undefined}
-
+ {#if value?.index} - #{value.index} + #{value.index} {/if}
+ {#if $canAddDocumentCommentsFeedback}
{:else}
@@ -195,6 +201,10 @@ .link { margin: 1.75rem 0 0; overflow-wrap: break-word; + + &.notSecure { + user-select: text; + } } .buttons { diff --git a/plugins/view-resources/src/index.ts b/plugins/view-resources/src/index.ts index f05390a215..0e08e278c5 100644 --- a/plugins/view-resources/src/index.ts +++ b/plugins/view-resources/src/index.ts @@ -135,7 +135,8 @@ import { canDeleteSpace, canEditSpace, canJoinSpace, - canLeaveSpace + canLeaveSpace, + isClipboardAvailable } from './visibilityTester' export { canArchiveSpace, canDeleteObject, canDeleteSpace, canEditSpace } from './visibilityTester' export { getActions, getContextActions, invokeAction, showMenu } from './actions' @@ -334,6 +335,7 @@ export default async (): Promise => ({ CanDeleteSpace: canDeleteSpace, CanJoinSpace: canJoinSpace, CanLeaveSpace: canLeaveSpace, + IsClipboardAvailable: isClipboardAvailable, BlobImageMetadata: blobImageMetadata, BlobVideoMetadata: blobVideoMetadata } diff --git a/plugins/view-resources/src/visibilityTester.ts b/plugins/view-resources/src/visibilityTester.ts index 060f2789e4..5787083d62 100644 --- a/plugins/view-resources/src/visibilityTester.ts +++ b/plugins/view-resources/src/visibilityTester.ts @@ -143,3 +143,7 @@ export async function canLeaveSpace (doc?: Doc | Doc[]): Promise { return space.members?.includes(getCurrentAccount()._id) } + +export function isClipboardAvailable (doc?: Doc | Doc[]): boolean { + return isSecureContext && navigator.clipboard !== undefined +} From 6f4713d603d2a0b0166c8f00911126a8f96a65af Mon Sep 17 00:00:00 2001 From: Andrey Sobolev Date: Fri, 20 Sep 2024 22:09:57 +0700 Subject: [PATCH 6/6] Skip failed test Signed-off-by: Andrey Sobolev --- tests/sanity/tests/documents/documents-content.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/sanity/tests/documents/documents-content.spec.ts b/tests/sanity/tests/documents/documents-content.spec.ts index e6399e8f54..6b5bf05eba 100644 --- a/tests/sanity/tests/documents/documents-content.spec.ts +++ b/tests/sanity/tests/documents/documents-content.spec.ts @@ -170,7 +170,7 @@ test.describe('Content in the Documents tests', () => { }) }) - test('Check Image view and size actions', async ({ page }) => { + test.skip('Check Image view and size actions', async ({ page }) => { await documentContentPage.addImageToDocument(page) const imageSrc = await documentContentPage.firstImageInDocument().getAttribute('src')