diff --git a/.gitignore b/.gitignore index 49ac726..579ab5a 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ jsconfig.json public *.iml .npmrc +packages/server/sessions diff --git a/config/auth.js b/config/auth.js new file mode 100644 index 0000000..d562fc2 --- /dev/null +++ b/config/auth.js @@ -0,0 +1,53 @@ +export default { + secret: process.env.NODE_ENV === 'test' ? 'secret for tests' : process.env.AUTH_SECRET, + session: { + enabled: false, + secret: 'secret', + store: null, + cookie: { maxAge: 60000 }, + resave: false, + saveUninitialized: false + }, + jwt: { + enabled: true, + tokenExpiresIn: '1m', + refreshTokenExpiresIn: '7d' + }, + password: { + requireEmailConfirmation: true, + sendPasswordChangesEmail: true, + minLength: 8, + enabled: true + }, + social: { + facebook: { + enabled: false, + clientID: process.env.FACEBOOK_CLIENTID, + clientSecret: process.env.FACEBOOK_CLIENTSECRET, + callbackURL: '/api/auth/facebook/callback', + scope: ['email'], + profileFields: ['id', 'emails', 'displayName'] + }, + github: { + enabled: false, + clientID: process.env.GITHUB_CLIENTID, + clientSecret: process.env.GITHUB_CLIENTSECRET, + callbackURL: '/api/auth/github/callback', + scope: ['user:email'] + }, + linkedin: { + enabled: false, + clientID: process.env.LINKEDIN_CLIENTID, + clientSecret: process.env.LINKEDIN_CLIENTSECRET, + callbackURL: '/api/auth/linkedin/callback', + scope: ['r_liteprofile'] + }, + google: { + enabled: false, + clientID: process.env.GOOGLE_CLIENTID, + clientSecret: process.env.GOOGLE_CLIENTSECRET, + callbackURL: '/api/auth/google/callback', + scope: ['https://www.googleapis.com/auth/userinfo.email', 'https://www.googleapis.com/auth/userinfo.profile'] + } + } +}; diff --git a/config/index.js b/config/index.js index 1cab3e5..a420c20 100644 --- a/config/index.js +++ b/config/index.js @@ -1,4 +1,5 @@ export { default as app } from './app'; +export { default as auth } from './auth'; export { default as db } from './db'; export { default as mailer } from './mailer'; export { default as analytics } from './analytics'; diff --git a/modules/authentication/client-react/access/AccessModule.ts b/modules/authentication/client-react/access/AccessModule.ts new file mode 100644 index 0000000..03d412c --- /dev/null +++ b/modules/authentication/client-react/access/AccessModule.ts @@ -0,0 +1,49 @@ +import ClientModule, { ClientModuleShape } from '@restapp/module-client-react'; + +/** + * Access module which provides authentication functions + */ +export interface AccessModuleShape extends ClientModuleShape { + // login handlers from all authentication modules + login?: Array<(callback?: () => void) => Promise>; + // logout handlers from all authentication modules + logout?: Array<(callback?: () => void) => Promise>; +} + +interface AccessModule extends AccessModuleShape {} + +class AccessModule extends ClientModule { + /** + * Constructs access module representation, that folds all the feature modules + * into a single module represented by this instance. + * + * @param modules feature modules + */ + constructor(...modules: AccessModuleShape[]) { + super(...modules); + } + + /** + * call all methods of login in authentication modules. + * + * @returns merge login methods + */ + public async doLogin(callback?: () => void) { + for (const login of this.login) { + await login(callback); + } + } + + /** + * call all methods of logout in authentication modules. + * + * @returns merge logout methods + */ + public async doLogout(callback?: () => void) { + for (const logout of this.logout) { + await logout(callback); + } + } +} + +export default AccessModule; diff --git a/modules/authentication/client-react/access/index.tsx b/modules/authentication/client-react/access/index.tsx new file mode 100644 index 0000000..e91c031 --- /dev/null +++ b/modules/authentication/client-react/access/index.tsx @@ -0,0 +1,47 @@ +import * as React from 'react'; +import jwt from './jwt'; +import session from './session'; +import AccessModule from './AccessModule'; + +interface PageReloaderProps { + children?: React.ReactElement; +} + +interface AuthPageReloaderProps extends PageReloaderProps {} + +const ref: React.RefObject = React.createRef(); + +const rerenderApp = () => { + ref.current.reloadPage(); +}; + +const login = async (_clearStore?: () => void) => { + rerenderApp(); +}; + +const logout = async (clearStore: () => void) => { + await clearStore(); + rerenderApp(); +}; + +class PageReloader extends React.Component { + public state = { + key: 1 + }; + + public reloadPage() { + this.setState({ key: this.state.key + 1 }); + } + + public render() { + return React.cloneElement(this.props.children, { key: this.state.key }); + } +} + +const AuthPageReloader = ({ children }: AuthPageReloaderProps) => {children}; + +export default new AccessModule(jwt, session, { + dataRootComponent: [AuthPageReloader], + login: [login], + logout: [logout] +}); diff --git a/modules/authentication/client-react/access/jwt/index.ts b/modules/authentication/client-react/access/jwt/index.ts new file mode 100644 index 0000000..cc76fa9 --- /dev/null +++ b/modules/authentication/client-react/access/jwt/index.ts @@ -0,0 +1,100 @@ +import axios from 'axios'; +import { getItem, setItem, removeItem } from '@restapp/core-common/clientStorage'; +import { Middleware } from 'redux'; +import settings from '../../../../../settings'; +import AccessModule from '../AccessModule'; + +enum TokensEnum { + accessToken = 'accessToken', + refreshToken = 'refreshToken' +} + +interface Tokens { + [key: string]: TokensEnum; +} + +const saveTokens = async ({ accessToken, refreshToken }: Tokens) => { + await setItem(TokensEnum.accessToken, accessToken); + await setItem(TokensEnum.refreshToken, refreshToken); +}; + +const removeTokens = async () => { + await removeItem(TokensEnum.accessToken); + await removeItem(TokensEnum.refreshToken); +}; + +const refreshAccessToken = async () => { + try { + const { data } = await axios.post(`${__API_URL__}/refreshToken`, { + refreshToken: await getItem('refreshToken') + }); + if (data) { + const { accessToken, refreshToken } = data; + await saveTokens({ accessToken, refreshToken }); + } else { + await removeTokens(); + } + } catch (e) { + await removeTokens(); + throw e; + } +}; + +const reduxMiddleware: Middleware = ({ dispatch }) => next => action => { + const { types, status, ...rest } = action; + (async () => { + try { + if (status === 401) { + try { + await refreshAccessToken(); + const newAction = { ...action, status: null }; + return dispatch(newAction); + } catch (e) { + throw e; + } + } + return next(action); + } catch (e) { + next({ + type: types.FAIL, + ...rest + }); + throw e; + } + })(); +}; + +axios.interceptors.request.use(async config => { + const accessToken = await getItem(TokensEnum.accessToken); + + const arrayExceptions = ['login', 'refreshTokens']; + const checkInclude = arrayExceptions.some(exception => config.url.includes(exception)); + + config.headers = !checkInclude && accessToken ? { Authorization: `Bearer ${accessToken}` } : {}; + return config; +}); + +axios.interceptors.response.use(async (res: any) => { + if (res.config.url.includes('login')) { + if (!!res.data && res.data.tokens) { + const { + data: { + tokens: { accessToken, refreshToken } + } + } = res; + await saveTokens({ accessToken, refreshToken }); + res.data = res.data.user; + return res; + } else { + await removeTokens(); + } + } + return res; +}); + +export default (settings.auth.jwt.enabled + ? new AccessModule({ + logout: [removeTokens], + reduxMiddleware: [reduxMiddleware] + }) + : undefined); diff --git a/modules/authentication/client-react/access/session/index.ts b/modules/authentication/client-react/access/session/index.ts new file mode 100644 index 0000000..3688ab1 --- /dev/null +++ b/modules/authentication/client-react/access/session/index.ts @@ -0,0 +1,12 @@ +import settings from '../../../../../settings'; +import AccessModule from '../AccessModule'; +import axios from 'axios'; + +const logout = async () => { + axios.post(`${__API_URL__}/logout`); +}; +export default (settings.auth.session.enabled + ? new AccessModule({ + logout: [logout] + }) + : undefined); diff --git a/modules/authentication/client-react/helpers/index.ts b/modules/authentication/client-react/helpers/index.ts new file mode 100644 index 0000000..207db80 --- /dev/null +++ b/modules/authentication/client-react/helpers/index.ts @@ -0,0 +1,12 @@ +import url from 'url'; +import { Constants } from 'expo'; + +export default function buildRedirectUrlForMobile(authType: string) { + const { protocol, hostname, port } = url.parse(__WEBSITE_URL__); + const expoHostname = `${url.parse(Constants.linkingUri).hostname}.nip.io`; + const urlHostname = process.env.NODE_ENV === 'production' ? hostname : expoHostname; + + return `${protocol}//${urlHostname}${port ? ':' + port : ''}/api/auth/${authType}?expoUrl=${encodeURIComponent( + Constants.linkingUri + )}`; +} diff --git a/modules/authentication/client-react/index.ts b/modules/authentication/client-react/index.ts new file mode 100644 index 0000000..e74ec5e --- /dev/null +++ b/modules/authentication/client-react/index.ts @@ -0,0 +1,5 @@ +import authentication from './access'; + +export * from './social'; + +export default authentication; diff --git a/modules/authentication/client-react/package.json b/modules/authentication/client-react/package.json new file mode 100644 index 0000000..5502094 --- /dev/null +++ b/modules/authentication/client-react/package.json @@ -0,0 +1,8 @@ +{ + "name": "@restapp/authentication-client-react", + "version": "0.1.0", + "private": true, + "dependencies": { + "@restapp/core-common": "^0.1.0" + } +} \ No newline at end of file diff --git a/modules/authentication/client-react/social/facebook/containers/FacebookButton.css b/modules/authentication/client-react/social/facebook/containers/FacebookButton.css new file mode 100644 index 0000000..f7a0a10 --- /dev/null +++ b/modules/authentication/client-react/social/facebook/containers/FacebookButton.css @@ -0,0 +1,39 @@ +.facebookBtn { + min-width: 320px; + margin-top: 10px; + background-color: #3769ae; + border-color: #3769ae; + display: flex; + justify-content: flex-start; + align-items: center; +} + +.facebookBtn:hover { + background-color: #17427e; + border-color: #17427e; +} + +.iconContainer { + display: flex; + flex: 1 1 10%; + justify-content: flex-start; + align-items: center; +} + +.separator { + height: 28px; + width: 1px; + background-color: #fff !important; + margin-left: 10px; +} + +.btnText { + display: flex; + justify-content: flex-start; + flex: 5 +} + +.facebookIcon { + color: #fff; + font-size: 30px; +} diff --git a/modules/authentication/client-react/social/facebook/containers/FacebookButton.native.tsx b/modules/authentication/client-react/social/facebook/containers/FacebookButton.native.tsx new file mode 100644 index 0000000..1dca6f6 --- /dev/null +++ b/modules/authentication/client-react/social/facebook/containers/FacebookButton.native.tsx @@ -0,0 +1,80 @@ +import React from 'react'; +import { View, StyleSheet, Linking, TouchableOpacity, Text, Platform } from 'react-native'; +import { WebBrowser } from 'expo'; +import { FontAwesome } from '@expo/vector-icons'; +import { + iconWrapper, + linkText, + link, + buttonContainer, + separator, + btnIconContainer, + btnTextContainer, + btnText +} from '@restapp/look-client-react-native/styles'; + +import { SocialButton, SocialButtonComponent } from '../../..'; +import buildRedirectUrlForMobile from '../../../helpers'; + +const facebookLogin = () => { + const url = buildRedirectUrlForMobile('facebook'); + if (Platform.OS === 'ios') { + WebBrowser.openBrowserAsync(url); + } else { + Linking.openURL(url); + } +}; + +const FacebookButton = ({ text }: SocialButtonComponent) => { + return ( + + + + + + + {text} + + + ); +}; + +const FacebookLink = ({ text }: SocialButtonComponent) => { + return ( + + {text} + + ); +}; + +const FacebookIcon = () => ( + + + +); + +const FacebookComponent: React.FunctionComponent = ({ type, text }) => { + switch (type) { + case 'button': + return ; + case 'link': + return ; + case 'icon': + return ; + default: + return ; + } +}; + +const styles = StyleSheet.create({ + iconWrapper, + linkText, + link, + buttonContainer, + separator, + btnIconContainer, + btnTextContainer, + btnText +}); + +export default FacebookComponent; diff --git a/modules/authentication/client-react/social/facebook/containers/FacebookButton.tsx b/modules/authentication/client-react/social/facebook/containers/FacebookButton.tsx new file mode 100644 index 0000000..9f654c2 --- /dev/null +++ b/modules/authentication/client-react/social/facebook/containers/FacebookButton.tsx @@ -0,0 +1,55 @@ +import * as React from 'react'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faFacebookSquare } from '@fortawesome/fontawesome-free-brands'; +import { IconProp } from '@fortawesome/fontawesome-svg-core'; +import { Button } from '@restapp/look-client-react'; + +import { SocialButton, SocialButtonComponent } from '../../..'; +import './FacebookButton.css'; + +const facebookLogin = () => { + window.location.href = '/api/auth/facebook'; +}; + +const FacebookButton = ({ text }: SocialButtonComponent) => { + return ( + + ); +}; + +const FacebookLink = ({ text }: SocialButtonComponent) => { + return ( + + ); +}; + +const FacebookIcon = () => ( +
+ +
+); + +const FacebookComponent: React.FunctionComponent = ({ text, type }) => { + switch (type) { + case 'button': + return ; + case 'link': + return ; + case 'icon': + return ; + default: + return ; + } +}; + +export default FacebookComponent; diff --git a/modules/authentication/client-react/social/facebook/index.native.ts b/modules/authentication/client-react/social/facebook/index.native.ts new file mode 100644 index 0000000..d22328c --- /dev/null +++ b/modules/authentication/client-react/social/facebook/index.native.ts @@ -0,0 +1,3 @@ +import FacebookButton from './containers/FacebookButton'; + +export default FacebookButton; diff --git a/modules/authentication/client-react/social/facebook/index.ts b/modules/authentication/client-react/social/facebook/index.ts new file mode 100644 index 0000000..d22328c --- /dev/null +++ b/modules/authentication/client-react/social/facebook/index.ts @@ -0,0 +1,3 @@ +import FacebookButton from './containers/FacebookButton'; + +export default FacebookButton; diff --git a/modules/authentication/client-react/social/github/containers/GitHubButton.css b/modules/authentication/client-react/social/github/containers/GitHubButton.css new file mode 100644 index 0000000..3c35726 --- /dev/null +++ b/modules/authentication/client-react/social/github/containers/GitHubButton.css @@ -0,0 +1,39 @@ +.githubBtn { + min-width: 320px; + margin-top: 10px; + background-color: #464646; + border-color: #464646; + display: flex; + justify-content: flex-start; + align-items: center; +} + +.githubBtn:hover { + background-color: #5f5e5e; + border-color: #5f5e5e; +} + +.iconContainer { + display: flex; + flex: 1 1 10%; + justify-content: flex-start; + align-items: center; +} + +.separator { + height: 28px; + width: 1px; + background-color: #fff !important; + margin-left: 10px; +} + +.btnText { + display: flex; + justify-content: flex-start; + flex: 5 +} + +.githubIcon { + color: #fff; + font-size: 30px; +} \ No newline at end of file diff --git a/modules/authentication/client-react/social/github/containers/GitHubButton.native.tsx b/modules/authentication/client-react/social/github/containers/GitHubButton.native.tsx new file mode 100644 index 0000000..80b78e0 --- /dev/null +++ b/modules/authentication/client-react/social/github/containers/GitHubButton.native.tsx @@ -0,0 +1,84 @@ +import React from 'react'; +import { View, StyleSheet, Linking, TouchableOpacity, Text, Platform } from 'react-native'; +import { WebBrowser } from 'expo'; +import { FontAwesome } from '@expo/vector-icons'; +import { + iconWrapper, + linkText, + link, + buttonContainer, + separator, + btnIconContainer, + btnTextContainer, + btnText +} from '@restapp/look-client-react-native/styles'; + +import { SocialButton, SocialButtonComponent } from '../..'; +import buildRedirectUrlForMobile from '../../../helpers'; + +const githubLogin = () => { + const url = buildRedirectUrlForMobile('github'); + if (Platform.OS === 'ios') { + WebBrowser.openBrowserAsync(url); + } else { + Linking.openURL(url); + } +}; + +const GitHubButton = ({ text }: SocialButtonComponent) => { + return ( + + + + + + + {text} + + + ); +}; + +const GitHubLink = ({ text }: SocialButtonComponent) => { + return ( + + {text} + + ); +}; + +const GitHubIcon = () => ( + + + +); + +const GitHubComponent: React.FunctionComponent = ({ type, text }) => { + switch (type) { + case 'button': + return ; + case 'link': + return ; + case 'icon': + return ; + default: + return ; + } +}; + +const styles = StyleSheet.create({ + iconWrapper, + linkText, + link, + buttonContainer: { + ...buttonContainer, + marginTop: 15, + backgroundColor: '#464646' + }, + separator, + btnIconContainer, + btnTextContainer, + btnText +}); + +export default GitHubComponent; diff --git a/modules/authentication/client-react/social/github/containers/GitHubButton.tsx b/modules/authentication/client-react/social/github/containers/GitHubButton.tsx new file mode 100644 index 0000000..97285a2 --- /dev/null +++ b/modules/authentication/client-react/social/github/containers/GitHubButton.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faGithubSquare } from '@fortawesome/fontawesome-free-brands'; +import { IconProp } from '@fortawesome/fontawesome-svg-core'; +import { Button } from '@restapp/look-client-react'; + +import { SocialButton, SocialButtonComponent } from '../..'; +import './GitHubButton.css'; + +const githubLogin = () => { + window.location.href = '/api/auth/github'; +}; + +const GitHubButton = ({ text }: SocialButtonComponent) => { + return ( + + ); +}; + +const GitHubLink = ({ text }: SocialButtonComponent) => { + return ( + + ); +}; + +const GitHubIcon = () => ( +
+ +
+); + +const GithubComponent: React.FunctionComponent = ({ text, type }) => { + switch (type) { + case 'button': + return ; + case 'link': + return ; + case 'icon': + return ; + default: + return ; + } +}; + +export default GithubComponent; diff --git a/modules/authentication/client-react/social/github/index.native.ts b/modules/authentication/client-react/social/github/index.native.ts new file mode 100644 index 0000000..2787227 --- /dev/null +++ b/modules/authentication/client-react/social/github/index.native.ts @@ -0,0 +1,3 @@ +import GitHubButton from './containers/GitHubButton'; + +export default GitHubButton; diff --git a/modules/authentication/client-react/social/github/index.ts b/modules/authentication/client-react/social/github/index.ts new file mode 100644 index 0000000..2787227 --- /dev/null +++ b/modules/authentication/client-react/social/github/index.ts @@ -0,0 +1,3 @@ +import GitHubButton from './containers/GitHubButton'; + +export default GitHubButton; diff --git a/modules/authentication/client-react/social/google/containers/GoogleButton.css b/modules/authentication/client-react/social/google/containers/GoogleButton.css new file mode 100644 index 0000000..236eb58 --- /dev/null +++ b/modules/authentication/client-react/social/google/containers/GoogleButton.css @@ -0,0 +1,39 @@ +.googleBtn { + min-width: 320px; + margin-top: 10px; + background-color: #c43832; + border-color: #c43832; + display: flex; + justify-content: flex-start; + align-items: center; +} + +.googleBtn:hover { + background-color: #aa1c17; + border-color: #aa1c17; +} + +.iconContainer { + display: flex; + flex: 1 1 10%; + justify-content: flex-start; + align-items: center; +} + +.separator { + height: 28px; + width: 1px; + background-color: #fff; + margin-left: 10px; +} + +.btnText { + display: flex; + justify-content: flex-start; + flex: 5 +} + +.googleIcon { + color: #fff; + font-size: 30px; +} \ No newline at end of file diff --git a/modules/authentication/client-react/social/google/containers/GoogleButton.native.tsx b/modules/authentication/client-react/social/google/containers/GoogleButton.native.tsx new file mode 100644 index 0000000..d448b1c --- /dev/null +++ b/modules/authentication/client-react/social/google/containers/GoogleButton.native.tsx @@ -0,0 +1,87 @@ +import React from 'react'; +import { View, StyleSheet, Linking, TouchableOpacity, Text, Platform } from 'react-native'; +import { WebBrowser } from 'expo'; +import { FontAwesome } from '@expo/vector-icons'; +import { + iconWrapper, + linkText, + link, + buttonContainer, + separator, + btnIconContainer, + btnTextContainer, + btnText +} from '@restapp/look-client-react-native/styles'; + +import { SocialButtonComponent, SocialButton } from '../..'; +import buildRedirectUrlForMobile from '../../../helpers'; + +const googleLogin = () => { + const url = buildRedirectUrlForMobile('google'); + if (Platform.OS === 'ios') { + WebBrowser.openBrowserAsync(url); + } else { + Linking.openURL(url); + } +}; + +const GoogleButton = ({ text }: SocialButtonComponent) => { + return ( + + + + + + + {text} + + + ); +}; + +const GoogleLink = ({ text }: SocialButtonComponent) => { + return ( + + {text} + + ); +}; + +const GoogleIcon = () => ( + + + +); + +const GoogleComponent: React.FunctionComponent = ({ type, text }) => { + switch (type) { + case 'button': + return ; + case 'link': + return ; + case 'icon': + return ; + default: + return ; + } +}; + +const styles = StyleSheet.create({ + iconWrapper, + linkText, + link, + buttonContainer: { + ...buttonContainer, + marginTop: 15, + backgroundColor: '#c43832' + }, + separator: { + ...separator, + backgroundColor: '#fff' + }, + btnIconContainer, + btnTextContainer, + btnText +}); + +export default GoogleComponent; diff --git a/modules/authentication/client-react/social/google/containers/GoogleButton.tsx b/modules/authentication/client-react/social/google/containers/GoogleButton.tsx new file mode 100644 index 0000000..dce2914 --- /dev/null +++ b/modules/authentication/client-react/social/google/containers/GoogleButton.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import faGooglePlusSquare from '@fortawesome/fontawesome-free-brands/faGooglePlusSquare'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { IconProp } from '@fortawesome/fontawesome-svg-core'; +import { Button } from '@restapp/look-client-react'; + +import { SocialButtonComponent, SocialButton } from '../..'; +import './GoogleButton.css'; + +const googleLogin = () => { + window.location.href = '/api/auth/google'; +}; + +const GoogleButton = ({ text }: SocialButtonComponent) => { + return ( + + ); +}; + +const GoogleLink = ({ text }: SocialButtonComponent) => { + return ( + + ); +}; + +const GoogleIcon = () => ( +
+ +
+); + +const GoogleComponent: React.FunctionComponent = ({ type, text }) => { + switch (type) { + case 'button': + return ; + case 'link': + return ; + case 'icon': + return ; + default: + return ; + } +}; + +export default GoogleComponent; diff --git a/modules/authentication/client-react/social/google/index.native.ts b/modules/authentication/client-react/social/google/index.native.ts new file mode 100644 index 0000000..bea8dc6 --- /dev/null +++ b/modules/authentication/client-react/social/google/index.native.ts @@ -0,0 +1,3 @@ +import GoogleButton from './containers/GoogleButton'; + +export default GoogleButton; diff --git a/modules/authentication/client-react/social/google/index.ts b/modules/authentication/client-react/social/google/index.ts new file mode 100644 index 0000000..bea8dc6 --- /dev/null +++ b/modules/authentication/client-react/social/google/index.ts @@ -0,0 +1,3 @@ +import GoogleButton from './containers/GoogleButton'; + +export default GoogleButton; diff --git a/modules/authentication/client-react/social/index.ts b/modules/authentication/client-react/social/index.ts new file mode 100644 index 0000000..15e0f59 --- /dev/null +++ b/modules/authentication/client-react/social/index.ts @@ -0,0 +1,12 @@ +export interface SocialButtonComponent { + text?: string; +} + +export interface SocialButton extends SocialButtonComponent { + type: string; +} + +export { default as LinkedInButton } from './linkedin'; +export { default as GoogleButton } from './google'; +export { default as GitHubButton } from './github'; +export { default as FacebookButton } from './facebook'; diff --git a/modules/authentication/client-react/social/linkedin/containers/LinkedInButton.css b/modules/authentication/client-react/social/linkedin/containers/LinkedInButton.css new file mode 100644 index 0000000..f044014 --- /dev/null +++ b/modules/authentication/client-react/social/linkedin/containers/LinkedInButton.css @@ -0,0 +1,39 @@ +.linkedInBtn { + min-width: 320px; + margin-top: 10px; + background-color: #0077b0; + border-color: #0077b0; + display: flex; + justify-content: flex-start; + align-items: center; +} + +.linkedInBtn:hover { + background-color: #054b6b; + border-color: #054b6b; +} + +.iconContainer { + display: flex; + flex: 1 1 10%; + justify-content: flex-start; + align-items: center; +} + +.separator { + height: 28px; + width: 1px; + background-color: #fff !important; + margin-left: 10px; +} + +.btnText { + display: flex; + justify-content: flex-start; + flex: 5 +} + +.linkedInIcon { + color: #fff; + font-size: 30px; +} diff --git a/modules/authentication/client-react/social/linkedin/containers/LinkedInButton.native.tsx b/modules/authentication/client-react/social/linkedin/containers/LinkedInButton.native.tsx new file mode 100644 index 0000000..dcf13f5 --- /dev/null +++ b/modules/authentication/client-react/social/linkedin/containers/LinkedInButton.native.tsx @@ -0,0 +1,84 @@ +import React from 'react'; +import { View, StyleSheet, Linking, TouchableOpacity, Text, Platform } from 'react-native'; +import { WebBrowser } from 'expo'; +import { FontAwesome } from '@expo/vector-icons'; +import { + iconWrapper, + linkText, + link, + buttonContainer, + separator, + btnIconContainer, + btnTextContainer, + btnText +} from '@restapp/look-client-react-native/styles'; + +import { SocialButton, SocialButtonComponent } from '../..'; +import buildRedirectUrlForMobile from '../../../helpers'; + +const linkedInLogin = () => { + const url = buildRedirectUrlForMobile('linkedin'); + if (Platform.OS === 'ios') { + WebBrowser.openBrowserAsync(url); + } else { + Linking.openURL(url); + } +}; + +const LinkedInButton = ({ text }: SocialButtonComponent) => { + return ( + + + + + + + {text} + + + ); +}; + +const LinkedInLink = ({ text }: SocialButtonComponent) => { + return ( + + {text} + + ); +}; + +const LinkedInIcon = () => ( + + + +); + +const LinkedInComponent: React.FunctionComponent = ({ type, text }) => { + switch (type) { + case 'button': + return ; + case 'link': + return ; + case 'icon': + return ; + default: + return ; + } +}; + +const styles = StyleSheet.create({ + iconWrapper, + linkText, + link, + buttonContainer: { + ...buttonContainer, + marginTop: 15, + backgroundColor: '#0077b0' + }, + separator, + btnIconContainer, + btnTextContainer, + btnText +}); + +export default LinkedInComponent; diff --git a/modules/authentication/client-react/social/linkedin/containers/LinkedInButton.tsx b/modules/authentication/client-react/social/linkedin/containers/LinkedInButton.tsx new file mode 100644 index 0000000..f32c1ab --- /dev/null +++ b/modules/authentication/client-react/social/linkedin/containers/LinkedInButton.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import { faLinkedin } from '@fortawesome/fontawesome-free-brands'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { IconProp } from '@fortawesome/fontawesome-svg-core'; +import { Button } from '@restapp/look-client-react'; + +import { SocialButtonComponent, SocialButton } from '../..'; +import './LinkedInButton.css'; + +const linkedInLogin = () => { + window.location.href = '/api/auth/linkedin'; +}; + +const LinkedInButton = ({ text }: SocialButtonComponent) => { + return ( + + ); +}; + +const LinkedInLink = ({ text }: SocialButtonComponent) => { + return ( + + ); +}; + +const LinkedInIcon = () => ( +
+ +
+); + +const LinkedInComponent: React.FunctionComponent = ({ text, type }) => { + switch (type) { + case 'button': + return ; + case 'link': + return ; + case 'icon': + return ; + default: + return ; + } +}; + +export default LinkedInComponent; diff --git a/modules/authentication/client-react/social/linkedin/index.native.ts b/modules/authentication/client-react/social/linkedin/index.native.ts new file mode 100644 index 0000000..8d6a494 --- /dev/null +++ b/modules/authentication/client-react/social/linkedin/index.native.ts @@ -0,0 +1,3 @@ +import LinkedInButton from './containers/LinkedInButton'; + +export default LinkedInButton; diff --git a/modules/authentication/client-react/social/linkedin/index.ts b/modules/authentication/client-react/social/linkedin/index.ts new file mode 100644 index 0000000..8d6a494 --- /dev/null +++ b/modules/authentication/client-react/social/linkedin/index.ts @@ -0,0 +1,3 @@ +import LinkedInButton from './containers/LinkedInButton'; + +export default LinkedInButton; diff --git a/modules/authentication/server-ts/access/AccessModule.ts b/modules/authentication/server-ts/access/AccessModule.ts new file mode 100644 index 0000000..928f961 --- /dev/null +++ b/modules/authentication/server-ts/access/AccessModule.ts @@ -0,0 +1,33 @@ +import { UserShape } from './index'; +import { Request } from 'express'; +import { merge } from 'lodash'; +import ServerModule, { ServerModuleShape } from '@restapp/module-server-ts'; + +interface AccessModuleShape extends ServerModuleShape { + grant?: Array<(identity: UserShape, req: Request, passwordHash: string) => { [key: string]: any } | void>; +} + +interface AccessModule extends AccessModuleShape {} + +type GrantAccessFunc = (identity: UserShape, req: Request, passwordHash: string) => Promise; + +class AccessModule extends ServerModule { + constructor(...modules: AccessModuleShape[]) { + super(...modules); + } + + get grantAccess(): GrantAccessFunc { + return async (identity: UserShape, req: Request, passwordHash: string) => { + let result = {}; + if (this.grant) { + for (const grant of this.grant) { + result = merge(result, await grant(identity, req, passwordHash)); + } + } + + return result; + }; + } +} + +export default AccessModule; diff --git a/modules/authentication/server-ts/access/index.ts b/modules/authentication/server-ts/access/index.ts new file mode 100644 index 0000000..db2e9a8 --- /dev/null +++ b/modules/authentication/server-ts/access/index.ts @@ -0,0 +1,20 @@ +import jwt from './jwt'; +import session from './session'; +import resources from '../locales'; + +import AccessModule from './AccessModule'; + +export interface UserShape { + id?: number; + username: string; + role?: string; + isActive?: boolean; + email?: string; + passwordHash?: string; +} + +// Try to grant access via sessions first, and if that fails, then try using JWT +// This way if both JWT and sessions enabled UI won't have to refresh access tokens +export default new AccessModule(session, jwt, { + localization: [{ ns: 'auth', resources }] +}); diff --git a/modules/authentication/server-ts/access/jwt/controllers.ts b/modules/authentication/server-ts/access/jwt/controllers.ts new file mode 100644 index 0000000..783b3b4 --- /dev/null +++ b/modules/authentication/server-ts/access/jwt/controllers.ts @@ -0,0 +1,45 @@ +import jwt from 'jsonwebtoken'; + +import settings from '../../../../../settings'; +import createTokens from './createTokens'; + +export const refreshTokens = async (req: any, res: any) => { + const { + body: { refreshToken: inputRefreshToken }, + t + } = req; + const { + locals: { + appContext: { + user: { getHash, getIdentity } + } + } + } = res; + const decodedToken = jwt.decode(inputRefreshToken) as { [key: string]: any }; + const isValidToken = decodedToken && decodedToken.id; + + if (!isValidToken) { + return res.status(401).send({ + errors: { + message: t('auth:invalidRefresh') + } + }); + } + + const identity = await getIdentity(decodedToken.id); + const hash = getHash ? await getHash(decodedToken.id) : ''; + const refreshSecret = settings.auth.secret + hash; + + try { + jwt.verify(inputRefreshToken, refreshSecret); + } catch (err) { + return res.status(401).send({ errors: err }); + } + + const [accessToken, refreshToken] = await createTokens(identity, settings.auth.secret, refreshSecret, req.t); + + res.json({ + accessToken, + refreshToken + }); +}; diff --git a/modules/authentication/server-ts/access/jwt/createTokens.ts b/modules/authentication/server-ts/access/jwt/createTokens.ts new file mode 100644 index 0000000..604d631 --- /dev/null +++ b/modules/authentication/server-ts/access/jwt/createTokens.ts @@ -0,0 +1,20 @@ +import jwt from 'jsonwebtoken'; + +import { UserShape } from './../index'; +import settings from '../../../../../settings'; + +const { + jwt: { tokenExpiresIn, refreshTokenExpiresIn } +} = settings.auth; + +const createTokens = async (identity: UserShape, secret: string, refreshSecret: string, t: any) => { + if (!identity.id) { + throw new Error(t('auth:identityWithoutId')); + } + const createToken = jwt.sign({ identity }, secret, { expiresIn: tokenExpiresIn }); + const createRefreshToken = jwt.sign({ id: identity.id }, refreshSecret, { expiresIn: refreshTokenExpiresIn }); + + return Promise.all([createToken, createRefreshToken]); +}; + +export default createTokens; diff --git a/modules/authentication/server-ts/access/jwt/index.ts b/modules/authentication/server-ts/access/jwt/index.ts new file mode 100644 index 0000000..0c8eba6 --- /dev/null +++ b/modules/authentication/server-ts/access/jwt/index.ts @@ -0,0 +1,119 @@ +import { Express, Request, Response, NextFunction } from 'express'; +import { Strategy as LocalStrategy } from 'passport-local'; +import passport from 'passport'; +import { Strategy as JWTStrategy, ExtractJwt } from 'passport-jwt'; + +import { RestMethod } from '@restapp/module-server-ts'; +import settings from '../../../../../settings'; +import AccessModule from '../AccessModule'; +import { refreshTokens } from './controllers'; +import createTokens from './createTokens'; + +const { + auth: { secret } +} = settings; + +const beforeware = (app: Express) => { + app.use(passport.initialize()); +}; + +const accessMiddleware = (req: Request, res: Response, next: NextFunction) => + passport.authenticate('jwt', { session: false }, (_err, user, info) => { + if (info) { + res.locals.error = info; + return next(); + } + req.user = { ...user }; + return next(); + })(req, res, next); + +const checkAuthentication = (req: Request, res: Response, next: NextFunction) => { + if (req.user) { + return next(); + } + return res.status(401).send({ + errors: { + message: res.locals.error ? res.locals.error.message : 'unauthorized' + } + }); +}; + +const grant = async (identity: any, req: any, passwordHash: string = '') => { + const refreshSecret = settings.auth.secret + (passwordHash || ''); + + const [accessToken, refreshToken] = await createTokens(identity, settings.auth.secret, refreshSecret, req.t); + + return { + accessToken, + refreshToken + }; +}; + +const loginMiddleware = (req: any, res: any, next: any) => { + passport.authenticate('local', { session: settings.auth.session.enabled }, (err, user, info) => { + if (err || !user) { + return res.status(400).json({ + errors: { + message: info ? info.message : 'Login failed' + } + }); + } + + req.login(user, { session: settings.auth.session.enabled }, async (loginErr: any) => { + if (loginErr) { + res.send(loginErr); + } + const tokens = settings.auth.jwt.enabled ? await grant(user, req, user.passwordHash) : null; + + return res.json({ user, tokens }); + }); + })(req, res, next); +}; + +const onAppCreate = ({ appContext }: AccessModule) => { + passport.use( + new LocalStrategy({ usernameField: 'usernameOrEmail' }, async (username: string, password: string, done: any) => { + const { user, message } = await appContext.user.validateLogin(username, password); + + if (message) { + return done(null, false, { message }); + } + return done(null, user); + }) + ); + + passport.use( + new JWTStrategy( + { + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + secretOrKey: secret + }, + (jwtPayload: any, cb: any) => { + return cb(null, jwtPayload.identity); + } + ) + ); +}; + +const jwtAppContext = { + auth: { + loginMiddleware + } +}; + +export default (settings.auth.jwt.enabled + ? new AccessModule({ + beforeware: [beforeware], + onAppCreate: [onAppCreate], + grant: [grant], + appContext: jwtAppContext, + apiRouteParams: [ + { + method: RestMethod.POST, + route: 'refreshToken', + controller: refreshTokens + } + ], + accessMiddleware: [accessMiddleware, checkAuthentication] + }) + : undefined); diff --git a/modules/authentication/server-ts/access/session/controllers.ts b/modules/authentication/server-ts/access/session/controllers.ts new file mode 100644 index 0000000..1402e1a --- /dev/null +++ b/modules/authentication/server-ts/access/session/controllers.ts @@ -0,0 +1,6 @@ +import { Request, Response } from 'express'; + +export const logout = (req: Request, res: Response) => { + req.logout(); + res.status(200).send(); +}; diff --git a/modules/authentication/server-ts/access/session/index.ts b/modules/authentication/server-ts/access/session/index.ts new file mode 100644 index 0000000..401ce4a --- /dev/null +++ b/modules/authentication/server-ts/access/session/index.ts @@ -0,0 +1,103 @@ +import { Express, Request, Response } from 'express'; +import { Strategy } from 'passport-local'; +import session from 'express-session'; +import passport from 'passport'; + +import { RestMethod } from '@restapp/module-server-ts'; + +import settings from '../../../../../settings'; +import AccessModule from '../AccessModule'; +import { logout } from './controllers'; + +const FileStore = require('session-file-store')(session); + +const { + auth: { session: sessionSetting } +} = settings; + +const beforeware = (app: Express) => { + app.use( + session({ + secret: sessionSetting.secret, + store: __DEV__ ? new FileStore() : sessionSetting.store, + cookie: sessionSetting.cookie, + resave: sessionSetting.resave, + saveUninitialized: sessionSetting.saveUninitialized + }) + ); + app.use(passport.initialize()); + app.use(passport.session()); +}; + +const accessMiddleware = (req: Request, res: Response, next: any) => + req.isAuthenticated() + ? next() + : res.status(401).send({ + errors: { + message: 'unauthorized' + } + }); + +const loginMiddleware = (req: any, res: any, next: any) => { + passport.authenticate('local', { session: sessionSetting.enabled }, (err, user, info) => { + if (err || !user) { + return res.status(400).json({ + errors: { + message: info ? info.message : 'Login failed' + } + }); + } + + req.login(user, { session: sessionSetting.enabled }, async (loginErr: any) => { + if (loginErr) { + res.send(loginErr); + } + + return res.json({ user }); + }); + })(req, res, next); +}; + +const onAppCreate = ({ appContext }: AccessModule) => { + passport.serializeUser((identity: { id: number }, cb) => { + cb(null, identity.id); + }); + + passport.deserializeUser(async (id, cb) => { + const identity = await appContext.user.getIdentity(id); + return cb(null, identity); + }); + + passport.use( + new Strategy({ usernameField: 'usernameOrEmail' }, async (username: string, password: string, done: any) => { + const { user, message } = await appContext.user.validateLogin(username, password); + + if (message) { + return done(null, false, { message }); + } + return done(null, user); + }) + ); +}; + +const sessionAppContext = { + auth: { + loginMiddleware + } +}; + +export default (settings.auth.session.enabled + ? new AccessModule({ + beforeware: [beforeware], + onAppCreate: [onAppCreate], + accessMiddleware: [accessMiddleware], + appContext: sessionAppContext, + apiRouteParams: [ + { + method: RestMethod.POST, + route: 'logout', + controller: logout + } + ] + }) + : undefined); diff --git a/modules/authentication/server-ts/index.ts b/modules/authentication/server-ts/index.ts new file mode 100644 index 0000000..29e12d5 --- /dev/null +++ b/modules/authentication/server-ts/index.ts @@ -0,0 +1,7 @@ +import ServerModule from '@restapp/module-server-ts'; +import AuthModule from './social/AuthModule'; +import access from './access'; +import social from './social'; + +export default new ServerModule(access, social); +export { access, AuthModule }; diff --git a/modules/authentication/server-ts/locales/en/translations.json b/modules/authentication/server-ts/locales/en/translations.json new file mode 100644 index 0000000..f1dd1a7 --- /dev/null +++ b/modules/authentication/server-ts/locales/en/translations.json @@ -0,0 +1,7 @@ +{ + "getIdentify": "Can not find 'getIdentity' method. Please, add this method to the context.", + "invalidCsrf": "CSRF token validation failed", + "identityWithoutId": "Identity must have 'id' method.", + "invalidRefresh": "Refresh token validation failed", + "social": "Cannot find social property on the context. Please, add this property to the context." +} \ No newline at end of file diff --git a/modules/authentication/server-ts/locales/index.ts b/modules/authentication/server-ts/locales/index.ts new file mode 100644 index 0000000..d9fb121 --- /dev/null +++ b/modules/authentication/server-ts/locales/index.ts @@ -0,0 +1,5 @@ +/* + * The index.js can be empty, it's just needed to point the loader to the root directory of the locales. + * https://github.com/alienfast/i18next-loader#option-2-use-with-import-syntax + */ +export default {}; diff --git a/modules/authentication/server-ts/locales/ru/translations.json b/modules/authentication/server-ts/locales/ru/translations.json new file mode 100644 index 0000000..d64617c --- /dev/null +++ b/modules/authentication/server-ts/locales/ru/translations.json @@ -0,0 +1,7 @@ +{ + "getIdentify": "Не доступен 'getIdentity' метод. Пожалуйста, добавьте метод в context.", + "invalidCsrf": "CSRF ошибка проверки токена", + "identityWithoutId": "Identity дожен иметь 'id'", + "invalidRefresh": "Refresh token не прошел проверку", + "social": "Не доступно свойство social в context. Пожалуйста, добавьте свойство в context." +} \ No newline at end of file diff --git a/modules/authentication/server-ts/package.json b/modules/authentication/server-ts/package.json new file mode 100644 index 0000000..110f74a --- /dev/null +++ b/modules/authentication/server-ts/package.json @@ -0,0 +1,8 @@ +{ + "name": "@restapp/authentication-server-ts", + "version": "0.1.0", + "private": true, + "dependencies": { + "@restapp/module-server-ts": "^0.1.0" + } +} diff --git a/modules/authentication/server-ts/social/AuthModule.ts b/modules/authentication/server-ts/social/AuthModule.ts new file mode 100644 index 0000000..4cf31cc --- /dev/null +++ b/modules/authentication/server-ts/social/AuthModule.ts @@ -0,0 +1,13 @@ +import ServerModule, { ServerModuleShape } from '@restapp/module-server-ts'; + +interface AuthModuleShape extends ServerModuleShape {} + +interface AuthModule extends AuthModuleShape {} + +class AuthModule extends ServerModule { + constructor(...modules: AuthModuleShape[]) { + super(...modules); + } +} + +export default AuthModule; diff --git a/modules/authentication/server-ts/social/facebook/controllers.ts b/modules/authentication/server-ts/social/facebook/controllers.ts new file mode 100644 index 0000000..37ee72c --- /dev/null +++ b/modules/authentication/server-ts/social/facebook/controllers.ts @@ -0,0 +1,15 @@ +import passport from 'passport'; + +export const auth = (req: any, res: any) => { + passport.authenticate('facebook', { state: req.query.expoUrl })(req, res); +}; + +export const onAuthenticationSuccess = (req: any, res: any) => { + const { + locals: { appContext } + } = res; + const { t } = req; + appContext.social + ? appContext.social.facebook.onAuthenticationSuccess(req, res) + : res.status(500).send(t('auth:social')); +}; diff --git a/modules/authentication/server-ts/social/facebook/index.ts b/modules/authentication/server-ts/social/facebook/index.ts new file mode 100644 index 0000000..9b21f35 --- /dev/null +++ b/modules/authentication/server-ts/social/facebook/index.ts @@ -0,0 +1,51 @@ +import { Express } from 'express'; +import passport from 'passport'; +import { Strategy as FacebookStrategy } from 'passport-facebook'; + +import { RestMethod } from '@restapp/module-server-ts'; + +import { auth, onAuthenticationSuccess } from './controllers'; +import AuthModule from '../AuthModule'; +import settings from '../../../../../settings'; + +const { + auth: { + session, + social: { + facebook: { clientID, clientSecret, callbackURL, profileFields, enabled } + } + } +} = settings; + +const beforeware = (app: Express) => { + app.use(passport.initialize()); +}; + +const onAppCreate = ({ appContext }: AuthModule) => { + passport.use( + new FacebookStrategy( + { clientID, clientSecret, callbackURL, profileFields }, + appContext.social ? appContext.social.facebook.verifyCallback : undefined + ) + ); +}; + +export default (enabled && !__TEST__ + ? new AuthModule({ + beforeware: [beforeware], + onAppCreate: [onAppCreate], + apiRouteParams: [ + { + method: RestMethod.GET, + route: 'auth/facebook', + controller: auth + }, + { + method: RestMethod.GET, + route: 'auth/facebook/callback', + middleware: [passport.authenticate('facebook', { session: session.enabled, failureRedirect: '/login' })], + controller: onAuthenticationSuccess + } + ] + }) + : undefined); diff --git a/modules/authentication/server-ts/social/github/controllers.ts b/modules/authentication/server-ts/social/github/controllers.ts new file mode 100644 index 0000000..dcf0bd1 --- /dev/null +++ b/modules/authentication/server-ts/social/github/controllers.ts @@ -0,0 +1,15 @@ +import passport from 'passport'; + +export const auth = (req: any, res: any) => { + passport.authenticate('github', { state: req.query.expoUrl })(req, res); +}; + +export const onAuthenticationSuccess = (req: any, res: any) => { + const { + locals: { appContext } + } = res; + const { t } = req; + appContext.social + ? appContext.social.github.onAuthenticationSuccess(req, res) + : res.status(500).send(t('auth:social')); +}; diff --git a/modules/authentication/server-ts/social/github/index.ts b/modules/authentication/server-ts/social/github/index.ts new file mode 100644 index 0000000..c0e00f6 --- /dev/null +++ b/modules/authentication/server-ts/social/github/index.ts @@ -0,0 +1,51 @@ +import { Express } from 'express'; +import passport from 'passport'; +import { Strategy as GitHubStrategy } from 'passport-github'; + +import { RestMethod } from '@restapp/module-server-ts'; + +import { auth, onAuthenticationSuccess } from './controllers'; +import settings from '../../../../../settings'; +import AuthModule from '../AuthModule'; + +const { + auth: { + session, + social: { + github: { clientID, clientSecret, scope, callbackURL, enabled } + } + } +} = settings; + +const beforeware = (app: Express) => { + app.use(passport.initialize()); +}; + +const onAppCreate = ({ appContext }: AuthModule) => { + passport.use( + new GitHubStrategy( + { clientID, clientSecret, scope, callbackURL }, + appContext.social ? appContext.social.github.verifyCallback : undefined + ) + ); +}; + +export default (enabled && !__TEST__ + ? new AuthModule({ + beforeware: [beforeware], + onAppCreate: [onAppCreate], + apiRouteParams: [ + { + method: RestMethod.GET, + route: 'auth/github', + controller: auth + }, + { + method: RestMethod.GET, + route: 'auth/github/callback', + middleware: [passport.authenticate('github', { session: session.enabled, failureRedirect: '/login' })], + controller: onAuthenticationSuccess + } + ] + }) + : undefined); diff --git a/modules/authentication/server-ts/social/google/controllers.ts b/modules/authentication/server-ts/social/google/controllers.ts new file mode 100644 index 0000000..8de72c8 --- /dev/null +++ b/modules/authentication/server-ts/social/google/controllers.ts @@ -0,0 +1,25 @@ +import passport from 'passport'; + +import settings from '../../../../../settings'; + +const { + auth: { + social: { + google: { scope } + } + } +} = settings; + +export const auth = (req: any, res: any) => { + passport.authenticate('google', { scope, state: req.query.expoUrl })(req, res); +}; + +export const onAuthenticationSuccess = (req: any, res: any) => { + const { + locals: { appContext } + } = res; + const { t } = req; + appContext.social + ? appContext.social.google.onAuthenticationSuccess(req, res) + : res.status(500).send(t('auth:social')); +}; diff --git a/modules/authentication/server-ts/social/google/index.ts b/modules/authentication/server-ts/social/google/index.ts new file mode 100644 index 0000000..1f40c7c --- /dev/null +++ b/modules/authentication/server-ts/social/google/index.ts @@ -0,0 +1,53 @@ +import { Express } from 'express'; +import passport from 'passport'; +import { OAuth2Strategy as GoogleStrategy } from 'passport-google-oauth'; + +import { RestMethod } from '@restapp/module-server-ts'; + +import { auth, onAuthenticationSuccess } from './controllers'; +import AuthModule from '../AuthModule'; +import settings from '../../../../../settings'; + +const { + auth: { + session, + social: { + google: { clientID, clientSecret, callbackURL, enabled } + } + } +} = settings; + +const beforeware = (app: Express) => { + app.use(passport.initialize()); +}; + +const onAppCreate = ({ appContext }: AuthModule) => { + if (enabled && !__TEST__) { + passport.use( + new GoogleStrategy( + { clientID, clientSecret, callbackURL }, + appContext.social ? appContext.social.google.verifyCallback : undefined + ) + ); + } +}; + +export default (enabled && !__TEST__ + ? new AuthModule({ + beforeware: [beforeware], + onAppCreate: [onAppCreate], + apiRouteParams: [ + { + method: RestMethod.GET, + route: 'auth/google', + controller: auth + }, + { + method: RestMethod.GET, + route: 'auth/google/callback', + middleware: [passport.authenticate('google', { session: session.enabled, failureRedirect: '/login' })], + controller: onAuthenticationSuccess + } + ] + }) + : undefined); diff --git a/modules/authentication/server-ts/social/index.ts b/modules/authentication/server-ts/social/index.ts new file mode 100644 index 0000000..38d571a --- /dev/null +++ b/modules/authentication/server-ts/social/index.ts @@ -0,0 +1,7 @@ +import ServerModule from '@restapp/module-server-ts'; +import github from './github'; +import google from './google'; +import facebook from './facebook'; +import linkedin from './linkedIn'; + +export default new ServerModule(github, google, facebook, linkedin); diff --git a/modules/authentication/server-ts/social/linkedIn/controllers.ts b/modules/authentication/server-ts/social/linkedIn/controllers.ts new file mode 100644 index 0000000..355bae4 --- /dev/null +++ b/modules/authentication/server-ts/social/linkedIn/controllers.ts @@ -0,0 +1,15 @@ +import passport from 'passport'; + +export const auth = (req: any, res: any) => { + passport.authenticate('linkedin', { state: req.query.expoUrl })(req, res); +}; + +export const onAuthenticationSuccess = (req: any, res: any) => { + const { + locals: { appContext } + } = res; + const { t } = req; + appContext.social + ? appContext.social.google.onAuthenticationSuccess(req, res) + : res.status(500).send(t('auth:social')); +}; diff --git a/modules/authentication/server-ts/social/linkedIn/index.ts b/modules/authentication/server-ts/social/linkedIn/index.ts new file mode 100644 index 0000000..a4ce503 --- /dev/null +++ b/modules/authentication/server-ts/social/linkedIn/index.ts @@ -0,0 +1,51 @@ +import { Express } from 'express'; +import passport from 'passport'; +import { Strategy as LinkedInStrategy } from '@sokratis/passport-linkedin-oauth2'; + +import { RestMethod } from '@restapp/module-server-ts'; + +import { auth, onAuthenticationSuccess } from './controllers'; +import AuthModule from '../AuthModule'; +import settings from '../../../../../settings'; + +const { + auth: { + session, + social: { + linkedin: { clientID, clientSecret, callbackURL, enabled, scope } + } + } +} = settings; + +const beforeware = (app: Express) => { + app.use(passport.initialize()); +}; + +const onAppCreate = ({ appContext }: AuthModule) => { + passport.use( + new LinkedInStrategy( + { clientID, clientSecret, callbackURL, scopeSeparator: scope }, + appContext.social ? appContext.social.linkedin.verifyCallback : undefined + ) + ); +}; + +export default (enabled && !__TEST__ + ? new AuthModule({ + beforeware: [beforeware], + onAppCreate: [onAppCreate], + apiRouteParams: [ + { + method: RestMethod.GET, + route: 'auth/linkedin', + controller: auth + }, + { + method: RestMethod.GET, + route: 'auth/linkedin/callback', + middleware: [passport.authenticate('linkedin', { session: session.enabled, failureRedirect: '/login' })], + controller: onAuthenticationSuccess + } + ] + }) + : undefined); diff --git a/modules/core/client-react-native/App.tsx b/modules/core/client-react-native/App.tsx index e6465d1..5cabe84 100644 --- a/modules/core/client-react-native/App.tsx +++ b/modules/core/client-react-native/App.tsx @@ -6,7 +6,6 @@ import ClientModule from '@restapp/module-client-react-native'; import settings from '../../../settings'; import createReduxStore from '../../../packages/common/createReduxStore'; import log from '../../../packages/common/log'; - const { protocol, pathname, port } = url.parse(__API_URL__); interface MainProps { diff --git a/modules/core/common/clientStorage.ts b/modules/core/common/clientStorage.ts index e5a6e99..77dc203 100644 --- a/modules/core/common/clientStorage.ts +++ b/modules/core/common/clientStorage.ts @@ -1,3 +1,3 @@ -export const getItem = async (name: string) => window.sessionStorage.getItem(name); -export const setItem = async (name: string, value: string) => window.sessionStorage.setItem(name, value); -export const removeItem = async (name: string) => window.sessionStorage.removeItem(name); +export const getItem = async (name: string) => window.localStorage.getItem(name); +export const setItem = async (name: string, value: string) => window.localStorage.setItem(name, value); +export const removeItem = async (name: string) => window.localStorage.removeItem(name); diff --git a/modules/core/server-ts/app.ts b/modules/core/server-ts/app.ts index 4bdb02f..ae2b214 100644 --- a/modules/core/server-ts/app.ts +++ b/modules/core/server-ts/app.ts @@ -1,6 +1,6 @@ import express from 'express'; -import path from 'path'; import bodyParser from 'body-parser'; +import path from 'path'; import { isApiExternal } from '@restapp/core-common'; import ServerModule from '@restapp/module-server-ts'; @@ -30,8 +30,7 @@ const createServerApp = (modules: ServerModule) => { if (modules.apiRoutes) { modules.apiRoutes.forEach(applyMiddleware => applyMiddleware(app, modules)); } - - app.get('/api', (req, res) => res.json({ message: 'REST API: Success' })); + app.get('/api', (req, res, next) => res.json({ message: 'REST API: Success' })); } app.use(websiteMiddleware(modules)); diff --git a/modules/forms/client-react/FormError.ts b/modules/forms/client-react/FormError.ts index 91c7188..c4f1f57 100644 --- a/modules/forms/client-react/FormError.ts +++ b/modules/forms/client-react/FormError.ts @@ -1,13 +1,11 @@ -export const isFormError = (err: any) => err instanceof FormError; - export class FormError { private readonly _errors: { [key: string]: any }; - constructor(errorMsg: string, err?: any) { + constructor(message: string, err?: any) { if (err) { throw err; } else { - this._errors = { errorMsg }; + this._errors = { message }; } } diff --git a/modules/look/client-react-native/HeaderTitle.jsx b/modules/look/client-react-native/HeaderTitle.tsx similarity index 66% rename from modules/look/client-react-native/HeaderTitle.jsx rename to modules/look/client-react-native/HeaderTitle.tsx index b7c5201..86f4fe4 100644 --- a/modules/look/client-react-native/HeaderTitle.jsx +++ b/modules/look/client-react-native/HeaderTitle.tsx @@ -1,20 +1,21 @@ import React from 'react'; -import PropTypes from 'prop-types'; import { Text, StyleSheet, Platform } from 'react-native'; +import { TranslateFunction } from '@restapp/i18n-client-react'; -const HeaderTitle = ({ t, i18nKey, style, children, ...props }) => ( +interface HeaderTitleProps { + t?: TranslateFunction; + style?: any; + i18nKey?: string; + children?: React.ReactNode | any; + onPress?: () => any; +} + +const HeaderTitle = ({ t, i18nKey, style, children, ...props }: HeaderTitleProps) => ( {t ? t(i18nKey || 'navLink') : children} ); -HeaderTitle.propTypes = { - t: PropTypes.func, - children: PropTypes.node, - i18nKey: PropTypes.string, - style: PropTypes.any -}; - const styles = StyleSheet.create({ menuTitle: { padding: 16, diff --git a/modules/look/client-react-native/RenderField.jsx b/modules/look/client-react-native/RenderField.jsx index d702d44..d000fe5 100644 --- a/modules/look/client-react-native/RenderField.jsx +++ b/modules/look/client-react-native/RenderField.jsx @@ -13,9 +13,7 @@ const RenderField = ({ input, label, meta: { touched, error }, ...inputProps }) onFocus={input.onFocus} {...inputProps} error={touched && error ? error : ''} - > - {label} - + /> ); }; diff --git a/modules/look/client-react-native/styles/button.js b/modules/look/client-react-native/styles/button.js deleted file mode 100644 index e3f09e7..0000000 --- a/modules/look/client-react-native/styles/button.js +++ /dev/null @@ -1,5 +0,0 @@ -// eslint-disable-next-line -export const submit = { - paddingTop: 30, - paddingBottom: 15 -}; diff --git a/modules/look/client-react-native/styles/button.ts b/modules/look/client-react-native/styles/button.ts new file mode 100644 index 0000000..565dae2 --- /dev/null +++ b/modules/look/client-react-native/styles/button.ts @@ -0,0 +1,6 @@ +import { ViewStyle } from 'react-native'; + +export const submit: ViewStyle = { + paddingTop: 30, + paddingBottom: 15 +}; diff --git a/modules/look/client-react-native/styles/colors.js b/modules/look/client-react-native/styles/colors.ts similarity index 100% rename from modules/look/client-react-native/styles/colors.js rename to modules/look/client-react-native/styles/colors.ts diff --git a/modules/look/client-react-native/styles/index.js b/modules/look/client-react-native/styles/index.ts similarity index 100% rename from modules/look/client-react-native/styles/index.js rename to modules/look/client-react-native/styles/index.ts diff --git a/modules/look/client-react-native/styles/itemContainer.js b/modules/look/client-react-native/styles/itemContainer.ts similarity index 52% rename from modules/look/client-react-native/styles/itemContainer.js rename to modules/look/client-react-native/styles/itemContainer.ts index 7f8f4ee..8c03378 100644 --- a/modules/look/client-react-native/styles/itemContainer.js +++ b/modules/look/client-react-native/styles/itemContainer.ts @@ -1,15 +1,17 @@ -export const itemContainer = { +import { ViewStyle, TextStyle } from 'react-native'; + +export const itemContainer: ViewStyle = { flex: 1, flexDirection: 'row', alignItems: 'center' }; -export const itemTitle = { +export const itemTitle: TextStyle = { flex: 5, flexDirection: 'column' }; -export const itemAction = { +export const itemAction: ViewStyle = { flex: 1, flexDirection: 'column', justifyContent: 'center', diff --git a/modules/look/client-react-native/styles/socialButton.js b/modules/look/client-react-native/styles/socialButton.ts similarity index 68% rename from modules/look/client-react-native/styles/socialButton.js rename to modules/look/client-react-native/styles/socialButton.ts index 83393bf..2afa9d4 100644 --- a/modules/look/client-react-native/styles/socialButton.js +++ b/modules/look/client-react-native/styles/socialButton.ts @@ -1,45 +1,47 @@ -const iconWrapper = { +import { ViewStyle, TextStyle } from 'react-native'; + +const iconWrapper: ViewStyle = { alignItems: 'center', marginTop: 10 }; -const linkText = { +const linkText: TextStyle = { color: '#0056b3', fontSize: 16, fontWeight: '600' }; -const link = { +const link: ViewStyle = { justifyContent: 'center', alignItems: 'center', margin: 10 }; -const buttonContainer = { +const buttonContainer: ViewStyle = { height: 45, flexDirection: 'row', alignItems: 'center', backgroundColor: '#3769ae', borderRadius: 4 }; -const separator = { +const separator: ViewStyle = { height: 30, width: 1.5, marginLeft: 10, backgroundColor: '#fff' }; -const btnIconContainer = { +const btnIconContainer: ViewStyle = { flex: 2, justifyContent: 'flex-start', alignItems: 'center', flexDirection: 'row' }; -const btnTextContainer = { +const btnTextContainer: ViewStyle = { flex: 5, justifyContent: 'center', alignItems: 'flex-start' }; -const btnText = { +const btnText: TextStyle = { color: '#fff', fontSize: 16, fontWeight: '400' diff --git a/modules/look/client-react-native/ui-native-base/components/CardLabel.jsx b/modules/look/client-react-native/ui-native-base/components/CardLabel.tsx similarity index 50% rename from modules/look/client-react-native/ui-native-base/components/CardLabel.jsx rename to modules/look/client-react-native/ui-native-base/components/CardLabel.tsx index eea6207..ebd8832 100644 --- a/modules/look/client-react-native/ui-native-base/components/CardLabel.jsx +++ b/modules/look/client-react-native/ui-native-base/components/CardLabel.tsx @@ -1,9 +1,13 @@ import React from 'react'; -import PropTypes from 'prop-types'; -import { Text, StyleSheet } from 'react-native'; +import { Text, StyleSheet, ViewStyle, TextStyle } from 'react-native'; import CardLabelStyles from '../styles/CardLabel'; -const CardLabel = ({ children, style, ...props }) => { +interface CardLabelProps { + children?: string; + style?: ViewStyle | TextStyle; +} + +const CardLabel = ({ children, style, ...props }: CardLabelProps) => { return ( {children.toUpperCase()} @@ -11,11 +15,6 @@ const CardLabel = ({ children, style, ...props }) => { ); }; -CardLabel.propTypes = { - children: PropTypes.string, - style: PropTypes.oneOfType([PropTypes.number, PropTypes.object]) -}; - const styles = StyleSheet.create(CardLabelStyles); export default CardLabel; diff --git a/modules/look/client-react-native/ui-native-base/components/CardText.jsx b/modules/look/client-react-native/ui-native-base/components/CardText.jsx deleted file mode 100644 index bc4d419..0000000 --- a/modules/look/client-react-native/ui-native-base/components/CardText.jsx +++ /dev/null @@ -1,21 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import { Text, StyleSheet } from 'react-native'; -import CardTextStyles from '../styles/CardText'; - -const CardText = ({ children, style, ...props }) => { - return ( - - {children} - - ); -}; - -CardText.propTypes = { - children: PropTypes.string, - style: PropTypes.oneOfType([PropTypes.number, PropTypes.object]) -}; - -const styles = StyleSheet.create(CardTextStyles); - -export default CardText; diff --git a/modules/look/client-react-native/ui-native-base/components/CardText.tsx b/modules/look/client-react-native/ui-native-base/components/CardText.tsx new file mode 100644 index 0000000..9e0f24c --- /dev/null +++ b/modules/look/client-react-native/ui-native-base/components/CardText.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import { Text, StyleSheet, TextStyle, ViewStyle } from 'react-native'; +import CardTextStyles from '../styles/CardText'; + +interface CardTextProps { + children?: string; + style?: ViewStyle | TextStyle; +} + +const CardText = ({ children, style, ...props }: CardTextProps) => { + return ( + + {children} + + ); +}; + +const styles = StyleSheet.create(CardTextStyles); + +export default CardText; diff --git a/modules/look/client-react-native/ui-native-base/components/ListItem.jsx b/modules/look/client-react-native/ui-native-base/components/ListItem.jsx deleted file mode 100644 index dbcb759..0000000 --- a/modules/look/client-react-native/ui-native-base/components/ListItem.jsx +++ /dev/null @@ -1,19 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import { ListItem as NBListItem } from 'native-base'; - -const ListItem = ({ children, onPress, onClick, ...props }) => { - return ( - - {children} - - ); -}; - -ListItem.propTypes = { - children: PropTypes.node, - onClick: PropTypes.func, - onPress: PropTypes.func -}; - -export default ListItem; diff --git a/modules/look/client-react-native/ui-native-base/components/ListItem.tsx b/modules/look/client-react-native/ui-native-base/components/ListItem.tsx new file mode 100644 index 0000000..5dbfbc2 --- /dev/null +++ b/modules/look/client-react-native/ui-native-base/components/ListItem.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import { ListItem as NBListItem } from 'native-base'; +import { GestureResponderEvent } from 'react-native'; + +interface ListItemProps { + onClick?: (e: GestureResponderEvent) => void; + onPress?: (e: GestureResponderEvent) => void; + children?: React.ReactNode; + style?: any; +} + +const ListItem = ({ children, onPress, onClick, ...props }: ListItemProps) => { + return ( + + {children} + + ); +}; + +export default ListItem; diff --git a/modules/look/client-react-native/ui-native-base/components/SearchBar.jsx b/modules/look/client-react-native/ui-native-base/components/SearchBar.jsx deleted file mode 100644 index 356c152..0000000 --- a/modules/look/client-react-native/ui-native-base/components/SearchBar.jsx +++ /dev/null @@ -1,25 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import { Item, Input, Icon } from 'native-base'; -import { placeholderColor } from '../../styles'; - -const SearchBar = ({ onChange, onChangeText, ...props }) => { - return ( - - - - - ); -}; - -SearchBar.propTypes = { - onChange: PropTypes.func, - onChangeText: PropTypes.func -}; - -export default SearchBar; diff --git a/modules/look/client-react-native/ui-native-base/components/SearchBar.tsx b/modules/look/client-react-native/ui-native-base/components/SearchBar.tsx new file mode 100644 index 0000000..7c15942 --- /dev/null +++ b/modules/look/client-react-native/ui-native-base/components/SearchBar.tsx @@ -0,0 +1,17 @@ +import * as React from 'react'; +import * as ReactNative from 'react-native'; +import { Item, Input, Icon } from 'native-base'; +import { placeholderColor } from '../../styles'; + +interface SearchBarProps extends ReactNative.TextInputProps {} + +const SearchBar = ({ ...props }: SearchBarProps) => { + return ( + + + + + ); +}; + +export default SearchBar; diff --git a/modules/look/client-react-native/ui-native-base/components/Select.jsx b/modules/look/client-react-native/ui-native-base/components/Select.jsx deleted file mode 100644 index a0fae26..0000000 --- a/modules/look/client-react-native/ui-native-base/components/Select.jsx +++ /dev/null @@ -1,73 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import { Platform, StyleSheet } from 'react-native'; -import { Picker, Item } from 'native-base'; -import { FontAwesome } from '@expo/vector-icons'; -import SelectStyles from '../styles/Select'; - -const Select = ({ - icon, - iconName, - iconColor, - iconSize, - data, - onValueChange, - selectedValue, - value, - onChange, - style, - itemStyle, - placeholder = '...', - ...props -}) => { - return Platform.OS === 'ios' ? ( - - {icon && ( - - )} - - {data.map((option, idx) => ( - - ))} - - - ) : ( - - - {data.map((option, idx) => ( - - ))} - - - ); -}; - -Select.propTypes = { - data: PropTypes.array.isRequired, - onValueChange: PropTypes.func, - onChange: PropTypes.func, - value: PropTypes.string, - selectedValue: PropTypes.string, - placeholder: PropTypes.string, - icon: PropTypes.bool, - iconName: PropTypes.string, - iconColor: PropTypes.string, - iconSize: PropTypes.number, - style: PropTypes.oneOfType([PropTypes.object, PropTypes.number]), - itemStyle: PropTypes.oneOfType([PropTypes.object, PropTypes.number]) -}; - -const styles = StyleSheet.create(SelectStyles); - -export default Select; diff --git a/modules/look/client-react-native/ui-native-base/components/Select.tsx b/modules/look/client-react-native/ui-native-base/components/Select.tsx new file mode 100644 index 0000000..070e806 --- /dev/null +++ b/modules/look/client-react-native/ui-native-base/components/Select.tsx @@ -0,0 +1,85 @@ +import * as React from 'react'; +import * as ReactNative from 'react-native'; +import { Platform, StyleSheet, ViewStyle, TextStyle } from 'react-native'; +import { Picker, Item } from 'native-base'; +import { FontAwesome } from '@expo/vector-icons'; +import { omit } from 'lodash'; +import SelectStyles from '../styles/Select'; + +interface Option { + value: string; + label: string; +} +interface AntProps { + okText: string; + dismissText: string; + cols: number; + extra: string; +} + +interface SelectProps extends ReactNative.PickerProps, AntProps { + data: Option[]; + onValueChange?: (value: string) => void; + onChange?: (value: string) => void; + value?: string; + selectedValue?: string; + placeholder?: string; + icon?: boolean; + iconName?: string; + iconColor?: string; + iconSize?: number; + style?: ViewStyle | TextStyle; +} + +const Select: React.FunctionComponent = selectProps => { + const { + icon, + iconName, + iconColor, + iconSize, + data, + onValueChange, + selectedValue, + value, + onChange, + style, + itemStyle, + placeholder = '...', + ...props + } = omit(selectProps, ['okText', 'dismissText', 'cols', 'extra']); + return Platform.OS === 'ios' ? ( + + {icon && ( + + )} + + {data.map((option, idx) => ( + + ))} + + + ) : ( + + + {data.map((option, idx) => ( + + ))} + + + ); +}; + +const styles = StyleSheet.create(SelectStyles); + +export default Select; diff --git a/modules/look/client-react-native/ui-native-base/components/Switch.jsx b/modules/look/client-react-native/ui-native-base/components/Switch.tsx similarity index 57% rename from modules/look/client-react-native/ui-native-base/components/Switch.jsx rename to modules/look/client-react-native/ui-native-base/components/Switch.tsx index e136f8c..3e12b08 100644 --- a/modules/look/client-react-native/ui-native-base/components/Switch.jsx +++ b/modules/look/client-react-native/ui-native-base/components/Switch.tsx @@ -1,17 +1,16 @@ import React from 'react'; import { Switch as NBSwitch } from 'native-base'; -import PropTypes from 'prop-types'; -const Switch = ({ color, checked, value, onValueChange, onChange, ...props }) => { - return ; -}; +interface SwitchProps { + checked?: boolean; + value: boolean; + color?: string; + onValueChange?: (value: boolean) => void; + onChange?: (value: boolean) => void; +} -Switch.propTypes = { - checked: PropTypes.bool, - value: PropTypes.bool, - color: PropTypes.string, - onValueChange: PropTypes.func, - onChange: PropTypes.func +const Switch = ({ color, checked, value, onValueChange, onChange, ...props }: SwitchProps) => { + return ; }; export default Switch; diff --git a/modules/look/client-react-native/ui-native-base/styles/CardLabel.js b/modules/look/client-react-native/ui-native-base/styles/CardLabel.js deleted file mode 100644 index 2fca6ea..0000000 --- a/modules/look/client-react-native/ui-native-base/styles/CardLabel.js +++ /dev/null @@ -1,9 +0,0 @@ -const CardLabelStyles = { - text: { - fontSize: 12, - fontWeight: '600', - color: '#686b70' - } -}; - -export default CardLabelStyles; diff --git a/modules/look/client-react-native/ui-native-base/styles/CardLabel.ts b/modules/look/client-react-native/ui-native-base/styles/CardLabel.ts new file mode 100644 index 0000000..947753b --- /dev/null +++ b/modules/look/client-react-native/ui-native-base/styles/CardLabel.ts @@ -0,0 +1,15 @@ +import { ViewStyle, TextStyle } from 'react-native'; + +interface Styles { + [key: string]: ViewStyle | TextStyle; +} + +const CardLabelStyles: Styles = { + text: { + fontSize: 12, + fontWeight: '600', + color: '#686b70' + } +}; + +export default CardLabelStyles; diff --git a/modules/look/client-react-native/ui-native-base/styles/CardText.js b/modules/look/client-react-native/ui-native-base/styles/CardText.js deleted file mode 100644 index b911ca8..0000000 --- a/modules/look/client-react-native/ui-native-base/styles/CardText.js +++ /dev/null @@ -1,7 +0,0 @@ -const CardTextStyles = { - text: { - fontSize: 14 - } -}; - -export default CardTextStyles; diff --git a/modules/look/client-react-native/ui-native-base/styles/CardText.ts b/modules/look/client-react-native/ui-native-base/styles/CardText.ts new file mode 100644 index 0000000..eb2db55 --- /dev/null +++ b/modules/look/client-react-native/ui-native-base/styles/CardText.ts @@ -0,0 +1,13 @@ +import { ViewStyle, TextStyle } from 'react-native'; + +interface Styles { + [key: string]: ViewStyle | TextStyle; +} + +const CardTextStyles: Styles = { + text: { + fontSize: 14 + } +}; + +export default CardTextStyles; diff --git a/modules/look/client-react/MetaData.tsx b/modules/look/client-react/MetaData.tsx new file mode 100644 index 0000000..77aa6e3 --- /dev/null +++ b/modules/look/client-react/MetaData.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import Helmet from 'react-helmet'; + +import settings from '../../../settings'; + +interface MetaDataProps { + title: string; + meta: string; +} + +const MetaData = ({ title, meta }: MetaDataProps) => ( + +); + +export default MetaData; diff --git a/modules/look/client-react/look.ts b/modules/look/client-react/look.ts index 7ff71e1..c847448 100644 --- a/modules/look/client-react/look.ts +++ b/modules/look/client-react/look.ts @@ -2,3 +2,4 @@ export * from './ui-bootstrap'; // export * from './ui-antd'; export { default as LayoutCenter } from './LayoutCenter'; +export { default as MetaData } from './MetaData'; diff --git a/modules/look/client-react/ui-bootstrap/components/FormItem.jsx b/modules/look/client-react/ui-bootstrap/components/FormItem.tsx similarity index 62% rename from modules/look/client-react/ui-bootstrap/components/FormItem.jsx rename to modules/look/client-react/ui-bootstrap/components/FormItem.tsx index 16acdaa..71b38ec 100644 --- a/modules/look/client-react/ui-bootstrap/components/FormItem.jsx +++ b/modules/look/client-react/ui-bootstrap/components/FormItem.tsx @@ -1,8 +1,12 @@ import React from 'react'; -import PropTypes from 'prop-types'; import { FormGroup, Label } from 'reactstrap'; -const FormItem = ({ children, label, ...props }) => { +interface FormItemProps { + children: React.ReactNode; + label?: string; +} + +const FormItem = ({ children, label, ...props }: FormItemProps) => { return ( {label && ( @@ -16,9 +20,4 @@ const FormItem = ({ children, label, ...props }) => { ); }; -FormItem.propTypes = { - children: PropTypes.node, - label: PropTypes.string -}; - export default FormItem; diff --git a/modules/look/client-react/ui-bootstrap/components/Input.jsx b/modules/look/client-react/ui-bootstrap/components/Input.jsx deleted file mode 100644 index e51b62b..0000000 --- a/modules/look/client-react/ui-bootstrap/components/Input.jsx +++ /dev/null @@ -1,13 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import { Input as RSInput } from 'reactstrap'; - -const Input = ({ children, ...props }) => { - return {children}; -}; - -Input.propTypes = { - children: PropTypes.node -}; - -export default Input; diff --git a/modules/look/client-react/ui-bootstrap/components/Input.tsx b/modules/look/client-react/ui-bootstrap/components/Input.tsx new file mode 100644 index 0000000..b76daab --- /dev/null +++ b/modules/look/client-react/ui-bootstrap/components/Input.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import { Input as RSInput } from 'reactstrap'; + +interface InputProps extends React.InputHTMLAttributes { + children?: React.ReactNode; +} + +const Input = ({ children, ...props }: InputProps) => { + return {children}; +}; + +export default Input; diff --git a/modules/look/client-react/ui-bootstrap/components/Select.jsx b/modules/look/client-react/ui-bootstrap/components/Select.tsx similarity index 51% rename from modules/look/client-react/ui-bootstrap/components/Select.jsx rename to modules/look/client-react/ui-bootstrap/components/Select.tsx index 39d62bc..c1df135 100644 --- a/modules/look/client-react/ui-bootstrap/components/Select.jsx +++ b/modules/look/client-react/ui-bootstrap/components/Select.tsx @@ -1,8 +1,11 @@ import React from 'react'; -import PropTypes from 'prop-types'; import { Input } from 'reactstrap'; -const Select = ({ children, ...props }) => { +interface SelectProps extends React.InputHTMLAttributes { + children?: React.ReactNode; +} + +const Select = ({ children, ...props }: SelectProps) => { return ( {children} @@ -10,8 +13,4 @@ const Select = ({ children, ...props }) => { ); }; -Select.propTypes = { - children: PropTypes.node -}; - export default Select; diff --git a/modules/module/client-react/BaseModule.ts b/modules/module/client-react/BaseModule.ts index 67cc3c1..2236c76 100644 --- a/modules/module/client-react/BaseModule.ts +++ b/modules/module/client-react/BaseModule.ts @@ -4,10 +4,14 @@ import { ReducersMapObject } from 'redux'; import CommonModule, { CommonModuleShape } from '@restapp/module-common'; +interface UniversalCookieRequest { + universalCookies?: any; +} + /** * A function that creates React Element that wraps root element of a React tree */ -type RootComponentFactory = (req: Request) => React.ReactElement; +type RootComponentFactory = (req: Request & UniversalCookieRequest) => React.ReactElement; /** * Common module interface for React and React Native feature modules. diff --git a/modules/module/client-react/ClientModule.ts b/modules/module/client-react/ClientModule.ts index 258ea3f..d8c9216 100644 --- a/modules/module/client-react/ClientModule.ts +++ b/modules/module/client-react/ClientModule.ts @@ -1,6 +1,6 @@ import React from 'react'; -import { Middleware } from 'redux'; import BaseModule, { BaseModuleShape } from './BaseModule'; +import { Middleware } from 'redux'; /** * React client feature modules interface. diff --git a/modules/module/server-ts/ServerModule.ts b/modules/module/server-ts/ServerModule.ts index 6c8d59a..fd07c5e 100644 --- a/modules/module/server-ts/ServerModule.ts +++ b/modules/module/server-ts/ServerModule.ts @@ -15,7 +15,7 @@ interface CreateContextFuncProps { } /** - * A function with reuqest. + * A function with reqest. * * @param req HTTP request * @param res HTTP response @@ -40,6 +40,13 @@ type CreateContextFunc = (props: CreateContextFuncProps) => { [key: string]: any */ type MiddlewareFunc = (app: Express, appContext: { [key: string]: any }) => void; +export enum RestMethod { + POST = 'post', + GET = 'get', + PUT = 'put', + DELETE = 'delete' +} + /** * Server feature modules interface */ @@ -50,7 +57,7 @@ export interface ServerModuleShape extends CommonModuleShape { beforeware?: MiddlewareFunc[]; // A list of functions to register normal-priority middlewares middleware?: MiddlewareFunc[]; - accessMiddleware?: RequestHandler; + accessMiddleware?: RequestHandler[]; apiRouteParams?: Array<{ method: RestMethod; route: string; @@ -83,16 +90,17 @@ class ServerModule extends CommonModule { this.apiRouteParams && this.apiRouteParams.map(({ method, route, controller, isAuthRoute, middleware }) => { return (app: Express, modules: ServerModule) => { - const handlers = [controller] + const handlers = [] .concat(!isEmpty(middleware) ? middleware : []) - .concat(isAuthRoute ? [modules.accessMiddleware] : []); + .concat(isAuthRoute ? [...modules.accessMiddleware] : []); + + handlers.push(controller); app[method](`/api/${route}`, ...handlers); }; }) ); } - /** * Creates context for this module * @@ -112,11 +120,4 @@ class ServerModule extends CommonModule { } } -export enum RestMethod { - POST = 'post', - GET = 'get', - PUT = 'put', - DELETE = 'delete' -} - export default ServerModule; diff --git a/modules/user/client-react/actions/currentUser.ts b/modules/user/client-react/actions/currentUser.ts new file mode 100644 index 0000000..8e1805c --- /dev/null +++ b/modules/user/client-react/actions/currentUser.ts @@ -0,0 +1,13 @@ +import axios from 'axios'; +import { ActionType } from '../signUp/reducers'; +import { ActionFunction } from '.'; + +const getCurrentUser: ActionFunction = () => ({ + types: { + REQUEST: ActionType.SET_LOADING_AND_CLEAR_USER, + SUCCESS: ActionType.SET_CURRENT_USER, + FAIL: ActionType.CLEAR_CURRENT_USER + }, + APICall: () => axios.get(`${__API_URL__}/currentUser`) +}); +export default getCurrentUser; diff --git a/modules/user/client-react/actions/index.ts b/modules/user/client-react/actions/index.ts new file mode 100644 index 0000000..e2a1e3e --- /dev/null +++ b/modules/user/client-react/actions/index.ts @@ -0,0 +1,27 @@ +export { default as getCurrentUser } from './currentUser'; +import { ActionType } from '../reducers'; + +interface Types { + REQUEST?: AT; + SUCCESS?: AT; + FAIL?: AT; +} +interface ActionCreator { + type?: AT; + types?: Types; + APICall?: () => Promise; + payload?: any; +} + +/** + * + * Action description function that will be processed further by redux middleware. + * Must return an object with a @type property if the @APICall property is not in the action. + * If there is an @APICall property then @types property must also be returned. + * + */ +export type ActionFunction = ( + param?: P, + param2?: P2, + param3?: P3 +) => ActionCreator; diff --git a/modules/user/client-react/components/Loading.native.tsx b/modules/user/client-react/components/Loading.native.tsx new file mode 100644 index 0000000..0ba6c84 --- /dev/null +++ b/modules/user/client-react/components/Loading.native.tsx @@ -0,0 +1,19 @@ +import React from 'react'; + +import { View, Text } from 'react-native'; +import { translate } from '@restapp/i18n-client-react'; +import { LayoutCenter } from '@restapp/look-client-react-native'; + +import { CommonProps } from '../index.native'; + +interface LoadingProps extends CommonProps {} + +const Loading = ({ t }: LoadingProps) => ( + + + {t('loading')} + + +); + +export default translate('user')(Loading); diff --git a/modules/user/client-react/components/Loading.tsx b/modules/user/client-react/components/Loading.tsx new file mode 100644 index 0000000..3d61751 --- /dev/null +++ b/modules/user/client-react/components/Loading.tsx @@ -0,0 +1,16 @@ +import React from 'react'; + +import { translate } from '@restapp/i18n-client-react'; +import { LayoutCenter } from '@restapp/look-client-react'; + +import { CommonProps } from '../types'; + +interface LoadingProps extends CommonProps {} + +const Loading = ({ t }: LoadingProps) => ( + +
{t('loading')}
+
+); + +export default translate('user')(Loading); diff --git a/modules/user/client-react/containers/Auth.native.tsx b/modules/user/client-react/containers/Auth.native.tsx new file mode 100644 index 0000000..b8bad20 --- /dev/null +++ b/modules/user/client-react/containers/Auth.native.tsx @@ -0,0 +1 @@ +export * from './AuthBase'; diff --git a/modules/user/client-react/containers/Auth.tsx b/modules/user/client-react/containers/Auth.tsx new file mode 100644 index 0000000..0ff79b0 --- /dev/null +++ b/modules/user/client-react/containers/Auth.tsx @@ -0,0 +1,42 @@ +import React, { ComponentType, FunctionComponent } from 'react'; +import { Route, Redirect, RouteComponentProps } from 'react-router-dom'; + +import { withUser } from './AuthBase'; +import { UserRole, User } from '../types'; +import { WithUserProps } from './AuthBase'; + +interface AuthRouteProps extends WithUserProps { + role?: UserRole | UserRole[]; + redirect?: string; + redirectOnLoggedIn?: boolean; + component?: ComponentType> | ComponentType; +} + +const AuthRoute: ComponentType = withUser( + ({ currentUser, role, redirect = '/login', redirectOnLoggedIn, component: Component, ...rest }) => { + const RenderComponent: FunctionComponent = props => { + // The users is not logged in + if (redirectOnLoggedIn && currentUser) { + return ; + } + + return isRoleMatch(role, currentUser) ? ( + + ) : ( + + ); + }; + + return ; + } +); + +const isRoleMatch = (role: UserRole | UserRole[], currentUser: User) => { + if (!role) { + return true; + } + return currentUser && (Array.isArray(role) ? role : [role]).includes(currentUser.role); +}; + +export * from './AuthBase'; +export { AuthRoute }; diff --git a/modules/user/client-react/containers/AuthBase.tsx b/modules/user/client-react/containers/AuthBase.tsx new file mode 100644 index 0000000..6e95e36 --- /dev/null +++ b/modules/user/client-react/containers/AuthBase.tsx @@ -0,0 +1,93 @@ +import React from 'react'; +import { RouteProps } from 'react-router'; +import { connect } from 'react-redux'; + +import authentication from '@restapp/authentication-client-react'; +import { getItem } from '@restapp/core-common/clientStorage'; + +import { User, UserRole, CommonProps } from '../types'; +import { clearUser } from '../signUp/actions'; +import { getCurrentUser } from '../actions'; +import setting from '../../../../settings'; + +export interface WithUserProps extends RouteProps { + currentUser?: User; + currentUserLoading?: boolean; + refetchCurrentUser?: any; + children?: Element | any; + getCurrentUser?: () => void; +} + +interface IfLoggedInComponent { + role?: UserRole | UserRole[]; + currentUser?: User; + refetchCurrentUser?: any; + elseComponent?: Element | any; + children?: Element | any; +} + +export interface WithLogoutProps extends WithUserProps, CommonProps { + logout?: () => void; + clearUser?: () => void; +} + +const withUser = (Component: React.ComponentType) => { + class WithUser extends React.Component { + public async componentDidMount() { + const { currentUser, getCurrentUser: actionGetCurrentUser } = this.props; + if (currentUser === undefined && ((await getItem('refreshToken')) || setting.auth.session.enabled)) { + await actionGetCurrentUser(); + } + } + + public render() { + const { currentUserLoading, currentUser, getCurrentUser: actionGetCurrentUser, ...rest } = this.props; + return currentUserLoading ? null : ( + + ); + } + } + return connect( + ({ signUpReducer: { loading, currentUser } }: any) => ({ + currentUserLoading: loading, + currentUser + }), + { getCurrentUser } + )(WithUser); +}; + +const hasRole = (role: UserRole | UserRole[], currentUser: User) => { + return currentUser && (!role || (Array.isArray(role) ? role : [role]).indexOf(currentUser.role) >= 0) ? true : false; +}; + +const IfLoggedInComponent: React.FunctionComponent = ({ + currentUser, + role, + children, + elseComponent +}) => (hasRole(role, currentUser) ? React.cloneElement(children, {}) : elseComponent || null); + +const IfLoggedIn: React.ComponentType = withUser(IfLoggedInComponent); + +const IfNotLoggedInComponent: React.FunctionComponent = ({ currentUser, children }) => { + return !currentUser ? React.cloneElement(children, {}) : null; +}; + +const IfNotLoggedIn: React.ComponentType = withUser(IfNotLoggedInComponent); + +const withLogout = (Component: React.ComponentType) => { + const WithLogout = ({ clearUser: dispatchClearUser, ...props }: WithLogoutProps) => { + const newProps = { + ...props, + logout: () => authentication.doLogout(dispatchClearUser) + }; + return ; + }; + + return connect( + null, + { clearUser } + )(WithLogout); +}; + +export { withUser, hasRole, IfLoggedIn, IfNotLoggedIn, withLogout }; diff --git a/modules/user/client-react/containers/DataRootComponent.native.tsx b/modules/user/client-react/containers/DataRootComponent.native.tsx new file mode 100644 index 0000000..38fccfa --- /dev/null +++ b/modules/user/client-react/containers/DataRootComponent.native.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { connect } from 'react-redux'; + +import { getItem } from '@restapp/core-common/clientStorage'; + +import Loading from '../components/Loading.native'; +import { getCurrentUser } from '../actions'; +import { User } from '../types'; +import setting from '../../../../settings'; + +interface DataRootComponent { + currentUser: User; + getCurrentUser: () => void; +} + +class DataRootComponent extends React.Component { + public state = { + ready: false + }; + + public async componentDidMount() { + const { currentUser, getCurrentUser: actionGetCurrentUser } = this.props; + if (!this.state.ready && !currentUser && ((await getItem('refreshToken')) || setting.auth.session.enabled)) { + await actionGetCurrentUser(); + } + this.setState({ ready: true }); + } + + public render() { + return this.state.ready ? this.props.children : ; + } +} + +export default connect( + ({ signUpReducer: { currentUser } }: any) => ({ + currentUser + }), + { getCurrentUser } +)(DataRootComponent); diff --git a/modules/user/client-react/containers/DataRootComponent.tsx b/modules/user/client-react/containers/DataRootComponent.tsx new file mode 100644 index 0000000..e9b15f2 --- /dev/null +++ b/modules/user/client-react/containers/DataRootComponent.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import { connect } from 'react-redux'; + +import { getItem } from '@restapp/core-common/clientStorage'; + +import Loading from '../components/Loading'; +import { getCurrentUser } from '../actions'; +import { User } from '../types'; +import setting from '../../../../settings'; + +interface DataRootComponent { + currentUser: User; + getCurrentUser: () => void; + children?: Element | any; +} + +const DataRootComponent: React.FunctionComponent = ({ + currentUser, + children, + getCurrentUser: actionGetCurrentUser +}) => { + const [ready, setReady] = React.useState(false); + + React.useEffect(() => { + (async () => { + if (!ready && !currentUser && ((await getItem('refreshToken')) || setting.auth.session.enabled)) { + await actionGetCurrentUser(); + } + setReady(true); + })(); + }, []); + return ready ? children : ; +}; + +export default connect( + ({ signUpReducer: { currentUser } }: any) => ({ + currentUser + }), + { getCurrentUser } +)(DataRootComponent); diff --git a/modules/user/client-react/containers/UserScreenNavigator.native.tsx b/modules/user/client-react/containers/UserScreenNavigator.native.tsx new file mode 100644 index 0000000..24db782 --- /dev/null +++ b/modules/user/client-react/containers/UserScreenNavigator.native.tsx @@ -0,0 +1,90 @@ +import { createAppContainer, createDrawerNavigator } from 'react-navigation'; +import React from 'react'; +import { pickBy } from 'lodash'; +import { compose } from 'redux'; +import { DrawerComponent } from '@restapp/look-client-react-native'; + +import { UserRole } from '../types'; +import { withUser } from './Auth.native'; + +interface User { + id: number | string; + role: UserRole; +} + +interface UserScreenNavigator { + currentUser: User; + context: any; + currentUserLoading: boolean; + routeConfigs: any; +} + +class UserScreenNavigator extends React.Component { + public shouldComponentUpdate(nextProps: UserScreenNavigator) { + const { currentUserLoading, currentUser } = this.props; + /** + * After a user edits the profile the CurrentUser being updated in the State as well. + * That leads to the Navigator re-rendering and, as a result, takes the user back to the initial route. + * In order to let the user get back to his/her profile we need to prevent the Navigator + * re-render action after profile was edited + */ + return !( + !currentUserLoading && + currentUser && + nextProps.currentUser && + currentUser.id === nextProps.currentUser.id && + currentUser.role === nextProps.currentUser.role + ); + } + + public navItemsFilter = () => { + const { currentUser, routeConfigs } = this.props; + const userFilter = (value: any) => { + if (!value.userInfo) { + return true; + } + const { showOnLogin, role } = value.userInfo; + return showOnLogin && (!role || (Array.isArray(role) ? role : [role]).includes(currentUser.role)); + }; + + const guestFilter = (value: any) => !value.userInfo || (value.userInfo && !value.userInfo.showOnLogin); + + return pickBy(routeConfigs, currentUser ? userFilter : guestFilter); + }; + public getInitialRoute = () => { + const { currentUser } = this.props; + return currentUser ? 'Profile' : 'Login'; + }; + + public render() { + const MainScreenNavigatorComponent = createAppContainer( + createDrawerNavigator( + { ...this.navItemsFilter() }, + { + // eslint-disable-next-line + contentComponent: props => , + initialRouteName: this.getInitialRoute() + } + ) + ); + + return ; + } +} + +const drawerNavigator: any = (routeConfigs: any) => { + const withRoutes = (Component: React.ComponentType) => { + const ownProps = { routeConfigs }; + const WithRoutesComponent = ({ ...props }) => { + return ; + }; + return WithRoutesComponent; + }; + + return compose( + withRoutes, + withUser + )(UserScreenNavigator); +}; + +export default drawerNavigator; diff --git a/modules/user/client-react/helpers/UserFormatter.ts b/modules/user/client-react/helpers/UserFormatter.ts new file mode 100644 index 0000000..30d8703 --- /dev/null +++ b/modules/user/client-react/helpers/UserFormatter.ts @@ -0,0 +1,16 @@ +export default class UserFormatter { + public static trimExtraSpaces(inputValues: { [key: string]: any }) { + const userValues = { ...inputValues }; + const propsForTrim = ['username', 'email', 'firstName', 'lastName']; + + for (const prop in userValues) { + if (userValues.hasOwnProperty(prop)) { + if (propsForTrim.includes(prop) && userValues[prop]) { + userValues[prop] = userValues[prop].trim(); + } + } + } + + return userValues; + } +} diff --git a/modules/user/client-react/helpers/index.ts b/modules/user/client-react/helpers/index.ts new file mode 100644 index 0000000..ea832e1 --- /dev/null +++ b/modules/user/client-react/helpers/index.ts @@ -0,0 +1,13 @@ +import url from 'url'; +import { Constants } from 'expo'; +import { Platform } from 'react-native'; + +export default function buildRedirectUrlForMobile(authType: string) { + const { protocol, hostname, port } = url.parse(__WEBSITE_URL__); + const expoHostname = Platform.OS === 'android' ? 'localhost' : `${url.parse(Constants.linkingUri).hostname}.nip.io`; + const urlHostname = process.env.NODE_ENV === 'production' ? hostname : expoHostname; + + return `${protocol}//${urlHostname}${port ? ':' + port : ''}/api/auth/${authType}?expoUrl=${encodeURIComponent( + Constants.linkingUri + )}`; +} diff --git a/modules/user/client-react/index.native.tsx b/modules/user/client-react/index.native.tsx new file mode 100644 index 0000000..e8c4f4c --- /dev/null +++ b/modules/user/client-react/index.native.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import { NavigationContainer } from 'react-navigation'; +import ClientModule from '@restapp/module-client-react-native'; + +import signUp from './signUp/index.native'; +import users from './users/index.native'; +import profile from './profile/index.native'; +import resources from './locales'; +import DataRootComponent from './containers/DataRootComponent.native'; +import UserScreenNavigator from './containers/UserScreenNavigator.native'; + +export const ref: { navigator: NavigationContainer } = { + navigator: null +}; + +const MainScreenNavigator = () => { + const Navigator = ref.navigator; + return ; +}; + +export default new ClientModule(signUp, profile, users, { + localization: [{ ns: 'user', resources }], + router: , + dataRootComponent: [DataRootComponent], + onAppCreate: [(module: ClientModule) => (ref.navigator = UserScreenNavigator(module.drawerItems))] +}); diff --git a/modules/user/client-react/index.tsx b/modules/user/client-react/index.tsx new file mode 100644 index 0000000..cb9f408 --- /dev/null +++ b/modules/user/client-react/index.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { CookiesProvider } from 'react-cookie'; + +import ClientModule from '@restapp/module-client-react'; + +import signUp from './signUp'; +import profile from './profile'; +import users from './users'; +import resources from './locales'; +import DataRootComponent from './containers/DataRootComponent'; + +export default new ClientModule(signUp, profile, users, { + localization: [{ ns: 'user', resources }], + dataRootComponent: [DataRootComponent], + rootComponentFactory: [req => (req ? : )] +}); diff --git a/modules/user/client-react/locales/en/translations.json b/modules/user/client-react/locales/en/translations.json new file mode 100644 index 0000000..bd0bd3c --- /dev/null +++ b/modules/user/client-react/locales/en/translations.json @@ -0,0 +1,4 @@ +{ + "loading": "App is loading...", + "nativeMock": "Hello, " +} \ No newline at end of file diff --git a/modules/user/client-react/locales/index.ts b/modules/user/client-react/locales/index.ts new file mode 100644 index 0000000..d9fb121 --- /dev/null +++ b/modules/user/client-react/locales/index.ts @@ -0,0 +1,5 @@ +/* + * The index.js can be empty, it's just needed to point the loader to the root directory of the locales. + * https://github.com/alienfast/i18next-loader#option-2-use-with-import-syntax + */ +export default {}; diff --git a/modules/user/client-react/locales/ru/translations.json b/modules/user/client-react/locales/ru/translations.json new file mode 100644 index 0000000..24f4d94 --- /dev/null +++ b/modules/user/client-react/locales/ru/translations.json @@ -0,0 +1,5 @@ +{ + "loading": "Приложение загружается...", + "nativeMock": "Привет, " + +} \ No newline at end of file diff --git a/modules/user/client-react/package.json b/modules/user/client-react/package.json new file mode 100644 index 0000000..526f145 --- /dev/null +++ b/modules/user/client-react/package.json @@ -0,0 +1,6 @@ + +{ + "name": "@restapp/user-client-react", + "version": "0.1.0", + "private": true +} \ No newline at end of file diff --git a/modules/user/client-react/profile/actions/index.ts b/modules/user/client-react/profile/actions/index.ts new file mode 100644 index 0000000..fd9d31a --- /dev/null +++ b/modules/user/client-react/profile/actions/index.ts @@ -0,0 +1,3 @@ +export * from '../../users/actions'; + +export * from '../../actions'; diff --git a/modules/user/client-react/profile/components/ProfileEditForm.native.tsx b/modules/user/client-react/profile/components/ProfileEditForm.native.tsx new file mode 100644 index 0000000..781dcdb --- /dev/null +++ b/modules/user/client-react/profile/components/ProfileEditForm.native.tsx @@ -0,0 +1,208 @@ +import React from 'react'; +import { withFormik, FormikProps } from 'formik'; +import { View, StyleSheet } from 'react-native'; +import KeyboardSpacer from 'react-native-keyboard-spacer'; + +import { FieldAdapter as Field } from '@restapp/forms-client-react'; +import { translate, TranslateFunction } from '@restapp/i18n-client-react'; +import { RenderField, Button, RenderSelect, RenderSwitch, FormView, primary } from '@restapp/look-client-react-native'; +import { placeholderColor, submit } from '@restapp/look-client-react-native/styles'; +import { email as emailRule, minLength, required, match, validate } from '@restapp/validation-common-react'; + +import { FormProps, User, UserRole } from '../../types'; +import settings from '../../../../../settings'; + +interface FormikFormProps extends FormProps { + initialValues: FormValues; +} + +interface UserFormProps extends FormikProps { + handleSubmit: () => void; + t: TranslateFunction; + handleChange: () => void; + setFieldValue: (type: string, value: any) => void; + onSubmit: (values: FormValues) => Promise; + setTouched: () => void; + isValid: boolean; + error: string; + shouldDisplayRole: boolean; + shouldDisplayActive: boolean; + values: any; + errors: any; + initialValues: FormValues; + touched: any; +} + +interface FormValues extends User { + password: string; + passwordConfirmation: string; +} + +type RoleChangeFunc = ( + type: string, + value: string | string[], + setFieldValue: (type: string, value: string) => void +) => void; + +const userFormSchema = { + username: [required, minLength(3)], + email: [required, emailRule], + password: [required, minLength(settings.auth.password.minLength)], + passwordConfirmation: [match('password'), required, minLength(settings.auth.password.minLength)] +}; + +const handleRoleChange: RoleChangeFunc = (type, value, setFieldValue) => { + const preparedValue = Array.isArray(value) ? value[0] : value; + setFieldValue(type, preparedValue); +}; + +const UserForm: React.FunctionComponent = ({ + values, + handleSubmit, + setFieldValue, + t, + shouldDisplayRole, + shouldDisplayActive +}) => { + const options = [ + { + value: 'user', + label: t('userEdit.form.field.role.user') + }, + { + value: 'admin', + label: t('userEdit.form.field.role.admin') + } + ]; + const { username, email, role, isActive, firstName, lastName, password, passwordConfirmation } = values; + return ( + + + + + {shouldDisplayRole && ( + handleRoleChange('role', value, setFieldValue)} + cols={1} + data={options} + /> + )} + {shouldDisplayActive && ( + setFieldValue('isActive', !isActive)} + component={RenderSwitch} + placeholder={t('userEdit.form.field.active')} + checked={isActive} + placeholderTextColor={placeholderColor} + /> + )} + + + + + + + + + + + + ); +}; + +const UserFormWithFormik = withFormik({ + mapPropsToValues: values => { + const { username, email, role, isActive, firstName, lastName, ...rest } = values.initialValues; + return { + username, + email, + role: role || UserRole.user, + isActive, + password: '', + passwordConfirmation: '', + firstName, + lastName, + ...rest + }; + }, + handleSubmit(values, { setErrors, props: { onSubmit } }) { + onSubmit(values).catch((e: any) => { + if (e && e.errors) { + setErrors(e.errors); + } else { + throw e; + } + }); + }, + displayName: 'SignUpForm ', // helps with React DevTools + validate: values => validate(values, userFormSchema) +}); + +const styles = StyleSheet.create({ + formContainer: { + paddingHorizontal: 20, + justifyContent: 'center', + flex: 1 + }, + submit, + formView: { + flex: 1, + alignSelf: 'stretch' + } +}); + +export default translate('userUsers')(UserFormWithFormik(UserForm)); diff --git a/modules/user/client-react/profile/components/ProfileEditView.native.tsx b/modules/user/client-react/profile/components/ProfileEditView.native.tsx new file mode 100644 index 0000000..e823c9f --- /dev/null +++ b/modules/user/client-react/profile/components/ProfileEditView.native.tsx @@ -0,0 +1,50 @@ +import * as React from 'react'; +import { StyleSheet, View } from 'react-native'; +import { translate, TranslateFunction } from '@restapp/i18n-client-react'; +import { Loading } from '@restapp/look-client-react-native'; + +import UserForm from './ProfileEditForm.native'; +import { User } from '../../types'; + +interface FormValues extends User { + password: string; + passwordConfirmation: string; +} + +interface UserEditViewProps { + loading: boolean; + user: User; + currentUser: User; + t: TranslateFunction; + onSubmit: (values: FormValues) => Promise; +} + +class UserEditView extends React.PureComponent { + public render() { + const { loading, user, t, currentUser } = this.props; + if (loading && !user) { + return ; + } else { + const isNotSelf = !user || (user && user.id !== currentUser.id); + return ( + + + + ); + } + } +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'center' + } +}); + +export default translate('userUsers')(UserEditView); diff --git a/modules/user/client-react/profile/components/ProfileView.native.tsx b/modules/user/client-react/profile/components/ProfileView.native.tsx new file mode 100644 index 0000000..eb4bf01 --- /dev/null +++ b/modules/user/client-react/profile/components/ProfileView.native.tsx @@ -0,0 +1,89 @@ +import React from 'react'; +import { StyleSheet, Text, View, ScrollView, TouchableOpacity } from 'react-native'; + +import { translate, TranslateFunction } from '@restapp/i18n-client-react'; +import { Card, CardItem, CardText, CardHeader, CardLabel, Loading } from '@restapp/look-client-react-native'; +import { linkText } from '@restapp/look-client-react-native/styles'; + +import { NavigationOptionsProps, User } from '../../types'; + +interface ProfileViewProps extends NavigationOptionsProps { + currentUserLoading: boolean; + currentUser: User; + t: TranslateFunction; +} + +type ProfileItemProps = (title: string, value: string, idx: number) => JSX.Element; + +const renderProfileItem: ProfileItemProps = (title, value, idx) => ( + + {`${title}: `} + {value} + +); + +const ProfileView: React.FunctionComponent = ({ currentUserLoading, currentUser, navigation, t }) => { + const profileItems = currentUser + ? [ + { + label: `${t('profile.card.group.name')}`, + value: currentUser.username + }, + { + label: `${t('profile.card.group.email')}`, + value: currentUser.email + }, + { + label: `${t('profile.card.group.role')}`, + value: currentUser.role + } + ] + : []; + + if (currentUser && currentUser.fullName) { + profileItems.push({ label: `${t('profile.card.group.full')}`, value: currentUser.fullName }); + } + + return ( + + {currentUserLoading ? ( + + ) : ( + + + + + {profileItems.map((item, idx) => renderProfileItem(item.label, item.value, idx))} + + + navigation.navigate('ProfileEdit', { id: currentUser.id })} + > + {t('profile.editProfileText')} + + + )} + + ); +}; + +const styles = StyleSheet.create({ + scrollContainer: { + paddingTop: 10, + paddingHorizontal: 20 + }, + container: { + flex: 1 + }, + cardWrapper: { + marginBottom: 15 + }, + linkWrapper: { + alignItems: 'center', + justifyContent: 'center' + }, + linkText +}); + +export default translate('userProfile')(ProfileView); diff --git a/modules/user/client-react/profile/components/ProfileView.tsx b/modules/user/client-react/profile/components/ProfileView.tsx new file mode 100644 index 0000000..56675c7 --- /dev/null +++ b/modules/user/client-react/profile/components/ProfileView.tsx @@ -0,0 +1,65 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; + +import { translate, TranslateFunction } from '@restapp/i18n-client-react'; +import { LayoutCenter, Card, CardGroup, CardTitle, CardText, PageLayout, MetaData } from '@restapp/look-client-react'; + +import { User } from '../../types'; + +interface ProfileViewProps { + currentUserLoading: boolean; + currentUser: User; + t: TranslateFunction; +} + +const ProfileView: React.FunctionComponent = ({ currentUserLoading, currentUser, t }) => { + if (currentUserLoading && !currentUser) { + return ( + + +
{t('profile.loadMsg')}
+
+ ); + } else if (currentUser) { + return ( + + + +

{t('profile.card.title')}

+ + + {t('profile.card.group.name')}: + {currentUser.username} + + + {t('profile.card.group.email')}: + {currentUser.email} + + + {t('profile.card.group.role')}: + {currentUser.role} + + {currentUser && currentUser.fullName && ( + + {t('profile.card.group.full')}: + {currentUser.fullName} + + )} + + + {t('profile.editProfileText')} + +
+
+ ); + } else { + return ( + + +

{t('profile.errorMsg')}

+
+ ); + } +}; + +export default translate('userProfile')(ProfileView); diff --git a/modules/user/client-react/profile/containers/Profile.native.tsx b/modules/user/client-react/profile/containers/Profile.native.tsx new file mode 100644 index 0000000..37857f9 --- /dev/null +++ b/modules/user/client-react/profile/containers/Profile.native.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import ProfileView from '../components/ProfileView.native'; +import { CommonProps, User } from '../../types'; + +interface ProfileProps extends CommonProps { + currentUser?: User; + currentUserLoading?: boolean; + error?: any; +} + +const Profile: React.FunctionComponent = props => ; +export default connect<{}, {}, ProfileProps>(({ signUpReducer: { currentUser, loading } }: any) => ({ + currentUser, + currentUserLoading: loading +}))(Profile); diff --git a/modules/user/client-react/profile/containers/Profile.tsx b/modules/user/client-react/profile/containers/Profile.tsx new file mode 100644 index 0000000..2a17392 --- /dev/null +++ b/modules/user/client-react/profile/containers/Profile.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import ProfileView from '../components/ProfileView'; +import { User, CommonProps } from '../../types'; + +interface ProfileProps extends CommonProps { + currentUser?: User; + currentUserLoading?: boolean; + getCurrentUser?: () => void; +} + +const Profile: React.FunctionComponent = ({ ...props }) => { + return ; +}; + +export default connect(({ signUpReducer: { currentUser } }: any) => ({ + currentUser +}))(Profile); diff --git a/modules/user/client-react/profile/containers/ProfileEdit.native.tsx b/modules/user/client-react/profile/containers/ProfileEdit.native.tsx new file mode 100644 index 0000000..e615590 --- /dev/null +++ b/modules/user/client-react/profile/containers/ProfileEdit.native.tsx @@ -0,0 +1,56 @@ +import React, { useEffect, useState } from 'react'; +import { connect } from 'react-redux'; +import { pick } from 'lodash'; + +import { translate } from '@restapp/i18n-client-react'; +import { FormError } from '@restapp/forms-client-react'; + +import ProfileEditView from '../components/ProfileEditView.native'; +import UserFormatter from '../../helpers/UserFormatter'; +import { User, CommonProps } from '../../types'; +import { user, editUser } from '../actions'; + +interface ProfileEditProps extends CommonProps { + editableUser?: User; + editUser?: (value: User) => any; + location?: any; + match?: any; + getUser?: (id: number) => void; +} + +const UserEdit: React.FunctionComponent = props => { + const { editableUser, editUser: actionEditUser, t, match, getUser } = props; + const [ready, setReady] = useState(false); + useEffect(() => { + (async () => { + let id = 0; + if (match) { + id = match.params.id; + } + await getUser(Number(id)); + setReady(true); + })(); + }, []); + + const onSubmit = async (values: User) => { + let userValues = pick(values, ['username', 'email', 'role', 'isActive', 'password', 'firstName', 'lastName']); + + userValues = UserFormatter.trimExtraSpaces(userValues); + + try { + await actionEditUser({ id: editableUser.id, ...userValues } as any); + } catch (e) { + const data = e.response && e.response.data; + throw new FormError(t('userEdit.errorMsg'), data); + } + }; + + return ready ? : null; +}; + +export default connect<{}, {}, ProfileEditProps>( + ({ usersReducer: { user: editableUser } }: any) => ({ + editableUser + }), + { getUser: user, editUser } +)(translate('userUsers')(UserEdit)); diff --git a/modules/user/client-react/profile/index.native.tsx b/modules/user/client-react/profile/index.native.tsx new file mode 100644 index 0000000..4003ecb --- /dev/null +++ b/modules/user/client-react/profile/index.native.tsx @@ -0,0 +1,119 @@ +import React from 'react'; +import { createStackNavigator, NavigationScreenProp, NavigationRoute, NavigationParams } from 'react-navigation'; +import { FormikErrors } from 'formik'; + +import { translate, TranslateFunction } from '@restapp/i18n-client-react'; +import { HeaderTitle, IconButton } from '@restapp/look-client-react-native'; +import ClientModule from '@restapp/module-client-react-native'; + +import resources from './locales'; +import { UserRole } from '../types'; +import Profile from './containers/Profile.native'; +import ProfileEdit from './containers/ProfileEdit.native'; + +interface Auth { + lnDisplayName: string; + lnId: string; + googleDisplayName: string; + googleId: string; + ghDisplayName: string; + ghId: string; + fbId: string; + fbDisplayName: string; +} + +interface UserProfile { + fullName?: string; + firstName?: string; + lastName?: string; +} + +export interface User extends UserProfile, Auth { + id?: number | string; + username: string; + role: UserRole; + isActive: boolean; + email: string; +} +export interface NavigationOptionsProps { + navigation?: NavigationScreenProp, NavigationParams>; +} + +export interface CommonProps extends NavigationOptionsProps { + error?: string; + t?: TranslateFunction; +} + +interface HandleSubmitProps

