- 后端:新增 refresh_token 模型与服务,登录签发 refresh token,支持静默续期与登出撤销 (backend/internal/model/refresh_token.go, backend/internal/service/auth/refresh.go, auth_controller/auth_service/dto/router/jwt 配套改动) - 前端:登录页改为「记住登录」(只持久化引导令牌与用户名,不落盘密码),401 自动刷新并重试原请求 (stores/user.js, api/request.js, api/auth.js, main.js, Login.vue 配套改动) - 部署:已在容器中验证运行(镜像构建于 2026-11-03 00:50,服务 healthy) - 安全:仓库内无任何 .env/证书/密钥文件变更
This commit is contained in:
1 parent
4bf7fb6e5c
commit
a01b89f942
13 files changed
+531
-208
No files matched your search
@@ -1,5 +1,14 @@
|
||||
import axios from 'axios'
|
||||
import request from './request'
|
||||
|
||||
// 认证相关的裸客户端:刷新/登出**不能**走 request 实例,
|
||||
// 否则刷新失败触发的 401 拦截会再次调用刷新,形成递归。 // Logan.Li
|
||||
const authClient = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
|
||||
timeout: 15000,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
|
||||
// 登录 // Logan.Li
|
||||
export function login(data) {
|
||||
return request({
|
||||
@@ -9,6 +18,22 @@ export function login(data) {
|
||||
})
|
||||
}
|
||||
|
||||
// 用刷新令牌换取新的访问令牌(响应与登录同构:token + refresh_token + user_info) // Logan.Li
|
||||
export async function refresh(data) {
|
||||
const res = await authClient.post('/auth/refresh', data)
|
||||
const body = res.data || {}
|
||||
if (body.code !== 0 && body.code !== 200) {
|
||||
throw new Error(body.message || '登录状态已失效')
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
// 登出:撤销服务端刷新令牌(失败不阻断本地清理) // Logan.Li
|
||||
export async function logout(data) {
|
||||
const res = await authClient.post('/auth/logout', data)
|
||||
return res.data
|
||||
}
|
||||
|
||||
// 获取当前用户信息 // Logan.Li
|
||||
export function getMe() {
|
||||
return request({
|
||||
|
||||
@@ -14,13 +14,37 @@ const request = axios.create({
|
||||
|
||||
let handling401 = false
|
||||
|
||||
// 401 兜底:先用刷新令牌静默换新访问令牌并重放原请求,
|
||||
// 刷新失败才退回登录页 —— 这样「记住登录」的用户不会被中途踢出。 // Logan.Li
|
||||
async function retryAfterRefresh(config) {
|
||||
if (!config || config._retried) return null
|
||||
const userStore = useUserStore()
|
||||
if (!userStore.refreshToken) return null
|
||||
config._retried = true
|
||||
try {
|
||||
await userStore.refreshAccessToken()
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
// 重新走一遍请求拦截器,注入刷新后的 Bearer // Logan.Li
|
||||
delete config.headers.Authorization
|
||||
return request(config)
|
||||
}
|
||||
|
||||
// 401 统一出口:能刷新就刷新重放,否则强制重新登录 // Logan.Li
|
||||
async function handleUnauthorized(config) {
|
||||
const retried = await retryAfterRefresh(config)
|
||||
if (retried) return retried
|
||||
return forceReLogin()
|
||||
}
|
||||
|
||||
// 统一强制重新登录,避免并发 401 叠加弹窗 // Logan.Li
|
||||
async function forceReLogin() {
|
||||
if (handling401) return Promise.reject(new Error('relogin'))
|
||||
handling401 = true
|
||||
try {
|
||||
const userStore = useUserStore()
|
||||
userStore.logout()
|
||||
userStore.clearLocal()
|
||||
ElMessage.warning('登录状态已过期,请重新登录')
|
||||
const redirect = router.currentRoute.value.fullPath
|
||||
await router.push({ path: '/login', query: redirect && redirect !== '/login' ? { redirect } : {} })
|
||||
@@ -60,7 +84,7 @@ request.interceptors.response.use(
|
||||
|
||||
// 401:登录态失效 // Logan.Li
|
||||
if (res.code === 401) {
|
||||
return forceReLogin()
|
||||
return handleUnauthorized(response.config)
|
||||
}
|
||||
|
||||
// 其他业务错误:仅错误用 ElMessage 提示(常规操作不打扰) // Logan.Li
|
||||
@@ -69,7 +93,7 @@ request.interceptors.response.use(
|
||||
},
|
||||
async (error) => {
|
||||
if (error.response?.status === 401) {
|
||||
return forceReLogin()
|
||||
return handleUnauthorized(error.config)
|
||||
}
|
||||
|
||||
let message = '网络错误'
|
||||
|
||||
@@ -24,4 +24,7 @@ app.use(ElementPlus, { locale: zhCn })
|
||||
const userStore = useUserStore()
|
||||
userStore.restore()
|
||||
|
||||
app.mount('#app')
|
||||
// 启动引导:访问令牌过期时先用刷新令牌静默续期,再挂载应用(避免刚打开就跳登录页) // Logan.Li
|
||||
userStore.bootstrap().finally(() => {
|
||||
app.mount('#app')
|
||||
})
|
||||
+140
-15
@@ -1,31 +1,144 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { login as loginApi, getMe } from '@/api/auth'
|
||||
import {
|
||||
login as loginApi,
|
||||
getMe,
|
||||
refresh as refreshApi,
|
||||
logout as logoutApi,
|
||||
} from '@/api/auth'
|
||||
|
||||
const TOKEN_KEY = 'opsx_licence_token'
|
||||
const REFRESH_KEY = 'opsx_licence_refresh'
|
||||
const REMEMBER_KEY = 'opsx_licence_remember_login'
|
||||
// 旧版本「记住密码」遗留键(存的是混淆后的账号密码),升级后必须清掉 // Logan.Li
|
||||
const LEGACY_PASSWORD_KEY = 'opsx_licence_remember'
|
||||
const LAST_USERNAME_KEY = 'opsx_licence_last_username'
|
||||
|
||||
// 读取键值(持久存储优先,其次会话存储) // Logan.Li
|
||||
function readKey(key) {
|
||||
return localStorage.getItem(key) || sessionStorage.getItem(key) || ''
|
||||
}
|
||||
|
||||
// 清除两个存储里的同一个键 // Logan.Li
|
||||
function dropKey(key) {
|
||||
localStorage.removeItem(key)
|
||||
sessionStorage.removeItem(key)
|
||||
}
|
||||
|
||||
// 解析 JWT 到期时间,判断是否已过期(留 30s 余量) // Logan.Li
|
||||
function isExpired(token, skewMs = 30000) {
|
||||
try {
|
||||
const payload = JSON.parse(
|
||||
atob(token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/'))
|
||||
)
|
||||
if (!payload?.exp) return false
|
||||
return payload.exp * 1000 <= Date.now() + skewMs
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// 用户状态 store(setup 语法) // Logan.Li
|
||||
export const useUserStore = defineStore('user', () => {
|
||||
const token = ref('')
|
||||
const refreshToken = ref('')
|
||||
const userInfo = ref(null)
|
||||
const rememberLogin = ref(false)
|
||||
|
||||
// 从 localStorage 恢复登录状态 // Logan.Li
|
||||
// 令牌落盘:勾选「记住登录」进 localStorage(长存),否则进 sessionStorage(关浏览器即失效) // Logan.Li
|
||||
function persistTokens(accessToken, refresh, remember) {
|
||||
dropKey(TOKEN_KEY)
|
||||
dropKey(REFRESH_KEY)
|
||||
const storage = remember ? localStorage : sessionStorage
|
||||
if (accessToken) storage.setItem(TOKEN_KEY, accessToken)
|
||||
if (refresh) storage.setItem(REFRESH_KEY, refresh)
|
||||
if (remember) {
|
||||
localStorage.setItem(REMEMBER_KEY, '1')
|
||||
} else {
|
||||
localStorage.removeItem(REMEMBER_KEY)
|
||||
}
|
||||
rememberLogin.value = !!remember
|
||||
}
|
||||
|
||||
// 清空本地登录状态(含旧版本遗留的明文密码键) // Logan.Li
|
||||
function clearLocal() {
|
||||
dropKey(TOKEN_KEY)
|
||||
dropKey(REFRESH_KEY)
|
||||
localStorage.removeItem(REMEMBER_KEY)
|
||||
localStorage.removeItem(LEGACY_PASSWORD_KEY)
|
||||
token.value = ''
|
||||
refreshToken.value = ''
|
||||
userInfo.value = null
|
||||
rememberLogin.value = false
|
||||
}
|
||||
|
||||
// 从本地存储恢复登录状态 // Logan.Li
|
||||
function restore() {
|
||||
const saved = localStorage.getItem(TOKEN_KEY)
|
||||
if (saved) {
|
||||
token.value = saved
|
||||
clearLegacyPassword()
|
||||
token.value = readKey(TOKEN_KEY)
|
||||
refreshToken.value = readKey(REFRESH_KEY)
|
||||
rememberLogin.value = localStorage.getItem(REMEMBER_KEY) === '1'
|
||||
}
|
||||
|
||||
// 清理旧版本「记住密码」留下的账号密码(幂等,可重复调用) // Logan.Li
|
||||
function clearLegacyPassword() {
|
||||
if (localStorage.getItem(LEGACY_PASSWORD_KEY) !== null) {
|
||||
localStorage.removeItem(LEGACY_PASSWORD_KEY)
|
||||
}
|
||||
}
|
||||
|
||||
// 登录:保存 token 并拉取用户信息 // Logan.Li
|
||||
async function login(loginForm) {
|
||||
// 登录:保存令牌对并拉取用户信息;remember 决定令牌存哪 // Logan.Li
|
||||
async function login(loginForm, remember = false) {
|
||||
const res = await loginApi(loginForm)
|
||||
token.value = res.data.token
|
||||
userInfo.value = res.data.user_info
|
||||
localStorage.setItem(TOKEN_KEY, token.value)
|
||||
const data = res.data || {}
|
||||
token.value = data.token
|
||||
refreshToken.value = data.refresh_token || ''
|
||||
userInfo.value = data.user_info
|
||||
persistTokens(token.value, refreshToken.value, remember)
|
||||
return res
|
||||
}
|
||||
|
||||
// 静默刷新:并发调用共享同一个 promise,避免重复轮换导致令牌互踢 // Logan.Li
|
||||
let refreshing = null
|
||||
function refreshAccessToken() {
|
||||
if (refreshing) return refreshing
|
||||
const current = refreshToken.value || readKey(REFRESH_KEY)
|
||||
if (!current) return Promise.reject(new Error('no-refresh-token'))
|
||||
refreshing = refreshApi({ refresh_token: current })
|
||||
.then((body) => {
|
||||
const data = body.data || {}
|
||||
token.value = data.token
|
||||
refreshToken.value = data.refresh_token || ''
|
||||
if (data.user_info) userInfo.value = data.user_info
|
||||
persistTokens(token.value, refreshToken.value, rememberLogin.value)
|
||||
return token.value
|
||||
})
|
||||
.catch((err) => {
|
||||
clearLocal()
|
||||
throw err
|
||||
})
|
||||
.finally(() => {
|
||||
refreshing = null
|
||||
})
|
||||
return refreshing
|
||||
}
|
||||
|
||||
// 启动引导:访问令牌缺失或已过期时,尝试用刷新令牌换一对新令牌 // Logan.Li
|
||||
async function bootstrap() {
|
||||
restore()
|
||||
if (token.value && !isExpired(token.value)) return true
|
||||
if (!refreshToken.value) {
|
||||
if (token.value) clearLocal()
|
||||
return false
|
||||
}
|
||||
try {
|
||||
await refreshAccessToken()
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 拉取当前用户信息 // Logan.Li
|
||||
async function fetchUserInfo() {
|
||||
const res = await getMe()
|
||||
@@ -33,19 +146,31 @@ export const useUserStore = defineStore('user', () => {
|
||||
return res.data
|
||||
}
|
||||
|
||||
// 登出:清空状态 // Logan.Li
|
||||
function logout() {
|
||||
token.value = ''
|
||||
userInfo.value = null
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
// 登出:先撤销服务端刷新令牌,再清本地(远端失败也照常清) // Logan.Li
|
||||
async function logout() {
|
||||
const current = refreshToken.value || readKey(REFRESH_KEY)
|
||||
clearLocal()
|
||||
if (current) {
|
||||
try {
|
||||
await logoutApi({ refresh_token: current })
|
||||
} catch {
|
||||
// 网络异常不影响本地登出 // Logan.Li
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
refreshToken,
|
||||
userInfo,
|
||||
rememberLogin,
|
||||
restore,
|
||||
bootstrap,
|
||||
login,
|
||||
refreshAccessToken,
|
||||
fetchUserInfo,
|
||||
logout,
|
||||
clearLegacyPassword,
|
||||
LAST_USERNAME_KEY,
|
||||
}
|
||||
})
|
||||
+20
-169
@@ -1,12 +1,11 @@
|
||||
<template>
|
||||
<meta name="robots" content="noindex,nofollow,noarchive" />
|
||||
<div class="login-page">
|
||||
<!-- 右侧登录区 -->
|
||||
<main class="form-panel">
|
||||
<div class="form-card">
|
||||
<div class="form-head">
|
||||
<h2>登录</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-form
|
||||
ref="loginFormRef"
|
||||
@@ -37,7 +36,7 @@
|
||||
</el-form-item>
|
||||
|
||||
<div class="form-extra">
|
||||
<el-checkbox v-model="rememberPassword">记住密码</el-checkbox>
|
||||
<el-checkbox v-model="rememberLogin">记住登录</el-checkbox>
|
||||
<span class="remember-hint">{{ rememberHint }}</span>
|
||||
</div>
|
||||
|
||||
@@ -50,8 +49,7 @@
|
||||
{{ loading ? '登录中…' : '登 录' }}
|
||||
</el-button>
|
||||
</el-form>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
@@ -68,7 +66,9 @@ const userStore = useUserStore()
|
||||
|
||||
const loginFormRef = ref(null)
|
||||
const loading = ref(false)
|
||||
const rememberPassword = ref(false)
|
||||
// 「记住登录」只影响刷新令牌的存放位置(localStorage / sessionStorage),
|
||||
// 账号密码一律不落盘 —— 本机只保存服务端可撤销的登录凭据。 // Logan.Li
|
||||
const rememberLogin = ref(false)
|
||||
|
||||
const loginForm = reactive({
|
||||
username: '',
|
||||
@@ -81,63 +81,16 @@ const loginRules = {
|
||||
}
|
||||
|
||||
const rememberHint = computed(() =>
|
||||
rememberPassword.value ? '已在本机保存,下次自动填充' : '勾选后本机自动填充账号密码'
|
||||
rememberLogin.value ? '30 天内免登录(不保存密码)' : '关闭浏览器后需重新登录'
|
||||
)
|
||||
|
||||
// ============ 「记住密码」本地存储 ============
|
||||
// 仅保存在本机浏览器;做一层 XOR + Base64 混淆,避免明文躺在 localStorage 里被随手看到。
|
||||
// ⚠️ 这是混淆而非加密,公共设备请勿勾选。 // Logan.Li
|
||||
const REMEMBER_KEY = 'opsx_licence_remember'
|
||||
const REMEMBER_USERNAME_KEY = 'opsx_licence_last_username'
|
||||
const XOR_KEY = 0x2f
|
||||
|
||||
function obfuscate(text) {
|
||||
const xored = Array.from(text)
|
||||
.map((ch) => String.fromCharCode(ch.charCodeAt(0) ^ XOR_KEY))
|
||||
.join('')
|
||||
return btoa(encodeURIComponent(xored))
|
||||
}
|
||||
|
||||
function deobfuscate(token) {
|
||||
const xored = decodeURIComponent(atob(token))
|
||||
return Array.from(xored)
|
||||
.map((ch) => String.fromCharCode(ch.charCodeAt(0) ^ XOR_KEY))
|
||||
.join('')
|
||||
}
|
||||
|
||||
// 读取上次的账号 / 密码 // Logan.Li
|
||||
function restoreRemembered() {
|
||||
try {
|
||||
const lastUser = localStorage.getItem(REMEMBER_USERNAME_KEY)
|
||||
if (lastUser) loginForm.username = lastUser
|
||||
|
||||
const saved = localStorage.getItem(REMEMBER_KEY)
|
||||
if (!saved) return
|
||||
const data = JSON.parse(deobfuscate(saved))
|
||||
if (data?.u) loginForm.username = data.u
|
||||
if (data?.p) {
|
||||
loginForm.password = data.p
|
||||
rememberPassword.value = true
|
||||
}
|
||||
} catch {
|
||||
localStorage.removeItem(REMEMBER_KEY)
|
||||
}
|
||||
}
|
||||
|
||||
// 保存 / 清理记住的凭据 // Logan.Li
|
||||
function persistRemembered() {
|
||||
const username = loginForm.username.trim()
|
||||
if (username) {
|
||||
localStorage.setItem(REMEMBER_USERNAME_KEY, username)
|
||||
}
|
||||
if (rememberPassword.value && username) {
|
||||
localStorage.setItem(
|
||||
REMEMBER_KEY,
|
||||
obfuscate(JSON.stringify({ u: username, p: loginForm.password }))
|
||||
)
|
||||
} else {
|
||||
localStorage.removeItem(REMEMBER_KEY)
|
||||
}
|
||||
// 回填上次登录的用户名(只记用户名,不记密码) // Logan.Li
|
||||
function restoreLastUsername() {
|
||||
// 清掉旧版本「记住密码」留下的混淆账号密码 // Logan.Li
|
||||
userStore.clearLegacyPassword()
|
||||
const lastUser = localStorage.getItem(userStore.LAST_USERNAME_KEY)
|
||||
if (lastUser) loginForm.username = lastUser
|
||||
rememberLogin.value = userStore.rememberLogin
|
||||
}
|
||||
|
||||
// 处理登录 // Logan.Li
|
||||
@@ -147,11 +100,10 @@ async function handleLogin() {
|
||||
if (!valid) return
|
||||
loading.value = true
|
||||
try {
|
||||
await userStore.login({
|
||||
username: loginForm.username.trim(),
|
||||
password: loginForm.password,
|
||||
})
|
||||
persistRemembered()
|
||||
const username = loginForm.username.trim()
|
||||
await userStore.login({ username, password: loginForm.password }, rememberLogin.value)
|
||||
localStorage.setItem(userStore.LAST_USERNAME_KEY, username)
|
||||
loginForm.password = ''
|
||||
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/'
|
||||
router.push(redirect)
|
||||
} catch {
|
||||
@@ -162,7 +114,7 @@ async function handleLogin() {
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(restoreRemembered)
|
||||
onMounted(restoreLastUsername)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -172,87 +124,6 @@ onMounted(restoreRemembered)
|
||||
background: #f5f7fa;
|
||||
}
|
||||
|
||||
/* ============ 左侧品牌区 ============ */
|
||||
.brand-panel {
|
||||
position: relative;
|
||||
flex: 0 0 46%;
|
||||
max-width: 620px;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(150deg, #0b1b3a 0%, #10294f 48%, #0d3b57 100%);
|
||||
color: #e8eefc;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.brand-glow {
|
||||
position: absolute;
|
||||
width: 520px;
|
||||
height: 520px;
|
||||
right: -180px;
|
||||
top: -160px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(64, 158, 255, 0.35) 0%, rgba(64, 158, 255, 0) 70%);
|
||||
pointer-events: none;
|
||||
}
|
||||
.brand-inner {
|
||||
position: relative;
|
||||
padding: 56px 56px 48px;
|
||||
width: 100%;
|
||||
}
|
||||
.brand-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
color: #fff;
|
||||
}
|
||||
.brand-title {
|
||||
margin: 34px 0 14px;
|
||||
font-size: 30px;
|
||||
line-height: 1.35;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
}
|
||||
.brand-desc {
|
||||
margin: 0;
|
||||
max-width: 30em;
|
||||
font-size: 14px;
|
||||
line-height: 1.75;
|
||||
color: rgba(232, 238, 252, 0.72);
|
||||
}
|
||||
.brand-points {
|
||||
margin: 38px 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 22px;
|
||||
}
|
||||
.brand-points li {
|
||||
padding-left: 18px;
|
||||
border-left: 2px solid rgba(64, 158, 255, 0.5);
|
||||
}
|
||||
.brand-points b {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.brand-points span {
|
||||
font-size: 13px;
|
||||
line-height: 1.65;
|
||||
color: rgba(232, 238, 252, 0.6);
|
||||
}
|
||||
.brand-foot {
|
||||
margin-top: 46px;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.02em;
|
||||
color: rgba(232, 238, 252, 0.38);
|
||||
}
|
||||
|
||||
/* ============ 右侧表单区 ============ */
|
||||
.form-panel {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
@@ -268,16 +139,11 @@ onMounted(restoreRemembered)
|
||||
margin-bottom: 26px;
|
||||
}
|
||||
.form-head h2 {
|
||||
margin: 0 0 8px;
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: #1f2d3d;
|
||||
}
|
||||
.form-head p {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: #8492a6;
|
||||
}
|
||||
.form-extra {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -294,12 +160,6 @@ onMounted(restoreRemembered)
|
||||
font-size: 15px;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.form-note {
|
||||
margin: 18px 0 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: #a3adbd;
|
||||
}
|
||||
|
||||
/* 表单项标签更紧凑,整体更接近控制台观感 */
|
||||
.form-card :deep(.el-form-item) {
|
||||
@@ -311,11 +171,7 @@ onMounted(restoreRemembered)
|
||||
color: #5a6b83;
|
||||
}
|
||||
|
||||
/* ============ 窄屏:收起品牌区,表单居中 ============ */
|
||||
@media (max-width: 900px) {
|
||||
.brand-panel {
|
||||
display: none;
|
||||
}
|
||||
.form-panel {
|
||||
padding: 32px 20px;
|
||||
}
|
||||
@@ -329,9 +185,4 @@ onMounted(restoreRemembered)
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 低调化:取消左右分栏,登录卡居中 // Logan.Li */
|
||||
.wrap, .login-wrap { grid-template-columns: 1fr !important; display: block !important; }
|
||||
.brand-panel { display: none !important; }
|
||||
.form-panel { min-height: 100vh; display: flex !important; align-items: center; justify-content: center; }
|
||||
</style>
|
||||
Reference in new issue
Block a user