feat: add oauth login

This commit is contained in:
Paul Pan 2024-03-15 19:04:16 +08:00
parent 09ef8c9c98
commit b7289dd09c
6 changed files with 104 additions and 20 deletions

View File

@ -1,3 +1,4 @@
node_modules/ node_modules/
/dist/ /dist/
pnpm-lock.yaml /pnpm-lock.yaml
/postcss.config.cjs

View File

@ -37,7 +37,8 @@
"rehype-raw": "^7.0.0", "rehype-raw": "^7.0.0",
"remark-emoji": "^4.0.1", "remark-emoji": "^4.0.1",
"remark-gfm": "^4.0.0", "remark-gfm": "^4.0.0",
"remark-math": "^6.0.0" "remark-math": "^6.0.0",
"universal-cookie": "^7.1.0"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^18.2.66", "@types/react": "^18.2.66",

View File

@ -74,6 +74,9 @@ dependencies:
remark-math: remark-math:
specifier: ^6.0.0 specifier: ^6.0.0
version: 6.0.0 version: 6.0.0
universal-cookie:
specifier: ^7.1.0
version: 7.1.0
devDependencies: devDependencies:
'@types/react': '@types/react':
@ -2400,6 +2403,10 @@ packages:
'@babel/types': 7.24.0 '@babel/types': 7.24.0
dev: true dev: true
/@types/cookie@0.6.0:
resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==}
dev: false
/@types/debug@4.1.12: /@types/debug@4.1.12:
resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==}
dependencies: dependencies:
@ -3098,6 +3105,11 @@ packages:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
dev: true dev: true
/cookie@0.6.0:
resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==}
engines: {node: '>= 0.6'}
dev: false
/copy-to-clipboard@3.3.3: /copy-to-clipboard@3.3.3:
resolution: {integrity: sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==} resolution: {integrity: sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==}
dependencies: dependencies:
@ -6515,6 +6527,13 @@ packages:
unist-util-visit-parents: 6.0.1 unist-util-visit-parents: 6.0.1
dev: false dev: false
/universal-cookie@7.1.0:
resolution: {integrity: sha512-LCLHwP0whxTqkBYMptW1dzNS0xxIVJmU6c51N5CfPNheVxuJW7fVxPa6MUGX7boUSyOlpMveBO96hMs5Gee6Fg==}
dependencies:
'@types/cookie': 0.6.0
cookie: 0.6.0
dev: false
/universalify@0.2.0: /universalify@0.2.0:
resolution: {integrity: sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==} resolution: {integrity: sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==}
engines: {node: '>= 4.0.0'} engines: {node: '>= 4.0.0'}

24
src/app/services/oauth.ts Normal file
View File

@ -0,0 +1,24 @@
import type { Wrap } from "./base";
import { api } from "./api";
export interface OAuthLoginResponse {
url: string;
cookie: {
name: string;
value: string;
live: number;
};
}
export const oauthApi = api.injectEndpoints({
endpoints: (builder) => ({
OAuthUrl: builder.query<Wrap<OAuthLoginResponse>, void>({
query: () => ({
url: "/oauth/login",
method: "POST",
}),
}),
}),
});
export const { useOAuthUrlQuery } = oauthApi;

View File

