Add a separate setting to control if guests can join WS (#9540)

Signed-off-by: Anton Alexeyev <alexeyev.anton@gmail.com>
This commit is contained in:
Anton Alexeyev
2025-07-14 12:36:46 +07:00
committed by GitHub
parent 9f3606424e
commit 446a2f9dda
26 changed files with 117 additions and 8 deletions
+10
View File
@@ -117,6 +117,7 @@ export interface AccountClient {
updateAllowReadOnlyGuests: (
readOnlyGuestsAllowed: boolean
) => Promise<{ guestPerson: Person, guestSocialIds: SocialId[] } | undefined>
updateAllowGuestSignUp: (guestSignUpAllowed: boolean) => Promise<void>
updateWorkspaceName: (name: string) => Promise<void>
deleteWorkspace: () => Promise<void>
findPersonBySocialKey: (socialKey: string, requireAccount?: boolean) => Promise<PersonUuid | undefined>
@@ -659,6 +660,15 @@ class AccountClientImpl implements AccountClient {
return await this.rpc(request)
}
async updateAllowGuestSignUp (guestSignUpAllowed: boolean): Promise<void> {
const request = {
method: 'updateAllowGuestSignUp' as const,
params: { guestSignUpAllowed }
}
await this.rpc(request)
}
async getWorkspaceMembers (): Promise<WorkspaceMemberInfo[]> {
const request = {
method: 'getWorkspaceMembers' as const,
+2 -1
View File
@@ -57,6 +57,7 @@ export interface WorkspaceLoginInfo extends LoginInfo {
endpoint: string
token: string
role: AccountRole
allowGuestSignUp?: boolean
}
export interface WorkspaceInviteInfo {
@@ -122,5 +123,5 @@ export interface AccountAggregatedInfo extends AccountInfo, Person {
uuid: AccountUuid
integrations: Omit<Integration, 'data'>[]
socialIds: SocialId[]
workspaces: Omit<WorkspaceInfo, 'allowReadOnlyGuest'>[]
workspaces: Omit<WorkspaceInfo, 'allowReadOnlyGuest' | 'allowGuestSignUp'>[]
}
+1
View File
@@ -832,6 +832,7 @@ export interface WorkspaceInfo {
createdBy?: PersonUuid // Should always be set for NEW workspaces
billingAccount?: PersonUuid // Should always be set for NEW workspaces
allowReadOnlyGuest?: boolean // Should always be set for NEW workspaces
allowGuestSignUp?: boolean // Should always be set for NEW workspaces
}
export interface BackupStatus {
+1
View File
@@ -169,6 +169,7 @@
"Customize": "Přizpůsobit",
"GuestAccess": "Anonymní hosté",
"GuestAccessDescription": "Umožňuje anonymním uživatelům prohlížet pracovní prostor v režimu pouze pro čtení",
"GuestSignUpDescription": "Umožňuje anonymním uživatelům připojit se k vašemu pracovnímu prostoru jako hosté s omezenými editačními právy",
"ManageIdentities": "Spravovat identity",
"Release": "Uvolnění",
"ReleaseSocialId": "Uvolnit sociální ID",
+1
View File
@@ -169,6 +169,7 @@
"Customize": "Anpassen",
"GuestAccess": "Anonyme Gäste",
"GuestAccessDescription": "Ermöglicht anonymen Benutzern den schreibgeschützten Zugriff auf den Arbeitsbereich",
"GuestSignUpDescription": "Ermöglicht anonymen Benutzern den Beitritt zu Ihrem Arbeitsbereich als Gäste mit eingeschränkten Bearbeitungsrechten",
"ManageIdentities": "Identitäten verwalten",
"Release": "Freigeben",
"ReleaseSocialId": "Social ID freigeben",
+1
View File
@@ -169,6 +169,7 @@
"Customize": "Customize",
"GuestAccess": "Anonymous guests",
"GuestAccessDescription": "Allows anonymous users to visit your workspace in read-only mode",
"GuestSignUpDescription": "Allows anonymous users to join your workspace as guests with limited editing rights",
"ManageIdentities": "Manage identities",
"Release": "Release",
"ReleaseSocialId": "Release social ID",
+1
View File
@@ -160,6 +160,7 @@
"Customize": "Personalizar",
"GuestAccess": "Invitados anónimos",
"GuestAccessDescription": "Permite a usuarios anónimos visitar el espacio de trabajo en modo de solo lectura",
"GuestSignUpDescription": "Permite a usuarios anónimos unirse a su espacio de trabajo como invitados con derechos de edición limitados",
"ManageIdentities": "Gestionar identidades",
"Release": "Liberar",
"ReleaseSocialId": "Liberar ID social",
+1
View File
@@ -169,6 +169,7 @@
"Customize": "Personnaliser",
"GuestAccess": "Invités anonymes",
"GuestAccessDescription": "Autorise les utilisateurs anonymes à accéder à l'espace de travail en lecture seule",
"GuestSignUpDescription": "Permet aux utilisateurs anonymes de rejoindre votre espace de travail en tant qu'invités avec des droits d'édition limités",
"ManageIdentities": "Gérer les identités",
"Release": "Libération",
"ReleaseSocialId": "Libérer l'ID social",
+1
View File
@@ -169,6 +169,7 @@
"Customize": "Personalizzare",
"GuestAccess": "Ospiti anonimi",
"GuestAccessDescription": "Consente agli utenti anonimi di accedere all'area di lavoro in modalità sola lettura",
"GuestSignUpDescription": "Consente agli utenti anonimi di unirsi al tuo spazio di lavoro come ospiti con diritti di modifica limitati",
"ManageIdentities": "Gestisci identità",
"Release": "Rilascio",
"ReleaseSocialId": "Rilascia ID social",
+1
View File
@@ -169,6 +169,7 @@
"Customize": "カスタマイズ",
"GuestAccess": "匿名ゲスト",
"GuestAccessDescription": "匿名ユーザーがワークスペースを読み取り専用モードで閲覧できます",
"GuestSignUpDescription": "匿名ユーザーがゲストとしてワークスペースに参加し、制限された編集権限を得られるようにします",
"ManageIdentities": "IDを管理",
"Release": "リリース",
"ReleaseSocialId": "ソーシャルIDをリリース",
+1
View File
@@ -160,6 +160,7 @@
"Customize": "Personalizar",
"GuestAccess": "Convidados anônimos",
"GuestAccessDescription": "Permite que usuários anônimos acessem seu espaço de trabalho em modo somente leitura",
"GuestSignUpDescription": "Permite que usuários anônimos entrem em seu espaço de trabalho como convidados com direitos de edição limitados",
"ManageIdentities": "Gerenciar identidades",
"Release": "Libertação",
"ReleaseSocialId": "Liberar ID social",
+1
View File
@@ -169,6 +169,7 @@
"Customize": "Настроить",
"GuestAccess": "Анонимные гости",
"GuestAccessDescription": "Позволяет анонимным пользователям просматривать рабочее пространство в режиме только для чтения",
"GuestSignUpDescription": "Позволяет анонимным пользователям присоединяться к вашему рабочему пространству в качестве гостей с ограниченными правами редактирования",
"ManageIdentities": "Управление идентификаторами",
"Release": "Освободить",
"ReleaseSocialId": "Освободить социальный ID",
+1
View File
@@ -169,6 +169,7 @@
"Customize": "自定义",
"GuestAccess": "匿名访客",
"GuestAccessDescription": "允许匿名用户以只读模式访问工作区",
"GuestSignUpDescription": "允许匿名用户以访客身份加入您的工作区,获得有限的编辑权限",
"ManageIdentities": "管理身份",
"Release": "释放",
"ReleaseSocialId": "释放社交ID",
@@ -58,6 +58,7 @@
let oldName: string
let name: string = ''
let allowReadOnlyGuests: boolean
let allowGuestSignUp: boolean
const accountClient = getAccountClient()
const disabledSet = ['\n', '<', '>', '/', '\\']
@@ -77,6 +78,7 @@
oldName = res.name
name = oldName
allowReadOnlyGuests = res.allowReadOnlyGuest ?? false
allowGuestSignUp = res.allowGuestSignUp ?? false
loading = false
}
@@ -155,6 +157,7 @@
async function handleToggleReadonlyAccess (e: CustomEvent<boolean>): Promise<void> {
const enabled = e.detail
const guestUserInfo = await accountClient.updateAllowReadOnlyGuests(enabled)
allowReadOnlyGuests = enabled
if (guestUserInfo !== undefined) {
const guestAccount: Account = {
uuid: guestUserInfo.guestPerson.uuid as AccountUuid,
@@ -181,6 +184,10 @@
}
}
async function handleToggleGuestSignUp (e: CustomEvent<boolean>): Promise<void> {
await accountClient.updateAllowGuestSignUp(e.detail)
}
function handleTogglePermissions (): void {
const newState = !arePermissionsDisabled
showPopup(MessageBox, {
@@ -223,6 +230,7 @@
selected = items[savedFirstDayOfWeek === 'system' ? 0 : $deviceInfo.firstDayOfWeek + 1].id
}
)
const onSelected = (e: CustomEvent<string>): void => {
selected = e.detail
localStorage.setItem('firstDayOfWeek', `${e.detail}`)
@@ -302,6 +310,16 @@
}}
/>
</div>
<div class="flex-row-center flex-gap-4">
<Label label={settingsRes.string.GuestSignUpDescription} />
<Toggle
disabled={!allowReadOnlyGuests}
on={allowGuestSignUp}
on:change={(e) => {
void handleToggleGuestSignUp(e)
}}
/>
</div>
<div class="delete">
<Button
kind="regular"
+1
View File
@@ -128,6 +128,7 @@ export default mergeIds(settingId, setting, {
BetaWarning: '' as IntlString,
GuestAccess: '' as IntlString,
GuestAccessDescription: '' as IntlString,
GuestSignUpDescription: '' as IntlString,
ManageIdentities: '' as IntlString,
AddNew: '' as IntlString,
Release: '' as IntlString,
@@ -2,6 +2,7 @@
import { Button, navigate, Notification, NotificationToast } from '@hcengineering/ui'
import view from '@hcengineering/view'
import { getCurrentWorkspaceUrl } from '@hcengineering/presentation'
import { allowGuestSignUpStore } from '../utils'
export let onRemove: () => void
export let notification: Notification
@@ -16,7 +17,11 @@
{notification.subTitle}
</svelte:fragment>
<svelte:fragment slot="buttons">
<Button label={view.string.ReadOnlyJoinWorkspace} stopPropagation={false} on:click={joinWorkspace} />
{#if $allowGuestSignUpStore}
<Button label={view.string.ReadOnlyJoinWorkspace} stopPropagation={false} on:click={joinWorkspace} />
{:else}
<div style="width: auto" />
{/if}
<a href="https://huly.io/signup" target="_blank">
<Button label={view.string.ReadOnlySignUp} stopPropagation={false} />
</a>
+1
View File
@@ -1526,6 +1526,7 @@ function getAttrEditor (key: KeyedAttribute, hierarchy: Hierarchy): AnyComponent
}
}
export const allowGuestSignUpStore = writable<boolean>(false)
export const accessDeniedStore = writable<boolean>(false)
const spaceSpaceQuery = createQuery(true)
@@ -55,6 +55,7 @@ import { get, writable } from 'svelte/store'
import plugin from './plugin'
import { logOut, workspaceCreating } from './utils'
import { WorkbenchEvents } from '@hcengineering/workbench'
import { allowGuestSignUpStore } from '@hcengineering/view-resources'
export const versionError = writable<string | undefined>(undefined)
const versionStorageKey = 'last_server_version'
@@ -405,6 +406,8 @@ export async function connect (title: string): Promise<Client | undefined> {
console.log('Logged in with account: ', me)
setCurrentAccount(me)
allowGuestSignUpStore.set(workspaceLoginInfo.allowGuestSignUp ?? false)
if (me.role === AccountRole.ReadOnlyGuest) {
await broadcastEvent(PlatformEvent, new Status(Severity.INFO, platform.status.ReadOnlyAccount, {}))
} else {
+2 -1
View File
@@ -1147,7 +1147,8 @@ describe('MongoAccountDB', () => {
const workspaceData = {
name: 'New Workspace',
url: 'new-workspace',
allowReadOnlyGuest: false
allowReadOnlyGuest: false,
allowGuestSignUp: false
}
const statusData = {
mode: 'active' as const,
+2 -1
View File
@@ -1941,7 +1941,8 @@ describe('account utils', () => {
uuid: 'test-workspace-uuid' as WorkspaceUuid,
name: 'Test Workspace',
url: 'test-workspace',
allowReadOnlyGuest: false
allowReadOnlyGuest: false,
allowGuestSignUp: false
}
test('should generate invite email content', async () => {
+9
View File
@@ -598,6 +598,15 @@ export class MongoAccountDB implements AccountDB {
)
}
async updateAllowGuestSignUp (workspaceId: WorkspaceUuid, guestSignUpAllowed: boolean): Promise<void> {
await this.workspace.update(
{
uuid: workspaceId
},
{ allowGuestSignUp: guestSignUpAllowed }
)
}
async getPendingWorkspace (
region: string,
version: Data<Version>,
@@ -31,7 +31,8 @@ export function getMigrations (ns: string): [string, string][] {
getV10Migration2(ns),
getV11Migration(ns),
getV12Migration(ns),
getV13Migration(ns)
getV13Migration(ns),
getV14Migration(ns)
]
}
@@ -414,3 +415,13 @@ function getV13Migration (ns: string): [string, string] {
`
]
}
function getV14Migration (ns: string): [string, string] {
return [
'account_db_v14_add_allow_guest_signup_flag_to_workspace',
`
ALTER TABLE ${ns}.workspace
ADD COLUMN IF NOT EXISTS allow_guest_sign_up BOOL NOT NULL DEFAULT FALSE;
`
]
}
@@ -769,6 +769,11 @@ export class PostgresAccountDB implements AccountDB {
.client`UPDATE ${this.client(this.workspace.getTableName())} SET allow_read_only_guest = ${readOnlyGuestsAllowed} WHERE uuid = ${workspaceId}`
}
async updateAllowGuestSignUp (workspaceId: WorkspaceUuid, guestSignUpAllowed: boolean): Promise<void> {
await this
.client`UPDATE ${this.client(this.workspace.getTableName())} SET allow_guest_sign_up = ${guestSignUpAllowed} WHERE uuid = ${workspaceId}`
}
async assignWorkspace (accountUuid: AccountUuid, workspaceUuid: WorkspaceUuid, role: AccountRole): Promise<void> {
await this.withRetry(
async (rTx) =>
+3
View File
@@ -101,6 +101,7 @@ import {
verifyPassword,
wrap,
updateAllowReadOnlyGuests,
updateAllowGuestSignUp,
getWorkspaceByDataId,
assignableRoles,
getWorkspacesInfoWithStatusByIds,
@@ -2196,6 +2197,7 @@ export type AccountMethods =
| 'getWorkspaceMembers'
| 'updateWorkspaceRole'
| 'updateAllowReadOnlyGuests'
| 'updateAllowGuestSignUp'
| 'findPersonBySocialId'
| 'findSocialIdBySocialKey'
| 'ensurePerson'
@@ -2242,6 +2244,7 @@ export function getMethods (hasSignUp: boolean = true): Partial<Record<AccountMe
deleteWorkspace: wrap(deleteWorkspace),
updateWorkspaceRole: wrap(updateWorkspaceRole),
updateAllowReadOnlyGuests: wrap(updateAllowReadOnlyGuests),
updateAllowGuestSignUp: wrap(updateAllowGuestSignUp),
createMailbox: wrap(createMailbox),
getMailboxes: wrap(getMailboxes),
deleteMailbox: wrap(deleteMailbox),
+4 -1
View File
@@ -108,6 +108,7 @@ export interface Workspace {
name: string
url: string
allowReadOnlyGuest: boolean
allowGuestSignUp: boolean
dataId?: WorkspaceDataId // Old workspace identifier. E.g. Database name in Mongo, bucket in R2, etc.
branding?: string
location?: Location
@@ -209,6 +210,7 @@ export interface AccountDB {
init: () => Promise<void>
createWorkspace: (data: WorkspaceData, status: WorkspaceStatusData) => Promise<WorkspaceUuid>
updateAllowReadOnlyGuests: (workspaceId: WorkspaceUuid, readOnlyGuestsAllowed: boolean) => Promise<void>
updateAllowGuestSignUp: (workspaceId: WorkspaceUuid, guestSignUpAllowed: boolean) => Promise<void>
assignWorkspace: (accountId: AccountUuid, workspaceId: WorkspaceUuid, role: AccountRole) => Promise<void>
batchAssignWorkspace: (data: [AccountUuid, WorkspaceUuid, AccountRole][]) => Promise<void>
updateWorkspaceRole: (accountId: AccountUuid, workspaceId: WorkspaceUuid, role: AccountRole) => Promise<void>
@@ -323,6 +325,7 @@ export interface WorkspaceLoginInfo extends LoginInfo {
workspaceDataId?: WorkspaceDataId
endpoint: string
role: AccountRole
allowGuestSignUp?: boolean
}
export interface OtpInfo {
@@ -359,5 +362,5 @@ export interface AccountAggregatedInfo extends Omit<Account, 'hash' | 'salt'>, P
uuid: AccountUuid
integrations: Omit<Integration, 'data'>[]
socialIds: SocialId[]
workspaces: Omit<WorkspaceInfo, 'allowReadOnlyGuest'>[]
workspaces: Omit<WorkspaceInfo, 'allowReadOnlyGuest' | 'allowGuestSignUp'>[]
}
+28 -2
View File
@@ -683,6 +683,7 @@ export async function selectWorkspace (
workspace: workspace.uuid,
workspaceUrl: workspace.url,
workspaceDataId: workspace.dataId,
allowGuestSignUp: workspace.allowReadOnlyGuest && workspace.allowGuestSignUp,
role
}
}
@@ -741,6 +742,30 @@ export async function updateAllowReadOnlyGuests (
return { guestPerson, guestSocialIds: guestSocialIds.filter((si) => si.isDeleted !== true) }
}
export async function updateAllowGuestSignUp (
ctx: MeasureContext,
db: AccountDB,
branding: Branding | null,
token: string,
params: {
guestSignUpAllowed: boolean
}
): Promise<void> {
const { guestSignUpAllowed } = params
const { account, workspace } = decodeTokenVerbose(ctx, token)
if (workspace === null) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.WorkspaceNotFound, { workspaceUuid: workspace }))
}
const accRole = account === systemAccountUuid ? AccountRole.Owner : await db.getWorkspaceRole(account, workspace)
if (accRole == null || getRolePower(accRole) < getRolePower(AccountRole.Owner)) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
}
await db.updateAllowGuestSignUp(workspace, guestSignUpAllowed)
}
export async function updateWorkspaceRole (
ctx: MeasureContext,
db: AccountDB,
@@ -883,6 +908,7 @@ export async function createWorkspaceRecord (
createdBy: account,
billingAccount: account,
allowReadOnlyGuest: false,
allowGuestSignUp: false,
region
},
{
@@ -1191,7 +1217,7 @@ export async function getWorkspaceJoinInfo (
}
if (workspaceUrl !== undefined && workspaceUrl !== '' && workspaceUrl !== null) {
const workspace = await getWorkspaceByUrl(db, workspaceUrl)
if (workspace == null || !workspace.allowReadOnlyGuest) {
if (workspace == null || !workspace.allowReadOnlyGuest || !workspace.allowGuestSignUp) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
}
return {
@@ -1221,7 +1247,7 @@ export async function doJoinByInvite (
await db.updateWorkspaceRole(account, workspace.uuid, invite.role)
}
await useInvite(db, invite.id)
} else if (workspace.allowReadOnlyGuest) {
} else if (workspace.allowReadOnlyGuest && workspace.allowGuestSignUp) {
if (role == null) {
await db.assignWorkspace(account, workspace.uuid, AccountRole.Guest)
}