UBERF-9636: Meeting links - more cases (#8369)
Some checks are pending
CI / test (push) Blocked by required conditions
CI / uitest (push) Waiting to run
CI / build (push) Waiting to run
CI / svelte-check (push) Blocked by required conditions
CI / formatting (push) Blocked by required conditions
CI / uitest-pg (push) Waiting to run
CI / uitest-qms (push) Waiting to run
CI / uitest-workspaces (push) Waiting to run
CI / docker-build (push) Blocked by required conditions
CI / dist-build (push) Blocked by required conditions

Signed-off-by: Alexey Zinoviev <alexey.zinoviev@xored.com>
This commit is contained in:
Alexey Zinoviev 2025-03-27 22:05:53 +04:00 committed by GitHub
parent 0bbf5773e5
commit c6326b3c73
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
33 changed files with 459 additions and 315 deletions

4
.vscode/launch.json vendored
View File

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

View File

@ -74,7 +74,7 @@ export interface AccountClient {
join: (email: string, password: string, inviteId: string) => Promise<WorkspaceLoginInfo>
createInvite: (exp: number, emailMask: string, limit: number, role: AccountRole) => Promise<string>
checkJoin: (inviteId: string) => Promise<WorkspaceLoginInfo>
checkAutoJoin: (inviteId: string, firstName: string, lastName?: string) => Promise<WorkspaceLoginInfo>
checkAutoJoin: (inviteId: string, firstName?: string, lastName?: string) => Promise<WorkspaceLoginInfo>
getWorkspaceInfo: (updateLastVisit?: boolean) => Promise<WorkspaceInfoWithStatus>
getWorkspacesInfo: (workspaces: WorkspaceUuid[]) => Promise<WorkspaceInfoWithStatus[]>
getRegionInfo: () => Promise<RegionInfo[]>
@ -377,7 +377,7 @@ class AccountClientImpl implements AccountClient {
return await this.rpc(request)
}
async checkAutoJoin (inviteId: string, firstName: string, lastName?: string): Promise<WorkspaceLoginInfo> {
async checkAutoJoin (inviteId: string, firstName?: string, lastName?: string): Promise<WorkspaceLoginInfo> {
const request = {
method: 'checkAutoJoin' as const,
params: { inviteId, firstName, lastName }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -65,6 +65,10 @@
"PasswordMinUpperChars": "{count, plural, =1 {Пароль должен содержать минимум # заглавную букву} other {Пароль должен содержать минимум # заглавных букв}}",
"PasswordMinLowerChars": "{count, plural, =1 {Пароль должен содержать минимум # строчную букву} other {Пароль должен содержать минимум # строчных букв}}",
"WorkspaceArchivedDesc": "Рабочее пространство архивировано из-за неиспользования.",
"RestoreArchivedWorkspace": "Восстановить"
"RestoreArchivedWorkspace": "Восстановить",
"Hello": "Привет {name},",
"ProcessingInvite": "Обработка приглашения, пожалуйста, подождите...",
"SignToProceed": "Пожалуйста, войдите, чтобы продолжить",
"Proceed": "Продолжить"
}
}

View File

@ -65,6 +65,10 @@
"PasswordMinUpperChars": "{count, plural, =1 {密码至少需要 # 个大写字母} other {密码至少需要 # 个大写字母}}",
"PasswordMinLowerChars": "{count, plural, =1 {密码至少需要 # 个小写字母} other {密码至少需要 # 个小写字母}}",
"WorkspaceArchivedDesc": "工作区已被归档,因为未使用。",
"RestoreArchivedWorkspace": "解包"
"RestoreArchivedWorkspace": "解包",
"Hello": "你好 {name},",
"ProcessingInvite": "处理邀请,请稍候...",
"SignToProceed": "请登录以继续",
"Proceed": "继续"
}
}

View File

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

View File

@ -15,94 +15,104 @@
<script lang="ts">
import { onMount } from 'svelte'
import { Analytics } from '@hcengineering/analytics'
import { workbenchId } from '@hcengineering/workbench'
import { OK, Severity, Status } from '@hcengineering/platform'
import { Location, getCurrentLocation, navigate } from '@hcengineering/ui'
import { logIn, workbenchId } from '@hcengineering/workbench'
import { setMetadata, translate } from '@hcengineering/platform'
import { Location, Loading, Label, getCurrentLocation, navigate } from '@hcengineering/ui'
import { type LoginInfo } from '@hcengineering/account-client'
import { loginId } from '@hcengineering/login'
import { themeStore } from '@hcengineering/theme'
import presentation from '@hcengineering/presentation'
import Form from './Form.svelte'
import { setLoginInfo, checkAutoJoin, isWorkspaceLoginInfo } from '../utils'
import { loginAction, recoveryAction } from '../actions'
import { setLoginInfo, checkAutoJoin, isWorkspaceLoginInfo, navigateToWorkspace } from '../utils'
import login from '../plugin'
import LoginForm from './LoginForm.svelte'
const location = getCurrentLocation()
Analytics.handleEvent('auto_join_invite_link_activated')
const fields = [
{ id: 'email', name: 'username', i18n: login.string.Email, disabled: true },
{
id: 'current-password',
name: 'password',
i18n: login.string.Password,
password: true
}
]
$: object = {
username: '',
password: ''
}
let status = OK
$: action = {
i18n: login.string.Join,
func: async () => {
status = new Status(Severity.INFO, login.status.ConnectingToServer, {})
await check()
}
}
let loading = true
let email: string | undefined = undefined
let name = ''
let subtitle = ''
onMount(() => {
void check()
})
async function check (): Promise<void> {
if (location.query?.inviteId == null || location.query?.firstName == null) return
status = new Status(Severity.INFO, login.status.ConnectingToServer, {})
$: void updateSubtitle(name, $themeStore.language)
const [, result] = await checkAutoJoin(
location.query.inviteId,
location.query.firstName,
location.query.lastName ?? ''
)
status = OK
if (result != null) {
if (isWorkspaceLoginInfo(result)) {
setLoginInfo(result)
if (location.query?.navigateUrl != null) {
try {
const loc = JSON.parse(decodeURIComponent(location.query.navigateUrl)) as Location
if (loc.path[1] === result.workspaceUrl) {
navigate(loc)
return
}
} catch (err: any) {
// Json parse error could be ignored
}
}
navigate({ path: [workbenchId, result.workspaceUrl] })
} else {
if (result.email == null) {
console.error('No email in auto join info')
navigate({ path: [loginId, 'login'] })
return
}
object.username = result.email
}
async function updateSubtitle (name: string, language: string): Promise<void> {
if (name != null && name !== '') {
subtitle = await translate(login.string.Hello, { name }, language)
}
}
async function check (): Promise<void> {
if (location.query?.inviteId == null || (location.query?.firstName == null && location.query?.token == null)) return
if (location.query?.token != null) {
setMetadata(presentation.metadata.Token, location.query.token)
delete location.query.token
navigate(location, true)
}
try {
const [, result] = await checkAutoJoin(
location.query.inviteId,
location.query.firstName ?? '',
location.query.lastName ?? ''
)
if (result != null) {
if (isWorkspaceLoginInfo(result)) {
await logIn(result)
navigateToWorkspace(result.workspaceUrl, result, location.query?.navigateUrl)
return
} else {
if (result.email == null) {
console.error('No email in auto join info')
navigate({ path: [loginId, 'login'] })
return
}
email = result.email
name = result.name
}
}
} catch (err: any) {
console.error('Failed to check auto join', err)
navigate({ path: [loginId, 'login'] })
return
}
if (loading) {
loading = false
}
}
async function onLogin (loginInfo: LoginInfo | null): Promise<void> {
if (loginInfo?.token == null) {
return
}
setMetadata(presentation.metadata.Token, loginInfo.token)
await check()
}
</script>
<Form
caption={login.string.Join}
{status}
{fields}
{object}
{action}
bottomActions={[loginAction, recoveryAction]}
withProviders
/>
{#if loading}
<div>
<div class="title"><Label label={login.string.ProcessingInvite} /></div>
<Loading />
</div>
{:else}
<LoginForm signUpDisabled {email} caption={login.string.SignToProceed} {subtitle} {onLogin} />
{/if}
<style lang="scss">
.title {
color: var(--theme-caption-color);
text-align: center;
}
</style>

View File

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

View File

@ -14,14 +14,21 @@
// limitations under the License.
-->
<script lang="ts">
import { type IntlString, type Status } from '@hcengineering/platform'
import { type BottomAction, LoginMethods } from '../index'
import LoginPasswordForm from './LoginPasswordForm.svelte'
import LoginOtpForm from './LoginOtpForm.svelte'
import BottomActionComponent from './BottomAction.svelte'
import login from '../plugin'
import { LoginInfo } from '@hcengineering/account-client'
export let navigateUrl: string | undefined = undefined
export let signUpDisabled = false
export let email: string | undefined = undefined
export let caption: IntlString | undefined = undefined
export let subtitle: string | undefined = undefined
export let onLogin: ((loginInfo: LoginInfo | null, status: Status) => void | Promise<void>) | undefined = undefined
let method: LoginMethods = LoginMethods.Otp
@ -45,12 +52,12 @@
</script>
{#if method === LoginMethods.Otp}
<LoginOtpForm {navigateUrl} {signUpDisabled} on:change={changeMethod} />
<LoginOtpForm {navigateUrl} {signUpDisabled} {email} {caption} {subtitle} {onLogin} on:change={changeMethod} />
<div class="action">
<BottomActionComponent action={loginWithPasswordAction} />
</div>
{:else}
<LoginPasswordForm {navigateUrl} {signUpDisabled} on:change={changeMethod} />
<LoginPasswordForm {navigateUrl} {signUpDisabled} {email} {caption} {subtitle} {onLogin} on:change={changeMethod} />
<div class="action">
<BottomActionComponent action={loginWithCodeAction} />
</div>

View File

@ -13,7 +13,8 @@
// limitations under the License.
-->
<script lang="ts">
import { OK, Severity, Status } from '@hcengineering/platform'
import { type IntlString, OK, Severity, Status } from '@hcengineering/platform'
import { LoginInfo } from '@hcengineering/account-client'
import OtpForm from './OtpForm.svelte'
import login from '../plugin'
@ -22,12 +23,22 @@
export let navigateUrl: string | undefined = undefined
export let signUpDisabled = false
export let email: string | undefined = undefined
export let caption: IntlString = login.string.LogIn
export let subtitle: string | undefined = undefined
export let onLogin: ((loginInfo: LoginInfo | null, status: Status) => void | Promise<void>) | undefined = undefined
const fields = [{ id: 'email', name: 'username', i18n: login.string.Email }]
$: fields = [
{ id: 'email', name: 'username', i18n: login.string.Email, disabled: email !== undefined && email !== '' }
]
const formData = {
username: '' as string
}
$: if (email !== undefined && email !== '' && formData.username === '') {
formData.username = email
}
let status = OK
let step = OtpLoginSteps.Email
let otpRetryOn = 0
@ -53,7 +64,8 @@
{#if step === OtpLoginSteps.Email}
<Form
caption={login.string.LogIn}
{caption}
{subtitle}
{status}
{fields}
object={formData}
@ -65,5 +77,13 @@
{/if}
{#if step === OtpLoginSteps.Otp && formData.username !== ''}
<OtpForm email={formData.username} {signUpDisabled} {navigateUrl} retryOn={otpRetryOn} on:step={handleStep} />
<OtpForm
email={formData.username}
{signUpDisabled}
{navigateUrl}
retryOn={otpRetryOn}
{onLogin}
canChangeEmail={email === undefined || email === ''}
on:step={handleStep}
/>
{/if}

View File

@ -13,7 +13,8 @@
// limitations under the License.
-->
<script lang="ts">
import { OK, Severity, Status } from '@hcengineering/platform'
import { type IntlString, OK, Severity, Status } from '@hcengineering/platform'
import { type LoginInfo } from '@hcengineering/account-client'
import { doLogin, doLoginNavigate } from '../utils'
import Form from './Form.svelte'
@ -22,9 +23,13 @@
export let navigateUrl: string | undefined = undefined
export let signUpDisabled = false
export let email: string | undefined = undefined
export let caption: IntlString = login.string.LogIn
export let subtitle: string | undefined = undefined
export let onLogin: ((loginInfo: LoginInfo | null, status: Status) => void | Promise<void>) | undefined = undefined
const fields = [
{ id: 'email', name: 'username', i18n: login.string.Email },
$: fields = [
{ id: 'email', name: 'username', i18n: login.string.Email, disabled: email !== undefined && email !== '' },
{
id: 'current-password',
name: 'password',
@ -38,6 +43,10 @@
password: ''
}
$: if (email !== undefined && email !== '' && object.username === '') {
object.username = email
}
let status = OK
const action = {
@ -46,19 +55,25 @@
status = new Status(Severity.INFO, login.status.ConnectingToServer, {})
const [loginStatus, result] = await doLogin(object.username, object.password)
status = loginStatus
await doLoginNavigate(
result,
(st) => {
status = st
},
navigateUrl
)
if (onLogin !== undefined) {
void onLogin(result, status)
} else {
await doLoginNavigate(
result,
(st) => {
status = st
},
navigateUrl
)
}
}
}
</script>
<Form
caption={login.string.LogIn}
{caption}
{subtitle}
{status}
{fields}
{object}

View File

@ -18,6 +18,7 @@
import { OK, Severity, Status } from '@hcengineering/platform'
import { createEventDispatcher, onDestroy } from 'svelte'
import { Timestamp } from '@hcengineering/core'
import { LoginInfo } from '@hcengineering/account-client'
import Tabs from './Tabs.svelte'
import { BottomAction, doLoginNavigate, validateOtpLogin, OtpLoginSteps, loginOtp } from '../index'
@ -30,6 +31,8 @@
export let retryOn: Timestamp
export let signUpDisabled = false
export let loginState: 'login' | 'signup' | 'none' = 'none'
export let canChangeEmail = true
export let onLogin: ((loginInfo: LoginInfo | null, status: Status) => void | Promise<void>) | 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 @@
</span>
</span>
<BottomActionComponent action={changeEmailAction} />
{#if canChangeEmail}
<BottomActionComponent action={changeEmailAction} />
{/if}
{#if canResend}
<BottomActionComponent action={resendCodeAction} />
{/if}

View File

@ -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)
}
</script>

View File

@ -135,7 +135,6 @@
let search: string = ''
</script>
<!-- TODO: show some social login instead of account.account -->
<form class="container" style:padding={$deviceInfo.docWidth <= 480 ? '1.25rem' : '5rem'}>
<div class="grow-separator" />
<div class="fs-title">

View File

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

View File

@ -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<void> {
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<LoginInfo | WorkspaceLoginInfo |
}
}
export function getAutoJoinInfo (): any {
const query = getCurrentLocation().query
if (query == null) {
return null
}
const { token, autoJoin, inviteId, navigateUrl } = query
if (token == null || autoJoin === undefined || inviteId == null) {
return null
}
return { token, autoJoin, inviteId, navigateUrl }
}
export async function getLoginInfoFromQuery (): Promise<LoginInfo | WorkspaceLoginInfo | null> {
const token = getCurrentLocation().query?.token

View File

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

View File

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

View File

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

View File

@ -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<string> {
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 ''
}
}

View File

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

View File

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

View File

@ -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;
`
]
}

View File

@ -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<WorkspaceLoginInfo | WorkspaceInviteInfo> {
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)
}

View File

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

View File

@ -435,6 +435,7 @@ export async function createAccount (
db: AccountDB,
personUuid: PersonUuid,
confirmed = false,
automatic = false,
createdOn = Date.now()
): Promise<void> {
// 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)
}