Skip to content

Commit

Permalink
feat(ui): add social sign-in binding page (#664)
Browse files Browse the repository at this point in the history
* feat(ui): add social sign-in binding page

add social sing-in binding page

* feat(ui): temp redirect to the username sign-in page

temp redirect to the username sign-in page

* fix(ui): fix style missing bug

fix style missing bug
  • Loading branch information
simeng-li authored Apr 26, 2022
1 parent a10b427 commit c5b1fed
Show file tree
Hide file tree
Showing 13 changed files with 292 additions and 3 deletions.
5 changes: 5 additions & 0 deletions packages/phrases/src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ const translation = {
enter_passcode: 'Enter Passcode',
confirm: 'Confirm',
cancel: 'Cancel',
bind: 'Binding with {{address}}',
},
description: {
loading: 'Loading...',
Expand All @@ -50,6 +51,10 @@ const translation = {
'The account with {{type}} {{value}} already exists, would you like to sign in?',
sign_in_id_does_not_exists:
'The account with {{type}} {{value}} does not exist, would you like to create a new account?',
bind_account_title: 'Binding Logto account',
social_create_account: 'No account? You can create a new account and bind.',
social_bind_account: 'Already have an account? Sign in to bind it with your social identity.',
social_bind_with_existing: 'We find a related account, you can bind it directly.',
},
error: {
username_password_mismatch: 'Username and password do not match.',
Expand Down
5 changes: 5 additions & 0 deletions packages/phrases/src/locales/zh-cn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ const translation = {
enter_passcode: '输入验证码',
cancel: '取消',
confirm: '确认',
bind: '绑定到 {{address}}',
},
description: {
loading: '读取中...',
Expand All @@ -50,6 +51,10 @@ const translation = {
continue_with: '通过以下方式继续',
create_account_id_exists: '{{ type }}为 {{ value }} 的账号已存在,您要登录吗?',
sign_in_id_does_not_exists: '{{ type }}为 {{ value }} 的账号不存在,您要创建一个新账号吗?',
bind_account_title: '绑定 Logto 账号',
social_create_account: 'No account? You can create a new account and bind.',
social_bind_account: 'Already have an account? Sign in to bind it with your social identity.',
social_bind_with_existing: 'We find a related account, you can bind it directly.',
},
error: {
username_password_mismatch: '用户名和密码不匹配。',
Expand Down
4 changes: 3 additions & 1 deletion packages/ui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import Passcode from './pages/Passcode';
import Register from './pages/Register';
import SecondarySignIn from './pages/SecondarySignIn';
import SignIn from './pages/SignIn';
import SocialRegister from './pages/SocialRegister';
import getSignInExperienceSettings from './utils/sign-in-experience';

import './scss/normalized.scss';
Expand Down Expand Up @@ -49,8 +50,9 @@ const App = () => {
<Route path="/sign-in/:method" element={<SecondarySignIn />} />
<Route path="/register" element={<Register />} />
<Route path="/register/:method" element={<Register />} />
<Route path="/:type/:method/passcode-validation" element={<Passcode />} />
<Route path="/callback/:connector" element={<Callback />} />
<Route path="/social-register/:connector" element={<SocialRegister />} />
<Route path="/:type/:method/passcode-validation" element={<Passcode />} />
</Routes>
</BrowserRouter>
</AppContent>
Expand Down
10 changes: 10 additions & 0 deletions packages/ui/src/apis/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
invokeSocialSignIn,
signInWithSocial,
bindSocialAccount,
bindSocialRelatedUser,
registerWithSocial,
} from './social';

Expand Down Expand Up @@ -168,6 +169,15 @@ describe('api', () => {

it('bindSocialAccount', async () => {
await bindSocialAccount('connectorId');
expect(ky.post).toBeCalledWith('/api/session/sign-in/bind-social', {
json: {
connectorId: 'connectorId',
},
});
});

it('bindSocialRelatedUser', async () => {
await bindSocialRelatedUser('connectorId');
expect(ky.post).toBeCalledWith('/api/session/sign-in/bind-social-related-user', {
json: {
connectorId: 'connectorId',
Expand Down
14 changes: 14 additions & 0 deletions packages/ui/src/apis/social.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,20 @@ export const bindSocialAccount = async (connectorId: string) => {
redirectTo: string;
};

return ky
.post('/api/session/sign-in/bind-social', {
json: {
connectorId,
},
})
.json<Response>();
};

export const bindSocialRelatedUser = async (connectorId: string) => {
type Response = {
redirectTo: string;
};

return ky
.post('/api/session/sign-in/bind-social-related-user', {
json: {
Expand Down
13 changes: 12 additions & 1 deletion packages/ui/src/components/NavBar/index.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,20 @@
.navBar {
width: 100%;
margin-bottom: _.unit(6);
@include _.flex-row;
justify-content: center;
position: relative;
padding: _.unit(3) 0;

svg {
margin-left: _.unit(-2);
position: absolute;
left: _.unit(-2);
top: 50%;
transform: translateY(-50%);
fill: var(--color-icon);
}

.title {
font: var(--font-body-bold);
}
}
7 changes: 6 additions & 1 deletion packages/ui/src/components/NavBar/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@ import { useNavigate } from 'react-router-dom';
import { NavArrowIcon } from '../Icons';
import * as styles from './index.module.scss';

const NavBar = () => {
type Props = {
title?: string;
};

const NavBar = ({ title }: Props) => {
const navigate = useNavigate();

return (
Expand All @@ -14,6 +18,7 @@ const NavBar = () => {
navigate(-1);
}}
/>
{title && <div className={styles.title}>{title}</div>}
</div>
);
};
Expand Down
23 changes: 23 additions & 0 deletions packages/ui/src/containers/SocialCreateAccount/index.module.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
@use '@/scss/underscore' as _;


.container {
width: 100%;
max-width: 360px;
margin: 0 auto;
@include _.flex-column;

> * {
width: 100%;
}
}

.desc {
@include _.text-hint;
margin-bottom: _.unit(2);

&:not(:first-child) {
margin-top: _.unit(8);
}
}

56 changes: 56 additions & 0 deletions packages/ui/src/containers/SocialCreateAccount/index.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { render, fireEvent, waitFor } from '@testing-library/react';
import React from 'react';

import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
import { registerWithSocial, bindSocialRelatedUser } from '@/apis/social';

import SocialCreateAccount from '.';

const mockNavigate = jest.fn();

jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: () => mockNavigate,
useLocation: () => ({ state: { relatedUser: '[email protected]' } }),
}));

jest.mock('@/apis/social', () => ({
registerWithSocial: jest.fn(async () => Promise.resolve()),
bindSocialRelatedUser: jest.fn(async () => Promise.resolve()),
}));

describe('SocialCreateAccount', () => {
it('should match snapshot', () => {
const { queryByText } = render(<SocialCreateAccount connector="github" />);
expect(queryByText('description.social_create_account')).not.toBeNull();
expect(queryByText('description.social_bind_account')).not.toBeNull();
});

it('should redirect to sign in page when click sign-in button', () => {
const { getByText } = render(<SocialCreateAccount connector="github" />);

const signInButton = getByText('action.sign_in');
fireEvent.click(signInButton);
expect(mockNavigate).toBeCalledWith('/sign-in/username/github');
});

it('should call registerWithSocial when click create button', async () => {
const { getByText } = renderWithPageContext(<SocialCreateAccount connector="github" />);
const createButton = getByText('action.create');

await waitFor(() => {
fireEvent.click(createButton);
});

expect(registerWithSocial).toBeCalledWith('github');
});

it('should render bindUser Button when relatedUserInfo found', async () => {
const { getByText } = renderWithPageContext(<SocialCreateAccount connector="github" />);
const bindButton = getByText('action.bind');
await waitFor(() => {
fireEvent.click(bindButton);
});
expect(bindSocialRelatedUser).toBeCalledWith('github');
});
});
77 changes: 77 additions & 0 deletions packages/ui/src/containers/SocialCreateAccount/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { Optional } from '@silverhand/essentials';
import classNames from 'classnames';
import React, { useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate, useLocation } from 'react-router-dom';

import { registerWithSocial, bindSocialRelatedUser } from '@/apis/social';
import Button from '@/components/Button';
import useApi from '@/hooks/use-api';

import * as styles from './index.module.scss';

type Props = {
className?: string;
connector: string;
};

type LocationState = {
relatedUser?: string;
};

const SocialCreateAccount = ({ connector, className }: Props) => {
const navigate = useNavigate();
const state = useLocation().state as Optional<LocationState>;
const { t } = useTranslation(undefined, { keyPrefix: 'main_flow' });

const { result: registerResult, run: asyncRegisterWithSocial } = useApi(registerWithSocial);
const { result: bindUserResult, run: asyncBindSocialRelatedUser } = useApi(bindSocialRelatedUser);

const createAccountHandler = useCallback(() => {
void asyncRegisterWithSocial(connector);
}, [asyncRegisterWithSocial, connector]);

const bindRelatedUserHandler = useCallback(() => {
void asyncBindSocialRelatedUser(connector);
}, [asyncBindSocialRelatedUser, connector]);

const signInHandler = useCallback(() => {
// TODO: redirect to desired sign-in page
navigate('/sign-in/username/' + connector);
}, [connector, navigate]);

useEffect(() => {
if (registerResult?.redirectTo) {
window.location.assign(registerResult.redirectTo);
}
}, [registerResult]);

useEffect(() => {
if (bindUserResult?.redirectTo) {
window.location.assign(bindUserResult.redirectTo);
}
}, [bindUserResult]);

return (
<div className={classNames(styles.container, className)}>
{state?.relatedUser && (
<>
<div className={styles.desc}>{t('description.social_bind_with_existing')}</div>
<Button onClick={bindRelatedUserHandler}>
{t('action.bind', { address: state.relatedUser })}
</Button>
</>
)}
<div className={styles.desc}>{t('description.social_create_account')}</div>
<Button type={state?.relatedUser ? 'secondary' : 'primary'} onClick={createAccountHandler}>
{t('action.create')}
</Button>
<div className={styles.desc}>{t('description.social_bind_account')}</div>
<Button type="secondary" onClick={signInHandler}>
{t('action.sign_in')}
</Button>
</div>
);
};

export default SocialCreateAccount;
7 changes: 7 additions & 0 deletions packages/ui/src/pages/SocialRegister/index.module.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
@use '@/scss/underscore' as _;

.wrapper {
position: relative;
padding: _.unit(8) _.unit(5);
@include _.flex-column;
}
37 changes: 37 additions & 0 deletions packages/ui/src/pages/SocialRegister/index.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { render } from '@testing-library/react';
import React from 'react';
import { MemoryRouter, Route, Routes } from 'react-router-dom';

import SocialRegister from '.';

const mockNavigate = jest.fn();

jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: () => mockNavigate,
}));

describe('SocialRegister', () => {
it('render null and redirect if no connector found', () => {
render(
<MemoryRouter initialEntries={['/social-register']}>
<Routes>
<Route path="/social-register" element={<SocialRegister />} />
</Routes>
</MemoryRouter>
);
expect(mockNavigate).toBeCalledWith('/404');
});

it('render with connection', () => {
const { queryByText } = render(
<MemoryRouter initialEntries={['/social-register/github']}>
<Routes>
<Route path="/social-register/:connector" element={<SocialRegister />} />
</Routes>
</MemoryRouter>
);
expect(queryByText('description.bind_account_title')).not.toBeNull();
expect(queryByText('description.social_create_account')).not.toBeNull();
});
});
37 changes: 37 additions & 0 deletions packages/ui/src/pages/SocialRegister/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import React, { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useParams, useNavigate } from 'react-router-dom';

import NavBar from '@/components/NavBar';
import SocialCreateAccount from '@/containers/SocialCreateAccount';

import * as styles from './index.module.scss';

type Parameters = {
connector: string;
};

const SocialRegister = () => {
const { t } = useTranslation(undefined, { keyPrefix: 'main_flow' });
const { connector } = useParams<Parameters>();
const navigate = useNavigate();

useEffect(() => {
if (!connector) {
navigate('/404');
}
}, [connector, navigate]);

if (!connector) {
return null;
}

return (
<div className={styles.wrapper}>
<NavBar title={t('description.bind_account_title')} />
<SocialCreateAccount connector={connector} />
</div>
);
};

export default SocialRegister;

0 comments on commit c5b1fed

Please sign in to comment.