[feat] Implement sso flow

This commit is contained in:
JasonWu 2021-09-08 17:27:03 +08:00
parent b33bb0aa1b
commit c6d55c9da3
14 changed files with 189 additions and 103 deletions

View File

@ -2,7 +2,6 @@ import { AxiosInstance } from 'axios';
import Header from '../_APITool/Header'; import Header from '../_APITool/Header';
import { import {
UserAPIProps, UserAPIProps,
PostUserRefreshAPIPromise,
GetUserSingleSignInAPIPromise, GetUserSingleSignInAPIPromise,
GetUserAccountInfoAPIPromise, GetUserAccountInfoAPIPromise,
PostUserSignOutAPIPromise, PostUserSignOutAPIPromise,
@ -12,7 +11,7 @@ export function CreatUserAPI({ axios, header }: { axios: AxiosInstance; header:
return { return {
getUserSSO: async (backUrl: string): Promise<GetUserSingleSignInAPIPromise> => { getUserSSO: async (backUrl: string): Promise<GetUserSingleSignInAPIPromise> => {
try { try {
const res = await axios.get('/account/login/sso', { const res = await axios.get('/login', {
params: { params: {
back_url: backUrl, back_url: backUrl,
}, },
@ -26,44 +25,25 @@ export function CreatUserAPI({ axios, header }: { axios: AxiosInstance; header:
return errorMessage; return errorMessage;
} }
}, },
postUserRefreshToken: async (): Promise<PostUserRefreshAPIPromise> => {
try {
const res = await axios.post('/account/refresh_token', undefined, {
headers: { ...header.header },
});
return res.data;
} catch (error) {
const errorMessage = {
token: '',
error: error.response?.data,
};
return errorMessage;
}
},
getUserAccountInfo: async (): Promise<GetUserAccountInfoAPIPromise> => { getUserAccountInfo: async (): Promise<GetUserAccountInfoAPIPromise> => {
try { try {
const res = await axios.get('/account', { const res = await axios.get('/userinfo', {
headers: { ...header.header }, headers: { ...header.header },
}); });
return res.data; return res.data;
} catch (error) { } catch (error) {
const errorMessage = { const errorMessage = {
account: { display_name: '',
_id: '', email: '',
username: '', groups: [],
email: '', username: '',
display_name: '',
createdAt: '',
updatedAt: '',
error: error.response?.data,
},
}; };
return errorMessage; return errorMessage;
} }
}, },
postUserSignOut: async (): Promise<PostUserSignOutAPIPromise> => { postUserSignOut: async (): Promise<PostUserSignOutAPIPromise> => {
try { try {
const res = await axios.post('/account/logout', undefined, { const res = await axios.post('/logout', undefined, {
headers: { ...header.header }, headers: { ...header.header },
}); });
return res.data; return res.data;

View File

@ -1,7 +1,6 @@
import { APIError } from '@Models/GeneralTypes'; import { APIError } from '@Models/GeneralTypes';
import { import {
UserTokenInfo, UserAccountInfo,
UserSignInInfo,
UserOAuthUrl, UserOAuthUrl,
UserSignOutUrl, UserSignOutUrl,
} from '@Models/Redux/User/types'; } from '@Models/Redux/User/types';
@ -9,10 +8,7 @@ import {
export interface GetUserSingleSignInAPIPromise extends UserOAuthUrl { export interface GetUserSingleSignInAPIPromise extends UserOAuthUrl {
error?: APIError; error?: APIError;
} }
export interface PostUserRefreshAPIPromise extends UserTokenInfo { export interface GetUserAccountInfoAPIPromise extends UserAccountInfo {
error?: APIError;
}
export interface GetUserAccountInfoAPIPromise extends UserSignInInfo {
error?: APIError; error?: APIError;
} }
export interface PostUserSignOutAPIPromise extends UserSignOutUrl { export interface PostUserSignOutAPIPromise extends UserSignOutUrl {
@ -21,7 +17,6 @@ export interface PostUserSignOutAPIPromise extends UserSignOutUrl {
export interface UserAPIProps { export interface UserAPIProps {
getUserSSO: (backUrl: string) => Promise<GetUserSingleSignInAPIPromise>; getUserSSO: (backUrl: string) => Promise<GetUserSingleSignInAPIPromise>;
postUserRefreshToken: () => Promise<PostUserRefreshAPIPromise>;
getUserAccountInfo: () => Promise<GetUserAccountInfoAPIPromise>; getUserAccountInfo: () => Promise<GetUserAccountInfoAPIPromise>;
postUserSignOut: () => Promise<PostUserSignOutAPIPromise>; postUserSignOut: () => Promise<PostUserSignOutAPIPromise>;
} }

View File

@ -1,8 +1,8 @@
import { createMuiTheme, ThemeOptions } from '@material-ui/core'; import { createTheme, ThemeOptions } from '@material-ui/core/styles';
import ValidConfig from './ThemeConfig'; import ValidConfig from './ThemeConfig';
import ThemeType from './ThemeType'; import ThemeType from './ThemeType';
export default function CreateTheme(themeType = ThemeType.v1): ThemeOptions { export default function CreateTheme(themeType = ThemeType.v1): ThemeOptions {
const theme = { ...createMuiTheme(ValidConfig[themeType]) }; const theme = { ...createTheme(ValidConfig[themeType]) };
return theme; return theme;
} }

View File

@ -3,4 +3,13 @@
width: 100%; width: 100%;
height: 100%; height: 100%;
position: relative; position: relative;
display: flex;
justify-content: center;
align-items: center;
}
:local(.homeInfoContainer) {
position: relative;
}
:local(.homeInfoItem) {
margin-bottom: 8px;
} }

View File

@ -1,10 +1,57 @@
import React, { memo } from 'react'; import React, { memo, useEffect, useState } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import Button from '@material-ui/core/Button';
import Typography from '@material-ui/core/Typography';
import useReduxApi from '@Hooks/useReduxApi';
import useDidMount from '@Hooks/useDidMount';
import useMappedState from '@Hooks/useMappedState';
import Loading from '@Components/Base/Loading';
import Styles from './index.module.css'; import Styles from './index.module.css';
function Home(): React.ReactElement { function Home(): React.ReactElement {
/* Global & Local State */ /* Global & Local State */
const reduxUser = useReduxApi('user');
const storeUser = useMappedState((state) => state.user);
const [isFirstInitial, setIsFirstInitial] = useState(false);
const [isLoading, setIsLoading] = useState(false);
/* Functions */
const onSignOut = (): void => {
reduxUser('postUserSignOut', []);
};
const initialize = (): void => {
setIsFirstInitial(true);
};
/* Hooks */
useDidMount(() => {
initialize();
});
useEffect(() => {
if (!isFirstInitial) return;
if (storeUser.userSignOutUrl.url) {
window.location.href = storeUser.userSignOutUrl.url;
}
setIsLoading(false);
}, [storeUser.userSignOutUrl]);
/* Main */ /* Main */
return <div className={classNames(Styles.homeContainer)}>Home</div>; return (
<div className={classNames(Styles.homeContainer)}>
<div className={classNames(Styles.homeInfoContainer)}>
<Typography className={classNames(Styles.homeInfoItem)} variant="h2">使</Typography>
<Typography className={classNames(Styles.homeInfoItem)} variant="h3">{storeUser.userAccount.username}</Typography>
<Typography className={classNames(Styles.homeInfoItem)} variant="h2"></Typography>
<Typography className={classNames(Styles.homeInfoItem)} variant="h3">
{storeUser.userAccount.display_name}
</Typography>
<Typography className={classNames(Styles.homeInfoItem)} variant="h2"></Typography>
<Typography className={classNames(Styles.homeInfoItem)} variant="h3">{storeUser.userAccount.email}</Typography>
<Typography className={classNames(Styles.homeInfoItem)} variant="h2"> Groups</Typography>
<Typography className={classNames(Styles.homeInfoItem)} variant="h3">
{storeUser.userAccount.groups.join('')}
</Typography>
<Button variant="contained" color="primary" onClick={onSignOut}></Button>
</div>
<Loading typePosition="absolute" typeZIndex={20000} typeIcon="line:fix" isLoading={isLoading} />
</div>
);
} }
export default memo(Home); export default memo(Home);

View File

@ -1,3 +1,4 @@
/* eslint-disable */
import React, { memo } from 'react'; import React, { memo } from 'react';
import { useHistory, useLocation } from 'react-router-dom'; import { useHistory, useLocation } from 'react-router-dom';
import { Base64 } from 'js-base64'; import { Base64 } from 'js-base64';

View File

@ -38,8 +38,11 @@ function SignIn(): React.ReactElement {
}); });
useEffect(() => { useEffect(() => {
if (!isFirstInitial) return; if (!isFirstInitial) return;
if (storeUser.userOAuth.url) {
window.location.href = storeUser.userOAuth.url;
}
setIsLoading(false); setIsLoading(false);
}, [storeUser.userAccount]); }, [storeUser.userOAuth]);
/* Main */ /* Main */
return ( return (
<div className={classNames(Styles.signInContainer)}> <div className={classNames(Styles.signInContainer)}>

View File

@ -1,55 +1,55 @@
import Immerable from '@Models/GeneralImmer'; import Immerable from '@Models/GeneralImmer';
import cloneDeep from 'lodash/cloneDeep'; import cloneDeep from 'lodash/cloneDeep';
import { UserSignInInfo, UserAccountInfo, UserOAuthUrl } from './types'; import { UserAccountInfo, UserOAuthUrl, UserSignOutUrl } from './types';
class User extends Immerable { class User extends Immerable {
public userIsLogin: boolean; public userIsLogin: boolean;
public userAuthCheck: boolean; public userAuthCheck: boolean;
public userAccount: UserSignInInfo; public userAccount: UserAccountInfo;
public userToken: string; public userToken: string;
public userOAuth: UserOAuthUrl; public userOAuth: UserOAuthUrl;
public userSignOutUrl: UserSignOutUrl;
public constructor() { public constructor() {
super(); super();
this.userIsLogin = false; this.userIsLogin = false;
this.userAuthCheck = true; this.userAuthCheck = false;
this.userAccount = { this.userAccount = {
account: { display_name: '',
_id: '', email: '',
username: '', groups: [],
email: '', username: '',
display_name: '',
createdAt: '',
updatedAt: '',
},
}; };
this.userToken = ''; this.userToken = '';
this.userOAuth = { this.userOAuth = {
url: '', url: '',
}; };
this.userSignOutUrl = {
url: '',
};
} }
public initialize(): void { public initialize(): void {
this.userIsLogin = false; this.userIsLogin = false;
this.userAuthCheck = false; this.userAuthCheck = false;
this.userAccount = { this.userAccount = {
account: { display_name: '',
_id: '', email: '',
username: '', groups: [],
email: '', username: '',
display_name: '',
createdAt: '',
updatedAt: '',
},
}; };
this.userToken = ''; this.userToken = '';
this.userOAuth = { this.userOAuth = {
url: '', url: '',
}; };
this.userSignOutUrl = {
url: '',
};
} }
public updateUserLoginState(newUserLoginState: boolean): void { public updateUserLoginState(newUserLoginState: boolean): void {
@ -65,12 +65,16 @@ class User extends Immerable {
} }
public updateUserAccountInFo(newUserAccount: UserAccountInfo): void { public updateUserAccountInFo(newUserAccount: UserAccountInfo): void {
this.userAccount.account = cloneDeep(newUserAccount); this.userAccount = cloneDeep(newUserAccount);
} }
public updateUserOAuthUrl(newUserOAuth: UserOAuthUrl): void { public updateUserOAuthUrl(newUserOAuth: UserOAuthUrl): void {
this.userOAuth = cloneDeep(newUserOAuth); this.userOAuth = cloneDeep(newUserOAuth);
} }
public updateUserSignOutUrl(newSignOutUrl: UserSignOutUrl): void {
this.userSignOutUrl = cloneDeep(newSignOutUrl);
}
} }
export default User; export default User;

View File

@ -1,13 +1,8 @@
import { TimeStamp } from '@Models/GeneralTypes'; export interface UserAccountInfo {
export interface UserAccountInfo extends TimeStamp {
_id: string;
username: string;
email: string;
display_name: string; display_name: string;
} email: string;
export interface UserSignInInfo { groups: string[];
account: UserAccountInfo; username: string;
} }
export interface UserTokenInfo { export interface UserTokenInfo {
token: string; token: string;
@ -15,6 +10,9 @@ export interface UserTokenInfo {
export interface UserOAuthUrl { export interface UserOAuthUrl {
url: string; url: string;
} }
export interface UserSignOutUrl {
url: string;
}
export interface UserOAuthResponse { export interface UserOAuthResponse {
success?: string; success?: string;
error?: string; error?: string;

View File

@ -1,7 +1,6 @@
import API from '@API/index'; import API from '@API/index';
import Env from '@Env/index'; import Env from '@Env/index';
import { postGlobalReduxReset } from '@Reducers/global/actions'; import { postGlobalReduxReset } from '@Reducers/global/actions';
import { errorCatch } from './errorCapture';
const EXPIRE_TIME_MS = 1000 * 60 * 60 * 24; // 確認一天前有沒有過期 const EXPIRE_TIME_MS = 1000 * 60 * 60 * 24; // 確認一天前有沒有過期
@ -32,16 +31,7 @@ export const readLocalUserToken = async (env: Env, api: API): Promise<boolean> =
removeLocalUserToken(env); removeLocalUserToken(env);
return false; return false;
} }
const result = await api.user.postUserRefreshToken(); return true;
if (!result.error) {
api.updateAccessToken(result.token);
localStorage.setItem(localTokenName, JSON.stringify({ token: result.token }));
return true;
}
if (result.error) {
api.removeAccessToken();
return false;
}
} }
return false; return false;
}; };
@ -56,26 +46,9 @@ export const checkUserTokenIsValid = async (env: Env, api: API, dispatch: any):
} }
/* 過期 */ /* 過期 */
if (isAccessTokenExpired) { if (isAccessTokenExpired) {
const result = await api.user.postUserRefreshToken(); // 嘗試更新 Token api.removeAccessToken();
/* 更新成功, 更換 API Token */ dispatch(postGlobalReduxReset());
if (!result.error) { return false;
const localTokenName = env.TokenLocalStorageName;
const localToken = localStorage.getItem(localTokenName);
if (localToken) {
api.removeAccessToken();
localStorage.removeItem(localTokenName);
}
localStorage.setItem(localTokenName, JSON.stringify({ token: result.token }));
api.updateAccessToken(result.token);
return true;
}
/* 更新失敗, 清空所有狀態, 讓使用者重新登入 */
if (result.error) {
api.removeAccessToken();
dispatch(postGlobalReduxReset());
dispatch(errorCatch(result.error));
return false;
}
} }
return true; return true;
} }

View File

@ -5,6 +5,7 @@ enum User {
SET_USER_TOKEN = 'SET_USER_TOKEN', SET_USER_TOKEN = 'SET_USER_TOKEN',
SET_USER_ACCOUNT_INFO = 'SET_USER_ACCOUNT_INFO', SET_USER_ACCOUNT_INFO = 'SET_USER_ACCOUNT_INFO',
SET_USER_OAUTH_URL = 'SET_USER_OAUTH_URL', SET_USER_OAUTH_URL = 'SET_USER_OAUTH_URL',
SET_USER_SIGN_OUT_URL = 'SET_USER_SIGN_OUT_URL',
} }
export default User; export default User;

View File

@ -2,9 +2,13 @@ import { ThunkAction } from 'redux-thunk';
import { StoreState } from '@Reducers/_InitializeStore/types'; import { StoreState } from '@Reducers/_InitializeStore/types';
import { MiddleWare } from '@Reducers/_initializeMiddleware/types'; import { MiddleWare } from '@Reducers/_initializeMiddleware/types';
import { errorCatch } from '@Reducers/_Capture/errorCapture'; import { errorCatch } from '@Reducers/_Capture/errorCapture';
import { postGlobalReduxReset } from '@Reducers/global/actions';
import { delay } from '@Tools/utility'; import { delay } from '@Tools/utility';
import { import {
checkUserTokenIsValid, checkUserTokenIsValid,
saveLocalUserToken,
readLocalUserToken,
removeLocalUserToken,
} from '@Reducers/_Capture/tokenCapture'; } from '@Reducers/_Capture/tokenCapture';
import USER_ACTION from '@Reducers/_Constants/User'; import USER_ACTION from '@Reducers/_Constants/User';
import { import {
@ -13,8 +17,10 @@ import {
SetUserTokenAction, SetUserTokenAction,
SetUserAccountInfoAction, SetUserAccountInfoAction,
SetUserOAuthAction, SetUserOAuthAction,
SetUserSignOutAction,
UserAccountInfo, UserAccountInfo,
UserOAuthUrl, UserOAuthUrl,
UserSignOutUrl,
} from './types'; } from './types';
/* User */ /* User */
@ -38,6 +44,10 @@ export const SET_USER_OAUTH_URL = ({ url }: { url: UserOAuthUrl }): SetUserOAuth
type: USER_ACTION.SET_USER_OAUTH_URL, type: USER_ACTION.SET_USER_OAUTH_URL,
url, url,
}); });
export const SET_USER_SIGN_OUT_URL = ({ url }: { url: UserSignOutUrl }): SetUserSignOutAction => ({
type: USER_ACTION.SET_USER_SIGN_OUT_URL,
url,
});
/* User Action */ /* User Action */
export const getUserAccountInfo = (): ThunkAction<Promise<void>, StoreState, MiddleWare, { type: string }> => async ( export const getUserAccountInfo = (): ThunkAction<Promise<void>, StoreState, MiddleWare, { type: string }> => async (
@ -50,7 +60,7 @@ export const getUserAccountInfo = (): ThunkAction<Promise<void>, StoreState, Mid
const result = await api.user.getUserAccountInfo(); const result = await api.user.getUserAccountInfo();
dispatch( dispatch(
SET_USER_ACCOUNT_INFO({ SET_USER_ACCOUNT_INFO({
accountInfo: result.account, accountInfo: result,
}), }),
); );
if (result.error) { if (result.error) {
@ -77,3 +87,59 @@ export const getUserSSO = (
dispatch(errorCatch(result.error)); dispatch(errorCatch(result.error));
} }
}; };
export const getUserIsLogin = (): ThunkAction<Promise<void>, StoreState, MiddleWare, { type: string }> => async (
dispatch,
getState,
{ api, env },
): Promise<void> => {
const isValidToken = await readLocalUserToken(env, api);
if (isValidToken) {
dispatch(SET_USER_LOGIN_STATE({ isLogin: true }));
dispatch(getUserAccountInfo());
} else {
api.removeAccessToken();
removeLocalUserToken(env);
}
await delay(500);
dispatch(SET_USER_AUTH_STATE({ isAuth: true }));
};
export const postUserTokenInfoSignIn = (
token: string,
): ThunkAction<Promise<void>, StoreState, MiddleWare, { type: string }> => async (
dispatch,
getState,
{ api, env },
): Promise<void> => {
api.updateAccessToken(token);
saveLocalUserToken(env, token);
dispatch(SET_USER_TOKEN({ token }));
dispatch(SET_USER_LOGIN_STATE({ isLogin: true }));
dispatch(getUserAccountInfo());
await delay(500);
};
export const postUserSignOut = (
callback?: () => void,
): ThunkAction<Promise<void>, StoreState, MiddleWare, { type: string }> => async (
dispatch,
getState,
{ api, env },
): Promise<void> => {
const result = await api.user.postUserSignOut();
if (!result.error) {
if (result.url) {
dispatch(
SET_USER_SIGN_OUT_URL({
url: result,
}),
);
} else {
dispatch(postGlobalReduxReset());
if (callback) callback();
}
api.removeAccessToken();
removeLocalUserToken(env);
}
if (result.error) {
dispatch(errorCatch(result.error));
}
};

View File

@ -26,6 +26,9 @@ export function createUserReducer(params: CreateUserReducerParams): Reducer<User
case USER_ACTION.SET_USER_OAUTH_URL: case USER_ACTION.SET_USER_OAUTH_URL:
draft.updateUserOAuthUrl(action.url); draft.updateUserOAuthUrl(action.url);
break; break;
case USER_ACTION.SET_USER_SIGN_OUT_URL:
draft.updateUserSignOutUrl(action.url);
break;
case GLOBAL_ACTION.SET_GLOBAL_REDUX_RESET: case GLOBAL_ACTION.SET_GLOBAL_REDUX_RESET:
draft.initialize(); draft.initialize();
break; break;

View File

@ -1,6 +1,6 @@
import USER_ACTION from '@Reducers/_Constants/User'; import USER_ACTION from '@Reducers/_Constants/User';
import User from '@Models/Redux/User'; import User from '@Models/Redux/User';
import { UserAccountInfo, UserOAuthUrl } from '@Models/Redux/User/types'; import { UserAccountInfo, UserOAuthUrl, UserSignOutUrl } from '@Models/Redux/User/types';
import { SetGlobalReduxResetAction } from '@Reducers/global/types'; import { SetGlobalReduxResetAction } from '@Reducers/global/types';
export type UserState = User; export type UserState = User;
@ -38,6 +38,11 @@ export interface SetUserOAuthAction {
url: UserOAuthUrl; url: UserOAuthUrl;
} }
export interface SetUserSignOutAction {
type: USER_ACTION.SET_USER_SIGN_OUT_URL;
url: UserSignOutUrl;
}
export type UserActionTypes = export type UserActionTypes =
| SetUserContentInitialAction | SetUserContentInitialAction
| SetUserLoginStateAction | SetUserLoginStateAction
@ -45,6 +50,7 @@ export type UserActionTypes =
| SetUserTokenAction | SetUserTokenAction
| SetUserAccountInfoAction | SetUserAccountInfoAction
| SetGlobalReduxResetAction | SetGlobalReduxResetAction
| SetUserOAuthAction; | SetUserOAuthAction
| SetUserSignOutAction;
export { UserAccountInfo, UserOAuthUrl }; export { UserAccountInfo, UserOAuthUrl, UserSignOutUrl };