Merge pull request #659 from mz8i/feature/state-mgmt
Move user authentication to context and hooks
This commit is contained in:
commit
bc55871661
@ -6,7 +6,7 @@ import React from 'react';
|
||||
import { hydrate } from 'react-dom';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
|
||||
import App from './frontend/app';
|
||||
import { App } from './frontend/app';
|
||||
|
||||
const data = (window as any).__PRELOADED_STATE__; // TODO: remove any
|
||||
|
||||
|
@ -2,7 +2,7 @@ import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
|
||||
import App from './app';
|
||||
import { App } from './app';
|
||||
|
||||
describe('<App />', () => {
|
||||
test('renders without exploding', () => {
|
||||
|
@ -1,9 +1,11 @@
|
||||
import React, { Fragment } from 'react';
|
||||
import { Link, Route, Switch } from 'react-router-dom';
|
||||
import React from 'react';
|
||||
import { Route, Switch } from 'react-router-dom';
|
||||
|
||||
import 'bootstrap/dist/css/bootstrap.min.css';
|
||||
import './app.css';
|
||||
|
||||
import { AuthRoute, PrivateRoute } from './route';
|
||||
import { AuthContext, AuthProvider } from './auth-context';
|
||||
import { Header } from './header';
|
||||
import MapApp from './map-app';
|
||||
import { Building } from './models/building';
|
||||
@ -20,10 +22,11 @@ import OrdnanceSurveyLicencePage from './pages/ordnance-survey-licence';
|
||||
import OrdnanceSurveyUprnPage from './pages/ordnance-survey-uprn';
|
||||
import PrivacyPolicyPage from './pages/privacy-policy';
|
||||
import ForgottenPassword from './user/forgotten-password';
|
||||
import Login from './user/login';
|
||||
import MyAccountPage from './user/my-account';
|
||||
import { Login } from './user/login';
|
||||
import { MyAccountPage } from './user/my-account';
|
||||
import PasswordReset from './user/password-reset';
|
||||
import SignUp from './user/signup';
|
||||
import { SignUp } from './user/signup';
|
||||
import { NotFound } from './pages/not-found';
|
||||
|
||||
|
||||
interface AppProps {
|
||||
@ -34,10 +37,6 @@ interface AppProps {
|
||||
revisionId: number;
|
||||
}
|
||||
|
||||
interface AppState {
|
||||
user?: User;
|
||||
}
|
||||
|
||||
/**
|
||||
* App component
|
||||
*
|
||||
@ -50,64 +49,27 @@ interface AppState {
|
||||
* map or other pages are rendered, based on the URL. Use a react-router-dom <Link /> in
|
||||
* child components to navigate without a full page reload.
|
||||
*/
|
||||
class App extends React.Component<AppProps, AppState> {
|
||||
static mapAppPaths = ['/', '/:mode(view|edit|multi-edit)/:category/:building(\\d+)?/(history)?'];
|
||||
|
||||
constructor(props: Readonly<AppProps>) {
|
||||
super(props);
|
||||
export const App: React.FC<AppProps> = props => {
|
||||
const mapAppPaths = ['/', '/:mode(view|edit|multi-edit)/:category/:building(\\d+)?/(history)?'];
|
||||
|
||||
this.state = {
|
||||
user: props.user
|
||||
};
|
||||
this.login = this.login.bind(this);
|
||||
this.updateUser = this.updateUser.bind(this);
|
||||
this.logout = this.logout.bind(this);
|
||||
}
|
||||
|
||||
login(user) {
|
||||
if (user.error) {
|
||||
this.logout();
|
||||
return;
|
||||
}
|
||||
this.setState({user: user});
|
||||
}
|
||||
|
||||
updateUser(user){
|
||||
this.setState({user: { ...this.state.user, ...user }});
|
||||
}
|
||||
|
||||
logout() {
|
||||
this.setState({user: undefined});
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Fragment>
|
||||
<AuthProvider>
|
||||
<Switch>
|
||||
<Route exact path={App.mapAppPaths}>
|
||||
<Header user={this.state.user} animateLogo={false} />
|
||||
<Route exact path={mapAppPaths}>
|
||||
<Header animateLogo={false} />
|
||||
</Route>
|
||||
<Route>
|
||||
<Header user={this.state.user} animateLogo={true} />
|
||||
<Header animateLogo={true} />
|
||||
</Route>
|
||||
</Switch>
|
||||
<Switch>
|
||||
<Route exact path="/about.html" component={AboutPage} />
|
||||
<Route exact path="/login.html">
|
||||
<Login user={this.state.user} login={this.login} />
|
||||
</Route>
|
||||
<Route exact path="/forgotten-password.html" component={ForgottenPassword} />
|
||||
<Route exact path="/password-reset.html" component={PasswordReset} />
|
||||
<Route exact path="/sign-up.html">
|
||||
<SignUp user={this.state.user} login={this.login} />
|
||||
</Route>
|
||||
<Route exact path="/my-account.html">
|
||||
<MyAccountPage
|
||||
user={this.state.user}
|
||||
updateUser={this.updateUser}
|
||||
logout={this.logout}
|
||||
/>
|
||||
</Route>
|
||||
<AuthRoute exact path="/login.html" component={Login} />
|
||||
<AuthRoute exact path="/forgotten-password.html" component={ForgottenPassword} />
|
||||
<AuthRoute exact path="/password-reset.html" component={PasswordReset} />
|
||||
<AuthRoute exact path="/sign-up.html" component={SignUp} />
|
||||
<PrivateRoute exact path="/my-account.html" component={MyAccountPage} />
|
||||
<Route exact path="/privacy-policy.html" component={PrivacyPolicyPage} />
|
||||
<Route exact path="/contributor-agreement.html" component={ContributorAgreementPage} />
|
||||
<Route exact path="/ordnance-survey-licence.html" component={OrdnanceSurveyLicencePage} />
|
||||
@ -118,38 +80,22 @@ class App extends React.Component<AppProps, AppState> {
|
||||
<Route exact path="/code-of-conduct.html" component={CodeOfConductPage} />
|
||||
<Route exact path="/leaderboard.html" component={LeaderboardPage} />
|
||||
<Route exact path="/history.html" component={ChangesPage} />
|
||||
<Route exact path={App.mapAppPaths} render={(props) => (
|
||||
<Route exact path={mapAppPaths} render={(routeProps) => (
|
||||
<AuthContext.Consumer>
|
||||
{({user}) =>
|
||||
<MapApp
|
||||
{...props}
|
||||
building={this.props.building}
|
||||
building_like={this.props.building_like}
|
||||
user_verified={this.props.user_verified}
|
||||
user={this.state.user}
|
||||
revisionId={this.props.revisionId}
|
||||
{...routeProps}
|
||||
building={props.building}
|
||||
building_like={props.building_like}
|
||||
user_verified={props.user_verified}
|
||||
user={user}
|
||||
revisionId={props.revisionId}
|
||||
/>
|
||||
}
|
||||
</AuthContext.Consumer>
|
||||
)} />
|
||||
<Route component={NotFound} />
|
||||
</Switch>
|
||||
</Fragment>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Component to fall back on in case of 404 or no other match
|
||||
*/
|
||||
const NotFound = () => (
|
||||
<article>
|
||||
<section className="main-col">
|
||||
<h1 className="h1">Page not found</h1>
|
||||
<p className="lead">
|
||||
|
||||
We can’t find that one anywhere.
|
||||
|
||||
</p>
|
||||
<Link className="btn btn-outline-dark" to="/">Back home</Link>
|
||||
</section>
|
||||
</article>
|
||||
);
|
||||
|
||||
export default App;
|
||||
};
|
||||
|
203
app/src/frontend/auth-context.tsx
Normal file
203
app/src/frontend/auth-context.tsx
Normal file
@ -0,0 +1,203 @@
|
||||
import React, { createContext, useCallback, useContext, useEffect, useState } from 'react';
|
||||
|
||||
import { apiPost, apiGet, apiDelete } from './apiHelpers';
|
||||
import { User } from './models/user';
|
||||
|
||||
interface AuthContextState {
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
user: User;
|
||||
userError: string;
|
||||
login: (data: UserLoginData, cb?: (err) => void) => void;
|
||||
logout: (cb?: (err) => void) => void;
|
||||
signup: (data: UserSignupData, cb?: (err) => void) => void;
|
||||
deleteAccount: (cb?: (err) => void) => void;
|
||||
generateApiKey: (cb?: (err) => void) => void;
|
||||
}
|
||||
|
||||
interface UserLoginData {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
interface UserSignupData {
|
||||
username: string;
|
||||
email: string;
|
||||
confirmEmail: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
const stub = (): never => {
|
||||
throw new Error('AuthProvider not set up');
|
||||
};
|
||||
|
||||
export const AuthContext = createContext<AuthContextState>({
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
user: undefined,
|
||||
userError: undefined,
|
||||
login: stub,
|
||||
logout: stub,
|
||||
signup: stub,
|
||||
generateApiKey: stub,
|
||||
deleteAccount: stub,
|
||||
});
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
export const AuthProvider: React.FC = ({
|
||||
children
|
||||
}) => {
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const [user, setUser] = useState<User>(undefined);
|
||||
const [userError, setUserError] = useState<string>(undefined);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const login = useCallback(async (data: UserLoginData, cb: (err) => void = noop) => {
|
||||
if(isAuthenticated) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const res = await apiPost('/api/login', { ...data });
|
||||
|
||||
if (res.error) {
|
||||
setIsLoading(false);
|
||||
cb(res.error);
|
||||
} else {
|
||||
setIsAuthenticated(true);
|
||||
}
|
||||
} catch(err) {
|
||||
cb('Error logging in.');
|
||||
}
|
||||
}, [isAuthenticated]);
|
||||
|
||||
const logout = useCallback(async (cb: (err) => void = noop) => {
|
||||
if(!isAuthenticated) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const res = await apiPost('/api/logout');
|
||||
|
||||
if (res.error) {
|
||||
setIsLoading(false);
|
||||
cb(res.error);
|
||||
} else {
|
||||
setIsAuthenticated(false);
|
||||
}
|
||||
} catch(err) {
|
||||
cb('Error logging out');
|
||||
}
|
||||
|
||||
}, [isAuthenticated]);
|
||||
|
||||
const signup = useCallback(async (data: UserSignupData, cb: (err) => void = noop) => {
|
||||
if(isAuthenticated) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const res = await apiPost('/api/users', {
|
||||
username: data.username,
|
||||
email: data.email,
|
||||
confirm_email: data.confirmEmail,
|
||||
password: data.password
|
||||
});
|
||||
|
||||
if(res.error) {
|
||||
setIsLoading(false);
|
||||
cb(res.error);
|
||||
} else {
|
||||
setIsAuthenticated(true);
|
||||
}
|
||||
} catch(err) {
|
||||
cb('Error signing up.');
|
||||
}
|
||||
}, [isAuthenticated]);
|
||||
|
||||
async function updateUserData(): Promise<void> {
|
||||
setUserError(undefined);
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const user = await apiGet('/api/users/me');
|
||||
if (user.error) {
|
||||
setUserError(user.error);
|
||||
} else {
|
||||
setUser(user);
|
||||
setIsAuthenticated(true);
|
||||
}
|
||||
} catch(err) {
|
||||
setUserError('Error loading user info.');
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
const generateApiKey = useCallback(async (cb: (err) => void = noop) => {
|
||||
try {
|
||||
const res = await apiPost('/api/api/key');
|
||||
if (res.error) {
|
||||
cb(res.error);
|
||||
} else {
|
||||
updateUserData();
|
||||
}
|
||||
} catch(err) {
|
||||
cb('Error getting API key.');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const deleteAccount = useCallback(async (cb) => {
|
||||
try {
|
||||
const data = await apiDelete('/api/users/me');
|
||||
if (data.error) {
|
||||
cb(data.error);
|
||||
} else {
|
||||
setIsAuthenticated(false);
|
||||
}
|
||||
} catch(err) {
|
||||
cb('Error getting API key.');
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if(isAuthenticated) {
|
||||
updateUserData();
|
||||
} else {
|
||||
setUser(undefined);
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [isAuthenticated]);
|
||||
|
||||
// update user data initially to check if already logged in
|
||||
useEffect(() => {
|
||||
updateUserData();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{
|
||||
isAuthenticated,
|
||||
isLoading,
|
||||
user,
|
||||
userError,
|
||||
login,
|
||||
logout,
|
||||
signup,
|
||||
generateApiKey,
|
||||
deleteAccount
|
||||
}}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useAuth = (): AuthContextState => {
|
||||
return useContext(AuthContext);
|
||||
};
|
@ -235,10 +235,6 @@ const withCopyEdit = (WrappedComponent: React.ComponentType<CategoryViewProps>)
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.props.mode === 'edit' && !this.props.user){
|
||||
return <Redirect to="/sign-up.html" />;
|
||||
}
|
||||
|
||||
const currentBuilding = this.getEditedBuilding();
|
||||
|
||||
const values_to_copy = {};
|
||||
|
@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { Link, Redirect } from 'react-router-dom';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { parseJsonOrDefault } from '../../helpers';
|
||||
import ErrorBox from '../components/error-box';
|
||||
@ -18,9 +18,6 @@ interface MultiEditProps {
|
||||
}
|
||||
|
||||
const MultiEdit: React.FC<MultiEditProps> = (props) => {
|
||||
if (!props.user){
|
||||
return <Redirect to="/sign-up.html" />;
|
||||
}
|
||||
if (props.category === 'like') {
|
||||
// special case for likes
|
||||
return (
|
||||
|
@ -16,6 +16,7 @@ import {
|
||||
faPaintBrush,
|
||||
faQuestionCircle,
|
||||
faSearch,
|
||||
faSpinner,
|
||||
faTimes
|
||||
} from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
@ -35,7 +36,8 @@ library.add(
|
||||
faCaretUp,
|
||||
faCaretRight,
|
||||
faSearch,
|
||||
faEye
|
||||
faEye,
|
||||
faSpinner
|
||||
);
|
||||
|
||||
const HelpIcon = () => (
|
||||
@ -94,6 +96,10 @@ const SearchIcon = () => (
|
||||
<FontAwesomeIcon icon="search" />
|
||||
);
|
||||
|
||||
const SpinnerIcon: React.FC<{spin?: boolean}> = ({spin=true}) => (
|
||||
<FontAwesomeIcon icon="spinner" spin={spin} />
|
||||
);
|
||||
|
||||
export {
|
||||
HelpIcon,
|
||||
InfoIcon,
|
||||
@ -108,5 +114,6 @@ export {
|
||||
UpIcon,
|
||||
RightIcon,
|
||||
SearchIcon,
|
||||
VerifyIcon
|
||||
VerifyIcon,
|
||||
SpinnerIcon
|
||||
};
|
||||
|
@ -5,7 +5,7 @@ import './header.css';
|
||||
|
||||
import { Logo } from './components/logo';
|
||||
import { WithSeparator } from './components/with-separator';
|
||||
import { User } from './models/user';
|
||||
import { useAuth } from './auth-context';
|
||||
|
||||
|
||||
interface MenuLink {
|
||||
@ -132,16 +132,16 @@ function getCurrentMenuLinks(username: string): MenuLink[][] {
|
||||
];
|
||||
}
|
||||
|
||||
const Menu: React.FC<{ onNavigate: () => void }> = ({ onNavigate }) => {
|
||||
const { user } = useAuth();
|
||||
|
||||
const Menu : React.FC<{
|
||||
menuLinkSections: MenuLink[][],
|
||||
onNavigate: () => void
|
||||
}> = ({ menuLinkSections, onNavigate }) => (
|
||||
const menuLinkSections = getCurrentMenuLinks(user?.username);
|
||||
return (
|
||||
<WithSeparator separator={<hr />}>
|
||||
{menuLinkSections.map((section, idx) =>
|
||||
<ul key={`menu-section-${idx}`} className="navbar-nav flex-container">
|
||||
{section.map(item => (
|
||||
<li className={`nav-item`}>
|
||||
<li className='nav-item' key={`${item.to}-${item.text}`}>
|
||||
{
|
||||
item.disabled ?
|
||||
<LinkStub note={item.note}>{item.text}</LinkStub> :
|
||||
@ -154,15 +154,16 @@ const Menu : React.FC<{
|
||||
</ul>
|
||||
)}
|
||||
</WithSeparator>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
const InternalNavLink: React.FC<{to: string, onClick: () => void}> = ({ to, onClick, children}) => (
|
||||
const InternalNavLink: React.FC<{to: string; onClick: () => void}> = ({ to, onClick, children}) => (
|
||||
<NavLink className="nav-link" to={to} onClick={onClick}>
|
||||
{children}
|
||||
</NavLink>
|
||||
);
|
||||
|
||||
const ExternalNavLink: React.FC<{to:string}> = ({ to, children }) => (
|
||||
const ExternalNavLink: React.FC<{to: string}> = ({ to, children }) => (
|
||||
<a className="nav-link" href={to}>
|
||||
{children}
|
||||
</a>
|
||||
@ -175,17 +176,14 @@ const LinkStub: React.FC<{note: string}> = ({note, children}) => (
|
||||
</a>
|
||||
);
|
||||
|
||||
export const Header : React.FC<{
|
||||
user: User;
|
||||
export const Header: React.FC<{
|
||||
animateLogo: boolean;
|
||||
}> = ({ user, animateLogo }) => {
|
||||
}> = ({ animateLogo }) => {
|
||||
const [collapseMenu, setCollapseMenu] = useState(true);
|
||||
|
||||
const toggleCollapse = () => setCollapseMenu(!collapseMenu);
|
||||
const handleNavigate = () => setCollapseMenu(true);
|
||||
|
||||
const currentMenuLinks = getCurrentMenuLinks(user?.username);
|
||||
|
||||
return (
|
||||
<header className="main-header navbar navbar-light">
|
||||
<div className="nav-header">
|
||||
@ -203,7 +201,7 @@ export const Header : React.FC<{
|
||||
</button>
|
||||
</div>
|
||||
<nav className={collapseMenu ? 'collapse navbar-collapse' : 'navbar-collapse'}>
|
||||
<Menu menuLinkSections={currentMenuLinks} onNavigate={handleNavigate}></Menu>
|
||||
<Menu onNavigate={handleNavigate}></Menu>
|
||||
</nav>
|
||||
</header>
|
||||
);
|
||||
|
@ -14,6 +14,7 @@ import Sidebar from './building/sidebar';
|
||||
import ColouringMap from './map/map';
|
||||
import { Building } from './models/building';
|
||||
import Welcome from './pages/welcome';
|
||||
import { PrivateRoute } from './route';
|
||||
|
||||
interface MapAppRouteParams {
|
||||
mode: 'view' | 'edit' | 'multi-edit';
|
||||
@ -205,6 +206,9 @@ class MapApp extends React.Component<MapAppProps, MapAppState> {
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<Switch>
|
||||
<PrivateRoute path="/:mode(edit|multi-edit)" /> {/* empty private route to ensure auth for editing */}
|
||||
</Switch>
|
||||
<Switch>
|
||||
<Route exact path="/">
|
||||
<Sidebar>
|
||||
|
19
app/src/frontend/pages/not-found.tsx
Normal file
19
app/src/frontend/pages/not-found.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
/**
|
||||
* Component to fall back on in case of 404 or no other match
|
||||
*/
|
||||
export const NotFound: React.FC = () => (
|
||||
<article>
|
||||
<section className="main-col">
|
||||
<h1 className="h1">Page not found</h1>
|
||||
<p className="lead">
|
||||
|
||||
We can’t find that one anywhere.
|
||||
|
||||
</p>
|
||||
<Link className="btn btn-outline-dark" to="/">Back home</Link>
|
||||
</section>
|
||||
</article>
|
||||
);
|
47
app/src/frontend/route.tsx
Normal file
47
app/src/frontend/route.tsx
Normal file
@ -0,0 +1,47 @@
|
||||
import React from 'react'
|
||||
import { Route, RouteProps, Redirect } from 'react-router-dom';
|
||||
|
||||
import { useAuth } from './auth-context';
|
||||
|
||||
export const PrivateRoute: React.FC<RouteProps> = ({component: Component, children, ...rest}) => {
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
return <Route
|
||||
{...rest}
|
||||
render={props => {
|
||||
if(isAuthenticated) {
|
||||
if(Component) {
|
||||
return <Component {...props} />;
|
||||
} else if(children) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
} else {
|
||||
return <Redirect to={{
|
||||
pathname: "/login.html",
|
||||
state: { from: props.location.pathname }
|
||||
}} />;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
};
|
||||
|
||||
export const AuthRoute: React.FC<RouteProps> = ({ component: Component, children, ...rest}) => {
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
return <Route
|
||||
{...rest}
|
||||
render={props => {
|
||||
if(isAuthenticated) {
|
||||
const { from } = props.location.state ?? { from: '/my-account.html'};
|
||||
return <Redirect to={{pathname: from }} />;
|
||||
} else {
|
||||
if(Component) {
|
||||
return <Component {...props} />;
|
||||
} else if(children) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
};
|
@ -1,70 +1,28 @@
|
||||
import React, { Component } from 'react';
|
||||
import { Link, Redirect } from 'react-router-dom';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { apiGet, apiPost } from '../apiHelpers';
|
||||
import { useAuth } from '../auth-context';
|
||||
import ErrorBox from '../components/error-box';
|
||||
import { SpinnerIcon } from '../components/icons';
|
||||
import InfoBox from '../components/info-box';
|
||||
import SupporterLogos from '../components/supporter-logos';
|
||||
import { User } from '../models/user';
|
||||
|
||||
interface LoginProps {
|
||||
user: User;
|
||||
login: (user: User) => void;
|
||||
}
|
||||
export const Login: React.FC = () => {
|
||||
const {isLoading, login } = useAuth();
|
||||
|
||||
class Login extends Component<LoginProps, any> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
username: '',
|
||||
password: '',
|
||||
show_password: '',
|
||||
error: undefined
|
||||
};
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
this.handleChange = this.handleChange.bind(this);
|
||||
this.handleSubmit = this.handleSubmit.bind(this);
|
||||
}
|
||||
const [error, setError] = useState(undefined);
|
||||
|
||||
handleChange(event) {
|
||||
const target = event.target;
|
||||
const value = target.type === 'checkbox' ? target.checked : target.value;
|
||||
const name = target.name;
|
||||
const onSubmit = useCallback((e) => {
|
||||
e.preventDefault();
|
||||
setError(undefined);
|
||||
|
||||
this.setState({
|
||||
[name]: value
|
||||
});
|
||||
}
|
||||
login({ username, password });
|
||||
}, [username, password]);
|
||||
|
||||
handleSubmit(event) {
|
||||
event.preventDefault();
|
||||
this.setState({error: undefined});
|
||||
|
||||
apiPost('/api/login', this.state)
|
||||
.then(res => {
|
||||
if (res.error) {
|
||||
this.setState({error: res.error});
|
||||
} else {
|
||||
apiGet('/api/users/me')
|
||||
.then(user => {
|
||||
if (user.error) {
|
||||
this.setState({error: user.error});
|
||||
} else {
|
||||
this.props.login(user);
|
||||
}
|
||||
}).catch(
|
||||
(err) => this.setState({error: err})
|
||||
);
|
||||
}
|
||||
}).catch(
|
||||
(err) => this.setState({error: err})
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.props.user && !this.props.user.error) {
|
||||
return <Redirect to="/my-account.html" />;
|
||||
}
|
||||
return (
|
||||
<article>
|
||||
<section className="main-col">
|
||||
@ -75,28 +33,28 @@ class Login extends Component<LoginProps, any> {
|
||||
href="https://github.com/colouring-london/colouring-london/issues">
|
||||
report issues or problems</a>.
|
||||
</InfoBox>
|
||||
<ErrorBox msg={this.state.error} />
|
||||
<form onSubmit={this.handleSubmit}>
|
||||
<ErrorBox msg={error} />
|
||||
<form onSubmit={onSubmit}>
|
||||
<label htmlFor="username">Username*</label>
|
||||
<input name="username" id="username"
|
||||
className="form-control" type="text"
|
||||
value={this.state.username} onChange={this.handleChange}
|
||||
value={username} onChange={e => setUsername(e.target.value)}
|
||||
placeholder="not-your-real-name" required
|
||||
/>
|
||||
|
||||
<label htmlFor="password">Password</label>
|
||||
<input name="password" id="password"
|
||||
className="form-control"
|
||||
type={(this.state.show_password)? 'text': 'password'}
|
||||
value={this.state.password} onChange={this.handleChange}
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password} onChange={e => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
|
||||
<div className="form-check">
|
||||
<input id="show_password" name="show_password"
|
||||
className="form-check-input" type="checkbox"
|
||||
checked={this.state.show_password}
|
||||
onChange={this.handleChange}
|
||||
checked={showPassword}
|
||||
onChange={e => setShowPassword(e.target.checked)}
|
||||
/>
|
||||
<label htmlFor="show_password" className="form-check-label">Show password?</label>
|
||||
</div>
|
||||
@ -104,7 +62,8 @@ class Login extends Component<LoginProps, any> {
|
||||
<Link to="/forgotten-password.html">Forgotten password?</Link>
|
||||
|
||||
<div className="buttons-container with-space">
|
||||
<input type="submit" value="Log In" className="btn btn-primary" />
|
||||
<input type="submit" disabled={isLoading} value="Log In" className="btn btn-primary" />
|
||||
{isLoading && <span><SpinnerIcon />Logging in...</span>}
|
||||
</div>
|
||||
|
||||
Would you like to create an account instead?
|
||||
@ -112,7 +71,6 @@ class Login extends Component<LoginProps, any> {
|
||||
<div className="buttons-container with-space">
|
||||
<Link to="sign-up.html" className="btn btn-outline-dark">Sign Up</Link>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</section>
|
||||
<hr />
|
||||
@ -120,8 +78,6 @@ class Login extends Component<LoginProps, any> {
|
||||
<SupporterLogos />
|
||||
</section>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
export default Login;
|
||||
};
|
||||
|
@ -1,115 +1,63 @@
|
||||
import React, { Component, FormEvent } from 'react';
|
||||
import { Link, Redirect } from 'react-router-dom';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { apiDelete, apiPost } from '../apiHelpers';
|
||||
import { useAuth } from '../auth-context';
|
||||
import ConfirmationModal from '../components/confirmation-modal';
|
||||
import ErrorBox from '../components/error-box';
|
||||
import { User } from '../models/user';
|
||||
import { SpinnerIcon } from '../components/icons';
|
||||
|
||||
interface MyAccountPageProps {
|
||||
user: User;
|
||||
updateUser: (user: User) => void;
|
||||
logout: () => void;
|
||||
}
|
||||
export const MyAccountPage: React.FC = () => {
|
||||
const { isLoading, user, userError, logout, generateApiKey, deleteAccount } = useAuth();
|
||||
|
||||
interface MyAccountPageState {
|
||||
showDeleteConfirm: boolean;
|
||||
error: string;
|
||||
}
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [error, setError] = useState(undefined);
|
||||
|
||||
const handleLogout = useCallback((e) => {
|
||||
e.preventDefault();
|
||||
logout(setError);
|
||||
}, [logout]);
|
||||
|
||||
class MyAccountPage extends Component<MyAccountPageProps, MyAccountPageState> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
error: undefined,
|
||||
showDeleteConfirm: false
|
||||
};
|
||||
this.handleLogout = this.handleLogout.bind(this);
|
||||
this.handleGenerateKey = this.handleGenerateKey.bind(this);
|
||||
}
|
||||
const handleGenerateKey = useCallback(async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
handleLogout(event) {
|
||||
event.preventDefault();
|
||||
this.setState({error: undefined});
|
||||
setError(undefined);
|
||||
generateApiKey(setError);
|
||||
}, [generateApiKey]);
|
||||
|
||||
apiPost('/api/logout')
|
||||
.then(function(res){
|
||||
if (res.error) {
|
||||
this.setState({error: res.error});
|
||||
} else {
|
||||
this.props.logout();
|
||||
}
|
||||
}.bind(this)).catch(
|
||||
(err) => this.setState({error: err})
|
||||
);
|
||||
}
|
||||
const handleDeleteAccount = useCallback(() => {
|
||||
setError(undefined);
|
||||
deleteAccount(setError);
|
||||
}, [deleteAccount])
|
||||
|
||||
handleGenerateKey(event) {
|
||||
event.preventDefault();
|
||||
this.setState({error: undefined});
|
||||
|
||||
apiPost('/api/api/key')
|
||||
.then(res => {
|
||||
if (res.error) {
|
||||
this.setState({error: res.error});
|
||||
} else {
|
||||
this.props.updateUser(res);
|
||||
}
|
||||
}).catch(
|
||||
(err) => this.setState({error: err})
|
||||
);
|
||||
}
|
||||
|
||||
confirmDelete(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
this.setState({ showDeleteConfirm: true });
|
||||
}
|
||||
|
||||
hideConfirmDelete() {
|
||||
this.setState({ showDeleteConfirm: false });
|
||||
}
|
||||
|
||||
async handleDelete() {
|
||||
this.setState({ error: undefined });
|
||||
|
||||
try {
|
||||
const data = await apiDelete('/api/users/me');
|
||||
|
||||
if(data.error) {
|
||||
this.setState({ error: data.error });
|
||||
} else {
|
||||
this.props.logout();
|
||||
}
|
||||
} catch (err) {
|
||||
this.setState({ error: err });
|
||||
} finally {
|
||||
this.hideConfirmDelete();
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.props.user && !this.props.user.error) {
|
||||
if(!user && isLoading) {
|
||||
return (
|
||||
<article>
|
||||
<section className="main-col">
|
||||
<h1 className="h1">Welcome, {this.props.user.username}!</h1>
|
||||
<SpinnerIcon spin={true} /> Loading user info...
|
||||
</section>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<article>
|
||||
<section className="main-col">
|
||||
{ !isLoading && <ErrorBox msg={userError} /> }
|
||||
{!userError && (<>
|
||||
<h1 className="h1">Welcome, {user.username}!</h1>
|
||||
<p>
|
||||
|
||||
Colouring London is under active development. Please <a href="https://discuss.colouring.london/">discuss
|
||||
suggestions for improvements</a> and <a
|
||||
href="https://github.com/colouring-london/colouring-london/issues">
|
||||
report issues or problems</a>.
|
||||
|
||||
Colouring London is under active development. Please
|
||||
<a href="https://discuss.colouring.london/">discuss suggestions for improvements</a> and
|
||||
<a href="https://github.com/colouring-london/colouring-london/issues"> report issues or problems</a>.
|
||||
</p>
|
||||
<p>
|
||||
For reference, here are the <Link
|
||||
to="/privacy-policy.html">privacy policy</Link>, <Link
|
||||
to="/contributor-agreement.html">contributor agreement</Link> and <Link
|
||||
to="/data-accuracy.html">data accuracy agreement</Link>.
|
||||
For reference, here are the
|
||||
<Link to="/privacy-policy.html">privacy policy</Link>,
|
||||
<Link to="/contributor-agreement.html">contributor agreement</Link> and
|
||||
<Link to="/data-accuracy.html">data accuracy agreement</Link>.
|
||||
</p>
|
||||
<ErrorBox msg={this.state.error} />
|
||||
<form onSubmit={this.handleLogout}>
|
||||
<ErrorBox msg={error} />
|
||||
<form onSubmit={handleLogout}>
|
||||
<div className="buttons-container">
|
||||
<Link to="/edit/age" className="btn btn-warning">Start colouring</Link>
|
||||
<input className="btn btn-secondary" type="submit" value="Log out"/>
|
||||
@ -117,22 +65,21 @@ class MyAccountPage extends Component<MyAccountPageProps, MyAccountPageState> {
|
||||
</form>
|
||||
|
||||
<hr/>
|
||||
|
||||
<h2 className="h2">My Details</h2>
|
||||
<h3 className="h3">Username</h3>
|
||||
<p>{this.props.user.username}</p>
|
||||
<p>{user.username}</p>
|
||||
<h3 className="h3">Email Address</h3>
|
||||
<p>{this.props.user.email? this.props.user.email : '-'}</p>
|
||||
<p>{user.email || '-'}</p>
|
||||
<h3 className="h3">Registered</h3>
|
||||
<p>{this.props.user.registered.toString()}</p>
|
||||
<p>{user.registered.toString()}</p>
|
||||
|
||||
<hr/>
|
||||
|
||||
<h2 className="h2">Technical details</h2>
|
||||
<p>Are you a software developer? If so, you might be interested in these.</p>
|
||||
<h3 className="h3">API key</h3>
|
||||
<p>{this.props.user.api_key? this.props.user.api_key : '-'}</p>
|
||||
<form onSubmit={this.handleGenerateKey} className="form-group mb-3">
|
||||
<p>{user.api_key || '-'}</p>
|
||||
<form onSubmit={handleGenerateKey} className="form-group mb-3">
|
||||
<input className="btn btn-warning" type="submit" value="Generate API key"/>
|
||||
</form>
|
||||
|
||||
@ -143,31 +90,26 @@ class MyAccountPage extends Component<MyAccountPageProps, MyAccountPageState> {
|
||||
|
||||
<h2 className="h2">Account actions</h2>
|
||||
<form
|
||||
onSubmit={e => this.confirmDelete(e)}
|
||||
onSubmit={e => {
|
||||
e.preventDefault();
|
||||
setShowDeleteConfirm(true);
|
||||
}}
|
||||
className="form-group mb-3"
|
||||
>
|
||||
<input className="btn btn-danger" type="submit" value="Delete account" />
|
||||
</form>
|
||||
|
||||
<ConfirmationModal
|
||||
show={this.state.showDeleteConfirm}
|
||||
show={showDeleteConfirm}
|
||||
title="Confirm account deletion"
|
||||
description="Are you sure you want to delete your account? This cannot be undone."
|
||||
confirmButtonText="Delete account"
|
||||
confirmButtonClass="btn-danger"
|
||||
onConfirm={() => this.handleDelete()}
|
||||
onCancel={() => this.hideConfirmDelete()}
|
||||
onConfirm={() => handleDeleteAccount()}
|
||||
onCancel={() => setShowDeleteConfirm(false)}
|
||||
/>
|
||||
|
||||
</>)}
|
||||
</section>
|
||||
</article>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Redirect to="/login.html" />
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default MyAccountPage;
|
||||
};
|
||||
|
@ -1,75 +1,31 @@
|
||||
import React, { Component } from 'react';
|
||||
import { Link, Redirect } from 'react-router-dom';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { apiGet, apiPost } from '../apiHelpers';
|
||||
import { useAuth } from '../auth-context';
|
||||
import ErrorBox from '../components/error-box';
|
||||
import { SpinnerIcon } from '../components/icons';
|
||||
import InfoBox from '../components/info-box';
|
||||
import SupporterLogos from '../components/supporter-logos';
|
||||
import { User } from '../models/user';
|
||||
|
||||
interface SignUpProps {
|
||||
login: (user: User) => void;
|
||||
user: User;
|
||||
}
|
||||
export const SignUp: React.FC = () => {
|
||||
const { isLoading, signup } = useAuth();
|
||||
const [error, setError] = useState(undefined);
|
||||
|
||||
interface SignUpState {
|
||||
username: string;
|
||||
email: string;
|
||||
confirm_email: string;
|
||||
show_password: boolean;
|
||||
password: string;
|
||||
confirm_conditions: boolean;
|
||||
error: string;
|
||||
}
|
||||
const [username, setUsername] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [confirmEmail, setConfirmEmail] = useState('')
|
||||
const [password, setPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [confirmConditions, setConfirmConditions] = useState(false);
|
||||
|
||||
class SignUp extends Component<SignUpProps, SignUpState> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
username: '',
|
||||
email: '',
|
||||
confirm_email: '',
|
||||
password: '',
|
||||
show_password: false,
|
||||
confirm_conditions: false,
|
||||
error: undefined
|
||||
};
|
||||
const onSubmit = useCallback(
|
||||
e => {
|
||||
e.preventDefault();
|
||||
signup({ username, email, confirmEmail, password }, setError);
|
||||
},
|
||||
[username, email, confirmEmail, password, confirmConditions, signup]
|
||||
);
|
||||
|
||||
this.handleChange = this.handleChange.bind(this);
|
||||
this.handleSubmit = this.handleSubmit.bind(this);
|
||||
}
|
||||
|
||||
handleChange(event) {
|
||||
const target = event.target;
|
||||
const value = target.type === 'checkbox' ? target.checked : target.value;
|
||||
const name: keyof SignUpState = target.name;
|
||||
|
||||
this.setState({
|
||||
[name]: value
|
||||
} as Pick<SignUpState, keyof SignUpState>);
|
||||
}
|
||||
|
||||
async handleSubmit(event) {
|
||||
event.preventDefault();
|
||||
this.setState({error: undefined});
|
||||
|
||||
try {
|
||||
const res = await apiPost('/api/users', this.state);
|
||||
if(res.error) {
|
||||
this.setState({ error: res.error });
|
||||
} else {
|
||||
const user = await apiGet('/api/users/me');
|
||||
this.props.login(user);
|
||||
}
|
||||
} catch(err) {
|
||||
this.setState({error: err});
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.props.user) {
|
||||
return <Redirect to="/my-account.html" />;
|
||||
}
|
||||
return (
|
||||
<article>
|
||||
<section className="main-col">
|
||||
@ -83,12 +39,12 @@ class SignUp extends Component<SignUpProps, SignUpState> {
|
||||
<p>
|
||||
Create an account to start colouring in.
|
||||
</p>
|
||||
<ErrorBox msg={this.state.error} />
|
||||
<form onSubmit={this.handleSubmit}>
|
||||
<ErrorBox msg={error} />
|
||||
<form onSubmit={onSubmit}>
|
||||
<label htmlFor="username">Username*</label>
|
||||
<input name="username" id="username"
|
||||
className="form-control" type="text"
|
||||
value={this.state.username} onChange={this.handleChange}
|
||||
value={username} onChange={e => setUsername(e.target.value)}
|
||||
placeholder="not-your-real-name" required
|
||||
minLength={4}
|
||||
maxLength={30}
|
||||
@ -99,21 +55,22 @@ class SignUp extends Component<SignUpProps, SignUpState> {
|
||||
<label htmlFor="email">Email (optional)</label>
|
||||
<input name="email" id="email"
|
||||
className="form-control" type="email"
|
||||
value={this.state.email} onChange={this.handleChange}
|
||||
value={email} onChange={e => setEmail(e.target.value)}
|
||||
placeholder="someone@example.com"
|
||||
/>
|
||||
<InfoBox msg="Please note that if you forget your password, you will only be able to recover your account if you provide an email address." />
|
||||
|
||||
<label htmlFor="confirm_email">Confirm email (optional)</label>
|
||||
<input name="confirm_email" id="confirm_email"
|
||||
className="form-control" type="email"
|
||||
value={this.state.confirm_email} onChange={this.handleChange}
|
||||
value={confirmEmail} onChange={e => setConfirmEmail(e.target.value)}
|
||||
/>
|
||||
|
||||
<label htmlFor="password">Password (at least 8 characters)</label>
|
||||
<input name="password" id="password"
|
||||
className="form-control"
|
||||
type={(this.state.show_password)? 'text': 'password'}
|
||||
value={this.state.password} onChange={this.handleChange}
|
||||
type={(showPassword)? 'text': 'password'}
|
||||
value={password} onChange={e => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
maxLength={128}
|
||||
@ -122,8 +79,8 @@ class SignUp extends Component<SignUpProps, SignUpState> {
|
||||
<div className="form-check">
|
||||
<input id="show_password" name="show_password"
|
||||
className="form-check-input" type="checkbox"
|
||||
checked={this.state.show_password}
|
||||
onChange={this.handleChange}
|
||||
checked={showPassword}
|
||||
onChange={e => setShowPassword(e.target.checked)}
|
||||
/>
|
||||
<label className="form-check-label" htmlFor="show_password">
|
||||
Show password?
|
||||
@ -133,8 +90,8 @@ class SignUp extends Component<SignUpProps, SignUpState> {
|
||||
<div className="form-check">
|
||||
<input id="confirm_conditions" name="confirm_conditions"
|
||||
className="form-check-input" type="checkbox"
|
||||
checked={this.state.confirm_conditions}
|
||||
onChange={this.handleChange}
|
||||
checked={confirmConditions}
|
||||
onChange={e => setConfirmConditions(e.target.checked)}
|
||||
required />
|
||||
<label className="form-check-label" htmlFor="confirm_conditions">
|
||||
I confirm that I have read and agree to the <Link
|
||||
@ -145,7 +102,8 @@ class SignUp extends Component<SignUpProps, SignUpState> {
|
||||
</div>
|
||||
|
||||
<div className="buttons-container with-space">
|
||||
<input type="submit" value="Sign Up" className="btn btn-primary" />
|
||||
<input type="submit" disabled={isLoading} value="Sign Up" className="btn btn-primary" />
|
||||
{isLoading && <span><SpinnerIcon/>Sending sign up data...</span>}
|
||||
</div>
|
||||
<InfoBox msg="">
|
||||
Please also read our <a href="https://www.pages.colouring.london/data-ethics">data ethics policy</a> before using or sharing our data
|
||||
@ -165,7 +123,4 @@ class SignUp extends Component<SignUpProps, SignUpState> {
|
||||
</section>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default SignUp;
|
||||
};
|
||||
|
@ -12,7 +12,7 @@ import {
|
||||
getUserVerifiedAttributes
|
||||
} from './api/services/building/base';
|
||||
import { getUserById } from './api/services/user';
|
||||
import App from './frontend/app';
|
||||
import { App } from './frontend/app';
|
||||
import { parseBuildingURL } from './parse';
|
||||
import asyncController from './api/routes/asyncController';
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user