add oauth flows, profile, logout page, and index page
This commit is contained in:
parent
650c93273a
commit
b0270cc98b
31 changed files with 449 additions and 162 deletions
|
|
@ -21,14 +21,14 @@ react-meteor-data
|
||||||
alanning:roles
|
alanning:roles
|
||||||
fourseven:scss
|
fourseven:scss
|
||||||
twbs:bootstrap
|
twbs:bootstrap
|
||||||
accounts-base
|
accounts-base@1.2.17
|
||||||
accounts-password
|
accounts-password@1.3.6
|
||||||
service-configuration
|
service-configuration@1.0.11
|
||||||
accounts-facebook
|
accounts-facebook@1.1.1
|
||||||
accounts-github
|
accounts-github@1.2.1
|
||||||
accounts-google
|
accounts-google@1.1.2
|
||||||
themeteorchef:bert
|
themeteorchef:bert
|
||||||
fortawesome:fontawesome
|
fortawesome:fontawesome
|
||||||
aldeed:collection2-core
|
aldeed:collection2-core@2.0.0
|
||||||
audit-argument-checks
|
audit-argument-checks@1.0.7
|
||||||
ddp-rate-limiter
|
ddp-rate-limiter@1.0.7
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
METEOR@1.4.4.2
|
METEOR@1.4.4.3
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,7 @@ accounts-google@1.1.2
|
||||||
accounts-oauth@1.1.15
|
accounts-oauth@1.1.15
|
||||||
accounts-password@1.3.6
|
accounts-password@1.3.6
|
||||||
alanning:roles@1.2.16
|
alanning:roles@1.2.16
|
||||||
aldeed:collection2-core@1.2.0
|
aldeed:collection2-core@2.0.1
|
||||||
aldeed:simple-schema@1.5.3
|
|
||||||
allow-deny@1.0.5
|
allow-deny@1.0.5
|
||||||
audit-argument-checks@1.0.7
|
audit-argument-checks@1.0.7
|
||||||
autoupdate@1.3.12
|
autoupdate@1.3.12
|
||||||
|
|
@ -36,7 +35,7 @@ email@1.2.1
|
||||||
es5-shim@4.6.15
|
es5-shim@4.6.15
|
||||||
facebook-oauth@1.3.0
|
facebook-oauth@1.3.0
|
||||||
fastclick@1.0.13
|
fastclick@1.0.13
|
||||||
fortawesome:fontawesome@4.4.0_1
|
fortawesome:fontawesome@4.7.0
|
||||||
fourseven:scss@4.5.0
|
fourseven:scss@4.5.0
|
||||||
geojson-utils@1.0.10
|
geojson-utils@1.0.10
|
||||||
github-oauth@1.2.0
|
github-oauth@1.2.0
|
||||||
|
|
@ -51,7 +50,6 @@ launch-screen@1.1.1
|
||||||
livedata@1.0.18
|
livedata@1.0.18
|
||||||
localstorage@1.0.12
|
localstorage@1.0.12
|
||||||
logging@1.1.17
|
logging@1.1.17
|
||||||
mdg:validation-error@0.2.0
|
|
||||||
meteor@1.6.1
|
meteor@1.6.1
|
||||||
meteor-base@1.0.4
|
meteor-base@1.0.4
|
||||||
minifier-css@1.2.16
|
minifier-css@1.2.16
|
||||||
|
|
@ -93,7 +91,7 @@ templating-compiler@1.3.2
|
||||||
templating-runtime@1.3.2
|
templating-runtime@1.3.2
|
||||||
templating-tools@1.1.2
|
templating-tools@1.1.2
|
||||||
themeteorchef:bert@2.1.2
|
themeteorchef:bert@2.1.2
|
||||||
tmeasday:check-npm-versions@0.2.0
|
tmeasday:check-npm-versions@0.3.1
|
||||||
tracker@1.1.3
|
tracker@1.1.3
|
||||||
twbs:bootstrap@3.3.6
|
twbs:bootstrap@3.3.6
|
||||||
ui@1.0.13
|
ui@1.0.13
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,6 @@ Documents.schema = new SimpleSchema({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Documents.attachSchema(Documents.schema);
|
Documents.attachSchema(Documents.schema);
|
||||||
|
|
||||||
export default Documents;
|
export default Documents;
|
||||||
|
|
|
||||||
14
imports/api/OAuth/server/methods.js
Normal file
14
imports/api/OAuth/server/methods.js
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { Meteor } from 'meteor/meteor';
|
||||||
|
import { check } from 'meteor/check';
|
||||||
|
import { ServiceConfiguration } from 'meteor/service-configuration';
|
||||||
|
|
||||||
|
Meteor.methods({
|
||||||
|
'oauth.verifyConfiguration': function oauthVerifyConfiguration(services) {
|
||||||
|
check(services, Array);
|
||||||
|
const verifiedServices = [];
|
||||||
|
services.forEach((service) => {
|
||||||
|
if (ServiceConfiguration.configurations.findOne({ service })) verifiedServices.push(service);
|
||||||
|
});
|
||||||
|
return verifiedServices.sort();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -5,6 +5,7 @@ Meteor.publish('users.editProfile', function usersProfile() {
|
||||||
fields: {
|
fields: {
|
||||||
emails: 1,
|
emails: 1,
|
||||||
profile: 1,
|
profile: 1,
|
||||||
|
services: 1,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
2
imports/startup/server/accounts/index.js
Normal file
2
imports/startup/server/accounts/index.js
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
import './oauth';
|
||||||
|
import './email-templates';
|
||||||
13
imports/startup/server/accounts/oauth.js
Normal file
13
imports/startup/server/accounts/oauth.js
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
import { Meteor } from 'meteor/meteor';
|
||||||
|
import { ServiceConfiguration } from 'meteor/service-configuration';
|
||||||
|
|
||||||
|
const OAuthSettings = Meteor.settings.private.OAuth;
|
||||||
|
|
||||||
|
if (OAuthSettings) {
|
||||||
|
Object.keys(OAuthSettings).forEach((service) => {
|
||||||
|
ServiceConfiguration.configurations.upsert(
|
||||||
|
{ service },
|
||||||
|
{ $set: OAuthSettings[service] },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
7
imports/startup/server/accounts/on-create-user.js
Normal file
7
imports/startup/server/accounts/on-create-user.js
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { Accounts } from 'meteor/accounts-base';
|
||||||
|
|
||||||
|
Accounts.onCreateUser((options, user) => {
|
||||||
|
const userToCreate = user;
|
||||||
|
if (options.profile) userToCreate.profile = options.profile;
|
||||||
|
return userToCreate;
|
||||||
|
});
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import '../../api/Documents/methods';
|
import '../../api/Documents/methods';
|
||||||
import '../../api/Documents/server/publications';
|
import '../../api/Documents/server/publications';
|
||||||
|
|
||||||
|
import '../../api/OAuth/server/methods';
|
||||||
|
|
||||||
import '../../api/Users/server/methods';
|
import '../../api/Users/server/methods';
|
||||||
import '../../api/Users/server/publications';
|
import '../../api/Users/server/publications';
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ const Authenticated = ({ loggingIn, authenticated, component, ...rest }) => (
|
||||||
if (loggingIn) return <div />;
|
if (loggingIn) return <div />;
|
||||||
return authenticated ?
|
return authenticated ?
|
||||||
(React.createElement(component, { ...props, loggingIn, authenticated })) :
|
(React.createElement(component, { ...props, loggingIn, authenticated })) :
|
||||||
(<Redirect to="/login" />);
|
(<Redirect to="/logout" />);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -86,7 +86,7 @@ class DocumentEditor extends React.Component {
|
||||||
}
|
}
|
||||||
|
|
||||||
DocumentEditor.defaultProps = {
|
DocumentEditor.defaultProps = {
|
||||||
doc: PropTypes.object,
|
doc: { title: '', body: '' },
|
||||||
};
|
};
|
||||||
|
|
||||||
DocumentEditor.propTypes = {
|
DocumentEditor.propTypes = {
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import AuthenticatedNavigation from '../AuthenticatedNavigation/AuthenticatedNav
|
||||||
|
|
||||||
import './Navigation.scss';
|
import './Navigation.scss';
|
||||||
|
|
||||||
const Navigation = ({ authenticated, name }) => (
|
const Navigation = props => (
|
||||||
<Navbar>
|
<Navbar>
|
||||||
<Navbar.Header>
|
<Navbar.Header>
|
||||||
<Navbar.Brand>
|
<Navbar.Brand>
|
||||||
|
|
@ -16,18 +16,17 @@ const Navigation = ({ authenticated, name }) => (
|
||||||
<Navbar.Toggle />
|
<Navbar.Toggle />
|
||||||
</Navbar.Header>
|
</Navbar.Header>
|
||||||
<Navbar.Collapse>
|
<Navbar.Collapse>
|
||||||
{!authenticated ? <PublicNavigation /> : <AuthenticatedNavigation name={name} />}
|
{!props.authenticated ? <PublicNavigation /> : <AuthenticatedNavigation {...props} />}
|
||||||
</Navbar.Collapse>
|
</Navbar.Collapse>
|
||||||
</Navbar>
|
</Navbar>
|
||||||
);
|
);
|
||||||
|
|
||||||
Navigation.defaultProps = {
|
Navigation.defaultProps = {
|
||||||
name: PropTypes.string,
|
name: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
Navigation.propTypes = {
|
Navigation.propTypes = {
|
||||||
authenticated: PropTypes.bool.isRequired,
|
authenticated: PropTypes.bool.isRequired,
|
||||||
name: PropTypes.string,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Navigation;
|
export default Navigation;
|
||||||
|
|
|
||||||
|
|
@ -5,58 +5,54 @@ import { Bert } from 'meteor/themeteorchef:bert';
|
||||||
|
|
||||||
import './OAuthLoginButton.scss';
|
import './OAuthLoginButton.scss';
|
||||||
|
|
||||||
const handleLogin = (service, options, callback) => {
|
const handleLogin = (service, callback) => {
|
||||||
const defaultOptions = {
|
const options = {
|
||||||
facebook: {
|
facebook: {
|
||||||
requestPermissions: ['email'],
|
requestPermissions: ['email'],
|
||||||
loginStyle: 'redirect',
|
loginStyle: 'popup',
|
||||||
|
},
|
||||||
|
github: {
|
||||||
|
requestPermissions: ['user:email'],
|
||||||
|
loginStyle: 'popup',
|
||||||
},
|
},
|
||||||
google: {
|
google: {
|
||||||
requestPermissions: ['email'],
|
requestPermissions: ['email'],
|
||||||
requestOfflineToken: true,
|
requestOfflineToken: true,
|
||||||
loginStyle: 'redirect',
|
loginStyle: 'popup',
|
||||||
},
|
|
||||||
github: {
|
|
||||||
requestPermissions: ['user:email'],
|
|
||||||
loginStyle: 'redirect',
|
|
||||||
},
|
},
|
||||||
}[service];
|
}[service];
|
||||||
|
|
||||||
const defaultCallback = (error) => {
|
|
||||||
if (error) Bert.alert(error.reason, 'danger');
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
facebook: Meteor.loginWithFacebook,
|
facebook: Meteor.loginWithFacebook,
|
||||||
google: Meteor.loginWithGoogle,
|
|
||||||
github: Meteor.loginWithGithub,
|
github: Meteor.loginWithGithub,
|
||||||
}[service](options || defaultOptions, callback || defaultCallback);
|
google: Meteor.loginWithGoogle,
|
||||||
|
}[service](options, callback);
|
||||||
};
|
};
|
||||||
|
|
||||||
const serviceLabel = {
|
const serviceLabel = {
|
||||||
facebook: <span><i className="fa fa-facebook" /> Log In with Facebook</span>,
|
facebook: <span><i className="fa fa-facebook" /> Log In with Facebook</span>,
|
||||||
google: <span><i className="fa fa-google" /> Log In with Google</span>,
|
|
||||||
github: <span><i className="fa fa-github" /> Log In with GitHub</span>,
|
github: <span><i className="fa fa-github" /> Log In with GitHub</span>,
|
||||||
|
google: <span><i className="fa fa-google" /> Log In with Google</span>,
|
||||||
};
|
};
|
||||||
|
|
||||||
const OAuthLoginButton = ({ service, options, callback }) => (
|
const OAuthLoginButton = ({ service, callback }) => (
|
||||||
<button
|
<button
|
||||||
className={`OAuthLoginButton OAuthLoginButton-${service}`}
|
className={`OAuthLoginButton OAuthLoginButton-${service}`}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleLogin(service, options, callback)}
|
onClick={() => handleLogin(service, callback)}
|
||||||
>
|
>
|
||||||
{serviceLabel[service]}
|
{serviceLabel[service]}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|
||||||
OAuthLoginButton.defaultProps = {
|
OAuthLoginButton.defaultProps = {
|
||||||
options: PropTypes.object,
|
callback: (error) => {
|
||||||
callback: PropTypes.func,
|
if (error) Bert.alert(error.message, 'danger');
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
OAuthLoginButton.propTypes = {
|
OAuthLoginButton.propTypes = {
|
||||||
service: PropTypes.string.isRequired,
|
service: PropTypes.string.isRequired,
|
||||||
options: PropTypes.object,
|
|
||||||
callback: PropTypes.func,
|
callback: PropTypes.func,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
42
imports/ui/components/OAuthLoginButtons/OAuthLoginButtons.js
Normal file
42
imports/ui/components/OAuthLoginButtons/OAuthLoginButtons.js
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
import React from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import { Meteor } from 'meteor/meteor';
|
||||||
|
import { createContainer } from 'meteor/react-meteor-data';
|
||||||
|
import { ReactiveVar } from 'meteor/reactive-var';
|
||||||
|
import OAuthLoginButton from '../OAuthLoginButton/OAuthLoginButton';
|
||||||
|
|
||||||
|
import './OAuthLoginButtons.scss';
|
||||||
|
|
||||||
|
const OAuthLoginButtons = ({ services, emailMessage }) => (services.length ? (
|
||||||
|
<div className={`OAuthLoginButtons ${emailMessage ? 'WithEmailMessage' : ''}`}>
|
||||||
|
{services.map(service => <OAuthLoginButton key={service} service={service} />)}
|
||||||
|
{emailMessage ? <p className="EmailMessage" style={{ marginLeft: `-${emailMessage.offset}px` }}>
|
||||||
|
{emailMessage.text}
|
||||||
|
</p> : ''}
|
||||||
|
</div>
|
||||||
|
) : <div />);
|
||||||
|
|
||||||
|
OAuthLoginButtons.propTypes = {
|
||||||
|
services: PropTypes.array.isRequired,
|
||||||
|
emailMessage: PropTypes.object.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
const verificationComplete = new ReactiveVar(false);
|
||||||
|
const verifiedServices = new ReactiveVar([]);
|
||||||
|
|
||||||
|
export default createContainer(({ services }) => {
|
||||||
|
if (!verificationComplete.get()) {
|
||||||
|
Meteor.call('oauth.verifyConfiguration', services, (error, response) => {
|
||||||
|
if (error) {
|
||||||
|
console.warn(error);
|
||||||
|
} else {
|
||||||
|
verifiedServices.set(response);
|
||||||
|
verificationComplete.set(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
services: verifiedServices.get(),
|
||||||
|
};
|
||||||
|
}, OAuthLoginButtons);
|
||||||
|
|
@ -1,20 +1,20 @@
|
||||||
@import '../../stylesheets/colors';
|
@import '../../stylesheets/colors';
|
||||||
|
|
||||||
.Signup {
|
.OAuthLoginButtons {
|
||||||
form {
|
margin-bottom: 25px;
|
||||||
position: relative;
|
|
||||||
border-top: 1px solid $gray-lighter;
|
|
||||||
margin-top: 15px;
|
|
||||||
padding-top: 25px;
|
|
||||||
|
|
||||||
.SignupWithEmail {
|
&.WithEmailMessage {
|
||||||
|
position: relative;
|
||||||
|
border-bottom: 1px solid $gray-lighter;
|
||||||
|
padding-bottom: 30px;
|
||||||
|
|
||||||
|
.EmailMessage {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
padding: 0 10px;
|
padding: 0 10px;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -12px;
|
bottom: -19px;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
margin-left: -106px;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -15,6 +15,7 @@ import ViewDocument from '../../pages/ViewDocument/ViewDocument';
|
||||||
import EditDocument from '../../pages/EditDocument/EditDocument';
|
import EditDocument from '../../pages/EditDocument/EditDocument';
|
||||||
import Signup from '../../pages/Signup/Signup';
|
import Signup from '../../pages/Signup/Signup';
|
||||||
import Login from '../../pages/Login/Login';
|
import Login from '../../pages/Login/Login';
|
||||||
|
import Logout from '../../pages/Logout/Logout';
|
||||||
import RecoverPassword from '../../pages/RecoverPassword/RecoverPassword';
|
import RecoverPassword from '../../pages/RecoverPassword/RecoverPassword';
|
||||||
import ResetPassword from '../../pages/ResetPassword/ResetPassword';
|
import ResetPassword from '../../pages/ResetPassword/ResetPassword';
|
||||||
import Profile from '../../pages/Profile/Profile';
|
import Profile from '../../pages/Profile/Profile';
|
||||||
|
|
@ -34,6 +35,7 @@ const App = props => (
|
||||||
<Authenticated exact path="/profile" component={Profile} {...props} />
|
<Authenticated exact path="/profile" component={Profile} {...props} />
|
||||||
<Public path="/signup" component={Signup} {...props} />
|
<Public path="/signup" component={Signup} {...props} />
|
||||||
<Public path="/login" component={Login} {...props} />
|
<Public path="/login" component={Login} {...props} />
|
||||||
|
<Public path="/logout" component={Logout} {...props} />
|
||||||
<Route name="recover-password" path="/recover-password" component={RecoverPassword} />
|
<Route name="recover-password" path="/recover-password" component={RecoverPassword} />
|
||||||
<Route name="reset-password" path="/reset-password/:token" component={ResetPassword} />
|
<Route name="reset-password" path="/reset-password/:token" component={ResetPassword} />
|
||||||
<Route component={NotFound} />
|
<Route component={NotFound} />
|
||||||
|
|
@ -47,17 +49,24 @@ App.propTypes = {
|
||||||
loading: PropTypes.bool.isRequired,
|
loading: PropTypes.bool.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getUserName = name => ({
|
||||||
|
string: name,
|
||||||
|
object: `${name.first} ${name.last}`,
|
||||||
|
}[typeof name]);
|
||||||
|
|
||||||
export default createContainer(() => {
|
export default createContainer(() => {
|
||||||
const loggingIn = Meteor.loggingIn();
|
const loggingIn = Meteor.loggingIn();
|
||||||
const user = Meteor.user();
|
const user = Meteor.user();
|
||||||
const userId = Meteor.userId();
|
const userId = Meteor.userId();
|
||||||
const loading = !Roles.subscription.ready();
|
const loading = !Roles.subscription.ready();
|
||||||
|
const name = user && user.profile && user.profile.name && getUserName(user.profile.name);
|
||||||
|
const emailAddress = user && user.emails && user.emails[0].address;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
loading,
|
loading,
|
||||||
loggingIn,
|
loggingIn,
|
||||||
authenticated: !loggingIn && !!userId,
|
authenticated: !loggingIn && !!userId,
|
||||||
name: user && user.profile ? `${user.profile.name.first} ${user.profile.name.last}` : '',
|
name: name || emailAddress,
|
||||||
roles: !loading && Roles.getRolesForUser(userId),
|
roles: !loading && Roles.getRolesForUser(userId),
|
||||||
};
|
};
|
||||||
}, App);
|
}, App);
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,7 @@ Documents.propTypes = {
|
||||||
|
|
||||||
export default createContainer(() => {
|
export default createContainer(() => {
|
||||||
const subscription = Meteor.subscribe('documents');
|
const subscription = Meteor.subscribe('documents');
|
||||||
|
console.log(DocumentsCollection.find().fetch());
|
||||||
return {
|
return {
|
||||||
loading: !subscription.ready(),
|
loading: !subscription.ready(),
|
||||||
documents: DocumentsCollection.find().fetch(),
|
documents: DocumentsCollection.find().fetch(),
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,20 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Jumbotron } from 'react-bootstrap';
|
import { Button } from 'react-bootstrap';
|
||||||
|
|
||||||
import './Index.scss';
|
import './Index.scss';
|
||||||
|
|
||||||
const Index = () => (
|
const Index = () => (
|
||||||
<div className="Index">
|
<div className="Index">
|
||||||
<Jumbotron>
|
<img
|
||||||
<img
|
src="https://s3-us-west-2.amazonaws.com/cleverbeagle-assets/graphics/email-icon.png"
|
||||||
src="https://s3-us-west-2.amazonaws.com/cleverbeagle-assets/graphics/email-icon.png"
|
alt="Clever Beagle"
|
||||||
alt="Clever Beagle"
|
/>
|
||||||
/>
|
<h1>Pup</h1>
|
||||||
<p>Need help building your app? Check out our mentorship service.</p>
|
<p>A boilerplate for products.</p>
|
||||||
</Jumbotron>
|
<div>
|
||||||
|
<Button href="https://pup.cleverbeagle.com">Read the Docs</Button>
|
||||||
|
<Button href="https://github.com/cleverbeagle/pup"><i className="fa fa-star" /> Star on GitHub</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
@import '../../stylesheets/mixins';
|
||||||
|
@import '../../stylesheets/colors';
|
||||||
|
|
||||||
|
.Index {
|
||||||
|
padding: 20px;
|
||||||
|
background: $cb-blue;
|
||||||
|
text-align: center;
|
||||||
|
border-radius: 3px;
|
||||||
|
color: #fff;
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100px;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
font-size: 18px;
|
||||||
|
color: lighten($cb-blue, 25%);
|
||||||
|
}
|
||||||
|
|
||||||
|
> div {
|
||||||
|
display: inline-block;
|
||||||
|
margin: 10px 0 0;
|
||||||
|
|
||||||
|
.btn:first-child {
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:last-child {
|
||||||
|
background: darken($cb-blue, 20%);
|
||||||
|
color: lighten($cb-blue, 30%);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@include breakpoint(tablet) {
|
||||||
|
.Index {
|
||||||
|
padding: 30px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@include breakpoint(desktop) {
|
||||||
|
.Index {
|
||||||
|
padding: 40px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,12 +4,10 @@ import PropTypes from 'prop-types';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { Meteor } from 'meteor/meteor';
|
import { Meteor } from 'meteor/meteor';
|
||||||
import { Bert } from 'meteor/themeteorchef:bert';
|
import { Bert } from 'meteor/themeteorchef:bert';
|
||||||
import OAuthLoginButton from '../../components/OAuthLoginButton/OAuthLoginButton';
|
import OAuthLoginButtons from '../../components/OAuthLoginButtons/OAuthLoginButtons';
|
||||||
import AccountPageFooter from '../../components/AccountPageFooter/AccountPageFooter';
|
import AccountPageFooter from '../../components/AccountPageFooter/AccountPageFooter';
|
||||||
import validate from '../../../modules/validate';
|
import validate from '../../../modules/validate';
|
||||||
|
|
||||||
import './Login.scss';
|
|
||||||
|
|
||||||
class Login extends React.Component {
|
class Login extends React.Component {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
|
|
@ -62,15 +60,16 @@ class Login extends React.Component {
|
||||||
<h4 className="page-header">Log In</h4>
|
<h4 className="page-header">Log In</h4>
|
||||||
<Row>
|
<Row>
|
||||||
<Col xs={12}>
|
<Col xs={12}>
|
||||||
<FormGroup className="OAuthLoginButtons">
|
<OAuthLoginButtons
|
||||||
<OAuthLoginButton service="facebook" />
|
services={['facebook', 'github', 'google']}
|
||||||
<OAuthLoginButton service="google" />
|
emailMessage={{
|
||||||
<OAuthLoginButton service="github" />
|
offset: 100,
|
||||||
</FormGroup>
|
text: 'Log In with an Email Address',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
<form ref={form => (this.form = form)} onSubmit={event => event.preventDefault()}>
|
<form ref={form => (this.form = form)} onSubmit={event => event.preventDefault()}>
|
||||||
<p className="LoginWithEmail">Log In with an Email Address</p>
|
|
||||||
<FormGroup>
|
<FormGroup>
|
||||||
<ControlLabel>Email Address</ControlLabel>
|
<ControlLabel>Email Address</ControlLabel>
|
||||||
<input
|
<input
|
||||||
|
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
@import '../../stylesheets/mixins';
|
|
||||||
@import '../../stylesheets/colors';
|
|
||||||
|
|
||||||
.Login {
|
|
||||||
form {
|
|
||||||
position: relative;
|
|
||||||
border-top: 1px solid $gray-lighter;
|
|
||||||
margin-top: 15px;
|
|
||||||
padding-top: 25px;
|
|
||||||
|
|
||||||
.LoginWithEmail {
|
|
||||||
display: inline-block;
|
|
||||||
background: #fff;
|
|
||||||
padding: 0 10px;
|
|
||||||
position: absolute;
|
|
||||||
top: -12px;
|
|
||||||
left: 50%;
|
|
||||||
margin-left: -106px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
23
imports/ui/pages/Logout/Logout.js
Normal file
23
imports/ui/pages/Logout/Logout.js
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import './Logout.scss';
|
||||||
|
|
||||||
|
const Logout = () => (
|
||||||
|
<div className="Logout">
|
||||||
|
<img
|
||||||
|
src="https://s3-us-west-2.amazonaws.com/cleverbeagle-assets/graphics/email-icon.png"
|
||||||
|
alt="Clever Beagle"
|
||||||
|
/>
|
||||||
|
<h1>Stay safe out there.</h1>
|
||||||
|
<p>{'Don\'t forget to like and follow Clever Beagle elsewhere on the web:'}</p>
|
||||||
|
<ul className="FollowUsElsewhere">
|
||||||
|
<li><a href="https://facebook.com/cleverbeagle"><i className="fa fa-facebook-official" /></a></li>
|
||||||
|
<li><a href="https://twitter.com/clvrbgl"><i className="fa fa-twitter" /></a></li>
|
||||||
|
<li><a href="https://github.com/cleverbeagle"><i className="fa fa-github" /></a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
Logout.propTypes = {};
|
||||||
|
|
||||||
|
export default Logout;
|
||||||
57
imports/ui/pages/Logout/Logout.scss
Normal file
57
imports/ui/pages/Logout/Logout.scss
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
@import '../../stylesheets/mixins';
|
||||||
|
@import '../../stylesheets/colors';
|
||||||
|
|
||||||
|
.Logout {
|
||||||
|
padding: 20px;
|
||||||
|
background: $cb-blue;
|
||||||
|
text-align: center;
|
||||||
|
border-radius: 3px;
|
||||||
|
color: #fff;
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100px;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
font-size: 18px;
|
||||||
|
color: lighten($cb-blue, 25%);
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
list-style: none;
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0;
|
||||||
|
margin: 10px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul li {
|
||||||
|
float: left;
|
||||||
|
font-size: 28px;
|
||||||
|
line-height: 28px;
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:not(:last-child) {
|
||||||
|
margin-right: 15px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@include breakpoint(tablet) {
|
||||||
|
.Logout {
|
||||||
|
padding: 30px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@include breakpoint(desktop) {
|
||||||
|
.Logout {
|
||||||
|
padding: 40px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { Row, Col, FormGroup, ControlLabel, Button } from 'react-bootstrap';
|
import { Row, Col, FormGroup, ControlLabel, Button } from 'react-bootstrap';
|
||||||
|
import { capitalize } from '@cleverbeagle/strings';
|
||||||
import { Meteor } from 'meteor/meteor';
|
import { Meteor } from 'meteor/meteor';
|
||||||
import { Accounts } from 'meteor/accounts-base';
|
import { Accounts } from 'meteor/accounts-base';
|
||||||
import { Bert } from 'meteor/themeteorchef:bert';
|
import { Bert } from 'meteor/themeteorchef:bert';
|
||||||
|
|
@ -10,10 +11,17 @@ import { createContainer } from 'meteor/react-meteor-data';
|
||||||
import InputHint from '../../components/InputHint/InputHint';
|
import InputHint from '../../components/InputHint/InputHint';
|
||||||
import validate from '../../../modules/validate';
|
import validate from '../../../modules/validate';
|
||||||
|
|
||||||
|
import './Profile.scss';
|
||||||
|
|
||||||
class Profile extends React.Component {
|
class Profile extends React.Component {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
|
|
||||||
|
this.getUserType = this.getUserType.bind(this);
|
||||||
this.handleSubmit = this.handleSubmit.bind(this);
|
this.handleSubmit = this.handleSubmit.bind(this);
|
||||||
|
this.renderOAuthUser = this.renderOAuthUser.bind(this);
|
||||||
|
this.renderPasswordUser = this.renderPasswordUser.bind(this);
|
||||||
|
this.renderProfileForm = this.renderProfileForm.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
|
|
@ -66,6 +74,13 @@ class Profile extends React.Component {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getUserType(user) {
|
||||||
|
const userToCheck = user;
|
||||||
|
delete userToCheck.services.resume;
|
||||||
|
const service = Object.keys(userToCheck.services)[0];
|
||||||
|
return service === 'password' ? 'password' : 'oauth';
|
||||||
|
}
|
||||||
|
|
||||||
handleSubmit() {
|
handleSubmit() {
|
||||||
const profile = {
|
const profile = {
|
||||||
emailAddress: this.emailAddress.value,
|
emailAddress: this.emailAddress.value,
|
||||||
|
|
@ -88,6 +103,85 @@ class Profile extends React.Component {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
renderOAuthUser(loading, user) {
|
||||||
|
return !loading ? (<div className="OAuthProfile">
|
||||||
|
{Object.keys(user.services).map(service => (
|
||||||
|
<div key={service} className={`LoggedInWith ${service}`}>
|
||||||
|
<div className="ServiceIcon"><i className={`fa fa-${service === 'facebook' ? 'facebook-official' : service}`} /></div>
|
||||||
|
<p>{`You're logged in with ${capitalize(service)} using the email address ${user.services[service].email}.`}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>) : <div />;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderPasswordUser(loading, user) {
|
||||||
|
return !loading ? (<div>
|
||||||
|
<Row>
|
||||||
|
<Col xs={6}>
|
||||||
|
<FormGroup>
|
||||||
|
<ControlLabel>First Name</ControlLabel>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="firstName"
|
||||||
|
defaultValue={user.profile.name.first}
|
||||||
|
ref={firstName => (this.firstName = firstName)}
|
||||||
|
className="form-control"
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
</Col>
|
||||||
|
<Col xs={6}>
|
||||||
|
<FormGroup>
|
||||||
|
<ControlLabel>Last Name</ControlLabel>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="lastName"
|
||||||
|
defaultValue={user.profile.name.last}
|
||||||
|
ref={lastName => (this.lastName = lastName)}
|
||||||
|
className="form-control"
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<FormGroup>
|
||||||
|
<ControlLabel>Email Address</ControlLabel>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
name="emailAddress"
|
||||||
|
defaultValue={user.emails[0].address}
|
||||||
|
ref={emailAddress => (this.emailAddress = emailAddress)}
|
||||||
|
className="form-control"
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
<FormGroup>
|
||||||
|
<ControlLabel>Current Password</ControlLabel>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
name="currentPassword"
|
||||||
|
ref={currentPassword => (this.currentPassword = currentPassword)}
|
||||||
|
className="form-control"
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
<FormGroup>
|
||||||
|
<ControlLabel>New Password</ControlLabel>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
name="newPassword"
|
||||||
|
ref={newPassword => (this.newPassword = newPassword)}
|
||||||
|
className="form-control"
|
||||||
|
/>
|
||||||
|
<InputHint>Use at least six characters.</InputHint>
|
||||||
|
</FormGroup>
|
||||||
|
<Button type="submit" bsStyle="success">Save Profile</Button>
|
||||||
|
</div>) : <div />;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderProfileForm(loading, user) {
|
||||||
|
return !loading ? ({
|
||||||
|
password: this.renderPasswordUser,
|
||||||
|
oauth: this.renderOAuthUser,
|
||||||
|
}[this.getUserType(user)])(loading, user) : <div />;
|
||||||
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { loading, user } = this.props;
|
const { loading, user } = this.props;
|
||||||
return (<div className="Profile">
|
return (<div className="Profile">
|
||||||
|
|
@ -95,65 +189,7 @@ class Profile extends React.Component {
|
||||||
<Col xs={12} sm={6} md={4}>
|
<Col xs={12} sm={6} md={4}>
|
||||||
<h4 className="page-header">Edit Profile</h4>
|
<h4 className="page-header">Edit Profile</h4>
|
||||||
<form ref={form => (this.form = form)} onSubmit={event => event.preventDefault()}>
|
<form ref={form => (this.form = form)} onSubmit={event => event.preventDefault()}>
|
||||||
{!loading ? (<div>
|
{this.renderProfileForm(loading, user)}
|
||||||
<Row>
|
|
||||||
<Col xs={6}>
|
|
||||||
<FormGroup>
|
|
||||||
<ControlLabel>First Name</ControlLabel>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="firstName"
|
|
||||||
defaultValue={user.profile.name.first}
|
|
||||||
ref={firstName => (this.firstName = firstName)}
|
|
||||||
className="form-control"
|
|
||||||
/>
|
|
||||||
</FormGroup>
|
|
||||||
</Col>
|
|
||||||
<Col xs={6}>
|
|
||||||
<FormGroup>
|
|
||||||
<ControlLabel>Last Name</ControlLabel>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="lastName"
|
|
||||||
defaultValue={user.profile.name.last}
|
|
||||||
ref={lastName => (this.lastName = lastName)}
|
|
||||||
className="form-control"
|
|
||||||
/>
|
|
||||||
</FormGroup>
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
<FormGroup>
|
|
||||||
<ControlLabel>Email Address</ControlLabel>
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
name="emailAddress"
|
|
||||||
defaultValue={user.emails[0].address}
|
|
||||||
ref={emailAddress => (this.emailAddress = emailAddress)}
|
|
||||||
className="form-control"
|
|
||||||
/>
|
|
||||||
</FormGroup>
|
|
||||||
<h5 className="page-header">Change Password</h5>
|
|
||||||
<FormGroup>
|
|
||||||
<ControlLabel>Current Password</ControlLabel>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
name="currentPassword"
|
|
||||||
ref={currentPassword => (this.currentPassword = currentPassword)}
|
|
||||||
className="form-control"
|
|
||||||
/>
|
|
||||||
</FormGroup>
|
|
||||||
<FormGroup>
|
|
||||||
<ControlLabel>New Password</ControlLabel>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
name="newPassword"
|
|
||||||
ref={newPassword => (this.newPassword = newPassword)}
|
|
||||||
className="form-control"
|
|
||||||
/>
|
|
||||||
<InputHint>Use at least six characters.</InputHint>
|
|
||||||
</FormGroup>
|
|
||||||
<Button type="submit" bsStyle="success">Save Profile</Button>
|
|
||||||
</div>) : <div />}
|
|
||||||
</form>
|
</form>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
|
||||||
39
imports/ui/pages/Profile/Profile.scss
Normal file
39
imports/ui/pages/Profile/Profile.scss
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
@import '../../stylesheets/colors';
|
||||||
|
|
||||||
|
.OAuthProfile {
|
||||||
|
.LoggedInWith {
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-radius: 3px;
|
||||||
|
color: #fff;
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ServiceIcon {
|
||||||
|
float: left;
|
||||||
|
width: auto;
|
||||||
|
margin-right: 15px;
|
||||||
|
height: 30px;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
i {
|
||||||
|
font-size: 30px;
|
||||||
|
position: relative;
|
||||||
|
top: 5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.facebook {
|
||||||
|
background: $facebook;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.github {
|
||||||
|
background: $github;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.google {
|
||||||
|
background: $google;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,13 +4,11 @@ import PropTypes from 'prop-types';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { Accounts } from 'meteor/accounts-base';
|
import { Accounts } from 'meteor/accounts-base';
|
||||||
import { Bert } from 'meteor/themeteorchef:bert';
|
import { Bert } from 'meteor/themeteorchef:bert';
|
||||||
import OAuthLoginButton from '../../components/OAuthLoginButton/OAuthLoginButton';
|
import OAuthLoginButtons from '../../components/OAuthLoginButtons/OAuthLoginButtons';
|
||||||
import InputHint from '../../components/InputHint/InputHint';
|
import InputHint from '../../components/InputHint/InputHint';
|
||||||
import AccountPageFooter from '../../components/AccountPageFooter/AccountPageFooter';
|
import AccountPageFooter from '../../components/AccountPageFooter/AccountPageFooter';
|
||||||
import validate from '../../../modules/validate';
|
import validate from '../../../modules/validate';
|
||||||
|
|
||||||
import './Signup.scss';
|
|
||||||
|
|
||||||
class Signup extends React.Component {
|
class Signup extends React.Component {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
|
|
@ -86,15 +84,16 @@ class Signup extends React.Component {
|
||||||
<h4 className="page-header">Sign Up</h4>
|
<h4 className="page-header">Sign Up</h4>
|
||||||
<Row>
|
<Row>
|
||||||
<Col xs={12}>
|
<Col xs={12}>
|
||||||
<FormGroup className="OAuthLoginButtons">
|
<OAuthLoginButtons
|
||||||
<OAuthLoginButton service="facebook" />
|
services={['facebook', 'github', 'google']}
|
||||||
<OAuthLoginButton service="google" />
|
emailMessage={{
|
||||||
<OAuthLoginButton service="github" />
|
offset: 97,
|
||||||
</FormGroup>
|
text: 'Sign Up with an Email Address',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
<form ref={form => (this.form = form)} onSubmit={event => event.preventDefault()}>
|
<form ref={form => (this.form = form)} onSubmit={event => event.preventDefault()}>
|
||||||
<p className="SignupWithEmail">Sign Up with an Email Address</p>
|
|
||||||
<Row>
|
<Row>
|
||||||
<Col xs={6}>
|
<Col xs={6}>
|
||||||
<FormGroup>
|
<FormGroup>
|
||||||
|
|
|
||||||
|
|
@ -13,3 +13,8 @@ $gray-lighter: #eee;
|
||||||
$facebook: #3b5998;
|
$facebook: #3b5998;
|
||||||
$google: #ea4335;
|
$google: #ea4335;
|
||||||
$github: $gray-dark;
|
$github: $gray-dark;
|
||||||
|
|
||||||
|
$cb-blue: #4285F4;
|
||||||
|
$cb-green: #00D490;
|
||||||
|
$cb-yellow: #FFCF50;
|
||||||
|
$cb-red: #DA5847;
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cleverbeagle/dates": "^0.2.0",
|
"@cleverbeagle/dates": "^0.2.0",
|
||||||
"@cleverbeagle/seeder": "^1.1.0",
|
"@cleverbeagle/seeder": "^1.1.0",
|
||||||
|
"@cleverbeagle/strings": "^0.1.0",
|
||||||
"babel-runtime": "^6.20.0",
|
"babel-runtime": "^6.20.0",
|
||||||
"bcrypt": "^1.0.2",
|
"bcrypt": "^1.0.2",
|
||||||
"jquery": "^2.2.4",
|
"jquery": "^2.2.4",
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,22 @@
|
||||||
{
|
{
|
||||||
"public": {},
|
"public": {},
|
||||||
"private": {
|
"private": {
|
||||||
|
"MAIL_URL": "",
|
||||||
"OAuth": {
|
"OAuth": {
|
||||||
"facebook": {
|
"facebook": {
|
||||||
"clientId": "",
|
"appId": "",
|
||||||
"secret": ""
|
"secret": "",
|
||||||
|
"loginStyle": "popup"
|
||||||
},
|
},
|
||||||
"google": {
|
"google": {
|
||||||
"clientId": "",
|
"clientId": "",
|
||||||
"secret": ""
|
"secret": "",
|
||||||
|
"loginStyle": "popup"
|
||||||
},
|
},
|
||||||
"github": {
|
"github": {
|
||||||
"clientId": "",
|
"clientId": "",
|
||||||
"secret": ""
|
"secret": "",
|
||||||
|
"loginStyle": "popup"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue