Basic implementation of a readonly user role

Signed-off-by: Anton Alexeyev <alexeyev.anton@gmail.com>
This commit is contained in:
Anton Alexeyev 2025-05-19 09:53:59 +07:00
parent 46113c7135
commit 2fc09ab94f
11 changed files with 69 additions and 35 deletions

View File

@ -517,6 +517,7 @@ export interface Permission extends Doc {
* @public
*/
export enum AccountRole {
ReadOnlyGuest = 'READONLYGUEST',
DocGuest = 'DocGuest',
Guest = 'GUEST',
User = 'USER',
@ -528,6 +529,7 @@ export enum AccountRole {
* @public
*/
export const roleOrder: Record<AccountRole, number> = {
[AccountRole.ReadOnlyGuest]: 5,
[AccountRole.DocGuest]: 10,
[AccountRole.Guest]: 20,
[AccountRole.User]: 30,

View File

@ -27,6 +27,7 @@
const currentAccount = getCurrentAccount()
const items: DropdownIntlItem[] = [
{ id: AccountRole.ReadOnlyGuest, label: setting.string.User },
{ id: AccountRole.User, label: setting.string.User },
{ id: AccountRole.Maintainer, label: setting.string.Maintainer },
{ id: AccountRole.Owner, label: setting.string.Owner }

View File

@ -22,33 +22,33 @@
Class,
type CollaborativeDoc,
type Doc,
type Ref,
generateId,
getCurrentAccount,
makeDocCollabId
makeDocCollabId,
type Ref
} from '@hcengineering/core'
import { IntlString, translate } from '@hcengineering/platform'
import {
DrawingCmd,
KeyedAttribute,
getAttribute,
getClient,
getFileUrl,
getImageSize,
imageSizeToRatio
imageSizeToRatio,
KeyedAttribute
} from '@hcengineering/presentation'
import { markupToJSON } from '@hcengineering/text'
import {
AnySvelteComponent,
Button,
getEventPositionElement,
getPopupPositionElement,
IconScribble,
IconSize,
Loading,
PopupAlignment,
ThrottledCaller,
getEventPositionElement,
getPopupPositionElement,
themeStore
themeStore,
ThrottledCaller
} from '@hcengineering/ui'
import view from '@hcengineering/view'
import { AnyExtension, Editor, FocusPosition, mergeAttributes } from '@tiptap/core'
@ -83,7 +83,7 @@
import { InlineCommentCollaborationExtension } from './extension/inlineComment'
import { LeftMenuExtension } from './extension/leftMenu'
import { mermaidOptions } from './extension/mermaid'
import { ReferenceExtension, referenceConfig } from './extension/reference'
import { referenceConfig, ReferenceExtension } from './extension/reference'
import { type FileAttachFunction } from './extension/types'
import { inlineCommandsConfig } from './extensions'
@ -121,7 +121,7 @@
const dispatch = createEventDispatcher()
const account = getCurrentAccount()
$: isGuest = account.role === AccountRole.DocGuest
$: isGuest = account.role === AccountRole.DocGuest || account.role === AccountRole.ReadOnlyGuest
const objectClass = object._class
const objectId = object._id
@ -142,7 +142,7 @@
let remoteSynced = false
$: loading = !localSynced && !remoteSynced
$: editable = !readonly && !contentError && remoteSynced
$: editable = !readonly && !contentError && remoteSynced && account.role !== AccountRole.ReadOnlyGuest
void localProvider.loaded.then(() => (localSynced = true))
void remoteProvider.loaded.then(() => (remoteSynced = true))

View File

@ -75,6 +75,8 @@ async function createMessages (
for (const data of result) {
void api.event(
{
// TODO: We should decide what to do with communications package and remove this workaround
// @ts-expect-error Temporary workaround because of Communications package depends on npm core package
account: systemAccount
},
{

View File

@ -412,6 +412,8 @@ async function updateCollaborators (control: TriggerControl, ctx: TxCreateDoc<Ca
if (collaborators.length === 0) continue
void communicationApi.event(
// TODO: We should decide what to do with communications package and remove this workaround
// @ts-expect-error Temporary workaround because of Communications package depends on npm core package
{ account: systemAccount },
{
type: NotificationRequestEventType.AddCollaborators,

View File

@ -26,7 +26,8 @@ export function getMigrations (ns: string): [string, string][] {
getV6Migration(ns),
getV7Migration(ns),
getV8Migration(ns),
getV9Migration(ns)
getV9Migration(ns),
getV10Migration(ns)
]
}
@ -45,7 +46,7 @@ function getV1Migration (ns: string): [string, string] {
/* ======= T Y P E S ======= */
CREATE TYPE IF NOT EXISTS ${ns}.social_id_type AS ENUM ('email', 'github', 'google', 'phone', 'oidc', 'huly', 'telegram');
CREATE TYPE IF NOT EXISTS ${ns}.location AS ENUM ('kv', 'weur', 'eeur', 'wnam', 'enam', 'apac');
CREATE TYPE IF NOT EXISTS ${ns}.workspace_role AS ENUM ('OWNER', 'MAINTAINER', 'USER', 'GUEST', 'DOCGUEST');
CREATE TYPE IF NOT EXISTS ${ns}.workspace_role AS ENUM ('OWNER', 'MAINTAINER', 'USER', 'GUEST', 'DOCGUEST', 'READONLYGUEST');
/* ======= P E R S O N ======= */
CREATE TABLE IF NOT EXISTS ${ns}.person (
@ -341,3 +342,12 @@ function getV9Migration (ns: string): [string, string] {
`
]
}
function getV10Migration (ns: string): [string, string] {
return [
'account_db_v10_add_readonly_role',
`
ALTER TYPE ${ns}.workspace_role ADD VALUE 'READONLYGUEST' AFTER 'DOCGUEST';
`
]
}

View File

@ -21,16 +21,16 @@ import {
groupByArray,
isActiveMode,
MeasureContext,
type Person,
type PersonId,
type PersonUuid,
roleOrder,
SocialIdType,
SocialKey,
systemAccountUuid,
type WorkspaceInfoWithStatus as WorkspaceInfoWithStatusCore,
WorkspaceMode,
WorkspaceUuid,
type Person,
type PersonId,
type PersonUuid,
type WorkspaceInfoWithStatus as WorkspaceInfoWithStatusCore
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'
@ -45,21 +45,21 @@ import { MongoAccountDB } from './collections/mongo'
import { PostgresAccountDB } from './collections/postgres/postgres'
import { accountPlugin } from './plugin'
import {
type Account,
type AccountDB,
AccountEventType,
AccountMethodHandler,
Integration,
LoginInfo,
Meta,
OtpInfo,
type RegionInfo,
type SocialId,
type Workspace,
WorkspaceInfoWithStatus,
WorkspaceInvite,
WorkspaceLoginInfo,
WorkspaceStatus,
type Account,
type AccountDB,
type RegionInfo,
type SocialId,
type Workspace
WorkspaceStatus
} from './types'
import { isAdminEmail } from './admin'
@ -112,6 +112,14 @@ export function getRolePower (role: AccountRole): number {
return roleOrder[role]
}
export function isReadOnlyOrGuest (account: AccountUuid, extra: Record<string, any> | undefined): boolean {
return isGuest(account, extra) || extra?.readonly === 'true'
}
export function isGuest (account: AccountUuid, extra: Record<string, any> | undefined): boolean {
return account === GUEST_ACCOUNT && extra?.guest === 'true'
}
export function wrap (
accountMethod: (ctx: MeasureContext, db: AccountDB, branding: Branding | null, ...args: any[]) => Promise<any>
): AccountMethodHandler {
@ -532,7 +540,7 @@ export async function selectWorkspace (
meta?: Meta
): Promise<WorkspaceLoginInfo> {
const { workspaceUrl, kind, externalRegions = [] } = params
const { account: accountUuid, workspace: tokenWorkspaceUuid, extra } = decodeTokenVerbose(ctx, token ?? '')
let { account: accountUuid, workspace: tokenWorkspaceUuid, extra } = decodeTokenVerbose(ctx, token ?? '')
const getKind = (region: string | undefined): EndpointKind => {
switch (kind) {
case 'external':
@ -546,7 +554,7 @@ export async function selectWorkspace (
}
}
if (accountUuid === GUEST_ACCOUNT && extra?.guest === 'true') {
if (isGuest(accountUuid, extra)) {
const workspace = await getWorkspaceByUrl(db, workspaceUrl)
if (workspace == null) {
ctx.error('Workspace not found in selectWorkspace', { workspaceUrl, kind, accountUuid, extra })
@ -605,6 +613,13 @@ export async function selectWorkspace (
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
}
if (role === AccountRole.ReadOnlyGuest) {
if (extra == null) {
extra = {}
}
extra.readonly = 'true'
}
const wsStatus = await db.workspaceStatus.findOne({ workspaceUuid: workspace.uuid })
if (wsStatus != null) {

View File

@ -17,9 +17,10 @@ import { decodeDocumentId } from '@hcengineering/collaborator-client'
import { MeasureContext } from '@hcengineering/core'
import { decodeToken } from '@hcengineering/server-token'
import { Extension, onAuthenticatePayload } from '@hocuspocus/server'
import { isReadOnlyOrGuest } from '@hcengineering/account'
import { Context, buildContext } from '../context'
import { getWorkspaceIds, isGuest } from '../utils'
import { getWorkspaceIds } from '../utils'
export interface AuthenticationConfiguration {
ctx: MeasureContext
@ -38,7 +39,7 @@ export class AuthenticationExtension implements Extension {
return await ctx.with('authenticate', { workspaceId }, async () => {
const token = decodeToken(data.token)
const readonly = isGuest(token)
const readonly = isReadOnlyOrGuest(token.account, token.extra)
ctx.info('authenticate', {
workspaceId,

View File

@ -12,17 +12,11 @@
// See the License for the specific language governing permissions and
// limitations under the License.
//
import { GUEST_ACCOUNT } from '@hcengineering/account'
import { getClient as getAccountClient } from '@hcengineering/account-client'
import { type WorkspaceIds } from '@hcengineering/core'
import { type Token } from '@hcengineering/server-token'
import config from './config'
export function isGuest (token: Token): boolean {
return token.account === GUEST_ACCOUNT && token.extra?.guest === 'true'
}
// TODO: consider storing this in a cache for some short period of time
export async function getWorkspaceIds (token: string): Promise<WorkspaceIds> {
const workspaceInfo = await getAccountClient(config.AccountsUrl, token).getWorkspaceInfo()

View File

@ -32,7 +32,7 @@ import core, {
TypedSpace,
type MeasureContext,
type SessionData,
type AccountUuid
type AccountUuid, AccountRole
} from '@hcengineering/core'
import platform, { PlatformError, Severity, Status } from '@hcengineering/platform'
import { Middleware, TxMiddlewareResult, type PipelineContext } from '@hcengineering/server-core'
@ -337,6 +337,11 @@ 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

View File

@ -450,6 +450,8 @@ export class ClientSession implements Session {
private getCommunicationCtx (): CommunicationSession {
return {
sessionId: this.sessionId,
// TODO: We should decide what to do with communications package and remove this workaround
// @ts-expect-error Temporary workaround because of Communications package depends on npm core package
account: this.account
}
}