feat: support for opening login modal after login session expiration (#715)

#### What type of PR is this?

/kind feature
/milestone 2.0

#### What this PR does / why we need it:

支持在登录会话失效之后打开登录弹窗,而不是直接跳转到登录页面,防止正在编辑的内容丢失。

#### Which issue(s) this PR fixes:

Fixes https://github.com/halo-dev/halo/issues/2783

#### Screenshots:

<img width="541" alt="image" src="https://user-images.githubusercontent.com/21301288/204085654-5c90627b-fbbd-4b04-ac92-f75bab28a1b0.png">


#### Special notes for your reviewer:

测试方式:

1. 打开 Console 之后登录进入到控制台。
2. 重启 Halo 或者等待会话失效。
3. 随意切换控制台界面,观察是否打开了登录弹窗。
4. 重新登录,检查是否成功。

#### Does this PR introduce a user-facing change?

```release-note
Console 端支持在登录会话失效之后打开登录弹窗。
```
pull/728/head
Ryan Wang 2022-11-28 22:30:19 +08:00 committed by GitHub
parent 0fc4815a67
commit 6901cdb812
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 164 additions and 117 deletions

View File

@ -134,10 +134,10 @@ defineExpose({ close });
</template>
<style lang="scss">
.toast-container {
@apply fixed pointer-events-none flex flex-col box-border transition-all w-full left-0 top-0 items-center justify-center p-4 gap-3;
@apply fixed pointer-events-none flex z-[9999] flex-col box-border transition-all w-full left-0 top-0 items-center justify-center p-4 gap-3;
.toast-wrapper {
@apply inline-block max-w-xs z-50 pointer-events-auto relative;
@apply inline-block max-w-xs pointer-events-auto relative;
}
.toast-body {

View File

@ -0,0 +1,120 @@
<script lang="ts" setup>
import { setFocus } from "@/formkit/utils/focus";
import { useUserStore } from "@/stores/user";
import { randomUUID } from "@/utils/id";
import axios from "axios";
import { Toast, VButton } from "@halo-dev/components";
import { onMounted, ref } from "vue";
import qs from "qs";
import { submitForm } from "@formkit/core";
const emit = defineEmits<{
(event: "succeed"): void;
}>();
interface LoginForm {
_csrf: string;
username: string;
password: string;
}
const userStore = useUserStore();
const loginForm = ref<LoginForm>({
_csrf: "",
username: "",
password: "",
});
const loading = ref(false);
const handleGenerateToken = async () => {
const token = randomUUID();
loginForm.value._csrf = token;
document.cookie = `XSRF-TOKEN=${token}; Path=/;`;
};
const handleLogin = async () => {
try {
loading.value = true;
await axios.post(
`${import.meta.env.VITE_API_URL}/login`,
qs.stringify(loginForm.value),
{
withCredentials: true,
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
}
);
await userStore.fetchCurrentUser();
localStorage.setItem("logged_in", "true");
emit("succeed");
} catch (e) {
console.error("Failed to login", e);
Toast.error("登录失败,用户名或密码错误");
loginForm.value.password = "";
setFocus("passwordInput");
} finally {
loading.value = false;
}
};
onMounted(() => {
handleGenerateToken();
});
</script>
<template>
<FormKit
id="login-form"
v-model="loginForm"
name="login-form"
:actions="false"
type="form"
:config="{ validationVisibility: 'submit' }"
@submit="handleLogin"
@keyup.enter="submitForm('login-form')"
>
<FormKit
:validation-messages="{
required: '请输入用户名',
}"
name="username"
placeholder="用户名"
type="text"
validation="required"
>
<template #prefixIcon>
<IconUserLine />
</template>
</FormKit>
<FormKit
id="passwordInput"
:validation-messages="{
required: '请输入密码',
}"
name="password"
placeholder="密码"
type="password"
validation="required"
>
<template #prefixIcon>
<IconShieldUser />
</template>
</FormKit>
</FormKit>
<VButton
class="mt-6"
block
:loading="loading"
type="secondary"
@click="submitForm('login-form')"
>
登录
</VButton>
</template>

View File

@ -0,0 +1,29 @@
<script lang="ts" setup>
import { Toast, VModal } from "@halo-dev/components";
import LoginForm from "@/components/login/LoginForm.vue";
import { useUserStore } from "@/stores/user";
const userStore = useUserStore();
const onVisibleChange = (visible: boolean) => {
userStore.loginModalVisible = visible;
};
const onLoginSucceed = () => {
onVisibleChange(false);
Toast.success("登录成功");
};
</script>
<template>
<VModal
:visible="userStore.loginModalVisible"
:mount-to-body="true"
:width="400"
:centered="true"
title="重新登录"
@update:visible="onVisibleChange"
>
<LoginForm v-if="userStore.loginModalVisible" @succeed="onLoginSucceed" />
</VModal>
</template>

View File

@ -21,6 +21,7 @@ import {
import { computed, onMounted, onUnmounted, ref } from "vue";
import axios from "axios";
import GlobalSearchModal from "@/components/global-search/GlobalSearchModal.vue";
import LoginModal from "@/components/login/LoginModal.vue";
import { coreMenuGroups } from "@/router/routes.config";
import sortBy from "lodash.sortby";
import { useRoleStore } from "@/stores/role";
@ -354,6 +355,7 @@ onMounted(generateMenus);
</div>
</div>
<GlobalSearchModal v-model:visible="globalSearchVisible" />
<LoginModal />
</template>
<style lang="scss">

View File

@ -1,130 +1,23 @@
<script lang="ts" setup>
import {
IconShieldUser,
IconUserLine,
VButton,
Toast,
} from "@halo-dev/components";
import qs from "qs";
import { onBeforeMount, onMounted, ref } from "vue";
import { submitForm } from "@formkit/vue";
import { onBeforeMount } from "vue";
import router from "@/router";
import axios from "axios";
import { setFocus } from "@/formkit/utils/focus";
import IconLogo from "~icons/core/logo?width=5rem&height=2rem";
import { randomUUID } from "@/utils/id";
import { useUserStore } from "@/stores/user";
interface LoginForm {
_csrf: string;
username: string;
password: string;
}
import LoginForm from "@/components/login/LoginForm.vue";
const userStore = useUserStore();
const loginForm = ref<LoginForm>({
_csrf: "",
username: "",
password: "",
});
const loading = ref(false);
const handleGenerateToken = async () => {
const token = randomUUID();
loginForm.value._csrf = token;
document.cookie = `XSRF-TOKEN=${token}; Path=/;`;
};
const handleLogin = async () => {
try {
loading.value = true;
await axios.post(
`${import.meta.env.VITE_API_URL}/login`,
qs.stringify(loginForm.value),
{
withCredentials: true,
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
}
);
await userStore.fetchCurrentUser();
localStorage.setItem("logged_in", "true");
router.go(0);
} catch (e) {
console.error("Failed to login", e);
Toast.error("登录失败,用户名或密码错误");
loginForm.value.password = "";
setFocus("passwordInput");
} finally {
loading.value = false;
}
};
onBeforeMount(() => {
if (!userStore.isAnonymous) {
router.push({ name: "Dashboard" });
}
});
onMounted(() => {
handleGenerateToken();
});
</script>
<template>
<div class="flex h-screen flex-col items-center justify-center">
<IconLogo class="mb-8" />
<div class="login-form flex w-72 flex-col gap-4">
<FormKit
id="login-form"
v-model="loginForm"
name="login-form"
:actions="false"
type="form"
:config="{ validationVisibility: 'submit' }"
@submit="handleLogin"
@keyup.enter="submitForm('login-form')"
>
<FormKit
:validation-messages="{
required: '请输入用户名',
}"
name="username"
placeholder="用户名"
type="text"
validation="required"
>
<template #prefixIcon>
<IconUserLine />
</template>
</FormKit>
<FormKit
id="passwordInput"
:validation-messages="{
required: '请输入密码',
}"
name="password"
placeholder="密码"
type="password"
validation="required"
>
<template #prefixIcon>
<IconShieldUser />
</template>
</FormKit>
</FormKit>
<VButton
block
:loading="loading"
type="secondary"
@click="submitForm('login-form')"
>
登录
</VButton>
<div class="login-form flex w-72 flex-col">
<LoginForm @succeed="router.go(0)" />
</div>
</div>
</template>

View File

@ -5,12 +5,14 @@ import { defineStore } from "pinia";
interface UserStoreState {
currentUser?: User;
isAnonymous: boolean;
loginModalVisible: boolean;
}
export const useUserStore = defineStore("user", {
state: (): UserStoreState => ({
currentUser: undefined,
isAnonymous: true,
loginModalVisible: false,
}),
actions: {
async fetchCurrentUser() {

View File

@ -34,7 +34,8 @@ import {
} from "@halo-dev/api-client";
import type { AxiosInstance } from "axios";
import axios from "axios";
import router from "@/router";
import { useUserStore } from "@/stores/user";
import { Toast } from "@halo-dev/components";
const baseURL = import.meta.env.VITE_API_URL;
@ -49,10 +50,10 @@ axiosInstance.interceptors.response.use(
},
async (error) => {
if (error.response.status === 401) {
const userStore = useUserStore();
userStore.loginModalVisible = true;
Toast.warning("登录已过期,请重新登录");
localStorage.removeItem("logged_in");
router.push({
name: "Login",
});
}
return Promise.reject(error);
}