diff --git a/dev/docker-compose.yaml b/dev/docker-compose.yaml index e1a4a6affd..c2f6254e2d 100644 --- a/dev/docker-compose.yaml +++ b/dev/docker-compose.yaml @@ -85,9 +85,6 @@ services: - COLLABORATOR_URL=ws://localhost:3078 - COLLABORATOR_API_URL=http://localhost:3078 - STORAGE_CONFIG=${STORAGE_CONFIG} - - TITLE=DevPlatform - - DEFAULT_LANGUAGE=ru - - LAST_NAME_FIRST=true restart: unless-stopped collaborator: image: hardcoreeng/collaborator diff --git a/dev/prod/public/branding.json b/dev/prod/public/branding.json new file mode 100644 index 0000000000..9108755de3 --- /dev/null +++ b/dev/prod/public/branding.json @@ -0,0 +1,58 @@ +{ + "localhost:8080": { + "title": "Platform", + "languages": "en,ru,pt,es", + "defaultLanguage": "en", + "defaultApplication": "tracker", + "defaultSpace": "tracker:project:DefaultProject", + "defaultSpecial": "issues", + "links": [ + { + "rel": "manifest", + "href": "/platform/site.webmanifest" + }, + { + "rel": "icon", + "href": "/platform/favicon.svg", + "type": "image/svg+xml" + }, + { + "rel": "shortcut icon", + "href": "/platform/favicon.ico", + "sizes": "any" + }, + { + "rel": "apple-touch-icon", + "href": "/platform/icon-192.png" + } + ] + }, + "localhost:8087": { + "title": "DevPlatform", + "languages": "en,ru,pt,es", + "defaultLanguage": "en", + "defaultApplication": "tracker", + "defaultSpace": "tracker:project:DefaultProject", + "defaultSpecial": "issues", + "links": [ + { + "rel": "manifest", + "href": "/platform/site.webmanifest" + }, + { + "rel": "icon", + "href": "/platform/favicon.svg", + "type": "image/svg+xml" + }, + { + "rel": "shortcut icon", + "href": "/platform/favicon.ico", + "sizes": "any" + }, + { + "rel": "apple-touch-icon", + "href": "/platform/icon-192.png" + } + ] + } +} diff --git a/dev/prod/public/config.json b/dev/prod/public/config.json index 22b22e1e3f..96df6d0db1 100644 --- a/dev/prod/public/config.json +++ b/dev/prod/public/config.json @@ -7,6 +7,5 @@ "CALENDAR_URL": "http://localhost:8095", "REKONI_URL": "http://localhost:4004", "COLLABORATOR_URL": "ws://localhost:3078", - "COLLABORATOR_API_URL": "http://localhost:3078", - "LAST_NAME_FIRST": "true" + "COLLABORATOR_API_URL": "http://localhost:3078" } \ No newline at end of file diff --git a/dev/prod/public/favicon.ico b/dev/prod/public/platform/favicon.ico similarity index 100% rename from dev/prod/public/favicon.ico rename to dev/prod/public/platform/favicon.ico diff --git a/dev/prod/public/favicon.svg b/dev/prod/public/platform/favicon.svg similarity index 100% rename from dev/prod/public/favicon.svg rename to dev/prod/public/platform/favicon.svg diff --git a/dev/prod/public/icon-1024.png b/dev/prod/public/platform/icon-1024.png similarity index 100% rename from dev/prod/public/icon-1024.png rename to dev/prod/public/platform/icon-1024.png diff --git a/dev/prod/public/icon-1600.png b/dev/prod/public/platform/icon-1600.png similarity index 100% rename from dev/prod/public/icon-1600.png rename to dev/prod/public/platform/icon-1600.png diff --git a/dev/prod/public/icon-192.png b/dev/prod/public/platform/icon-192.png similarity index 100% rename from dev/prod/public/icon-192.png rename to dev/prod/public/platform/icon-192.png diff --git a/dev/prod/public/icon-256.png b/dev/prod/public/platform/icon-256.png similarity index 100% rename from dev/prod/public/icon-256.png rename to dev/prod/public/platform/icon-256.png diff --git a/dev/prod/public/icon-512.png b/dev/prod/public/platform/icon-512.png similarity index 100% rename from dev/prod/public/icon-512.png rename to dev/prod/public/platform/icon-512.png diff --git a/dev/prod/public/site.webmanifest b/dev/prod/public/platform/site.webmanifest similarity index 100% rename from dev/prod/public/site.webmanifest rename to dev/prod/public/platform/site.webmanifest diff --git a/dev/prod/src/index.ejs b/dev/prod/src/index.ejs index 8713736b58..1c1d8aa1b7 100644 --- a/dev/prod/src/index.ejs +++ b/dev/prod/src/index.ejs @@ -3,13 +3,8 @@ - Platform - - - - - + diff --git a/dev/prod/src/platform.ts b/dev/prod/src/platform.ts index 23d2cf6e75..3e43b85ec2 100644 --- a/dev/prod/src/platform.ts +++ b/dev/prod/src/platform.ts @@ -101,12 +101,28 @@ interface Config { COLLABORATOR_URL: string COLLABORATOR_API_URL: string PUSH_PUBLIC_KEY: string - TITLE?: string - LANGUAGES?: string - DEFAULT_LANGUAGE?: string - LAST_NAME_FIRST?: string + BRANDING_URL?: string } +export interface Branding { + title?: string + links?: { + rel: string + href: string + type?: string + sizes?: string + }[] + languages?: string + lastNameFirst?: string + defaultLanguage?: string + defaultApplication?: string + defaultSpace?: string + defaultSpecial?: string + initWorkspace?: string +} + +export type BrandingMap = Record + const devConfig = process.env.CLIENT_TYPE === 'dev-production' function configureI18n(): void { @@ -164,7 +180,40 @@ export async function configurePlatform() { configureI18n() const config: Config = await (await fetch(devConfig? '/config-dev.json' : '/config.json')).json() + const branding: BrandingMap = await (await fetch(config.BRANDING_URL ?? '/branding.json')).json() + const myBranding = branding[window.location.host] ?? {} + console.log('loading configuration', config) + console.log('loaded branding', myBranding) + + const title = myBranding.title ?? 'Platform' + + // apply branding + window.document.title = title + + const links = myBranding.links ?? [] + if (links.length > 0) { + // remove the default favicon + // it's only needed for Safari which cannot use dynamically added links for favicons + document.getElementById('default-favicon')?.remove() + + for (const link of links) { + const htmlLink = document.createElement('link') + htmlLink.rel = link.rel + htmlLink.href = link.href + + if (link.type !== undefined) { + htmlLink.type = link.type + } + + if (link.sizes !== undefined) { + htmlLink.setAttribute('sizes', link.sizes) + } + + document.head.appendChild(htmlLink) + } + } + setMetadata(login.metadata.AccountsUrl, config.ACCOUNTS_URL) setMetadata(presentation.metadata.UploadURL, config.UPLOAD_URL) setMetadata(presentation.metadata.CollaboratorUrl, config.COLLABORATOR_URL) @@ -187,8 +236,8 @@ export async function configurePlatform() { setMetadata(uiPlugin.metadata.DefaultApplication, login.component.LoginApp) - setMetadata(contactPlugin.metadata.LastNameFirst, config.LAST_NAME_FIRST === 'true' ?? false) - const languages = config.LANGUAGES ? (config.LANGUAGES as string).split(',').map((l) => l.trim()) : ['en', 'ru', 'es', 'pt'] + setMetadata(contactPlugin.metadata.LastNameFirst, myBranding.lastNameFirst === 'true' ?? false) + const languages = myBranding.languages ? (myBranding.languages as string).split(',').map((l) => l.trim()) : ['en', 'ru', 'es', 'pt'] setMetadata(uiPlugin.metadata.Languages, languages) setMetadata( @@ -245,10 +294,10 @@ export async function configurePlatform() { // Disable for now, since it causes performance issues on linux/docker/kubernetes boxes for now. setMetadata(client.metadata.UseProtocolCompression, true) - setMetadata(uiPlugin.metadata.PlatformTitle, config.TITLE ?? 'Platform') - setMetadata(workbench.metadata.PlatformTitle, config.TITLE ?? 'Platform') - setDefaultLanguage(config.DEFAULT_LANGUAGE ?? 'en') - setMetadata(workbench.metadata.DefaultApplication, 'tracker') - setMetadata(workbench.metadata.DefaultSpace, tracker.project.DefaultProject) - setMetadata(workbench.metadata.DefaultSpecial, 'issues') + setMetadata(uiPlugin.metadata.PlatformTitle, title) + setMetadata(workbench.metadata.PlatformTitle, title) + setDefaultLanguage(myBranding.defaultLanguage ?? 'en') + setMetadata(workbench.metadata.DefaultApplication, myBranding.defaultApplication ?? 'tracker') + setMetadata(workbench.metadata.DefaultSpace, myBranding.defaultSpace ?? tracker.project.DefaultProject) + setMetadata(workbench.metadata.DefaultSpecial, myBranding.defaultSpecial ?? 'issues') } diff --git a/dev/tool/src/cleanOrphan.ts b/dev/tool/src/cleanOrphan.ts index 44b1146329..2cf24e2227 100644 --- a/dev/tool/src/cleanOrphan.ts +++ b/dev/tool/src/cleanOrphan.ts @@ -70,6 +70,7 @@ export async function checkOrphanWorkspaces ( db, client, productId, + null, ws.workspace, storageAdapter ) diff --git a/dev/tool/src/index.ts b/dev/tool/src/index.ts index 5fd0e7069a..edaad27d29 100644 --- a/dev/tool/src/index.ts +++ b/dev/tool/src/index.ts @@ -183,7 +183,7 @@ export function devTool ( const { mongodbUri } = prepareTools() await withDatabase(mongodbUri, async (db) => { console.log(`creating account ${cmd.first as string} ${cmd.last as string} (${email})...`) - await createAcc(toolCtx, db, productId, email, cmd.password, cmd.first, cmd.last, true) + await createAcc(toolCtx, db, productId, null, email, cmd.password, cmd.first, cmd.last, true) }) }) @@ -234,7 +234,7 @@ export function devTool ( } console.log('assigning to workspace', workspaceInfo) try { - await assignWorkspace(toolCtx, db, productId, email, workspaceInfo.workspace, AccountRole.User) + await assignWorkspace(toolCtx, db, productId, null, email, workspaceInfo.workspace, AccountRole.User) } catch (err: any) { console.error(err) } @@ -281,7 +281,8 @@ export function devTool ( .description('create workspace') .requiredOption('-w, --workspaceName ', 'Workspace name') .option('-e, --email ', 'Author email', 'platform@email.com') - .action(async (workspace, cmd) => { + .option('-i, --init ', 'Init from workspace') + .action(async (workspace, cmd: { email: string, workspaceName: string, init?: string }) => { const { mongodbUri, txes, version, migrateOperations } = prepareTools() await withDatabase(mongodbUri, async (db) => { await createWorkspace( @@ -291,6 +292,7 @@ export function devTool ( migrateOperations, db, productId, + cmd.init !== undefined ? { initWorkspace: cmd.init } : null, cmd.email, cmd.workspaceName, workspace @@ -429,9 +431,9 @@ export function devTool ( return } if (cmd.full) { - await dropWorkspaceFull(toolCtx, db, client, productId, workspace, storageAdapter) + await dropWorkspaceFull(toolCtx, db, client, productId, null, workspace, storageAdapter) } else { - await dropWorkspace(toolCtx, db, productId, workspace) + await dropWorkspace(toolCtx, db, productId, null, workspace) } }) }) @@ -447,9 +449,9 @@ export function devTool ( await withDatabase(mongodbUri, async (db, client) => { for (const workspace of await listWorkspacesByAccount(db, productId, email)) { if (cmd.full) { - await dropWorkspaceFull(toolCtx, db, client, productId, workspace.workspace, storageAdapter) + await dropWorkspaceFull(toolCtx, db, client, productId, null, workspace.workspace, storageAdapter) } else { - await dropWorkspace(toolCtx, db, productId, workspace.workspace) + await dropWorkspace(toolCtx, db, productId, null, workspace.workspace) } } }) @@ -480,7 +482,7 @@ export function devTool ( for (const ws of workspacesJSON) { const lastVisit = Math.floor((Date.now() - ws.lastVisit) / 1000 / 3600 / 24) if (lastVisit > 30) { - await dropWorkspaceFull(toolCtx, db, client, productId, ws.workspace, storageAdapter) + await dropWorkspaceFull(toolCtx, db, client, productId, null, ws.workspace, storageAdapter) } } }) @@ -575,7 +577,7 @@ export function devTool ( .action(async (email: string, cmd) => { const { mongodbUri } = prepareTools() await withDatabase(mongodbUri, async (db) => { - await dropAccount(toolCtx, db, productId, email) + await dropAccount(toolCtx, db, productId, null, email) }) }) diff --git a/pods/account/src/__start.ts b/pods/account/src/__start.ts index 55fe29e9e9..3cbf6c20ee 100644 --- a/pods/account/src/__start.ts +++ b/pods/account/src/__start.ts @@ -13,6 +13,8 @@ // limitations under the License. // +import fs from 'fs' +import { type BrandingMap } from '@hcengineering/account' import { serveAccount } from '@hcengineering/account-service' import { MeasureMetricsContext, newMetrics, type Tx } from '@hcengineering/core' import builder, { getModelVersion, migrateOperations } from '@hcengineering/model-all' @@ -24,4 +26,11 @@ const txes = JSON.parse(JSON.stringify(builder(enabled, disabled).getTxes())) as const metricsContext = new MeasureMetricsContext('account', {}, {}, newMetrics()) -serveAccount(metricsContext, getModelVersion(), txes, migrateOperations, '') +const brandingPath = process.env.BRANDING_PATH + +let brandings: BrandingMap = {} +if (brandingPath !== undefined && brandingPath !== '') { + brandings = JSON.parse(fs.readFileSync(brandingPath, 'utf8')) +} + +serveAccount(metricsContext, getModelVersion(), txes, migrateOperations, '', brandings) diff --git a/pods/authProviders/src/github.ts b/pods/authProviders/src/github.ts index a456fb44e4..4ae35ee341 100644 --- a/pods/authProviders/src/github.ts +++ b/pods/authProviders/src/github.ts @@ -47,14 +47,24 @@ export function registerGithub ( if (email !== undefined) { try { if (ctx.query?.state != null) { - const loginInfo = await joinWithProvider(measureCtx, db, productId, email, first, last, ctx.query.state, { - githubId: ctx.state.user.id - }) + const loginInfo = await joinWithProvider( + measureCtx, + db, + productId, + null, + email, + first, + last, + ctx.query.state, + { + githubId: ctx.state.user.id + } + ) if (ctx.session != null) { ctx.session.loginInfo = loginInfo } } else { - const loginInfo = await loginWithProvider(measureCtx, db, productId, email, first, last, { + const loginInfo = await loginWithProvider(measureCtx, db, productId, null, email, first, last, { githubId: ctx.state.user.id }) if (ctx.session != null) { diff --git a/pods/authProviders/src/google.ts b/pods/authProviders/src/google.ts index 78d42276e9..4734aff1bf 100644 --- a/pods/authProviders/src/google.ts +++ b/pods/authProviders/src/google.ts @@ -48,12 +48,21 @@ export function registerGoogle ( if (email !== undefined) { try { if (ctx.query?.state != null) { - const loginInfo = await joinWithProvider(measureCtx, db, productId, email, first, last, ctx.query.state) + const loginInfo = await joinWithProvider( + measureCtx, + db, + productId, + null, + email, + first, + last, + ctx.query.state + ) if (ctx.session != null) { ctx.session.loginInfo = loginInfo } } else { - const loginInfo = await loginWithProvider(measureCtx, db, productId, email, first, last) + const loginInfo = await loginWithProvider(measureCtx, db, productId, null, email, first, last) if (ctx.session != null) { ctx.session.loginInfo = loginInfo } diff --git a/pods/front/Dockerfile b/pods/front/Dockerfile index 9d8c4c8986..f40251f7ba 100644 --- a/pods/front/Dockerfile +++ b/pods/front/Dockerfile @@ -4,6 +4,7 @@ ENV NODE_ENV production WORKDIR /app RUN npm install --ignore-scripts=false --verbose sharp@v0.32.6 bufferutil utf-8-validate @mongodb-js/zstd --unsafe-perm + COPY bundle/bundle.js ./ COPY dist/ ./dist/ diff --git a/server/account-service/src/index.ts b/server/account-service/src/index.ts index 0607c32be9..200176c80c 100644 --- a/server/account-service/src/index.ts +++ b/server/account-service/src/index.ts @@ -7,7 +7,8 @@ import account, { UpgradeWorker, accountId, cleanInProgressWorkspaces, - getMethods + getMethods, + type BrandingMap } from '@hcengineering/account' import accountEn from '@hcengineering/account/lang/en.json' import accountRu from '@hcengineering/account/lang/ru.json' @@ -34,8 +35,10 @@ export function serveAccount ( txes: Tx[], migrateOperations: [string, MigrateOperation][], productId: string, + brandings: BrandingMap, onClose?: () => void ): void { + console.log('Starting account service with brandings: ', brandings) const methods = getMethods(version, txes, migrateOperations) const ACCOUNT_PORT = parseInt(process.env.ACCOUNT_PORT ?? '3000') const dbUri = process.env.MONGO_URL @@ -141,7 +144,14 @@ export function serveAccount ( client = await client } const db = client.db(ACCOUNT_DB) - const result = await method(measureCtx, db, productId, request, token) + + let host: string | undefined + const origin = ctx.request.headers.origin ?? ctx.request.headers.referer + if (origin !== undefined) { + host = new URL(origin).host + } + const branding = host !== undefined ? brandings[host] : null + const result = await method(measureCtx, db, productId, branding, request, token) worker?.updateResponseStatistics(result) ctx.body = result diff --git a/server/account/src/__tests__/account.test_skip.ts b/server/account/src/__tests__/account.test_skip.ts index 39df87a03a..da40c99894 100644 --- a/server/account/src/__tests__/account.test_skip.ts +++ b/server/account/src/__tests__/account.test_skip.ts @@ -51,7 +51,7 @@ describe('server', () => { params: [workspace, 'ООО Рога и Копыта'] } - const result = await methods.createWorkspace(metricsContext, db, '', request) + const result = await methods.createWorkspace(metricsContext, db, '', null, request) expect(result.result).toBeDefined() workspace = result.result as string }) @@ -62,12 +62,12 @@ describe('server', () => { params: ['andrey2', '123'] } - const result = await methods.createAccount(metricsContext, db, '', request) + const result = await methods.createAccount(metricsContext, db, '', null, request) expect(result.result).toBeDefined() }) it('should not create, duplicate account', async () => { - await methods.createAccount(metricsContext, db, '', { + await methods.createAccount(metricsContext, db, '', null, { method: 'createAccount', params: ['andrey', '123'] }) @@ -77,20 +77,20 @@ describe('server', () => { params: ['andrey', '123'] } - const result = await methods.createAccount(metricsContext, db, '', request) + const result = await methods.createAccount(metricsContext, db, '', null, request) expect(result.error).toBeDefined() }) it('should login', async () => { - await methods.createAccount(metricsContext, db, '', { + await methods.createAccount(metricsContext, db, '', null, { method: 'createAccount', params: ['andrey', '123'] }) - await methods.createWorkspace(metricsContext, db, '', { + await methods.createWorkspace(metricsContext, db, '', null, { method: 'createWorkspace', params: [workspace, 'ООО Рога и Копыта'] }) - await methods.assignWorkspace(metricsContext, db, '', { + await methods.assignWorkspace(metricsContext, db, '', null, { method: 'assignWorkspace', params: ['andrey', workspace] }) @@ -100,7 +100,7 @@ describe('server', () => { params: ['andrey', '123', workspace] } - const result = await methods.login(metricsContext, db, '', request) + const result = await methods.login(metricsContext, db, '', null, request) expect(result.result).toBeDefined() }) @@ -110,7 +110,7 @@ describe('server', () => { params: ['andrey', '123555', workspace] } - const result = await methods.login(metricsContext, db, '', request) + const result = await methods.login(metricsContext, db, '', null, request) expect(result.error).toBeDefined() }) @@ -120,7 +120,7 @@ describe('server', () => { params: ['andrey1', '123555', workspace] } - const result = await methods.login(metricsContext, db, '', request) + const result = await methods.login(metricsContext, db, '', null, request) expect(result.error).toBeDefined() }) @@ -130,20 +130,20 @@ describe('server', () => { params: ['andrey', '123', 'non-existent-workspace'] } - const result = await methods.login(metricsContext, db, '', request) + const result = await methods.login(metricsContext, db, '', null, request) expect(result.error).toBeDefined() }) it('do remove workspace', async () => { - await methods.createAccount(metricsContext, db, '', { + await methods.createAccount(metricsContext, db, '', null, { method: 'createAccount', params: ['andrey', '123'] }) - await methods.createWorkspace(metricsContext, db, '', { + await methods.createWorkspace(metricsContext, db, '', null, { method: 'createWorkspace', params: [workspace, 'ООО Рога и Копыта'] }) - await methods.assignWorkspace(metricsContext, db, '', { + await methods.assignWorkspace(metricsContext, db, '', null, { method: 'assignWorkspace', params: ['andrey', workspace] }) @@ -152,7 +152,7 @@ describe('server', () => { expect((await getAccount(db, 'andrey'))?.workspaces.length).toEqual(1) expect((await getWorkspaceByUrl(db, '', workspace))?.accounts.length).toEqual(1) - await methods.removeWorkspace(metricsContext, db, '', { + await methods.removeWorkspace(metricsContext, db, '', null, { method: 'removeWorkspace', params: ['andrey', workspace] }) diff --git a/server/account/src/operations.ts b/server/account/src/operations.ts index 00fb7e0880..3e12e66d5b 100644 --- a/server/account/src/operations.ts +++ b/server/account/src/operations.ts @@ -234,6 +234,7 @@ async function getAccountInfo ( ctx: MeasureContext, db: Db, productId: string, + branding: Branding | null, email: string, password: string ): Promise { @@ -254,6 +255,7 @@ async function getAccountInfoByToken ( ctx: MeasureContext, db: Db, productId: string, + branding: Branding | null, token: string ): Promise { let email: string = '' @@ -290,12 +292,13 @@ export async function login ( ctx: MeasureContext, db: Db, productId: string, + branding: Branding | null, _email: string, password: string ): Promise { const email = cleanEmail(_email) try { - const info = await getAccountInfo(ctx, db, productId, email, password) + const info = await getAccountInfo(ctx, db, productId, branding, email, password) const result = { endpoint: getEndpoint(), email, @@ -330,6 +333,7 @@ export async function selectWorkspace ( ctx: MeasureContext, db: Db, productId: string, + branding: Branding | null, token: string, workspaceUrl: string, allowAdmin: boolean = true @@ -429,6 +433,7 @@ export async function join ( ctx: MeasureContext, db: Db, productId: string, + branding: Branding | null, _email: string, password: string, inviteId: ObjectId @@ -441,14 +446,15 @@ export async function join ( ctx, db, productId, + branding, email, workspace.name, invite?.role ?? AccountRole.User, invite?.personId ) - const token = (await login(ctx, db, productId, email, password)).token - const result = await selectWorkspace(ctx, db, productId, token, ws.workspaceUrl ?? ws.workspace) + const token = (await login(ctx, db, productId, branding, email, password)).token + const result = await selectWorkspace(ctx, db, productId, branding, token, ws.workspaceUrl ?? ws.workspace) await useInvite(db, inviteId) return result } @@ -476,7 +482,13 @@ export async function confirmEmail (db: Db, _email: string): Promise { /** * @public */ -export async function confirm (ctx: MeasureContext, db: Db, productId: string, token: string): Promise { +export async function confirm ( + ctx: MeasureContext, + db: Db, + productId: string, + branding: Branding | null, + token: string +): Promise { const decode = decodeToken(token) const _email = decode.extra?.confirm if (_email === undefined) { @@ -495,7 +507,7 @@ export async function confirm (ctx: MeasureContext, db: Db, productId: string, t return result } -async function sendConfirmation (productId: string, account: Account): Promise { +async function sendConfirmation (productId: string, branding: Branding | null, account: Account): Promise { const sesURL = getMetadata(accountPlugin.metadata.SES_URL) if (sesURL === undefined || sesURL === '') { console.info('Please provide email service url to enable email confirmations.') @@ -516,10 +528,11 @@ async function sendConfirmation (productId: string, account: Account): Promise { decodeToken(token) // Just verify token is valid @@ -739,7 +759,7 @@ export async function cleanInProgressWorkspaces (db: Db, productId: string): Pro ).map((it) => ({ ...it, productId })) const ctx = new MeasureMetricsContext('clean', {}) for (const d of toDelete) { - await dropWorkspace(ctx, db, productId, d.workspace) + await dropWorkspace(ctx, db, productId, null, d.workspace) } } @@ -878,6 +898,7 @@ export async function createWorkspace ( migrationOperation: [string, MigrateOperation][], db: Db, productId: string, + branding: Branding | null, email: string, workspaceName: string, workspace?: string, @@ -915,7 +936,7 @@ export async function createWorkspace ( } let model: Tx[] = [] try { - const initWS = getMetadata(toolPlugin.metadata.InitWorkspace) + const initWS = branding?.initWorkspace ?? getMetadata(toolPlugin.metadata.InitWorkspace) const wsId = getWorkspaceId(workspaceInfo.workspace, productId) // We should not try to clone INIT_WS into INIT_WS during it's creation. @@ -1034,7 +1055,14 @@ export async function upgradeWorkspace ( */ export const createUserWorkspace = (version: Data, txes: Tx[], migrationOperation: [string, MigrateOperation][]) => - async (ctx: MeasureContext, db: Db, productId: string, token: string, workspaceName: string): Promise => { + async ( + ctx: MeasureContext, + db: Db, + productId: string, + branding: Branding | null, + token: string, + workspaceName: string + ): Promise => { const { email } = decodeToken(token) ctx.info('Creating workspace', { workspaceName, email }) @@ -1064,12 +1092,13 @@ export const createUserWorkspace = migrationOperation, db, productId, + branding, email, workspaceName, undefined, notifyHandler, async (workspace, model) => { - const initWS = getMetadata(toolPlugin.metadata.InitWorkspace) + const initWS = branding?.initWorkspace ?? getMetadata(toolPlugin.metadata.InitWorkspace) const shouldUpdateAccount = initWS !== undefined && (await getWorkspaceById(db, productId, initWS)) !== null const client = await connect( getTransactor(), @@ -1085,6 +1114,7 @@ export const createUserWorkspace = ctx, db, productId, + branding, email, workspace.workspace, AccountRole.Owner, @@ -1145,6 +1175,7 @@ export async function getInviteLink ( ctx: MeasureContext, db: Db, productId: string, + branding: Branding | null, token: string, exp: number, emailMask: string, @@ -1202,6 +1233,7 @@ export async function getUserWorkspaces ( ctx: MeasureContext, db: Db, productId: string, + branding: Branding | null, token: string ): Promise { const { email } = decodeToken(token) @@ -1227,6 +1259,7 @@ export async function getWorkspaceInfo ( ctx: MeasureContext, db: Db, productId: string, + branding: Branding | null, token: string, _updateLastVisit: boolean = false ): Promise { @@ -1337,10 +1370,11 @@ export async function createMissingEmployee ( ctx: MeasureContext, db: Db, productId: string, + branding: Branding | null, token: string ): Promise { const { email } = decodeToken(token) - const wsInfo = await getWorkspaceInfo(ctx, db, productId, token) + const wsInfo = await getWorkspaceInfo(ctx, db, productId, branding, token) const account = await getAccount(db, email) if (account === null) { @@ -1357,6 +1391,7 @@ export async function assignWorkspace ( ctx: MeasureContext, db: Db, productId: string, + branding: Branding | null, _email: string, workspaceId: string, role: AccountRole, @@ -1366,7 +1401,7 @@ export async function assignWorkspace ( personAccountId?: Ref ): Promise { const email = cleanEmail(_email) - const initWS = getMetadata(toolPlugin.metadata.InitWorkspace) + const initWS = branding?.initWorkspace ?? getMetadata(toolPlugin.metadata.InitWorkspace) if (initWS !== undefined && initWS === workspaceId) { Analytics.handleError(new Error(`assign-workspace failed ${email} ${workspaceId}`)) ctx.error('assign-workspace failed', { email, workspaceId, reason: 'initWs === workspaceId' }) @@ -1571,12 +1606,13 @@ export async function changePassword ( ctx: MeasureContext, db: Db, productId: string, + branding: Branding | null, token: string, oldPassword: string, password: string ): Promise { const { email } = decodeToken(token) - const account = await getAccountInfo(ctx, db, productId, email, oldPassword) + const account = await getAccountInfo(ctx, db, productId, branding, email, oldPassword) const salt = randomBytes(32) const hash = hashWithSalt(password, salt) @@ -1611,7 +1647,13 @@ export async function replacePassword (db: Db, productId: string, email: string, /** * @public */ -export async function requestPassword (ctx: MeasureContext, db: Db, productId: string, _email: string): Promise { +export async function requestPassword ( + ctx: MeasureContext, + db: Db, + productId: string, + branding: Branding | null, + _email: string +): Promise { const email = cleanEmail(_email) const account = await getAccount(db, email) @@ -1638,10 +1680,10 @@ export async function requestPassword (ctx: MeasureContext, db: Db, productId: s ) const link = concatLink(front, `/login/recovery?id=${token}`) - - const text = await translate(accountPlugin.string.RecoveryText, { link }) - const html = await translate(accountPlugin.string.RecoveryHTML, { link }) - const subject = await translate(accountPlugin.string.RecoverySubject, {}) + const lang = branding?.language + const text = await translate(accountPlugin.string.RecoveryText, { link }, lang) + const html = await translate(accountPlugin.string.RecoveryHTML, { link }, lang) + const subject = await translate(accountPlugin.string.RecoverySubject, {}, lang) const to = account.email await fetch(concatLink(sesURL, '/send'), { @@ -1666,6 +1708,7 @@ export async function restorePassword ( ctx: MeasureContext, db: Db, productId: string, + branding: Branding | null, token: string, password: string ): Promise { @@ -1682,7 +1725,7 @@ export async function restorePassword ( await updatePassword(db, account, password) - return await login(ctx, db, productId, email, password) + return await login(ctx, db, productId, branding, email, password) } async function updatePassword (db: Db, account: Account, password: string | null): Promise { @@ -1699,6 +1742,7 @@ export async function removeWorkspace ( ctx: MeasureContext, db: Db, productId: string, + branding: Branding | null, email: string, workspaceId: string ): Promise { @@ -1719,6 +1763,7 @@ export async function checkJoin ( ctx: MeasureContext, db: Db, productId: string, + branding: Branding | null, token: string, inviteId: ObjectId ): Promise { @@ -1732,7 +1777,7 @@ export async function checkJoin ( new Status(Severity.ERROR, platform.status.WorkspaceNotFound, { workspace: workspace.name }) ) } - return await selectWorkspace(ctx, db, productId, token, ws?.workspaceUrl ?? ws.workspace, false) + return await selectWorkspace(ctx, db, productId, branding, token, ws?.workspaceUrl ?? ws.workspace, false) } /** @@ -1742,6 +1787,7 @@ export async function dropWorkspace ( ctx: MeasureContext, db: Db, productId: string, + branding: Branding | null, workspaceId: string ): Promise { const ws = await getWorkspaceById(db, productId, workspaceId) @@ -1765,10 +1811,11 @@ export async function dropWorkspaceFull ( db: Db, client: MongoClient, productId: string, + branding: Branding | null, workspaceId: string, storageAdapter?: StorageAdapter ): Promise { - const ws = await dropWorkspace(ctx, db, productId, workspaceId) + const ws = await dropWorkspace(ctx, db, productId, branding, workspaceId) const workspaceDb = client.db(ws.workspace) await workspaceDb.dropDatabase() const wspace = getWorkspaceId(workspaceId, productId) @@ -1782,7 +1829,13 @@ export async function dropWorkspaceFull ( /** * @public */ -export async function dropAccount (ctx: MeasureContext, db: Db, productId: string, email: string): Promise { +export async function dropAccount ( + ctx: MeasureContext, + db: Db, + productId: string, + branding: Branding | null, + email: string +): Promise { const account = await getAccount(db, email) if (account === null) { throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, { account: email })) @@ -1813,6 +1866,7 @@ export async function leaveWorkspace ( ctx: MeasureContext, db: Db, productId: string, + branding: Branding | null, token: string, email: string ): Promise { @@ -1851,6 +1905,7 @@ export async function sendInvite ( ctx: MeasureContext, db: Db, productId: string, + branding: Branding | null, token: string, email: string, personId?: Ref, @@ -1885,13 +1940,14 @@ export async function sendInvite ( const expHours = 48 const exp = expHours * 60 * 60 * 1000 - const inviteId = await getInviteLink(ctx, db, productId, token, exp, email, 1) + const inviteId = await getInviteLink(ctx, db, productId, branding, token, exp, email, 1) const link = concatLink(front, `/login/join?inviteId=${inviteId.toString()}`) const ws = workspace.workspaceName ?? workspace.workspace - const text = await translate(accountPlugin.string.InviteText, { link, ws, expHours }) - const html = await translate(accountPlugin.string.InviteHTML, { link, ws, expHours }) - const subject = await translate(accountPlugin.string.InviteSubject, { ws }) + const lang = branding?.language + const text = await translate(accountPlugin.string.InviteText, { link, ws, expHours }, lang) + const html = await translate(accountPlugin.string.InviteHTML, { link, ws, expHours }, lang) + const subject = await translate(accountPlugin.string.InviteSubject, { ws }, lang) const to = email await fetch(concatLink(sesURL, '/send'), { @@ -1935,6 +1991,14 @@ async function deactivatePersonAccount ( } } +export interface Branding { + title?: string + language?: string + initWorkspace?: string +} + +export type BrandingMap = Record + /** * @public */ @@ -1942,16 +2006,30 @@ export type AccountMethod = ( ctx: MeasureContext, db: Db, productId: string, + branding: Branding | null, request: any, token?: string ) => Promise function wrap ( - accountMethod: (ctx: MeasureContext, db: Db, productId: string, ...args: any[]) => Promise + accountMethod: ( + ctx: MeasureContext, + db: Db, + productId: string, + branding: Branding | null, + ...args: any[] + ) => Promise ): AccountMethod { - return async function (ctx: MeasureContext, db: Db, productId: string, request: any, token?: string): Promise { + return async function ( + ctx: MeasureContext, + db: Db, + productId: string, + branding: Branding | null, + request: any, + token?: string + ): Promise { if (token !== undefined) request.params.unshift(token) - return await accountMethod(ctx, db, productId, ...request.params) + return await accountMethod(ctx, db, productId, branding, ...request.params) .then((result) => ({ id: request.id, result })) .catch((err) => { const status = @@ -1975,6 +2053,7 @@ export async function joinWithProvider ( ctx: MeasureContext, db: Db, productId: string, + branding: Branding | null, _email: string, first: string, last: string, @@ -2013,29 +2092,39 @@ export async function joinWithProvider ( ctx, db, productId, + branding, email, workspace.name, invite?.role ?? AccountRole.User, invite?.personId ) - const result = await selectWorkspace(ctx, db, productId, token, wsRes.workspaceUrl ?? wsRes.workspace, false) + const result = await selectWorkspace( + ctx, + db, + productId, + branding, + token, + wsRes.workspaceUrl ?? wsRes.workspace, + false + ) await useInvite(db, inviteId) return result } - const newAccount = await createAcc(ctx, db, productId, email, null, first, last, true, extra) + const newAccount = await createAcc(ctx, db, productId, branding, email, null, first, last, true, extra) const token = generateToken(email, getWorkspaceId('', productId), getExtra(newAccount)) const ws = await assignWorkspace( ctx, db, productId, + branding, email, workspace.name, invite?.role ?? AccountRole.User, invite?.personId ) - const result = await selectWorkspace(ctx, db, productId, token, ws.workspaceUrl ?? ws.workspace, false) + const result = await selectWorkspace(ctx, db, productId, branding, token, ws.workspaceUrl ?? ws.workspace, false) await useInvite(db, inviteId) @@ -2046,6 +2135,7 @@ export async function loginWithProvider ( ctx: MeasureContext, db: Db, productId: string, + branding: Branding | null, _email: string, first: string, last: string, @@ -2071,7 +2161,7 @@ export async function loginWithProvider ( } return result } - const newAccount = await createAcc(ctx, db, productId, email, null, first, last, true, extra) + const newAccount = await createAcc(ctx, db, productId, branding, email, null, first, last, true, extra) const result = { endpoint: getEndpoint(), diff --git a/server/front/src/index.ts b/server/front/src/index.ts index a7dcb9d2db..b18e7fac57 100644 --- a/server/front/src/index.ts +++ b/server/front/src/index.ts @@ -244,10 +244,7 @@ export function start ( calendarUrl: string collaboratorUrl: string collaboratorApiUrl: string - title?: string - languages: string - defaultLanguage: string - lastNameFirst?: string + brandingUrl?: string }, port: number, extraConfig?: Record @@ -281,10 +278,7 @@ export function start ( CALENDAR_URL: config.calendarUrl, COLLABORATOR_URL: config.collaboratorUrl, COLLABORATOR_API_URL: config.collaboratorApiUrl, - TITLE: config.title, - LANGUAGES: config.languages, - DEFAULT_LANGUAGE: config.defaultLanguage, - LAST_NAME_FIRST: config.lastNameFirst, + BRANDING_URL: config.brandingUrl, ...(extraConfig ?? {}) } res.set('Cache-Control', cacheControlNoCache) diff --git a/server/front/src/starter.ts b/server/front/src/starter.ts index 6d659d46d8..d1c855a5ab 100644 --- a/server/front/src/starter.ts +++ b/server/front/src/starter.ts @@ -22,8 +22,6 @@ import serverToken from '@hcengineering/server-token' import { start } from '.' export function startFront (ctx: MeasureContext, extraConfig?: Record): void { - const defaultLanguage = process.env.DEFAULT_LANGUAGE ?? 'en' - const languages = process.env.LANGUAGES ?? 'en,ru' const SERVER_PORT = parseInt(process.env.SERVER_PORT ?? '8080') const transactorEndpoint = process.env.TRANSACTOR_URL @@ -107,9 +105,7 @@ export function startFront (ctx: MeasureContext, extraConfig?: Record this.page.locator('button[id$="Contacts"]') buttonTracker = (): Locator => this.page.locator('button[id$="TrackerApplication"]') buttonNotification = (): Locator => this.page.locator('button[id$="Inbox"]') - buttonDocuments = (): Locator => this.page.locator('button[id$="DocumentApplication"]') + buttonDocuments = (): Locator => this.page.locator('button[id$="document:string:DocumentApplication"]') profileButton = (): Locator => this.page.locator('#profile-button') inviteToWorkspaceButton = (): Locator => this.page.locator('button:has-text("Invite to workspace")') getInviteLinkButton = (): Locator => this.page.locator('button:has-text("Get invite link")')