UBERF-6984: Host-based branding (#5657)

Signed-off-by: Alexey Zinoviev <alexey.zinoviev@xored.com>
This commit is contained in:
Alexey Zinoviev 2024-05-28 18:14:45 +04:00 committed by GitHub
parent 9050eef34a
commit 2d163bf428
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 364 additions and 114 deletions

View File

@ -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

View File

@ -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"
}
]
}
}

View File

@ -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"
}

View File

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

Before

Width:  |  Height:  |  Size: 961 B

After

Width:  |  Height:  |  Size: 961 B

View File

Before

Width:  |  Height:  |  Size: 930 KiB

After

Width:  |  Height:  |  Size: 930 KiB

View File

Before

Width:  |  Height:  |  Size: 2.3 MiB

After

Width:  |  Height:  |  Size: 2.3 MiB

View File

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 37 KiB

View File

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

View File

Before

Width:  |  Height:  |  Size: 221 KiB

After

Width:  |  Height:  |  Size: 221 KiB

View File

@ -3,13 +3,8 @@
<head>
<meta charset="utf8">
<title>Platform</title>
<link rel="manifest" href="/site.webmanifest">
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="shortcut icon" href="/favicon.ico" sizes="any">
<link rel="apple-touch-icon" href="/icon-192.png">
<link rel="shortcut icon" href="/platform/favicon.ico" sizes="any" id="default-favicon">
</head>
<body style="margin: 0; overflow: hidden;">

View File

@ -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<string, Branding>
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')
}

View File

@ -70,6 +70,7 @@ export async function checkOrphanWorkspaces (
db,
client,
productId,
null,
ws.workspace,
storageAdapter
)

View File

@ -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 <workspaceName>', 'Workspace name')
.option('-e, --email <email>', 'Author email', 'platform@email.com')
.action(async (workspace, cmd) => {
.option('-i, --init <ws>', '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)
})
})

View File

@ -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)

View File

@ -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) {

View File

@ -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
}

View File

@ -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/

View File

@ -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

View File

@ -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]
})

View File

