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',
|
enter_passcode: 'Enter Passcode',
|
||||||
confirm: 'Confirm',
|
confirm: 'Confirm',
|
||||||
cancel: 'Cancel',
|
cancel: 'Cancel',
|
||||||
|
bind: 'Binding with {{address}}',
|
||||||
},
|
},
|
||||||
description: {
|
description: {
|
||||||
loading: 'Loading...',
|
loading: 'Loading...',
|
||||||
|
@ -50,6 +51,10 @@ const translation = {
|
||||||
'The account with {{type}} {{value}} already exists, would you like to sign in?',
|
'The account with {{type}} {{value}} already exists, would you like to sign in?',
|
||||||
sign_in_id_does_not_exists:
|
sign_in_id_does_not_exists:
|
||||||
'The account with {{type}} {{value}} does not exist, would you like to create a new account?',
|
'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: {
|
error: {
|
||||||
username_password_mismatch: 'Username and password do not match.',
|
username_password_mismatch: 'Username and password do not match.',
|
||||||
|
|
|
@ -34,6 +34,7 @@ const translation = {
|
||||||
enter_passcode: '输入验证码',
|
enter_passcode: '输入验证码',
|
||||||
cancel: '取消',
|
cancel: '取消',
|
||||||
confirm: '确认',
|
confirm: '确认',
|
||||||
|
bind: '绑定到 {{address}}',
|
||||||
},
|
},
|
||||||
description: {
|
description: {
|
||||||
loading: '读取中...',
|
loading: '读取中...',
|
||||||
|
@ -50,6 +51,10 @@ const translation = {
|
||||||
continue_with: '通过以下方式继续',
|
continue_with: '通过以下方式继续',
|
||||||
create_account_id_exists: '{{ type }}为 {{ value }} 的账号已存在,您要登录吗?',
|
create_account_id_exists: '{{ type }}为 {{ value }} 的账号已存在,您要登录吗?',
|
||||||
sign_in_id_does_not_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: {
|
error: {
|
||||||
username_password_mismatch: '用户名和密码不匹配。',
|
username_password_mismatch: '用户名和密码不匹配。',
|
||||||
|
|
|
@ -10,6 +10,7 @@ import Passcode from './pages/Passcode';
|
||||||
import Register from './pages/Register';
|
import Register from './pages/Register';
|
||||||
import SecondarySignIn from './pages/SecondarySignIn';
|
import SecondarySignIn from './pages/SecondarySignIn';
|
||||||
import SignIn from './pages/SignIn';
|
import SignIn from './pages/SignIn';
|
||||||
|
import SocialRegister from './pages/SocialRegister';
|
||||||
import getSignInExperienceSettings from './utils/sign-in-experience';
|
import getSignInExperienceSettings from './utils/sign-in-experience';
|
||||||
|
|
||||||
import './scss/normalized.scss';
|
import './scss/normalized.scss';
|
||||||
|
@ -49,8 +50,9 @@ const App = () => {
|
||||||
<Route path="/sign-in/:method" element={<SecondarySignIn />} />
|
<Route path="/sign-in/:method" element={<SecondarySignIn />} />
|
||||||
<Route path="/register" element={<Register />} />
|
<Route path="/register" element={<Register />} />
|
||||||
<Route path="/register/:method" element={<Register />} />
|
<Route path="/register/:method" element={<Register />} />
|
||||||
<Route path="/:type/:method/passcode-validation" element={<Passcode />} />
|
|
||||||
<Route path="/callback/:connector" element={<Callback />} />
|
<Route path="/callback/:connector" element={<Callback />} />
|
||||||
|
<Route path="/social-register/:connector" element={<SocialRegister />} />
|
||||||
|
<Route path="/:type/:method/passcode-validation" element={<Passcode />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</AppContent>
|
</AppContent>
|
||||||
|
|
|
@ -19,6 +19,7 @@ import {
|
||||||
invokeSocialSignIn,
|
invokeSocialSignIn,
|
||||||
signInWithSocial,
|
signInWithSocial,
|
||||||
bindSocialAccount,
|
bindSocialAccount,
|
||||||
|
bindSocialRelatedUser,
|
||||||
registerWithSocial,
|
registerWithSocial,
|
||||||
} from './social';
|
} from './social';
|
||||||
|
|
||||||
|
@ -168,6 +169,15 @@ describe('api', () => {
|
||||||
|
|
||||||
it('bindSocialAccount', async () => {
|
it('bindSocialAccount', async () => {
|
||||||
await bindSocialAccount('connectorId');
|
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', {
|
expect(ky.post).toBeCalledWith('/api/session/sign-in/bind-social-related-user', {
|
||||||
json: {
|
json: {
|
||||||
connectorId: 'connectorId',
|
connectorId: 'connectorId',
|
||||||
|
|
|
@ -42,6 +42,20 @@ export const bindSocialAccount = async (connectorId: string) => {
|
||||||
redirectTo: 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
|
return ky
|
||||||
.post('/api/session/sign-in/bind-social-related-user', {
|
.post('/api/session/sign-in/bind-social-related-user', {
|
||||||
json: {
|
json: {
|
||||||
|
|
|
@ -3,9 +3,20 @@
|
||||||
.navBar {
|
.navBar {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-bottom: _.unit(6);
|
margin-bottom: _.unit(6);
|
||||||
|
@include _.flex-row;
|
||||||
|
justify-content: center;
|
||||||
|
position: relative;
|
||||||
|
padding: _.unit(3) 0;
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
margin-left: _.unit(-2);
|
position: absolute;
|
||||||
|
left: _.unit(-2);
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
fill: var(--color-icon);
|
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 { NavArrowIcon } from '../Icons';
|
||||||
import * as styles from './index.module.scss';
|
import * as styles from './index.module.scss';
|
||||||
|
|
||||||
const NavBar = () => {
|
type Props = {
|
||||||
|
title?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const NavBar = ({ title }: Props) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -14,6 +18,7 @@ const NavBar = () => {
|
||||||
navigate(-1);
|
navigate(-1);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
{title && <div className={styles.title}>{title}</div>}
|
||||||
</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