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;
+}