Skip to content
This repository has been archived by the owner on Aug 21, 2024. It is now read-only.

Commit

Permalink
Add TOS Checks & Disable Chat and Media if user has not accepted (#10732
Browse files Browse the repository at this point in the history
)

* add terms of service and age checkboxes, disable chat and media if user does not accept terms

* replace verify scope with check scope

* add under 13 check and message about accessing chat

* bug fixes
  • Loading branch information
HexaField authored Jul 30, 2024
1 parent 99dd250 commit 0e1e55d
Show file tree
Hide file tree
Showing 13 changed files with 278 additions and 18 deletions.
2 changes: 2 additions & 0 deletions packages/client-core/i18n/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@
"loadingAllowed": "Loading allowed routes...",
"loadingXRSystems": "Loading immersive session...",
"connectingToWorld": "Connecting to world...",
"needToAcceptTOS": "You need to accept the terms of service to access chat.",
"needToLogIn": "You need to log in to access chat.",
"connectingToMedia": "Connecting to media...",
"entering": "Entering world...",
"loading": "Loading...",
Expand Down
5 changes: 5 additions & 0 deletions packages/client-core/i18n/en/user.json
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,11 @@
"issueVC": "Issue a VC",
"requestVC": "Request a VC",
"addSocial": "Connect your Social Logins",
"logIn": "Log In",
"acceptTOS": "I accept the ",
"termsOfService": "Terms of Service",
"confirmAge13": "I confirm that I am 13 years or older",
"confirmAge18": "I confirm that I am 18 years or older",
"removeSocial": "Remove Social Logins",
"connections": {
"title": "Connections",
Expand Down
25 changes: 25 additions & 0 deletions packages/client-core/src/components/InstanceChat/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -459,9 +459,34 @@ export const InstanceChatWrapper = () => {
const { t } = useTranslation()
const { bottomShelfStyle } = useShelfStyles()

const acceptedTOS = useMutableState(AuthState).user.acceptedTOS.value
const isGuest = useMutableState(AuthState).user.isGuest.value

const networkWorldConfig = useHookstate(getMutableState(NetworkState).config.world)
const targetChannelId = useHookstate(getMutableState(ChannelState).targetChannelId)

if (isGuest)
return (
<>
<div className={styles.modalConnecting}>
<div className={styles.modalConnectingTitle}>
<p>{t('common:loader.needToLogIn')}</p>
</div>
</div>
</>
)

if (!acceptedTOS)
return (
<>
<div className={styles.modalConnecting}>
<div className={styles.modalConnectingTitle}>
<p>{t('common:loader.needToAcceptTOS')}</p>
</div>
</div>
</>
)

return (
<>
{targetChannelId.value ? (
Expand Down
7 changes: 5 additions & 2 deletions packages/client-core/src/components/World/EngineHooks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import { EngineState } from '@etherealengine/spatial/src/EngineState'

import { RouterState } from '../../common/services/RouterService'
import { LocationState } from '../../social/services/LocationService'
import { AuthState } from '../../user/services/AuthService'

const logger = multiLogger.child({ component: 'client-core:world' })

Expand Down Expand Up @@ -143,15 +144,17 @@ export const useLoadEngineWithScene = () => {
}

export const useNetwork = (props: { online?: boolean }) => {
const acceptedTOS = useMutableState(AuthState).user.acceptedTOS.value

useEffect(() => {
getMutableState(NetworkState).config.set({
world: !!props.online,
media: !!props.online,
media: !!props.online && acceptedTOS,
friends: !!props.online,
instanceID: !!props.online,
roomID: false
})
}, [props.online])
}, [props.online, acceptedTOS])

/** Offline/local world network */
useEffect(() => {
Expand Down
145 changes: 134 additions & 11 deletions packages/client-core/src/user/components/UserMenu/menus/ProfileMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ Ethereal Engine. All Rights Reserved.
import { QRCodeSVG } from 'qrcode.react'
import React, { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { useLocation } from 'react-router-dom'
import { Link, useLocation } from 'react-router-dom'

import Avatar from '@etherealengine/client-core/src/common/components/Avatar'
import Button from '@etherealengine/client-core/src/common/components/Button'
Expand All @@ -44,14 +44,23 @@ import Menu from '@etherealengine/client-core/src/common/components/Menu'
import Text from '@etherealengine/client-core/src/common/components/Text'
import config, { validateEmail, validatePhoneNumber } from '@etherealengine/common/src/config'
import multiLogger from '@etherealengine/common/src/logger'
import { authenticationSettingPath, clientSettingPath, UserName } from '@etherealengine/common/src/schema.type.module'
import {
authenticationSettingPath,
clientSettingPath,
UserName,
userPath
} from '@etherealengine/common/src/schema.type.module'
import { getMutableState, useHookstate } from '@etherealengine/hyperflux'
import { useFind } from '@etherealengine/spatial/src/common/functions/FeathersHooks'
import Box from '@etherealengine/ui/src/primitives/mui/Box'
import Checkbox from '@etherealengine/ui/src/primitives/mui/Checkbox'
import CircularProgress from '@etherealengine/ui/src/primitives/mui/CircularProgress'
import FormControlLabel from '@etherealengine/ui/src/primitives/mui/FormControlLabel'
import Icon from '@etherealengine/ui/src/primitives/mui/Icon'
import IconButton from '@etherealengine/ui/src/primitives/mui/IconButton'

import { Engine } from '@etherealengine/ecs'
import Grid from '@etherealengine/ui/src/primitives/mui/Grid'
import { initialAuthState, initialOAuthConnectedState } from '../../../../common/initialAuthState'
import { NotificationService } from '../../../../common/services/NotificationService'
import { useZendesk } from '../../../../hooks/useZendesk'
Expand All @@ -64,6 +73,8 @@ import { UserMenus } from '../../../UserUISystem'
import styles from '../index.module.scss'
import { PopupMenuServices } from '../PopupMenuService'

const termsOfService = config.client.tosAddress ?? '/terms-of-service'

const logger = multiLogger.child({ component: 'engine:ecs:ProfileMenu', modifier: clientContextParams })

interface Props {
Expand Down Expand Up @@ -96,6 +107,32 @@ const ProfileMenu = ({ hideLogin, onClose, isPopover }: Props): JSX.Element => {
const userId = selfUser.id.value
const apiKey = selfUser.apiKey?.token?.value
const isGuest = selfUser.isGuest.value
const acceptedTOS = !!selfUser.acceptedTOS.value

const checkedTOS = useHookstate(!isGuest)
const checked13OrOver = useHookstate(!isGuest)
const checked18OrOver = useHookstate(acceptedTOS)
const hasAcceptedTermsAndAge = checkedTOS.value && checked13OrOver.value

const originallyAcceptedTOS = useHookstate(acceptedTOS)

useEffect(() => {
if (!originallyAcceptedTOS.value && checked18OrOver.value) {
Engine.instance.api
.service(userPath)
.patch(userId, { acceptedTOS: true })
.then(() => {
selfUser.acceptedTOS.set(true)
logger.info({
event_name: 'accept_tos',
event_value: ''
})
})
.catch((e) => {
console.error(e, 'Error updating user')
})
}
}, [checked18OrOver])

const hasAdminAccess = useUserHasAccessHook('admin:admin')
const avatarThumbnail = useUserAvatarThumbnail(userId)
Expand Down Expand Up @@ -393,7 +430,7 @@ const ProfileMenu = ({ hideLogin, onClose, isPopover }: Props): JSX.Element => {
<Box className={styles.profileContainer}>
<Avatar
imageSrc={avatarThumbnail}
showChangeButton={true}
showChangeButton={hasAcceptedTermsAndAge}
onChange={() => PopupMenuServices.showPopupMenu(UserMenus.AvatarSelect)}
/>

Expand All @@ -403,28 +440,113 @@ const ProfileMenu = ({ hideLogin, onClose, isPopover }: Props): JSX.Element => {
<span className={commonStyles.bold}>{hasAdminAccess ? ' Admin' : isGuest ? ' Guest' : ' User'}</span>.
</Text>

{selfUser?.inviteCode.value && (
{hasAcceptedTermsAndAge && selfUser?.inviteCode.value && (
<Text mt={1} variant="body2">
{t('user:usermenu.profile.inviteCode')}: {selfUser.inviteCode.value}
</Text>
)}

{!selfUser?.isGuest.value && (
{hasAcceptedTermsAndAge && !selfUser?.isGuest.value && (
<Text mt={1} variant="body2" onClick={() => createLoginLink()}>
{t('user:usermenu.profile.createLoginLink')}
</Text>
)}

<Text id="show-user-id" mt={1} variant="body2" onClick={() => showUserId.set(!showUserId.value)}>
{showUserId.value ? t('user:usermenu.profile.hideUserId') : t('user:usermenu.profile.showUserId')}
</Text>
{hasAcceptedTermsAndAge && (
<Text id="show-user-id" mt={1} variant="body2" onClick={() => showUserId.set(!showUserId.value)}>
{showUserId.value ? t('user:usermenu.profile.hideUserId') : t('user:usermenu.profile.showUserId')}
</Text>
)}

{selfUser?.apiKey?.id && (
{hasAcceptedTermsAndAge && selfUser?.apiKey?.id && (
<Text variant="body2" mt={1} onClick={() => showApiKey.set(!showApiKey.value)}>
{showApiKey.value ? t('user:usermenu.profile.hideApiKey') : t('user:usermenu.profile.showApiKey')}
</Text>
)}

{isGuest && (
<Grid item xs={12}>
<FormControlLabel
control={
<Checkbox
disabled={hasAcceptedTermsAndAge}
value={checkedTOS.value}
onChange={(e) => checkedTOS.set(e.target.checked)}
color="primary"
name="isAgreedTermsOfService"
/>
}
label={
<div
className={styles.termsLink}
style={{
fontStyle: 'italic'
}}
>
{t('user:usermenu.profile.acceptTOS')}
<Link
style={{
fontStyle: 'italic',
color: 'var(--textColor)',
textDecoration: 'underline'
}}
to={termsOfService}
>
{t('user:usermenu.profile.termsOfService')}
</Link>
</div>
}
/>
<FormControlLabel
control={
<Checkbox
disabled={hasAcceptedTermsAndAge}
value={checked13OrOver.value}
onChange={(e) => checked13OrOver.set(e.target.checked)}
color="primary"
name="is13OrOver"
/>
}
label={
<div
style={{
fontStyle: 'italic'
}}
className={styles.termsLink}
>
{t('user:usermenu.profile.confirmAge13')}
</div>
}
/>
</Grid>
)}

{!isGuest && !originallyAcceptedTOS.value && (
<Grid item xs={12}>
<FormControlLabel
control={
<Checkbox
disabled={checked18OrOver.value}
value={checked18OrOver.value}
onChange={(e) => checked18OrOver.set(e.target.checked)}
color="primary"
name="is13OrOver"
/>
}
label={
<div
style={{
fontStyle: 'italic'
}}
className={styles.termsLink}
>
{t('user:usermenu.profile.confirmAge18')}
</div>
}
/>
</Grid>
)}

{!isGuest && (
<Text variant="body2" mt={1} onClick={handleLogout}>
{t('user:usermenu.profile.logout')}
Expand Down Expand Up @@ -491,6 +613,7 @@ const ProfileMenu = ({ hideLogin, onClose, isPopover }: Props): JSX.Element => {
</Box>

<InputText
disabled={!hasAcceptedTermsAndAge}
name={'username' as UserName}
label={t('user:usermenu.profile.lbl-username')}
value={username.value || ('' as UserName)}
Expand Down Expand Up @@ -561,7 +684,7 @@ const ProfileMenu = ({ hideLogin, onClose, isPopover }: Props): JSX.Element => {
</div>
)}

{!hideLogin && (
{!hideLogin && hasAcceptedTermsAndAge && (
<>
{isGuest && enableConnect && (
<>
Expand Down Expand Up @@ -618,7 +741,7 @@ const ProfileMenu = ({ hideLogin, onClose, isPopover }: Props): JSX.Element => {
<>
{selfUser?.isGuest.value && (
<Text align="center" variant="body2" mb={1} mt={2}>
{t('user:usermenu.profile.addSocial')}
{hasAcceptedTermsAndAge ? t('user:usermenu.profile.addSocial') : t('user:usermenu.profile.logIn')}
</Text>
)}
<div className={styles.socialContainer}>
Expand Down
1 change: 1 addition & 0 deletions packages/client-core/src/user/services/AuthService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ export const UserSeed: UserType = {
createdAt: '',
updatedAt: ''
},
acceptedTOS: false,
userSetting: {
id: '' as UserSettingID,
themeModes: {},
Expand Down
1 change: 1 addition & 0 deletions packages/common/src/schemas/user/user.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ export const userSchema = Type.Object(
format: 'uuid'
}),
name: TypedString<UserName>(),
acceptedTOS: Type.Boolean(),
isGuest: Type.Boolean(),
inviteCode: Type.Optional(TypedString<InviteCode>()),
avatarId: TypedString<AvatarID>({
Expand Down
6 changes: 5 additions & 1 deletion packages/instanceserver/src/NetworkFunctions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,11 @@ export async function cleanupOldInstanceservers(app: Application): Promise<void>
* @param userId
* @returns
*/
export const authorizeUserToJoinServer = async (app: Application, instance: InstanceType, userId: UserID) => {
export const authorizeUserToJoinServer = async (app: Application, instance: InstanceType, user: UserType) => {
const userId = user.id
// disallow users from joining media servers if they haven't accepted the TOS
if (instance.channelId && !user.acceptedTOS) return false

const authorizedUsers = (await app.service(instanceAuthorizedUserPath).find({
query: {
instanceId: instance.id,
Expand Down
2 changes: 1 addition & 1 deletion packages/instanceserver/src/SocketFunctions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ export const setupSocketFunctions = async (app: Application, spark: any) => {

// Check that this use is allowed on this instance
const instance = await app.service(instancePath).get(getState(InstanceServerState).instance.id)
if (!(await authorizeUserToJoinServer(app, instance, userId))) {
if (!(await authorizeUserToJoinServer(app, instance, user))) {
authTask.status = 'fail'
authTask.error = AuthError.USER_NOT_AUTHORIZED
logger.error('[MessageTypes.Authorization]: user %s not authorized over peer %s %o', userId, peerID, authTask)
Expand Down
23 changes: 21 additions & 2 deletions packages/instanceserver/src/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,12 @@ const initializeInstance = async ({

if (existingInstanceResult.total > 0) {
const instance = existingInstanceResult.data[0]
if (userId && !(await authorizeUserToJoinServer(app, instance, userId))) return false
if (userId) {
const user = await app.service(userPath).get(userId)
if (!user) return false
const authorised = await authorizeUserToJoinServer(app, instance, user)
if (!authorised) return false
}
if (instance.locationId) {
const existingChannel = (await app.service(channelPath).find({
query: {
Expand Down Expand Up @@ -418,7 +423,12 @@ const updateInstance = async ({
}, 1000)
})
const instance = await app.service(instancePath).get(instanceServerState.instance.id, { headers })
if (userId && !(await authorizeUserToJoinServer(app, instance, userId))) return false
if (userId) {
const user = await app.service(userPath).get(userId)
if (!user) return false
const authorised = await authorizeUserToJoinServer(app, instance, user)
if (!authorised) return false
}

logger.info(`Authorized user ${userId} to join server`)
await serverState.agonesSDK.allocate()
Expand Down Expand Up @@ -607,6 +617,15 @@ export const onConnection = (app: Application) => async (connection: PrimusConne

logger.info(`user ${userId} joining ${locationId ?? channelId} and room code ${roomCode}`)

if (userId) {
const user = await app.service(userPath).get(userId)
// disallow users from joining media servers if they haven't accepted the TOS
if (channelId && !user.acceptedTOS) {
logger.warn('User tried to connect without accepting TOS')
return
}
}

const instanceServerState = getState(InstanceServerState)
const serverState = getState(ServerState)

Expand Down
Loading

0 comments on commit 0e1e55d

Please sign in to comment.