Add admin login page with session auth, route guard, and RTL form layout.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-03 15:19:50 +02:00
co-authored by Cursor
parent 32c15dbd1d
commit d97bcea99e
10 changed files with 501 additions and 6 deletions
+17
View File
@@ -172,6 +172,23 @@ defineProps<{
<circle cx="9" cy="12" r="2" />
<path d="M14 10h5M14 14h5" />
</template>
<!-- lock -->
<template v-else-if="name === 'lock'">
<rect x="5" y="11" width="14" height="10" rx="2" />
<path d="M8 11V8a4 4 0 1 1 8 0v3" />
</template>
<!-- eye -->
<template v-else-if="name === 'eye'">
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z" />
<circle cx="12" cy="12" r="3" />
</template>
<!-- eye-off -->
<template v-else-if="name === 'eye-off'">
<path d="M10.6 10.6a2.5 2.5 0 0 0 3.5 3.5" />
<path d="M6.7 6.7C4.6 8.2 3 10.2 2 12s3.5 7 10 7c1.8 0 3.4-.4 4.8-1.1" />
<path d="M17.3 17.3C19.4 15.8 21 13.8 22 12s-3.5-7-10-7c-1.8 0-3.4.4-4.8 1.1" />
<path d="M2 2l20 20" />
</template>
<!-- user -->
<template v-else-if="name === 'user'">
<circle cx="12" cy="8" r="4" />
+5 -3
View File
@@ -4,6 +4,7 @@ import { useRouter } from 'vue-router'
import AdminIcon from '@/components/icons/AdminIcon.vue'
import { adminNotifications, internalMessages } from '@/data/mockData'
import { formatDateTime } from '@/utils/format'
import { useAuthStore } from '@/stores/auth'
import type { AdminNotification, InternalMessage } from '@/types'
defineProps<{
@@ -17,6 +18,7 @@ defineEmits<{
type OpenPanel = 'messages' | 'notifications' | null
const router = useRouter()
const auth = useAuthStore()
const openPanel = ref<OpenPanel>(null)
const actionsRef = ref<HTMLElement | null>(null)
@@ -225,10 +227,10 @@ onUnmounted(() => {
</div>
<div class="admin-header__user">
<div class="admin-header__avatar">ع</div>
<div class="admin-header__avatar">{{ auth.fullName.charAt(0) || '؟' }}</div>
<div class="admin-header__user-info">
<strong>علی محمدی</strong>
<span>مدیر سیستم</span>
<strong>{{ auth.fullName || 'مدیر' }}</strong>
<span>{{ auth.roleLabel || 'پنل مدیریت' }}</span>
</div>
</div>
</div>
+9 -1
View File
@@ -2,6 +2,7 @@
import { useRoute, useRouter } from 'vue-router'
import { ADMIN_NAV_ITEMS, isNavActive } from '@/utils/navigation'
import AdminIcon from '@/components/icons/AdminIcon.vue'
import { useAuthStore } from '@/stores/auth'
defineProps<{
open: boolean
@@ -13,11 +14,18 @@ const emit = defineEmits<{
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
function navigate(path: string) {
router.push(path)
emit('close')
}
function logout() {
auth.logout()
router.push('/login')
emit('close')
}
</script>
<template>
@@ -55,7 +63,7 @@ function navigate(path: string) {
</button>
</nav>
<button type="button" class="admin-drawer__logout" @click="navigate('/login')">
<button type="button" class="admin-drawer__logout" @click="logout">
<AdminIcon name="logout" :size="20" />
<span>خروج</span>
</button>
+8 -1
View File
@@ -2,13 +2,20 @@
import { useRoute, useRouter } from 'vue-router'
import { ADMIN_NAV_ITEMS, isNavActive } from '@/utils/navigation'
import AdminIcon from '@/components/icons/AdminIcon.vue'
import { useAuthStore } from '@/stores/auth'
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
function navigate(path: string) {
router.push(path)
}
function logout() {
auth.logout()
router.push('/login')
}
</script>
<template>
@@ -32,7 +39,7 @@ function navigate(path: string) {
</button>
</nav>
<button type="button" class="admin-sidebar__logout" @click="navigate('/login')">
<button type="button" class="admin-sidebar__logout" @click="logout">
<AdminIcon name="logout" :size="20" />
<span>خروج</span>
</button>
+5 -1
View File
@@ -3,14 +3,18 @@ import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import { useAuthStore } from '@/stores/auth'
import '@/assets/styles/main.css'
import '@/assets/styles/admin.css'
import '@/assets/styles/responsive.css'
const app = createApp(App)
const pinia = createPinia()
app.use(createPinia())
app.use(pinia)
app.use(router)
useAuthStore(pinia).hydrate()
app.mount('#app')
+29
View File
@@ -1,12 +1,20 @@
import { createRouter, createWebHistory } from 'vue-router'
import AdminShell from '@/layouts/AdminShell.vue'
import { useAuthStore } from '@/stores/auth'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/login',
name: 'login',
component: () => import('@/views/auth/LoginView.vue'),
meta: { public: true, title: 'ورود به پنل مدیریت' },
},
{
path: '/',
component: AdminShell,
meta: { requiresAuth: true },
children: [
{
path: '',
@@ -102,4 +110,25 @@ const router = createRouter({
],
})
router.beforeEach((to) => {
const auth = useAuthStore()
auth.hydrate()
if (to.meta.public) {
if (auth.isAuthenticated && to.name === 'login') {
return { name: 'dashboard' }
}
return true
}
if (to.matched.some((record) => record.meta.requiresAuth) && !auth.isAuthenticated) {
return {
name: 'login',
query: { redirect: to.fullPath },
}
}
return true
})
export default router
+71
View File
@@ -0,0 +1,71 @@
import type { AdminLoginPayload, AdminLoginResult, AdminSession } from '@/types/auth'
import { adminUsers } from '@/data/mockData'
const STORAGE_KEY = 'fabric-admin-session'
const MOCK_PASSWORD = 'admin123'
function readSession(): AdminSession | null {
try {
const raw = localStorage.getItem(STORAGE_KEY) ?? sessionStorage.getItem(STORAGE_KEY)
if (!raw) return null
return JSON.parse(raw) as AdminSession
} catch {
return null
}
}
function writeSession(session: AdminSession, remember: boolean) {
const payload = JSON.stringify(session)
localStorage.removeItem(STORAGE_KEY)
sessionStorage.removeItem(STORAGE_KEY)
if (remember) {
localStorage.setItem(STORAGE_KEY, payload)
} else {
sessionStorage.setItem(STORAGE_KEY, payload)
}
}
function clearSession() {
localStorage.removeItem(STORAGE_KEY)
sessionStorage.removeItem(STORAGE_KEY)
}
export function getStoredSession(): AdminSession | null {
return readSession()
}
export async function loginAdmin(payload: AdminLoginPayload): Promise<AdminLoginResult> {
await new Promise((resolve) => window.setTimeout(resolve, 450))
const phone = payload.phone.trim()
const password = payload.password
if (!phone || !password) {
return { success: false, message: 'شماره موبایل و رمز عبور الزامی است.' }
}
const user = adminUsers.find((item) => item.phone === phone && item.isActive)
if (!user) {
return { success: false, message: 'کاربر یافت نشد یا حساب غیرفعال است.' }
}
if (password !== MOCK_PASSWORD) {
return { success: false, message: 'رمز عبور نادرست است.' }
}
const session: AdminSession = {
id: user.id,
fullName: user.fullName,
phone: user.phone,
email: user.email,
role: user.role,
}
writeSession(session, payload.remember)
return { success: true }
}
export function logoutAdmin() {
clearSession()
}
+69
View File
@@ -0,0 +1,69 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import type { AdminSession } from '@/types/auth'
import {
getStoredSession,
loginAdmin,
logoutAdmin,
} from '@/services/authService'
export const useAuthStore = defineStore('auth', () => {
const session = ref<AdminSession | null>(getStoredSession())
const loading = ref(false)
const error = ref<string | null>(null)
const isAuthenticated = computed(() => !!session.value)
const fullName = computed(() => session.value?.fullName ?? '')
const roleLabel = computed(() => {
const map = {
admin: 'مدیر سیستم',
sales: 'واحد فروش',
finance: 'واحد مالی',
warehouse: 'انبار',
} as const
return session.value ? map[session.value.role] : ''
})
function clearError() {
error.value = null
}
function hydrate() {
session.value = getStoredSession()
}
async function login(phone: string, password: string, remember: boolean) {
loading.value = true
error.value = null
const result = await loginAdmin({ phone, password, remember })
loading.value = false
if (result.success) {
session.value = getStoredSession()
return true
}
error.value = result.message ?? 'ورود انجام نشد.'
return false
}
function logout() {
logoutAdmin()
session.value = null
error.value = null
}
return {
session,
loading,
error,
isAuthenticated,
fullName,
roleLabel,
clearError,
hydrate,
login,
logout,
}
})
+18
View File
@@ -0,0 +1,18 @@
export interface AdminSession {
id: string
fullName: string
phone: string
email: string
role: 'admin' | 'sales' | 'finance' | 'warehouse'
}
export interface AdminLoginPayload {
phone: string
password: string
remember: boolean
}
export interface AdminLoginResult {
success: boolean
message?: string
}
+270
View File
@@ -0,0 +1,270 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import AdminIcon from '@/components/icons/AdminIcon.vue'
import { useAuthStore } from '@/stores/auth'
import { useToast } from '@/composables/useToast'
const router = useRouter()
const route = useRoute()
const auth = useAuthStore()
const toast = useToast()
const phone = ref('۰۹۱۲۱۱۱۲۲۲۲')
const password = ref('')
const remember = ref(true)
const showPassword = ref(false)
const isFormValid = computed(() => phone.value.trim().length > 0 && password.value.length > 0)
async function submit() {
if (!isFormValid.value) {
auth.error = 'لطفاً شماره موبایل و رمز عبور را وارد کنید.'
return
}
const ok = await auth.login(phone.value.trim(), password.value, remember.value)
if (!ok) return
toast.success(`خوش آمدید، ${auth.fullName}`)
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/'
router.replace(redirect)
}
</script>
<template>
<div class="login-page">
<div class="login-page__form-wrap">
<div class="login-page__card admin-card">
<div class="login-page__header">
<h2 class="login-page__title">ورود به پنل مدیریت</h2>
<p class="login-page__subtitle">برای ادامه، اطلاعات حساب کاربری خود را وارد کنید.</p>
</div>
<form class="admin-form login-page__form" @submit.prevent="submit">
<p v-if="auth.error" class="admin-form__error" role="alert">{{ auth.error }}</p>
<div class="admin-form__field">
<label for="login-phone">شماره موبایل</label>
<div class="login-page__input-wrap">
<AdminIcon name="phone" :size="18" />
<input
id="login-phone"
v-model="phone"
type="tel"
class="admin-input login-page__input"
placeholder="۰۹۱۲۱۱۱۱۲۲۲۲"
autocomplete="username"
@input="auth.clearError()"
/>
</div>
</div>
<div class="admin-form__field">
<label for="login-password">رمز عبور</label>
<div class="login-page__input-wrap">
<AdminIcon name="lock" :size="18" />
<input
id="login-password"
v-model="password"
:type="showPassword ? 'text' : 'password'"
class="admin-input login-page__input"
placeholder="رمز عبور"
autocomplete="current-password"
@input="auth.clearError()"
/>
<button
type="button"
class="login-page__toggle-password"
:aria-label="showPassword ? 'پنهان کردن رمز' : 'نمایش رمز'"
@click="showPassword = !showPassword"
>
<AdminIcon :name="showPassword ? 'eye-off' : 'eye'" :size="18" />
</button>
</div>
</div>
<label class="login-page__remember">
<input v-model="remember" type="checkbox" />
<span>مرا به خاطر بسپار</span>
</label>
<button type="submit" class="admin-btn admin-btn--primary login-page__submit" :disabled="!isFormValid || auth.loading">
<span v-if="auth.loading" class="admin-btn__spinner" aria-hidden="true" />
ورود به پنل
</button>
</form>
<p class="login-page__hint">
حساب آزمایشی: <span dir="ltr">09121112222</span> / رمز: <span dir="ltr">admin123</span>
</p>
</div>
</div>
<div class="login-page__brand">
<div class="login-page__brand-inner">
<p class="login-page__brand-badge">پَر | فام بافت</p>
<h1 class="login-page__brand-title">پنل مدیریت سفارش‌های عمده</h1>
<p class="login-page__brand-text">
مدیریت سفارش‌ها، مشتریان، قیمت‌گذاری، چک‌ها و گزارش‌های فروش در یک محیط یکپارچه.
</p>
</div>
</div>
</div>
</template>
<style scoped>
.login-page {
min-height: 100dvh;
display: grid;
grid-template-columns: 1.05fr 1fr;
background: var(--color-bg);
}
.login-page__brand {
display: flex;
align-items: center;
justify-content: center;
padding: 48px 40px;
background: linear-gradient(160deg, #1a2542 0%, #243456 55%, #17379f 100%);
color: #fff;
}
.login-page__brand-inner {
max-width: 420px;
}
.login-page__brand-badge {
display: inline-flex;
margin: 0 0 16px;
padding: 6px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.12);
font-size: 0.8125rem;
font-weight: 700;
}
.login-page__brand-title {
margin: 0 0 12px;
font-size: clamp(1.5rem, 2.4vw, 2rem);
line-height: 1.35;
}
.login-page__brand-text {
margin: 0;
color: rgba(255, 255, 255, 0.78);
line-height: 1.8;
font-size: 0.9375rem;
}
.login-page__form-wrap {
display: flex;
align-items: center;
justify-content: center;
padding: 32px 24px;
}
.login-page__card {
width: min(100%, 420px);
padding: 28px 24px;
}
.login-page__header {
margin-bottom: 20px;
}
.login-page__title {
margin: 0 0 6px;
font-size: 1.25rem;
font-weight: 700;
}
.login-page__subtitle {
margin: 0;
font-size: 0.875rem;
color: var(--color-text-muted);
line-height: 1.6;
}
.login-page__input-wrap {
position: relative;
display: flex;
align-items: center;
gap: 10px;
color: var(--color-text-muted);
}
.login-page__input {
padding-right: 0;
}
.login-page__toggle-password {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
border: none;
border-radius: 8px;
background: transparent;
color: var(--color-text-muted);
flex-shrink: 0;
cursor: pointer;
transition: color 0.15s ease, background-color 0.15s ease;
}
.login-page__toggle-password:hover {
color: var(--color-primary);
background: var(--color-bg-muted, rgba(0, 0, 0, 0.04));
}
.login-page__remember {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.8125rem;
color: var(--color-text-muted);
}
.login-page__remember input {
width: 16px;
height: 16px;
accent-color: var(--color-primary);
}
.login-page__submit {
width: 100%;
margin-top: 4px;
}
.login-page__hint {
margin: 16px 0 0;
padding-top: 16px;
border-top: 1px solid var(--color-border-light);
font-size: 0.75rem;
color: var(--color-text-muted);
text-align: center;
line-height: 1.7;
}
@media (max-width: 900px) {
.login-page {
grid-template-columns: 1fr;
}
.login-page__form-wrap {
order: 2;
}
.login-page__brand {
order: 1;
padding: 28px 20px;
min-height: auto;
}
.login-page__brand-text {
font-size: 0.875rem;
}
}
</style>