feat: Redesign workspace join (#10507)

* Redesign workspace join

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* Login rename

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* Fix UI tests

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* Fix unstable tests

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* Minor fixes

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* Clean up

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* Fix translations

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* Redesign join

Signed-off-by: Artem Savchenko <armisav@gmail.com>

---------

Signed-off-by: Artem Savchenko <armisav@gmail.com>
This commit is contained in:
Artyom Savchenko
2026-02-16 16:07:18 +07:00
committed by GitHub
parent 850e15fc05
commit bb54748716
21 changed files with 514 additions and 44 deletions
@@ -42,6 +42,7 @@ import type {
LoginInfo,
LoginInfoByToken,
LoginInfoRequestData,
InviteInfo,
LoginInfoWithWorkspaces,
MailboxInfo,
MailboxOptions,
@@ -124,7 +125,9 @@ export interface AccountClient {
}
) => Promise<string>
checkJoin: (inviteId: string) => Promise<WorkspaceLoginInfo>
joinByToken: (inviteId: string) => Promise<WorkspaceLoginInfo>
checkAutoJoin: (inviteId: string, firstName?: string, lastName?: string) => Promise<WorkspaceLoginInfo>
getInviteInfo: (inviteId: string) => Promise<InviteInfo>
getWorkspaceInfo: (updateLastVisit?: boolean) => Promise<WorkspaceInfoWithStatus>
getWorkspacesInfo: (workspaces: WorkspaceUuid[]) => Promise<WorkspaceInfoWithStatus[]>
updateLastVisit: (workspaces: WorkspaceUuid[]) => Promise<void>
@@ -577,6 +580,15 @@ class AccountClientImpl implements AccountClient {
return await this.rpc(request)
}
async joinByToken (inviteId: string): Promise<WorkspaceLoginInfo> {
const request = {
method: 'joinByToken' as const,
params: { inviteId }
}
return await this.rpc(request)
}
async checkAutoJoin (inviteId: string, firstName?: string, lastName?: string): Promise<WorkspaceLoginInfo> {
const request = {
method: 'checkAutoJoin' as const,
@@ -586,6 +598,15 @@ class AccountClientImpl implements AccountClient {
return await this.rpc(request)
}
async getInviteInfo (inviteId: string): Promise<InviteInfo> {
const request = {
method: 'getInviteInfo' as const,
params: { inviteId }
}
return await this.rpc(request)
}
async getWorkspacesInfo (ids: WorkspaceUuid[]): Promise<WorkspaceInfoWithStatus[]> {
const request = {
method: 'getWorkspacesInfo' as const,
@@ -80,6 +80,11 @@ export interface WorkspaceInviteInfo {
name?: string
}
/** Public invite details from getInviteInfo (no auth required). */
export interface InviteInfo {
workspaceName: string | null
}
export interface OtpInfo {
sent: boolean
retryOn: Timestamp
+9 -1
View File
@@ -20,6 +20,7 @@
"LastName": "Příjmení",
"FirstName": "Jméno",
"Join": "Připojit se",
"JoinWorkspace": "Připojit se k {workspaceName}",
"Email": "E-mail",
"Password": "Heslo",
"Workspace": "Název pracovního prostoru",
@@ -84,6 +85,13 @@
"SignUpToCreateWorkspace": "Neexistují žádné pracovní prostory s přístupem pro hosty. Prosím, zaregistrujte se a vytvořte si vlastní.",
"WhatIsYourName": "Jak se jmenujete?",
"AccessExpired": "Doba přístupu vypršela. Požádejte prosím o nový odkaz pro přístup.",
"AccessNotActive": "Doba přístupu ještě nezačala. Začíná za: "
"AccessNotActive": "Doba přístupu ještě nezačala. Začíná za: ",
"JoinWithThisAccount": "Připojit se tímto účtem",
"UseDifferentAccount": "Použít jiný účet",
"UseCurrentAccountToJoin": "Už jste přihlášeni?",
"LogInAndJoin": "Přihlásit se a připojit",
"SignUpAndJoin": "Registrovat se a připojit",
"CreateNewAccount": "Vytvořit nový účet",
"SignedInAs": "Přihlášen jako {name}"
}
}
+9 -1
View File
@@ -20,6 +20,7 @@
"LastName": "Nachname",
"FirstName": "Vorname",
"Join": "Beitreten",
"JoinWorkspace": "{workspaceName} beitreten",
"Email": "E-Mail",
"Password": "Passwort",
"Workspace": "Name des Arbeitsbereichs",
@@ -84,6 +85,13 @@
"SignUpToCreateWorkspace": "Keine Arbeitsbereiche mit Gastzugang. Bitte registrieren Sie sich, um Ihren eigenen zu erstellen.",
"WhatIsYourName": "Wie ist Ihr Name?",
"AccessExpired": "Zugriffszeit abgelaufen. Bitte fragen Sie nach einem neuen Zugangslink.",
"AccessNotActive": "Zugriffszeit hat noch nicht begonnen. Sie beginnt in: "
"AccessNotActive": "Zugriffszeit hat noch nicht begonnen. Sie beginnt in: ",
"JoinWithThisAccount": "Mit diesem Konto beitreten",
"UseDifferentAccount": "Anderes Konto verwenden",
"UseCurrentAccountToJoin": "Bereits angemeldet?",
"LogInAndJoin": "Anmelden & beitreten",
"SignUpAndJoin": "Registrieren & beitreten",
"CreateNewAccount": "Neues Konto erstellen",
"SignedInAs": "Angemeldet als {name}"
}
}
+9 -1
View File
@@ -20,6 +20,7 @@
"LastName": "Last name",
"FirstName": "First name",
"Join": "Join",
"JoinWorkspace": "Join {workspaceName}",
"Email": "Email",
"Password": "Password",
"Workspace": "Workspace name",
@@ -83,6 +84,13 @@
"SignUpToCreateWorkspace": "There are no workspaces with guest access. Please sign up to create your own.",
"WhatIsYourName": "What is your name?",
"AccessExpired": "Access time expired. Please ask for a new access link.",
"AccessNotActive": "Access time hasn't started yet. It is starting in: "
"AccessNotActive": "Access time hasn't started yet. It is starting in: ",
"JoinWithThisAccount": "Join with this account",
"UseDifferentAccount": "Use different account",
"UseCurrentAccountToJoin": "Already signed in?",
"LogInAndJoin": "Log In & Join",
"SignUpAndJoin": "Sign Up & Join",
"CreateNewAccount": "Create new account",
"SignedInAs": "Signed in as {name}"
}
}
+9 -1
View File
@@ -20,6 +20,7 @@
"LastName": "Apellido",
"FirstName": "Nombre",
"Join": "Unirse",
"JoinWorkspace": "Unirse a {workspaceName}",
"Email": "Correo Electrónico",
"Password": "Contraseña",
"Workspace": "Nombre del espacio de trabajo",
@@ -83,6 +84,13 @@
"SignUpToCreateWorkspace": "No hay espacios de trabajo con acceso de invitado. Por favor, regístrese para crear el suyo.",
"WhatIsYourName": "¿Cuál es su nombre?",
"AccessExpired": "El tiempo de acceso ha expirado. Por favor, solicite un nuevo enlace de acceso.",
"AccessNotActive": "El tiempo de acceso aún no ha comenzado. Comienza en: "
"AccessNotActive": "El tiempo de acceso aún no ha comenzado. Comienza en: ",
"JoinWithThisAccount": "Unirse con esta cuenta",
"UseDifferentAccount": "Usar otra cuenta",
"UseCurrentAccountToJoin": "¿Ya has iniciado sesión?",
"LogInAndJoin": "Iniciar sesión y unirse",
"SignUpAndJoin": "Registrarse y unirse",
"CreateNewAccount": "Crear nueva cuenta",
"SignedInAs": "Conectado como {name}"
}
}
+9 -1
View File
@@ -20,6 +20,7 @@
"LastName": "Nom de famille",
"FirstName": "Prénom",
"Join": "Rejoindre",
"JoinWorkspace": "Rejoindre {workspaceName}",
"Email": "E-mail",
"Password": "Mot de passe",
"Workspace": "Nom de l'espace de travail",
@@ -83,6 +84,13 @@
"SignUpToCreateWorkspace": "Aucun espace de travail avec accès invité. Veuillez vous inscrire pour créer le vôtre.",
"WhatIsYourName": "Quel est votre nom ?",
"AccessExpired": "Le temps d'accès a expiré. Veuillez demander un nouveau lien d'accès.",
"AccessNotActive": "Le temps d'accès n'a pas encore commencé. Il commence dans : "
"AccessNotActive": "Le temps d'accès n'a pas encore commencé. Il commence dans : ",
"JoinWithThisAccount": "Rejoindre avec ce compte",
"UseDifferentAccount": "Utiliser un autre compte",
"UseCurrentAccountToJoin": "Déjà connecté ?",
"LogInAndJoin": "Se connecter et rejoindre",
"SignUpAndJoin": "S'inscrire et rejoindre",
"CreateNewAccount": "Créer un nouveau compte",
"SignedInAs": "Connecté en tant que {name}"
}
}
+9 -1
View File
@@ -20,6 +20,7 @@
"LastName": "Cognome",
"FirstName": "Nome",
"Join": "Unisciti",
"JoinWorkspace": "Partecipa a {workspaceName}",
"Email": "Email",
"Password": "Password",
"Workspace": "Nome dello spazio di lavoro",
@@ -83,6 +84,13 @@
"SignUpToCreateWorkspace": "Nessuna area di lavoro con accesso ospite. Per favore, registrati per crearne una tua.",
"WhatIsYourName": "Come ti chiami?",
"AccessExpired": "Il tempo di accesso è scaduto. Per favore, richiedi un nuovo link di accesso.",
"AccessNotActive": "Il tempo di accesso non è ancora iniziato. Inizia tra: "
"AccessNotActive": "Il tempo di accesso non è ancora iniziato. Inizia tra: ",
"JoinWithThisAccount": "Partecipa con questo account",
"UseDifferentAccount": "Usa un altro account",
"UseCurrentAccountToJoin": "Già effettuato l'accesso?",
"LogInAndJoin": "Accedi e partecipa",
"SignUpAndJoin": "Iscriviti e partecipa",
"CreateNewAccount": "Crea nuovo account",
"SignedInAs": "Accesso effettuato come {name}"
}
}
+9 -1
View File
@@ -13,6 +13,7 @@
"LastName": "姓",
"FirstName": "名",
"Join": "参加",
"JoinWorkspace": "{workspaceName} に参加",
"Email": "メールアドレス",
"Password": "パスワード",
"Workspace": "ワークスペース名",
@@ -83,6 +84,13 @@
"EnterCurrentPassword": "現在のパスワードを入力",
"EnterNewPassword": "新しいパスワードを入力",
"RepeatNewPassword": "新しいパスワードを再入力",
"PasswordExpiredDesc": "パスワードの有効期限が切れています。続行するには新しいパスワードを設定してください。"
"PasswordExpiredDesc": "パスワードの有効期限が切れています。続行するには新しいパスワードを設定してください。",
"JoinWithThisAccount": "このアカウントで参加",
"UseDifferentAccount": "別のアカウントを使用",
"UseCurrentAccountToJoin": "すでにサインインしていますか?",
"LogInAndJoin": "ログインして参加",
"SignUpAndJoin": "サインアップして参加",
"CreateNewAccount": "新規アカウントを作成",
"SignedInAs": "{name} としてサインイン中"
}
}
+9 -1
View File
@@ -20,6 +20,7 @@
"LastName": "Apelido",
"FirstName": "Nome",
"Join": "Participar",
"JoinWorkspace": "Participar de {workspaceName}",
"Email": "Email",
"Password": "Palavra-Passe",
"Workspace": "Nome do espaço de trabalho",
@@ -83,6 +84,13 @@
"SignUpToCreateWorkspace": "Não há espaços de trabalho com acesso de convidado. Por favor, cadastre-se para criar o seu.",
"WhatIsYourName": "Qual é o seu nome?",
"AccessExpired": "O tempo de acesso expirou. Por favor, solicite um novo link de acesso.",
"AccessNotActive": "O tempo de acesso ainda não começou. Começa em: "
"AccessNotActive": "O tempo de acesso ainda não começou. Começa em: ",
"JoinWithThisAccount": "Entrar com esta conta",
"UseDifferentAccount": "Usar outra conta",
"UseCurrentAccountToJoin": "Já está conectado?",
"LogInAndJoin": "Entrar e participar",
"SignUpAndJoin": "Cadastrar e participar",
"CreateNewAccount": "Criar nova conta",
"SignedInAs": "Conectado como {name}"
}
}
+9 -1
View File
@@ -20,6 +20,7 @@
"LastName": "Apelido",
"FirstName": "Nome",
"Join": "Participar",
"JoinWorkspace": "Participar em {workspaceName}",
"Email": "Email",
"Password": "Palavra-Passe",
"Workspace": "Nome do espaço de trabalho",
@@ -83,6 +84,13 @@
"SignUpToCreateWorkspace": "Não há espaços de trabalho com acesso de convidado. Por favor, cadastre-se para criar o seu.",
"WhatIsYourName": "Qual é o seu nome?",
"AccessExpired": "O tempo de acesso expirou. Por favor, solicite um novo link de acesso.",
"AccessNotActive": "O tempo de acesso ainda não começou. Começa em: "
"AccessNotActive": "O tempo de acesso ainda não começou. Começa em: ",
"JoinWithThisAccount": "Participar com esta conta",
"UseDifferentAccount": "Usar outra conta",
"UseCurrentAccountToJoin": "Já tem sessão iniciada?",
"LogInAndJoin": "Entrar e participar",
"SignUpAndJoin": "Registar e participar",
"CreateNewAccount": "Criar nova conta",
"SignedInAs": "Sessão iniciada como {name}"
}
}
+9 -1
View File
@@ -20,6 +20,7 @@
"LastName": "Фамилия",
"FirstName": "Имя",
"Join": "Присоединиться",
"JoinWorkspace": "Присоединиться к {workspaceName}",
"Email": "Email",
"Password": "Пароль",
"Workspace": "Рабочее пространство",
@@ -83,6 +84,13 @@
"SignUpToCreateWorkspace": "Нет рабочих пространств с гостевым доступом. Пожалуйста, зарегистрируйтесь, чтобы создать своё.",
"WhatIsYourName": "Как вас зовут?",
"AccessExpired": "Время доступа истекло. Пожалуйста, запросите новую ссылку доступа.",
"AccessNotActive": "Время доступа еще не началось. Начинается через: "
"AccessNotActive": "Время доступа еще не началось. Начинается через: ",
"JoinWithThisAccount": "Присоединиться с этим аккаунтом",
"UseDifferentAccount": "Использовать другой аккаунт",
"UseCurrentAccountToJoin": "Уже вошли в аккаунт?",
"LogInAndJoin": "Войти и присоединиться",
"SignUpAndJoin": "Зарегистрироваться и присоединиться",
"CreateNewAccount": "Создать новый аккаунт",
"SignedInAs": "Вы вошли как {name}"
}
}
+9 -1
View File
@@ -20,6 +20,7 @@
"LastName": "Soyad",
"FirstName": "Ad",
"Join": "Katıl",
"JoinWorkspace": "{workspaceName} alanına katıl",
"Email": "E-posta",
"Password": "Şifre",
"Workspace": "Çalışma alanı adı",
@@ -83,6 +84,13 @@
"SignUpToCreateWorkspace": "Misafir erişimi olan çalışma alanı yok. Kendi alanınızı oluşturmak için lütfen kayıt olun.",
"WhatIsYourName": "Adınız nedir?",
"AccessExpired": "Erişim süresi doldu. Lütfen yeni bir erişim bağlantısı isteyin.",
"AccessNotActive": "Erişim süresi henüz başlamadı. Başlangıç zamanı: "
"AccessNotActive": "Erişim süresi henüz başlamadı. Başlangıç zamanı: ",
"JoinWithThisAccount": "Bu hesapla katıl",
"UseDifferentAccount": "Farklı hesap kullan",
"UseCurrentAccountToJoin": "Zaten giriş yaptınız mı?",
"LogInAndJoin": "Giriş yap ve katıl",
"SignUpAndJoin": "Kayıt ol ve katıl",
"CreateNewAccount": "Yeni hesap oluştur",
"SignedInAs": "{name} olarak giriş yapıldı"
}
}
+9 -1
View File
@@ -20,6 +20,7 @@
"LastName": "姓",
"FirstName": "名",
"Join": "加入",
"JoinWorkspace": "加入 {workspaceName}",
"Email": "电子邮件",
"Password": "密码",
"Workspace": "工作区名称",
@@ -83,6 +84,13 @@
"SignUpToCreateWorkspace": "没有支持访客访问的工作区。请注册以创建您自己的工作区。",
"WhatIsYourName": "您的姓名是什么?",
"AccessExpired": "访问时间已过期。请申请新的访问链接。",
"AccessNotActive": "访问时间尚未开始。开始时间:"
"AccessNotActive": "访问时间尚未开始。开始时间:",
"JoinWithThisAccount": "使用此账户加入",
"UseDifferentAccount": "使用其他账户",
"UseCurrentAccountToJoin": "已登录?",
"LogInAndJoin": "登录并加入",
"SignUpAndJoin": "注册并加入",
"CreateNewAccount": "创建新账户",
"SignedInAs": "已以 {name} 身份登录"
}
}
@@ -34,6 +34,7 @@
}
export let caption: IntlString
export let captionParams: Record<string, any> = {}
export let status: Status
export let fields: Field[]
export let action: Action
@@ -46,6 +47,8 @@
export let subtitle: string | undefined = undefined
export let signUpDisabled = false
export let isLoading: boolean = false
export let actionButtonDataId: string | undefined = undefined
export let secondaryButtonDataId: string | undefined = undefined
const validate = makeSequential(async function validateAsync (language: string): Promise<boolean> {
if (ignoreInitialValidation || isLoading) return true
@@ -147,7 +150,7 @@
</div>
{/if}
<div class="flex-row-center">
<div class="title"><Label label={caption} /></div>
<div class="title"><Label label={caption} params={captionParams} /></div>
<slot name="region-selector" />
</div>
{/if}
@@ -174,6 +177,7 @@
<div class="form-row send">
<Button
dataId={actionButtonDataId}
label={action.i18n}
kind={'contrast'}
shape={'round2'}
@@ -196,6 +200,7 @@
{#if secondaryButtonLabel !== undefined && secondaryButtonAction}
<div class="form-row">
<Button
dataId={secondaryButtonDataId}
label={secondaryButtonLabel}
width="100%"
on:click={(e) => {
@@ -1,5 +1,5 @@
<!--
// Copyright © 2022 Hardcore Engineering Inc.
// Copyright © 2026 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
@@ -13,11 +13,29 @@
// limitations under the License.
-->
<script lang="ts">
import { OK, Severity, Status } from '@hcengineering/platform'
import { Location, getCurrentLocation, navigate } from '@hcengineering/ui'
import { OK, PlatformError, Severity, Status, getMetadata, setMetadata } from '@hcengineering/platform'
import {
Button,
Label,
Loading,
Location,
deviceOptionsStore as deviceInfo,
getCurrentLocation,
navigate
} from '@hcengineering/ui'
import presentation from '@hcengineering/presentation'
import { checkJoined, join, setLoginInfo, signUpJoin } from '../utils'
import {
checkJoined,
getInviteWorkspaceName,
join,
joinByToken,
setLoginInfo,
signUpJoin,
getLoginInfo
} from '../utils'
import Form from './Form.svelte'
import StatusControl from './StatusControl.svelte'
import { Analytics } from '@hcengineering/analytics'
import { signupStore } from '@hcengineering/analytics-providers'
@@ -28,7 +46,14 @@
const location = getCurrentLocation()
Analytics.handleEvent('invite_link_activated', { invite_id: location.query?.inviteId })
let page = 'signUp'
const token = getMetadata(presentation.metadata.Token)
let page = token != null ? 'login' : 'signUp'
let checking = true
let showJoinWithAccount = false
let currentAccountName: string | undefined
let joiningWithAccount = false
let inviteWorkspaceName: string | undefined
$: signupStore.setSignUpFlow(page === 'signUp')
@@ -62,7 +87,7 @@
let status = OK
$: action = {
i18n: page === 'login' ? login.string.Join : login.string.SignUp,
i18n: page === 'login' ? login.string.LogInAndJoin : login.string.SignUpAndJoin,
func: async () => {
status = new Status(Severity.INFO, login.status.ConnectingToServer, {})
@@ -105,7 +130,7 @@
}
}
$: secondaryButtonLabel = page === 'login' ? login.string.SignUp : login.string.Join
$: secondaryButtonLabel = page === 'login' ? login.string.CreateNewAccount : login.string.HaveAccount
$: secondaryButtonAction =
page === 'login'
? () => {
@@ -120,11 +145,18 @@
})
async function check (): Promise<void> {
if (location.query?.inviteId === undefined || location.query?.inviteId === null) return
if (location.query?.inviteId === undefined || location.query?.inviteId === null) {
checking = false
return
}
checking = true
status = new Status(Severity.INFO, login.status.ConnectingToServer, {})
const result = await checkJoined(location.query.inviteId)
const inviteId = location.query.inviteId
const [result, workspaceName] = await Promise.all([checkJoined(inviteId), getInviteWorkspaceName(inviteId)])
inviteWorkspaceName = workspaceName
status = OK
if (result != null) {
setLoginInfo(result)
@@ -140,18 +172,199 @@
}
}
navigate({ path: [workbenchId, result.workspaceUrl] })
return
}
try {
const info = await getLoginInfo()
if (info != null) {
showJoinWithAccount = true
currentAccountName = info.name
if (info.token != null) {
setMetadata(presentation.metadata.Token, info.token)
}
}
} catch {
// No session (metadata token or cookie)
}
checking = false
}
async function handleJoinWithThisAccount (): Promise<void> {
const inviteId = location.query?.inviteId
if (inviteId == null) return
joiningWithAccount = true
status = new Status(Severity.INFO, login.status.ConnectingToServer, {})
try {
const result = await joinByToken(inviteId)
await logIn(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] })
} catch (err: any) {
status =
err instanceof PlatformError ? err.status : new Status(Severity.ERROR, login.status.ConnectingToServer, {})
} finally {
joiningWithAccount = false
}
}
function handleUseDifferentAccount (): void {
setMetadata(presentation.metadata.Token, null)
showJoinWithAccount = false
page = 'login'
}
async function handleUseCurrentAccountToJoin (): Promise<void> {
try {
if (currentAccountName == null) {
console.error('Current account is not found')
return
}
const info = await getLoginInfo()
if (info != null) {
showJoinWithAccount = true
}
} catch {
// No session
}
}
const useCurrentAccountToJoinAction = {
caption: login.string.UseCurrentAccountToJoin,
i18n: login.string.JoinWithThisAccount,
func: () => {
void handleUseCurrentAccountToJoin()
}
}
</script>
<Form
caption={login.string.Join}
{status}
{fields}
{object}
{action}
{secondaryButtonLabel}
{secondaryButtonAction}
bottomActions={[loginAction, recoveryAction]}
withProviders
/>
{#if checking}
<div class="join-checking">
<Loading size="small" shrink={true} />
<Label label={login.string.ProcessingInvite} />
</div>
{:else if showJoinWithAccount}
<div
class="join-with-account-container"
style:padding={$deviceInfo.docWidth <= 480 ? '.25rem 1.25rem' : '4rem 5rem'}
style:min-height={$deviceInfo.docHeight > 720 ? '42rem' : '0'}
>
<div class="join-with-account">
<div class="join-title">
<Label label={login.string.JoinWorkspace} params={{ workspaceName: inviteWorkspaceName ?? '' }} />
</div>
{#if currentAccountName}
<div class="join-subtitle pb-4">
<Label label={login.string.SignedInAs} params={{ name: currentAccountName }} />
</div>
{/if}
{#if status.severity !== Severity.OK}
<div class="join-status">
<StatusControl {status} />
</div>
{/if}
<div class="join-with-account-buttons">
<Button
dataId="join-with-this-account"
label={login.string.Join}
kind={'contrast'}
shape={'round2'}
size={'large'}
loading={joiningWithAccount}
disabled={joiningWithAccount}
on:click={() => handleJoinWithThisAccount()}
/>
<Button
dataId="join-use-different-account"
label={login.string.UseDifferentAccount}
size={'large'}
shape={'round2'}
disabled={joiningWithAccount}
on:click={handleUseDifferentAccount}
/>
</div>
</div>
</div>
{:else}
<Form
caption={login.string.JoinWorkspace}
captionParams={{ workspaceName: inviteWorkspaceName ?? '' }}
actionButtonDataId="join-form-submit"
secondaryButtonDataId="join-form-toggle"
{status}
{fields}
{object}
{action}
{secondaryButtonLabel}
{secondaryButtonAction}
bottomActions={[
loginAction,
...(currentAccountName != null ? [useCurrentAccountToJoinAction] : []),
recoveryAction
]}
withProviders
/>
{/if}
<style lang="scss">
.join-title {
font-weight: 500;
font-size: 1.25rem;
color: var(--theme-caption-color);
}
.join-subtitle {
font-size: 0.95rem;
color: var(--theme-content-color);
}
.join-checking {
display: flex;
flex-direction: row;
align-items: center;
align-self: center;
gap: 1rem;
padding: 2rem;
color: var(--theme-caption-color);
}
.join-with-account-container {
overflow: hidden;
display: flex;
flex-direction: column;
}
.join-with-account {
display: flex;
flex-direction: column;
flex: 1;
gap: 0.25rem;
.join-status {
margin-bottom: 1rem;
}
.join-with-account-buttons {
display: flex;
flex-direction: column;
gap: 1rem;
:global(button:first-child) {
width: 100%;
}
}
}
</style>
+9 -1
View File
@@ -34,6 +34,7 @@ export default mergeIds(loginId, login, {
HaveAccount: '' as IntlString,
LoadingAccount: '' as IntlString,
Join: '' as IntlString,
JoinWorkspace: '' as IntlString<{ workspaceName: string }>,
Workspace: '' as IntlString,
SignUp: '' as IntlString,
DoNotHaveAnAccount: '' as IntlString,
@@ -75,6 +76,13 @@ export default mergeIds(loginId, login, {
SignUpToCreateWorkspace: '' as IntlString,
WhatIsYourName: '' as IntlString,
AccessExpired: '' as IntlString,
AccessNotActive: '' as IntlString
AccessNotActive: '' as IntlString,
JoinWithThisAccount: '' as IntlString,
UseDifferentAccount: '' as IntlString,
UseCurrentAccountToJoin: '' as IntlString,
LogInAndJoin: '' as IntlString,
SignUpAndJoin: '' as IntlString,
CreateNewAccount: '' as IntlString,
SignedInAs: '' as IntlString<{ name: string }>
}
})
+25 -5
View File
@@ -558,12 +558,9 @@ export function navigateToWorkspace (
export async function checkJoined (inviteId: string): Promise<WorkspaceLoginInfo | undefined> {
const token = getMetadata(presentation.metadata.Token)
if (token == null) return
try {
const workspaceLoginInfo = await getAccountClient(token).checkJoin(inviteId)
return workspaceLoginInfo
const workspaceLoginInfo = await getAccountClient(token ?? undefined).checkJoin(inviteId)
return workspaceLoginInfo ?? undefined
} catch (err: any) {
if (!(err instanceof PlatformError)) {
Analytics.handleError(err)
@@ -571,6 +568,29 @@ export async function checkJoined (inviteId: string): Promise<WorkspaceLoginInfo
}
}
/**
* Fetches workspace name for a valid invite (no auth required). Returns undefined for invalid or expired invites.
*/
export async function getInviteWorkspaceName (inviteId: string): Promise<string | undefined> {
try {
const client = getAccountClient(null)
const info = await client.getInviteInfo(inviteId)
return info.workspaceName ?? undefined
} catch (err: any) {
console.error('Failed to get invite workspace name', err)
if (!(err instanceof PlatformError)) {
Analytics.handleError(err)
}
return undefined
}
}
export async function joinByToken (inviteId: string): Promise<WorkspaceLoginInfo> {
const token = getMetadata(presentation.metadata.Token)
return await getAccountClient(token ?? undefined).joinByToken(inviteId)
}
export async function checkAutoJoin (
inviteId: string,
firstName?: string,
+78 -1
View File
@@ -64,7 +64,8 @@ import {
type PersonWithProfile,
type Subscription,
SubscriptionStatus,
type Query
type Query,
type InviteInfo
} from './types'
import {
addSocialIdBase,
@@ -993,6 +994,38 @@ export async function join (
return await doJoinByInvite(ctx, db, branding, token, account, workspaceJoinInfo.workspace, workspaceJoinInfo.invite)
}
/**
* Returns public invite details (e.g. workspace name) for a valid invite. No auth required.
* Returns { workspaceName: null } for invalid or expired invites.
*/
export async function getInviteInfo (
ctx: MeasureContext,
db: AccountDB,
_branding: Branding | null,
_token: string,
params: { inviteId: string }
): Promise<InviteInfo> {
const { inviteId } = params
if (inviteId == null || inviteId === '') {
ctx.error('Mandatory param inviteId is missing in getInviteInfo', { inviteId })
return { workspaceName: null }
}
const invite = await getWorkspaceInvite(db, inviteId)
if (invite == null) {
ctx.error('Invite not found in getInviteInfo', { inviteId })
return { workspaceName: null }
}
const workspace = await getWorkspaceById(db, invite.workspaceUuid)
if (workspace === null) {
return { workspaceName: null }
}
return { workspaceName: workspace.name }
}
/**
* Given an invite and a token, checks if the user has already joined the workspace and updates the role if necessary.
* Returns the workspace login information if the user has already joined. Otherwise, throws an error.
@@ -1042,6 +1075,46 @@ export async function checkJoin (
}
}
/**
* Joins the workspace using the current session token (no password).
* Called only when the user explicitly clicks "Join with this account".
*/
export async function joinByToken (
ctx: MeasureContext,
db: AccountDB,
branding: Branding | null,
token: string,
params: { inviteId: string }
): Promise<WorkspaceLoginInfo> {
const { inviteId } = params
if (inviteId == null || inviteId === '') {
throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {}))
}
const invite = await getWorkspaceInvite(db, inviteId)
if (invite == null) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
}
const { account: accountUuid } = decodeTokenVerbose(ctx, token)
const emailSocialId = await db.socialId.findOne({
type: SocialIdType.EMAIL,
personUuid: accountUuid,
verifiedOn: { $gt: 0 }
})
const email = emailSocialId?.value ?? ''
const workspaceUuid = await checkInvite(ctx, invite, email)
const workspace = await getWorkspaceById(db, workspaceUuid)
if (workspace === null) {
ctx.error('Workspace not found in joinByToken', { workspaceUuid, email, inviteId })
throw new PlatformError(new Status(Severity.ERROR, platform.status.WorkspaceNotFound, { workspaceUuid }))
}
return await doJoinByInvite(ctx, db, branding, token, accountUuid, workspace, invite)
}
export async function checkAutoJoin (
ctx: MeasureContext,
db: AccountDB,
@@ -2892,8 +2965,10 @@ export type AccountMethods =
| 'resendInvite'
| 'selectWorkspace'
| 'join'
| 'joinByToken'
| 'checkJoin'
| 'checkAutoJoin'
| 'getInviteInfo'
| 'signUpJoin'
| 'confirm'
| 'changePassword'
@@ -2966,8 +3041,10 @@ export function getMethods (hasSignUp: boolean = true): Partial<Record<AccountMe
resendInvite: wrap(resendInvite),
selectWorkspace: wrap(selectWorkspace),
join: wrap(join),
joinByToken: wrap(joinByToken),
checkJoin: wrap(checkJoin),
checkAutoJoin: wrap(checkAutoJoin),
getInviteInfo: wrap(getInviteInfo),
signUpJoin: wrap(signUpJoin),
confirm: wrap(confirm),
changePassword: wrap(changePassword),
+4
View File
@@ -487,6 +487,10 @@ export interface WorkspaceInviteInfo {
name?: string
}
export interface InviteInfo {
workspaceName: string | null
}
export interface MailboxOptions {
availableDomains: string[]
minNameLength: number
+25 -4
View File
@@ -12,13 +12,34 @@ export class SignInJoinPage extends CommonPage {
inputEmail = (): Locator => this.page.locator('input[name="email"]')
inputPassword = (): Locator => this.page.locator('//div[text()="Password"]/../input')
buttonJoin = (): Locator => this.page.locator('button', { hasText: 'Join' })
buttonSubmit = (): Locator => this.page.locator('button[data-id="join-form-submit"]')
buttonToggle = (): Locator => this.page.locator('button[data-id="join-form-toggle"]')
buttonJoinWithThisAccount = (): Locator => this.page.locator('button[data-id="join-with-this-account"]')
buttonUseDifferentAccount = (): Locator => this.page.locator('button[data-id="join-use-different-account"]')
async join (data: Pick<SignUpData, 'email' | 'password'>): Promise<void> {
await this.buttonJoin().click()
await Promise.race([
this.buttonSubmit().waitFor({ state: 'visible', timeout: 10000 }),
this.buttonJoinWithThisAccount().waitFor({ state: 'visible', timeout: 10000 })
])
const submitBtn = this.buttonSubmit()
const toggleBtn = this.buttonToggle()
const joinWithAccountBtn = this.buttonJoinWithThisAccount()
if (await joinWithAccountBtn.isVisible({ timeout: 2000 }).catch(() => false)) {
await this.buttonUseDifferentAccount().click()
await this.inputEmail().waitFor({ state: 'visible', timeout: 5000 })
}
const submitText = await submitBtn.textContent().catch(() => '')
if (submitText != null && submitText.toLowerCase().includes('sign up')) {
await toggleBtn.click()
await this.inputEmail().waitFor({ state: 'visible', timeout: 5000 })
}
await this.inputEmail().fill(data.email)
await this.inputPassword().fill(data.password)
expect(await this.buttonJoin().isEnabled()).toBe(true)
await this.buttonJoin().click()
await expect(this.buttonSubmit()).toBeEnabled()
await this.buttonSubmit().click()
}
}