@ -1,3 +1,4 @@
import type { PayloadAction } from "@reduxjs/toolkit";
import { createSlice } from "@reduxjs/toolkit"; import { createSlice } from "@reduxjs/toolkit";
import { userApi } from "../../app/services/user"; import { userApi } from "../../app/services/user";
import type { RootState } from "../../app/store"; import type { RootState } from "../../app/store";
@ -17,12 +18,16 @@ export const authSlice = createSlice({
load: (state) => { load: (state) => {
state.token = localStorage.getItem("token"); state.token = localStorage.getItem("token");
}, },
setToken: (state, action: PayloadAction<string>) => {
const token = action.payload;
localStorage.setItem("token", token);
state.token = token;
},
}, },
extraReducers: (builder) => { extraReducers: (builder) => {
builder builder
.addMatcher(userApi.endpoints.login.matchFulfilled, (_state, action) => { .addMatcher(userApi.endpoints.login.matchFulfilled, (_state, action) => {
// Login Success // Login Success
console.log(action);
const { token } = action.payload.body; const { token } = action.payload.body;
localStorage.setItem("token", token); localStorage.setItem("token", token);
return { ...initialState, token: token }; return { ...initialState, token: token };
@ -41,6 +46,6 @@ export const authSlice = createSlice({
}, },
}); });
export const { load } = authSlice.actions; export const { load, setToken } = authSlice.actions;
export const selectCurrentUser = (state: RootState) => state.auth; export const selectCurrentUser = (state: RootState) => state.auth;

View File

@ -1,5 +1,5 @@
import type React from "react"; import type React from "react";
import { useState } from "react"; import { useEffect, useState } from "react";
import { import {
Box, Box,
Button, Button,
@ -17,29 +17,53 @@ import {
useColorModeValue, useColorModeValue,
useToast, useToast,
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import { Link as ReactRouterLink, useNavigate } from "react-router-dom"; import { Link as ReactRouterLink, useNavigate, useSearchParams } from "react-router-dom";
import { RiLoginCircleLine } from "react-icons/ri"; import Cookies from "universal-cookie";
import KeyCloakSVG from "../resources/keycloak.svg"; import { useAppDispatch } from "../hooks/store";
import { PasswordField } from "../components/PasswordField"; import { PasswordField } from "../components/PasswordField";
import { api } from "../app/services/api";
import type { UserRequest } from "../app/services/user"; import type { UserRequest } from "../app/services/user";
import { useLoginMutation } from "../app/services/user"; import { useLoginMutation } from "../app/services/user";
import { useOAuthUrlQuery } from "../app/services/oauth";
import type { Wrap } from "../app/services/base"; import type { Wrap } from "../app/services/base";
export default function LoginPage() { import { RiLoginCircleLine } from "react-icons/ri";
const toast = useToast(); import KeyCloakSVG from "../resources/keycloak.svg";
const navigate = useNavigate(); import { setToken } from "../features/auth/authSlice";
export default function LoginPage() {
const bg = useColorModeValue("gray.50", "gray.900"); const bg = useColorModeValue("gray.50", "gray.900");
const fg = "teal"; const fg = "teal";
const fg2 = "gray"; const fg2 = "gray";
const [login, { isLoading }] = useLoginMutation(); const dispatch = useAppDispatch();
const toast = useToast();
const navigate = useNavigate();
const cookies = new Cookies(null);
const [searchParams, _] = useSearchParams();
const redirectToken = searchParams.get("redirect_token");
const [login, { isLoading: loginIsLoading }] = useLoginMutation();
const { data: oauthInfo, isSuccess: oauthIsSuccess } = useOAuthUrlQuery(undefined, {
// refresh every minute
pollingInterval: 60000,
});
const [formState, setFormState] = useState<UserRequest>({ const [formState, setFormState] = useState<UserRequest>({
email: "", email: "",
password: "", password: "",
}); });
useEffect(() => {
// callback from sso login
if (!redirectToken) return;
dispatch(setToken(redirectToken));
dispatch(api.util.invalidateTags(["User", "Status", "Submission"]));
navigate("/");
}, [dispatch, navigate, redirectToken]);
const handleChange = ({ target: { name, value } }: React.ChangeEvent<HTMLInputElement>) => const handleChange = ({ target: { name, value } }: React.ChangeEvent<HTMLInputElement>) =>
setFormState((prev) => ({ ...prev, [name]: value })); setFormState((prev) => ({ ...prev, [name]: value }));
@ -75,12 +99,22 @@ export default function LoginPage() {
}; };
const doSSOLogin = () => { const doSSOLogin = () => {
toast({ if (!oauthIsSuccess || !oauthInfo) {
status: "error", toast({
title: "Error", status: "loading",
description: "Not Implemented", title: "Loading",
isClosable: true, description: "Please wait for a while and try again ...",
isClosable: true,
});
return;
}
cookies.set(oauthInfo.body.cookie.name, oauthInfo.body.cookie.value, {
path: "/",
maxAge: oauthInfo.body.cookie.live,
}); });
window.open(oauthInfo.body.url, "_self");
}; };
const formArea = ( const formArea = (
@ -93,11 +127,11 @@ export default function LoginPage() {
<Stack spacing="5"> <Stack spacing="5">
<FormControl> <FormControl>
<FormLabel htmlFor="email">Email</FormLabel> <FormLabel htmlFor="email">Email</FormLabel>
<Input id="email" name="email" type="email" isDisabled={isLoading} onChange={handleChange} /> <Input id="email" name="email" type="email" isDisabled={loginIsLoading} onChange={handleChange} />
</FormControl> </FormControl>
<PasswordField isDisabled={isLoading} onChange={handleChange} /> <PasswordField isDisabled={loginIsLoading} onChange={handleChange} />
{/* eslint-disable-next-line @typescript-eslint/no-misused-promises */} {/* eslint-disable-next-line @typescript-eslint/no-misused-promises */}
<Button type="submit" colorScheme={fg} isLoading={isLoading} onClick={doLogin}> <Button type="submit" colorScheme={fg} isLoading={loginIsLoading} onClick={doLogin}>
Sign in Sign in
</Button> </Button>
</Stack> </Stack>