feat: add user authentication system
- User model (users table) with bcrypt password hashing - JWT-based login API (POST /api/auth/login, GET /api/auth/me) - Default admin account (admin/admin123) auto-created on startup - Login page with centered dark-themed login card - Route guards + Axios interceptors for token management - Login page renders without sidebar (standalone layout) - Gunicorn --preload to avoid worker DDL race condition
This commit is contained in:
parent
521cf0269c
commit
cd41e59afc
@ -100,8 +100,21 @@ def upgrade() -> None:
|
||||
sa.Column("updated_at", sa.DateTime, server_default=sa.func.now()),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"users",
|
||||
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
|
||||
sa.Column("username", sa.String(64), unique=True, nullable=False),
|
||||
sa.Column("password_hash", sa.String(256), nullable=False),
|
||||
sa.Column("display_name", sa.String(128), default=""),
|
||||
sa.Column("email", sa.String(128), default=""),
|
||||
sa.Column("is_active", sa.Boolean, default=True),
|
||||
sa.Column("created_at", sa.DateTime, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime, server_default=sa.func.now()),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("users")
|
||||
op.drop_table("system_config")
|
||||
op.drop_table("alerts")
|
||||
op.drop_table("alert_rules")
|
||||
|
||||
39
backend/app/api/auth.py
Normal file
39
backend/app/api/auth.py
Normal file
@ -0,0 +1,39 @@
|
||||
"""API router — Auth (login, me)."""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Header
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.schemas import LoginRequest, TokenResponse, UserInfo, UserOut
|
||||
from app.services.auth import AuthService, create_access_token, decode_access_token
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
async def login(body: LoginRequest, db: AsyncSession = Depends(get_db)):
|
||||
svc = AuthService(db)
|
||||
user = await svc.authenticate(body.username, body.password)
|
||||
if not user:
|
||||
raise HTTPException(401, detail="用户名或密码错误")
|
||||
token = create_access_token(user.id, user.username)
|
||||
return TokenResponse(
|
||||
access_token=token,
|
||||
user=UserInfo(id=user.id, username=user.username,
|
||||
display_name=user.display_name, email=user.email),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserOut)
|
||||
async def get_me(authorization: str = Header("", alias="Authorization"),
|
||||
db: AsyncSession = Depends(get_db)):
|
||||
if not authorization.startswith("Bearer "):
|
||||
raise HTTPException(401, detail="未登录")
|
||||
payload = decode_access_token(authorization[7:])
|
||||
if payload is None:
|
||||
raise HTTPException(401, detail="登录已过期,请重新登录")
|
||||
svc = AuthService(db)
|
||||
user = await svc.get_user_by_id(int(payload["sub"]))
|
||||
if not user or not user.is_active:
|
||||
raise HTTPException(401, detail="用户不存在或已停用")
|
||||
return UserOut(**user.__dict__)
|
||||
@ -10,14 +10,16 @@ from fastapi.responses import HTMLResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from app.api.agents import router as agents_router
|
||||
from app.api.auth import router as auth_router
|
||||
from app.api.tasks import router as tasks_router
|
||||
from app.api.executions import router as executions_router
|
||||
from app.api.notifications import router as notifications_router
|
||||
from app.api.dashboard import router as dashboard_router
|
||||
from app.api.system import router as system_router
|
||||
from app.config import settings
|
||||
from app.database import engine, Base
|
||||
from app.database import engine, Base, async_session_factory
|
||||
from app.services.scheduler import SchedulerService
|
||||
from app.services.auth import AuthService
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s")
|
||||
logger = logging.getLogger("taskpulse")
|
||||
@ -31,6 +33,9 @@ async def lifespan(app: FastAPI):
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
logger.info("Database tables ensured")
|
||||
# Ensure default admin
|
||||
async with async_session_factory() as db:
|
||||
await AuthService.ensure_default_admin(db)
|
||||
# Start background scheduler
|
||||
task = asyncio.create_task(scheduler.start())
|
||||
yield
|
||||
@ -49,6 +54,7 @@ app = FastAPI(
|
||||
|
||||
# API routers
|
||||
app.include_router(agents_router)
|
||||
app.include_router(auth_router)
|
||||
app.include_router(tasks_router)
|
||||
app.include_router(executions_router)
|
||||
app.include_router(notifications_router)
|
||||
|
||||
@ -5,5 +5,7 @@ from app.models.task import ScheduledTask # noqa: F401
|
||||
from app.models.execution import TaskExecution # noqa: F401
|
||||
from app.models.notification import Alert, AlertRule, NotificationChannel # noqa: F401
|
||||
from app.models.system_config import SystemConfig # noqa: F401
|
||||
from app.models.user import User # noqa: F401
|
||||
|
||||
__all__ = ["Agent", "ScheduledTask", "TaskExecution", "NotificationChannel", "AlertRule", "Alert", "SystemConfig"]
|
||||
__all__ = ["Agent", "ScheduledTask", "TaskExecution", "NotificationChannel",
|
||||
"AlertRule", "Alert", "SystemConfig", "User"]
|
||||
|
||||
25
backend/app/models/user.py
Normal file
25
backend/app/models/user.py
Normal file
@ -0,0 +1,25 @@
|
||||
"""SQLAlchemy models — User (dashboard login)."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class User(Base):
|
||||
"""Dashboard user for login authentication."""
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
username: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, comment="登录用户名")
|
||||
password_hash: Mapped[str] = mapped_column(String(256), nullable=False, comment="bcrypt 密码哈希")
|
||||
display_name: Mapped[str] = mapped_column(String(128), default="", comment="显示名称")
|
||||
email: Mapped[str] = mapped_column(String(128), default="", comment="邮箱")
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<User {self.username}>"
|
||||
@ -1,6 +1,7 @@
|
||||
"""Pydantic schema registry."""
|
||||
|
||||
from app.schemas.agent import AgentBatchRegister, AgentBatchRegisterResult, AgentCreate, AgentHeartbeat, AgentOut, AgentUpdate # noqa: F401
|
||||
from app.schemas.auth import LoginRequest, TokenResponse, UserInfo, UserOut # noqa: F401
|
||||
from app.schemas.execution import ExecutionOut, ExecutionReport # noqa: F401
|
||||
from app.schemas.notification import ( # noqa: F401
|
||||
AlertAcknowledge,
|
||||
|
||||
37
backend/app/schemas/auth.py
Normal file
37
backend/app/schemas/auth.py
Normal file
@ -0,0 +1,37 @@
|
||||
"""Pydantic schemas — Auth."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
username: str = Field(..., max_length=64)
|
||||
password: str = Field(..., max_length=128)
|
||||
|
||||
|
||||
class UserInfo(BaseModel):
|
||||
id: int
|
||||
username: str
|
||||
display_name: str
|
||||
email: str
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
user: UserInfo
|
||||
|
||||
|
||||
class UserOut(BaseModel):
|
||||
id: int
|
||||
username: str
|
||||
display_name: str
|
||||
email: str
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
@ -1,6 +1,7 @@
|
||||
"""Service layer __init__."""
|
||||
|
||||
from app.services.agent import AgentService # noqa: F401
|
||||
from app.services.auth import AuthService # noqa: F401
|
||||
from app.services.task import TaskService # noqa: F401
|
||||
from app.services.execution import ExecutionService # noqa: F401
|
||||
from app.services.scheduler import SchedulerService # noqa: F401
|
||||
@ -8,6 +9,7 @@ from app.services.notification import NotificationService # noqa: F401
|
||||
|
||||
__all__ = [
|
||||
"AgentService",
|
||||
"AuthService",
|
||||
"TaskService",
|
||||
"ExecutionService",
|
||||
"SchedulerService",
|
||||
|
||||
70
backend/app/services/auth.py
Normal file
70
backend/app/services/auth.py
Normal file
@ -0,0 +1,70 @@
|
||||
"""Auth service — JWT token handling + password hashing."""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.models.user import User
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
ALGORITHM = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_DAYS = 7
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def verify_password(plain: str, hashed: str) -> bool:
|
||||
return pwd_context.verify(plain, hashed)
|
||||
|
||||
|
||||
def create_access_token(user_id: int, username: str) -> str:
|
||||
expire = datetime.now(timezone.utc) + timedelta(days=ACCESS_TOKEN_EXPIRE_DAYS)
|
||||
payload = {"sub": str(user_id), "username": username, "exp": expire}
|
||||
return jwt.encode(payload, settings.SECRET_KEY, algorithm=ALGORITHM)
|
||||
|
||||
|
||||
def decode_access_token(token: str) -> dict | None:
|
||||
try:
|
||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM])
|
||||
return payload
|
||||
except JWTError:
|
||||
return None
|
||||
|
||||
|
||||
class AuthService:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def authenticate(self, username: str, password: str) -> User | None:
|
||||
stmt = select(User).where(User.username == username, User.is_active == True)
|
||||
result = await self.db.execute(stmt)
|
||||
user = result.scalar_one_or_none()
|
||||
if user and verify_password(password, user.password_hash):
|
||||
return user
|
||||
return None
|
||||
|
||||
async def get_user_by_id(self, user_id: int) -> User | None:
|
||||
return await self.db.get(User, user_id)
|
||||
|
||||
@staticmethod
|
||||
async def ensure_default_admin(db: AsyncSession):
|
||||
"""Create default admin if no users exist."""
|
||||
stmt = select(User).limit(1)
|
||||
result = await db.execute(stmt)
|
||||
if result.scalar_one_or_none() is None:
|
||||
admin = User(
|
||||
username="admin",
|
||||
password_hash=hash_password("admin123"),
|
||||
display_name="Administrator",
|
||||
email="admin@taskpulse.local",
|
||||
)
|
||||
db.add(admin)
|
||||
await db.flush()
|
||||
await db.commit()
|
||||
@ -12,3 +12,6 @@ apscheduler==3.10.4
|
||||
httpx==0.27.2
|
||||
pycryptodome==3.20.0
|
||||
gunicorn==23.0.0
|
||||
python-jose[cryptography]==3.3.0
|
||||
passlib[bcrypt]==1.7.4
|
||||
bcrypt==4.0.1
|
||||
|
||||
@ -1,5 +1,12 @@
|
||||
<template>
|
||||
<div id="app-container">
|
||||
<!-- Login page: standalone without sidebar -->
|
||||
<template v-if="isLoginPage">
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<!-- App pages: with sidebar -->
|
||||
<template v-else>
|
||||
<!-- Sidebar -->
|
||||
<aside class="sidebar">
|
||||
<div class="logo">
|
||||
@ -46,22 +53,44 @@
|
||||
<header class="topbar">
|
||||
<h2 class="page-title">{{ pageTitle }}</h2>
|
||||
<div class="topbar-right">
|
||||
<span class="version-badge">v1.0</span>
|
||||
<template v-if="isLoggedIn">
|
||||
<span class="user-name">{{ userName }}</span>
|
||||
<button class="btn-logout" @click="handleLogout">退出</button>
|
||||
</template>
|
||||
<span v-else class="version-badge">v1.0</span>
|
||||
</div>
|
||||
</header>
|
||||
<main class="content">
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const currentRoute = computed(() => route.path)
|
||||
const isLoginPage = computed(() => route.path === '/login')
|
||||
|
||||
const isLoggedIn = computed(() => !!localStorage.getItem('token'))
|
||||
const userName = ref('')
|
||||
|
||||
// Load user info from localStorage
|
||||
try {
|
||||
const userData = JSON.parse(localStorage.getItem('user') || '{}')
|
||||
userName.value = userData.display_name || userData.username || ''
|
||||
} catch {}
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('user')
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
const routeTitles = {
|
||||
'/': '仪表盘',
|
||||
@ -195,6 +224,24 @@ const pageTitle = computed(() => routeTitles[route.path] || 'TaskPulse')
|
||||
color: rgba(255,255,255,0.4);
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
}
|
||||
.user-name {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.btn-logout {
|
||||
padding: 4px 12px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
background: rgba(255,255,255,0.04);
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-logout:hover {
|
||||
color: #f87171;
|
||||
border-color: rgba(239,68,68,0.2);
|
||||
background: rgba(239,68,68,0.08);
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
|
||||
@ -5,6 +5,34 @@ const api = axios.create({
|
||||
timeout: 10000,
|
||||
})
|
||||
|
||||
// Auto-attach token
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('token')
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
// Auto-redirect on 401
|
||||
api.interceptors.response.use(
|
||||
(res) => res,
|
||||
(err) => {
|
||||
if (err.response?.status === 401) {
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('user')
|
||||
if (window.location.pathname !== '/login') {
|
||||
window.location.href = '/login'
|
||||
}
|
||||
}
|
||||
return Promise.reject(err)
|
||||
},
|
||||
)
|
||||
|
||||
// Auth
|
||||
export const login = (data) => api.post('/auth/login', data)
|
||||
export const getMe = () => api.get('/auth/me')
|
||||
|
||||
// Dashboard
|
||||
export const getDashboardSummary = () => api.get('/dashboard/summary')
|
||||
export const getDashboardTasks = () => api.get('/dashboard/tasks')
|
||||
|
||||
@ -5,14 +5,16 @@ import TaskDetail from '../views/TaskDetail.vue'
|
||||
import Agents from '../views/Agents.vue'
|
||||
import Alerts from '../views/Alerts.vue'
|
||||
import Settings from '../views/Settings.vue'
|
||||
import Login from '../views/Login.vue'
|
||||
|
||||
const routes = [
|
||||
{ path: '/', component: Dashboard },
|
||||
{ path: '/tasks', component: Tasks },
|
||||
{ path: '/tasks/:id', component: TaskDetail, name: 'TaskDetail' },
|
||||
{ path: '/agents', component: Agents },
|
||||
{ path: '/alerts', component: Alerts },
|
||||
{ path: '/settings', component: Settings },
|
||||
{ path: '/login', component: Login, meta: { public: true } },
|
||||
{ path: '/', component: Dashboard, meta: { requiresAuth: true } },
|
||||
{ path: '/tasks', component: Tasks, meta: { requiresAuth: true } },
|
||||
{ path: '/tasks/:id', component: TaskDetail, name: 'TaskDetail', meta: { requiresAuth: true } },
|
||||
{ path: '/agents', component: Agents, meta: { requiresAuth: true } },
|
||||
{ path: '/alerts', component: Alerts, meta: { requiresAuth: true } },
|
||||
{ path: '/settings', component: Settings, meta: { requiresAuth: true } },
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
@ -20,4 +22,15 @@ const router = createRouter({
|
||||
routes,
|
||||
})
|
||||
|
||||
router.beforeEach((to, from, next) => {
|
||||
const token = localStorage.getItem('token')
|
||||
if (to.meta.requiresAuth && !token) {
|
||||
next('/login')
|
||||
} else if (to.path === '/login' && token) {
|
||||
next('/')
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
133
frontend/src/views/Login.vue
Normal file
133
frontend/src/views/Login.vue
Normal file
@ -0,0 +1,133 @@
|
||||
<template>
|
||||
<div class="login-page">
|
||||
<div class="login-card">
|
||||
<div class="login-header">
|
||||
<div class="login-logo">
|
||||
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1>TaskPulse</h1>
|
||||
<p>AI Agent Task Monitor</p>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="handleLogin" class="login-form">
|
||||
<div class="field">
|
||||
<label>用户名</label>
|
||||
<input v-model="username" class="input" placeholder="admin" autocomplete="username" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>密码</label>
|
||||
<input v-model="password" type="password" class="input" placeholder="admin123" autocomplete="current-password" />
|
||||
</div>
|
||||
<p v-if="error" class="login-error">{{ error }}</p>
|
||||
<button type="submit" class="btn-login" :disabled="loading">
|
||||
{{ loading ? '登录中...' : '登录' }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { login } from '../api/index.js'
|
||||
|
||||
const router = useRouter()
|
||||
const username = ref('')
|
||||
const password = ref('')
|
||||
const error = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
const handleLogin = async () => {
|
||||
error.value = ''
|
||||
if (!username.value || !password.value) {
|
||||
error.value = '请输入用户名和密码'
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await login({ username: username.value, password: password.value })
|
||||
const { access_token, user } = res.data
|
||||
localStorage.setItem('token', access_token)
|
||||
localStorage.setItem('user', JSON.stringify(user))
|
||||
router.push('/')
|
||||
} catch (e) {
|
||||
if (e.response?.status === 401) {
|
||||
error.value = '用户名或密码错误'
|
||||
} else {
|
||||
error.value = '登录失败,请检查网络连接'
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-page {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #0a0a0f;
|
||||
}
|
||||
.login-card {
|
||||
width: 380px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 16px;
|
||||
padding: 40px 36px;
|
||||
}
|
||||
.login-header { text-align: center; margin-bottom: 32px; }
|
||||
.login-logo {
|
||||
width: 56px; height: 56px;
|
||||
margin: 0 auto 12px;
|
||||
background: linear-gradient(135deg, #667eea, #764ba2);
|
||||
border-radius: 14px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
color: #fff;
|
||||
}
|
||||
.login-header h1 {
|
||||
font-size: 22px; font-weight: 700;
|
||||
background: linear-gradient(135deg, #a8b4ff, #c084fc);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
.login-header p { font-size: 13px; color: var(--text-muted); margin: 0; }
|
||||
|
||||
.login-form { display: flex; flex-direction: column; gap: 16px; }
|
||||
.field { display: flex; flex-direction: column; gap: 6px; }
|
||||
.field label { font-size: 12px; color: var(--text-secondary); }
|
||||
.input {
|
||||
padding: 10px 14px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: rgba(255,255,255,0.04);
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.input:focus { border-color: var(--accent-1); }
|
||||
.input::placeholder { color: var(--text-muted); }
|
||||
|
||||
.login-error { font-size: 13px; color: #f87171; margin: 0; }
|
||||
|
||||
.btn-login {
|
||||
padding: 11px 0;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
background: linear-gradient(135deg, #667eea, #764ba2);
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.btn-login:hover { opacity: 0.9; }
|
||||
.btn-login:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
</style>
|
||||
Loading…
Reference in New Issue
Block a user