{ + setErrors: (errors: FormikErrors

) => void; + props: P; +} + +interface Errors { + message?: string; +} + +export interface FormProps { + handleSubmit: (values: V, props: HandleSubmitProps) => void; + onSubmit: (values: V) => Promise | void | any; + submitting?: boolean; + errors: Errors; + values: V; + t?: TranslateFunction; +} + +class ProfileScreen extends React.Component { + public static navigationOptions = () => ({ + title: 'Profile' + }); + public render() { + return ; + } +} + +class ProfilerEditScreen extends React.Component { + public static navigationOptions = () => ({ + title: 'Edit profile' + }); + public render() { + return ; + } +} + +const HeaderTitleWithI18n = translate('userProfile')(HeaderTitle); + +export default new ClientModule({ + drawerItem: [ + { + Profile: { + screen: createStackNavigator({ + Profile: { + screen: ProfileScreen, + navigationOptions: ({ navigation }: NavigationOptionsProps) => ({ + headerTitle: , + headerLeft: ( + navigation.openDrawer()} /> + ), + headerForceInset: {} + }) + }, + ProfileEdit: { + screen: ProfilerEditScreen, + navigationOptions: () => ({ + headerTitle: , + headerForceInset: {} + }) + } + }), + userInfo: { + showOnLogin: true, + role: [UserRole.user, UserRole.admin] + }, + navigationOptions: { + drawerLabel: + } + } + } + ], + localization: [{ ns: 'userProfile', resources }] +}); diff --git a/modules/user/client-react/profile/index.tsx b/modules/user/client-react/profile/index.tsx new file mode 100644 index 0000000..74410d6 --- /dev/null +++ b/modules/user/client-react/profile/index.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import { NavLink } from 'react-router-dom'; + +import { MenuItem } from '@restapp/look-client-react'; +import ClientModule from '@restapp/module-client-react'; + +import resources from './locales'; +import Profile from './containers/Profile'; +import { AuthRoute, IfLoggedIn, withUser } from '../containers/Auth'; +import { UserRole } from '../types'; + +const ProfileName = withUser(({ currentUser }) => ( + <>{currentUser ? currentUser.fullName || currentUser.username : null} +)); + +export default new ClientModule({ + route: [ + + ], + navItemRight: [ + + + + + + + + ], + localization: [{ ns: 'userProfile', resources }] +}); diff --git a/modules/user/client-react/profile/locales/en/translations.json b/modules/user/client-react/profile/locales/en/translations.json new file mode 100644 index 0000000..6f35f8b --- /dev/null +++ b/modules/user/client-react/profile/locales/en/translations.json @@ -0,0 +1,25 @@ +{ + "navLink": { + "profile": "Profile", + "editProfile": "Edit profile" + }, + "profile": { + "title": "Profile", + "headerText": "Profile info", + "meta": "A Profile page example", + "loadMsg": "Loading...", + "errorMsg": "No current user is logged in", + "editProfileText": "Edit Profile", + "card": { + "title": "Profile", + "group": { + "name": "User Name", + "email": "Email", + "role": "Role", + "full": "Full Name" + }, + "fldEmail": "Email", + "btnSubmit": "Send instructions" + } + } +} \ No newline at end of file diff --git a/modules/user/client-react/profile/locales/index.ts b/modules/user/client-react/profile/locales/index.ts new file mode 100644 index 0000000..ff8b4c5 --- /dev/null +++ b/modules/user/client-react/profile/locales/index.ts @@ -0,0 +1 @@ +export default {}; diff --git a/modules/user/client-react/profile/locales/ru/translations.json b/modules/user/client-react/profile/locales/ru/translations.json new file mode 100644 index 0000000..07295a3 --- /dev/null +++ b/modules/user/client-react/profile/locales/ru/translations.json @@ -0,0 +1,23 @@ +{ + "navLink": { + "profile": "Профиль", + "editProfile": "Редактирование профиля" + }, + "profile": { + "title": "Профиль", + "headerText": "Информация о профиле", + "meta": "Пример страницы с профилем", + "loadMsg": "Загрузка...", + "errorMsg": "Нет текущего пользователя", + "editProfileText": "Редактировать профиль", + "card": { + "title": "Профиль", + "group": { + "name": "Имя пользователя", + "email": "Электронная почта", + "role": "Роль", + "full": "Полное имя" + } + } + } +} \ No newline at end of file diff --git a/modules/user/client-react/reducers/index.ts b/modules/user/client-react/reducers/index.ts new file mode 100644 index 0000000..72325b2 --- /dev/null +++ b/modules/user/client-react/reducers/index.ts @@ -0,0 +1 @@ +export enum ActionType {} diff --git a/modules/user/client-react/signUp/actions/clearUser.ts b/modules/user/client-react/signUp/actions/clearUser.ts new file mode 100644 index 0000000..f6c6675 --- /dev/null +++ b/modules/user/client-react/signUp/actions/clearUser.ts @@ -0,0 +1,7 @@ +import { ActionFunction, ActionType } from '.'; + +const clearUser: ActionFunction = () => ({ + type: ActionType.CLEAR_CURRENT_USER +}); + +export default clearUser; diff --git a/modules/user/client-react/signUp/actions/forgotPassword.ts b/modules/user/client-react/signUp/actions/forgotPassword.ts new file mode 100644 index 0000000..ab58a97 --- /dev/null +++ b/modules/user/client-react/signUp/actions/forgotPassword.ts @@ -0,0 +1,10 @@ +import axios from 'axios'; +import { ActionFunction, ActionType } from '.'; +import { ForgotPasswordSubmitProps } from '../types'; + +const forgotPassword: ActionFunction = value => ({ + types: {}, + APICall: () => axios.post(`${__API_URL__}/forgotPassword`, { value }) +}); + +export default forgotPassword; diff --git a/modules/user/client-react/signUp/actions/index.ts b/modules/user/client-react/signUp/actions/index.ts new file mode 100644 index 0000000..6ec123f --- /dev/null +++ b/modules/user/client-react/signUp/actions/index.ts @@ -0,0 +1,8 @@ +export { default as register } from './register'; +export { default as login } from './login'; +export { default as clearUser } from './clearUser'; +export { default as resetPassword } from './resetPassword'; +export { default as forgotPassword } from './forgotPassword'; + +export * from '../../actions'; +export { ActionType } from '../reducers'; diff --git a/modules/user/client-react/signUp/actions/login.ts b/modules/user/client-react/signUp/actions/login.ts new file mode 100644 index 0000000..35c17fd --- /dev/null +++ b/modules/user/client-react/signUp/actions/login.ts @@ -0,0 +1,12 @@ +import axios from 'axios'; +import { ActionFunction, ActionType } from '.'; +import { LoginSubmitProps } from '../types'; + +const login: ActionFunction = value => ({ + types: { + SUCCESS: ActionType.SET_CURRENT_USER + }, + APICall: () => axios.post(`${__API_URL__}/login`, { ...value }) +}); + +export default login; diff --git a/modules/user/client-react/signUp/actions/register.ts b/modules/user/client-react/signUp/actions/register.ts new file mode 100644 index 0000000..8f8bb45 --- /dev/null +++ b/modules/user/client-react/signUp/actions/register.ts @@ -0,0 +1,10 @@ +import axios from 'axios'; +import { ActionFunction, ActionType } from '.'; +import { RegisterSubmitProps } from '../types'; + +const register: ActionFunction = value => ({ + types: {}, + APICall: () => axios.post(`${__API_URL__}/register`, { ...value }) +}); + +export default register; diff --git a/modules/user/client-react/signUp/actions/resetPassword.ts b/modules/user/client-react/signUp/actions/resetPassword.ts new file mode 100644 index 0000000..f958b25 --- /dev/null +++ b/modules/user/client-react/signUp/actions/resetPassword.ts @@ -0,0 +1,14 @@ +import axios from 'axios'; +import { ActionFunction, ActionType } from '.'; +import { ResetPasswordSubmitProps } from '../types'; + +interface ResetPasswordProps extends ResetPasswordSubmitProps { + token: string; +} + +const resetPassword: ActionFunction = value => ({ + types: {}, + APICall: () => axios.post(`${__API_URL__}/resetPassword`, { ...value }) +}); + +export default resetPassword; diff --git a/modules/user/client-react/signUp/components/ForgotPasswordForm.native.tsx b/modules/user/client-react/signUp/components/ForgotPasswordForm.native.tsx new file mode 100644 index 0000000..2302f24 --- /dev/null +++ b/modules/user/client-react/signUp/components/ForgotPasswordForm.native.tsx @@ -0,0 +1,132 @@ +import React from 'react'; +import { withFormik, FormikProps } from 'formik'; +import { View, StyleSheet, Text, Keyboard } from 'react-native'; +import KeyboardSpacer from 'react-native-keyboard-spacer'; +import { FontAwesome } from '@expo/vector-icons'; + +import { FieldAdapter as Field } from '@restapp/forms-client-react'; +import { RenderField, Button, primary } from '@restapp/look-client-react-native'; +import { placeholderColor, submit } from '@restapp/look-client-react-native/styles'; +import { required, email, validate } from '@restapp/validation-common-react'; +import { translate } from '@restapp/i18n-client-react'; + +import { CommonProps, FormProps } from '../../types'; +import { ForgotPasswordSubmitProps } from '../types'; + +interface ForgotPasswordFormProps extends CommonProps, FormProps { + sent: boolean; +} + +const forgotPasswordFormSchema = { + email: [required, email] +}; + +const ForgotPasswordForm: React.FunctionComponent> = ({ + handleSubmit, + values, + sent, + t +}) => { + return ( + + + {sent && ( + + + + + + {t('forgotPass.form.submitMsg')} + + + )} + + + + + + + + + + + + ); +}; + +const ForgotPasswordFormWithFormik = withFormik({ + enableReinitialize: true, + mapPropsToValues: () => ({ email: '' }), + async handleSubmit(values, { setErrors, resetForm, props: { onSubmit } }) { + Keyboard.dismiss(); + await onSubmit(values) + .then(() => { + resetForm(); + }) + .catch((e: any) => { + if (e && e.errors) { + setErrors(e.errors); + } else { + throw e; + } + }); + }, + validate: values => validate(values, forgotPasswordFormSchema), + displayName: 'ForgotPasswordForm' // helps with React DevTools +}); + +const styles = StyleSheet.create({ + submit, + formContainer: { + flex: 1, + alignItems: 'stretch', + justifyContent: 'center', + paddingHorizontal: 20 + }, + form: { + flex: 2 + }, + alertWrapper: { + backgroundColor: '#d4edda', + justifyContent: 'space-between', + alignItems: 'center', + flexDirection: 'row', + flexWrap: 'wrap', + borderRadius: 5, + paddingVertical: 10 + }, + alertContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'stretch' + }, + alertTextWrapper: { + padding: 5, + flex: 20, + justifyContent: 'center', + alignItems: 'center' + }, + alertIconWrapper: { + padding: 5, + flex: 4, + justifyContent: 'center', + alignItems: 'center' + }, + alertText: { + color: '#155724', + fontSize: 20, + fontWeight: '400' + } +}); + +export default translate('userSignUp')(ForgotPasswordFormWithFormik(ForgotPasswordForm)); diff --git a/modules/user/client-react/signUp/components/ForgotPasswordForm.tsx b/modules/user/client-react/signUp/components/ForgotPasswordForm.tsx new file mode 100644 index 0000000..b5719b7 --- /dev/null +++ b/modules/user/client-react/signUp/components/ForgotPasswordForm.tsx @@ -0,0 +1,65 @@ +import React from 'react'; +import { withFormik, FormikProps } from 'formik'; + +import { FieldAdapter as Field } from '@restapp/forms-client-react'; +import { translate } from '@restapp/i18n-client-react'; +import { Form, RenderField, Button, Alert } from '@restapp/look-client-react'; +import { required, email, validate } from '@restapp/validation-common-react'; + +import { CommonProps, FormProps } from '../../types'; +import { ForgotPasswordSubmitProps } from '../types'; + +interface ForgotPasswordFormProps extends CommonProps, FormProps { + sent: boolean; +} + +const forgotPasswordFormSchema = { + email: [required, email] +}; + +const ForgotPasswordForm: React.FunctionComponent> = ({ + handleSubmit, + errors, + sent, + values, + t +}) => { + return ( +

+ {sent && {t('forgotPass.form.submitMsg')}} + +
+ {errors && errors.message && {errors.message}} + +
+ + ); +}; + +const ForgotPasswordFormWithFormik = withFormik({ + enableReinitialize: true, + mapPropsToValues: () => ({ email: '' }), + async handleSubmit(values, { setErrors, resetForm, props: { onSubmit } }) { + await onSubmit(values) + .then(() => resetForm()) + .catch((e: any) => { + if (e && e.errors) { + setErrors(e.errors); + } else { + throw e; + } + }); + }, + validate: values => validate(values, forgotPasswordFormSchema), + displayName: 'ForgotPasswordForm' // helps with React DevTools +}); + +export default translate('userSignUp')(ForgotPasswordFormWithFormik(ForgotPasswordForm)); diff --git a/modules/user/client-react/signUp/components/ForgotPasswordView.native.tsx b/modules/user/client-react/signUp/components/ForgotPasswordView.native.tsx new file mode 100644 index 0000000..7881dcb --- /dev/null +++ b/modules/user/client-react/signUp/components/ForgotPasswordView.native.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import { StyleSheet, View } from 'react-native'; + +import ForgotPasswordForm from './ForgotPasswordForm.native'; +import { ForgotPasswordSubmitProps } from '../types'; + +interface ForgotPasswordViewProps { + onSubmit: (values: ForgotPasswordSubmitProps) => void; + sent: boolean; +} + +class ForgotPasswordView extends React.PureComponent { + public render() { + const { onSubmit, sent } = this.props; + return ( + + + + ); + } +} + +const styles = StyleSheet.create({ + forgotPassContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'stretch' + } +}); + +export default ForgotPasswordView; diff --git a/modules/user/client-react/signUp/components/ForgotPasswordView.tsx b/modules/user/client-react/signUp/components/ForgotPasswordView.tsx new file mode 100644 index 0000000..076cf97 --- /dev/null +++ b/modules/user/client-react/signUp/components/ForgotPasswordView.tsx @@ -0,0 +1,26 @@ +import React from 'react'; + +import { LayoutCenter, PageLayout, MetaData } from '@restapp/look-client-react'; + +import ForgotPasswordForm from './ForgotPasswordForm'; +import { CommonProps } from '../../types'; +import { ForgotPasswordSubmitProps } from '../types'; + +interface ForgotPasswordViewProps extends CommonProps { + onSubmit: (values: ForgotPasswordSubmitProps) => void; + sent: boolean; +} + +const ForgotPasswordView: React.FunctionComponent = ({ onSubmit, t, sent }) => { + return ( + + + +

{t('forgotPass.form.title')}

+ +
+
+ ); +}; + +export default ForgotPasswordView; diff --git a/modules/user/client-react/signUp/components/LoginForm.native.tsx b/modules/user/client-react/signUp/components/LoginForm.native.tsx new file mode 100644 index 0000000..94dd988 --- /dev/null +++ b/modules/user/client-react/signUp/components/LoginForm.native.tsx @@ -0,0 +1,174 @@ +import React from 'react'; +import { StyleSheet, View, Text, TouchableOpacity, ViewStyle } from 'react-native'; +import KeyboardSpacer from 'react-native-keyboard-spacer'; +import { withFormik } from 'formik'; + +import { FieldAdapter as Field } from '@restapp/forms-client-react'; +import { translate, TranslateFunction } from '@restapp/i18n-client-react'; +import { RenderField, Button, primary, FormView } from '@restapp/look-client-react-native'; +import { placeholderColor, submit } from '@restapp/look-client-react-native/styles'; +import { required, minLength, validate } from '@restapp/validation-common-react'; +import { LinkedInButton, GoogleButton, GitHubButton, FacebookButton } from '@restapp/authentication-client-react'; + +import { FormProps, NavigationOptionsProps } from '../../types'; +import { LoginSubmitProps } from '../types'; +import settings from '../../../../../settings'; + +interface SocialButtons { + buttonsLength: number; + t: TranslateFunction; +} + +interface LoginForm extends FormProps { + valid: string; +} + +const loginFormSchema = { + usernameOrEmail: [required, minLength(3)], + password: [required, minLength(settings.auth.password.minLength)] +}; +const { github, facebook, linkedin, google } = settings.auth.social; + +const renderSocialButtons = ({ buttonsLength, t }: SocialButtons) => { + const type: string = buttonsLength > 2 ? 'icon' : 'button'; + const containerStyle: ViewStyle = + buttonsLength > 2 ? { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' } : {}; + + return buttonsLength > 0 ? ( + + {facebook.enabled && } + {google.enabled && } + {github.enabled && } + {linkedin.enabled && } + + ) : null; +}; + +const LoginForm = ({ handleSubmit, valid, values, errors, navigation, t }: LoginForm & NavigationOptionsProps) => { + const buttonsLength = [facebook.enabled, linkedin.enabled, google.enabled, github.enabled].filter(button => button) + .length; + + return ( + + + + + + + + + {errors && errors.message && ( + + {errors.message} + + )} + + + + {renderSocialButtons({ buttonsLength, t })} + + navigation.navigate('ForgotPassword')}> + {t('login.btn.forgotPass')} + + + + + + + {t('login.notRegText')} + navigation.navigate('Register')}> + {t('login.btn.sign')} + + + + + ); +}; + +const styles = StyleSheet.create({ + formContainer: { + flex: 1 + }, + formView: { + flex: 1, + alignSelf: 'stretch' + }, + form: { + justifyContent: 'center', + paddingHorizontal: 20, + flex: 9 + }, + submit, + buttonsGroup: { + flex: 1, + paddingTop: 10 + }, + buttonWrapper: { + flex: 1, + flexDirection: 'row', + justifyContent: 'center', + alignItems: 'flex-end' + }, + text: { + fontSize: 14, + color: '#bcb8b8' + }, + signUpText: { + fontSize: 16, + paddingLeft: 3, + color: '#8e908c', + fontWeight: '600', + textDecorationLine: 'underline', + textAlign: 'center' + }, + alert: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: 'red', + marginTop: 10 + }, + alertText: { + padding: 10 + } +}); + +const LoginFormWithFormik = withFormik({ + mapPropsToValues: () => ({ usernameOrEmail: '', password: '' }), + handleSubmit(values, { setErrors, props: { onSubmit } }) { + onSubmit(values).catch((e: any) => { + if (e && e.errors) { + setErrors(e.errors); + } else { + throw e; + } + }); + }, + validate: values => validate(values, loginFormSchema), + enableReinitialize: true, + displayName: 'LoginForm' // helps with React DevTools +}); + +export default translate('userSignUp')(LoginFormWithFormik(LoginForm)); diff --git a/modules/user/client-react/signUp/components/LoginForm.tsx b/modules/user/client-react/signUp/components/LoginForm.tsx new file mode 100644 index 0000000..30c732b --- /dev/null +++ b/modules/user/client-react/signUp/components/LoginForm.tsx @@ -0,0 +1,117 @@ +import React from 'react'; +import { withFormik } from 'formik'; +import { NavLink, Link } from 'react-router-dom'; + +import { FieldAdapter as Field } from '@restapp/forms-client-react'; +import { translate, TranslateFunction } from '@restapp/i18n-client-react'; +import { required, minLength, validate } from '@restapp/validation-common-react'; +import { Form, RenderField, Alert, Button } from '@restapp/look-client-react'; +import { LinkedInButton, GoogleButton, GitHubButton, FacebookButton } from '@restapp/authentication-client-react'; + +import settings from '../../../../../settings'; +import { LoginSubmitProps } from '../types'; +import { FormProps } from '../../types'; + +interface SocialButtons { + buttonsLength: number; + t: TranslateFunction; +} + +const loginFormSchema = { + usernameOrEmail: [required, minLength(3)], + password: [required, minLength(8)] +}; +const { github, facebook, linkedin, google } = settings.auth.social; + +const renderSocialButtons = ({ buttonsLength, t }: SocialButtons) => { + const type: string = buttonsLength > 2 ? 'icon' : 'button'; + const containerStyle = + buttonsLength > 2 ? { display: 'flex', justifyContent: 'space-between', alignItems: 'center', minWidth: 200 } : {}; + + return ( +
+ {facebook.enabled && ( +
+ +
+ )} + {google.enabled && ( +
+ +
+ )} + {github.enabled && ( +
+ +
+ )} + {linkedin.enabled && ( +
+ +
+ )} +
+ ); +}; + +const LoginForm = ({ handleSubmit, submitting, errors, values, t }: FormProps) => { + const buttonsLength: number = [facebook.enabled, linkedin.enabled, google.enabled, github.enabled].filter( + button => button + ).length; + return ( +
+ + +
{errors && errors.message && {errors.message}}
+
+
+ +
+ {renderSocialButtons({ buttonsLength, t })} +
+
+ {t('login.btn.forgotPass')} +
+
+
+ {t('login.btn.notReg')} + + {t('login.btn.sign')} + +
+ + ); +}; + +const LoginFormWithFormik = withFormik, LoginSubmitProps>({ + enableReinitialize: true, + mapPropsToValues: () => ({ usernameOrEmail: '', password: '' }), + + handleSubmit(values, { setErrors, props: { onSubmit } }) { + onSubmit(values).catch((e: any) => { + if (e && e.errors) { + setErrors(e.errors); + } else { + throw e; + } + }); + }, + validate: values => validate(values, loginFormSchema), + displayName: 'LoginForm' // helps with React DevTools +}); + +export default translate('userSignUp')(LoginFormWithFormik(LoginForm)); diff --git a/modules/user/client-react/signUp/components/LoginView.native.tsx b/modules/user/client-react/signUp/components/LoginView.native.tsx new file mode 100644 index 0000000..f504e8a --- /dev/null +++ b/modules/user/client-react/signUp/components/LoginView.native.tsx @@ -0,0 +1,111 @@ +import React from 'react'; +import { StyleSheet, View, Text, Linking, Platform } from 'react-native'; +import { WebBrowser } from 'expo'; + +import { translate } from '@restapp/i18n-client-react'; +import { placeholderColor } from '@restapp/look-client-react-native/styles'; +import { setItem } from '@restapp/core-common/clientStorage'; +import authentication from '@restapp/authentication-client-react'; + +import LoginForm from './LoginForm.native'; +import { LoginProps } from '../containers/Login.native'; + +import { LoginSubmitProps } from '../types'; + +interface LoginViewProps extends LoginProps { + onSubmit: (values: LoginSubmitProps) => void; +} + +interface LinkingEvent { + url: string; +} +class LoginView extends React.PureComponent { + public componentDidMount() { + Linking.addEventListener('url', this.handleOpenURL); + } + + public componentWillUnmount() { + Linking.removeEventListener('url', this.handleOpenURL); + } + + public handleOpenURL = async ({ url }: LinkingEvent) => { + const dataRegExp = /data=([^#]+)/; + if (!url.match(dataRegExp)) { + return; + } + + // Extract stringified user string out of the URL + const [, data] = url.match(dataRegExp); + const decodedData = JSON.parse(decodeURI(data)); + + if (decodedData.tokens) { + await setItem('accessToken', decodedData.tokens.accessToken); + await setItem('refreshToken', decodedData.tokens.refreshToken); + + await authentication.doLogin(); + } + + if (Platform.OS === 'ios') { + WebBrowser.dismissBrowser(); + } + }; + + public renderAvailableLogins = () => ( + + {this.props.t('login.cardTitle')}: + admin@example.com: admin123 + user@example.com: user1234 + + ); + + public render() { + const { navigation, onSubmit } = this.props; + return ( + + {this.renderAvailableLogins()} + + + + + ); + } +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'center', + alignItems: 'stretch', + padding: 10 + }, + examplesArea: { + borderWidth: 0.5, + borderRadius: 5, + borderColor: placeholderColor, + justifyContent: 'space-between', + alignItems: 'center', + backgroundColor: '#e3e3e3', + padding: 10 + }, + examplesContainer: { + flex: 1, + alignItems: 'center', + justifyContent: 'center' + }, + title: { + fontSize: 18, + fontWeight: '600', + textAlign: 'center', + color: placeholderColor + }, + exampleText: { + fontSize: 14, + fontWeight: '400', + color: placeholderColor + }, + loginContainer: { + flex: 3 + } +}); + +export default translate('userSignUp')(LoginView); diff --git a/modules/user/client-react/signUp/components/LoginView.tsx b/modules/user/client-react/signUp/components/LoginView.tsx new file mode 100644 index 0000000..24a9096 --- /dev/null +++ b/modules/user/client-react/signUp/components/LoginView.tsx @@ -0,0 +1,66 @@ +import React from 'react'; + +import { + LayoutCenter, + PageLayout, + Card, + CardGroup, + CardTitle, + CardText, + Button, + MetaData +} from '@restapp/look-client-react'; +import { translate, TranslateFunction } from '@restapp/i18n-client-react'; + +import LoginForm from './LoginForm'; +import { LoginSubmitProps } from '../types'; +import { LoginProps } from '../containers/Login'; + +interface LoginViewProps extends LoginProps { + onSubmit: (values: LoginSubmitProps) => void; + t: TranslateFunction; + isRegistered?: boolean; + hideModal: () => void; +} + +const LoginView = ({ onSubmit, t, isRegistered, hideModal }: LoginViewProps) => { + const renderConfirmationModal = () => ( + + + {t('reg.successRegTitle')} + {t('reg.successRegBody')} + + + + + + ); + + return ( + + + + {isRegistered ? ( + renderConfirmationModal() + ) : ( + <> +

{t('login.form.title')}

+ +
+ + + {t('login.cardTitle')}: + admin@example.com:admin123 + user@example.com:user1234 + + + + )} +
+
+ ); +}; + +export default translate('userSignUp')(LoginView); diff --git a/modules/user/client-react/signUp/components/RegisterForm.native.tsx b/modules/user/client-react/signUp/components/RegisterForm.native.tsx new file mode 100644 index 0000000..7860aed --- /dev/null +++ b/modules/user/client-react/signUp/components/RegisterForm.native.tsx @@ -0,0 +1,107 @@ +import React from 'react'; +import { View, StyleSheet } from 'react-native'; +import { withFormik } from 'formik'; +import KeyboardSpacer from 'react-native-keyboard-spacer'; + +import { FieldAdapter as Field } from '@restapp/forms-client-react'; +import { translate } from '@restapp/i18n-client-react'; +import { RenderField, Button, primary, FormView } from '@restapp/look-client-react-native'; +import { placeholderColor, submit } from '@restapp/look-client-react-native/styles'; +import { match, email, minLength, required, validate } from '@restapp/validation-common-react'; + +import settings from '../../../../../settings'; + +import { FormProps } from '../../types'; +import { RegisterSubmitProps } from '../types'; + +interface RegisterProps extends FormProps { + valid: string; +} + +const registerFormSchema = { + username: [required, minLength(3)], + email: [required, email], + password: [required, minLength(settings.auth.password.minLength)], + passwordConfirmation: [match('password'), required, minLength(settings.auth.password.minLength)] +}; + +const RegisterForm = ({ values, handleSubmit, t, valid }: RegisterProps) => { + return ( + + + + + + + + + + + + + ); +}; + +const RegisterFormWithFormik = withFormik({ + mapPropsToValues: () => ({ username: '', email: '', password: '', passwordConfirmation: '' }), + validate: values => validate(values, registerFormSchema), + handleSubmit(values, { setErrors, props: { onSubmit } }) { + onSubmit(values).catch((e: any) => { + if (e && e.errors) { + setErrors(e.errors); + } else { + throw e; + } + }); + }, + enableReinitialize: true, + displayName: 'SignUpForm' // helps with React DevTools +}); + +const styles = StyleSheet.create({ + submit, + formView: { + flex: 1, + alignSelf: 'stretch' + }, + formContainer: { + paddingHorizontal: 20, + flex: 1, + justifyContent: 'center' + } +}); + +export default translate('userSignUp')(RegisterFormWithFormik(RegisterForm)); diff --git a/modules/user/client-react/signUp/components/RegisterForm.tsx b/modules/user/client-react/signUp/components/RegisterForm.tsx new file mode 100644 index 0000000..c5856bc --- /dev/null +++ b/modules/user/client-react/signUp/components/RegisterForm.tsx @@ -0,0 +1,71 @@ +import React from 'react'; +import { withFormik } from 'formik'; + +import { FieldAdapter as Field } from '@restapp/forms-client-react'; +import { translate } from '@restapp/i18n-client-react'; +import { match, email, minLength, required, validate } from '@restapp/validation-common-react'; +import { Form, RenderField, Button, Alert } from '@restapp/look-client-react'; + +import { FormProps } from '../../types'; +import { RegisterSubmitProps } from '../types'; +import settings from '../../../../../settings'; + +const registerFormSchema = { + username: [required, minLength(3)], + email: [required, email], + password: [required, minLength(settings.auth.password.minLength)], + passwordConfirmation: [match('password'), required, minLength(settings.auth.password.minLength)] +}; + +const RegisterForm = ({ values, handleSubmit, submitting, errors, t }: FormProps) => { + return ( +
+ + + + +
+ {errors && errors.message && {errors.message}} + +
+ + ); +}; + +const RegisterFormWithFormik = withFormik, RegisterSubmitProps>({ + mapPropsToValues: () => ({ username: '', email: '', password: '', passwordConfirmation: '' }), + validate: values => validate(values, registerFormSchema), + async handleSubmit(values, { setErrors, props: { onSubmit } }) { + onSubmit(values).catch((e: any) => { + if (e) { + setErrors(e.errors); + } else { + throw e; + } + }); + }, + enableReinitialize: true, + displayName: 'SignUpForm' // helps with React DevTools +}); + +export default translate('userSignUp')(RegisterFormWithFormik(RegisterForm)); diff --git a/modules/user/client-react/signUp/components/RegisterView.native.tsx b/modules/user/client-react/signUp/components/RegisterView.native.tsx new file mode 100644 index 0000000..6a333a9 --- /dev/null +++ b/modules/user/client-react/signUp/components/RegisterView.native.tsx @@ -0,0 +1,69 @@ +import React from 'react'; +import { View, Text, StyleSheet } from 'react-native'; + +import { Button, primary } from '@restapp/look-client-react-native'; +import { TranslateFunction, translate } from '@restapp/i18n-client-react'; + +import RegisterForm from './RegisterForm.native'; +import { RegisterSubmitProps } from '../types'; + +interface RegisterViewProps { + t: TranslateFunction; + onSubmit: (values: RegisterSubmitProps) => void; + isRegistered: boolean; + hideModal: () => void; +} + +class RegisterView extends React.PureComponent { + public renderModal = () => { + const { t, hideModal } = this.props; + return ( + + {t('reg.confirmationMsgTitle')} + {t('reg.confirmationMsgBody')} + + + + + ); + }; + + public render() { + const { onSubmit, isRegistered } = this.props; + return ( + {isRegistered ? this.renderModal() : } + ); + } +} + +const styles = StyleSheet.create({ + container: { + justifyContent: 'center', + alignItems: 'stretch', + flex: 1 + }, + modalWrapper: { + flex: 1, + justifyContent: 'center', + alignItems: 'stretch', + margin: 20 + }, + modalTitle: { + fontSize: 18, + fontWeight: 'bold', + marginBottom: 20, + textAlign: 'center' + }, + modalBody: { + fontSize: 16, + marginBottom: 20 + }, + button: { + flex: 1, + paddingTop: 10 + } +}); + +export default translate('userSignUp')(RegisterView); diff --git a/modules/user/client-react/signUp/components/RegisterView.tsx b/modules/user/client-react/signUp/components/RegisterView.tsx new file mode 100644 index 0000000..efceff5 --- /dev/null +++ b/modules/user/client-react/signUp/components/RegisterView.tsx @@ -0,0 +1,41 @@ +import React from 'react'; + +import { translate, TranslateFunction } from '@restapp/i18n-client-react'; +import { LayoutCenter, PageLayout, Card, CardGroup, CardTitle, CardText, MetaData } from '@restapp/look-client-react'; + +import RegisterForm from './RegisterForm'; +import settings from '../../../../../settings'; +import { RegisterSubmitProps } from '../types'; + +interface RegisterViewProps { + t: TranslateFunction; + onSubmit: (values: RegisterSubmitProps) => void; + isRegistered: boolean; +} + +const RegisterView = ({ t, onSubmit, isRegistered }: RegisterViewProps) => { + const renderConfirmationModal = () => ( + + + {t('reg.confirmationMsgTitle')} + {t('reg.confirmationMsgBody')} + + + ); + + return ( + + + +

{t('reg.form.title')}

+ {isRegistered && settings.auth.password.requireEmailConfirmation ? ( + renderConfirmationModal() + ) : ( + + )} +
+
+ ); +}; + +export default translate('userSignUp')(RegisterView); diff --git a/modules/user/client-react/signUp/components/ResetPasswordForm.native.tsx b/modules/user/client-react/signUp/components/ResetPasswordForm.native.tsx new file mode 100644 index 0000000..2f4243e --- /dev/null +++ b/modules/user/client-react/signUp/components/ResetPasswordForm.native.tsx @@ -0,0 +1,84 @@ +import React from 'react'; +import { withFormik, FormikProps } from 'formik'; +import { View, StyleSheet } from 'react-native'; +import KeyboardSpacer from 'react-native-keyboard-spacer'; + +import { FieldAdapter as Field } from '@restapp/forms-client-react'; +import { translate } from '@restapp/i18n-client-react'; +import { RenderField, Button, primary } from '@restapp/look-client-react-native'; +import { placeholderColor, submit } from '@restapp/look-client-react-native/styles'; +import { required, minLength, validate, match } from '@restapp/validation-common-react'; + +import settings from '../../../../../settings'; +import { CommonProps, FormProps } from '../../types'; +import { ResetPasswordSubmitProps } from '../types'; + +interface ResetPasswordFormProps extends CommonProps, FormProps {} + +const resetPasswordFormSchema = { + password: [required, minLength(settings.auth.password.minLength)], + passwordConfirmation: [match('password'), required, minLength(settings.auth.password.minLength)] +}; + +const ResetPasswordForm: React.FunctionComponent> = ({ + values, + handleSubmit, + t +}) => { + return ( + + + + + + + + + ); +}; + +const ResetPasswordFormWithFormik = withFormik({ + enableReinitialize: true, + mapPropsToValues: () => ({ password: '', passwordConfirmation: '' }), + async handleSubmit(values, { setErrors, resetForm, props: { onSubmit } }) { + await onSubmit(values) + .then(() => resetForm()) + .catch((e: any) => { + if (e && e.errors) { + setErrors(e.errors); + } else { + throw e; + } + }); + }, + validate: values => validate(values, resetPasswordFormSchema), + displayName: 'LoginForm' // helps with React DevTools +}); + +const styles = StyleSheet.create({ + submit, + formContainer: { + paddingHorizontal: 20, + justifyContent: 'center' + } +}); + +export default translate('userSignUp')(ResetPasswordFormWithFormik(ResetPasswordForm)); diff --git a/modules/user/client-react/signUp/components/ResetPasswordForm.tsx b/modules/user/client-react/signUp/components/ResetPasswordForm.tsx new file mode 100644 index 0000000..474f957 --- /dev/null +++ b/modules/user/client-react/signUp/components/ResetPasswordForm.tsx @@ -0,0 +1,66 @@ +import React from 'react'; +import { withFormik, FormikProps } from 'formik'; + +import { FieldAdapter as Field } from '@restapp/forms-client-react'; +import { translate } from '@restapp/i18n-client-react'; +import { required, minLength, validate, match } from '@restapp/validation-common-react'; +import { Form, RenderField, Button, Alert } from '@restapp/look-client-react'; + +import settings from '../../../../../settings'; +import { CommonProps, FormProps } from '../../types'; +import { ResetPasswordSubmitProps } from '../types'; + +interface ResetPasswordFormProps extends CommonProps, FormProps {} + +const resetPasswordFormSchema = { + password: [required, minLength(settings.auth.password.minLength)], + passwordConfirmation: [match('password'), required, minLength(settings.auth.password.minLength)] +}; + +const ResetPasswordForm: React.FunctionComponent> = ({ + values, + handleSubmit, + errors, + t +}) => ( +
+ + + {errors && errors.message && {errors.message}} + + +); + +const ResetPasswordFormWithFormik = withFormik({ + enableReinitialize: true, + mapPropsToValues: () => ({ password: '', passwordConfirmation: '' }), + async handleSubmit(values, { setErrors, resetForm, props: { onSubmit } }) { + await onSubmit(values) + .then(() => resetForm()) + .catch((e: any) => { + if (e && e.errors) { + setErrors(e.errors); + } else { + throw e; + } + }); + }, + validate: values => validate(values, resetPasswordFormSchema), + displayName: 'LoginForm' // helps with React DevTools +}); + +export default translate('userSignUp')(ResetPasswordFormWithFormik(ResetPasswordForm)); diff --git a/modules/user/client-react/signUp/components/ResetPasswordView.native.tsx b/modules/user/client-react/signUp/components/ResetPasswordView.native.tsx new file mode 100644 index 0000000..0864c70 --- /dev/null +++ b/modules/user/client-react/signUp/components/ResetPasswordView.native.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { View, StyleSheet } from 'react-native'; + +import ResetPasswordForm from './ResetPasswordForm.native'; +import { ResetPasswordSubmitProps } from '../types'; + +interface ResetPasswordViewProps { + onSubmit: (values: ResetPasswordSubmitProps) => void; +} + +const ResetPasswordView: React.FunctionComponent = ({ onSubmit }) => ( + + + +); + +const styles = StyleSheet.create({ + container: { + justifyContent: 'center', + alignItems: 'stretch', + flex: 1 + } +}); + +export default ResetPasswordView; diff --git a/modules/user/client-react/signUp/components/ResetPasswordView.tsx b/modules/user/client-react/signUp/components/ResetPasswordView.tsx new file mode 100644 index 0000000..5885828 --- /dev/null +++ b/modules/user/client-react/signUp/components/ResetPasswordView.tsx @@ -0,0 +1,23 @@ +import React from 'react'; + +import { PageLayout, MetaData } from '@restapp/look-client-react'; + +import ResetPasswordForm from './ResetPasswordForm'; +import { CommonProps } from '../../types'; +import { ResetPasswordSubmitProps } from '../types'; + +interface ResetPasswordViewProps extends CommonProps { + onSubmit: (values: ResetPasswordSubmitProps) => void; +} + +const ResetPasswordView: React.FunctionComponent = ({ t, onSubmit }) => { + return ( + + +

{t('resetPass.form.title')}

+ +
+ ); +}; + +export default ResetPasswordView; diff --git a/modules/user/client-react/signUp/containers/ForgotPassword.native.tsx b/modules/user/client-react/signUp/containers/ForgotPassword.native.tsx new file mode 100644 index 0000000..90535ce --- /dev/null +++ b/modules/user/client-react/signUp/containers/ForgotPassword.native.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { connect } from 'react-redux'; + +import { translate } from '@restapp/i18n-client-react'; +import { FormError } from '@restapp/forms-client-react'; + +import ForgotPasswordView from '../components/ForgotPasswordView.native'; +import { CommonProps, User } from '../../types'; +import { ForgotPasswordSubmitProps } from '../types'; +import { forgotPassword } from '../actions'; + +interface ForgotPasswordProps extends CommonProps { + forgotPassword?: (values: ForgotPasswordSubmitProps) => any; +} + +class ForgotPassword extends React.Component { + public state = { + sent: false + }; + + public onSubmit = async (values: User) => { + const { t, forgotPassword: actionForgotPassword } = this.props; + + this.setState({ sent: true }); + try { + await actionForgotPassword(values); + } catch (e) { + throw new FormError(t('forgotPass.errorMsg'), e); + } + }; + + public render() { + const { sent } = this.state; + + return ; + } +} + +export default connect<{}, {}, ForgotPasswordProps>( + null, + { forgotPassword } +)(translate('userSignUp')(ForgotPassword)); diff --git a/modules/user/client-react/signUp/containers/ForgotPassword.tsx b/modules/user/client-react/signUp/containers/ForgotPassword.tsx new file mode 100644 index 0000000..b4a5407 --- /dev/null +++ b/modules/user/client-react/signUp/containers/ForgotPassword.tsx @@ -0,0 +1,36 @@ +import React, { useState } from 'react'; +import { connect } from 'react-redux'; + +import { translate } from '@restapp/i18n-client-react'; +import { FormError } from '@restapp/forms-client-react'; + +import ForgotPasswordView from '../components/ForgotPasswordView'; +import { CommonProps } from '../../types'; +import { ForgotPasswordSubmitProps } from '../types'; +import { forgotPassword } from '../actions'; + +interface ForgotPasswordProps extends CommonProps { + forgotPassword: (values: ForgotPasswordSubmitProps) => any; +} + +const ForgotPassword: React.FunctionComponent = props => { + const { t, forgotPassword: actionForgotPassword } = props; + + const [sent, setSent] = useState(false); + + const onSubmit = async (values: ForgotPasswordSubmitProps) => { + setSent(true); + try { + await actionForgotPassword(values); + } catch (e) { + throw new FormError(t('forgotPass.errorMsg'), e); + } + }; + + return ; +}; + +export default connect( + null, + { forgotPassword } +)(translate('userSignUp')(ForgotPassword)); diff --git a/modules/user/client-react/signUp/containers/Login.native.tsx b/modules/user/client-react/signUp/containers/Login.native.tsx new file mode 100644 index 0000000..1ce148b --- /dev/null +++ b/modules/user/client-react/signUp/containers/Login.native.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import { connect } from 'react-redux'; + +import { FormError } from '@restapp/forms-client-react'; +import { translate } from '@restapp/i18n-client-react'; +import authentication from '@restapp/authentication-client-react'; + +import LoginView from '../components/LoginView.native'; +import { CommonProps } from '../../types'; +import { LoginSubmitProps } from '../types'; +import { login } from '../actions'; + +export interface LoginProps extends CommonProps { + login?: (values: LoginSubmitProps) => Promise | any; +} + +class Login extends React.Component { + public onSubmit = async (values: LoginSubmitProps) => { + const { t, login: actionLogin } = this.props; + + try { + await actionLogin(values); + } catch (e) { + const data = e.response && e.response.data; + throw new FormError(t('reg.errorMsg'), data); + } + + await authentication.doLogin(); + }; + public render() { + return ; + } +} + +export default connect<{}, {}, LoginProps>( + null, + { login } +)(translate('userSignUp')(Login)); diff --git a/modules/user/client-react/signUp/containers/Login.tsx b/modules/user/client-react/signUp/containers/Login.tsx new file mode 100644 index 0000000..83e45ac --- /dev/null +++ b/modules/user/client-react/signUp/containers/Login.tsx @@ -0,0 +1,80 @@ +import React, { useEffect } from 'react'; +import { connect } from 'react-redux'; + +import { translate } from '@restapp/i18n-client-react'; +import authentication from '@restapp/authentication-client-react'; +import { FormError } from '@restapp/forms-client-react'; +import { setItem } from '@restapp/core-common/clientStorage'; + +import LoginView from '../components/LoginView'; +import { CommonProps } from '../../types'; +import { LoginSubmitProps } from '../types'; +import { login } from '../actions'; + +export interface LoginProps extends CommonProps { + login?: (values: LoginSubmitProps) => any; +} + +const Login: React.FunctionComponent = props => { + const { t, history, login: actionLogin } = props; + const { + location: { search } + } = history; + + const [isRegistered, setIsRegistered] = React.useState(false); + const [isReady, setIsReady] = React.useState(false); + + useEffect(() => { + if (search.includes('data')) { + checkAndSaveTokens(); + } + }, []); + + useEffect(() => { + if (search.includes('email-verified')) { + setIsRegistered(true); + } + setIsReady(true); + }, []); + + const checkAndSaveTokens = async () => { + const dataRegExp = /data=([^#]+)/; + + const [, data] = search.match(dataRegExp); + const decodedData = JSON.parse(decodeURI(data)); + + if (decodedData.tokens) { + await setItem('accessToken', decodedData.tokens.accessToken); + await setItem('refreshToken', decodedData.tokens.refreshToken); + + await authentication.doLogin(); + } + + history.push('profile'); + }; + + const hideModal = () => { + setIsRegistered(false); + history.push({ search: '' }); + }; + + const onSubmit = async (values: LoginSubmitProps) => { + try { + await actionLogin(values); + } catch (e) { + const data = e.response && e.response.data; + + throw new FormError(t('reg.errorMsg'), data); + } + + await authentication.doLogin(); + history.push('/profile'); + }; + + return isReady && ; +}; + +export default connect( + null, + { login } +)(translate('userSignUp')(Login)); diff --git a/modules/user/client-react/signUp/containers/Logout.native.tsx b/modules/user/client-react/signUp/containers/Logout.native.tsx new file mode 100644 index 0000000..98996e4 --- /dev/null +++ b/modules/user/client-react/signUp/containers/Logout.native.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { View } from 'react-native'; + +import { HeaderTitle } from '@restapp/look-client-react-native'; +import { translate } from '@restapp/i18n-client-react'; + +import { withLogout } from '../../containers/Auth'; +import { CommonProps } from '../../types'; + +interface LogoutViewProps extends CommonProps { + logout: () => void; +} + +const LogoutView = ({ logout, t }: LogoutViewProps) => { + return ( + + { + await logout(); + }} + > + {t('mobile.logout')} + + + ); +}; + +export default translate('userSignUp')(withLogout(LogoutView)); diff --git a/modules/user/client-react/signUp/containers/Logout.tsx b/modules/user/client-react/signUp/containers/Logout.tsx new file mode 100644 index 0000000..dfb2d5f --- /dev/null +++ b/modules/user/client-react/signUp/containers/Logout.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { withRouter } from 'react-router-dom'; + +import { withLogout, WithLogoutProps } from '../../containers/Auth'; +import { translate } from 'react-i18next'; + +const LogoutLink = ({ logout, history, t }: WithLogoutProps) => ( + { + e.preventDefault(); + (async () => { + await logout(); + history.push('/'); + })(); + }} + className="nav-link" + > + {t('logout')} + +); + +export default withRouter(withLogout(translate('userSignUp')(LogoutLink)) as any); diff --git a/modules/user/client-react/signUp/containers/NavLoginLink.tsx b/modules/user/client-react/signUp/containers/NavLoginLink.tsx new file mode 100644 index 0000000..604d14d --- /dev/null +++ b/modules/user/client-react/signUp/containers/NavLoginLink.tsx @@ -0,0 +1,13 @@ +import React from 'react'; +import { NavLink } from 'react-router-dom'; + +import { translate } from '@restapp/i18n-client-react'; +import { CommonProps } from '../../types'; + +const NavLinkLoginWithI18n = ({ t }: CommonProps) => ( + + {t('navLink.signIn')} + +); + +export default translate('userSignUp')(NavLinkLoginWithI18n); diff --git a/modules/user/client-react/signUp/containers/Register.native.tsx b/modules/user/client-react/signUp/containers/Register.native.tsx new file mode 100644 index 0000000..b12774d --- /dev/null +++ b/modules/user/client-react/signUp/containers/Register.native.tsx @@ -0,0 +1,62 @@ +import React from 'react'; +import { connect } from 'react-redux'; + +import { translate } from '@restapp/i18n-client-react'; +import { FormError } from '@restapp/forms-client-react'; + +import RegisterView from '../components/RegisterView.native'; +import settings from '../../../../../settings'; +import { register } from '../actions'; +import { CommonProps } from '../../types'; +import { RegisterSubmitProps } from '../types'; + +interface RegisterProps extends CommonProps { + register?: (values: RegisterSubmitProps) => any; +} + +interface RegisterState { + isRegistered: boolean; +} + +class Register extends React.Component { + public state = { + isRegistered: false + }; + + public onSubmit = async (values: RegisterSubmitProps) => { + const { t, register: actionRegister, navigation } = this.props; + + try { + await await actionRegister(values); + } catch (e) { + const data = e.response && e.response.data; + throw new FormError(t('reg.errorMsg'), data); + } + + if (!settings.auth.password.requireEmailConfirmation) { + navigation.goBack(); + } else { + this.setState({ isRegistered: true }); + } + }; + + public hideModal = () => { + this.props.navigation.goBack(); + }; + + public toggleModal = () => { + this.setState(prevState => ({ isRegistered: !prevState.isRegistered })); + }; + + public render() { + const { isRegistered } = this.state; + return ( + + ); + } +} + +export default connect<{}, {}, RegisterProps>( + null, + { register } +)(translate('userSignUp')(Register)); diff --git a/modules/user/client-react/signUp/containers/Register.tsx b/modules/user/client-react/signUp/containers/Register.tsx new file mode 100644 index 0000000..40d839e --- /dev/null +++ b/modules/user/client-react/signUp/containers/Register.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { connect } from 'react-redux'; + +import { translate } from '@restapp/i18n-client-react'; +import { FormError } from '@restapp/forms-client-react'; + +import RegisterView from '../components/RegisterView'; +import { CommonProps } from '../../types'; +import { RegisterSubmitProps } from '../types'; +import { register } from '../actions'; +import settings from '../../../../../settings'; + +interface RegisterProps extends CommonProps { + register: (values: RegisterSubmitProps) => any; +} + +const Register: React.FunctionComponent = props => { + const { t, register: actionRegister, history } = props; + + const [isRegistered, setIsRegistered] = React.useState(false); + + const onSubmit = async (values: RegisterSubmitProps) => { + try { + await await actionRegister(values); + } catch (e) { + const data = e.response && e.response.data; + throw new FormError(t('reg.errorMsg'), data); + } + + if (!settings.auth.password.requireEmailConfirmation) { + history.push('/'); + } else { + setIsRegistered(true); + } + }; + + return ; +}; + +export default connect( + null, + { register } +)(translate('userSignUp')(Register)); diff --git a/modules/user/client-react/signUp/containers/ResetPassword.native.tsx b/modules/user/client-react/signUp/containers/ResetPassword.native.tsx new file mode 100644 index 0000000..227d410 --- /dev/null +++ b/modules/user/client-react/signUp/containers/ResetPassword.native.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { connect } from 'react-redux'; + +import { translate } from '@restapp/i18n-client-react'; +import { FormError } from '@restapp/forms-client-react'; + +import ResetPasswordView from '../components/ResetPasswordView.native'; +import { resetPassword } from '../actions'; +import { CommonProps } from '../../types'; +import { ResetPasswordSubmitProps } from '../types'; + +interface Token { + token: string; +} + +interface ResetPasswordProps extends CommonProps { + resetPassword?: (value: ResetPasswordSubmitProps & Token) => void; + match?: any; +} + +const ResetPassword: React.FunctionComponent = props => { + const { t, resetPassword: actionResetPassword, navigation, match } = props; + + const onSubmit = async (values: ResetPasswordSubmitProps) => { + try { + await actionResetPassword({ ...values, token: match.params.token }); + } catch (e) { + throw new FormError(t('resetPass.errorMsg'), e); + } + navigation.navigate('Login'); + }; + + return ; +}; + +export default connect<{}, {}, ResetPasswordProps>( + null, + { resetPassword } +)(translate('userSignUp')(ResetPassword)); diff --git a/modules/user/client-react/signUp/containers/ResetPassword.tsx b/modules/user/client-react/signUp/containers/ResetPassword.tsx new file mode 100644 index 0000000..976c4dc --- /dev/null +++ b/modules/user/client-react/signUp/containers/ResetPassword.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import { connect } from 'react-redux'; + +import { translate } from '@restapp/i18n-client-react'; +import { FormError } from '@restapp/forms-client-react'; + +import ResetPasswordView from '../components/ResetPasswordView'; + +import { ResetPasswordSubmitProps } from '../types'; +import { resetPassword } from '../actions'; +import { CommonProps } from '../../types'; + +interface Token { + token: string; +} + +interface ResetPasswordProps extends CommonProps { + resetPassword: (value: ResetPasswordSubmitProps & Token) => void; + match: any; +} + +const ResetPassword: React.FunctionComponent = props => { + const { t, resetPassword: actionResetPassword, history, match } = props; + + const onSubmit = async (values: ResetPasswordSubmitProps) => { + try { + await actionResetPassword({ ...values, token: match.params.token }); + } catch (e) { + throw new FormError(t('resetPass.errorMsg'), e); + } + history.push('/login'); + }; + + return ; +}; + +export default connect( + null, + { resetPassword } +)(translate('userSignUp')(ResetPassword)); diff --git a/modules/user/client-react/signUp/containers/index.ts b/modules/user/client-react/signUp/containers/index.ts new file mode 100644 index 0000000..4ccd7c4 --- /dev/null +++ b/modules/user/client-react/signUp/containers/index.ts @@ -0,0 +1,6 @@ +export { default as Login } from './Login'; +export { default as Logout } from './Logout'; +export { default as ForgotPassword } from './ForgotPassword'; +export { default as NavLoginLink } from './NavLoginLink'; +export { default as Register } from './Register'; +export { default as ResetPassword } from './ResetPassword'; diff --git a/modules/user/client-react/signUp/index.native.tsx b/modules/user/client-react/signUp/index.native.tsx new file mode 100644 index 0000000..c811e93 --- /dev/null +++ b/modules/user/client-react/signUp/index.native.tsx @@ -0,0 +1,106 @@ +import React from 'react'; +import { createStackNavigator } from 'react-navigation'; + +import { translate } from '@restapp/i18n-client-react'; +import { HeaderTitle, IconButton } from '@restapp/look-client-react-native'; +import ClientModule from '@restapp/module-client-react-native'; + +import resources from './locales'; +import Login from './containers/Login.native'; +import Logout from './containers/Logout.native'; +import Register from './containers/Register.native'; +import ForgotPassword from './containers/ForgotPassword.native'; +import ResetPassword from './containers/ResetPassword.native'; +import { NavigationOptionsProps } from '../types'; +import signUpReducer from './reducers'; + +class LoginScreen extends React.Component { + public static navigationOptions = ({ navigation }: NavigationOptionsProps) => ({ + headerTitle: , + headerLeft: ( + navigation.openDrawer()} /> + ), + headerForceInset: {} + }); + + public render() { + return ; + } +} +class RegisterScreen extends React.Component { + public static navigationOptions = () => ({ + headerTitle: , + headerForceInset: {} + }); + public render() { + return ; + } +} + +class ForgotPasswordScreen extends React.Component { + public static navigationOptions = () => ({ + headerTitle: , + headerForceInset: {} + }); + public render() { + return ; + } +} + +class ResetPasswordScreen extends React.Component { + public static navigationOptions = () => ({ + headerTitle: , + headerForceInset: {} + }); + public render() { + return ; + } +} + +const AuthScreen = createStackNavigator( + { + Login: { screen: LoginScreen }, + ForgotPassword: { screen: ForgotPasswordScreen }, + ResetPassword: { screen: ResetPasswordScreen }, + Register: { screen: RegisterScreen } + }, + { + cardStyle: { + backgroundColor: '#fff' + }, + navigationOptions: { + headerStyle: { backgroundColor: '#fff' } + } + } +); + +const HeaderTitleWithI18n = translate('userSignUp')(HeaderTitle); + +export default new ClientModule({ + drawerItem: [ + { + Login: { + screen: AuthScreen, + userInfo: { + showOnLogin: false + }, + navigationOptions: { + drawerLabel: + } + }, + Logout: { + screen: (): null => null, + userInfo: { + showOnLogin: true + }, + navigationOptions: ({ navigation }: NavigationOptionsProps) => { + return { + drawerLabel: + }; + } + } + } + ], + localization: [{ ns: 'userSignUp', resources }], + reducer: [{ signUpReducer }] +}); diff --git a/modules/user/client-react/signUp/index.tsx b/modules/user/client-react/signUp/index.tsx new file mode 100644 index 0000000..8e589f1 --- /dev/null +++ b/modules/user/client-react/signUp/index.tsx @@ -0,0 +1,33 @@ +import React from 'react'; + +import { MenuItem } from '@restapp/look-client-react'; +import ClientModule from '@restapp/module-client-react'; + +import { Login, Register, ForgotPassword, ResetPassword, Logout, NavLoginLink } from './containers'; +import signUpReducer from './reducers'; +import resources from './locales'; + +import { AuthRoute, IfLoggedIn, IfNotLoggedIn } from '../containers/Auth'; + +export default new ClientModule({ + route: [ + , + , + , + + ], + navItemRight: [ + + + + + , + + + + + + ], + localization: [{ ns: 'userSignUp', resources }], + reducer: [{ signUpReducer }] +}); diff --git a/modules/user/client-react/signUp/locales/en/translations.json b/modules/user/client-react/signUp/locales/en/translations.json new file mode 100644 index 0000000..985cb0e --- /dev/null +++ b/modules/user/client-react/signUp/locales/en/translations.json @@ -0,0 +1,92 @@ +{ + "loading": "App is loading...", + "navLink": { + "signIn": "Sign In", + "logout": "Log out", + "forgotPassword": "Forgot password", + "register": "Register", + "resetPassword": "Reset password" + }, + "login": { + "title": "Login", + "meta": "A Login page example", + "errorMsg": "Login failed!", + "cardTitle": "Available logins", + "notRegText": "Not registered yet?", + "fbBtn": "Log in with Facebook", + "googleBtn": "Log in with Google", + "githubBtn": "Log in with GitHub", + "linkedinBtn": "Log in with LinkedIn", + "form": { + "title": "Sign In", + "field": { + "usernameOrEmail": "Username or Email", + "pass": "Password" + }, + "btnSubmit": "Login" + }, + "btn": { + "forgotPass": "Forgot your password?", + "notReg": "Not registered yet?", + "sign": "Sign Up" + } + }, + "logout": "Log out", + "reg": { + "title": "Register", + "meta": "A Registration page example", + "errorMsg": "Registration failed!", + "form": { + "title": "Sign Up", + "field": { + "name": "Username", + "email": "Email", + "pass": "Password", + "passConf": "Password Confirmation" + }, + "btnSubmit": "Register" + }, + "confirmationMsgTitle": "Verify Your Email Address", + "confirmationMsgBody": "We now need to verify your email address. We've sent you the email. Please click the link in that email to continue. ", + "confirmationBtn": "Go back to Login page", + "successRegTitle": "Email Has Been Verified Successfully", + "successRegBody": "Your email has been verified successfully. You can sign in using your login and password." + }, + "resetPass": { + "title": "Reset password", + "meta": "A Reset Password page example", + "errorMsg": "Password reset failed!", + "form": { + "title": "Reset Password", + "field": { + "pass": "Password", + "passConf": "Password Confirmation" + }, + "btnSubmit": "Reset password" + } + }, + "forgotPass": { + "title": "Forgot password", + "meta": "A Forgot Password page example", + "errorMsg": "Reset password failed!", + "form": { + "title": "Password Reset", + "submitMsg": "Instructions for resetting your password were emailed to you.", + "fldEmail": "Email", + "btnSubmit": "Send instructions" + } + }, + "mobile": { + "login": { + "usernameOrEmail": { + "label": "Username or Email", + "placeholder": "Enter Your Username or Email" + }, + "pass": { + "label": "Password", + "placeholder": "Enter Your Password" + } + }, + "logout": "Log out" + } +} \ No newline at end of file diff --git a/modules/user/client-react/signUp/locales/index.ts b/modules/user/client-react/signUp/locales/index.ts new file mode 100644 index 0000000..ff8b4c5 --- /dev/null +++ b/modules/user/client-react/signUp/locales/index.ts @@ -0,0 +1 @@ +export default {}; diff --git a/modules/user/client-react/signUp/locales/ru/translations.json b/modules/user/client-react/signUp/locales/ru/translations.json new file mode 100644 index 0000000..a69180c --- /dev/null +++ b/modules/user/client-react/signUp/locales/ru/translations.json @@ -0,0 +1,92 @@ +{ + "loading": "Приложение загружается...", + "navLink": { + "signIn": "Вход в систему", + "logout": "Выход", + "forgotPassword": "Забыли пароль", + "register": "Регистрация", + "resetPassword": "Сброс пароля" + }, + "forgotPass": { + "title": "Забыли пароль", + "meta": "Пример страницы Забыли пароль?", + "errorMsg": "Сброс пароля не выполнен!", + "form": { + "title": "Сброс пароля", + "submitMsg": "Инструкции по сбросу пароля были отправлены вам на электронную почту.", + "fldEmail": "Электронная почта", + "btnSubmit": "Отправить инструкции" + } + }, + "login": { + "title": "Вход", + "meta": "Пример страницы с входом", + "errorMsg": "Ошибка входа!", + "cardTitle": "Доступные учетные записи", + "notRegText": "Ещё не зарегистрированы?", + "fbBtn": "Войти через Facebook", + "googleBtn": "Войти через Google", + "githubBtn": "Войти через GitHub", + "linkedinBtn": "Войти через LinkedIn", + "form": { + "title": "Вход в систему", + "field": { + "usernameOrEmail": "Имя пользователя или эл.почта", + "pass": "Пароль" + }, + "btnSubmit": "Войти" + }, + "btn": { + "forgotPass": "Забыли пароль?", + "notReg": "Ещё не зарегистрированы?", + "sign": "Зарегистрироваться" + } + }, + "logout": "Выход", + "reg": { + "title": "Регистрация", + "meta": "Пример страницы с регистрацией", + "errorMsg": "Ошибка регистрации!", + "form": { + "title": "Регистрация", + "field": { + "name": "Имя пользователя", + "email": "Электронная почта", + "pass": "Пароль", + "passConf": "Подтверждение пароля" + }, + "btnSubmit": "Зарегистрироваться" + }, + "confirmationMsgTitle": "Подтвердите свой адрес электронной почты", + "confirmationMsgBody": "Теперь нам нужно подтвердить ваш адрес электронной почты. Мы отправили вам электронное письмо. Пожалуйста, нажмите на ссылку в этом письме, чтобы продолжить.", + "confirmationBtn": "Вернуться на страница регистрации", + "successRegTitle": "Адрес электронной почты был успешно подтвержден", + "successRegBody": "Ваш адрес электронной почты был успешно подтвержден. Вы можете войти в приложение, используя свой логин и пароль." + }, + "resetPass": { + "title": "Сброс пароля", + "meta": "Пример страницы со сбросом пароля", + "errorMsg": "Сброс пароля не выполнен!", + "form": { + "title": "Сброс пароля", + "field": { + "pass": "Пароля", + "passConf": "Подтверждение пароля" + }, + "btnSubmit": "Сбросить пароль" + } + }, + "mobile": { + "login": { + "usernameOrEmail": { + "label": "Имя пользователя или email", + "placeholder": "Введите имя пользователя или эл.почту" + }, + "pass": { + "label": "Пароль", + "placeholder": "Введите пароль" + } + }, + "logout": "Выйти" + } +} \ No newline at end of file diff --git a/modules/user/client-react/signUp/reducers/index.ts b/modules/user/client-react/signUp/reducers/index.ts new file mode 100644 index 0000000..3ac4888 --- /dev/null +++ b/modules/user/client-react/signUp/reducers/index.ts @@ -0,0 +1,53 @@ +import { User } from '../../types'; + +export interface SignUpActionProps { + type: ActionType | ActionType[]; + payload?: any; + APICall?: () => Promise; + [key: string]: any; +} + +export enum ActionType { + SET_CURRENT_USER = 'SET_CURRENT_USER', + CLEAR_CURRENT_USER = 'CLEAR_CURRENT_USER', + SET_LOADING_AND_CLEAR_USER = 'SET_LOADING_AND_CLEAR_USER' +} + +export interface CurrentUserState { + currentUser: User; + loading: boolean; +} + +const defaultState: CurrentUserState = { + currentUser: undefined, + loading: false +}; + +export default function(state = defaultState, action: SignUpActionProps) { + switch (action.type) { + case ActionType.SET_LOADING_AND_CLEAR_USER: + return { + ...state, + currentUser: null, + loading: true + }; + + case ActionType.CLEAR_CURRENT_USER: + return { + ...state, + currentUser: null, + loading: false + }; + + case ActionType.SET_CURRENT_USER: + const currentUser = action.payload && action.payload.errors ? null : action.payload.user || action.payload; + return { + ...state, + currentUser, + loading: false + }; + + default: + return state; + } +} diff --git a/modules/user/client-react/signUp/types/index.ts b/modules/user/client-react/signUp/types/index.ts new file mode 100644 index 0000000..53aa9f3 --- /dev/null +++ b/modules/user/client-react/signUp/types/index.ts @@ -0,0 +1,19 @@ +export interface LoginSubmitProps { + usernameOrEmail: string; + password: string; +} + +export interface RegisterSubmitProps { + username: string; + email: string; + password: string; + passwordConfirmation: string; +} + +export interface ResetPasswordSubmitProps { + password: string; + passwordConfirmation: string; +} +export interface ForgotPasswordSubmitProps { + email: string; +} diff --git a/modules/user/client-react/types/index.ts b/modules/user/client-react/types/index.ts new file mode 100644 index 0000000..57efddf --- /dev/null +++ b/modules/user/client-react/types/index.ts @@ -0,0 +1,65 @@ +import * as H from 'history'; +import { FormikErrors } from 'formik'; +import { NavigationScreenProp, NavigationRoute, NavigationParams } from 'react-navigation'; + +import { TranslateFunction } from '@restapp/i18n-client-react'; + +export enum UserRole { + admin = 'admin', + user = 'user' +} + +interface Auth { + lnDisplayName: string; + lnId: string; + googleDisplayName: string; + googleId: string; + ghDisplayName: string; + ghId: string; + fbId: string; + fbDisplayName: string; +} + +interface UserProfile { + fullName?: string; + firstName?: string; + lastName?: string; +} + +export interface User extends UserProfile, Auth { + id?: number | string; + username: string; + role: UserRole; + isActive: boolean; + email: string; +} + +export interface CommonProps extends HistoryProp, NavigationOptionsProps { + t?: TranslateFunction; +} + +export interface HistoryProp { + history?: H.History; +} + +export interface NavigationOptionsProps { + navigation?: NavigationScreenProp, NavigationParams>; +} + +export interface FormProps { + handleSubmit: (values: V, props: HandleSubmitProps) => void; + onSubmit: (values: V) => Promise; + submitting?: boolean; + errors: Errors; + values: V; + t?: TranslateFunction; +} + +interface HandleSubmitProps

{ + setErrors: (errors: FormikErrors

) => void; + props: P; +} + +interface Errors { + message?: string; +} diff --git a/modules/user/client-react/users/actions/addUser.ts b/modules/user/client-react/users/actions/addUser.ts new file mode 100644 index 0000000..dc50692 --- /dev/null +++ b/modules/user/client-react/users/actions/addUser.ts @@ -0,0 +1,10 @@ +import axios from 'axios'; +import { User } from '../../types'; +import { ActionType, ActionFunction } from '.'; + +const addUser: ActionFunction = user => ({ + types: {}, + APICall: () => axios.post(`${__API_URL__}/addUser`, { ...user }) +}); + +export default addUser; diff --git a/modules/user/client-react/users/actions/deleteUser.ts b/modules/user/client-react/users/actions/deleteUser.ts new file mode 100644 index 0000000..e74fc52 --- /dev/null +++ b/modules/user/client-react/users/actions/deleteUser.ts @@ -0,0 +1,11 @@ +import axios from 'axios'; +import { ActionType, ActionFunction } from '.'; + +const deleteUser: ActionFunction = id => ({ + types: { + SUCCESS: ActionType.DELETE_USER + }, + APICall: () => axios.delete(`${__API_URL__}/deleteUser`, { data: { id } }) +}); + +export default deleteUser; diff --git a/modules/user/client-react/users/actions/editUser.ts b/modules/user/client-react/users/actions/editUser.ts new file mode 100644 index 0000000..f75cfd3 --- /dev/null +++ b/modules/user/client-react/users/actions/editUser.ts @@ -0,0 +1,10 @@ +import axios from 'axios'; +import { ActionType, ActionFunction } from '.'; +import { User } from '../../types'; + +const editUser: ActionFunction = user => ({ + types: {}, + APICall: () => axios.post(`${__API_URL__}/editUser`, { ...user }) +}); + +export default editUser; diff --git a/modules/user/client-react/users/actions/index.ts b/modules/user/client-react/users/actions/index.ts new file mode 100644 index 0000000..ded0962 --- /dev/null +++ b/modules/user/client-react/users/actions/index.ts @@ -0,0 +1,8 @@ +export { default as addUser } from './addUser'; +export { default as editUser } from './editUser'; +export { default as deleteUser } from './deleteUser'; +export { default as user } from './user'; +export { default as users } from './users'; + +export * from '../../actions'; +export { ActionType } from '../reducers'; diff --git a/modules/user/client-react/users/actions/user.ts b/modules/user/client-react/users/actions/user.ts new file mode 100644 index 0000000..16ee395 --- /dev/null +++ b/modules/user/client-react/users/actions/user.ts @@ -0,0 +1,11 @@ +import axios from 'axios'; +import { ActionFunction, ActionType } from '.'; + +const user: ActionFunction = id => ({ + types: { + SUCCESS: ActionType.SET_USER + }, + APICall: () => axios.get(`${__API_URL__}/user/${id}`) +}); + +export default user; diff --git a/modules/user/client-react/users/actions/users.ts b/modules/user/client-react/users/actions/users.ts new file mode 100644 index 0000000..56f6ffc --- /dev/null +++ b/modules/user/client-react/users/actions/users.ts @@ -0,0 +1,19 @@ +import axios from 'axios'; +import { ActionType, ActionFunction } from '.'; +import { OrderBy, Filter } from '../types'; + +const users: ActionFunction = ( + orderBy: OrderBy, + filter: Filter, + type: ActionType +) => ({ + types: { + REQUEST: type ? ActionType[type] : null, + SUCCESS: ActionType.SET_USERS, + FAIL: ActionType.CLEAR_USERS + }, + payload: { orderBy, filter }, + APICall: () => axios.post(`${__API_URL__}/users`, { filter, orderBy }) +}); + +export default users; diff --git a/modules/user/client-react/users/components/UserAddView.native.tsx b/modules/user/client-react/users/components/UserAddView.native.tsx new file mode 100644 index 0000000..bc73d9a --- /dev/null +++ b/modules/user/client-react/users/components/UserAddView.native.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { StyleSheet, View } from 'react-native'; + +import { translate, TranslateFunction } from '@restapp/i18n-client-react'; + +import UserForm from './UserForm'; +import { User } from '../../types'; + +interface FormValues extends User { + password: string; + passwordConfirmation: string; +} + +interface UserAddViewProps { + t: TranslateFunction; + onSubmit: (values: FormValues) => Promise; +} + +class UserAddView extends React.PureComponent { + public render() { + return ( + + + + ); + } +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'center' + } +}); + +export default translate('userUsers')(UserAddView); diff --git a/modules/user/client-react/users/components/UserAddView.tsx b/modules/user/client-react/users/components/UserAddView.tsx new file mode 100644 index 0000000..7278043 --- /dev/null +++ b/modules/user/client-react/users/components/UserAddView.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; + +import { translate, TranslateFunction } from '@restapp/i18n-client-react'; +import { PageLayout, MetaData } from '@restapp/look-client-react'; + +import UserForm from './UserForm'; +import { User } from '../../types'; + +interface FormValues extends User { + password: string; + passwordConfirmation: string; +} + +interface UserAddViewProps { + t: TranslateFunction; + onSubmit: (values: FormValues) => Promise; +} + +const UserAddView = ({ t, onSubmit }: UserAddViewProps) => { + return ( + + + + Back + +

+ {t('userEdit.form.titleCreate')} {t('userEdit.form.title')} +

+ + + ); +}; + +export default translate('userUsers')(UserAddView); diff --git a/modules/user/client-react/users/components/UserEditView.native.tsx b/modules/user/client-react/users/components/UserEditView.native.tsx new file mode 100644 index 0000000..33f4f75 --- /dev/null +++ b/modules/user/client-react/users/components/UserEditView.native.tsx @@ -0,0 +1,50 @@ +import * as React from 'react'; +import { StyleSheet, View } from 'react-native'; +import { translate, TranslateFunction } from '@restapp/i18n-client-react'; +import { Loading } from '@restapp/look-client-react-native'; + +import UserForm from './UserForm'; +import { User } from '../../types'; + +interface FormValues extends User { + password: string; + passwordConfirmation: string; +} + +interface UserEditViewProps { + loading: boolean; + user: User; + currentUser: User; + t: TranslateFunction; + onSubmit: (values: FormValues) => Promise; +} + +class UserEditView extends React.PureComponent { + public render() { + const { loading, user, t, currentUser } = this.props; + if (loading && !user) { + return ; + } else { + const isNotSelf = !user || (user && user.id !== currentUser.id); + return ( + + + + ); + } + } +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'center' + } +}); + +export default translate('userUsers')(UserEditView); diff --git a/modules/user/client-react/users/components/UserEditView.tsx b/modules/user/client-react/users/components/UserEditView.tsx new file mode 100644 index 0000000..41dbce0 --- /dev/null +++ b/modules/user/client-react/users/components/UserEditView.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; + +import { translate, TranslateFunction } from '@restapp/i18n-client-react'; +import { PageLayout, MetaData } from '@restapp/look-client-react'; + +import UserForm from './UserForm'; +import { User } from '../../types'; + +interface FormValues extends User { + password: string; + passwordConfirmation: string; +} + +interface UserEditViewProps { + loading: boolean; + user: User; + currentUser: User; + t: TranslateFunction; + onSubmit: (values: FormValues) => Promise; +} + +const UserEditView: React.FunctionComponent = ({ loading, user, t, currentUser, onSubmit }) => { + const isNotSelf = !user || (user && user.id !== currentUser.id); + + return ( + + + {loading && !user ? ( +
{t('userEdit.loadMsg')}
+ ) : ( + <> + + Back + +

+ {t('userEdit.form.titleEdit')} {t('userEdit.form.title')} +

+ + + )} +
+ ); +}; + +export default translate('userUsers')(UserEditView); diff --git a/modules/user/client-react/users/components/UserForm.native.tsx b/modules/user/client-react/users/components/UserForm.native.tsx new file mode 100644 index 0000000..781dcdb --- /dev/null +++ b/modules/user/client-react/users/components/UserForm.native.tsx @@ -0,0 +1,208 @@ +import React from 'react'; +import { withFormik, FormikProps } from 'formik'; +import { View, StyleSheet } from 'react-native'; +import KeyboardSpacer from 'react-native-keyboard-spacer'; + +import { FieldAdapter as Field } from '@restapp/forms-client-react'; +import { translate, TranslateFunction } from '@restapp/i18n-client-react'; +import { RenderField, Button, RenderSelect, RenderSwitch, FormView, primary } from '@restapp/look-client-react-native'; +import { placeholderColor, submit } from '@restapp/look-client-react-native/styles'; +import { email as emailRule, minLength, required, match, validate } from '@restapp/validation-common-react'; + +import { FormProps, User, UserRole } from '../../types'; +import settings from '../../../../../settings'; + +interface FormikFormProps extends FormProps { + initialValues: FormValues; +} + +interface UserFormProps extends FormikProps { + handleSubmit: () => void; + t: TranslateFunction; + handleChange: () => void; + setFieldValue: (type: string, value: any) => void; + onSubmit: (values: FormValues) => Promise; + setTouched: () => void; + isValid: boolean; + error: string; + shouldDisplayRole: boolean; + shouldDisplayActive: boolean; + values: any; + errors: any; + initialValues: FormValues; + touched: any; +} + +interface FormValues extends User { + password: string; + passwordConfirmation: string; +} + +type RoleChangeFunc = ( + type: string, + value: string | string[], + setFieldValue: (type: string, value: string) => void +) => void; + +const userFormSchema = { + username: [required, minLength(3)], + email: [required, emailRule], + password: [required, minLength(settings.auth.password.minLength)], + passwordConfirmation: [match('password'), required, minLength(settings.auth.password.minLength)] +}; + +const handleRoleChange: RoleChangeFunc = (type, value, setFieldValue) => { + const preparedValue = Array.isArray(value) ? value[0] : value; + setFieldValue(type, preparedValue); +}; + +const UserForm: React.FunctionComponent = ({ + values, + handleSubmit, + setFieldValue, + t, + shouldDisplayRole, + shouldDisplayActive +}) => { + const options = [ + { + value: 'user', + label: t('userEdit.form.field.role.user') + }, + { + value: 'admin', + label: t('userEdit.form.field.role.admin') + } + ]; + const { username, email, role, isActive, firstName, lastName, password, passwordConfirmation } = values; + return ( + + + + + {shouldDisplayRole && ( + handleRoleChange('role', value, setFieldValue)} + cols={1} + data={options} + /> + )} + {shouldDisplayActive && ( + setFieldValue('isActive', !isActive)} + component={RenderSwitch} + placeholder={t('userEdit.form.field.active')} + checked={isActive} + placeholderTextColor={placeholderColor} + /> + )} + + + + + + + + + + + + ); +}; + +const UserFormWithFormik = withFormik({ + mapPropsToValues: values => { + const { username, email, role, isActive, firstName, lastName, ...rest } = values.initialValues; + return { + username, + email, + role: role || UserRole.user, + isActive, + password: '', + passwordConfirmation: '', + firstName, + lastName, + ...rest + }; + }, + handleSubmit(values, { setErrors, props: { onSubmit } }) { + onSubmit(values).catch((e: any) => { + if (e && e.errors) { + setErrors(e.errors); + } else { + throw e; + } + }); + }, + displayName: 'SignUpForm ', // helps with React DevTools + validate: values => validate(values, userFormSchema) +}); + +const styles = StyleSheet.create({ + formContainer: { + paddingHorizontal: 20, + justifyContent: 'center', + flex: 1 + }, + submit, + formView: { + flex: 1, + alignSelf: 'stretch' + } +}); + +export default translate('userUsers')(UserFormWithFormik(UserForm)); diff --git a/modules/user/client-react/users/components/UserForm.tsx b/modules/user/client-react/users/components/UserForm.tsx new file mode 100644 index 0000000..9ab2a19 --- /dev/null +++ b/modules/user/client-react/users/components/UserForm.tsx @@ -0,0 +1,161 @@ +import React from 'react'; +import { withFormik, FormikProps } from 'formik'; +import { isEmpty } from 'lodash'; + +import { FieldAdapter as Field } from '@restapp/forms-client-react'; +import { translate } from '@restapp/i18n-client-react'; +import { email as emailRule, minLength, required, match, validate } from '@restapp/validation-common-react'; +import { Form, RenderField, RenderSelect, RenderCheckBox, Option, Button, Alert } from '@restapp/look-client-react'; + +import settings from '../../../../../settings'; +import { CommonProps, FormProps, User, UserRole } from '../../types'; + +interface FormikFormProps extends FormProps { + initialValues: FormValues; +} + +interface UserFormProps extends CommonProps, FormikProps { + handleSubmit: () => void; + handleChange: () => void; + setFieldValue: (name: string, value: any) => void; + onSubmit: (values: FormValues) => Promise; + setTouched: () => void; + isValid: boolean; + shouldDisplayRole: boolean; + shouldDisplayActive: boolean; + values: any; + errors: any; + initialValues: FormValues; +} + +interface FormValues extends User { + password: string; + passwordConfirmation: string; +} + +const userFormSchema = { + username: [required, minLength(3)], + email: [required, emailRule] +}; + +const createUserFormSchema = { + ...userFormSchema, + password: [required, minLength(settings.auth.password.minLength)], + passwordConfirmation: [required, match('password'), minLength(settings.auth.password.minLength)] +}; + +const updateUserFormSchema = { + ...userFormSchema, + password: minLength(settings.auth.password.minLength), + passwordConfirmation: [match('password'), minLength(settings.auth.password.minLength)] +}; + +const UserForm: React.FunctionComponent = ({ + values, + handleSubmit, + errors, + t, + shouldDisplayRole, + shouldDisplayActive +}) => { + const { username, email, role, isActive, lastName, firstName, password, passwordConfirmation } = values; + + return ( +
+ + + {shouldDisplayRole && ( + + + + + )} + {shouldDisplayActive && ( + + )} + + + + + {errors && errors.errorMsg && {errors.errorMsg}} + + + ); +}; + +const UserFormWithFormik = withFormik({ + mapPropsToValues: values => { + const { username, email, role, isActive, firstName, lastName, ...rest } = values.initialValues; + return { + username, + email, + role: role || UserRole.user, + isActive, + password: '', + passwordConfirmation: '', + firstName, + lastName, + ...rest + }; + }, + async handleSubmit(values, { setErrors, props: { onSubmit } }) { + await onSubmit(values).catch((e: any) => { + if (e && e.errors) { + setErrors(e.errors); + } else { + throw e; + } + }); + }, + displayName: 'SignUpForm ', // helps with React DevTools + validate: (values, props) => { + const schema: any = isEmpty(props.initialValues) ? createUserFormSchema : updateUserFormSchema; + return validate(values, schema); + } +}); + +export default translate('userUsers')(UserFormWithFormik(UserForm)); diff --git a/modules/user/client-react/users/components/UsersFilterView.native.tsx b/modules/user/client-react/users/components/UsersFilterView.native.tsx new file mode 100644 index 0000000..1d08050 --- /dev/null +++ b/modules/user/client-react/users/components/UsersFilterView.native.tsx @@ -0,0 +1,245 @@ +import React from 'react'; +import { View, StyleSheet, Text, TouchableOpacity } from 'react-native'; +import { debounce } from 'lodash'; +import { FontAwesome } from '@expo/vector-icons'; + +import { translate, TranslateFunction } from '@restapp/i18n-client-react'; +import { + Select, + SearchBar, + Switch, + List, + ListItem, + success, + danger, + Modal, + Button +} from '@restapp/look-client-react-native'; +import { itemAction, itemContainer, itemTitle } from '@restapp/look-client-react-native/styles'; + +import { OrderBy } from '../index.native'; + +interface UsersFilterViewProps { + searchText: string; + role: string; + isActive: boolean; + onSearchTextChange: (value: string) => void; + onRoleChange: (value: string) => void; + onIsActiveChange: (isActive: boolean) => void; + orderBy: OrderBy; + onOrderBy: (order: OrderBy) => void; + t: TranslateFunction; + filter: any; +} + +interface UsersFilterViewState { + showModal: boolean; + orderBy: OrderBy; +} + +type ListItemProps = (label: string, value: string, idx: number) => JSX.Element; + +class UsersFilterView extends React.PureComponent { + private onChangeTextDelayed: (value: string) => void = null; + constructor(props: UsersFilterViewProps) { + super(props); + this.state = { + showModal: false, + orderBy: { + column: '', + order: '' + } + }; + this.onChangeTextDelayed = debounce(this.handleSearch, 500); + } + + public renderOrderByArrow = (name: string) => { + const { orderBy } = this.state; + + if (orderBy && orderBy.column === name) { + if (orderBy.order === 'desc') { + return ; + } else { + return ; + } + } else { + return ; + } + }; + + public orderBy = (name: string) => { + const { orderBy } = this.state; + + let order = 'asc'; + if (orderBy && orderBy.column === name) { + if (orderBy.order === 'asc') { + order = 'desc'; + } else if (orderBy.order === 'desc') { + return this.setState({ + orderBy: { + column: '', + order: '' + } + }); + } + } + return this.setState({ orderBy: { column: name, order } }); + }; + + public renderListItem: ListItemProps = (label, value, idx) => { + return ( + this.orderBy(value)}> + + + {label} + + {this.renderOrderByArrow(value)} + + + ); + }; + + public renderModalChildren = () => { + const { orderBy, t } = this.props; + + const orderByParams = [ + { + label: t('users.column.name'), + value: 'username' + }, + { + label: t('users.column.email'), + value: 'email' + }, + { + label: t('users.column.role'), + value: 'role' + }, + { + label: t('users.column.active'), + value: 'isActive' + } + ]; + return ( + + + {orderByParams.map((item, idx) => this.renderListItem(item.label, item.value, idx))} + + + + + + + + + ); + }; + + public onOrderBy = () => { + this.props.onOrderBy(this.state.orderBy); + this.setState({ showModal: false }); + }; + + public handleSearch = (text: string) => { + const { onSearchTextChange } = this.props; + onSearchTextChange(text); + }; + + public handleRole = (value: string) => { + const { onRoleChange } = this.props; + onRoleChange(value); + }; + + public handleIsActive = () => { + const { + onIsActiveChange, + filter: { isActive } + } = this.props; + onIsActiveChange(!isActive); + }; + + public render() { + const { + filter: { role, isActive }, + t + } = this.props; + const options = [ + { value: '', label: t('users.list.item.role.all') }, + { value: 'user', label: t('users.list.item.role.user') }, + { value: 'admin', label: t('users.list.item.role.admin') } + ]; + return ( + + + + + + {t('users.list.item.role.label')} + + onRoleChange(e.target.value)}> + + + + + +   + + + + +); + +export default translate('userUsers')(UsersFilterView); diff --git a/modules/user/client-react/users/components/UsersListView.native.tsx b/modules/user/client-react/users/components/UsersListView.native.tsx new file mode 100644 index 0000000..75b4fc5 --- /dev/null +++ b/modules/user/client-react/users/components/UsersListView.native.tsx @@ -0,0 +1,132 @@ +import React from 'react'; +import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { FontAwesome } from '@expo/vector-icons'; + +import { translate, TranslateFunction } from '@restapp/i18n-client-react'; +import { + Button, + Card, + CardItem, + CardLabel, + CardText, + List, + ListItem, + Loading, + primary +} from '@restapp/look-client-react-native'; + +import { NavigationOptionsProps, User } from '../../types'; + +interface UsersListViewProps extends NavigationOptionsProps { + users: User[]; + deleteUser: (id: number | string) => void; + loading: boolean; + t: TranslateFunction; +} + +const UsersListView = ({ users, loading, navigation, deleteUser, t }: UsersListViewProps) => { + return ( + + {loading ? ( + + ) : ( + + + + + + {(users && users.length && ( + + {users.map(({ username, email, isActive, role, id }, idx) => ( + navigation.navigate('UserEdit', { id })}> + + + + + {`${t('users.column.name')}: `} + {username} + + + {`${t('users.column.email')}: `} + {email} + + + {`${t('users.column.role')}: `} + {role} + + + {`${t('users.column.active')}: `} + {String(isActive)} + + + + deleteUser(id)}> + + + + + + + ))} + + )) || ( + + Users not found + + )} + + + )} + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#fff', + alignItems: 'stretch' + }, + iconWrapper: { + backgroundColor: 'transparent', + width: 50, + height: 50, + alignItems: 'center', + justifyContent: 'center', + flexDirection: 'row' + }, + buttonWrapper: { + paddingHorizontal: 15, + marginBottom: 15 + }, + buttonContainer: { + flex: 1, + alignItems: 'center', + justifyContent: 'center' + }, + listItem: { + marginRight: 15, + paddingRight: 0 + }, + cardItem: { + flex: 9 + }, + cardItemWrapper: { + paddingTop: 5, + paddingBottom: 5, + flexDirection: 'column', + alignItems: 'flex-start' + }, + notificationContainer: { + justifyContent: 'center', + alignItems: 'center' + }, + notificationText: { + fontSize: 24, + fontWeight: '600' + } +}); + +export default translate('userUsers')(UsersListView); diff --git a/modules/user/client-react/users/components/UsersListView.tsx b/modules/user/client-react/users/components/UsersListView.tsx new file mode 100644 index 0000000..9a3def9 --- /dev/null +++ b/modules/user/client-react/users/components/UsersListView.tsx @@ -0,0 +1,133 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; + +import { translate } from '@restapp/i18n-client-react'; +import { Table, Button } from '@restapp/look-client-react'; + +import { User, CommonProps } from '../../types'; +import { OrderBy } from '../types'; + +export interface UsersViewProps extends CommonProps { + loading: boolean; + users?: User[]; + orderBy?: OrderBy; + onOrderBy: (orderBy: OrderBy) => void; + deleteUser: (id: number | string) => Promise; +} + +const UsersView = ({ deleteUser, orderBy, onOrderBy, loading, users, t, ...rest }: UsersViewProps) => { + const [errors, setErrors] = React.useState([]); + + const handleDeleteUser = async (id: number | string) => { + const result = await deleteUser(id); + if (result && result.errors) { + setErrors(result.errors); + } else { + setErrors([]); + } + }; + + const renderOrderByArrow = (name: string) => { + if (orderBy && orderBy.column === name) { + if (orderBy.order === 'desc') { + return ; + } else { + return ; + } + } else { + return ; + } + }; + + const handleOrderBy = (e: React.MouseEvent, name: string) => { + e.preventDefault(); + + let order = 'asc'; + if (orderBy && orderBy.column === name) { + if (orderBy.order === 'asc') { + order = 'desc'; + } else if (orderBy.order === 'desc') { + return onOrderBy({ + column: '', + order: '' + }); + } + } + + return onOrderBy({ column: name, order }); + }; + + const columns = [ + { + title: ( + handleOrderBy(e, 'username')} href="#"> + {t('users.column.name')} {renderOrderByArrow('username')} + + ), + dataIndex: 'username', + key: 'username', + render: (text: string, record: User) => ( + + {text} + + ) + }, + { + title: ( + handleOrderBy(e, 'email')} href="#"> + {t('users.column.email')} {renderOrderByArrow('email')} + + ), + dataIndex: 'email', + key: 'email' + }, + { + title: ( + handleOrderBy(e, 'role')} href="#"> + {t('users.column.role')} {renderOrderByArrow('role')} + + ), + dataIndex: 'role', + key: 'role' + }, + { + title: ( + handleOrderBy(e, 'isActive')} href="#"> + {t('users.column.active')} {renderOrderByArrow('isActive')} + + ), + dataIndex: 'isActive', + key: 'isActive', + render: (text: string) => text.toString() + }, + { + title: t('users.column.actions'), + key: 'actions', + render: (_text: string, record: User) => ( + + ) + } + ]; + + return ( + <> + {loading && !users ? ( +
{t('users.loadMsg')}
+ ) : ( + <> + {errors && + errors.map(error => ( +
+ {error.message} +
+ ))} + + + )} + + ); +}; + +export default translate('userUsers')(UsersView); diff --git a/modules/user/client-react/users/containers/NavLink.tsx b/modules/user/client-react/users/containers/NavLink.tsx new file mode 100644 index 0000000..ffb9748 --- /dev/null +++ b/modules/user/client-react/users/containers/NavLink.tsx @@ -0,0 +1,14 @@ +import React from 'react'; +import { NavLink } from 'react-router-dom'; + +import { translate } from '@restapp/i18n-client-react'; + +import { CommonProps } from '../../types'; + +const NavLinkUsersWithI18n = ({ t }: CommonProps) => ( + + {t('navLink.users')} + +); + +export default translate('userUsers')(NavLinkUsersWithI18n); diff --git a/modules/user/client-react/users/containers/UserAdd.tsx b/modules/user/client-react/users/containers/UserAdd.tsx new file mode 100644 index 0000000..f921bcd --- /dev/null +++ b/modules/user/client-react/users/containers/UserAdd.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { pick } from 'lodash'; + +import { translate } from '@restapp/i18n-client-react'; +import { FormError } from '@restapp/forms-client-react'; + +import UserAddView from '../components/UserAddView'; +import UserFormatter from '../../helpers/UserFormatter'; +import { User, CommonProps } from '../../types'; +import { addUser } from '../actions'; + +interface UserAddProps extends CommonProps { + addUser?: (values: User) => any; +} + +const UserAdd: React.FunctionComponent = props => { + const { addUser: actionAddUser, t, history, navigation } = props; + + const onSubmit = async (values: User) => { + let userValues = pick(values, ['username', 'email', 'role', 'isActive', 'password', 'firstName', 'lastName']); + + userValues = UserFormatter.trimExtraSpaces(userValues); + + try { + await actionAddUser(userValues as User); + } catch (e) { + const data = e.response && e.response.data; + throw new FormError(t('userAdd.errorMsg'), data); + } + + if (history) { + return history.push('/users/'); + } + if (navigation) { + return navigation.goBack(); + } + }; + + return ; +}; + +export default connect<{}, {}, UserAddProps>( + null, + { addUser } +)(translate('userUsers')(UserAdd)); diff --git a/modules/user/client-react/users/containers/UserEdit.native.tsx b/modules/user/client-react/users/containers/UserEdit.native.tsx new file mode 100644 index 0000000..ee1f49f --- /dev/null +++ b/modules/user/client-react/users/containers/UserEdit.native.tsx @@ -0,0 +1,64 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { pick } from 'lodash'; + +import { translate } from '@restapp/i18n-client-react'; +import { FormError } from '@restapp/forms-client-react'; + +import UserEditView from '../components/UserEditView.native'; +import UserFormatter from '../../helpers/UserFormatter'; +import { CommonProps, User } from '../../types/'; +import { user, editUser } from '../actions'; + +interface UserEditProps extends CommonProps { + editableUser?: User; + currentUser?: User; + loading?: boolean; + editUser?: (value: User) => any; + getUser?: (id: number) => void; +} + +class UserEdit extends React.Component { + public state = { ready: false }; + public async componentDidMount() { + let id = 0; + if (this.props.navigation) { + id = this.props.navigation.state.params.id; + } + await this.props.getUser(Number(id)); + this.setState({ ready: true }); + } + public onSubmit = async (values: User) => { + const { editableUser, editUser: actionEditUser, t, navigation } = this.props; + + let userValues = pick(values, ['username', 'email', 'role', 'isActive', 'password', 'firstName', 'lastName']); + + userValues = UserFormatter.trimExtraSpaces(userValues); + + try { + await actionEditUser({ id: editableUser.id, ...userValues } as any); + } catch (e) { + const data = e.response && e.response.data; + throw new FormError(t('userEdit.errorMsg'), data); + } + + if (navigation) { + return navigation.goBack(); + } + }; + + public render() { + return this.state.ready ? ( + + ) : null; + } +} + +export default connect<{}, {}, UserEditProps>( + ({ usersReducer: { user: editableUser }, signUpReducer: { currentUser, loading } }: any) => ({ + editableUser, + currentUser, + loading + }), + { getUser: user, editUser } +)(translate('userUsers')(UserEdit)); diff --git a/modules/user/client-react/users/containers/UserEdit.tsx b/modules/user/client-react/users/containers/UserEdit.tsx new file mode 100644 index 0000000..7463511 --- /dev/null +++ b/modules/user/client-react/users/containers/UserEdit.tsx @@ -0,0 +1,60 @@ +import React, { useEffect, useState } from 'react'; +import { connect } from 'react-redux'; +import { pick } from 'lodash'; + +import { translate } from '@restapp/i18n-client-react'; +import { FormError } from '@restapp/forms-client-react'; + +import UserEditView from '../components/UserEditView'; +import UserFormatter from '../../helpers/UserFormatter'; +import { User, CommonProps } from '../../types'; +import { user, editUser } from '../actions'; + +interface UserEditProps extends CommonProps { + editableUser?: User; + editUser?: (value: User) => any; + location?: any; + match?: any; + getUser?: (id: number) => void; +} + +const UserEdit: React.FunctionComponent = props => { + const { editableUser, editUser: actionEditUser, t, history, match, getUser } = props; + const [ready, setReady] = useState(false); + useEffect(() => { + (async () => { + let id = 0; + if (match) { + id = match.params.id; + } + await getUser(Number(id)); + setReady(true); + })(); + }, []); + + const onSubmit = async (values: User) => { + let userValues = pick(values, ['username', 'email', 'role', 'isActive', 'password', 'firstName', 'lastName']); + + userValues = UserFormatter.trimExtraSpaces(userValues); + + try { + await actionEditUser({ id: editableUser.id, ...userValues } as any); + } catch (e) { + const data = e.response && e.response.data; + throw new FormError(t('userEdit.errorMsg'), data); + } + + if (history) { + return history.goBack(); + } + }; + + return ready ? : null; +}; + +export default connect( + ({ usersReducer: { user: editableUser } }: any) => ({ + editableUser + }), + { getUser: user, editUser } +)(translate('userUsers')(UserEdit)); diff --git a/modules/user/client-react/users/containers/UserOperations.tsx b/modules/user/client-react/users/containers/UserOperations.tsx new file mode 100644 index 0000000..99e1960 --- /dev/null +++ b/modules/user/client-react/users/containers/UserOperations.tsx @@ -0,0 +1,71 @@ +import React from 'react'; +import { connect } from 'react-redux'; + +import { users, deleteUser } from '../actions'; +import { UserRole } from '../../types'; +import { OrderBy } from '../types'; + +const withUsers = (Component: React.ComponentType) => { + class WithUsers extends React.Component { + public state = { + ready: false + }; + + public async componentDidMount() { + const { getUsers, orderBy, filter } = this.props; + if (!this.state.ready) { + await getUsers(orderBy, filter); + } + this.setState({ ready: true }); + } + + public render() { + const { getUsers, ...props } = this.props; + return ; + } + } + return connect( + ({ usersReducer: { usersLoading, users: usersList } }: any) => ({ + loading: usersLoading, + users: usersList + }), + { getUsers: users } + )(WithUsers); +}; + +const withUsersDeleting = (Component: React.ComponentType) => + connect( + null, + { deleteUser } + )(Component); + +const withSortAndFilter = (Component: React.ComponentType) => { + const WithFilterUpdating: React.ComponentType = ({ orderBy, filter, sortAndFilter, ...props }) => { + const onOrderBy = (_orderBy: OrderBy) => sortAndFilter(_orderBy, filter, 'SET_ORDER_BY'); + const onSearchTextChange = (searchText: string) => sortAndFilter(orderBy, { ...filter, searchText }, 'SET_FILTER'); + const onRoleChange = (role: UserRole) => sortAndFilter(orderBy, { ...filter, role }, 'SET_FILTER'); + const onIsActiveChange = (isActive: boolean) => sortAndFilter(orderBy, { ...filter, isActive }, 'SET_FILTER'); + + return ( + + ); + }; + + return connect( + ({ usersReducer: { orderBy, filter } }: any) => ({ + orderBy, + filter + }), + { sortAndFilter: users } + )(WithFilterUpdating); +}; + +export { withUsers, withUsersDeleting, withSortAndFilter }; diff --git a/modules/user/client-react/users/containers/Users.native.tsx b/modules/user/client-react/users/containers/Users.native.tsx new file mode 100644 index 0000000..e20dfb4 --- /dev/null +++ b/modules/user/client-react/users/containers/Users.native.tsx @@ -0,0 +1,57 @@ +import React from 'react'; +import { StyleSheet, View } from 'react-native'; +import { compose } from 'redux'; + +import UsersList from '../components/UsersListView.native'; +import UsersFilter from '../components/UsersFilterView.native'; +import { withSortAndFilter, withUsers, withUsersDeleting } from './UserOperations'; + +import { NavigationOptionsProps } from '../../types'; + +interface UsersProps extends NavigationOptionsProps { + loading: boolean; +} + +class Users extends React.Component { + public render() { + const isOpenFilter = !!this.props.navigation.getParam('isOpenFilter'); + return ( + + {isOpenFilter && ( + + + + )} + + + + + ); + } +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#fff', + alignItems: 'stretch', + justifyContent: 'center' + }, + filterContainer: { + flex: 5, + borderWidth: 1, + borderColor: '#e3e3e3', + marginBottom: 15, + justifyContent: 'center' + }, + usersListContainer: { + flex: 8, + marginTop: 15 + } +}); + +export default compose( + withUsersDeleting, + withSortAndFilter, + withUsers +)(Users); diff --git a/modules/user/client-react/users/containers/Users.tsx b/modules/user/client-react/users/containers/Users.tsx new file mode 100644 index 0000000..6b21a1f --- /dev/null +++ b/modules/user/client-react/users/containers/Users.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import { compose } from 'redux'; +import { Link } from 'react-router-dom'; + +import { translate } from '@restapp/i18n-client-react'; +import { Button, PageLayout, MetaData } from '@restapp/look-client-react'; + +import UsersFilterView, { UsersFilterViewProps } from '../components/UsersFilterView'; +import UsersListView, { UsersViewProps } from '../components/UsersListView'; +import { withSortAndFilter, withUsers, withUsersDeleting } from './UserOperations'; + +interface UsersProps extends UsersViewProps, UsersFilterViewProps {} + +class Users extends React.Component { + public render() { + const { t } = this.props; + + return ( + + +

{t('users.list.title')}

+ + + +
+ +
+ +
+ ); + } +} + +export default compose( + withUsersDeleting, + withSortAndFilter, + withUsers +)(translate('userUsers')(Users)); diff --git a/modules/user/client-react/users/index.native.tsx b/modules/user/client-react/users/index.native.tsx new file mode 100644 index 0000000..57eff5c --- /dev/null +++ b/modules/user/client-react/users/index.native.tsx @@ -0,0 +1,94 @@ +import React from 'react'; +import { createStackNavigator } from 'react-navigation'; + +import { translate } from '@restapp/i18n-client-react'; +import { HeaderTitle, IconButton } from '@restapp/look-client-react-native'; +import ClientModule from '@restapp/module-client-react-native'; + +import resources from './locales'; +import Users from './containers/Users.native'; +import UserEdit from './containers/UserEdit.native'; +import UserAdd from './containers/UserAdd'; +import { NavigationOptionsProps } from '../types'; +import usersReducer from './reducers'; + +class UsersListScreen extends React.Component { + public render() { + return ; + } +} + +class UserEditScreen extends React.Component { + public static navigationOptions = () => ({ + title: 'Edit user' + }); + public render() { + return ; + } +} + +class UserAddScreen extends React.Component { + public static navigationOptions = () => ({ + title: 'Create user' + }); + public render() { + return ; + } +} + +const HeaderTitleWithI18n = translate('userUsers')(HeaderTitle); + +export default new ClientModule({ + drawerItem: [ + { + Users: { + screen: createStackNavigator({ + Users: { + screen: UsersListScreen, + navigationOptions: ({ navigation }: NavigationOptionsProps) => ({ + headerTitle: , + headerLeft: ( + navigation.openDrawer()} /> + ), + headerRight: ( + { + const isOpenFilter = navigation.getParam('isOpenFilter'); + navigation.setParams({ isOpenFilter: !isOpenFilter }); + }} + /> + ), + headerForceInset: {} + }) + }, + UserEdit: { + screen: UserEditScreen, + navigationOptions: () => ({ + headerTitle: , + headerForceInset: {} + }) + }, + UserAdd: { + screen: UserAddScreen, + navigationOptions: () => ({ + headerTitle: , + headerForceInset: {} + }) + } + }), + userInfo: { + showOnLogin: true, + role: 'admin' + }, + navigationOptions: { + drawerLabel: + } + } + } + ], + localization: [{ ns: 'userUsers', resources }], + reducer: [{ usersReducer }] +}); diff --git a/modules/user/client-react/users/index.tsx b/modules/user/client-react/users/index.tsx new file mode 100644 index 0000000..4838773 --- /dev/null +++ b/modules/user/client-react/users/index.tsx @@ -0,0 +1,30 @@ +import React from 'react'; + +import { MenuItem } from '@restapp/look-client-react'; +import ClientModule from '@restapp/module-client-react'; + +import resources from './locales'; +import Users from './containers/Users'; +import UserEdit from './containers/UserEdit'; +import UserAdd from './containers/UserAdd'; +import NavLinkUsersWithI18n from './containers/NavLink'; +import { AuthRoute, IfLoggedIn } from '../containers/Auth'; +import usersReducer from './reducers'; +import { UserRole } from '../types'; + +export default new ClientModule({ + route: [ + , + , + + ], + navItem: [ + + + + + + ], + localization: [{ ns: 'userUsers', resources }], + reducer: [{ usersReducer }] +}); diff --git a/modules/user/client-react/users/locales/en/translations.json b/modules/user/client-react/users/locales/en/translations.json new file mode 100644 index 0000000..a1befbc --- /dev/null +++ b/modules/user/client-react/users/locales/en/translations.json @@ -0,0 +1,78 @@ +{ + "loading": "App is loading...", + "navLink": { + "users": "Users", + "editUser": "Edit user" + }, + "userEdit": { + "title": "Edit User", + "meta": "An Edit User page example", + "errorMsg": "User editing failed!", + "loadMsg": "Loading...", + "form": { + "title": "User", + "titleEdit": "Edit", + "titleCreate": "Create", + "field": { + "name": "Username", + "email": "Email", + "role": { + "label": "Role", + "user": "user", + "admin": "admin" + }, + "active": "Is Active", + "firstName": "First Name", + "lastName": "Last Name", + "pass": "Password", + "passConf": "Password Confirmation" + }, + "btnSubmit": "Save" + }, + "select": { + "okText": "Done", + "dismissText": "Cancel" + }, + "btnBack": "Back" + }, + "userAdd": { + "errorMsg": "Add user failed!" + }, + "users": { + "title": "Users", + "meta": "A User List page example", + "loadMsg": "Loading...", + "list": { + "title": "Users", + "item": { + "filter": "Filter", + "search": "Search ...", + "role": { + "label": "Role", + "all": "All", + "user": "user", + "admin": "admin" + }, + "active": "Is Active" + } + }, + "select": { + "okText": "Done", + "dismissText": "Cancel" + }, + "btnModalSubmit": "Submit", + "btnModalClose": "Close", + "orderByText": "Order by", + "column": { + "name": "Username", + "email": "Email", + "role": "Role", + "active": "Is Active", + "actions": "Actions" + }, + "btn": { + "add": "Add user", + "delete": "Delete" + } + } +} \ No newline at end of file diff --git a/modules/user/client-react/users/locales/index.ts b/modules/user/client-react/users/locales/index.ts new file mode 100644 index 0000000..ff8b4c5 --- /dev/null +++ b/modules/user/client-react/users/locales/index.ts @@ -0,0 +1 @@ +export default {}; diff --git a/modules/user/client-react/users/locales/ru/translations.json b/modules/user/client-react/users/locales/ru/translations.json new file mode 100644 index 0000000..3a3ff7b --- /dev/null +++ b/modules/user/client-react/users/locales/ru/translations.json @@ -0,0 +1,78 @@ +{ + "loading": "Приложение загружается...", + "navLink": { + "users": "Пользователи", + "editUser": "Редактирование пользователя" + }, + "userEdit": { + "title": "Редактирование пользователя", + "meta": "Пример страницы с редактированием пользователя", + "errorMsg": "Не удалось изменить пользователя!", + "loadMsg": "Загрузка...", + "form": { + "title": "пользователя", + "titleEdit": "Редактировать", + "titleCreate": "Создать", + "field": { + "name": "Имя пользователя", + "email": "Электронная почта", + "role": { + "label": "Роль", + "user": "пользователь", + "admin": "админ" + }, + "active": "Активен", + "firstName": "Имя", + "lastName": "Фамилия", + "pass": "Пароль", + "passConf": "Подтверждение пароля" + }, + "btnSubmit": "Сохранить" + }, + "select": { + "okText": "Подтвердить", + "dismissText": "Отмена" + }, + "btnBack": "Назад" + }, + "userAdd": { + "errorMsg": "Не удалось добавить пользователя!" + }, + "users": { + "title": "Список пользователей", + "meta": "Пример страницы со списком пользователей", + "loadMsg": "Загрузка...", + "list": { + "title": "Список пользователей", + "item": { + "filter": "Фильтр", + "search": "Поиск ...", + "role": { + "label": "Роль", + "all": "Все", + "user": "пользователь", + "admin": "админ" + }, + "active": "Активен" + } + }, + "select": { + "okText": "Подтвердить", + "dismissText": "Отмена" + }, + "btnModalSubmit": "Сохранить", + "btnModalClose": "Закрыть", + "orderByText": "Сортировать", + "column": { + "name": "Имя пользователя", + "email": "Электронная почта", + "role": "Роль", + "active": "Активен", + "actions": "Действия" + }, + "btn": { + "add": "Добавить пользователя", + "delete": "Удалить" + } + } +} \ No newline at end of file diff --git a/modules/user/client-react/users/reducers/index.ts b/modules/user/client-react/users/reducers/index.ts new file mode 100644 index 0000000..dd8fa05 --- /dev/null +++ b/modules/user/client-react/users/reducers/index.ts @@ -0,0 +1,88 @@ +import { User } from '../../types'; +import { OrderBy, Filter } from '../types'; + +export enum ActionType { + SET_LOADING = 'SET_LOADING', + SET_USER = 'SET_USER', + SET_USERS = 'SET_USERS', + CLEAR_USERS = 'CLEAR_USERS', + SET_ORDER_BY = 'SET_ORDER_BY', + SET_FILTER = 'SET_FILTER', + DELETE_USER = 'DELETE_USER' +} + +export interface UsersActionProps { + type: ActionType | ActionType[]; + payload?: any; + APICall?: () => Promise; + [key: string]: any; +} + +export interface UserModuleState { + usersLoading: boolean; + user: User; + users: User[]; + orderBy: OrderBy; + filter: Filter; +} + +const defaultState: UserModuleState = { + usersLoading: false, + user: null, + users: [], + orderBy: { column: '', order: '' }, + filter: { searchText: '', role: '', isActive: true } +}; + +export default function(state = defaultState, action: UsersActionProps) { + switch (action.type) { + case ActionType.SET_LOADING: + return { + ...state, + usersLoading: true + }; + + case ActionType.SET_USER: + return { + ...state, + user: action.payload, + usersLoading: false + }; + + case ActionType.SET_USERS: + return { + ...state, + users: action.payload, + usersLoading: false + }; + + case ActionType.CLEAR_USERS: + return { + ...state, + users: null, + usersLoading: false + }; + + case ActionType.SET_FILTER: + return { + ...state, + filter: action.payload && action.payload.filter + }; + + case ActionType.SET_ORDER_BY: + return { + ...state, + orderBy: action.payload && action.payload.orderBy + }; + + case ActionType.DELETE_USER: + const users = state.users.filter(user => user.id !== action.payload.id); + return { + ...state, + users + }; + + default: + return state; + } +} diff --git a/modules/user/client-react/users/types/index.ts b/modules/user/client-react/users/types/index.ts new file mode 100644 index 0000000..653e9b7 --- /dev/null +++ b/modules/user/client-react/users/types/index.ts @@ -0,0 +1,10 @@ +export interface OrderBy { + column: string; + order: string; +} + +export interface Filter { + searchText: string; + role: string; + isActive: boolean; +} diff --git a/modules/user/server-ts/controllers.ts b/modules/user/server-ts/controllers.ts new file mode 100644 index 0000000..e69698c --- /dev/null +++ b/modules/user/server-ts/controllers.ts @@ -0,0 +1,228 @@ +import { pick, isEmpty } from 'lodash'; +import jwt from 'jsonwebtoken'; + +import { createTransaction } from '@restapp/database-server-ts'; +import { log } from '@restapp/core-common'; +import { mailer } from '@restapp/mailer-server-ts'; + +import userDAO, { UserShape } from './sql'; +import settings from '../../../settings'; +import { ValidationErrors } from '.'; +import { createPasswordHash } from './password'; +import emailTemplate from './emailTemplate'; + +const { + auth: { password, secret }, + app +} = settings; + +export const user = async ({ params: { id }, user: identity, t }: any, res: any) => { + if ((identity && +identity.id === +id) || identity.role === 'admin') { + try { + res.json(await userDAO.getUser(id)); + } catch (e) { + res.status(500).json({ errors: e }); + } + } else { + res.status(401).send(t('user:accessDenied')); + } +}; + +export const users = async ({ body: { orderBy, filter }, user: identity, t }: any, res: any) => { + identity && identity.role === 'admin' + ? res.json(await userDAO.getUsers(orderBy, filter)) + : res.status(401).send(t('user:accessDenied')); +}; + +export const currentUser = async ({ user: identity }: any, res: any) => { + if (identity && identity.id) { + res.json(await userDAO.getUser(identity.id)); + } else { + res.send(null); + } +}; + +export const addUser = async ({ body, user: identity, t }: any, res: any) => { + if (identity && identity.role !== 'admin') { + return res.status(401).send(t('user:accessDenied')); + } + const errors: ValidationErrors = {}; + + const userExists = await userDAO.getUserByUsername(body.username); + if (userExists) { + errors.username = t('user:usernameIsExisted'); + } + + const emailExists = await userDAO.getUserByEmail(body.email); + if (emailExists) { + errors.email = t('user:emailIsExisted'); + } + + if (body.password.length < password.minLength) { + errors.password = t('user:passwordLength'); + } + + if (!isEmpty(errors)) { + return res.status(422).json({ + errors: { + message: t('user:auth.password.registrationFailed'), + ...errors + } + }); + } + + const passwordHash: string = await createPasswordHash(body.password); + + const trx = await createTransaction(); + let createdUserId; + try { + const isActive = password.requireEmailConfirmation ? body.isActive || false : !password.requireEmailConfirmation; + + [createdUserId] = await userDAO.register({ ...body, isActive }, passwordHash).transacting(trx); + await userDAO.editUserProfile({ id: createdUserId, ...body }).transacting(trx); + trx.commit(); + } catch (e) { + res.status(500).json({ + errors: { + message: e + } + }); + trx.rollback(); + } + + try { + const createdUser = (await userDAO.getUser(createdUserId)) as UserShape; + res.json(user); + + if (mailer && password.requireEmailConfirmation && !emailExists) { + // async email + jwt.sign({ identity: pick(createdUser, 'id') }, secret, { expiresIn: '1d' }, (err: any, emailToken: string) => { + const encodedToken = Buffer.from(emailToken).toString('base64'); + const url = `${__WEBSITE_URL__}/api/confirmation/${encodedToken}`; + mailer.sendMail({ + from: `${app.name} <${process.env.EMAIL_USER}>`, + to: createdUser.email, + subject: 'Your account has been created', + html: emailTemplate.accountCreated(url, createdUser) + }); + log.info(`Sent registration confirmation email to: ${createdUser.email}`); + }); + } + } catch (e) { + res.status(500).json({ + errors: { + message: e + } + }); + } +}; + +export const editUser = async ({ user: identity, body, t }: any, res: any) => { + const isAdmin = () => identity && identity.role === 'admin'; + const isSelf = () => identity && +identity.id === +body.id; + + if (!isSelf() && !isAdmin()) { + return res.status(401).send(t('user:accessDenied')); + } + + const errors: ValidationErrors = {}; + + const userExists = (await userDAO.getUserByUsername(body.username)) as UserShape; + if (userExists && userExists.id !== body.id) { + errors.username = t('user:usernameIsExisted'); + } + + const emailExists = (await userDAO.getUserByEmail(body.email)) as UserShape; + if (emailExists && emailExists.id !== body.id) { + errors.email = t('user:emailIsExisted'); + } + + if (body.password && body.password.length < password.minLength) { + errors.password = t('user:passwordLength'); + } + + if (!isEmpty(errors)) { + return res.status(422).json({ + errors: { + message: t('user:auth.password.registrationFailed'), + ...errors + } + }); + } + + const userInfo = !isSelf() && isAdmin() ? body : pick(body, ['id', 'username', 'email', 'password']); + + const passwordHash = !!body.password && (await createPasswordHash(body.password)); + + const trx = await createTransaction(); + try { + await userDAO.editUser(userInfo, passwordHash).transacting(trx); + + if (mailer && body.password && password.sendPasswordChangesEmail) { + const url = `${__WEBSITE_URL__}/profile`; + + mailer.sendMail({ + from: `${settings.app.name} <${process.env.EMAIL_USER}>`, + to: body.email, + subject: 'Your Password Has Been Updated', + html: emailTemplate.passwordUpdated(url) + }); + log.info(`Sent password has been updated to: ${body.email}`); + } + trx.commit(); + } catch (e) { + res.status(500).json({ + errors: { + message: e + } + }); + trx.rollback(); + } + + try { + res.json(await userDAO.getUser(body.id)); + } catch (e) { + res.status(500).json({ + errors: { + message: e + } + }); + } +}; + +export const deleteUser = async ({ user: identity, body: { id }, t }: any, res: any) => { + const isAdmin = () => identity && identity.role === 'admin'; + const isSelf = () => identity && +identity.id === +id; + + const userData = await userDAO.getUser(id); + if (!userData) { + res.send(t('user:userIsNotExisted')); + } + + if (isSelf()) { + res.send(t('user:userCannotDeleteYourself')); + } + + const isDeleted = !isSelf() && isAdmin() ? await userDAO.deleteUser(id) : false; + + if (isDeleted) { + res.json(userData); + } else { + res.send(t('user:userCouldNotDeleted')); + } +}; + +export const confirmEmail = async ({ params, t }: any, res: any) => { + try { + const token = Buffer.from(params.token, 'base64').toString(); + const { + identity: { id } + }: any = jwt.verify(token, settings.auth.secret); + + await userDAO.updateActive(id, true); + + res.redirect('/login/?email-verified'); + } catch (e) { + throw e; + } +}; diff --git a/modules/user/server-ts/emailTemplate.ts b/modules/user/server-ts/emailTemplate.ts new file mode 100644 index 0000000..83b6008 --- /dev/null +++ b/modules/user/server-ts/emailTemplate.ts @@ -0,0 +1,34 @@ +import { UserShape } from './sql'; +import settings from '../../../settings'; + +const { app } = settings; + +const accountCreated = (url: string, user: UserShape) => + `

Hi, ${user.username}!

+

Welcome to ${app.name}. Please click the following link to confirm your email:

+

${url}

+

Below are your login information

+

Your email is: ${user.email}

`; + +const passwordUpdated = (url: string) => + `

Your account password has been updated.

+

To view or edit your account settings, please visit the “Profile” page at

+

${url}

`; + +const confirmEmail = (url: string, user: UserShape) => + `

Hi, ${user.username}!

+

Welcome to ${app.name}. Please click the following link to confirm your email:

+

${url}

+

Below are your login information

+

Your email is: ${user.email}

`; + +const passwordReset = (url: string) => ` +

Please click this link to reset your password: reset.

+`; + +export default { + accountCreated, + passwordUpdated, + confirmEmail, + passwordReset +}; diff --git a/modules/user/server-ts/index.ts b/modules/user/server-ts/index.ts new file mode 100644 index 0000000..f67d90b --- /dev/null +++ b/modules/user/server-ts/index.ts @@ -0,0 +1,101 @@ +import bcrypt from 'bcryptjs'; +import i18n from 'i18next'; + +import ServerModule, { RestMethod } from '@restapp/module-server-ts'; + +import { user, users, currentUser, addUser, editUser, deleteUser, confirmEmail } from './controllers'; +import password from './password'; +import social from './social'; +import UserDAO, { UserShapePassword } from './sql'; +import settings from '../../../settings'; +import resources from './locales'; + +export interface ValidationErrors { + username?: string; + email?: string; + password?: string; +} + +const getIdentity = (id: number) => { + return UserDAO.getUser(id); +}; + +const getHash = async (id: number) => ((await UserDAO.getUserWithPassword(id)) as UserShapePassword).passwordHash || ''; + +const validateLogin = async (usernameOrEmail: string, pswd: string) => { + const identity = (await UserDAO.getUserByUsernameOrEmail(usernameOrEmail)) as UserShapePassword; + + if (!identity || !identity.passwordHash) { + return { message: i18n.t('user:auth.password.validPasswordEmail') }; + } + + if (settings.auth.password.requireEmailConfirmation && !identity.isActive) { + return { message: i18n.t('user:auth.password.emailConfirmation') }; + } + + const valid = await bcrypt.compare(pswd, identity.passwordHash); + if (!valid) { + return { message: i18n.t('user:auth.password.validPassword') }; + } + + return { user: identity }; +}; + +const appContext = { + user: { + getIdentity, + getHash, + validateLogin + } +}; + +export default new ServerModule(password, social, { + appContext, + localization: [{ ns: 'user', resources }], + apiRouteParams: [ + { + method: RestMethod.GET, + route: 'user/:id', + isAuthRoute: true, + controller: user + }, + { + method: RestMethod.POST, + route: 'users', + isAuthRoute: true, + controller: users + }, + { + method: RestMethod.GET, + route: 'currentUser', + isAuthRoute: true, + controller: currentUser + }, + { + method: RestMethod.POST, + route: 'addUser', + isAuthRoute: true, + controller: addUser + }, + { + method: RestMethod.POST, + route: 'editUser', + isAuthRoute: true, + controller: editUser + }, + { + method: RestMethod.DELETE, + route: 'deleteUser', + isAuthRoute: true, + controller: deleteUser + }, + { + ...(settings.auth.password.requireEmailConfirmation && { + method: RestMethod.GET, + route: 'confirmation/:token', + isAuthRoute: false, + controller: confirmEmail + }) + } + ] +}); diff --git a/modules/user/server-ts/locales/en/translations.json b/modules/user/server-ts/locales/en/translations.json new file mode 100644 index 0000000..d816ae1 --- /dev/null +++ b/modules/user/server-ts/locales/en/translations.json @@ -0,0 +1,24 @@ +{ + "accessDenied": "Access Denied", + "usernameIsExisted": "Username already exists.", + "emailIsExisted": "E-mail already exists.", + "passwordLength": "Password must be {{length}} characters or more.", + "userIsNotExisted": "User does not exist.", + "userCannotDeleteYourself": "You can not delete your self.", + "userCouldNotDeleted": "Could not delete user. Please try again later.", + "auth": { + "password": { + "validPasswordEmail": "Please enter a valid username or password.", + "emailConfirmation": "Please confirm your e-mail first.", + "registrationFailed": "Registration failed due to validation errors", + "validPassword": "Please enter a valid password.", + "usernameIsExisted": "Username already exists", + "emailIsExisted": "E-mail already exists.", + "passwordsIsNotMatch": "Passwords do not match.", + "passwordLength": "Password must be {{length}} characters or more.", + "invalidToken": "Invalid token", + "forgotPassword": "Instructions were send to your email", + "resestPassword": "Your password has been updated succesfully" + } + } +} \ No newline at end of file diff --git a/modules/user/server-ts/locales/index.ts b/modules/user/server-ts/locales/index.ts new file mode 100644 index 0000000..d9fb121 --- /dev/null +++ b/modules/user/server-ts/locales/index.ts @@ -0,0 +1,5 @@ +/* + * The index.js can be empty, it's just needed to point the loader to the root directory of the locales. + * https://github.com/alienfast/i18next-loader#option-2-use-with-import-syntax + */ +export default {}; diff --git a/modules/user/server-ts/locales/ru/translations.json b/modules/user/server-ts/locales/ru/translations.json new file mode 100644 index 0000000..29227c8 --- /dev/null +++ b/modules/user/server-ts/locales/ru/translations.json @@ -0,0 +1,24 @@ +{ + "accessDenied": "Доступ запрещен", + "usernameIsExisted": "Данное имя пользователя уже используется.", + "emailIsExisted": "Данный e-mail уже используется.", + "passwordLength": "Длина пароля должна составлять {{length}} символов и больше.", + "userIsNotExisted": "Такого пользователя не существует.", + "userCannotDeleteYourself": "Вы не можете удалить себя", + "userCouldNotDeleted": "Невозможно удалить пользователя. Побробуйте повторить попытку позже.", + "auth": { + "password": { + "validPasswordEmail": "Пожалуйста, введите Ваш username или пароль.", + "emailConfirmation": "Пожалуйста, подтвердите Ваш e-mail", + "registrationFailed": "Регистрация не была успешной из-за ошибок валидации.", + "validPassword": "Пожалуйста, введите действительный пароль.", + "usernameIsExisted": "Данное имя пользователя уже используется", + "emailIsExisted": "Данный e-mail уже используется.", + "passwordsIsNotMatch": "Пароли не совпадают.", + "passwordLength": "Длина пароля должна составлять {{length}} символов и больше.", + "invalidToken": "Не действительный токен", + "forgotPassword": "Интсрукции были отправлены на ваш email", + "resestPassword": "Ваш пароль был успешно обновлен" + } + } +} \ No newline at end of file diff --git a/modules/user/server-ts/migrations/002_user.js b/modules/user/server-ts/migrations/002_user.js new file mode 100644 index 0000000..b466eae --- /dev/null +++ b/modules/user/server-ts/migrations/002_user.js @@ -0,0 +1,84 @@ +exports.up = function(knex, Promise) { + return Promise.all([ + knex.schema.createTable('user', table => { + table.increments(); + table.string('username').unique(); + table.string('email').unique(); + table.string('password_hash'); + table.string('role').defaultTo('user'); + table.boolean('is_active').defaultTo(false); + table.timestamps(false, true); + }), + knex.schema.createTable('user_profile', table => { + table.increments(); + table.string('first_name'); + table.string('last_name'); + table + .integer('user_id') + .unsigned() + .references('id') + .inTable('user') + .onDelete('CASCADE'); + table.timestamps(false, true); + }), + knex.schema.createTable('auth_facebook', table => { + table.increments(); + table.string('fb_id').unique(); + table.string('display_name'); + table + .integer('user_id') + .unsigned() + .references('id') + .inTable('user') + .onDelete('CASCADE'); + table.timestamps(false, true); + }), + knex.schema.createTable('auth_google', table => { + table.increments(); + table.string('google_id').unique(); + table.string('display_name'); + table + .integer('user_id') + .unsigned() + .references('id') + .inTable('user') + .onDelete('CASCADE'); + table.timestamps(false, true); + }), + knex.schema.createTable('auth_github', table => { + table.increments(); + table.string('gh_id').unique(); + table.string('display_name'); + table + .integer('user_id') + .unsigned() + .references('id') + .inTable('user') + .onDelete('CASCADE'); + table.timestamps(false, true); + }), + knex.schema.createTable('auth_linkedin', table => { + table.increments(); + table.string('ln_id').unique(); + table.string('display_name'); + table + .integer('user_id') + .unsigned() + .references('id') + .inTable('user') + .onDelete('CASCADE'); + table.timestamps(false, true); + }) + ]); +}; + +exports.down = function(knex, Promise) { + return Promise.all([ + knex.schema.dropTable('auth_facebook'), + knex.schema.dropTable('auth_google'), + knex.schema.dropTable('auth_github'), + knex.schema.dropTable('auth_linkedin'), + knex.schema.dropTable('user_profile'), + knex.schema.dropTable('user') + ]); +}; diff --git a/modules/user/server-ts/package.json b/modules/user/server-ts/package.json new file mode 100644 index 0000000..e8dd2bb --- /dev/null +++ b/modules/user/server-ts/package.json @@ -0,0 +1,5 @@ +{ + "name": "@restapp/user-server-ts", + "version": "0.1.0", + "private": true +} \ No newline at end of file diff --git a/modules/user/server-ts/password/controllers.ts b/modules/user/server-ts/password/controllers.ts new file mode 100644 index 0000000..e3ca661 --- /dev/null +++ b/modules/user/server-ts/password/controllers.ts @@ -0,0 +1,156 @@ +import { UserShape, UserShapePassword } from './../sql'; +import { pick, isEmpty } from 'lodash'; +import jwt from 'jsonwebtoken'; +import { log } from '@restapp/core-common'; +import { mailer } from '@restapp/mailer-server-ts'; + +import userDAO from '../sql'; +import settings from '../../../../settings'; +import { ValidationErrors } from '../'; +import emailTemplate from '../emailTemplate'; +import { createPasswordHash } from '.'; + +const { + auth: { password: passwordSettings, secret }, + app +} = settings; + +export const login = async (req: any, res: any, next: any) => { + const { + locals: { appContext } + } = res; + const { usernameOrEmail, password } = req.body; + + appContext.auth && appContext.auth.loginMiddleware + ? res.locals.appContext.auth.loginMiddleware(req, res, next) + : res.json(await appContext.user.validateLogin(usernameOrEmail, password)); +}; + +export const register = async ({ body, t }: any, res: any) => { + const errors: ValidationErrors = {}; + const userExists = await userDAO.getUserByUsername(body.username); + if (userExists) { + errors.username = t('user:auth.password.usernameIsExisted'); + } + + const emailExists = (await userDAO.getUserByEmail(body.email)) as UserShape; + if (emailExists) { + errors.email = t('user:auth.password.emailIsExisted'); + } + + if (!isEmpty(errors)) { + return res.status(422).send({ + errors: { + message: t('user:auth.password.registrationFailed'), + ...errors + } + }); + } + + let userId = 0; + if (!emailExists) { + const passwordHash = await createPasswordHash(body.password); + const isActive = !passwordSettings.requireEmailConfirmation; + [userId] = await userDAO.register({ ...body, isActive }, passwordHash); + + // if user has previously logged with facebook auth + } else { + await userDAO.updatePassword(emailExists.userId, body.password); + userId = emailExists.userId; + } + + const user = (await userDAO.getUser(userId)) as UserShape; + + if (mailer && passwordSettings.requireEmailConfirmation && !emailExists) { + // async email + jwt.sign({ identity: pick(user, 'id') }, secret, { expiresIn: '1d' }, (err, emailToken) => { + const encodedToken = Buffer.from(emailToken).toString('base64'); + const url = `${__WEBSITE_URL__}/api/confirmation/${encodedToken}`; + mailer.sendMail({ + from: `${app.name} <${process.env.EMAIL_USER}>`, + to: user.email, + subject: 'Confirm Email', + html: emailTemplate.confirmEmail(url, user) + }); + log.info(`Sent registration confirmation email to: ${user.email}`); + }); + } + + res.json(user); +}; + +export const forgotPassword = async ({ body, t }: any, res: any) => { + try { + const localAuth = pick(body.value, 'email'); + const identity = (await userDAO.getUserByEmail(localAuth.email)) as UserShapePassword; + + if (identity && mailer) { + // async email + jwt.sign( + { email: identity.email, passwordHash: identity.passwordHash }, + secret, + { expiresIn: '1d' }, + (err, emailToken) => { + // encoded token since react router does not match dots in params + const encodedToken = Buffer.from(emailToken).toString('base64'); + const url = `${__WEBSITE_URL__}/reset-password/${encodedToken}`; + mailer.sendMail({ + from: `${app.name} <${process.env.EMAIL_USER}>`, + to: identity.email, + subject: 'Reset Password', + html: emailTemplate.passwordReset(url) + }); + log.info(`Sent link to reset email to: ${identity.email}`); + } + ); + res.send(t('user:auth.password.forgotPassword')); + } + } catch (e) { + res.status(500); + res.end(); + } +}; + +export const resetPassword = async ({ body, t }: any, res: any) => { + const errors: ValidationErrors = {}; + const reset = pick(body, ['password', 'passwordConfirmation', 'token']); + + if (reset.password !== reset.passwordConfirmation) { + errors.password = t('user:auth.password.passwordsIsNotMatch'); + } + + if (reset.password.length < passwordSettings.minLength) { + errors.password = t('user:auth.password.passwordLength', { length: passwordSettings.minLength }); + } + + if (!isEmpty(errors)) { + return res.status(422).send({ + message: 'Failed reset password', + ...errors + }); + } + + const token = Buffer.from(reset.token, 'base64').toString(); + const { email, passwordHash } = jwt.verify(token, secret) as UserShapePassword; + const identity = (await userDAO.getUserByEmail(email)) as UserShapePassword; + + if (identity.passwordHash !== passwordHash) { + throw res.status(401).send(t('user:auth.password.invalidToken')); + } + + if (identity) { + await userDAO.updatePassword(identity.id, reset.password); + const url = `${__WEBSITE_URL__}/profile`; + res.send(t('user:auth.password.resestPassword')); + + if (mailer && passwordSettings.sendPasswordChangesEmail) { + mailer.sendMail({ + from: `${app.name} <${process.env.EMAIL_USER}>`, + to: identity.email, + subject: 'Your Password Has Been Updated', + html: emailTemplate.passwordUpdated(url) + }); + log.info(`Sent password has been updated to: ${identity.email}`); + } + } +}; diff --git a/modules/user/server-ts/password/index.ts b/modules/user/server-ts/password/index.ts new file mode 100644 index 0000000..4c3f2bf --- /dev/null +++ b/modules/user/server-ts/password/index.ts @@ -0,0 +1,35 @@ +import bcrypt from 'bcryptjs'; + +import ServerModule, { RestMethod } from '@restapp/module-server-ts'; + +import { login, register, forgotPassword, resetPassword } from './controllers'; +import settings from '../../../../settings'; + +export const createPasswordHash = (pswd: string) => bcrypt.hash(pswd, 12); + +export default (settings.auth.password.enabled + ? new ServerModule({ + apiRouteParams: [ + { + method: RestMethod.POST, + route: 'login', + controller: login + }, + { + method: RestMethod.POST, + route: 'register', + controller: register + }, + { + method: RestMethod.POST, + route: 'forgotPassword', + controller: forgotPassword + }, + { + method: RestMethod.POST, + route: 'resetPassword', + controller: resetPassword + } + ] + }) + : undefined); diff --git a/modules/user/server-ts/seeds/.eslintrc b/modules/user/server-ts/seeds/.eslintrc new file mode 100644 index 0000000..712a899 --- /dev/null +++ b/modules/user/server-ts/seeds/.eslintrc @@ -0,0 +1,5 @@ +{ + "rules": { + "import/prefer-default-export": 0 + } +} \ No newline at end of file diff --git a/modules/user/server-ts/seeds/002_user.js b/modules/user/server-ts/seeds/002_user.js new file mode 100644 index 0000000..6565d8b --- /dev/null +++ b/modules/user/server-ts/seeds/002_user.js @@ -0,0 +1,22 @@ +import bcrypt from 'bcryptjs'; +import { returnId, truncateTables } from '@restapp/database-server-ts'; + +export async function seed(knex, Promise) { + await truncateTables(knex, Promise, ['user', 'user_profile', 'auth_facebook', 'auth_github', 'auth_linkedin']); + + await returnId(knex('user')).insert({ + username: 'admin', + email: 'admin@example.com', + password_hash: await bcrypt.hash('admin123', 12), + role: 'admin', + is_active: true + }); + + await returnId(knex('user')).insert({ + username: 'user', + email: 'user@example.com', + password_hash: await bcrypt.hash('user1234', 12), + role: 'user', + is_active: true + }); +} diff --git a/modules/user/server-ts/social/facebook/index.ts b/modules/user/server-ts/social/facebook/index.ts new file mode 100644 index 0000000..fa23afd --- /dev/null +++ b/modules/user/server-ts/social/facebook/index.ts @@ -0,0 +1,40 @@ +import { pick } from 'lodash'; +import { AuthModule } from '@restapp/authentication-server-ts'; +import { onAuthenticationSuccess, registerUser, UserSocial } from '../shared'; +import UserDAO, { UserShape } from '../../sql'; +import settings from '../../../../../settings'; + +const createFacebookAuth = async (user: any) => UserDAO.createFacebookAuth(user); + +async function verifyCallback(accessToken: string, refreshToken: string, profile: UserSocial, cb: any) { + const { + id, + displayName, + emails: [{ value }] + } = profile; + + try { + let user = (await UserDAO.getUserByFbIdOrEmail(id, value)) as UserShape & { fbId: number }; + + if (!user) { + const [createdUserId] = await registerUser(profile); + await createFacebookAuth({ id, displayName, userId: createdUserId }); + user = (await UserDAO.getUser(createdUserId)) as UserShape & { fbId: number }; + } else if (!user.fbId) { + await createFacebookAuth({ id, displayName, userId: user.id }); + } + + return cb(null, pick(user, ['id', 'username', 'role', 'email'])); + } catch (err) { + return cb(err, {}); + } +} + +export const facebookData = { + facebook: { + onAuthenticationSuccess, + verifyCallback + } +}; + +export default (settings.auth.social.facebook.enabled && !__TEST__ ? new AuthModule() : undefined); diff --git a/modules/user/server-ts/social/github/index.ts b/modules/user/server-ts/social/github/index.ts new file mode 100644 index 0000000..e28c59f --- /dev/null +++ b/modules/user/server-ts/social/github/index.ts @@ -0,0 +1,41 @@ +import { pick } from 'lodash'; +import { AuthModule } from '@restapp/authentication-server-ts'; +import { onAuthenticationSuccess, registerUser, UserSocial } from '../shared'; +import UserDAO, { UserShape } from '../../sql'; + +import settings from '../../../../../settings'; + +const createGithubAuth = async (user: any) => UserDAO.createGithubAuth(user); + +async function verifyCallback(accessToken: string, refreshToken: string, profile: UserSocial, cb: any) { + const { + id, + displayName, + emails: [{ value }] + } = profile; + + try { + let user = (await UserDAO.getUserByGHIdOrEmail(id, value)) as UserShape & { ghId: number }; + + if (!user) { + const [createdUserId] = await registerUser(profile); + await createGithubAuth({ id, displayName, userId: createdUserId }); + user = (await UserDAO.getUser(createdUserId)) as UserShape & { ghId: number }; + } else if (!user.ghId) { + await createGithubAuth({ id, displayName, userId: user.id }); + } + + return cb(null, pick(user, ['id', 'username', 'role', 'email'])); + } catch (err) { + return cb(err, {}); + } +} + +export const githubData = { + github: { + onAuthenticationSuccess, + verifyCallback + } +}; + +export default (settings.auth.social.github.enabled && !__TEST__ ? new AuthModule() : undefined); diff --git a/modules/user/server-ts/social/google/index.ts b/modules/user/server-ts/social/google/index.ts new file mode 100644 index 0000000..cae3da2 --- /dev/null +++ b/modules/user/server-ts/social/google/index.ts @@ -0,0 +1,66 @@ +import { pick } from 'lodash'; +import { AuthModule } from '@restapp/authentication-server-ts'; +import { onAuthenticationSuccess, UserSocial } from '../shared'; +import UserDAO from '../../sql'; +import settings from '../../../../../settings'; +import { UserShape } from '@restapp/authentication-server-ts/access'; + +interface UserSocialGoogle extends UserSocial { + name: { + givenName?: string; + familyName?: string; + }; +} + +const registerUser = async ({ emails: [{ value }] }: UserSocial) => { + return UserDAO.register({ + username: value, + email: value, + isActive: true + }); +}; + +const createGoogleOAuth = async (user: any) => UserDAO.createGoogleOAuth(user); + +async function verifyCallback(accessToken: string, refreshToken: string, profile: UserSocialGoogle, cb: any) { + const { + id, + displayName, + emails: [{ value }] + } = profile; + + try { + let user = (await UserDAO.getUserByGoogleIdOrEmail(id, value)) as UserShape & { googleId: number }; + + if (!user) { + const [createdUserId] = await registerUser(profile); + + await createGoogleOAuth({ id, displayName, userId: createdUserId }); + + await UserDAO.editUserProfile({ + id: createdUserId, + profile: { + firstName: profile.name.givenName, + lastName: profile.name.familyName + } + }); + + user = (await UserDAO.getUser(createdUserId)) as UserShape & { googleId: number }; + } else if (!user.googleId) { + await createGoogleOAuth({ id, displayName, userId: user.id }); + } + + return cb(null, pick(user, ['id', 'username', 'role', 'email'])); + } catch (err) { + return cb(err, {}); + } +} + +export const googleData = { + google: { + onAuthenticationSuccess, + verifyCallback + } +}; + +export default (settings.auth.social.google.enabled && !__TEST__ ? new AuthModule() : undefined); diff --git a/modules/user/server-ts/social/index.ts b/modules/user/server-ts/social/index.ts new file mode 100644 index 0000000..011b17a --- /dev/null +++ b/modules/user/server-ts/social/index.ts @@ -0,0 +1,16 @@ +import { AuthModule } from '@restapp/authentication-server-ts'; +import facebook, { facebookData } from './facebook'; +import github, { githubData } from './github'; +import google, { googleData } from './google'; +import linkedin, { linkedinData } from './linkedIn'; + +const social = { + ...facebookData, + ...githubData, + ...googleData, + ...linkedinData +}; + +export default new AuthModule(facebook, github, google, linkedin, { + appContext: { social } +}); diff --git a/modules/user/server-ts/social/linkedIn/index.ts b/modules/user/server-ts/social/linkedIn/index.ts new file mode 100644 index 0000000..6831bba --- /dev/null +++ b/modules/user/server-ts/social/linkedIn/index.ts @@ -0,0 +1,36 @@ +import { UserShape } from './../../sql'; +import { pick } from 'lodash'; +import { AuthModule } from '@restapp/authentication-server-ts'; +import { onAuthenticationSuccess, registerUser, UserSocial } from '../shared'; +import UserDAO from '../../sql'; +import settings from '../../../../../settings'; + +const createLinkedInAuth = async (user: any) => UserDAO.createLinkedInAuth(user); + +async function verifyCallback(accessToken: string, refreshToken: string, profile: UserSocial, cb: any) { + const { id, displayName } = profile; + try { + let user = (await UserDAO.getUserByLnInIdOrEmail(id)) as UserShape & { lnId: number }; + + if (!user) { + const [createdUserId] = await registerUser(profile); + await createLinkedInAuth({ id, displayName, userId: createdUserId }); + user = (await UserDAO.getUser(createdUserId)) as UserShape & { lnId: number }; + } else if (!user.lnId) { + await createLinkedInAuth({ id, displayName, userId: user.id }); + } + + return cb(null, pick(user, ['id', 'username', 'role', 'email'])); + } catch (err) { + return cb(err, {}); + } +} + +export const linkedinData = { + linkedin: { + onAuthenticationSuccess, + verifyCallback + } +}; + +export default (settings.auth.social.linkedin.enabled && !__TEST__ ? new AuthModule() : undefined); diff --git a/modules/user/server-ts/social/shared.ts b/modules/user/server-ts/social/shared.ts new file mode 100644 index 0000000..ce5df5a --- /dev/null +++ b/modules/user/server-ts/social/shared.ts @@ -0,0 +1,29 @@ +import { isEmpty } from 'lodash'; +import { access } from '@restapp/authentication-server-ts'; +import UserDAO, { UserShapePassword } from '../sql'; + +export interface UserSocial { + id: number; + username: string; + displayName: string; + emails: Array<{ value: string }>; +} + +export async function onAuthenticationSuccess(req: any, res: any) { + const user = (await UserDAO.getUserWithPassword(req.user.id)) as UserShapePassword; + const redirectUrl = req.query.state; + const tokens = await access.grantAccess(user, req, user.passwordHash); + if (redirectUrl) { + res.redirect(redirectUrl + (!isEmpty(tokens) ? '?data=' + JSON.stringify({ tokens }) : '')); + } else { + res.redirect(`/login${!isEmpty(tokens) ? '?data=' + JSON.stringify({ tokens }) : ''}`); + } +} + +export const registerUser = async ({ username, displayName, emails: [{ value }] = [{ value: '' }] }: UserSocial) => { + return UserDAO.register({ + username: username || displayName, + email: value, + isActive: true + }); +}; diff --git a/modules/user/server-ts/sql.ts b/modules/user/server-ts/sql.ts new file mode 100644 index 0000000..84ff877 --- /dev/null +++ b/modules/user/server-ts/sql.ts @@ -0,0 +1,283 @@ +// Helpers +import { camelizeKeys, decamelizeKeys, decamelize } from 'humps'; +import { has } from 'lodash'; +import bcrypt from 'bcryptjs'; +import { knex, returnId } from '@restapp/database-server-ts'; + +export interface UserShape { + id?: number; + username: string; + role?: string; + isActive?: boolean; + email?: string; + userId?: number; +} + +export interface Profile { + firstName?: string; + lastName?: string; +} + +export interface UserShapePassword extends UserShape { + passwordHash?: string; +} + +interface OrderBy { + column: string; + order: string; +} + +interface Filter { + role: string; + isActive: boolean; + searchText: string; +} + +interface SocialInterface { + id: number; + displayName: string; + userId: number; +} + +const userColumns = ['u.id', 'u.username', 'u.role', 'u.is_active', 'u.email', 'up.first_name', 'up.last_name']; +const userColumnsWithSocial = [ + ...userColumns, + 'fa.fb_id', + 'fa.display_name AS fbDisplayName', + 'lna.ln_id', + 'lna.display_name AS lnDisplayName', + 'gha.gh_id', + 'gha.display_name AS ghDisplayName', + 'ga.google_id', + 'ga.display_name AS googleDisplayName' +]; +const userColumnsWithPassword = [...userColumns, 'u.password_hash']; + +class UserDAO { + public async getUsers(orderBy: OrderBy, filter: Filter) { + const queryBuilder = knex + .select(...userColumnsWithSocial) + .from('user AS u') + .leftJoin('user_profile AS up', 'up.user_id', 'u.id') + .leftJoin('auth_facebook AS fa', 'fa.user_id', 'u.id') + .leftJoin('auth_google AS ga', 'ga.user_id', 'u.id') + .leftJoin('auth_github AS gha', 'gha.user_id', 'u.id') + .leftJoin('auth_linkedin AS lna', 'lna.user_id', 'u.id'); + + // add order by + if (orderBy && orderBy.column) { + const column = orderBy.column; + let order = 'asc'; + if (orderBy.order) { + order = orderBy.order; + } + + queryBuilder.orderBy(decamelize(column), order); + } + + // add filter conditions + if (filter) { + if (has(filter, 'role') && filter.role !== '') { + queryBuilder.where(function() { + this.where('u.role', filter.role); + }); + } + + if (has(filter, 'isActive') && filter.isActive !== null) { + queryBuilder.where(function() { + this.where('u.is_active', filter.isActive); + }); + } + + if (has(filter, 'searchText') && filter.searchText !== '') { + queryBuilder.where(function() { + this.where(knex.raw('LOWER(??) LIKE LOWER(?)', ['username', `%${filter.searchText}%`])) + .orWhere(knex.raw('LOWER(??) LIKE LOWER(?)', ['email', `%${filter.searchText}%`])) + .orWhere(knex.raw('LOWER(??) LIKE LOWER(?)', ['first_name', `%${filter.searchText}%`])) + .orWhere(knex.raw('LOWER(??) LIKE LOWER(?)', ['last_name', `%${filter.searchText}%`])); + }); + } + } + + return camelizeKeys(await queryBuilder); + } + + public async getUser(id: number) { + return camelizeKeys( + await knex + .select(...userColumnsWithSocial) + .from('user AS u') + .leftJoin('user_profile AS up', 'up.user_id', 'u.id') + .leftJoin('auth_facebook AS fa', 'fa.user_id', 'u.id') + .leftJoin('auth_google AS ga', 'ga.user_id', 'u.id') + .leftJoin('auth_github AS gha', 'gha.user_id', 'u.id') + .leftJoin('auth_linkedin AS lna', 'lna.user_id', 'u.id') + .where('u.id', '=', id) + .first() + ); + } + + public async getUserWithPassword(id: number) { + return camelizeKeys( + await knex + .select(...userColumnsWithPassword) + .from('user AS u') + .where('u.id', '=', id) + .leftJoin('user_profile AS up', 'up.user_id', 'u.id') + .first() + ); + } + + public register({ username, email, role = 'user', isActive }: UserShape, passwordHash?: string | false) { + return knex('user').insert(decamelizeKeys({ username, email, role, passwordHash, isActive })); + } + + public createFacebookAuth({ id, displayName, userId }: SocialInterface) { + return returnId(knex('auth_facebook')).insert({ fb_id: id, display_name: displayName, user_id: userId }); + } + + public createGithubAuth({ id, displayName, userId }: SocialInterface) { + return returnId(knex('auth_github')).insert({ gh_id: id, display_name: displayName, user_id: userId }); + } + + public createGoogleOAuth({ id, displayName, userId }: SocialInterface) { + return returnId(knex('auth_google')).insert({ google_id: id, display_name: displayName, user_id: userId }); + } + + public createLinkedInAuth({ id, displayName, userId }: SocialInterface) { + return returnId(knex('auth_linkedin')).insert({ ln_id: id, display_name: displayName, user_id: userId }); + } + + public editUser({ id, username, email, role, isActive }: UserShape, passwordHash: string) { + const localAuthInput = passwordHash ? { email, passwordHash } : { email }; + return knex('user') + .update(decamelizeKeys({ username, role, isActive, ...localAuthInput })) + .where({ id }); + } + + public async isUserProfileExists(userId: number) { + return !!(await knex('user_profile') + .count('id as count') + .where(decamelizeKeys({ userId })) + .first()).count; + } + + public editUserProfile({ id, profile }: { id: number; profile: any }, isExists?: boolean) { + if (isExists) { + return knex('user_profile') + .update(decamelizeKeys(profile)) + .where({ user_id: id }); + } else { + return returnId(knex('user_profile')).insert({ ...decamelizeKeys(profile), user_id: id }); + } + } + + public deleteUser(id: number) { + return knex('user') + .where('id', '=', id) + .del(); + } + + public async updatePassword(id: number, newPassword: string) { + const passwordHash = await bcrypt.hash(newPassword, 12); + + return knex('user') + .update({ password_hash: passwordHash }) + .where({ id }); + } + + public updateActive(id: number, isActive: boolean) { + return knex('user') + .update({ is_active: isActive }) + .where({ id }); + } + + public async getUserByEmail(email: string) { + return camelizeKeys( + await knex + .select(...userColumnsWithPassword) + .from('user AS u') + .leftJoin('user_profile AS up', 'up.user_id', 'u.id') + .where({ email }) + .first() + ); + } + + public async getUserByFbIdOrEmail(id: number, email: string) { + return camelizeKeys( + await knex + .select(...userColumnsWithPassword, 'fa.fb_id') + .from('user AS u') + .leftJoin('auth_facebook AS fa', 'fa.user_id', 'u.id') + .leftJoin('user_profile AS up', 'up.user_id', 'u.id') + .where('fa.fb_id', '=', id) + .orWhere('u.email', '=', email) + .first() + ); + } + + public async getUserByLnInIdOrEmail(id: number, email: string = '') { + return camelizeKeys( + await knex + .select(...userColumnsWithPassword, 'lna.ln_id') + .from('user AS u') + .leftJoin('auth_linkedin AS lna', 'lna.user_id', 'u.id') + .leftJoin('user_profile AS up', 'up.user_id', 'u.id') + .where('lna.ln_id', '=', id) + .orWhere('u.email', '=', email) + .first() + ); + } + + public async getUserByGHIdOrEmail(id: number, email: string) { + return camelizeKeys( + await knex + .select(...userColumnsWithPassword, 'gha.gh_id') + .from('user AS u') + .leftJoin('auth_github AS gha', 'gha.user_id', 'u.id') + .leftJoin('user_profile AS up', 'up.user_id', 'u.id') + .where('gha.gh_id', '=', id) + .orWhere('u.email', '=', email) + .first() + ); + } + + public async getUserByGoogleIdOrEmail(id: number, email: string) { + return camelizeKeys( + await knex + .select(...userColumnsWithPassword, 'ga.google_id') + .from('user AS u') + .leftJoin('auth_google AS ga', 'ga.user_id', 'u.id') + .leftJoin('user_profile AS up', 'up.user_id', 'u.id') + .where('ga.google_id', '=', id) + .orWhere('u.email', '=', email) + .first() + ); + } + + public async getUserByUsername(username: string) { + return camelizeKeys( + await knex + .select(...userColumns) + .from('user AS u') + .where('u.username', '=', username) + .leftJoin('user_profile AS up', 'up.user_id', 'u.id') + .first() + ); + } + + public async getUserByUsernameOrEmail(usernameOrEmail: string) { + return camelizeKeys( + await knex + .select(...userColumnsWithPassword) + .from('user AS u') + .where('u.username', '=', usernameOrEmail) + .orWhere('u.email', '=', usernameOrEmail) + .leftJoin('user_profile AS up', 'up.user_id', 'u.id') + .first() + ); + } +} +const userDAO = new UserDAO(); + +export default userDAO; diff --git a/packages/client/package.json b/packages/client/package.json index 7c95010..b223dfd 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -40,15 +40,19 @@ "dependencies": { "@fortawesome/fontawesome": "^1.1.8", "@fortawesome/fontawesome-free-brands": "^5.0.6", - "@fortawesome/react-fontawesome": "0.0.18", + "@fortawesome/fontawesome-svg-core": "^1.2.10", + "@fortawesome/react-fontawesome": "^0.1.3", "@restapp/core-client-react": "^0.1.0", "@restapp/forms-client-react": "^0.1.0", "@restapp/i18n-client-react": "^0.1.0", "@restapp/i18n-common-react": "^0.1.0", "@restapp/module-client-react": "^0.1.0", + "@restapp/user-client-react": "^0.1.0", + "@restapp/authentication-client-react": "^0.1.0", "@restapp/validation-common-react": "^0.1.0", "@restapp/welcome-client-react": "^0.1.0", "antd": "^3.10.0", + "axios": "^0.18.0", "babel-polyfill": "^6.26.0", "bootstrap": "^4.1.1", "error-stack-parser": "^2.0.1", @@ -147,4 +151,4 @@ "not ie < 11" ] } -} +} \ No newline at end of file diff --git a/packages/client/src/modules.ts b/packages/client/src/modules.ts index 85cdc5c..828b088 100644 --- a/packages/client/src/modules.ts +++ b/packages/client/src/modules.ts @@ -1,4 +1,6 @@ import welcome from '@restapp/welcome-client-react'; +import user from '@restapp/user-client-react'; +import authentication from '@restapp/authentication-client-react'; import core from '@restapp/core-client-react'; import look from '@restapp/look-client-react'; import i18n from '@restapp/i18n-client-react'; @@ -9,6 +11,16 @@ import '@restapp/favicon-common'; const pageNotFound = require('@restapp/page-not-found-client-react').default; -const modules = new ClientModule(welcome, look, validation, defaultRouter, i18n, pageNotFound, core); +const modules = new ClientModule( + authentication, + user, + welcome, + look, + validation, + defaultRouter, + i18n, + pageNotFound, + core +); export default modules; diff --git a/packages/client/typings/typings.d.ts b/packages/client/typings/typings.d.ts index 9f68cd8..bb8bb24 100644 --- a/packages/client/typings/typings.d.ts +++ b/packages/client/typings/typings.d.ts @@ -13,3 +13,4 @@ interface Window { declare module 'react-native-credit-card-input'; declare module 'sourcemapped-stacktrace'; declare module 'minilog'; +declare module 'reactstrap'; diff --git a/packages/mobile/src/modules.ts b/packages/mobile/src/modules.ts index ba8e395..2523612 100644 --- a/packages/mobile/src/modules.ts +++ b/packages/mobile/src/modules.ts @@ -1,4 +1,6 @@ import welcome from '@restapp/welcome-client-react'; +import user from '@restapp/user-client-react'; +import authentication from '@restapp/authentication-client-react'; import core from '@restapp/core-client-react-native'; import i18n from '@restapp/i18n-client-react'; import validation from '@restapp/validation-common-react'; @@ -6,6 +8,6 @@ import defaultRouter from '@restapp/router-client-react-native'; import ClientModule from '@restapp/module-client-react-native'; -const modules = new ClientModule(welcome, validation, defaultRouter, i18n, core); +const modules = new ClientModule(defaultRouter, authentication, user, welcome, validation, i18n, core); export default modules; diff --git a/packages/server/.env b/packages/server/.env index e43508d..f59989f 100644 --- a/packages/server/.env +++ b/packages/server/.env @@ -19,8 +19,7 @@ GOOGLE_CLIENTID= GOOGLE_CLIENTSECRET= # Email -EMAIL_HOST=smtp.ethereal.email -EMAIL_PORT=587 -EMAIL_USER=olgv7abv3lcmipb7@ethereal.email -EMAIL_PASSWORD=VTKxTbK7RPFNBjQwp9 - +EMAIL_HOST= +EMAIL_PORT= +EMAIL_USER= +EMAIL_PASSWORD= diff --git a/packages/server/package.json b/packages/server/package.json index 41e96c9..c068b48 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -51,16 +51,20 @@ "@babel/polyfill": "^7.0.0", "@babel/preset-env": "^7.0.0", "@babel/register": "^7.0.0", + "@restapp/authentication-server-ts": "^0.1.0", "@restapp/core-common": "^0.1.0", "@restapp/database-server-ts": "^0.1.0", "@restapp/testing-server-ts": "^0.1.0", + "@restapp/user-server-ts": "^0.1.0", "@restapp/welcome-server-ts": "^0.1.0", + "@sokratis/passport-linkedin-oauth2": "^2.0.2", "@types/pdfmake": "^0.1.3", "bcryptjs": "^2.4.3", "body-parser": "^1.18.3", "dotenv": "^5.0.1", "error-stack-parser": "^2.0.1", "express": "^4.16.2", + "express-session": "^1.16.1", "filesize": "^3.5.11", "glob": "^7.1.4", "humps": "^2.0.1", @@ -79,6 +83,7 @@ "passport-facebook": "^2.1.1", "passport-github": "^1.1.0", "passport-google-oauth": "^1.0.0", + "passport-jwt": "^4.0.0", "passport-linkedin-oauth2": "^1.5.0", "passport-local": "^1.0.0", "performance-now": "^2.1.0", @@ -95,6 +100,7 @@ "redux": "^4.0.0", "roboto-npm-webfont": "^1.0.1", "serialize-javascript": "^1.4.0", + "session-file-store": "^1.2.0", "shortid": "^2.2.8", "source-map-support": "^0.5.0", "sourcemapped-stacktrace": "^1.1.8", @@ -106,14 +112,23 @@ }, "devDependencies": { "@alienfast/i18next-loader": "^1.0.15", + "@types/bcryptjs": "^2.4.2", "@types/chai": "^4.1.4", "@types/chai-http": "^3.0.5", + "@types/express-session": "^1.15.12", "@types/humps": "^1.1.2", "@types/i18next": "^8.4.4", "@types/i18next-express-middleware": "^0.0.33", + "@types/jsonwebtoken": "^8.3.2", "@types/knex": "^0.14.20", "@types/mkdirp": "^0.5.2", "@types/nodemailer": "^4.6.2", + "@types/passport": "^1.0.0", + "@types/passport-facebook": "^2.1.9", + "@types/passport-github": "^1.1.5", + "@types/passport-google-oauth": "^1.0.41", + "@types/passport-jwt": "^3.0.1", + "@types/passport-local": "^1.0.33", "@types/react-dom": "^16.8.0", "@types/serialize-javascript": "^1.3.2", "@types/shortid": "0.0.29", diff --git a/packages/server/src/modules.ts b/packages/server/src/modules.ts index 4de5266..28c4d6d 100644 --- a/packages/server/src/modules.ts +++ b/packages/server/src/modules.ts @@ -4,10 +4,12 @@ import i18n from '@restapp/i18n-server-ts'; import validation from '@restapp/validation-common-react'; import cookies from '@restapp/cookies-server-ts'; import mailer from '@restapp/mailer-server-ts'; +import user from '@restapp/user-server-ts'; +import authentication from '@restapp/authentication-server-ts'; import '@restapp/debug-server-ts'; import ServerModule from '@restapp/module-server-ts'; -const modules: ServerModule = new ServerModule(welcome, cookies, i18n, validation, mailer, core); +const modules: ServerModule = new ServerModule(authentication, welcome, cookies, i18n, validation, mailer, core, user); export default modules; diff --git a/packages/server/typings/typings.d.ts b/packages/server/typings/typings.d.ts index 13c5dc3..9a48eb4 100644 --- a/packages/server/typings/typings.d.ts +++ b/packages/server/typings/typings.d.ts @@ -15,3 +15,4 @@ declare var global: Global; // packages without types declare module 'universal-cookie-express'; +declare module '@sokratis/passport-linkedin-oauth2'; \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 7204ed1..b6a3f59 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1218,6 +1218,11 @@ resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-0.1.7.tgz#4336c4b06d0b5608ff1215464b66fcf9f4795284" integrity sha512-ego8jRVSHfq/iq4KRZJKQeUAdi3ZjGNrqw4oPN3fNdvTBnLCSntwVCnc37bsAJP9UB8MhrTfPnZYxkv2vpS4pg== +"@fortawesome/fontawesome-common-types@^0.2.19": + version "0.2.19" + resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-0.2.19.tgz#754a0f85e1290858152e1c05700ab502b11197f1" + integrity sha512-nd2Ul/CUs8U9sjofQYAALzOGpgkVJQgEhIJnOHaoyVR/LeC3x2mVg4eB910a4kS6WgLPebAY0M2fApEI497raQ== + "@fortawesome/fontawesome-free-brands@^5.0.6": version "5.0.13" resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-free-brands/-/fontawesome-free-brands-5.0.13.tgz#4d15ff4e1e862d5e4a4df3654f8e8acbd47e9c09" @@ -1225,6 +1230,13 @@ dependencies: "@fortawesome/fontawesome-common-types" "^0.1.7" +"@fortawesome/fontawesome-svg-core@^1.2.10": + version "1.2.19" + resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-1.2.19.tgz#0eca1ce9285c3d99e6e340633ee8f615f9d1a2e0" + integrity sha512-D4ICXg9oU08eF9o7Or392gPpjmwwgJu8ecCFusthbID95CLVXOgIyd4mOKD9Nud5Ckz+Ty59pqkNtThDKR0erA== + dependencies: + "@fortawesome/fontawesome-common-types" "^0.2.19" + "@fortawesome/fontawesome@^1.1.8": version "1.1.8" resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome/-/fontawesome-1.1.8.tgz#75fe66a60f95508160bb16bd781ad7d89b280f5b" @@ -1232,12 +1244,13 @@ dependencies: "@fortawesome/fontawesome-common-types" "^0.1.7" -"@fortawesome/react-fontawesome@0.0.18": - version "0.0.18" - resolved "https://registry.yarnpkg.com/@fortawesome/react-fontawesome/-/react-fontawesome-0.0.18.tgz#4e0eb1cf9797715a67bb7705ae084fa0a410f185" - integrity sha512-ZSO55oWtGSZFvoJW0gvhlYndKvHqmMj/27qTrJOT4DaxQ4Fcc90h/BVEED6IktzBqEunUF8aP+bwU5Og1I0fnQ== +"@fortawesome/react-fontawesome@^0.1.3": + version "0.1.4" + resolved "https://registry.yarnpkg.com/@fortawesome/react-fontawesome/-/react-fontawesome-0.1.4.tgz#18d61d9b583ca289a61aa7dccc05bd164d6bc9ad" + integrity sha512-GwmxQ+TK7PEdfSwvxtGnMCqrfEm0/HbRHArbUudsYiy9KzVCwndxa2KMcfyTQ8El0vROrq8gOOff09RF1oQe8g== dependencies: humps "^2.0.1" + prop-types "^15.5.10" "@improved/node@^1.0.0": version "1.0.0" @@ -2228,6 +2241,14 @@ resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-0.7.0.tgz#9a06f4f137ee84d7df0460c1fdb1135ffa6c50fd" integrity sha512-ONhaKPIufzzrlNbqtWFFd+jlnemX6lJAgq9ZeiZtS7I1PIf/la7CW4m83rTXRnVnsMbW2k56pGYu7AUFJD9Pow== +"@sokratis/passport-linkedin-oauth2@^2.0.2": + version "2.1.0" + resolved "https://registry.yarnpkg.com/@sokratis/passport-linkedin-oauth2/-/passport-linkedin-oauth2-2.1.0.tgz#4108354f7a9dbb34d45230a5e655d346aee239fa" + integrity sha512-+Yimvm4wm++eezRft3WFywTyDwHVTiDPnRR9wqr8oNXigcymDni7o/eh9zAFVTGXYeUnPPmp+eLGQRHuDYmZQg== + dependencies: + passport-oauth2 "1.x.x" + underscore "^1.7.0" + "@types/babel__core@^7.1.0": version "7.1.2" resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.1.2.tgz#608c74f55928033fce18b99b213c16be4b3d114f" @@ -2261,6 +2282,11 @@ dependencies: "@babel/types" "^7.3.0" +"@types/bcryptjs@^2.4.2": + version "2.4.2" + resolved "https://registry.yarnpkg.com/@types/bcryptjs/-/bcryptjs-2.4.2.tgz#e3530eac9dd136bfdfb0e43df2c4c5ce1f77dfae" + integrity sha512-LiMQ6EOPob/4yUL66SZzu6Yh77cbzJFYll+ZfaPiPPFswtIlA/Fs1MzdKYA7JApHU49zQTbJGX3PDmCpIdDBRQ== + "@types/bluebird@*": version "3.5.27" resolved "https://registry.yarnpkg.com/@types/bluebird/-/bluebird-3.5.27.tgz#61eb4d75dc6bfbce51cf49ee9bbebe941b2cb5d0" @@ -2331,6 +2357,14 @@ "@types/node" "*" "@types/range-parser" "*" +"@types/express-session@^1.15.12": + version "1.15.12" + resolved "https://registry.yarnpkg.com/@types/express-session/-/express-session-1.15.12.tgz#1126704826e80f8381da4fbbb35a199f550d1433" + integrity sha512-DHZXzWy6Nu5Ng0syXUiVFRpZ6/1DOXoTCWa6RG3itGrub2ioBYvgtDbkT6VHHNo3iOdHRROyWANsMBJVaflblQ== + dependencies: + "@types/express" "*" + "@types/node" "*" + "@types/express@*": version "4.16.1" resolved "https://registry.yarnpkg.com/@types/express/-/express-4.16.1.tgz#d756bd1a85c34d87eaf44c888bad27ba8a4b7cf0" @@ -2433,6 +2467,13 @@ "@types/tough-cookie" "*" parse5 "^4.0.0" +"@types/jsonwebtoken@*", "@types/jsonwebtoken@^8.3.2": + version "8.3.2" + resolved "https://registry.yarnpkg.com/@types/jsonwebtoken/-/jsonwebtoken-8.3.2.tgz#e3d5245197152346fae7ee87d5541aa5a92d0362" + integrity sha512-Mkjljd9DTpkPlrmGfTJvcP4aBU7yO2QmW7wNVhV4/6AEUxYoacqU7FJU/N0yFEHTsIrE4da3rUrjrR5ejicFmA== + dependencies: + "@types/node" "*" + "@types/knex@^0.14.20": version "0.14.26" resolved "https://registry.yarnpkg.com/@types/knex/-/knex-0.14.26.tgz#18c1b5d5a7e44bb0d394209f7e0e965b2987ce57" @@ -2487,6 +2528,80 @@ dependencies: "@types/node" "*" +"@types/oauth@*": + version "0.9.1" + resolved "https://registry.yarnpkg.com/@types/oauth/-/oauth-0.9.1.tgz#e17221e7f7936b0459ae7d006255dff61adca305" + integrity sha512-a1iY62/a3yhZ7qH7cNUsxoI3U/0Fe9+RnuFrpTKr+0WVOzbKlSLojShCKe20aOD1Sppv+i8Zlq0pLDuTJnwS4A== + dependencies: + "@types/node" "*" + +"@types/passport-facebook@^2.1.9": + version "2.1.9" + resolved "https://registry.yarnpkg.com/@types/passport-facebook/-/passport-facebook-2.1.9.tgz#0181b40be4e002705f7a29d64da9029c2ab464dc" + integrity sha512-7Y5aHo35Io9fRvStBgXasf+6rRJ5SMiu1D3+cnUuxM1GS+QlgFnH+YTZjye0XgF4EWzxlXKTeLlK2DFU8+0Jpw== + dependencies: + "@types/express" "*" + "@types/passport" "*" + +"@types/passport-github@^1.1.5": + version "1.1.5" + resolved "https://registry.yarnpkg.com/@types/passport-github/-/passport-github-1.1.5.tgz#57ea20d1de0789b98a7157603d76d87c271de6b2" + integrity sha512-BeWDdLRWfPpJcmT1XofY5r1Av//TcxBEgllY4LnArcYdGqbIIVLyHwR+8bIG+ZC4PwJ6W1trnVEG3EQ+5J+Jmw== + dependencies: + "@types/express" "*" + "@types/passport" "*" + "@types/passport-oauth2" "*" + +"@types/passport-google-oauth@^1.0.41": + version "1.0.41" + resolved "https://registry.yarnpkg.com/@types/passport-google-oauth/-/passport-google-oauth-1.0.41.tgz#e24a8a070dcc5139f0a205c72730b14e83c1405a" + integrity sha512-sl79Oau92bilbq6Sv47StzEt98qhu3kMjOBK4GaJX6XtG2a3bwVEebYkwxPrJkz7wb9S1O9x2Dtb3jMqqj9j9Q== + dependencies: + "@types/express" "*" + "@types/passport" "*" + +"@types/passport-jwt@^3.0.1": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@types/passport-jwt/-/passport-jwt-3.0.1.tgz#bc4c2610815565de977ea1a580c047d71c646084" + integrity sha512-JwF9U/Rmr6YicHSu/MITmHNDy2KeiedxKW2bhz6wZts3y4cq48NiN0UD98zO56TyM5Vm6BpyjFxcs6jh68ni/A== + dependencies: + "@types/express" "*" + "@types/jsonwebtoken" "*" + "@types/passport-strategy" "*" + +"@types/passport-local@^1.0.33": + version "1.0.33" + resolved "https://registry.yarnpkg.com/@types/passport-local/-/passport-local-1.0.33.tgz#d245b60c5b801cb3aeca1ffab557d5fe1534260d" + integrity sha512-+rn6ZIxje0jZ2+DAiWFI8vGG7ZFKB0hXx2cUdMmudSWsigSq6ES7Emso46r4HJk0qCgrZVfI8sJiM7HIYf4SbA== + dependencies: + "@types/express" "*" + "@types/passport" "*" + "@types/passport-strategy" "*" + +"@types/passport-oauth2@*": + version "1.4.8" + resolved "https://registry.yarnpkg.com/@types/passport-oauth2/-/passport-oauth2-1.4.8.tgz#b11cb3323970a88db3d170fe3d4dcdd0af02d8bf" + integrity sha512-tlX16wyFE5YJR2pHpZ308dgB1MV9/Ra2wfQh71eWk+/umPoD1Rca2D4N5M27W7nZm1wqUNGTk1I864nHvEgiFA== + dependencies: + "@types/express" "*" + "@types/oauth" "*" + "@types/passport" "*" + +"@types/passport-strategy@*": + version "0.2.35" + resolved "https://registry.yarnpkg.com/@types/passport-strategy/-/passport-strategy-0.2.35.tgz#e52f5212279ea73f02d9b06af67efe9cefce2d0c" + integrity sha512-o5D19Jy2XPFoX2rKApykY15et3Apgax00RRLf0RUotPDUsYrQa7x4howLYr9El2mlUApHmCMv5CZ1IXqKFQ2+g== + dependencies: + "@types/express" "*" + "@types/passport" "*" + +"@types/passport@*", "@types/passport@^1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@types/passport/-/passport-1.0.0.tgz#747fa127a747a145ff279f3df3e07c425e5ff297" + integrity sha512-R2FXqM+AgsMIym0PuKj08Ybx+GR6d2rU3b1/8OcHolJ+4ga2pRPX105wboV6hq1AJvMo2frQzYKdqXS5+4cyMw== + dependencies: + "@types/express" "*" + "@types/pdfmake@^0.1.3": version "0.1.7" resolved "https://registry.yarnpkg.com/@types/pdfmake/-/pdfmake-0.1.7.tgz#7f4fa9e8925240a6eee8ded5c8a26e20eb0f59c3" @@ -3585,6 +3700,14 @@ axios@0.16.2: follow-redirects "^1.2.3" is-buffer "^1.1.5" +axios@^0.18.0: + version "0.18.1" + resolved "https://registry.yarnpkg.com/axios/-/axios-0.18.1.tgz#ff3f0de2e7b5d180e757ad98000f1081b87bcea3" + integrity sha512-0BfJq4NSfQXd+SkFdrvFbG7addhYSBA2mQwISr46pD6E5iqkWg02RAs8vyTT/j0RTnoYmeXauBuSv1qKwR179g== + dependencies: + follow-redirects "1.5.10" + is-buffer "^2.0.2" + axios@v0.19.0-beta.1: version "0.19.0-beta.1" resolved "https://registry.yarnpkg.com/axios/-/axios-0.19.0-beta.1.tgz#3d6a9ee75885d1fd39e108df9a4fb2e48e1af1e8" @@ -4429,6 +4552,11 @@ babylon@^6.18.0: resolved "https://registry.yarnpkg.com/babylon/-/babylon-6.18.0.tgz#af2f3b88fa6f5c1e4c634d1a0f8eac4f55b395e3" integrity sha512-q/UEjfGJ2Cm3oKV71DJz9d25TPnq5rhBVL2Q4fA5wcC3jcrdn7+SssEybFIxwAvvP+YCsCYNKughoF33GxgycQ== +bagpipe@^0.3.5: + version "0.3.5" + resolved "https://registry.yarnpkg.com/bagpipe/-/bagpipe-0.3.5.tgz#e341d164fcb24cdf04ea7e05b765ec10c8aea6a1" + integrity sha1-40HRZPyyTN8E6n4Ft2XsEMiupqE= + balanced-match@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767" @@ -6339,7 +6467,7 @@ debug@2, debug@2.6.9, debug@^2.2.0, debug@^2.3.3, debug@^2.6.0, debug@^2.6.2, de dependencies: ms "2.0.0" -debug@3.1.0: +debug@3.1.0, debug@=3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/debug/-/debug-3.1.0.tgz#5bb5a0672628b64149566ba16819e61518c67261" integrity sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g== @@ -6536,6 +6664,11 @@ depd@~1.1.1, depd@~1.1.2: resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9" integrity sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak= +depd@~2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df" + integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw== + deprecation@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/deprecation/-/deprecation-2.0.0.tgz#dd0427cd920c78bc575ec39dab2f22e7c304fb9d" @@ -7946,6 +8079,20 @@ expo@^32.0.0: uuid-js "^0.7.5" whatwg-fetch "^2.0.4" +express-session@^1.16.1: + version "1.16.1" + resolved "https://registry.yarnpkg.com/express-session/-/express-session-1.16.1.tgz#251ff9776c59382301de6c8c33411af357ed439c" + integrity sha512-pWvUL8Tl5jUy1MLH7DhgUlpoKeVPUTe+y6WQD9YhcN0C5qAhsh4a8feVjiUXo3TFhIy191YGZ4tewW9edbl2xQ== + dependencies: + cookie "0.3.1" + cookie-signature "1.0.6" + debug "2.6.9" + depd "~2.0.0" + on-headers "~1.0.2" + parseurl "~1.3.2" + safe-buffer "5.1.2" + uid-safe "~2.1.5" + express@4.16.4: version "4.16.4" resolved "https://registry.yarnpkg.com/express/-/express-4.16.4.tgz#fddef61926109e24c515ea97fd2f1bdbf62df12e" @@ -8442,6 +8589,13 @@ flush-write-stream@^1.0.0: inherits "^2.0.3" readable-stream "^2.3.6" +follow-redirects@1.5.10: + version "1.5.10" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.5.10.tgz#7b7a9f9aea2fdff36786a94ff643ed07f4ff5e2a" + integrity sha512-0V5l4Cizzvqt5D44aTXbFZz+FtyXV1vrDN6qrelxtfYQKW0KO0W2T/hkE8xvGa/540LkZlkaUjO4ailYTFtHVQ== + dependencies: + debug "=3.1.0" + follow-redirects@^1.0.0, follow-redirects@^1.2.3, follow-redirects@^1.4.1: version "1.7.0" resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.7.0.tgz#489ebc198dc0e7f64167bd23b03c4c19b5784c76" @@ -8573,7 +8727,7 @@ fs-extra@^2.0.0: graceful-fs "^4.1.2" jsonfile "^2.1.0" -fs-extra@^4.0.1, fs-extra@^4.0.2: +fs-extra@^4.0.0, fs-extra@^4.0.1, fs-extra@^4.0.2: version "4.0.3" resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-4.0.3.tgz#0d852122e5bc5beb453fb028e9c0c9bf36340c94" integrity sha512-q6rbdDd1o2mAnQreO7YADIxf/Whx4AHBiRf6d+/cVT8h44ss+lHgxf1FemcqDnQt9X3ct4McHr+JMGlYSsK7Cg== @@ -11526,7 +11680,7 @@ jsonparse@^1.2.0: resolved "https://registry.yarnpkg.com/jsonparse/-/jsonparse-1.3.1.tgz#3f4dae4a91fac315f71062f8521cc239f1366280" integrity sha1-P02uSpH6wxX3EGL4UhzCOfE2YoA= -jsonwebtoken@^8.1.0: +jsonwebtoken@^8.1.0, jsonwebtoken@^8.2.0: version "8.5.1" resolved "https://registry.yarnpkg.com/jsonwebtoken/-/jsonwebtoken-8.5.1.tgz#00e71e0b8df54c2121a1f26137df2280673bcc0d" integrity sha512-XjwVfRS6jTMsqYs0EsuJ4LGxXV14zQybNd4L2r0UvbVnSF9Af8x7p5MzbJ90Ioz/9TI41/hTCvznF/loiSzn8w== @@ -14497,6 +14651,14 @@ passport-google-oauth@^1.0.0: passport-google-oauth1 "1.x.x" passport-google-oauth20 "1.x.x" +passport-jwt@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/passport-jwt/-/passport-jwt-4.0.0.tgz#7f0be7ba942e28b9f5d22c2ebbb8ce96ef7cf065" + integrity sha512-BwC0n2GP/1hMVjR4QpnvqA61TxenUMlmfNjYNgK0ZAs0HK4SOQkHcSv4L328blNTLtHq7DbmvyNJiH+bn6C5Mg== + dependencies: + jsonwebtoken "^8.2.0" + passport-strategy "^1.0.0" + passport-linkedin-oauth2@^1.5.0: version "1.6.1" resolved "https://registry.yarnpkg.com/passport-linkedin-oauth2/-/passport-linkedin-oauth2-1.6.1.tgz#e540db935d9bfd65b0179f4bb424bd5d87d3f76e" @@ -14532,7 +14694,7 @@ passport-oauth2@1.x.x: uid2 "0.0.x" utils-merge "1.x.x" -passport-strategy@1.x.x: +passport-strategy@1.x.x, passport-strategy@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/passport-strategy/-/passport-strategy-1.0.0.tgz#b5539aa8fc225a3d1ad179476ddf236b440f52e4" integrity sha1-tVOaqPwiWj0a0XlHbd8ja0QPUuQ= @@ -15305,6 +15467,11 @@ ramda@^0.26.1: resolved "https://registry.yarnpkg.com/ramda/-/ramda-0.26.1.tgz#8d41351eb8111c55353617fc3bbffad8e4d35d06" integrity sha512-hLWjpy7EnsDBb0p+Z3B7rPi3GDeRG5ZtiI33kJhTt+ORCd38AbAIjB/9zRIUoeTbE/AVX5ZkU7m6bznsvrf8eQ== +random-bytes@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/random-bytes/-/random-bytes-1.0.0.tgz#4f68a1dc0ae58bd3fb95848c30324db75d64360b" + integrity sha1-T2ih3Arli9P7lYSMMDJNt11kNgs= + randomatic@^3.0.0: version "3.1.1" resolved "https://registry.yarnpkg.com/randomatic/-/randomatic-3.1.1.tgz#b776efc59375984e36c537b2f51a1f0aff0da1ed" @@ -17534,6 +17701,17 @@ serve-static@1.14.1, serve-static@^1.13.1: parseurl "~1.3.3" send "0.17.1" +session-file-store@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/session-file-store/-/session-file-store-1.2.0.tgz#35a994a7fb0cbf7f784067bf9059939c520b7c44" + integrity sha512-DkYLYFkkK6u9xyraVHemulhlUuuufLukf7SQxOZSx8SPwkswcaIrls882PaQZ72zRKsyhUVNxOUl9w0lQubUFw== + dependencies: + bagpipe "^0.3.5" + fs-extra "^4.0.0" + object-assign "^4.1.1" + retry "^0.10.0" + write-file-atomic "1.3.1" + set-blocking@^2.0.0, set-blocking@~2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7" @@ -18990,6 +19168,13 @@ uid-number@0.0.6: resolved "https://registry.yarnpkg.com/uid-number/-/uid-number-0.0.6.tgz#0ea10e8035e8eb5b8e4449f06da1c730663baa81" integrity sha1-DqEOgDXo61uOREnwbaHHMGY7qoE= +uid-safe@~2.1.5: + version "2.1.5" + resolved "https://registry.yarnpkg.com/uid-safe/-/uid-safe-2.1.5.tgz#2b3d5c7240e8fc2e58f8aa269e5ee49c0857bd3a" + integrity sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA== + dependencies: + random-bytes "~1.0.0" + uid2@0.0.x: version "0.0.3" resolved "https://registry.yarnpkg.com/uid2/-/uid2-0.0.3.tgz#483126e11774df2f71b8b639dcd799c376162b82" @@ -19723,6 +19908,15 @@ wrappy@1: resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" integrity sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8= +write-file-atomic@1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/write-file-atomic/-/write-file-atomic-1.3.1.tgz#7d45ba32316328dd1ec7d90f60ebc0d845bb759a" + integrity sha1-fUW6MjFjKN0ex9kPYOvA2EW7dZo= + dependencies: + graceful-fs "^4.1.11" + imurmurhash "^0.1.4" + slide "^1.1.5" + write-file-atomic@2.4.1: version "2.4.1" resolved "https://registry.yarnpkg.com/write-file-atomic/-/write-file-atomic-2.4.1.tgz#d0b05463c188ae804396fd5ab2a370062af87529"