Readonly allow signup (#9517)

Signed-off-by: Anton Alexeyev <alexeyev.anton@gmail.com>
This commit is contained in:
Anton Alexeyev
2025-07-11 13:19:37 +07:00
committed by GitHub
parent cd3396cf9d
commit 73fa668681
45 changed files with 511 additions and 178 deletions
+6 -1
View File
@@ -48,7 +48,8 @@ import core, {
type Blob,
type Timestamp,
type Tx,
type TxCUD
type TxCUD,
AccountRole
} from '@hcengineering/core'
import {
ArrOf,
@@ -277,6 +278,10 @@ export function createModel (builder: Builder): void {
TUserMentionInfo
)
builder.mixin(activity.class.Reaction, core.class.Class, core.mixin.TxAccessLevel, {
removeAccessLevel: AccountRole.Guest
})
builder.mixin(activity.class.DocUpdateMessage, core.class.Class, view.mixin.ObjectPresenter, {
presenter: activity.component.DocUpdateMessagePresenter
})
+5
View File
@@ -37,6 +37,7 @@ import {
TObjectChatPanel,
TThreadMessage
} from './types'
import { AccountRole } from '@hcengineering/core'
export { chunterId } from '@hcengineering/chunter'
export { chunterOperation } from './migration'
@@ -88,6 +89,10 @@ export function createModel (builder: Builder): void {
component: chunter.component.WorkbenchTabExtension
})
builder.mixin(chunter.class.DirectMessage, core.class.Class, core.mixin.TxAccessLevel, {
createAccessLevel: AccountRole.Guest
})
const spaceClasses = [chunter.class.Channel, chunter.class.DirectMessage]
spaceClasses.forEach((spaceClass) => {
+12
View File
@@ -309,6 +309,18 @@ export function createModel (builder: Builder): void {
TUserRole
)
builder.mixin(contact.class.PersonSpace, core.class.Class, core.mixin.TxAccessLevel, {
createAccessLevel: AccountRole.Guest
})
builder.mixin(contact.class.Person, core.class.Class, core.mixin.TxAccessLevel, {
createAccessLevel: AccountRole.Guest
})
builder.mixin(contact.class.SocialIdentity, core.class.Class, core.mixin.TxAccessLevel, {
createAccessLevel: AccountRole.Guest
})
builder.mixin(contact.class.Contact, core.class.Class, activity.mixin.ActivityDoc, {})
builder.mixin(contact.class.Person, core.class.Class, activity.mixin.ActivityDoc, {
+11 -3
View File
@@ -17,6 +17,7 @@ import {
DOMAIN_MODEL,
DOMAIN_SPACE,
IndexKind,
type AccountRole,
type Class,
type CollectionSize,
type Permission,
@@ -27,7 +28,8 @@ import {
type SpaceType,
type SpaceTypeDescriptor,
type TypedSpace,
type AccountUuid
type AccountUuid,
type TxAccessLevel
} from '@hcengineering/core'
import {
ArrOf,
@@ -45,8 +47,7 @@ import {
} from '@hcengineering/model'
import { getEmbeddedLabel, type Asset, type IntlString } from '@hcengineering/platform'
import core from './component'
import { TAttachedDoc, TDoc } from './core'
import { TAttachedDoc, TDoc, TClass } from './core'
// S P A C E
@Model(core.class.Space, core.class.Doc, DOMAIN_SPACE)
@@ -163,3 +164,10 @@ export class TPermission extends TDoc implements Permission {
export class TSpacesTypeData extends TSpace implements RolesAssignment {
[key: Ref<Role>]: AccountUuid[]
}
@Mixin(core.mixin.TxAccessLevel, core.class.Class)
export class TTxAccessLevel extends TClass implements TxAccessLevel {
createAccessLevel?: AccountRole
removeAccessLevel?: AccountRole
updateAccessLevel?: AccountRole
}
+2 -1
View File
@@ -17,7 +17,7 @@ import { ArrOf, Prop, TypeString, type Builder } from '@hcengineering/model'
import { type Asset } from '@hcengineering/platform'
import { getRoleAttributeLabel } from '@hcengineering/core'
import { TSpacesTypeData } from './security'
import { TSpacesTypeData, TTxAccessLevel } from './security'
import core from './component'
const roles = [
@@ -37,6 +37,7 @@ export function defineSpaceType (builder: Builder): void {
}
builder.createModel(TSpacesTypeData)
builder.createModel(TTxAccessLevel)
builder.createDoc(
core.class.SpaceTypeDescriptor,
+8
View File
@@ -513,6 +513,14 @@ export function createModel (builder: Builder): void {
components: { input: { component: chunter.component.ChatMessageInput, props: { collection: 'messages' } } }
})
builder.mixin(love.class.JoinRequest, core.class.Class, core.mixin.TxAccessLevel, {
removeAccessLevel: AccountRole.Guest
})
builder.mixin(love.class.ParticipantInfo, core.class.Class, core.mixin.TxAccessLevel, {
updateAccessLevel: AccountRole.Guest
})
builder.mixin(love.class.MeetingMinutes, core.class.Class, activity.mixin.ActivityDoc, {})
builder.mixin(love.class.Room, core.class.Class, activity.mixin.ActivityDoc, {})
+4
View File
@@ -555,6 +555,10 @@ export function createModel (builder: Builder): void {
presenter: notification.component.MentionInboxNotificationPresenter
})
builder.mixin(notification.class.BrowserNotification, core.class.Class, core.mixin.TxAccessLevel, {
removeAccessLevel: AccountRole.Guest
})
builder.createDoc(
notification.class.NotificationType,
core.space.Model,
+8 -2
View File
@@ -13,11 +13,11 @@
// limitations under the License.
//
import { type Class, DOMAIN_MODEL, type Ref, type Space, type AccountRole, type AccountUuid } from '@hcengineering/core'
import { AccountRole, type AccountUuid, type Class, DOMAIN_MODEL, type Ref, type Space } from '@hcengineering/core'
import { type Builder, Mixin, Model, Prop, TypeRef, UX } from '@hcengineering/model'
import preference, { TPreference } from '@hcengineering/model-preference'
import { createAction } from '@hcengineering/model-view'
import { getEmbeddedLabel, type Asset, type IntlString, type Resource } from '@hcengineering/platform'
import { type Asset, getEmbeddedLabel, type IntlString, type Resource } from '@hcengineering/platform'
import view, { type KeyBinding } from '@hcengineering/view'
import type {
Application,
@@ -115,6 +115,12 @@ export function createModel (builder: Builder): void {
TWorkbenchTab
)
builder.mixin(workbench.class.WorkbenchTab, core.class.Class, core.mixin.TxAccessLevel, {
createAccessLevel: AccountRole.Guest,
removeAccessLevel: AccountRole.Guest,
updateAccessLevel: AccountRole.Guest
})
builder.mixin(workbench.class.Application, core.class.Class, view.mixin.ObjectPresenter, {
presenter: workbench.component.ApplicationPresenter
})
+8 -6
View File
@@ -89,9 +89,10 @@ export interface AccountClient {
password: string,
first: string,
last: string,
inviteId: string
inviteId: string,
workspaceUrl: string
) => Promise<WorkspaceLoginInfo>
join: (email: string, password: string, inviteId: string) => Promise<WorkspaceLoginInfo>
join: (email: string, password: string, inviteId: string, workspaceUrl: 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>
@@ -442,20 +443,21 @@ class AccountClientImpl implements AccountClient {
password: string,
first: string,
last: string,
inviteId: string
inviteId: string,
workspaceUrl: string
): Promise<WorkspaceLoginInfo> {
const request = {
method: 'signUpJoin' as const,
params: { email, password, first, last, inviteId }
params: { email, password, first, last, inviteId, workspaceUrl }
}
return await this.rpc(request)
}
async join (email: string, password: string, inviteId: string): Promise<WorkspaceLoginInfo> {
async join (email: string, password: string, inviteId: string, workspaceUrl: string): Promise<WorkspaceLoginInfo> {
const request = {
method: 'join' as const,
params: { email, password, inviteId }
params: { email, password, inviteId, workspaceUrl }
}
return await this.rpc(request)
+6
View File
@@ -550,6 +550,12 @@ export const roleOrder: Record<AccountRole, number> = {
[AccountRole.Admin]: 100
}
export interface TxAccessLevel extends Class<Doc> {
createAccessLevel?: AccountRole
removeAccessLevel?: AccountRole
updateAccessLevel?: AccountRole
}
/**
* @public
*/
+5 -2
View File
@@ -15,7 +15,7 @@
import type { Asset, IntlString, Metadata, Plugin, StatusCode } from '@hcengineering/platform'
import { plugin } from '@hcengineering/platform'
import type { BenchmarkDoc } from './benchmark'
import { AccountRole } from './classes'
import { AccountRole, TxAccessLevel } from './classes'
import type {
Account,
AnyAttribute,
@@ -99,6 +99,8 @@ export const systemAccount: Account = {
export const configUserAccountUuid = '0d94731c-0787-4bcd-aefe-304efc3706b1' as AccountUuid
export const readOnlyGuestAccountUuid = '83bbed9a-0867-4851-be32-31d49d1d42ce' as AccountUuid
export default plugin(coreId, {
class: {
Obj: '' as Ref<Class<Obj>>,
@@ -189,7 +191,8 @@ export default plugin(coreId, {
ConfigurationElement: '' as Ref<Mixin<ConfigurationElement>>,
IndexConfiguration: '' as Ref<Mixin<IndexingConfiguration<Doc>>>,
SpacesTypeData: '' as Ref<Mixin<Space>>,
TransientConfiguration: '' as Ref<Mixin<TransientConfiguration>>
TransientConfiguration: '' as Ref<Mixin<TransientConfiguration>>,
TxAccessLevel: '' as Ref<Mixin<TxAccessLevel>>
},
space: {
Tx: '' as Ref<Space>,
+8 -1
View File
@@ -17,7 +17,14 @@ import core from './component'
export * from './classes'
export * from './client'
export * from './collaboration'
export { coreId, systemAccountUuid, systemAccountEmail, systemAccount, configUserAccountUuid } from './component'
export {
coreId,
systemAccountUuid,
readOnlyGuestAccountUuid,
systemAccountEmail,
systemAccount,
configUserAccountUuid
} from './component'
export * from './hierarchy'
export * from './measurements'
export * from './memdb'
+1
View File
@@ -172,6 +172,7 @@ export default plugin(platformId, {
MailboxError: '' as StatusCode<{ reason: string }>,
SocialIdAlreadyExists: '' as StatusCode,
ReadOnlyAccount: '' as StatusCode,
RegularAccount: '' as StatusCode,
SystemAccount: '' as StatusCode
},
metadata: {
@@ -104,6 +104,9 @@
maintenanceTime = _status.params.time
maintenanceMessage = _status.params.message
} else {
if (_status.code === platform.status.RegularAccount) {
readonlyAccount = false
}
if (readonlyAccount) return
if (_status.code === platform.status.ReadOnlyAccount) {
readonlyAccount = true
+6
View File
@@ -26,11 +26,13 @@ import { type Channel, type ChatMessage, type DirectMessage, type ThreadMessage
import contact, { type Employee, getCurrentEmployee, getName, type Person } from '@hcengineering/contact'
import { employeeByAccountStore, employeeByIdStore, PersonIcon } from '@hcengineering/contact-resources'
import core, {
AccountRole,
type AccountUuid,
type Class,
type Client,
type Doc,
getCurrentAccount,
hasAccountRole,
notEmpty,
type Ref,
type Space,
@@ -120,6 +122,10 @@ export async function canDeleteMessage (doc?: ChatMessage): Promise<boolean> {
const me = getCurrentAccount()
if (hasAccountRole(me, AccountRole.Maintainer)) {
return true
}
return doc.createdBy !== undefined && me.socialIds.includes(doc.createdBy)
}
@@ -66,13 +66,19 @@
const [loginStatus, result] =
page === 'login'
? await join(object.username, object.password, location.query?.inviteId ?? '')
? await join(
object.username,
object.password,
location.query?.inviteId ?? '',
location.query?.workspace ?? ''
)
: await signUpJoin(
object.username,
object.password,
object.first,
object.last,
location.query?.inviteId ?? ''
location.query?.inviteId ?? '',
location.query?.workspace ?? ''
)
status = loginStatus
+6 -4
View File
@@ -615,10 +615,11 @@ export async function getInviteLinkId (
export async function join (
email: string,
password: string,
inviteId: string
inviteId: string,
workspace: string
): Promise<[Status, WorkspaceLoginInfo | null]> {
try {
const workspaceLoginInfo = await getAccountClient().join(email, password, inviteId)
const workspaceLoginInfo = await getAccountClient().join(email, password, inviteId, workspace)
Analytics.handleEvent('Join', { email, ok: true })
@@ -643,10 +644,11 @@ export async function signUpJoin (
password: string,
first: string,
last: string,
inviteId: string
inviteId: string,
workspace: string
): Promise<[Status, WorkspaceLoginInfo | null]> {
try {
const workspaceLoginInfo = await getAccountClient().signUpJoin(email, password, first, last, inviteId)
const workspaceLoginInfo = await getAccountClient().signUpJoin(email, password, first, last, inviteId, workspace)
Analytics.handleEvent('Signup Join', { email, ok: true })
@@ -19,7 +19,8 @@
AccountUuid,
Configuration,
getCurrentAccount,
pickPrimarySocialId
pickPrimarySocialId,
readOnlyGuestAccountUuid
} from '@hcengineering/core'
import {
Breadcrumb,
@@ -49,7 +50,7 @@
import { translateCB } from '@hcengineering/platform'
import { createQuery, getClient, MessageBox, uiContext } from '@hcengineering/presentation'
import { WorkspaceSetting } from '@hcengineering/setting'
import { AvatarType, ensureEmployeeForPerson } from '@hcengineering/contact'
import contact, { AvatarType, ensureEmployeeForPerson } from '@hcengineering/contact'
import settingsRes from '../plugin'
let loading = true
@@ -172,6 +173,11 @@
guestUserInfo.guestSocialIds,
guestUserInfo.guestPerson
)
} else {
const readonlyEmployee = await client.findOne(contact.mixin.Employee, { personUuid: readOnlyGuestAccountUuid })
if (readonlyEmployee !== undefined) {
await client.update(readonlyEmployee, { active: false })
}
}
}
+5 -2
View File
@@ -136,8 +136,11 @@
"Loading": "Načítání...",
"NoRelations": "Žádné vztahy",
"ReadOnlyWarningTitle": "✨ Ukázka pro hosty",
"ReadOnlyWarningMessage": "Jste v režimu prohlížení. Chcete spravovat jakékoli projekty v Huly? Úkoly, termíny, tým — zaregistrujte se pro plný přístup!",
"ReadOnlySignUp": "Zaregistrovat se \uD83D\uDE80",
"ReadOnlyWarningMessage": "Jste v režimu prohlížení. Chcete spravovat jakékoli projekty v Huly? Úkoly, termíny, tým — připojte se k tomuto prostoru pro spolupráci nebo si vytvořte vlastní!",
"ReadOnlyJoinWorkspace": "Připojit se k prostoru 👥",
"ReadOnlySignUp": "Vytvořit účet 🚀",
"PermissionWarningTitle": "✨ Zvyšte svůj přístup!",
"PermissionWarningMessage": "Vaše současná role má omezená oprávnění. Chcete-li odemknout tuto a další pokročilé funkce, požádejte správce svého pracovního prostoru o zvýšení úrovně přístupu!",
"Icon": "Ikona",
"Color": "Barva",
"AutomationOnly": "Pouze automatizace"
+5 -2
View File
@@ -136,8 +136,11 @@
"Loading": "Laden...",
"NoRelations": "Keine Beziehungen",
"ReadOnlyWarningTitle": "✨ Gast-Demo",
"ReadOnlyWarningMessage": "Sie sind im Ansichtsmodus. Möchten Sie beliebige Projekte in Huly verwalten? Aufgaben, Fristen, Team — registrieren Sie sich für vollen Zugriff!",
"ReadOnlySignUp": "Registrieren \uD83D\uDE80",
"ReadOnlyWarningMessage": "Sie sind im Ansichtsmodus. Möchten Sie beliebige Projekte in Huly verwalten? Aufgaben, Fristen, Team — treten Sie diesem Arbeitsbereich bei oder erstellen Sie Ihren eigenen!",
"ReadOnlyJoinWorkspace": "Arbeitsbereich beitreten 👥",
"ReadOnlySignUp": "Konto erstellen 🚀",
"PermissionWarningTitle": "✨ Erweitern Sie Ihren Zugriff!",
"PermissionWarningMessage": "Ihre aktuelle Rolle hat eingeschränkte Berechtigungen. Um diese und andere erweiterte Funktionen freizuschalten, bitten Sie Ihren Arbeitsbereich-Administrator, Ihr Zugriffslevel zu erhöhen!",
"Icon": "Symbol",
"Color": "Farbe",
"AutomationOnly": "Nur Automatisierung"
+5 -2
View File
@@ -136,8 +136,11 @@
"Loading": "Loading...",
"NoRelations": "No relations",
"ReadOnlyWarningTitle": "✨ Guest Demo",
"ReadOnlyWarningMessage": "You're in view-only mode. Want to manage any project in Huly? Tasks, deadlines, team — sign up for full access!",
"ReadOnlySignUp": "Sign up \uD83D\uDE80",
"ReadOnlyWarningMessage": "You're in view-only mode. Want to manage any project in Huly? Tasks, deadlines, team — join this workspace to collaborate or create your own space!",
"ReadOnlyJoinWorkspace": "Join workspace 👥",
"ReadOnlySignUp": "Create account 🚀",
"PermissionWarningTitle": "✨ Level up your access!",
"PermissionWarningMessage": "Your current role has limited permissions. To unlock this and other advanced features, ask your workspace admin to upgrade your access level!",
"Icon": "Icon",
"Color": "Color",
"AutomationOnly": "Automation only"
+5 -2
View File
@@ -131,8 +131,11 @@
"Loading": "Cargando...",
"NoRelations": "No hay relaciones",
"ReadOnlyWarningTitle": "✨ Demo de Invitado",
"ReadOnlyWarningMessage": "Estás en modo de solo lectura. ¿Quieres gestionar cualquier proyecto en Huly? Tareas, plazos, equipo — ¡regístrate para acceso completo!",
"ReadOnlySignUp": "Registrarse \uD83D\uDE80",
"ReadOnlyWarningMessage": "Estás en modo de solo lectura. ¿Quieres gestionar cualquier proyecto en Huly? Tareas, plazos, equipo — ¡únete a este espacio para colaborar o crea el tuyo!",
"ReadOnlyJoinWorkspace": "Unirse al espacio 👥",
"ReadOnlySignUp": "Crear cuenta 🚀",
"PermissionWarningTitle": "✨ ¡Mejora tu acceso!",
"PermissionWarningMessage": "Tu rol actual tiene permisos limitados. ¡Para desbloquear esta y otras funciones avanzadas, pídele al administrador de tu espacio de trabajo que actualice tu nivel de acceso!",
"Icon": "Icono",
"Color": "Color",
"AutomationOnly": "Solo automatización"
+5 -2
View File
@@ -131,8 +131,11 @@
"Loading": "Chargement...",
"NoRelations": "Aucune relation",
"ReadOnlyWarningTitle": "✨ Démo Invité",
"ReadOnlyWarningMessage": "Vous êtes en mode consultation. Voulez-vous gérer tous types de projets dans Huly ? Tâches, délais, équipe — inscrivez-vous pour un accès complet !",
"ReadOnlySignUp": "Inscription \uD83D\uDE80",
"ReadOnlyWarningMessage": "Vous êtes en mode consultation. Voulez-vous gérer tous types de projets dans Huly ? Tâches, délais, équipe — rejoignez cet espace pour collaborer ou créez le vôtre !",
"ReadOnlyJoinWorkspace": "Rejoindre l'espace 👥",
"ReadOnlySignUp": "Créer un compte 🚀",
"PermissionWarningTitle": "✨ Améliorez votre accès !",
"PermissionWarningMessage": "Votre rôle actuel a des autorisations limitées. Pour débloquer cette fonctionnalité et d'autres fonctionnalités avancées, demandez à l'administrateur de votre espace de travail de mettre à jour votre niveau d'accès !",
"Icon": "Icône",
"Color": "Couleur",
"AutomationOnly": "Automatisation uniquement"
+5 -2
View File
@@ -131,8 +131,11 @@
"Loading": "Caricamento...",
"NoRelations": "Nessuna relazione",
"ReadOnlyWarningTitle": "✨ Demo Ospite",
"ReadOnlyWarningMessage": "Sei in modalità di sola lettura. Vuoi gestire qualsiasi progetto su Huly? Task, scadenze, team — registrati per l'accesso completo!",
"ReadOnlySignUp": "Registrati \uD83D\uDE80",
"ReadOnlyWarningMessage": "Sei in modalità di sola lettura. Vuoi gestire qualsiasi progetto su Huly? Task, scadenze, team — unisciti a questo spazio per collaborare o creane uno tuo!",
"ReadOnlyJoinWorkspace": "Unisciti allo spazio 👥",
"ReadOnlySignUp": "Crea account 🚀",
"PermissionWarningTitle": "✨ Migliora il tuo accesso!",
"PermissionWarningMessage": "Il tuo ruolo attuale ha autorizzazioni limitate. Per sbloccare questa e altre funzionalità avanzate, chiedi all'amministratore del tuo spazio di lavoro di aggiornare il tuo livello di accesso!",
"Icon": "Icona",
"Color": "Colore",
"AutomationOnly": "Solo automazione"
+5 -2
View File
@@ -131,8 +131,11 @@
"Loading": "読み込み中...",
"NoRelations": "関係なし",
"ReadOnlyWarningTitle": "✨ ゲストデモ",
"ReadOnlyWarningMessage": "閲覧モードです。Hulyでどんなプロジェクトも管理しませんか? タスク、期限、チーム — 完全アクセスには登録してください!",
"ReadOnlySignUp": "サインアップ \uD83D\uDE80",
"ReadOnlyWarningMessage": "閲覧モードです。Hulyでどんなプロジェクトも管理しませんか? タスク、期限、チーム — このワークスペースに参加して共同作業するか、自分自身のスペースを作成しましょう!",
"ReadOnlyJoinWorkspace": "ワークスペースに参加 👥",
"ReadOnlySignUp": "アカウント作成 🚀",
"PermissionWarningTitle": "✨ アクセスレベルを上げましょう!",
"PermissionWarningMessage": "現在のロールの権限は制限されています。この機能や他の高度な機能を利用するには、ワークスペース管理者にアクセスレベルのアップグレードを依頼してください!",
"Icon": "アイコン",
"Color": "色",
"AutomationOnly": "自動化のみ"
+5 -2
View File
@@ -131,8 +131,11 @@
"Loading": "Carregando...",
"NoRelations": "Sem relações",
"ReadOnlyWarningTitle": "✨ Demonstração para Convidados",
"ReadOnlyWarningMessage": "Você está no modo de visualização. Quer gerenciar qualquer projeto no Huly? Tarefas, prazos, equipe — cadastre-se para acesso total!",
"ReadOnlySignUp": "Registar \uD83D\uDE80",
"ReadOnlyWarningMessage": "Você está no modo de visualização. Quer gerenciar qualquer projeto no Huly? Tarefas, prazos, equipe — junte-se a este espaço para colaborar ou crie o seu!",
"ReadOnlyJoinWorkspace": "Entrar no espaço 👥",
"ReadOnlySignUp": "Criar conta 🚀",
"PermissionWarningTitle": "✨ Melhore seu acesso!",
"PermissionWarningMessage": "Seu cargo atual tem permissões limitadas. Para desbloquear este e outros recursos avançados, peça ao administrador do seu espaço de trabalho para atualizar seu nível de acesso!",
"Icon": "Ícone",
"Color": "Cor",
"AutomationOnly": "Apenas automação"
+5 -2
View File
@@ -133,8 +133,11 @@
"Loading": "Загрузка...",
"NoRelations": "Нет связей",
"ReadOnlyWarningTitle": "✨ Гостевое демо",
"ReadOnlyWarningMessage": "Вы в режиме просмотра. Хотите управлять любыми проектами в Huly? Задачи, сроки, команда — регистрируйтесь для полного доступа!",
"ReadOnlySignUp": "Регистрация \uD83D\uDE80",
"ReadOnlyWarningMessage": "Вы в режиме просмотра. Хотите управлять любыми проектами в Huly? Задачи, сроки, команда — присоединитесь к этому пространству для сотрудничества или создайте свое!",
"ReadOnlyJoinWorkspace": "Присоединиться 👥",
"ReadOnlySignUp": "Создать аккаунт 🚀",
"PermissionWarningTitle": "✨ Повысьте свой уровень доступа!",
"PermissionWarningMessage": "Ваша текущая роль имеет ограниченные права. Чтобы разблокировать эту и другие продвинутые функции, попросите администратора вашего пространства повысить ваш уровень доступа!",
"Icon": "Иконка",
"Color": "Цвет",
"AutomationOnly": "Только автоматизация"
+5 -2
View File
@@ -136,8 +136,11 @@
"Loading": "加载中...",
"NoRelations": "无关系",
"ReadOnlyWarningTitle": "✨ 访客演示",
"ReadOnlyWarningMessage": "您处于只读模式。想在Huly管理任何项目吗?任务、截止日期、团队 — 立即注册获取完整权限!",
"ReadOnlySignUp": "注册 \uD83D\uDE80",
"ReadOnlyWarningMessage": "您处于只读模式。想在Huly管理任何项目吗?任务、截止日期、团队 — 加入此工作区协作或创建您自己的空间!",
"ReadOnlyJoinWorkspace": "加入工作区 👥",
"ReadOnlySignUp": "创建账户 🚀",
"PermissionWarningTitle": "✨ 提升您的访问权限!",
"PermissionWarningMessage": "您当前角色的权限有限。要解锁此功能和其他高级功能,请让您的工作区管理员升级您的访问权限!",
"Icon": "图标",
"Color": "颜色",
"AutomationOnly": "仅限自动化"
@@ -0,0 +1,12 @@
<script lang="ts">
import { Notification, NotificationToast } from '@hcengineering/ui'
export let onRemove: () => void
export let notification: Notification
</script>
<NotificationToast title={notification.title} severity={notification.severity} onClose={onRemove}>
<svelte:fragment slot="content">
{notification.subTitle}
</svelte:fragment>
</NotificationToast>
@@ -1,9 +1,14 @@
<script lang="ts">
import { Button, Notification, NotificationToast } from '@hcengineering/ui'
import { Button, navigate, Notification, NotificationToast } from '@hcengineering/ui'
import view from '@hcengineering/view'
import { getCurrentWorkspaceUrl } from '@hcengineering/presentation'
export let onRemove: () => void
export let notification: Notification
function joinWorkspace (e: MouseEvent): void {
navigate({ path: ['login', 'join'], query: { workspace: getCurrentWorkspaceUrl() } })
}
</script>
<NotificationToast title={notification.title} severity={notification.severity} onClose={onRemove}>
@@ -11,7 +16,7 @@
{notification.subTitle}
</svelte:fragment>
<svelte:fragment slot="buttons">
<div style="width: auto" />
<Button label={view.string.ReadOnlyJoinWorkspace} stopPropagation={false} on:click={joinWorkspace} />
<a href="https://huly.io/signup" target="_blank">
<Button label={view.string.ReadOnlySignUp} stopPropagation={false} />
</a>
+20 -2
View File
@@ -20,12 +20,13 @@ import core, {
TxProcessor,
type TxResult
} from '@hcengineering/core'
import { getResource, translate } from '@hcengineering/platform'
import platform, { getResource, PlatformError, translate } from '@hcengineering/platform'
import { BasePresentationMiddleware, type PresentationMiddleware } from '@hcengineering/presentation'
import view, { type IAggregationManager } from '@hcengineering/view'
import notification from '@hcengineering/notification'
import { addNotification, NotificationSeverity } from '@hcengineering/ui'
import ReadOnlyNotification from './components/ReadOnlyNotification.svelte'
import ForbiddenNotification from './components/ForbiddenNotification.svelte'
import { getCurrentLanguage } from '@hcengineering/theme'
/**
@@ -361,6 +362,23 @@ export class ReadOnlyAccessMiddleware extends BasePresentationMiddleware impleme
)
return {}
}
return await this.provideTx(tx)
try {
return await this.provideTx(tx)
} catch (err: any) {
if (err instanceof PlatformError && err.status.code === platform.status.Forbidden) {
addNotification(
await translate(view.string.PermissionWarningTitle, {}, getCurrentLanguage()),
await translate(view.string.PermissionWarningMessage, {}, getCurrentLanguage()),
ForbiddenNotification,
{
onClose: () => {}
},
NotificationSeverity.Info
)
return {}
} else {
throw err
}
}
}
}
+3
View File
@@ -238,6 +238,9 @@ const view = plugin(viewId, {
ReadOnlyWarningTitle: '' as IntlString,
ReadOnlyWarningMessage: '' as IntlString,
ReadOnlySignUp: '' as IntlString,
ReadOnlyJoinWorkspace: '' as IntlString,
PermissionWarningTitle: '' as IntlString,
PermissionWarningMessage: '' as IntlString,
Icon: '' as IntlString,
Color: '' as IntlString,
AutomationOnly: '' as IntlString
@@ -407,6 +407,8 @@ export async function connect (title: string): Promise<Client | undefined> {
if (me.role === AccountRole.ReadOnlyGuest) {
await broadcastEvent(PlatformEvent, new Status(Severity.INFO, platform.status.ReadOnlyAccount, {}))
} else {
await broadcastEvent(PlatformEvent, new Status(Severity.INFO, platform.status.RegularAccount, {}))
}
try {
+48 -4
View File
@@ -42,13 +42,15 @@ import core, {
type Space,
SpaceType,
systemAccountUuid,
readOnlyGuestAccountUuid,
Tx,
TxCreateDoc,
TxCUD,
TxMixin,
TxRemoveDoc,
TxUpdateDoc,
TypedSpace
TypedSpace,
TxFactory
} from '@hcengineering/core'
import { getMetadata } from '@hcengineering/platform'
import { makeRank } from '@hcengineering/rank'
@@ -92,6 +94,10 @@ export async function OnSpaceTypeMembers (txes: Tx[], control: TriggerControl):
export async function OnEmployeeCreate (_txes: Tx[], control: TriggerControl): Promise<Tx[]> {
const result: Tx[] = []
const systemTxFactory = new TxFactory(core.account.System, false)
const systemTxes: Tx[] = []
for (const tx of _txes) {
const mixinTx = tx as TxMixin<Person, Employee>
if (mixinTx.attributes.active !== true) continue
@@ -104,21 +110,59 @@ export async function OnEmployeeCreate (_txes: Tx[], control: TriggerControl): P
result.push(...txes)
const emp = control.hierarchy.as(person, contact.mixin.Employee)
if (emp.role === 'GUEST') continue
if (emp.role === 'GUEST') {
const readonlyEmployees = await control.findAll(control.ctx, contact.mixin.Employee, {
personUuid: readOnlyGuestAccountUuid
})
if (readonlyEmployees.length === 0) continue
const readonlyEmployee = readonlyEmployees[0]
if (!readonlyEmployee.active) continue
const spaces = await control.findAll(control.ctx, core.class.Space, { members: readOnlyGuestAccountUuid })
for (const space of spaces) {
if (space._class === contact.class.PersonSpace || space.members.includes(account)) continue
const pushTx = systemTxFactory.createTxUpdateDoc(space._class, space.space, space._id, {
$push: {
members: account
}
})
systemTxes.push(pushTx)
}
const collabs = await control.findAll(control.ctx, core.class.Collaborator, {
collaborator: readOnlyGuestAccountUuid
})
for (const collab of collabs) {
const pushTx = systemTxFactory.createTxCreateDoc(core.class.Collaborator, collab.space, {
attachedTo: collab.attachedTo,
collaborator: account,
attachedToClass: collab.attachedToClass,
collection: 'collaborators'
})
systemTxes.push(pushTx)
}
continue
}
const spaces = await control.findAll(control.ctx, core.class.Space, { autoJoin: true })
for (const space of spaces) {
if (space.members.includes(account)) continue
const pushTx = control.txFactory.createTxUpdateDoc(space._class, space.space, space._id, {
const pushTx = systemTxFactory.createTxUpdateDoc(space._class, space.space, space._id, {
$push: {
members: account
}
})
result.push(pushTx)
systemTxes.push(pushTx)
}
}
await control.apply(control.ctx, systemTxes)
const account = control.ctx.contextData.account
if (account.role !== AccountRole.Owner) return result
@@ -35,6 +35,7 @@ import core, {
MixinUpdate,
notEmpty,
PersonId,
readOnlyGuestAccountUuid,
Ref,
RefTo,
SortingOrder,
@@ -358,7 +359,7 @@ export async function pushInboxNotifications (
const notificationData = {
user: receiver.account,
isViewed: receiver.role === 'GUEST',
isViewed: receiver.role === 'GUEST' && receiver.account === readOnlyGuestAccountUuid,
docNotifyContext: docNotifyContextId,
archived: false,
objectId,
@@ -538,7 +539,7 @@ async function createNotifyContext (
}
const lastViewedTimestamp =
receiver.role === 'GUEST'
receiver.role === 'GUEST' && receiver.account === readOnlyGuestAccountUuid
? Number.MAX_VALUE
: receiver.socialIds.some((it) => it === sender)
? updateTimestamp
@@ -14,6 +14,7 @@
//
import {
readOnlyGuestAccountUuid,
AccountRole,
type PersonId,
SocialIdType,
@@ -965,7 +966,7 @@ describe('invite operations', () => {
describe('loginAsGuest', () => {
test('should successfully login as readonly guest', async () => {
const mockGuestPerson = {
uuid: utils.READONLY_GUEST_ACCOUNT
uuid: readOnlyGuestAccountUuid
}
;(mockDb.person.findOne as jest.Mock).mockResolvedValue(mockGuestPerson)
@@ -973,7 +974,7 @@ describe('invite operations', () => {
const result = await loginAsGuest(mockCtx, mockDb, mockBranding, mockToken)
expect(result).toEqual({
account: utils.READONLY_GUEST_ACCOUNT,
account: readOnlyGuestAccountUuid,
token: expect.any(String)
})
})
+36 -48
View File
@@ -30,6 +30,7 @@ import {
type PersonUuid,
SocialIdType,
systemAccountUuid,
readOnlyGuestAccountUuid,
type WorkspaceMemberInfo,
type WorkspaceUuid
} from '@hcengineering/core'
@@ -100,11 +101,11 @@ import {
verifyPassword,
wrap,
updateAllowReadOnlyGuests,
READONLY_GUEST_ACCOUNT,
getWorkspaceByDataId,
assignableRoles,
getWorkspacesInfoWithStatusByIds,
doMergePersons
doMergePersons,
getWorkspaceJoinInfo
} from './utils'
// Note: it is IMPORTANT to always destructure params passed here to avoid sending extra params
@@ -127,7 +128,7 @@ export async function loginAsGuest (
branding: Branding | null,
token: string
): Promise<LoginInfo> {
const guestPerson = await db.person.findOne({ uuid: READONLY_GUEST_ACCOUNT as PersonUuid })
const guestPerson = await db.person.findOne({ uuid: readOnlyGuestAccountUuid as PersonUuid })
if (guestPerson == null) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, {}))
}
@@ -811,31 +812,24 @@ export async function join (
email: string
password: string
inviteId: string
workspaceUrl: string
},
meta?: Meta
): Promise<WorkspaceLoginInfo | LoginInfo> {
const { email, password, inviteId } = params
const { email, password, inviteId, workspaceUrl } = params
if (email == null || email === '' || password == null || password === '' || inviteId == null || inviteId === '') {
if (password == null || password === '') {
throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {}))
}
const normalizedEmail = cleanEmail(email)
const invite = await getWorkspaceInvite(db, inviteId)
if (invite == null) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
}
const workspaceJoinInfo = await getWorkspaceJoinInfo(ctx, db, email, inviteId, workspaceUrl)
ctx.info('Joining a workspace using invite', {
email,
normalizedEmail: workspaceJoinInfo.email,
...workspaceJoinInfo.invite
})
const workspaceUuid = await checkInvite(ctx, invite, normalizedEmail)
const workspace = await getWorkspaceById(db, workspaceUuid)
if (workspace == null) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.WorkspaceNotFound, { workspaceUuid }))
}
ctx.info('Joining a workspace using invite', { email, normalizedEmail, ...invite })
const { token, account } = await login(ctx, db, branding, _token, { email: normalizedEmail, password })
const { token, account } = await login(ctx, db, branding, _token, { email: workspaceJoinInfo.email, password })
if (token == null) {
return {
@@ -843,7 +837,7 @@ export async function join (
}
}
return await doJoinByInvite(ctx, db, branding, token, account, workspace, invite)
return await doJoinByInvite(ctx, db, branding, token, account, workspaceJoinInfo.workspace, workspaceJoinInfo.invite)
}
/**
@@ -1022,43 +1016,37 @@ export async function signUpJoin (
first: string
last?: string
inviteId: string
workspaceUrl: string
},
meta?: Meta
): Promise<WorkspaceLoginInfo> {
const { email, password, first, last, inviteId } = params
const { email, password, first, last, inviteId, workspaceUrl } = params
if (
email == null ||
email === '' ||
password == null ||
password === '' ||
first == null ||
first === '' ||
inviteId == null ||
inviteId === ''
) {
if (password == null || password === '' || first == null || first === '') {
throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {}))
}
const normalizedEmail = cleanEmail(email)
ctx.info('Signing up and joining a workspace using invite', { email, normalizedEmail, first, last, inviteId })
const invite = await getWorkspaceInvite(db, inviteId)
if (invite == null) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
}
const workspaceUuid = await checkInvite(ctx, invite, normalizedEmail)
const workspace = await getWorkspaceById(db, workspaceUuid)
if (workspace == null) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.WorkspaceNotFound, { workspaceUuid }))
}
const workspaceJoinInfo = await getWorkspaceJoinInfo(ctx, db, email, inviteId, workspaceUrl)
ctx.info('Signing up and joining a workspace using invite', {
email,
normalizedEmail: workspaceJoinInfo.email,
first,
last,
inviteId
})
const { account } = await signUpByEmail(ctx, db, branding, email, password, first, last ?? '', true)
void setTimezoneIfNotDefined(ctx, db, account, null, meta)
return await doJoinByInvite(ctx, db, branding, generateToken(account, workspaceUuid), account, workspace, invite)
return await doJoinByInvite(
ctx,
db,
branding,
generateToken(account, workspaceJoinInfo.workspace?.uuid),
account,
workspaceJoinInfo.workspace,
workspaceJoinInfo.invite
)
}
export async function confirm (
@@ -1743,7 +1731,7 @@ export async function isReadOnlyGuest (
token: string
): Promise<boolean> {
const { account } = decodeTokenVerbose(ctx, token)
return account === READONLY_GUEST_ACCOUNT
return account === readOnlyGuestAccountUuid
}
export async function getPerson (
+3 -3
View File
@@ -26,7 +26,8 @@ import {
type PersonId,
type PersonUuid,
type WorkspaceUuid,
type AccountUuid
type AccountUuid,
readOnlyGuestAccountUuid
} from '@hcengineering/core'
import platform, { getMetadata, PlatformError, Severity, Status, unknownError } from '@hcengineering/platform'
import { decodeTokenVerbose } from '@hcengineering/server-token'
@@ -67,7 +68,6 @@ import {
getPersonName,
doMergeAccounts,
doMergePersons,
READONLY_GUEST_ACCOUNT,
assignableRoles
} from './utils'
@@ -636,7 +636,7 @@ export async function createIntegration (
const { socialId, kind, workspaceUuid, data } = params
const social = await db.socialId.findOne({ _id: socialId })
if (social?.personUuid === READONLY_GUEST_ACCOUNT) {
if (social?.personUuid === readOnlyGuestAccountUuid) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
}
+6
View File
@@ -136,6 +136,12 @@ export interface WorkspaceInvite {
autoJoin?: boolean
}
export interface WorkspaceJoinInfo {
email: string
workspace: Workspace
invite?: WorkspaceInvite | null
}
export interface Mailbox {
accountUuid: PersonUuid
mailbox: string
+75 -23
View File
@@ -24,14 +24,15 @@ import {
type Person,
type PersonId,
type PersonUuid,
readOnlyGuestAccountUuid,
roleOrder,
SocialIdType,
type SocialKey,
systemAccountUuid,
type WorkspaceDataId,
type WorkspaceInfoWithStatus as WorkspaceInfoWithStatusCore,
type WorkspaceMode,
type WorkspaceUuid,
type WorkspaceDataId
type WorkspaceUuid
} from '@hcengineering/core'
import { getMongoClient } from '@hcengineering/mongo' // TODO: get rid of this import later
import platform, { getMetadata, PlatformError, Severity, Status, translate } from '@hcengineering/platform'
@@ -59,13 +60,13 @@ import {
type Workspace,
type WorkspaceInfoWithStatus,
type WorkspaceInvite,
type WorkspaceJoinInfo,
type WorkspaceLoginInfo,
type WorkspaceStatus
} from './types'
import { isAdminEmail } from './admin'
export const GUEST_ACCOUNT = 'b6996120-416f-49cd-841e-e4a5d2e49c9b'
export const READONLY_GUEST_ACCOUNT = '83bbed9a-0867-4851-be32-31d49d1d42ce'
export async function getAccountDB (
uri: string,
@@ -574,7 +575,7 @@ export async function selectWorkspace (
extra = decodedToken.extra
} catch (e) {
if (workspace?.allowReadOnlyGuest === true) {
accountUuid = READONLY_GUEST_ACCOUNT as AccountUuid
accountUuid = readOnlyGuestAccountUuid
} else {
throw e
}
@@ -635,7 +636,7 @@ export async function selectWorkspace (
let account = await db.account.findOne({ uuid: accountUuid })
if ((role == null || account == null) && workspace.allowReadOnlyGuest) {
accountUuid = READONLY_GUEST_ACCOUNT as AccountUuid
accountUuid = readOnlyGuestAccountUuid
role = await db.getWorkspaceRole(accountUuid, workspace.uuid)
account = await db.account.findOne({ uuid: accountUuid })
}
@@ -709,27 +710,31 @@ export async function updateAllowReadOnlyGuests (
await db.updateAllowReadOnlyGuests(workspace, readOnlyGuestsAllowed)
if (!readOnlyGuestsAllowed) {
await db.unassignWorkspace(READONLY_GUEST_ACCOUNT as AccountUuid, workspace)
await db.unassignWorkspace(readOnlyGuestAccountUuid, workspace)
return undefined
}
let guestPerson = await db.person.findOne({ uuid: READONLY_GUEST_ACCOUNT as PersonUuid })
let guestPerson = await db.person.findOne({ uuid: readOnlyGuestAccountUuid as PersonUuid })
if (guestPerson == null) {
await db.person.insertOne({ uuid: READONLY_GUEST_ACCOUNT as PersonUuid, firstName: 'Anonymous', lastName: 'Guest' })
await createAccount(db, READONLY_GUEST_ACCOUNT as PersonUuid, true)
guestPerson = await db.person.findOne({ uuid: READONLY_GUEST_ACCOUNT as PersonUuid })
await db.person.insertOne({
uuid: readOnlyGuestAccountUuid as PersonUuid,
firstName: 'Anonymous',
lastName: 'Guest'
})
await createAccount(db, readOnlyGuestAccountUuid as PersonUuid, true)
guestPerson = await db.person.findOne({ uuid: readOnlyGuestAccountUuid as PersonUuid })
}
const roleInWorkspace = await db.getWorkspaceRole(READONLY_GUEST_ACCOUNT as AccountUuid, workspace)
const roleInWorkspace = await db.getWorkspaceRole(readOnlyGuestAccountUuid, workspace)
if (roleInWorkspace == null) {
await db.assignWorkspace(READONLY_GUEST_ACCOUNT as AccountUuid, workspace, AccountRole.ReadOnlyGuest)
await db.assignWorkspace(readOnlyGuestAccountUuid, workspace, AccountRole.ReadOnlyGuest)
}
const guestAccount = await db.account.findOne({ uuid: READONLY_GUEST_ACCOUNT as AccountUuid })
const guestAccount = await db.account.findOne({ uuid: readOnlyGuestAccountUuid })
if (guestPerson === null || guestAccount == null) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.InternalServerError, {}))
}
const guestSocialIds = await db.socialId.find({
personUuid: READONLY_GUEST_ACCOUNT as PersonUuid,
personUuid: readOnlyGuestAccountUuid as PersonUuid,
verifiedOn: { $gt: 0 }
})
@@ -748,7 +753,7 @@ export async function updateWorkspaceRole (
): Promise<void> {
const { targetAccount, targetRole } = params
if (targetAccount === READONLY_GUEST_ACCOUNT) {
if (targetAccount === readOnlyGuestAccountUuid) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
}
@@ -1157,6 +1162,46 @@ export async function updateArchiveInfo (
)
}
export async function getWorkspaceJoinInfo (
ctx: MeasureContext,
db: AccountDB,
email: string,
inviteId: string,
workspaceUrl: string
): Promise<WorkspaceJoinInfo> {
if (email === undefined || email === '' || email === null) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {}))
}
const normalizedEmail = cleanEmail(email)
if (inviteId !== undefined && inviteId !== '' && inviteId !== null) {
const invite = await getWorkspaceInvite(db, inviteId)
if (invite == null) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
}
const workspaceUuid = await checkInvite(ctx, invite, normalizedEmail)
const workspace = await getWorkspaceById(db, workspaceUuid)
if (workspace == null) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.WorkspaceNotFound, { workspaceUuid }))
}
return {
email,
invite,
workspace
}
}
if (workspaceUrl !== undefined && workspaceUrl !== '' && workspaceUrl !== null) {
const workspace = await getWorkspaceByUrl(db, workspaceUrl)
if (workspace == null || !workspace.allowReadOnlyGuest) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
}
return {
email,
workspace
}
}
throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {}))
}
export async function doJoinByInvite (
ctx: MeasureContext,
db: AccountDB,
@@ -1164,21 +1209,28 @@ export async function doJoinByInvite (
token: string,
account: AccountUuid,
workspace: Workspace,
invite: WorkspaceInvite
invite: WorkspaceInvite | null | undefined
): Promise<WorkspaceLoginInfo> {
const role = await db.getWorkspaceRole(account, workspace.uuid)
// TODO: should we re-join kicked users? How are they marked as inactive?
if (role == null) {
await db.assignWorkspace(account, workspace.uuid, invite.role)
} else if (getRolePower(role) < getRolePower(invite.role)) {
await db.updateWorkspaceRole(account, workspace.uuid, invite.role)
if (invite !== undefined && invite != null) {
// TODO: should we re-join kicked users? How are they marked as inactive?
if (role == null) {
await db.assignWorkspace(account, workspace.uuid, invite.role)
} else if (getRolePower(role) < getRolePower(invite.role)) {
await db.updateWorkspaceRole(account, workspace.uuid, invite.role)
}
await useInvite(db, invite.id)
} else if (workspace.allowReadOnlyGuest) {
if (role == null) {
await db.assignWorkspace(account, workspace.uuid, AccountRole.Guest)
}
} else {
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
}
const result = await selectWorkspace(ctx, db, branding, token, { workspaceUrl: workspace.url, kind: 'external' })
await useInvite(db, invite.id)
ctx.info('Successfully joined a workspace using invite', {
account,
workspaceUuid: workspace.uuid,
+110
View File
@@ -0,0 +1,110 @@
import {
BaseMiddleware,
type Middleware,
type PipelineContext,
type TxMiddlewareResult
} from '@hcengineering/server-core'
import core, {
AccountRole,
type Doc,
hasAccountRole,
type MeasureContext,
type SessionData,
type Space,
type Tx,
type TxApplyIf,
type TxCUD,
TxProcessor,
type TxUpdateDoc
} from '@hcengineering/core'
import platform, { PlatformError, Severity, Status } from '@hcengineering/platform'
export class GuestPermissionsMiddleware extends BaseMiddleware implements Middleware {
static async create (
ctx: MeasureContext,
context: PipelineContext,
next: Middleware | undefined
): Promise<GuestPermissionsMiddleware> {
return new GuestPermissionsMiddleware(context, next)
}
async tx (ctx: MeasureContext<SessionData>, txes: Tx[]): Promise<TxMiddlewareResult> {
const account = ctx.contextData.account
if (hasAccountRole(account, AccountRole.User)) {
return await this.provideTx(ctx, txes)
}
if (account.role === AccountRole.DocGuest || account.role === AccountRole.ReadOnlyGuest) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
}
for (const tx of txes) {
this.processTx(ctx, tx)
}
return await this.provideTx(ctx, txes)
}
private processTx (ctx: MeasureContext<SessionData>, tx: Tx): void {
const h = this.context.hierarchy
if (tx._class === core.class.TxApplyIf) {
const applyTx = tx as TxApplyIf
for (const t of applyTx.txes) {
this.processTx(ctx, t)
}
return
}
if (TxProcessor.isExtendsCUD(tx._class)) {
const cudTx = tx as TxCUD<Doc>
const isSpace = h.isDerived(cudTx.objectClass, core.class.Space)
if (isSpace) {
if (this.isForbiddenSpaceTx(cudTx as TxCUD<Space>)) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
}
} else if (cudTx.space !== core.space.DerivedTx && this.isForbiddenTx(cudTx)) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
}
}
}
private isForbiddenTx (tx: TxCUD<Doc>): boolean {
if (tx._class === core.class.TxCreateDoc) return false
if (tx._class === core.class.TxMixin) return false
return !this.hasMixinAccessLevel(tx)
}
private isForbiddenSpaceTx (tx: TxCUD<Space>): boolean {
if (tx._class === core.class.TxRemoveDoc) return true
if (tx._class === core.class.TxCreateDoc) {
return !this.hasMixinAccessLevel(tx)
}
if (tx._class === core.class.TxUpdateDoc) {
const updateTx = tx as TxUpdateDoc<Space>
const ops = updateTx.operations
const keys = ['members', 'private', 'archived', 'owners', 'autoJoin']
if (keys.some((key) => (ops as any)[key] !== undefined)) {
return true
}
if (ops.$push !== undefined || ops.$pull !== undefined) {
return true
}
}
return false
}
private hasMixinAccessLevel (tx: TxCUD<Doc>): boolean {
const h = this.context.hierarchy
const accessLevelMixin = h.classHierarchyMixin(tx.objectClass, core.mixin.TxAccessLevel)
if (accessLevelMixin === undefined) return false
if (tx._class === core.class.TxCreateDoc) {
return accessLevelMixin.createAccessLevel === AccountRole.Guest
}
if (tx._class === core.class.TxRemoveDoc) {
return accessLevelMixin.removeAccessLevel === AccountRole.Guest
}
if (tx._class === core.class.TxUpdateDoc) {
return accessLevelMixin.updateAccessLevel === AccountRole.Guest
}
return false
}
}
+1
View File
@@ -30,6 +30,7 @@ export * from './model'
export * from './modified'
export * from './private'
export * from './queryJoin'
export * from './guestPermissions'
export * from './spacePermissions'
export * from './spaceSecurity'
export * from './triggers'
+1 -7
View File
@@ -32,8 +32,7 @@ import core, {
type TypedSpace,
type MeasureContext,
type SessionData,
type AccountUuid,
AccountRole
type AccountUuid
} from '@hcengineering/core'
import platform, { PlatformError, Severity, Status } from '@hcengineering/platform'
import { type Middleware, type TxMiddlewareResult, type PipelineContext } from '@hcengineering/server-core'
@@ -338,11 +337,6 @@ export class SpacePermissionsMiddleware extends BaseMiddleware implements Middle
}
protected checkPermissions (ctx: MeasureContext, tx: Tx): void {
const account = ctx.contextData.account
if (account.role === AccountRole.ReadOnlyGuest) {
this.throwForbidden()
}
if (tx._class === core.class.TxApplyIf) {
const applyTx = tx as TxApplyIf
+13 -40
View File
@@ -18,11 +18,14 @@ import core, {
type AccountUuid,
type AttachedDoc,
type Class,
DOMAIN_MODEL,
clone,
type Collaborator,
type Doc,
type DocumentQuery,
type Domain,
DOMAIN_MODEL,
type FindResult,
generateId,
type LookupData,
type MeasureContext,
type ObjQueryType,
@@ -32,32 +35,29 @@ import core, {
type SearchOptions,
type SearchQuery,
type SearchResult,
type SessionData,
shouldShowArchived,
type Space,
systemAccountUuid,
toFindResult,
type Tx,
type TxCUD,
type TxCreateDoc,
type TxCUD,
TxProcessor,
type TxRemoveDoc,
type TxUpdateDoc,
type TxWorkspaceEvent,
WorkspaceEvent,
clone,
generateId,
shouldShowArchived,
systemAccountUuid,
toFindResult,
type SessionData,
type Collaborator
WorkspaceEvent
} from '@hcengineering/core'
import platform, { PlatformError, Severity, Status } from '@hcengineering/platform'
import {
BaseMiddleware,
type Middleware,
type PipelineContext,
type ServerFindOptions,
type TxMiddlewareResult,
type PipelineContext
type TxMiddlewareResult
} from '@hcengineering/server-core'
import { isOwner, isSystem } from './utils'
type SpaceWithMembers = Pick<Space, '_id' | 'members' | 'private' | '_class' | 'archived'>
/**
@@ -404,35 +404,12 @@ export class SpaceSecurityMiddleware extends BaseMiddleware implements Middlewar
}
}
private isForbiddenGuestTx (tx: TxCUD<Space>): boolean {
if (tx._class === core.class.TxRemoveDoc) return true
if (tx._class === core.class.TxCreateDoc) return false
if (tx._class === core.class.TxUpdateDoc) {
const updateTx = tx as TxUpdateDoc<Space>
const ops = updateTx.operations
const keys = ['members', 'private', 'archived', 'owners', 'autoJoin']
if (keys.some((key) => (ops as any)[key] !== undefined)) {
return true
}
if (ops.$push !== undefined || ops.$pull !== undefined) {
return true
}
}
return false
}
private async processTx (ctx: MeasureContext<SessionData>, tx: Tx): Promise<void> {
const h = this.context.hierarchy
if (TxProcessor.isExtendsCUD(tx._class)) {
const cudTx = tx as TxCUD<Doc>
const isSpace = h.isDerived(cudTx.objectClass, core.class.Space)
if (isSpace) {
const account = ctx.contextData.account
if (account.role === AccountRole.Guest) {
if (this.isForbiddenGuestTx(cudTx as TxCUD<Space>)) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
}
}
await this.handleTx(ctx, cudTx as TxCUD<Space>)
} else {
await this.handeCollaborator(ctx, cudTx as TxCUD<Collaborator>)
@@ -448,10 +425,6 @@ export class SpaceSecurityMiddleware extends BaseMiddleware implements Middlewar
async tx (ctx: MeasureContext<SessionData>, txes: Tx[]): Promise<TxMiddlewareResult> {
await this.init(ctx)
const account = ctx.contextData.account
if (account.role === AccountRole.DocGuest) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
}
const processed = new Set<Ref<Tx>>()
ctx.contextData.contextCache.set('processed', processed)
for (const tx of txes) {
+3 -1
View File
@@ -39,7 +39,8 @@ import {
SpaceSecurityMiddleware,
TriggersMiddleware,
TxMiddleware,
UserStatusMiddleware
UserStatusMiddleware,
GuestPermissionsMiddleware
} from '@hcengineering/middleware'
import {
createBenchmarkAdapter,
@@ -125,6 +126,7 @@ export function createServerPipeline (
(ctx: MeasureContext, context: PipelineContext, next?: Middleware) =>
SpaceSecurityMiddleware.create(opt.adapterSecurity ?? false, ctx, context, next),
SpacePermissionsMiddleware.create,
GuestPermissionsMiddleware.create,
ConfigurationMiddleware.create,
ContextNameMiddleware.create,
MarkDerivedEntryMiddleware.create,