diff --git a/.vscode/launch.json b/.vscode/launch.json index 4be0626890..8ec96f9991 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -182,8 +182,8 @@ "args": ["src/__start.ts"], "env": { "MONGO_URL": "mongodb://localhost:27017", - "DB_URL": "mongodb://localhost:27017", - // "DB_URL": "postgresql://root@huly.local:26257/defaultdb?sslmode=disable", + // "DB_URL": "mongodb://localhost:27017", + "DB_URL": "postgresql://root@huly.local:26257/defaultdb?sslmode=disable", "SERVER_SECRET": "secret", "REGION_INFO": "|Mongo;cockroach|CockroachDB", "TRANSACTOR_URL": "ws://huly.local:3333,ws://huly.local:3332;;cockroach", diff --git a/packages/account-client/src/client.ts b/packages/account-client/src/client.ts index 0bb875e953..fd37f48294 100644 --- a/packages/account-client/src/client.ts +++ b/packages/account-client/src/client.ts @@ -74,7 +74,7 @@ export interface AccountClient { join: (email: string, password: string, inviteId: string) => Promise createInvite: (exp: number, emailMask: string, limit: number, role: AccountRole) => Promise checkJoin: (inviteId: string) => Promise - checkAutoJoin: (inviteId: string, firstName: string, lastName?: string) => Promise + checkAutoJoin: (inviteId: string, firstName?: string, lastName?: string) => Promise getWorkspaceInfo: (updateLastVisit?: boolean) => Promise getWorkspacesInfo: (workspaces: WorkspaceUuid[]) => Promise getRegionInfo: () => Promise @@ -377,7 +377,7 @@ class AccountClientImpl implements AccountClient { return await this.rpc(request) } - async checkAutoJoin (inviteId: string, firstName: string, lastName?: string): Promise { + async checkAutoJoin (inviteId: string, firstName?: string, lastName?: string): Promise { const request = { method: 'checkAutoJoin' as const, params: { inviteId, firstName, lastName } diff --git a/packages/ui/src/components/StylishEdit.svelte b/packages/ui/src/components/StylishEdit.svelte index aa9e77410b..062b6a4fe4 100644 --- a/packages/ui/src/components/StylishEdit.svelte +++ b/packages/ui/src/components/StylishEdit.svelte @@ -84,6 +84,7 @@ background-color: var(--theme-button-focused); border-color: var(--theme-list-divider-color); } + input { height: 3.25rem; margin: 0; @@ -91,7 +92,12 @@ background-color: transparent; border: none; border-radius: 0.75rem; + + &:disabled { + background-color: var(--theme-button-pressed); + } } + .nolabel { padding-top: 0; } diff --git a/plugins/login-assets/lang/cs.json b/plugins/login-assets/lang/cs.json index 65afc5160b..96427f3a64 100644 --- a/plugins/login-assets/lang/cs.json +++ b/plugins/login-assets/lang/cs.json @@ -66,6 +66,10 @@ "PasswordMinUpperChars": "{count, plural, =1 {Heslo musí obsahovat alespoň # velké písmeno} other {Heslo musí obsahovat alespoň # velkých písmen}}", "PasswordMinLowerChars": "{count, plural, =1 {Heslo musí obsahovat alespoň # malé písmeno} other {Heslo musí obsahovat alespoň # malých písmen}}", "WorkspaceIsArchived": "Pracovní prostor je archivován kvůli nečinnosti.", - "RestoreArchivedWorkspace": "Obnovit pracovní prostor." + "RestoreArchivedWorkspace": "Obnovit pracovní prostor.", + "Hello": "Ahoj {name},", + "ProcessingInvite": "Zpracovávám pozvánku, čekejte prosím...", + "SignToProceed": "Přihlaste se, abyste mohli pokračovat", + "Proceed": "Pokračovat" } } diff --git a/plugins/login-assets/lang/de.json b/plugins/login-assets/lang/de.json index 674dcf80f5..f3df7e6097 100644 --- a/plugins/login-assets/lang/de.json +++ b/plugins/login-assets/lang/de.json @@ -66,6 +66,10 @@ "PasswordMinUpperChars": "{count, plural, =1 {Das Passwort muss mindestens # Großbuchstaben enthalten} other {Das Passwort muss mindestens # Großbuchstaben enthalten}}", "PasswordMinLowerChars": "{count, plural, =1 {Das Passwort muss mindestens # Kleinbuchstaben enthalten} other {Das Passwort muss mindestens # Kleinbuchstaben enthalten}}", "WorkspaceArchivedDesc": "Workspace wurde wegen Inaktivität archiviert.", - "RestoreArchivedWorkspace": "Workspace wiederherstellen." + "RestoreArchivedWorkspace": "Workspace wiederherstellen.", + "Hello": "Hallo {name},", + "ProcessingInvite": "Einladung wird bearbeitet, bitte warten...", + "SignToProceed": "Bitte melden Sie sich an, um fortzufahren", + "Proceed": "Fortfahren" } } diff --git a/plugins/login-assets/lang/en.json b/plugins/login-assets/lang/en.json index 5cc122a20b..6ce17ee1ba 100644 --- a/plugins/login-assets/lang/en.json +++ b/plugins/login-assets/lang/en.json @@ -65,6 +65,10 @@ "PasswordMinUpperChars": "{count, plural, =1 {Password must contain at least # uppercase letter} other {Password must contain at least # uppercase letters}}", "PasswordMinLowerChars": "{count, plural, =1 {Password must contain at least # lowercase letter} other {Password must contain at least # lowercase letters}}", "WorkspaceArchivedDesc": "Workspace is archived because of being unused.", - "RestoreArchivedWorkspace": "Unarchive" + "RestoreArchivedWorkspace": "Unarchive", + "Hello": "Hello {name},", + "ProcessingInvite": "Processing invite, please wait...", + "SignToProceed": "Please sign in to proceed", + "Proceed": "Proceed" } } diff --git a/plugins/login-assets/lang/es.json b/plugins/login-assets/lang/es.json index b14a2158f3..0375254ad9 100644 --- a/plugins/login-assets/lang/es.json +++ b/plugins/login-assets/lang/es.json @@ -65,6 +65,10 @@ "PasswordMinUpperChars": "{count, plural, =1 {La contraseña debe contener al menos # letra mayúscula} other {La contraseña debe contener al menos # letras mayúsculas}}", "PasswordMinLowerChars": "{count, plural, =1 {La contraseña debe contener al menos # letra minúscula} other {La contraseña debe contener al menos # letras minúsculas}}", "WorkspaceArchivedDesc": "El espacio de trabajo está archivado por no estar en uso.", - "RestoreArchivedWorkspace": "Restaurar" + "RestoreArchivedWorkspace": "Restaurar", + "Hello": "Hola {name},", + "ProcessingInvite": "Procesando invitación, por favor espere...", + "SignToProceed": "Por favor inicie sesión para continuar", + "Proceed": "Continuar" } } diff --git a/plugins/login-assets/lang/fr.json b/plugins/login-assets/lang/fr.json index 800ed464c2..4bf0743e2a 100644 --- a/plugins/login-assets/lang/fr.json +++ b/plugins/login-assets/lang/fr.json @@ -65,6 +65,10 @@ "PasswordMinUpperChars": "{count, plural, =1 {Le mot de passe doit contenir au moins # lettre majuscule} other {Le mot de passe doit contenir au moins # lettres majuscules}}", "PasswordMinLowerChars": "{count, plural, =1 {Le mot de passe doit contenir au moins # lettre minuscule} other {Le mot de passe doit contenir au moins # lettres minuscules}}", "WorkspaceArchivedDesc": "L'espace de travail est archivé en raison de son inactivité.", - "RestoreArchivedWorkspace": "Restaurer" + "RestoreArchivedWorkspace": "Restaurer", + "Hello": "Bonjour {name},", + "ProcessingInvite": "Invitation en cours, veuillez patienter...", + "SignToProceed": "Veuillez vous connecter pour continuer", + "Proceed": "Continuer" } } diff --git a/plugins/login-assets/lang/it.json b/plugins/login-assets/lang/it.json index 58457f1d24..7cde3be55a 100644 --- a/plugins/login-assets/lang/it.json +++ b/plugins/login-assets/lang/it.json @@ -65,6 +65,10 @@ "PasswordMinUpperChars": "{count, plural, =1 {La password deve contenere almeno # lettera maiuscola} other {La password deve contenere almeno # lettere maiuscole}}", "PasswordMinLowerChars": "{count, plural, =1 {La password deve contenere almeno # lettera minuscola} other {La password deve contenere almeno # lettere minuscole}}", "WorkspaceArchivedDesc": "Il workspace è stato archiviato perché inutilizzato.", - "RestoreArchivedWorkspace": "Un archiviato workspace" + "RestoreArchivedWorkspace": "Un archiviato workspace", + "Hello": "Ciao {name},", + "ProcessingInvite": "Elaborazione invito, attendere prego...", + "SignToProceed": "Accedi per procedere", + "Proceed": "Procedere" } } diff --git a/plugins/login-assets/lang/pt.json b/plugins/login-assets/lang/pt.json index 78b5d9db07..63d7f966ca 100644 --- a/plugins/login-assets/lang/pt.json +++ b/plugins/login-assets/lang/pt.json @@ -65,6 +65,10 @@ "PasswordMinUpperChars": "{count, plural, =1 {A senha deve conter pelo menos # letra maiúscula} other {A senha deve conter pelo menos # letras maiúsculas}}", "PasswordMinLowerChars": "{count, plural, =1 {A senha deve conter pelo menos # letra minúscula} other {A senha deve conter pelo menos # letras minúsculas}}", "WorkspaceArchivedDesc": "O espaço de trabalho está arquivado por estar inativo.", - "RestoreArchivedWorkspace": "Restaurar" + "RestoreArchivedWorkspace": "Restaurar", + "Hello": "Olá {name},", + "ProcessingInvite": "Processando convite, aguarde...", + "SignToProceed": "Por favor, inicie sessão para continuar", + "Proceed": "Continuar" } } diff --git a/plugins/login-assets/lang/ru.json b/plugins/login-assets/lang/ru.json index ab71181c31..2b36339512 100644 --- a/plugins/login-assets/lang/ru.json +++ b/plugins/login-assets/lang/ru.json @@ -65,6 +65,10 @@ "PasswordMinUpperChars": "{count, plural, =1 {Пароль должен содержать минимум # заглавную букву} other {Пароль должен содержать минимум # заглавных букв}}", "PasswordMinLowerChars": "{count, plural, =1 {Пароль должен содержать минимум # строчную букву} other {Пароль должен содержать минимум # строчных букв}}", "WorkspaceArchivedDesc": "Рабочее пространство архивировано из-за неиспользования.", - "RestoreArchivedWorkspace": "Восстановить" + "RestoreArchivedWorkspace": "Восстановить", + "Hello": "Привет {name},", + "ProcessingInvite": "Обработка приглашения, пожалуйста, подождите...", + "SignToProceed": "Пожалуйста, войдите, чтобы продолжить", + "Proceed": "Продолжить" } } diff --git a/plugins/login-assets/lang/zh.json b/plugins/login-assets/lang/zh.json index 4e927e9df5..14f4434005 100644 --- a/plugins/login-assets/lang/zh.json +++ b/plugins/login-assets/lang/zh.json @@ -65,6 +65,10 @@ "PasswordMinUpperChars": "{count, plural, =1 {密码至少需要 # 个大写字母} other {密码至少需要 # 个大写字母}}", "PasswordMinLowerChars": "{count, plural, =1 {密码至少需要 # 个小写字母} other {密码至少需要 # 个小写字母}}", "WorkspaceArchivedDesc": "工作区已被归档,因为未使用。", - "RestoreArchivedWorkspace": "解包" + "RestoreArchivedWorkspace": "解包", + "Hello": "你好 {name},", + "ProcessingInvite": "处理邀请,请稍候...", + "SignToProceed": "请登录以继续", + "Proceed": "继续" } } diff --git a/plugins/login-resources/src/components/Auth.svelte b/plugins/login-resources/src/components/Auth.svelte index 25d589a004..8f23703c97 100644 --- a/plugins/login-resources/src/components/Auth.svelte +++ b/plugins/login-resources/src/components/Auth.svelte @@ -3,9 +3,22 @@ import { logIn } from '@hcengineering/workbench' import { onMount } from 'svelte' - import { afterConfirm, getLoginInfoFromQuery, goTo, isWorkspaceLoginInfo, navigateToWorkspace } from '../utils' + import { + afterConfirm, + getLoginInfoFromQuery, + getAutoJoinInfo, + goTo, + isWorkspaceLoginInfo, + navigateToWorkspace + } from '../utils' onMount(async () => { + const autoJoinInfo = getAutoJoinInfo() + if (autoJoinInfo != null) { + goTo('autoJoin') + return + } + const result = await getLoginInfoFromQuery() if (result != null) { diff --git a/plugins/login-resources/src/components/AutoJoin.svelte b/plugins/login-resources/src/components/AutoJoin.svelte index 1b554f7805..baaa8a2dbc 100644 --- a/plugins/login-resources/src/components/AutoJoin.svelte +++ b/plugins/login-resources/src/components/AutoJoin.svelte @@ -15,94 +15,104 @@ -
+{#if loading} +
+
+ +
+{:else} + +{/if} + + diff --git a/plugins/login-resources/src/components/LoginApp.svelte b/plugins/login-resources/src/components/LoginApp.svelte index e0ca0ef4a3..3d65785bca 100644 --- a/plugins/login-resources/src/components/LoginApp.svelte +++ b/plugins/login-resources/src/components/LoginApp.svelte @@ -89,6 +89,9 @@ if (page === 'auth') { // token handled by auth page return + } else if (page === 'autoJoin') { + // there's a separate workflow for auto join + return } if (getMetadata(presentation.metadata.Token) == null) { diff --git a/plugins/login-resources/src/components/LoginForm.svelte b/plugins/login-resources/src/components/LoginForm.svelte index ced1085f38..07a57b78eb 100644 --- a/plugins/login-resources/src/components/LoginForm.svelte +++ b/plugins/login-resources/src/components/LoginForm.svelte @@ -14,14 +14,21 @@ // limitations under the License. --> {#if method === LoginMethods.Otp} - +
{:else} - +
diff --git a/plugins/login-resources/src/components/LoginOtpForm.svelte b/plugins/login-resources/src/components/LoginOtpForm.svelte index a38e8cdfd6..48319683f1 100644 --- a/plugins/login-resources/src/components/LoginOtpForm.svelte +++ b/plugins/login-resources/src/components/LoginOtpForm.svelte @@ -13,7 +13,8 @@ // limitations under the License. --> void | Promise) | undefined = undefined const dispatch = createEventDispatcher() @@ -66,13 +69,17 @@ const [loginStatus, result] = await validateOtpLogin(email, otp) status = loginStatus - await doLoginNavigate( - result, - (st) => { - status = st - }, - navigateUrl - ) + if (onLogin !== undefined) { + void onLogin(result, status) + } else { + await doLoginNavigate( + result, + (st) => { + status = st + }, + navigateUrl + ) + } } function onInput (e: Event): void { @@ -276,7 +283,9 @@ - + {#if canChangeEmail} + + {/if} {#if canResend} {/if} diff --git a/plugins/login-resources/src/components/Providers.svelte b/plugins/login-resources/src/components/Providers.svelte index 4753592a75..4629cb48e9 100644 --- a/plugins/login-resources/src/components/Providers.svelte +++ b/plugins/login-resources/src/components/Providers.svelte @@ -45,11 +45,20 @@ function getLink (provider: Provider): string { const inviteId = location.query?.inviteId + const autoJoin = location.query?.autoJoin !== undefined + const navigateUrl = location.query?.navigateUrl const accountsUrl = getMetadata(login.metadata.AccountsUrl) ?? '' let path = `/auth/${provider.name}` if (inviteId != null) { path += `?inviteId=${inviteId}` + if (autoJoin) { + path += '&autoJoin' + } + if (navigateUrl != null) { + path += `&navigateUrl=${navigateUrl}` + } } + return concatLink(accountsUrl, path) } diff --git a/plugins/login-resources/src/components/SelectWorkspace.svelte b/plugins/login-resources/src/components/SelectWorkspace.svelte index ffec72c8af..6eb99da7c0 100644 --- a/plugins/login-resources/src/components/SelectWorkspace.svelte +++ b/plugins/login-resources/src/components/SelectWorkspace.svelte @@ -135,7 +135,6 @@ let search: string = '' -
diff --git a/plugins/login-resources/src/plugin.ts b/plugins/login-resources/src/plugin.ts index fe80a46861..b8434d5ec3 100644 --- a/plugins/login-resources/src/plugin.ts +++ b/plugins/login-resources/src/plugin.ts @@ -69,6 +69,10 @@ export default mergeIds(loginId, login, { LoginWithCode: '' as IntlString, LoginWithPassword: '' as IntlString, SignUpWithCode: '' as IntlString, - SignUpWithPassword: '' as IntlString + SignUpWithPassword: '' as IntlString, + Hello: '' as IntlString, + ProcessingInvite: '' as IntlString, + SignToProceed: '' as IntlString, + Proceed: '' as IntlString } }) diff --git a/plugins/login-resources/src/utils.ts b/plugins/login-resources/src/utils.ts index bc566e22fb..1375ba179c 100644 --- a/plugins/login-resources/src/utils.ts +++ b/plugins/login-resources/src/utils.ts @@ -451,7 +451,7 @@ export function setLoginInfo (loginInfo: WorkspaceLoginInfo): void { export function navigateToWorkspace ( workspaceUrl: string, loginInfo: WorkspaceLoginInfo | null, - navigateUrl?: string, + navigateUrl?: string | null, replace = false ): void { if (loginInfo == null) { @@ -460,7 +460,7 @@ export function navigateToWorkspace ( setLoginInfo(loginInfo) - if (navigateUrl !== undefined) { + if (navigateUrl != null) { try { const loc = JSON.parse(decodeURIComponent(navigateUrl)) as Location if (loc.path[1] === workspaceUrl) { @@ -512,7 +512,7 @@ export async function checkJoined (inviteId: string): Promise<[Status, Workspace export async function checkAutoJoin ( inviteId: string, - firstName: string, + firstName?: string, lastName?: string ): Promise<[Status, WorkspaceInviteInfo | WorkspaceLoginInfo | null]> { const token = getMetadata(presentation.metadata.Token) @@ -789,7 +789,7 @@ export async function afterConfirm (clearQuery = false): Promise { setLoginInfo(result) - navigateToWorkspace(joinedWS[0].uuid, result, undefined, clearQuery) + navigateToWorkspace(joinedWS[0].url, result, undefined, clearQuery) } } else { goTo('selectWorkspace', clearQuery) @@ -812,6 +812,22 @@ export async function getLoginInfo (): Promise { const token = getCurrentLocation().query?.token diff --git a/pods/authProviders/src/github.ts b/pods/authProviders/src/github.ts index 18181debbd..8fdf6c45fa 100644 --- a/pods/authProviders/src/github.ts +++ b/pods/authProviders/src/github.ts @@ -1,10 +1,9 @@ -import { type AccountDB, LoginInfo, joinWithProvider, loginOrSignUpWithProvider } from '@hcengineering/account' +import { type AccountDB } from '@hcengineering/account' import { BrandingMap, concatLink, MeasureContext, getBranding, SocialIdType } from '@hcengineering/core' import Router from 'koa-router' import { Strategy as GitHubStrategy } from 'passport-github2' -import qs from 'querystringify' import { Passport } from '.' -import { getHost, safeParseAuthState } from './utils' +import { encodeState, handleProviderAuth, safeParseAuthState } from './utils' export function registerGithub ( measureCtx: MeasureContext, @@ -37,14 +36,7 @@ export function registerGithub ( router.get('/auth/github', async (ctx, next) => { measureCtx.info('try auth via', { provider: 'github' }) - const host = getHost(ctx.request.headers) - const branding = host !== undefined ? brandings[host]?.key ?? undefined : undefined - const state = encodeURIComponent( - JSON.stringify({ - inviteId: ctx.query?.inviteId, - branding - }) - ) + const state = encodeState(ctx, brandings) passport.authenticate('github', { scope: ['user:email'], session: true, state })(ctx, next) }) @@ -61,60 +53,29 @@ export function registerGithub ( })(ctx, next) }, async (ctx, next) => { - try { - const email = ctx.state.user.emails?.[0]?.value - const [first, last] = ctx.state.user.displayName?.split(' ') ?? [ctx.state.user.username, ''] + const email = ctx.state.user.emails?.[0]?.value + const [first, last] = ctx.state.user.displayName?.split(' ') ?? [ctx.state.user.username, ''] + const db = await dbPromise - measureCtx.info('Provider auth handler', { email, type: 'github' }) - let loginInfo: LoginInfo | null - const state = safeParseAuthState(ctx.query?.state) - const branding = getBranding(brandings, state?.branding) - const db = await dbPromise - const socialKey = { type: SocialIdType.GITHUB, value: ctx.state.user.username } + const redirectUrl = await handleProviderAuth( + measureCtx, + db, + brandings, + frontUrl, + 'github', + ctx.query?.state, + ctx.state?.user, + email, + first, + last, + { type: SocialIdType.GITHUB, value: ctx.state.user.username }, + signUpDisabled + ) - if (state.inviteId != null && state.inviteId !== '') { - loginInfo = await joinWithProvider( - measureCtx, - db, - null, - email, - first, - last, - state.inviteId as any, - socialKey, - signUpDisabled - ) - } else { - loginInfo = await loginOrSignUpWithProvider( - measureCtx, - db, - null, - email, - first, - last, - socialKey, - signUpDisabled - ) - } - - if (loginInfo === null) { - measureCtx.info('Failed to auth: no associated account found', { - email, - type: 'github', - user: ctx.state?.user - }) - ctx.redirect(concatLink(branding?.front ?? frontUrl, '/login')) - } else { - const origin = concatLink(branding?.front ?? frontUrl, '/login/auth') - const query = encodeURIComponent(qs.stringify({ token: loginInfo.token })) - - measureCtx.info('Success auth, redirect', { email, type: 'github', target: origin }) - // Successful authentication, redirect to your application - ctx.redirect(`${origin}?${query}`) - } - } catch (err: any) { - measureCtx.error('failed to auth', { err, type: 'github', user: ctx.state?.user }) + if (redirectUrl !== '') { + ctx.redirect(redirectUrl) } + await next() } ) diff --git a/pods/authProviders/src/google.ts b/pods/authProviders/src/google.ts index b403c98f1c..32575c57b4 100644 --- a/pods/authProviders/src/google.ts +++ b/pods/authProviders/src/google.ts @@ -1,10 +1,9 @@ -import { type AccountDB, LoginInfo, joinWithProvider, loginOrSignUpWithProvider } from '@hcengineering/account' +import { type AccountDB } from '@hcengineering/account' import { BrandingMap, concatLink, MeasureContext, getBranding, SocialIdType } from '@hcengineering/core' import Router from 'koa-router' import { Strategy as GoogleStrategy } from 'passport-google-oauth20' -import qs from 'querystringify' import { Passport } from '.' -import { getHost, safeParseAuthState } from './utils' +import { encodeState, handleProviderAuth, safeParseAuthState } from './utils' export function registerGoogle ( measureCtx: MeasureContext, @@ -37,14 +36,7 @@ export function registerGoogle ( router.get('/auth/google', async (ctx, next) => { measureCtx.info('try auth via', { provider: 'google' }) - const host = getHost(ctx.request.headers) - const branding = host !== undefined ? brandings[host]?.key ?? undefined : undefined - const state = encodeURIComponent( - JSON.stringify({ - inviteId: ctx.query?.inviteId, - branding - }) - ) + const state = encodeState(ctx, brandings) passport.authenticate('google', { scope: ['profile', 'email'], session: true, state })(ctx, next) }) @@ -68,57 +60,25 @@ export function registerGoogle ( const email = ctx.state.user.emails?.[0]?.value const first = ctx.state.user.name.givenName const last = ctx.state.user.name.familyName - measureCtx.info('Provider auth handler', { email, type: 'google' }) + const db = await dbPromise - try { - let loginInfo: LoginInfo | null - const state = safeParseAuthState(ctx.query?.state) - const branding = getBranding(brandings, state?.branding) - const db = await dbPromise - const socialKey = { type: SocialIdType.GOOGLE, value: email } + const redirectUrl = await handleProviderAuth( + measureCtx, + db, + brandings, + frontUrl, + 'google', + ctx.query?.state, + ctx.state?.user, + email, + first, + last, + { type: SocialIdType.GOOGLE, value: email }, + signUpDisabled + ) - if (state.inviteId != null && state.inviteId !== '') { - loginInfo = await joinWithProvider( - measureCtx, - db, - null, - email, - first, - last, - state.inviteId as any, - socialKey, - signUpDisabled - ) - } else { - loginInfo = await loginOrSignUpWithProvider( - measureCtx, - db, - null, - email, - first, - last, - socialKey, - signUpDisabled - ) - } - - if (loginInfo === null) { - measureCtx.info('Failed to auth: no associated account found', { - email, - type: 'google', - user: ctx.state?.user - }) - ctx.redirect(concatLink(branding?.front ?? frontUrl, '/login')) - } else { - const origin = concatLink(branding?.front ?? frontUrl, '/login/auth') - const query = encodeURIComponent(qs.stringify({ token: loginInfo.token })) - - // Successful authentication, redirect to your application - measureCtx.info('Success auth, redirect', { email, type: 'google', target: origin }) - ctx.redirect(`${origin}?${query}`) - } - } catch (err: any) { - measureCtx.error('failed to auth', { err, type: 'google', user: ctx.state?.user }) + if (redirectUrl !== '') { + ctx.redirect(redirectUrl) } await next() diff --git a/pods/authProviders/src/openid.ts b/pods/authProviders/src/openid.ts index 4452a9424a..43a0282dd3 100644 --- a/pods/authProviders/src/openid.ts +++ b/pods/authProviders/src/openid.ts @@ -12,14 +12,13 @@ // See the License for the specific language governing permissions and // limitations under the License. // -import { type AccountDB, LoginInfo, joinWithProvider, loginOrSignUpWithProvider } from '@hcengineering/account' +import { type AccountDB } from '@hcengineering/account' import { BrandingMap, concatLink, MeasureContext, getBranding, SocialIdType } from '@hcengineering/core' import Router from 'koa-router' import { Issuer, Strategy } from 'openid-client' -import qs from 'querystringify' import { Passport } from '.' -import { getHost, safeParseAuthState } from './utils' +import { encodeState, handleProviderAuth, safeParseAuthState } from './utils' export function registerOpenid ( measureCtx: MeasureContext, @@ -64,14 +63,7 @@ export function registerOpenid ( 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 - }) - ) + const state = encodeState(ctx, brandings) await passport.authenticate('oidc', { scope: 'openid profile email', @@ -90,62 +82,30 @@ export function registerOpenid ( })(ctx, next) }, async (ctx, next) => { - try { - const email = ctx.state.user.email - const verifiedEmail = (ctx.state.user.email_verified as boolean) ? email : '' - const [first, last] = ctx.state.user.name?.split(' ') ?? [ctx.state.user.username, ''] - measureCtx.info('Provider auth handler', { email, verifiedEmail, type: 'openid' }) + const email = ctx.state.user.email + const verifiedEmail = (ctx.state.user.email_verified as boolean) ? email : '' + const [first, last] = ctx.state.user.name?.split(' ') ?? [ctx.state.user.username, ''] - let loginInfo: LoginInfo | null - const state = safeParseAuthState(ctx.query?.state) - const branding = getBranding(brandings, state?.branding) - const db = await dbPromise - const socialKey = { type: SocialIdType.OIDC, value: ctx.state.user.sub } + const db = await dbPromise + const redirectUrl = await handleProviderAuth( + measureCtx, + db, + brandings, + frontUrl, + 'openid', + ctx.query?.state, + ctx.state?.user, + verifiedEmail, + first, + last, + { type: SocialIdType.OIDC, value: ctx.state.user.sub }, + signUpDisabled + ) - if (state.inviteId != null && state.inviteId !== '') { - loginInfo = await joinWithProvider( - measureCtx, - db, - null, - verifiedEmail, - first, - last, - state.inviteId as any, - socialKey, - signUpDisabled - ) - } else { - loginInfo = await loginOrSignUpWithProvider( - measureCtx, - db, - null, - verifiedEmail, - first, - last, - socialKey, - signUpDisabled - ) - } - - if (loginInfo === null) { - measureCtx.info('Failed to auth: no associated account found', { - email, - verifiedEmail, - type: 'openid', - user: ctx.state?.user - }) - ctx.redirect(concatLink(branding?.front ?? frontUrl, '/login')) - } else { - 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 }) + if (redirectUrl !== '') { + ctx.redirect(redirectUrl) } + await next() } ) diff --git a/pods/authProviders/src/utils.ts b/pods/authProviders/src/utils.ts index 80734e0e07..e3e652ddc7 100644 --- a/pods/authProviders/src/utils.ts +++ b/pods/authProviders/src/utils.ts @@ -12,7 +12,10 @@ // See the License for the specific language governing permissions and // limitations under the License. // +import { AccountDB, joinWithProvider, LoginInfo, loginOrSignUpWithProvider } from '@hcengineering/account' +import { BrandingMap, concatLink, getBranding, MeasureContext, SocialKey } from '@hcengineering/core' import { IncomingHttpHeaders } from 'http' +import qs from 'querystringify' export function getHost (headers: IncomingHttpHeaders): string | undefined { let host: string | undefined @@ -27,6 +30,8 @@ export function getHost (headers: IncomingHttpHeaders): string | undefined { export interface AuthState { inviteId?: string branding?: string + autoJoin?: boolean + navigateUrl?: string } export function safeParseAuthState (rawState: string | undefined): AuthState { @@ -40,3 +45,89 @@ export function safeParseAuthState (rawState: string | undefined): AuthState { return {} } } + +export function encodeState (ctx: any, brandings: BrandingMap): string { + const host = getHost(ctx.request.headers) + const branding = host !== undefined ? brandings[host]?.key ?? undefined : undefined + const state: AuthState = { + inviteId: ctx.query?.inviteId, + branding, + autoJoin: ctx.query?.autoJoin !== undefined, + navigateUrl: ctx.query?.navigateUrl + } + + return encodeURIComponent(JSON.stringify(state)) +} + +export async function handleProviderAuth ( + measureCtx: MeasureContext, + db: AccountDB, + brandings: BrandingMap, + frontUrl: string, + providerType: string, + rawState: string | undefined, + user: any, + email: string, + first: string, + last: string, + socialKey: SocialKey, + signUpDisabled: boolean | undefined +): Promise { + try { + measureCtx.info('Provider auth handler', { email, type: providerType }) + let loginInfo: LoginInfo | null + const state = safeParseAuthState(rawState) + const branding = getBranding(brandings, state?.branding) + + if (state.inviteId != null && state.inviteId !== '' && state.autoJoin == null) { + loginInfo = await joinWithProvider( + measureCtx, + db, + null, + email, + first, + last, + state.inviteId as any, + socialKey, + signUpDisabled + ) + } else { + loginInfo = await loginOrSignUpWithProvider( + measureCtx, + db, + null, + email, + first, + last, + socialKey, + signUpDisabled === true || state.autoJoin != null + ) + } + + if (loginInfo === null) { + measureCtx.info('Failed to auth: no associated account found', { + email, + type: providerType, + user + }) + return concatLink(branding?.front ?? frontUrl, '/login') + } else { + const origin = concatLink(branding?.front ?? frontUrl, '/login/auth') + const queryObj: any = { token: loginInfo.token } + if (state.autoJoin != null) { + queryObj.autoJoin = state.autoJoin + queryObj.inviteId = state.inviteId + queryObj.navigateUrl = state.navigateUrl + } + + const query = encodeURIComponent(qs.stringify(queryObj)) + + // Successful authentication, redirect to your application + measureCtx.info('Success auth, redirect', { email, type: providerType, target: origin }) + return `${origin}?${query}` + } + } catch (err: any) { + measureCtx.error('failed to auth', { err, type: providerType, user }) + return '' + } +} diff --git a/server/account-service/src/migration/migration.ts b/server/account-service/src/migration/migration.ts index a470b22d0b..79390ca6d3 100644 --- a/server/account-service/src/migration/migration.ts +++ b/server/account-service/src/migration/migration.ts @@ -231,7 +231,7 @@ async function migrateAccount (account: OldAccount, accountDB: AccountDB): Promi ...verified }) - await createAccount(accountDB, personUuid, account.confirmed, account.createdOn) + await createAccount(accountDB, personUuid, account.confirmed, false, account.createdOn) if (account.hash != null && account.salt != null) { await accountDB.account.updateOne({ uuid: personUuid }, { hash: account.hash, salt: account.salt }) } diff --git a/server/account/src/__tests__/utils.test.ts b/server/account/src/__tests__/utils.test.ts index 3284d8e700..460e7f4a64 100644 --- a/server/account/src/__tests__/utils.test.ts +++ b/server/account/src/__tests__/utils.test.ts @@ -1302,7 +1302,7 @@ describe('account utils', () => { personUuid, verifiedOn: undefined }) - expect(mockDb.account.insertOne).toHaveBeenCalledWith({ uuid: personUuid }) + expect(mockDb.account.insertOne).toHaveBeenCalledWith({ uuid: personUuid, automatic: false }) expect(mockDb.setPassword).toHaveBeenCalledWith(personUuid, expect.any(Buffer), expect.any(Buffer)) }) @@ -1326,7 +1326,7 @@ describe('account utils', () => { expect(result.account).toBe(personUuid) expect(mockDb.person.updateOne).toHaveBeenCalledWith({ uuid: personUuid }, { firstName, lastName }) - expect(mockDb.account.insertOne).toHaveBeenCalledWith({ uuid: personUuid }) + expect(mockDb.account.insertOne).toHaveBeenCalledWith({ uuid: personUuid, automatic: false }) expect(mockDb.setPassword).toHaveBeenCalledWith(personUuid, expect.any(Buffer), expect.any(Buffer)) }) diff --git a/server/account/src/collections/postgres.ts b/server/account/src/collections/postgres.ts index 5863ac0d0b..ebbefb2b0f 100644 --- a/server/account/src/collections/postgres.ts +++ b/server/account/src/collections/postgres.ts @@ -846,11 +846,14 @@ export class PostgresAccountDB implements AccountDB { private getV3Migration (): [string, string] { return [ - 'account_db_v3_add_invite_auto_join', + 'account_db_v3_add_invite_auto_join_final', ` ALTER TABLE ${this.ns}.invite ADD COLUMN IF NOT EXISTS email STRING, ADD COLUMN IF NOT EXISTS auto_join BOOL DEFAULT FALSE; + + ALTER TABLE ${this.ns}.account + ADD COLUMN IF NOT EXISTS automatic BOOL; ` ] } diff --git a/server/account/src/operations.ts b/server/account/src/operations.ts index 67f54ef5ca..df51fe5e08 100644 --- a/server/account/src/operations.ts +++ b/server/account/src/operations.ts @@ -725,7 +725,7 @@ export async function checkAutoJoin ( db: AccountDB, branding: Branding | null, token: string, - params: { inviteId: string, firstName: string, lastName?: string } + params: { inviteId: string, firstName?: string, lastName?: string } ): Promise { const { inviteId, firstName, lastName } = params const invite = await getWorkspaceInvite(db, inviteId) @@ -768,8 +768,11 @@ export async function checkAutoJoin ( if (targetAccount != null) { if (token == null) { // Login required + const person = await db.person.findOne({ uuid: targetAccount.uuid }) + return { workspace: workspace.uuid, + name: person == null ? '' : getPersonName(person), email: normalizedEmail } } @@ -778,15 +781,20 @@ export async function checkAutoJoin ( if (callerAccount !== targetAccount.uuid) { // Login with target email required + const person = await db.person.findOne({ uuid: targetAccount.uuid }) + return { workspace: workspace.uuid, + name: person == null ? '' : getPersonName(person), email: normalizedEmail } } const targetRole = await getWorkspaceRole(db, targetAccount.uuid, workspace.uuid) - if (targetRole == null || getRolePower(targetRole) < getRolePower(invite.role)) { + if (targetRole == null) { + await db.assignWorkspace(targetAccount.uuid, workspace.uuid, invite.role) + } else if (getRolePower(targetRole) < getRolePower(invite.role)) { await db.updateWorkspaceRole(targetAccount.uuid, workspace.uuid, invite.role) } @@ -800,7 +808,17 @@ export async function checkAutoJoin ( throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {})) } - const { account } = await signUpByEmail(ctx, db, branding, normalizedEmail, null, firstName, lastName ?? '', true) + const { account } = await signUpByEmail( + ctx, + db, + branding, + normalizedEmail, + null, + firstName, + lastName ?? '', + true, + true + ) return await doJoinByInvite(ctx, db, branding, generateToken(account, workspaceUuid), account, workspace, invite) } diff --git a/server/account/src/types.ts b/server/account/src/types.ts index 86c864b90b..2823c8265d 100644 --- a/server/account/src/types.ts +++ b/server/account/src/types.ts @@ -50,6 +50,7 @@ export interface SocialId extends SocialIdBase { export interface Account { uuid: PersonUuid + automatic?: boolean timezone?: string locale?: string hash?: Buffer | null @@ -270,6 +271,7 @@ export interface RegionInfo { export interface WorkspaceInviteInfo { workspace: WorkspaceUuid email?: string + name?: string } export interface MailboxOptions { diff --git a/server/account/src/utils.ts b/server/account/src/utils.ts index 1a63241e51..5d80ed6b9e 100644 --- a/server/account/src/utils.ts +++ b/server/account/src/utils.ts @@ -435,6 +435,7 @@ export async function createAccount ( db: AccountDB, personUuid: PersonUuid, confirmed = false, + automatic = false, createdOn = Date.now() ): Promise { // Create Huly social id and account @@ -446,7 +447,7 @@ export async function createAccount ( personUuid, ...(confirmed ? { verifiedOn: Date.now() } : {}) }) - await db.account.insertOne({ uuid: personUuid }) + await db.account.insertOne({ uuid: personUuid, automatic }) await db.accountEvent.insertOne({ accountUuid: personUuid, eventType: AccountEventType.ACCOUNT_CREATED, @@ -462,7 +463,8 @@ export async function signUpByEmail ( password: string | null, firstName: string, lastName: string, - confirmed = false + confirmed = false, + automatic = false ): Promise<{ account: PersonUuid, socialId: PersonId }> { const normalizedEmail = cleanEmail(email) @@ -493,7 +495,7 @@ export async function signUpByEmail ( }) } - await createAccount(db, account, confirmed) + await createAccount(db, account, confirmed, automatic) if (password != null) { await setPassword(ctx, db, branding, account, password) }