Basic authentication forms (#347)

This commit is contained in:
Piotr Rogowski 2021-12-25 23:32:07 +01:00 committed by GitHub
parent a293e95979
commit a3527e0b77
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 2251 additions and 30 deletions

9
.env
View File

@ -1,2 +1,9 @@
BROWSER=none
NPM_GITHUB_TOKEN=
REACT_APP_FIREBASE_APP_SENTRY_DSN=
REACT_APP_FIREBASE_API_KEY=
REACT_APP_FIREBASE_AUTH_DOMAIN=
REACT_APP_FIREBASE_PROJECT_ID=
REACT_APP_FIREBASE_STORAGE_BUCKET=
REACT_APP_FIREBASE_MESSAGING_SENDER_ID=
REACT_APP_FIREBASE_APP_ID=
REACT_APP_FIREBASE_MEASUREMENT_ID=

1851
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -35,6 +35,7 @@
"@sentry/tracing": "^6.16.1",
"@speedy-tuner/types": "^0.2.1",
"antd": "^4.17.4",
"firebase": "^9.6.1",
"mlg-converter": "^0.5.1",
"pako": "^2.0.4",
"react": "^17.0.1",

View File

@ -64,6 +64,10 @@ html, body {
overflow-x: hidden;
}
.ant-checkbox-wrapper {
user-select: none;
}
.table {
margin: 20px;

View File

@ -10,6 +10,8 @@ import {
import { Layout } from 'antd';
import { connect } from 'react-redux';
import {
ReactNode,
useCallback,
useEffect,
useMemo,
} from 'react';
@ -32,6 +34,8 @@ import Log from './components/Log';
import Diagnose from './components/Diagnose';
import useStorage from './hooks/useStorage';
import useConfig from './hooks/useConfig';
import Login from './components/Auth/Login';
import SignUp from './components/Auth/SignUp';
const { Content } = Layout;
@ -73,7 +77,21 @@ const App = ({ ui, config }: { ui: UIState, config: ConfigType }) => {
// window.removeEventListener('beforeunload', beforeUnload);
// };
// eslint-disable-next-line react-hooks/exhaustive-deps
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const ContentFor = useCallback((props: { children: ReactNode, marginLeft?: number }) => {
const { children, marginLeft } = props;
return (
<Layout style={{ marginLeft }}>
<Layout className="app-content">
<Content>
{children}
</Content>
</Layout>
</Layout>
);
}, []);
return (
@ -102,22 +120,24 @@ const App = ({ ui, config }: { ui: UIState, config: ConfigType }) => {
</Layout>
</Route>
<Route path={Routes.LOG}>
<Layout style={{ marginLeft: margin }}>
<Layout className="app-content">
<Content>
<Log />
</Content>
</Layout>
</Layout>
<ContentFor marginLeft={margin}>
<Log />
</ContentFor>
</Route>
<Route>
<Layout style={{ marginLeft: margin }}>
<Layout className="app-content">
<Content>
<Diagnose />
</Content>
</Layout>
</Layout>
<Route path={Routes.DIAGNOSE}>
<ContentFor marginLeft={margin}>
<Diagnose />
</ContentFor>
</Route>
<Route path={Routes.LOGIN}>
<ContentFor>
<Login />
</ContentFor>
</Route>
<Route path={Routes.SIGN_UP}>
<ContentFor>
<SignUp />
</ContentFor>
</Route>
</Switch>
</Layout>

View File

@ -0,0 +1,92 @@
import { useState } from 'react';
import {
Form,
Input,
Button,
Divider,
notification,
} from 'antd';
import {
MailOutlined,
LockOutlined,
} from '@ant-design/icons';
import { Link } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext';
import { Routes } from '../../routes';
import validateMessages from './validateMessages';
const { Item } = Form;
const Login = () => {
const [isLoading, setIsLoading] = useState(false);
const { login } = useAuth();
const onFinish = async ({ email, password }: { email: string, password: string }) => {
setIsLoading(true);
try {
await login(email, password);
} catch (err) {
console.warn(err);
notification.error({
message: 'Login failed',
description: (err as Error).message,
});
}
setIsLoading(false);
};
return (
<div style={{
padding: 20,
maxWidth: 350,
margin: '0 auto',
}}>
<Divider>Login</Divider>
<Form
initialValues={{ remember: true }}
onFinish={onFinish}
validateMessages={validateMessages}
autoComplete="off"
>
<Item
name="email"
rules={[{ required: true, type: 'email' }]}
hasFeedback
>
<Input
prefix={<MailOutlined />}
placeholder="Email"
/>
</Item>
<Item
name="password"
rules={[{ required: true }]}
hasFeedback
>
<Input.Password
placeholder="Password"
prefix={<LockOutlined />}
/>
</Item>
<Item>
<Button
type="primary"
htmlType="submit"
style={{ width: '100%' }}
loading={isLoading}
>
Log in
</Button>
</Item>
<Link to={Routes.SIGN_UP}>Sign Up now!</Link>
<Link to="/" style={{ float: 'right' }}>
Forgot password?
</Link>
</Form>
</div>
);
};
export default Login;

View File

@ -0,0 +1,115 @@
import { useState } from 'react';
import {
Form,
Input,
Button,
Divider,
notification,
} from 'antd';
import {
MailOutlined,
LockOutlined,
} from '@ant-design/icons';
import { Link } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext';
import { Routes } from '../../routes';
import validateMessages from './validateMessages';
const { Item } = Form;
const strongPassword = /^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[!@#$%^&*])(?=.{8,})/;
const SignUp = () => {
const [isLoading, setIsLoading] = useState(false);
const { signUp } = useAuth();
const onFinish = async ({ email, password }: { email: string, password: string }) => {
setIsLoading(true);
try {
await signUp(email, password);
} catch (err) {
console.warn(err);
notification.error({
message: 'Failed to create an account',
description: (err as Error).message,
});
}
setIsLoading(false);
};
return (
<div style={{
padding: 20,
maxWidth: 350,
margin: '0 auto',
}}>
<Divider>Sign Up</Divider>
<Form
initialValues={{ remember: true }}
onFinish={onFinish}
validateMessages={validateMessages}
autoComplete="off"
>
<Item
name="email"
rules={[{ required: true, type: 'email' }]}
hasFeedback
>
<Input
prefix={<MailOutlined />}
placeholder="Email"
/>
</Item>
<Item
name="password"
rules={[
{ required: true },
{ pattern: strongPassword, message: 'Password is too weak!' },
]}
hasFeedback
>
<Input.Password
placeholder="Password"
prefix={<LockOutlined />}
/>
</Item>
<Item
name="passwordConfirmation"
rules={[
{ required: true },
({ getFieldValue }) => ({
validator(_, value) {
if (!value || getFieldValue('password') === value) {
return Promise.resolve();
}
return Promise.reject(new Error('Passwords don\'t match!'));
},
}),
]}
hasFeedback
>
<Input.Password
placeholder="Password confirmation"
prefix={<LockOutlined />}
/>
</Item>
<Item>
<Button
type="primary"
htmlType="submit"
style={{ width: '100%' }}
loading={isLoading}
>
Sign up
</Button>
</Item>
Or <Link to={Routes.LOGIN}>login</Link> if you already have an account!
</Form>
</div>
);
};
export default SignUp;

View File

@ -0,0 +1,8 @@
const validateMessages = {
required: 'This field is required!',
types: {
email: 'This is not a valid email!',
},
};
export default validateMessages;

View File

@ -5,7 +5,10 @@ import {
Row,
Col,
} from 'antd';
import { InfoCircleOutlined } from '@ant-design/icons';
import {
InfoCircleOutlined,
FieldTimeOutlined,
} from '@ant-design/icons';
import { connect } from 'react-redux';
import {
AppState,
@ -22,19 +25,22 @@ const mapStateToProps = (state: AppState) => ({
const firmware = (signature: string) => (
<Space>
<InfoCircleOutlined />{signature}
<InfoCircleOutlined />
{signature}
</Space>
);
const StatusBar = ({ status, config }: { status: StatusState, config: ConfigState }) => (
<Footer className="app-status-bar">
<Row>
<Col span={8} />
<Col span={8} style={{ textAlign: 'center' }}>
<Col span={12}>
{config.megaTune && firmware(config.megaTune.signature)}
</Col>
<Col span={8} style={{ textAlign: 'right' }}>
{status.text}
<Col span={12} style={{ textAlign: 'right' }}>
<Space>
<FieldTimeOutlined />
{status.text}
</Space>
</Col>
</Row>
</Footer>

View File

@ -3,6 +3,7 @@ import {
useLocation,
useHistory,
} from 'react-router';
import { Link } from 'react-router-dom';
import {
Layout,
Space,
@ -38,6 +39,8 @@ import {
SearchOutlined,
ToolOutlined,
FundOutlined,
UserAddOutlined,
LogoutOutlined,
} from '@ant-design/icons';
import {
useEffect,
@ -51,6 +54,7 @@ import {
isToggleSidebar,
} from '../utils/keyboard/shortcuts';
import { Routes } from '../routes';
import { useAuth } from '../contexts/AuthContext';
const { Header } = Layout;
const { useBreakpoint } = Grid;
@ -59,16 +63,32 @@ const { SubMenu } = Menu;
const TopBar = () => {
const { sm } = useBreakpoint();
const { pathname } = useLocation();
const { currentUser, logout } = useAuth();
const history = useHistory();
const matchedTabPath = useMemo(() => matchPath(pathname, { path: Routes.TAB }), [pathname]);
const userMenu = (
<Menu>
<Menu.Item key="login" disabled icon={<LoginOutlined />}>
Login / Sign-up
</Menu.Item>
{currentUser ? (
<Menu.Item key="logout" icon={<LogoutOutlined />} onClick={logout}>
Logout
</Menu.Item>
) : (
<>
<Menu.Item key="login" icon={<LoginOutlined />}>
<Link to={Routes.LOGIN}>Login</Link>
</Menu.Item>
<Menu.Item key="sign-up" icon={<UserAddOutlined />}>
<Link to={Routes.SIGN_UP}>Sign Up</Link>
</Menu.Item>
</>
)}
<Menu.Item key="gh" icon={<GithubOutlined />}>
<a href="https://github.com/speedy-tuner/speedy-tuner-cloud" target="__blank" rel="noopener noreferrer">
<a
href="https://github.com/speedy-tuner/speedy-tuner-cloud"
target="__blank"
rel="noopener noreferrer"
>
GitHub
</a>
</Menu.Item>

View File

@ -0,0 +1,59 @@
import { UserCredential } from 'firebase/auth';
import {
createContext,
ReactNode,
useContext,
useEffect,
useMemo,
useState,
} from 'react';
import {
auth,
createUserWithEmailAndPassword,
signInWithEmailAndPassword,
signOut,
} from '../firebase';
const AuthContext = createContext<any>(null);
interface AuthValue {
currentUser?: UserCredential,
signUp: (email: string, password: string) => Promise<UserCredential>,
login: (email: string, password: string) => Promise<UserCredential>,
logout: () => Promise<void>,
}
const useAuth = () => useContext<AuthValue>(AuthContext);
const AuthProvider = (props: { children: ReactNode }) => {
const { children } = props;
const [currentUser, setCurrentUser] = useState<any | null>(null);
const [isLoading, setIsLoading] = useState(true);
const value = useMemo(() => ({
currentUser,
signUp: (email: string, password: string) => createUserWithEmailAndPassword(auth, email, password),
login: (email: string, password: string) => signInWithEmailAndPassword(auth, email, password),
logout: () => signOut(auth),
}), [currentUser]);
useEffect(() => {
const unsubscribe = auth.onAuthStateChanged((user) => {
setCurrentUser(user);
setIsLoading(false);
});
return unsubscribe;
}, []);
return (
<AuthContext.Provider value={value}>
{!isLoading && children}
</AuthContext.Provider>
);
};
export {
useAuth,
AuthProvider,
};

30
src/firebase.ts Normal file
View File

@ -0,0 +1,30 @@
import { initializeApp } from 'firebase/app';
import {
getAuth,
createUserWithEmailAndPassword,
signInWithEmailAndPassword,
signOut,
} from 'firebase/auth';
import { getAnalytics } from 'firebase/analytics';
const firebaseConfig = {
apiKey: process.env.REACT_APP_FIREBASE_API_KEY,
authDomain: process.env.REACT_APP_FIREBASE_AUTH_DOMAIN,
projectId: process.env.REACT_APP_FIREBASE_PROJECT_ID,
storageBucket: process.env.REACT_APP_FIREBASE_STORAGE_BUCKET,
messagingSenderId: process.env.REACT_APP_FIREBASE_MESSAGING_SENDER_ID,
appId: process.env.REACT_APP_FIREBASE_APP_ID,
measurementId: process.env.REACT_APP_FIREBASE_MEASUREMENT_ID,
};
const firebase = initializeApp(firebaseConfig);
const analytics = getAnalytics(firebase);
const auth = getAuth(firebase);
export {
auth,
analytics,
createUserWithEmailAndPassword,
signInWithEmailAndPassword,
signOut,
};

View File

@ -10,6 +10,7 @@ import {
isProduction,
sentryDsn,
} from './utils/env';
import { AuthProvider } from './contexts/AuthContext';
if (isProduction) {
Sentry.init({
@ -22,9 +23,11 @@ if (isProduction) {
ReactDOM.render(
<HashRouter>
<Provider store={store}>
<App />
</Provider>
<AuthProvider>
<Provider store={store}>
<App />
</Provider>
</AuthProvider>
</HashRouter>,
document.getElementById('root'),
);

View File

@ -6,4 +6,8 @@ export enum Routes {
DIALOG = '/tune/:category/:dialog',
LOG = '/log',
DIAGNOSE = '/diagnose',
LOGIN = '/login',
LOGOUT = '/logout',
SIGN_UP = '/sign-up',
FORGOT_PASSWORD = '/forgot-password',
}

View File

@ -91,6 +91,7 @@ export const loadLogs = (onProgress?: onProgressType, signal?: AbortSignal) =>
export const loadCompositeLogs = (onProgress?: onProgressType, signal?: AbortSignal) =>
fetchWithProgress(
'https://d29mjpbgm6k6md.cloudfront.net/trigger-logs/composite_1_2.csv.gz',
// 'https://d29mjpbgm6k6md.cloudfront.net/trigger-logs/composite_miata.csv.gz',
// 'https://d29mjpbgm6k6md.cloudfront.net/trigger-logs/2.csv.gz',
onProgress,
signal,