mirror of
https://github.com/logto-io/logto.git
synced 2025-02-17 22:04:19 -05:00
feat(ui): add social sign-in binding page (#664)
* 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
This commit is contained in:
parent
a10b427c87
commit
c5b1fed805
13 changed files with 292 additions and 3 deletions
|
@ -32,6 +32,7 @@ const translation = {
|
|||
enter_passcode: 'Enter Passcode',
|
||||
confirm: 'Confirm',
|
||||
cancel: 'Cancel',
|
||||
bind: 'Binding with {{address}}',
|
||||
},
|
||||
description: {
|
||||
loading: 'Loading...',
|
||||
|
@ -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.',
|
||||
|
|
|
@ -34,6 +34,7 @@ const translation = {
|
|||
enter_passcode: '输入验证码',
|
||||
cancel: '取消',
|
||||
confirm: '确认',
|
||||
bind: '绑定到 {{address}}',
|
||||
},
|
||||
description: {
|
||||
loading: '读取中...',
|
||||
|
@ -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: '用户名和密码不匹配。',
|
||||
|
|
|
@ -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';
|
||||
|
@ -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>
|
||||
|
|
|
@ -19,6 +19,7 @@ import {
|
|||
invokeSocialSignIn,
|
||||
signInWithSocial,
|
||||
bindSocialAccount,
|
||||
bindSocialRelatedUser,
|
||||
registerWithSocial,
|
||||
} from './social';
|
||||
|
||||
|
@ -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',
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 (
|
||||
|
@ -14,6 +18,7 @@ const NavBar = () => {
|
|||
navigate(-1);
|
||||
}}
|
||||
/>
|
||||
{title && <div className={styles.title}>{title}</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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: 'foo@logto.io' } }),
|
||||
}));
|
||||
|
||||
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
packages/ui/src/containers/SocialCreateAccount/index.tsx
Normal file
77
packages/ui/src/containers/SocialCreateAccount/index.tsx
Normal file
|
@ -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
packages/ui/src/pages/SocialRegister/index.module.scss
Normal file
7
packages/ui/src/pages/SocialRegister/index.module.scss
Normal file
|
@ -0,0 +1,7 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.wrapper {
|
||||
position: relative;
|
||||
padding: _.unit(8) _.unit(5);
|
||||
@include _.flex-column;
|
||||
}
|
37
packages/ui/src/pages/SocialRegister/index.test.tsx
Normal file
37
packages/ui/src/pages/SocialRegister/index.test.tsx
Normal file
|
@ -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
packages/ui/src/pages/SocialRegister/index.tsx
Normal file
37
packages/ui/src/pages/SocialRegister/index.tsx
Normal file
|
@ -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;
|
Loading…
Add table
Reference in a new issue