@ -234,6 +234,7 @@ async function getAccountInfo (
ctx: MeasureContext,
db: Db,
productId: string,
branding: Branding | null,
email: string,
password: string
): Promise<AccountInfo> {
@ -254,6 +255,7 @@ async function getAccountInfoByToken (
ctx: MeasureContext,
db: Db,
productId: string,
branding: Branding | null,
token: string
): Promise<LoginInfo> {
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<LoginInfo> {
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<Account> {
/**
* @public
*/
export async function confirm (ctx: MeasureContext, db: Db, productId: string, token: string): Promise<LoginInfo> {
export async function confirm (
ctx: MeasureContext,
db: Db,
productId: string,
branding: Branding | null,
token: string
): Promise<LoginInfo> {
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<void> {
async function sendConfirmation (productId: string, branding: Branding | null, account: Account): Promise<void> {
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<v
const link = concatLink(front, `/login/confirm?id=${token}`)
const name = getMetadata(accountPlugin.metadata.ProductName)
const text = await translate(accountPlugin.string.ConfirmationText, { name, link })
const html = await translate(accountPlugin.string.ConfirmationHTML, { name, link })
const subject = await translate(accountPlugin.string.ConfirmationSubject, { name })
const name = branding?.title ?? getMetadata(accountPlugin.metadata.ProductName)
const lang = branding?.language
const text = await translate(accountPlugin.string.ConfirmationText, { name, link }, lang)
const html = await translate(accountPlugin.string.ConfirmationHTML, { name, link }, lang)
const subject = await translate(accountPlugin.string.ConfirmationSubject, { name }, lang)
if (sesURL !== undefined && sesURL !== '') {
const to = account.email
@ -545,6 +558,7 @@ export async function signUpJoin (
ctx: MeasureContext,
db: Db,
productId: string,
branding: Branding | null,
_email: string,
password: string,
first: string,
@ -560,6 +574,7 @@ export async function signUpJoin (
ctx,
db,
productId,
branding,
email,
password,
first,
@ -570,14 +585,15 @@ export async function signUpJoin (
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
}
@ -589,6 +605,7 @@ export async function createAcc (
ctx: MeasureContext,
db: Db,
productId: string,
branding: Branding | null,
_email: string,
password: string | null,
first: string,
@ -631,7 +648,7 @@ export async function createAcc (
const sesURL = getMetadata(accountPlugin.metadata.SES_URL)
if (!confirmed) {
if (sesURL !== undefined && sesURL !== '') {
await sendConfirmation(productId, newAccount)
await sendConfirmation(productId, branding, newAccount)
} else {
ctx.info('Please provide email service url to enable email confirmations.')
await confirmEmail(db, email)
@ -648,6 +665,7 @@ export async function createAccount (
ctx: MeasureContext,
db: Db,
productId: string,
branding: Branding | null,
_email: string,
password: string,
first: string,
@ -659,6 +677,7 @@ export async function createAccount (
ctx,
db,
productId,
branding,
email,
password,
first,
@ -681,6 +700,7 @@ export async function listWorkspaces (
ctx: MeasureContext,
db: Db,
productId: string,
branding: Branding | null,
token: string
): Promise<WorkspaceInfo[]> {
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<Version>, txes: Tx[], migrationOperation: [string, MigrateOperation][]) =>
async (ctx: MeasureContext, db: Db, productId: string, token: string, workspaceName: string): Promise<LoginInfo> => {
async (
ctx: MeasureContext,
db: Db,
productId: string,
branding: Branding | null,
token: string,
workspaceName: string
): Promise<LoginInfo> => {
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<ClientWorkspaceInfo[]> {
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<ClientWorkspaceInfo> {
@ -1337,10 +1370,11 @@ export async function createMissingEmployee (
ctx: MeasureContext,
db: Db,
productId: string,
branding: Branding | null,
token: string
): Promise<void> {
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<PersonAccount>
): Promise<Workspace> {
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<void> {
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<void> {
export async function requestPassword (
ctx: MeasureContext,
db: Db,
productId: string,
branding: Branding | null,
_email: string
): Promise<void> {
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<LoginInfo> {
@ -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<void> {
@ -1699,6 +1742,7 @@ export async function removeWorkspace (
ctx: MeasureContext,
db: Db,
productId: string,
branding: Branding | null,
email: string,
workspaceId: string
): Promise<void> {
@ -1719,6 +1763,7 @@ export async function checkJoin (
ctx: MeasureContext,
db: Db,
productId: string,
branding: Branding | null,
token: string,
inviteId: ObjectId
): Promise<WorkspaceLoginInfo> {
@ -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<Workspace> {
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<void> {
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<void> {
export async function dropAccount (
ctx: MeasureContext,
db: Db,
productId: string,
branding: Branding | null,
email: string
): Promise<void> {
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<void> {
@ -1851,6 +1905,7 @@ export async function sendInvite (
ctx: MeasureContext,
db: Db,
productId: string,
branding: Branding | null,
token: string,
email: string,
personId?: Ref<Person>,
@ -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<string, Branding>
/**
* @public
*/
@ -1942,16 +2006,30 @@ export type AccountMethod = (
ctx: MeasureContext,
db: Db,
productId: string,
branding: Branding | null,
request: any,
token?: string
) => Promise<any>
function wrap (
accountMethod: (ctx: MeasureContext, db: Db, productId: string, ...args: any[]) => Promise<any>
accountMethod: (
ctx: MeasureContext,
db: Db,
productId: string,
branding: Branding | null,
...args: any[]
) => Promise<any>
): AccountMethod {
return async function (ctx: MeasureContext, db: Db, productId: string, request: any, token?: string): Promise<any> {
return async function (
ctx: MeasureContext,
db: Db,
productId: string,
branding: Branding | null,
request: any,
token?: string
): Promise<any> {
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(),

View File

@ -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<string, string | undefined>
@ -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)

View File

@ -22,8 +22,6 @@ import serverToken from '@hcengineering/server-token'
import { start } from '.'
export function startFront (ctx: MeasureContext, extraConfig?: Record<string, string | undefined>): 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<string, st
process.exit(1)
}
const lastNameFirst = process.env.LAST_NAME_FIRST
const title = process.env.TITLE
const brandingUrl = process.env.BRANDING_URL
setMetadata(serverToken.metadata.Secret, serverSecret)
@ -121,15 +117,12 @@ export function startFront (ctx: MeasureContext, extraConfig?: Record<string, st
uploadUrl,
modelVersion,
gmailUrl,
lastNameFirst,
telegramUrl,
rekoniUrl,
calendarUrl,
collaboratorUrl,
collaboratorApiUrl,
title,
languages,
defaultLanguage
brandingUrl
}
console.log('Starting Front service with', config)
const shutdown = start(ctx, config, SERVER_PORT, extraConfig)

31
tests/branding-test.json Normal file
View File

@ -0,0 +1,31 @@
{
"localhost:8083": {
"title": "Platform",
"languages": "en,ru,pt,es",
"defaultLanguage": "en",
"defaultApplication": "tracker",
"defaultSpace": "tracker:project:DefaultProject",
"defaultSpecial": "issues",
"lastNameFirst": "true",
"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"
}
]
}
}

View File

@ -59,6 +59,8 @@ services:
- transactor
ports:
- 8083:8083
volumes:
- ./branding-test.json:/app/dist/branding-test.json
environment:
- SERVER_PORT=8083
- SERVER_SECRET=secret
@ -74,7 +76,7 @@ services:
- COLLABORATOR_URL=ws://localhost:3079
- COLLABORATOR_API_URL=http://localhost:3079
- STORAGE_CONFIG=${STORAGE_CONFIG}
- LAST_NAME_FIRST=true
- BRANDING_URL=/branding-test.json
transactor:
image: hardcoreeng/transactor
pull_policy: never

View File

@ -13,7 +13,7 @@ export class LeftSideMenuPage extends CommonPage {
buttonContacts = (): Locator => 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")')