diff --git a/package-lock.json b/package-lock.json index de3fdf05..ea21e458 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14770,6 +14770,15 @@ "whatwg-fetch": "^3.4.1" } }, + "react-async-script": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/react-async-script/-/react-async-script-1.2.0.tgz", + "integrity": "sha512-bCpkbm9JiAuMGhkqoAiC0lLkb40DJ0HOEJIku+9JDjxX3Rcs+ztEOG13wbrOskt3n2DTrjshhaQ/iay+SnGg5Q==", + "requires": { + "hoist-non-react-statics": "^3.3.0", + "prop-types": "^15.5.0" + } + }, "react-bootstrap": { "version": "1.5.2", "resolved": "https://registry.npmjs.org/react-bootstrap/-/react-bootstrap-1.5.2.tgz", @@ -14925,6 +14934,15 @@ "resolved": "https://registry.npmjs.org/react-error-overlay/-/react-error-overlay-6.0.9.tgz", "integrity": "sha512-nQTTcUu+ATDbrSD1BZHr5kgSD4oF8OFjxun8uAaL8RwPBacGBNPf/yAuVVdx17N8XNzRDMrZ9XcKZHCjPW+9ew==" }, + "react-google-recaptcha": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/react-google-recaptcha/-/react-google-recaptcha-2.1.0.tgz", + "integrity": "sha512-K9jr7e0CWFigi8KxC3WPvNqZZ47df2RrMAta6KmRoE4RUi7Ys6NmNjytpXpg4HI/svmQJLKR+PncEPaNJ98DqQ==", + "requires": { + "prop-types": "^15.5.0", + "react-async-script": "^1.1.1" + } + }, "react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", diff --git a/package.json b/package.json index a8d68434..f8aac660 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "react-bootstrap": "^1.5.2", "react-dom": "^17.0.2", "react-draggable": "^4.4.3", + "react-google-recaptcha": "^2.1.0", "react-redux": "^7.2.3", "react-scripts": "4.0.3", "react-transition-group": "^4.4.1", diff --git a/public/configuration.json b/public/configuration.json index 07875190..13550796 100644 --- a/public/configuration.json +++ b/public/configuration.json @@ -21,6 +21,53 @@ "furni.extras.url": "${asset.url}/images/furniextras/%image%.png", "url.prefix": "", "chat.viewer.height.percentage": 0.40, + "auth.system.enabled": true, + "auth.system.http.enabled": true, + "auth.system.http.endpoint.login": "https://reqres.in/api/posts", + "auth.system.http.endpoint.register": "https://reqres.in/api/posts", + "auth.system.sso_field_name": "username", + "auth.system.recaptcha.public_key": "", + "auth.system.recaptcha.field_name": "recaptcha", + "auth.system.login.fields": [ + { + "name": "username", + "label": "Username", + "type": "text", + "col": 12 + }, + { + "name": "password", + "label": "Password", + "type": "password", + "col": 12 + } + ], + "auth.system.register.fields": [ + { + "name": "username", + "label": "Username", + "type": "text", + "col": 12 + }, + { + "name": "email", + "label": "Email Address", + "type": "email", + "col": 12 + }, + { + "name": "password", + "label": "Password", + "type": "password", + "col": 6 + }, + { + "name": "confirm_password", + "label": "Confirm Password", + "type": "password", + "col": 6 + } + ], "navigator.slider.enabled": true, "navigator.slider.content": [ { diff --git a/src/App.tsx b/src/App.tsx index 89ceb5f6..1a538ed1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,6 +5,7 @@ import { useLocalizationEvent } from './hooks/events/nitro/localization/localiza import { dispatchMainEvent, useMainEvent } from './hooks/events/nitro/main-event'; import { useRoomEngineEvent } from './hooks/events/nitro/room/room-engine-event'; import { GetConfiguration } from './utils/GetConfiguration'; +import { AuthView } from './views/auth/AuthView'; import { LoadingView } from './views/loading/LoadingView'; import { MainView } from './views/main/MainView'; @@ -12,6 +13,7 @@ export function App(): JSX.Element { const [ isReady, setIsReady ] = useState(false); const [ isError, setIsError ] = useState(false); + const [ isAuth, setIsAuth ] = useState(false); const [ message, setMessage ] = useState('Getting Ready'); //@ts-ignore @@ -60,8 +62,17 @@ export function App(): JSX.Element case NitroCommunicationDemoEvent.CONNECTION_HANDSHAKING: return; case NitroCommunicationDemoEvent.CONNECTION_HANDSHAKE_FAILED: - setIsError(true); - setMessage('Handshake Failed'); + const authEnabled = (GetConfiguration('auth.system.enabled') as boolean); + + if(authEnabled) + { + setIsAuth(true); + } + else + { + setIsError(true); + setMessage('Handshake Failed'); + } return; case NitroCommunicationDemoEvent.CONNECTION_AUTHENTICATED: setMessage('Finishing Up'); @@ -125,8 +136,9 @@ export function App(): JSX.Element return (
- { (!isReady || isError) && } - { (isReady && !isError) && } + { (!isReady || isError) && !isAuth && } + { (isReady && !isError && !isAuth) && } + { isAuth && }
); } diff --git a/src/views/Styles.scss b/src/views/Styles.scss index 55c9058d..657a17c8 100644 --- a/src/views/Styles.scss +++ b/src/views/Styles.scss @@ -1,3 +1,4 @@ +@import './auth/AuthView'; @import './avatar-editor/AvatarEditorView'; @import './avatar-image/AvatarImage'; @import './badge-image/BadgeImage'; diff --git a/src/views/auth/AuthView.scss b/src/views/auth/AuthView.scss new file mode 100644 index 00000000..0cc28282 --- /dev/null +++ b/src/views/auth/AuthView.scss @@ -0,0 +1,11 @@ +.nitro-auth { + width: 100%; + height: 100%; + position: absolute; + + .logo { + background: url('../../assets/images/nitro/nitro-light.svg') no-repeat center; + width: 45%; + height: 45%; + } +} diff --git a/src/views/auth/AuthView.tsx b/src/views/auth/AuthView.tsx new file mode 100644 index 00000000..5ae952b3 --- /dev/null +++ b/src/views/auth/AuthView.tsx @@ -0,0 +1,168 @@ +import classNames from 'classnames'; +import { AuthenticationMessageComposer } from 'nitro-renderer'; +import { AuthenticationEvent } from 'nitro-renderer/src/nitro/communication/messages/incoming/handshake/AuthenticationEvent'; +import { FC, useCallback, useEffect, useState } from 'react'; +import ReCAPTCHA from "react-google-recaptcha"; +import { CreateMessageHook, SendMessageHook } from '../../hooks/messages'; +import { GetConfiguration } from '../../utils/GetConfiguration'; +import { AuthField, AuthViewProps } from './AuthView.types'; +import { AuthFormView } from './views/form/AuthFormView'; + +export const AuthView: FC = props => +{ + const [ showLogin, setShowLogin ] = useState(true); + const [ isLoading, setIsLoading ] = useState(false); + const [ fields, setFields ] = useState(null); + const [ recaptchaPublicKey, setRecaptchaPublicKey ] = useState(null); + const [ recaptchaAnswer, setRecaptchaAnswer ] = useState(null); + + useEffect(() => + { + const configFields = GetConfiguration('auth.system.' + (showLogin ? 'login' : 'register') + '.fields'); + + if(configFields.length > 0) setFields(configFields); + + const recaptchaKey = GetConfiguration('auth.system.recaptcha.public_key'); + + if(recaptchaKey) setRecaptchaPublicKey(recaptchaKey); + }, [ showLogin ]); + + const setFieldValue = useCallback((key: string, value: string) => + { + const fieldsClone = Array.from(fields); + + const field = fieldsClone.find(field => field.name === key); + + if(!field) return; + + field.value = value; + + setFields(fieldsClone); + }, [ fields ]); + + const sendHttpAuthentication = useCallback((body: string) => + { + const endpoint = (GetConfiguration('auth.system.http.endpoint.' + (showLogin ? 'login' : 'register')) as string); + + if(!endpoint) return; + + const requestOptions = { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: body + }; + + fetch(endpoint, requestOptions) + .then(response => response.json(), error => + { + setIsLoading(false); + console.log('Login failed', error); + + return null; + }) + .then(data => handleAuthentication(data)); + }, [ showLogin ]); + + const sendPacketAuthentication = useCallback((keys: string[], values: string[]) => + { + SendMessageHook(new AuthenticationMessageComposer((showLogin ? 'login' : 'register'), keys, values)); + }, [ showLogin ]); + + CreateMessageHook(AuthenticationEvent, event => handleAuthentication(Object.entries(event.parser))); + + const handleAuthentication = useCallback((data: any) => + { + setIsLoading(false); + + if(!data) return; + + let ssoFieldName = 'sso'; + + if(GetConfiguration('auth.system.http.enabled')) ssoFieldName = GetConfiguration('auth.system.sso_field_name', 'sso'); + + if(!data[ssoFieldName]) return; + + window.location.href = window.location.origin + '/?sso=' + data[ssoFieldName]; + }, []); + + const sendAuthentication = useCallback(() => + { + const recaptchaFieldName = GetConfiguration('auth.system.recaptcha.field_name'); + + if(recaptchaPublicKey && (!recaptchaAnswer || !recaptchaFieldName)) return; + + let fieldsOk = true; + + const httpEnabled = GetConfiguration('auth.system.http.enabled'); + + const requestData = {}; + const requestKeys: string[] = []; + const requestValues: string[] = []; + + fields.map(field => + { + if(!field.value || field.value.length === 0) + { + fieldsOk = false; + return false; + } + + if(httpEnabled) + requestData[field.name] = field.value; + else + { + requestKeys.push(field.name); + requestValues.push(field.value.toString()); + } + return true; + }); + + + if(!fieldsOk) return; + + if(recaptchaPublicKey) requestData[recaptchaFieldName] = recaptchaAnswer; + + setIsLoading(true); + + if(httpEnabled) + { + sendHttpAuthentication(JSON.stringify(requestData)); + } + else + { + sendPacketAuthentication(requestKeys, requestValues); + } + }, [ fields, recaptchaPublicKey, recaptchaAnswer ]); + + const handleAction = useCallback((action: string, value?: string) => + { + if(!action) return; + + switch(action) + { + case 'toggle_login': + setShowLogin(value => !value); + return; + case 'recaptcha_load': + setRecaptchaAnswer(value); + return; + case 'send': + sendAuthentication(); + return; + } + }, [ fields, recaptchaAnswer ]); + + if(!fields) return null; + + return ( +
+
+ + { recaptchaPublicKey && handleAction('recaptcha_load', event) } /> } +
+ + +
+
+ ); +} diff --git a/src/views/auth/AuthView.types.ts b/src/views/auth/AuthView.types.ts new file mode 100644 index 00000000..95c65d29 --- /dev/null +++ b/src/views/auth/AuthView.types.ts @@ -0,0 +1,14 @@ +export interface AuthViewProps +{} + +export class AuthField +{ + constructor( + public name: string, + public label: string, + public type: string, + public col: number, + public value: string = '' + ) + {} +} diff --git a/src/views/auth/views/form/AuthFormView.tsx b/src/views/auth/views/form/AuthFormView.tsx new file mode 100644 index 00000000..239a9887 --- /dev/null +++ b/src/views/auth/views/form/AuthFormView.tsx @@ -0,0 +1,30 @@ +import { FC, useCallback } from 'react'; +import { AuthFormViewProps } from './AuthFormView.types'; + +export const AuthFormView: FC = props => +{ + const { fields = null, setFieldValue = null, isLoading = null } = props; + + const getFieldValue = useCallback((key: string) => + { + const field = fields.find(field => field.name === key); + + if(!field) return; + + return field.value; + }, [ fields ]); + + if(!fields) return null; + + return (<> +
+ { fields.map(field => + { + return
+
{ field.label }
+ setFieldValue(field.name, event.target.value.toString())} /> +
; + }) } +
+ ); +} diff --git a/src/views/auth/views/form/AuthFormView.types.ts b/src/views/auth/views/form/AuthFormView.types.ts new file mode 100644 index 00000000..b35c7fe4 --- /dev/null +++ b/src/views/auth/views/form/AuthFormView.types.ts @@ -0,0 +1,8 @@ +import { AuthField } from './../../AuthView.types'; + +export interface AuthFormViewProps +{ + fields: AuthField[]; + setFieldValue: (key: string, value: string | number) => void; + isLoading: boolean; +}