Merge pull request #659 from mz8i/feature/state-mgmt

Move user authentication to context and hooks
This commit is contained in:
Maciej Ziarkowski 2021-02-09 08:19:26 +00:00 committed by GitHub
commit bc55871661
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 621 additions and 551 deletions

View File

@ -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

View File

@ -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', () => {

View File

@ -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>
return (
<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) => (
<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}
/>
<Route exact path={mapAppPaths} render={(routeProps) => (
<AuthContext.Consumer>
{({user}) =>
<MapApp
{...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>
);
}
}
/**
* 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&rsquo;t find that one anywhere.
</p>
<Link className="btn btn-outline-dark" to="/">Back home</Link>
</section>
</article>
);
export default App;
</AuthProvider>
);
};

View 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);
};

View File

@ -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 = {};

View File

@ -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 (

View File

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

View File

@ -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,37 +132,38 @@ 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 }) => (
<WithSeparator separator={<hr />}>
{menuLinkSections.map((section, idx) =>
<ul key={`menu-section-${idx}`} className="navbar-nav flex-container">
{section.map(item => (
<li className={`nav-item`}>
{
item.disabled ?
<LinkStub note={item.note}>{item.text}</LinkStub> :
item.external ?
<ExternalNavLink to={item.to}>{item.text}</ExternalNavLink> :
<InternalNavLink to={item.to} onClick={onNavigate}>{item.text}</InternalNavLink>
}
</li>
))}
</ul>
)}
</WithSeparator>
);
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' key={`${item.to}-${item.text}`}>
{
item.disabled ?
<LinkStub note={item.note}>{item.text}</LinkStub> :
item.external ?
<ExternalNavLink to={item.to}>{item.text}</ExternalNavLink> :
<InternalNavLink to={item.to} onClick={onNavigate}>{item.text}</InternalNavLink>
}
</li>
))}
</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>
);

View File

@ -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>

View 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&rsquo;t find that one anywhere.
</p>
<Link className="btn btn-outline-dark" to="/">Back home</Link>
</section>
</article>
);

View 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}</>;
}
}
}}
/>
};

View File

@ -1,127 +1,83 @@
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});
return (
<article>
<section className="main-col">
<h1 className="h2">Log in</h1>
<InfoBox msg="Welcome to Colouring London. You're one of the first people to use the site! ">
<br/>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>.
</InfoBox>
<ErrorBox msg={error} />
<form onSubmit={onSubmit}>
<label htmlFor="username">Username*</label>
<input name="username" id="username"
className="form-control" type="text"
value={username} onChange={e => setUsername(e.target.value)}
placeholder="not-your-real-name" required
/>
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})
);
}
<label htmlFor="password">Password</label>
<input name="password" id="password"
className="form-control"
type={showPassword ? 'text' : 'password'}
value={password} onChange={e => setPassword(e.target.value)}
required
/>
render() {
if (this.props.user && !this.props.user.error) {
return <Redirect to="/my-account.html" />;
}
return (
<article>
<section className="main-col">
<h1 className="h2">Log in</h1>
<InfoBox msg="Welcome to Colouring London. You're one of the first people to use the site! ">
<br/>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>.
</InfoBox>
<ErrorBox msg={this.state.error} />
<form onSubmit={this.handleSubmit}>
<label htmlFor="username">Username*</label>
<input name="username" id="username"
className="form-control" type="text"
value={this.state.username} onChange={this.handleChange}
placeholder="not-your-real-name" required
<div className="form-check">
<input id="show_password" name="show_password"
className="form-check-input" type="checkbox"
checked={showPassword}
onChange={e => setShowPassword(e.target.checked)}
/>
<label htmlFor="show_password" className="form-check-label">Show password?</label>
</div>
<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}
required
/>
<Link to="/forgotten-password.html">Forgotten password?</Link>
<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}
/>
<label htmlFor="show_password" className="form-check-label">Show password?</label>
</div>
<div className="buttons-container with-space">
<input type="submit" disabled={isLoading} value="Log In" className="btn btn-primary" />
{isLoading && <span><SpinnerIcon />Logging in...</span>}
</div>
<Link to="/forgotten-password.html">Forgotten password?</Link>
Would you like to create an account instead?
<div className="buttons-container with-space">
<input type="submit" value="Log In" className="btn btn-primary" />
</div>
<div className="buttons-container with-space">
<Link to="sign-up.html" className="btn btn-outline-dark">Sign Up</Link>
</div>
</form>
</section>
<hr />
<section className="main-col">
<SupporterLogos />
</section>
</article>
)
Would you like to create an account instead?
<div className="buttons-container with-space">
<Link to="sign-up.html" className="btn btn-outline-dark">Sign Up</Link>
</div>
</form>
</section>
<hr />
<section className="main-col">
<SupporterLogos />
</section>
</article>
);
}
}
export default Login;
};

View File

@ -1,173 +1,115 @@
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();
setError(undefined);
generateApiKey(setError);
}, [generateApiKey]);
handleLogout(event) {
event.preventDefault();
this.setState({error: undefined});
const handleDeleteAccount = useCallback(() => {
setError(undefined);
deleteAccount(setError);
}, [deleteAccount])
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})
if(!user && isLoading) {
return (
<article>
<section className="main-col">
<SpinnerIcon spin={true} /> Loading user info...
</section>
</article>
);
}
handleGenerateKey(event) {
event.preventDefault();
this.setState({error: undefined});
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>.
</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>.
</p>
<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"/>
</div>
</form>
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})
);
}
<hr/>
<h2 className="h2">My Details</h2>
<h3 className="h3">Username</h3>
<p>{user.username}</p>
<h3 className="h3">Email Address</h3>
<p>{user.email || '-'}</p>
<h3 className="h3">Registered</h3>
<p>{user.registered.toString()}</p>
confirmDelete(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
this.setState({ showDeleteConfirm: true });
}
<hr/>
hideConfirmDelete() {
this.setState({ showDeleteConfirm: false });
}
<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>{user.api_key || '-'}</p>
<form onSubmit={handleGenerateKey} className="form-group mb-3">
<input className="btn btn-warning" type="submit" value="Generate API key"/>
</form>
async handleDelete() {
this.setState({ error: undefined });
<h3 className="h3">Open Source Code</h3>
Colouring London site code is developed at <a href="http://github.com/colouring-london/colouring-london/">colouring-london</a> on Github
try {
const data = await apiDelete('/api/users/me');
<hr />
if(data.error) {
this.setState({ error: data.error });
} else {
this.props.logout();
}
} catch (err) {
this.setState({ error: err });
} finally {
this.hideConfirmDelete();
}
}
<h2 className="h2">Account actions</h2>
<form
onSubmit={e => {
e.preventDefault();
setShowDeleteConfirm(true);
}}
className="form-group mb-3"
>
<input className="btn btn-danger" type="submit" value="Delete account" />
</form>
render() {
if (this.props.user && !this.props.user.error) {
return (
<article>
<section className="main-col">
<h1 className="h1">Welcome, {this.props.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>.
</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>.
</p>
<ErrorBox msg={this.state.error} />
<form onSubmit={this.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"/>
</div>
</form>
<hr/>
<h2 className="h2">My Details</h2>
<h3 className="h3">Username</h3>
<p>{this.props.user.username}</p>
<h3 className="h3">Email Address</h3>
<p>{this.props.user.email? this.props.user.email : '-'}</p>
<h3 className="h3">Registered</h3>
<p>{this.props.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">
<input className="btn btn-warning" type="submit" value="Generate API key"/>
</form>
<h3 className="h3">Open Source Code</h3>
Colouring London site code is developed at <a href="http://github.com/colouring-london/colouring-london/">colouring-london</a> on Github
<hr />
<h2 className="h2">Account actions</h2>
<form
onSubmit={e => this.confirmDelete(e)}
className="form-group mb-3"
>
<input className="btn btn-danger" type="submit" value="Delete account" />
</form>
<ConfirmationModal
show={this.state.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()}
/>
</section>
</article>
);
} else {
return (
<Redirect to="/login.html" />
);
}
}
}
export default MyAccountPage;
<ConfirmationModal
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={() => handleDeleteAccount()}
onCancel={() => setShowDeleteConfirm(false)}
/>
</>)}
</section>
</article>
);
};

View File

@ -1,171 +1,126 @@
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);
}
return (
<article>
<section className="main-col">
<h1 className="h2">Sign up</h1>
<InfoBox msg="Welcome to Colouring London. You're one of the first people to sign up! ">
<br/>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>.
</InfoBox>
<p>
Create an account to start colouring in.
</p>
<ErrorBox msg={error} />
<form onSubmit={onSubmit}>
<label htmlFor="username">Username*</label>
<input name="username" id="username"
className="form-control" type="text"
value={username} onChange={e => setUsername(e.target.value)}
placeholder="not-your-real-name" required
minLength={4}
maxLength={30}
pattern="\w+"
title="Usernames can contain only letters, numbers and the underscore"
/>
handleChange(event) {
const target = event.target;
const value = target.type === 'checkbox' ? target.checked : target.value;
const name: keyof SignUpState = target.name;
<label htmlFor="email">Email (optional)</label>
<input name="email" id="email"
className="form-control" type="email"
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={confirmEmail} onChange={e => setConfirmEmail(e.target.value)}
/>
this.setState({
[name]: value
} as Pick<SignUpState, keyof SignUpState>);
}
<label htmlFor="password">Password (at least 8 characters)</label>
<input name="password" id="password"
className="form-control"
type={(showPassword)? 'text': 'password'}
value={password} onChange={e => setPassword(e.target.value)}
required
minLength={8}
maxLength={128}
/>
async handleSubmit(event) {
event.preventDefault();
this.setState({error: undefined});
<div className="form-check">
<input id="show_password" name="show_password"
className="form-check-input" type="checkbox"
checked={showPassword}
onChange={e => setShowPassword(e.target.checked)}
/>
<label className="form-check-label" htmlFor="show_password">
Show password?
</label>
</div>
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});
}
}
<div className="form-check">
<input id="confirm_conditions" name="confirm_conditions"
className="form-check-input" type="checkbox"
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
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>.
</label>
</div>
render() {
if (this.props.user) {
return <Redirect to="/my-account.html" />;
}
return (
<article>
<section className="main-col">
<h1 className="h2">Sign up</h1>
<InfoBox msg="Welcome to Colouring London. You're one of the first people to sign up! ">
<br/>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>.
<div className="buttons-container with-space">
<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
</InfoBox>
<p>
Create an account to start colouring in.
</p>
<ErrorBox msg={this.state.error} />
<form onSubmit={this.handleSubmit}>
<label htmlFor="username">Username*</label>
<input name="username" id="username"
className="form-control" type="text"
value={this.state.username} onChange={this.handleChange}
placeholder="not-your-real-name" required
minLength={4}
maxLength={30}
pattern="\w+"
title="Usernames can contain only letters, numbers and the underscore"
/>
<label htmlFor="email">Email (optional)</label>
<input name="email" id="email"
className="form-control" type="email"
value={this.state.email} onChange={this.handleChange}
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}
/>
Do you already have an account?
<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}
required
minLength={8}
maxLength={128}
/>
<div className="buttons-container with-space">
<Link to="login.html" className="btn btn-outline-dark">Log in</Link>
</div>
<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}
/>
<label className="form-check-label" htmlFor="show_password">
Show password?
</label>
</div>
<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}
required />
<label className="form-check-label" htmlFor="confirm_conditions">
I confirm that I have read and agree to 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>.
</label>
</div>
<div className="buttons-container with-space">
<input type="submit" value="Sign Up" className="btn btn-primary" />
</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
</InfoBox>
Do you already have an account?
<div className="buttons-container with-space">
<Link to="login.html" className="btn btn-outline-dark">Log in</Link>
</div>
</form>
</section>
<hr />
<section className="main-col">
<SupporterLogos />
</section>
</article>
);
}
}
export default SignUp;
</form>
</section>
<hr />
<section className="main-col">
<SupporterLogos />
</section>
</article>
);
};

View File

@ -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';