feat: Implement user authentication, admin user management, and role-based access control.

This commit is contained in:
Missez 2026-01-16 16:37:16 +07:00
parent 8a2ca592bc
commit 38648581ec
19 changed files with 1762 additions and 514 deletions

View file

@ -1,59 +0,0 @@
<template>
<q-card
:class="[
'rounded-lg shadow-md transition-shadow',
hoverable && 'hover:shadow-lg cursor-pointer',
customClass
]"
style="border: 1px solid #e5e7eb;"
@click="handleClick"
>
<!-- Card Header -->
<q-card-section v-if="$slots.header || title" class="border-b border-gray-200">
<div class="flex justify-between items-center">
<div>
<h3 v-if="title" class="text-lg font-semibold text-gray-900">{{ title }}</h3>
<p v-if="subtitle" class="text-sm text-gray-600 mt-1">{{ subtitle }}</p>
</div>
<slot name="header-actions"></slot>
</div>
<slot name="header"></slot>
</q-card-section>
<!-- Card Content -->
<q-card-section :class="contentClass">
<slot></slot>
</q-card-section>
<!-- Card Footer -->
<q-card-section v-if="$slots.footer" class="border-t border-gray-200">
<slot name="footer"></slot>
</q-card-section>
</q-card>
</template>
<script setup lang="ts">
interface Props {
title?: string;
subtitle?: string;
hoverable?: boolean;
customClass?: string;
contentClass?: string;
}
withDefaults(defineProps<Props>(), {
title: '',
subtitle: '',
hoverable: false,
customClass: '',
contentClass: ''
});
const emit = defineEmits<{
click: [];
}>();
const handleClick = () => {
emit('click');
};
</script>

View file

@ -1,53 +0,0 @@
<template>
<header class="bg-white shadow-sm border-b border-gray-200 px-8 py-4">
<div class="flex justify-between items-center">
<!-- Left: Page Title -->
<div>
<h1 v-if="title" class="text-2xl font-bold text-gray-900">{{ title }}</h1>
<p v-if="subtitle" class="text-sm text-gray-600 mt-1">{{ subtitle }}</p>
</div>
<!-- Right: Actions & User -->
<div class="flex items-center gap-4">
<!-- Action Buttons Slot -->
<slot name="actions"></slot>
<!-- User Avatar & Info -->
<div class="flex items-center gap-3">
<div class="text-right">
<div class="text-sm font-semibold text-gray-900">{{ userName }}</div>
<div class="text-xs text-gray-500">{{ userRole }}</div>
</div>
<div
class="w-10 h-10 rounded-full bg-primary-100 flex items-center justify-center text-lg cursor-pointer hover:bg-primary-200 transition"
@click="$emit('avatar-click')"
>
{{ userAvatar }}
</div>
</div>
</div>
</div>
</header>
</template>
<script setup lang="ts">
interface Props {
title?: string;
subtitle?: string;
userName?: string;
userRole?: string;
userAvatar?: string;
}
withDefaults(defineProps<Props>(), {
title: '',
subtitle: '',
userName: 'ผู้ใช้งาน',
userRole: 'User',
userAvatar: '👤'
});
defineEmits<{
'avatar-click': [];
}>();
</script>

View file

@ -1,101 +0,0 @@
<template>
<q-dialog
:model-value="modelValue"
@update:model-value="$emit('update:modelValue', $event)"
:persistent="persistent"
>
<q-card :style="{ width: width, maxWidth: maxWidth }">
<!-- Modal Header -->
<q-card-section class="row items-center q-pb-none">
<div class="text-h6">{{ title }}</div>
<q-space />
<q-btn
v-if="showClose"
icon="close"
flat
round
dense
@click="handleClose"
/>
</q-card-section>
<!-- Modal Content -->
<q-card-section :class="contentClass">
<slot></slot>
</q-card-section>
<!-- Modal Actions -->
<q-card-actions v-if="$slots.actions || showDefaultActions" align="right" class="q-px-md q-pb-md">
<slot name="actions">
<q-btn
v-if="showDefaultActions"
flat
:label="cancelLabel"
color="grey"
@click="handleCancel"
/>
<q-btn
v-if="showDefaultActions"
unelevated
:label="confirmLabel"
:color="confirmColor"
:loading="loading"
@click="handleConfirm"
/>
</slot>
</q-card-actions>
</q-card>
</q-dialog>
</template>
<script setup lang="ts">
interface Props {
modelValue: boolean;
title?: string;
width?: string;
maxWidth?: string;
persistent?: boolean;
showClose?: boolean;
showDefaultActions?: boolean;
cancelLabel?: string;
confirmLabel?: string;
confirmColor?: string;
loading?: boolean;
contentClass?: string;
}
withDefaults(defineProps<Props>(), {
title: '',
width: '500px',
maxWidth: '90vw',
persistent: false,
showClose: true,
showDefaultActions: true,
cancelLabel: 'ยกเลิก',
confirmLabel: 'ยืนยัน',
confirmColor: 'primary',
loading: false,
contentClass: ''
});
const emit = defineEmits<{
'update:modelValue': [value: boolean];
'confirm': [];
'cancel': [];
'close': [];
}>();
const handleClose = () => {
emit('update:modelValue', false);
emit('close');
};
const handleCancel = () => {
emit('update:modelValue', false);
emit('cancel');
};
const handleConfirm = () => {
emit('confirm');
};
</script>

View file

@ -1,177 +0,0 @@
<template>
<div class="overflow-x-auto">
<table class="w-full border-collapse">
<!-- Table Header -->
<thead class="bg-gray-50 border-b border-gray-200">
<tr>
<th
v-for="column in columns"
:key="column.key"
:class="[
'px-6 py-3 text-left text-xs font-semibold text-gray-700 uppercase tracking-wider',
column.align === 'center' && 'text-center',
column.align === 'right' && 'text-right',
column.headerClass
]"
:style="{ width: column.width }"
>
{{ column.label }}
</th>
</tr>
</thead>
<!-- Table Body -->
<tbody class="bg-white divide-y divide-gray-200">
<tr
v-for="(row, index) in data"
:key="getRowKey(row, index)"
:class="[
'hover:bg-gray-50 transition',
rowClickable && 'cursor-pointer'
]"
@click="handleRowClick(row)"
>
<td
v-for="column in columns"
:key="column.key"
:class="[
'px-6 py-4 text-sm text-gray-900',
column.align === 'center' && 'text-center',
column.align === 'right' && 'text-right',
column.cellClass
]"
>
<!-- Custom Cell Slot -->
<slot
v-if="$slots[`cell-${column.key}`]"
:name="`cell-${column.key}`"
:row="row"
:value="row[column.key]"
></slot>
<!-- Default Cell Content -->
<span v-else>{{ row[column.key] }}</span>
</td>
</tr>
<!-- Empty State -->
<tr v-if="data.length === 0">
<td :colspan="columns.length" class="px-6 py-12 text-center text-gray-500">
<slot name="empty">
<div class="text-gray-400">
<p class="text-lg mb-2">📭</p>
<p>{{ emptyMessage }}</p>
</div>
</slot>
</td>
</tr>
</tbody>
</table>
<!-- Pagination -->
<div v-if="showPagination && totalPages > 1" class="flex justify-between items-center px-6 py-4 border-t border-gray-200">
<div class="text-sm text-gray-600">
แสดง {{ startItem }}-{{ endItem }} จาก {{ totalItems }} รายการ
</div>
<div class="flex gap-2">
<q-btn
flat
dense
icon="chevron_left"
:disable="currentPage === 1"
@click="changePage(currentPage - 1)"
/>
<q-btn
v-for="page in visiblePages"
:key="page"
flat
dense
:label="String(page)"
:color="page === currentPage ? 'primary' : 'grey'"
@click="changePage(page)"
/>
<q-btn
flat
dense
icon="chevron_right"
:disable="currentPage === totalPages"
@click="changePage(currentPage + 1)"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
interface Column {
key: string;
label: string;
width?: string;
align?: 'left' | 'center' | 'right';
headerClass?: string;
cellClass?: string;
}
interface Props {
columns: Column[];
data: any[];
rowKey?: string;
rowClickable?: boolean;
emptyMessage?: string;
showPagination?: boolean;
currentPage?: number;
pageSize?: number;
totalItems?: number;
}
const props = withDefaults(defineProps<Props>(), {
rowKey: 'id',
rowClickable: false,
emptyMessage: 'ไม่พบข้อมูล',
showPagination: false,
currentPage: 1,
pageSize: 10,
totalItems: 0
});
const emit = defineEmits<{
'row-click': [row: any];
'page-change': [page: number];
}>();
const getRowKey = (row: any, index: number) => {
return row[props.rowKey] || index;
};
const handleRowClick = (row: any) => {
if (props.rowClickable) {
emit('row-click', row);
}
};
const changePage = (page: number) => {
emit('page-change', page);
};
// Pagination calculations
const totalPages = computed(() => Math.ceil(props.totalItems / props.pageSize));
const startItem = computed(() => (props.currentPage - 1) * props.pageSize + 1);
const endItem = computed(() => Math.min(props.currentPage * props.pageSize, props.totalItems));
const visiblePages = computed(() => {
const pages = [];
const maxVisible = 5;
let start = Math.max(1, props.currentPage - Math.floor(maxVisible / 2));
let end = Math.min(totalPages.value, start + maxVisible - 1);
if (end - start < maxVisible - 1) {
start = Math.max(1, end - maxVisible + 1);
}
for (let i = start; i <= end; i++) {
pages.push(i);
}
return pages;
});
</script>

View file

@ -1,34 +0,0 @@
import type { UseFetchOptions } from 'nuxt/app';
export const useApi = () => {
const config = useRuntimeConfig();
const baseURL = config.public.apiBaseUrl as string;
const apiFetch = <T>(url: string, options?: UseFetchOptions<T>) => {
return $fetch<T>(url, {
baseURL,
...options,
headers: {
...options?.headers,
},
onRequest({ options }) {
// Add auth token if available
const token = localStorage.getItem('token');
if (token) {
options.headers = {
...options.headers,
Authorization: `Bearer ${token}`
};
}
},
onResponseError({ response }) {
// Handle errors globally
console.error('API Error:', response.status, response._data);
}
});
};
return {
apiFetch
};
};

View file

@ -1,6 +1,17 @@
export default defineNuxtRouteMiddleware((to, from) => { export default defineNuxtRouteMiddleware((to, from) => {
// Skip on server side - let client handle auth
if (process.server) {
return;
}
const authStore = useAuthStore(); const authStore = useAuthStore();
authStore.checkAuth();
// Restore session if not authenticated
if (!authStore.isAuthenticated) {
authStore.checkAuth();
}
// Check if user is admin
if (!authStore.isAdmin) { if (!authStore.isAdmin) {
return navigateTo('/login'); return navigateTo('/login');
} }

View file

@ -1,6 +1,22 @@
export default defineNuxtRouteMiddleware((to, from) => { export default defineNuxtRouteMiddleware((to, from) => {
// Skip on server side - let client handle auth
if (process.server) {
return;
}
// Skip middleware on login page
if (to.path === '/login') {
return;
}
const authStore = useAuthStore(); const authStore = useAuthStore();
authStore.checkAuth();
// Restore session if not authenticated
if (!authStore.isAuthenticated) {
authStore.checkAuth();
}
// Check authentication after restore
if (!authStore.isAuthenticated) { if (!authStore.isAuthenticated) {
return navigateTo('/login'); return navigateTo('/login');
} }

View file

@ -0,0 +1,328 @@
<template>
<div>
<!-- Header -->
<div class="flex justify-between items-center mb-6">
<div>
<h1 class="text-2xl font-bold text-primary-600">ดการหมวดหม</h1>
<p class="text-gray-600 mt-1">ดการหมวดหมหลกสตรในระบบ</p>
</div>
<q-btn
color="primary"
label="+ เพิ่มหมวดหมู่ใหม่"
@click="openAddModal"
/>
</div>
<!-- Table Card -->
<div class="bg-white rounded-xl shadow-sm p-6">
<h2 class="text-lg font-semibold text-primary-600 mb-4">รายการหมวดหม</h2>
<!-- Search -->
<div class="mb-4" style="max-width: 400px">
<q-input
v-model="searchQuery"
placeholder="ค้นหาหมวดหมู่..."
outlined
dense
bg-color="grey-1"
>
<template v-slot:prepend>
<q-icon name="search" />
</template>
</q-input>
</div>
<!-- Table -->
<q-table
:rows="filteredCategories"
:columns="columns"
row-key="id"
:loading="loading"
flat
bordered
>
<!-- Name -->
<template v-slot:body-cell-name="props">
<q-td :props="props">
<div class="font-medium">{{ props.row.name.th }}</div>
</q-td>
</template>
<!-- Description -->
<template v-slot:body-cell-description="props">
<q-td :props="props">
<div class="text-gray-600">{{ props.row.description.th }}</div>
</q-td>
</template>
<!-- Date -->
<template v-slot:body-cell-created_at="props">
<q-td :props="props">
{{ formatDate(props.row.created_at) }}
</q-td>
</template>
<!-- Actions -->
<template v-slot:body-cell-actions="props">
<q-td :props="props">
<div class="flex gap-2">
<q-btn
flat
round
dense
icon="edit"
color="warning"
@click="openEditModal(props.row)"
/>
<q-btn
flat
round
dense
icon="delete"
color="negative"
@click="confirmDelete(props.row)"
/>
</div>
</q-td>
</template>
</q-table>
</div>
<!-- Add/Edit Modal -->
<q-dialog v-model="showModal" persistent>
<q-card style="min-width: 500px">
<q-card-section class="row items-center q-pb-none">
<div class="text-h6">{{ isEditing ? 'แก้ไขหมวดหมู่' : 'เพิ่มหมวดหมู่ใหม่' }}</div>
<q-space />
<q-btn icon="close" flat round dense @click="showModal = false" />
</q-card-section>
<q-card-section>
<q-form @submit="handleSave">
<div class="grid grid-cols-2 gap-4 mb-4">
<q-input
v-model="form.name.th"
label="ชื่อ (ภาษาไทย)"
outlined
:rules="[val => !!val || 'กรุณากรอกชื่อ']"
/>
<q-input
v-model="form.name.en"
label="ชื่อ (English)"
outlined
:rules="[val => !!val || 'Please enter name']"
/>
</div>
<q-input
v-model="form.slug"
label="Slug (URL)"
outlined
class="mb-4"
hint="ใช้สำหรับ URL เช่น web-development"
:rules="[val => !!val || 'กรุณากรอก slug']"
/>
<q-input
v-model="form.description.th"
label="คำอธิบาย (ภาษาไทย)"
type="textarea"
outlined
class="mb-4"
autogrow
/>
<q-input
v-model="form.description.en"
label="คำอธิบาย (English)"
type="textarea"
outlined
class="mb-4"
autogrow
/>
<div class="flex justify-end gap-2 mt-4">
<q-btn
flat
label="ยกเลิก"
color="grey-7"
@click="showModal = false"
/>
<q-btn
type="submit"
:label="isEditing ? 'บันทึก' : 'เพิ่ม'"
color="primary"
:loading="saving"
/>
</div>
</q-form>
</q-card-section>
</q-card>
</q-dialog>
</div>
</template>
<script setup lang="ts">
import { useQuasar } from 'quasar';
import { adminService, type CategoryResponse } from '~/services/admin.service';
definePageMeta({
layout: 'admin',
middleware: ['auth', 'admin']
});
const $q = useQuasar();
const authStore = useAuthStore();
// Data
const categories = ref<CategoryResponse[]>([]);
const loading = ref(true);
const searchQuery = ref('');
const showModal = ref(false);
const isEditing = ref(false);
const saving = ref(false);
const editingId = ref<number | null>(null);
// Form
const form = ref({
name: { th: '', en: '' },
slug: '',
description: { th: '', en: '' }
});
// Table columns
const columns = [
{ name: 'id', label: 'ID', field: 'id', align: 'center' as const, style: 'width: 60px' },
{ name: 'name', label: 'ชื่อหมวดหมู่', field: 'name', align: 'left' as const },
{ name: 'description', label: 'คำอธิบาย', field: 'description', align: 'left' as const },
{ name: 'created_at', label: 'วันที่สร้าง', field: 'created_at', align: 'center' as const },
{ name: 'actions', label: 'การจัดการ', field: 'actions', align: 'center' as const, style: 'width: 120px' }
];
// Computed
const filteredCategories = computed(() => {
if (!searchQuery.value) return categories.value;
const query = searchQuery.value.toLowerCase();
return categories.value.filter(cat =>
cat.name.th.toLowerCase().includes(query) ||
cat.name.en.toLowerCase().includes(query)
);
});
// Methods
const fetchCategories = async () => {
loading.value = true;
try {
categories.value = await adminService.getCategories();
} catch (error) {
$q.notify({
type: 'negative',
message: 'ไม่สามารถโหลดข้อมูลหมวดหมู่ได้',
position: 'top'
});
} finally {
loading.value = false;
}
};
const formatDate = (date: string) => {
return new Date(date).toLocaleDateString('th-TH', {
year: 'numeric',
month: '2-digit',
day: '2-digit'
});
};
const resetForm = () => {
form.value = {
name: { th: '', en: '' },
slug: '',
description: { th: '', en: '' }
};
editingId.value = null;
isEditing.value = false;
};
const openAddModal = () => {
resetForm();
showModal.value = true;
};
const openEditModal = (category: CategoryResponse) => {
isEditing.value = true;
editingId.value = category.id;
form.value = {
name: { ...category.name },
slug: category.slug,
description: { ...category.description }
};
showModal.value = true;
};
const handleSave = async () => {
saving.value = true;
try {
if (isEditing.value && editingId.value) {
await adminService.updateCategory(editingId.value, {
id: editingId.value,
...form.value
});
$q.notify({
type: 'positive',
message: 'แก้ไขหมวดหมู่สำเร็จ',
position: 'top'
});
} else {
await adminService.createCategory({
...form.value,
created_by: parseInt(authStore.user?.id || '0')
});
$q.notify({
type: 'positive',
message: 'เพิ่มหมวดหมู่สำเร็จ',
position: 'top'
});
}
showModal.value = false;
fetchCategories();
} catch (error) {
$q.notify({
type: 'negative',
message: 'เกิดข้อผิดพลาด กรุณาลองใหม่',
position: 'top'
});
} finally {
saving.value = false;
}
};
const confirmDelete = (category: CategoryResponse) => {
$q.dialog({
title: 'ยืนยันการลบ',
message: `คุณต้องการลบหมวดหมู่ "${category.name.th}" หรือไม่?`,
cancel: true,
persistent: true
}).onOk(async () => {
try {
await adminService.deleteCategory(category.id);
$q.notify({
type: 'positive',
message: 'ลบหมวดหมู่สำเร็จ',
position: 'top'
});
fetchCategories();
} catch (error) {
$q.notify({
type: 'negative',
message: 'เกิดข้อผิดพลาดในการลบ',
position: 'top'
});
}
});
};
// Lifecycle
onMounted(() => {
fetchCategories();
});
</script>

View file

@ -0,0 +1,394 @@
<template>
<div>
<!-- Header -->
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold text-primary-600">ดการผใชงาน</h1>
<div class="flex gap-3">
<q-btn
outline
color="red"
label="ส่งออก Excel"
icon="download"
@click="exportExcel"
/>
<q-btn
color="primary"
label="+ เพิ่มผู้ใช้ใหม่"
@click="showAddModal = true"
/>
</div>
</div>
<!-- Search and Filter -->
<div class="bg-white rounded-xl shadow-sm p-4 mb-6">
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<div class="md:col-span-3">
<q-input
v-model="searchQuery"
placeholder="ค้นหาชื่อ, อีเมล, username..."
outlined
dense
bg-color="grey-1"
>
<template v-slot:prepend>
<q-icon name="search" />
</template>
</q-input>
</div>
<q-select
v-model="filterRole"
:options="roleOptions"
outlined
dense
emit-value
map-options
/>
</div>
</div>
<!-- Stats Cards -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<div class="bg-white rounded-xl shadow-sm p-6 text-center">
<div class="text-4xl font-bold text-primary-600">{{ stats.total.toLocaleString() }}</div>
<div class="text-gray-500 mt-1">ใชงหมด</div>
</div>
<div class="bg-white rounded-xl shadow-sm p-6 text-center">
<div class="text-4xl font-bold text-gray-700">{{ stats.admin }}</div>
<div class="text-gray-500 mt-1">Admin</div>
</div>
<div class="bg-white rounded-xl shadow-sm p-6 text-center">
<div class="text-4xl font-bold text-gray-700">{{ stats.instructor }}</div>
<div class="text-gray-500 mt-1">Instructor</div>
</div>
<div class="bg-white rounded-xl shadow-sm p-6 text-center">
<div class="text-4xl font-bold text-gray-700">{{ stats.student.toLocaleString() }}</div>
<div class="text-gray-500 mt-1">Student</div>
</div>
</div>
<!-- Users Table -->
<div class="bg-white rounded-xl shadow-sm overflow-hidden">
<q-table
:rows="filteredUsers"
:columns="columns"
row-key="id"
:loading="loading"
flat
:pagination="pagination"
@update:pagination="pagination = $event"
>
<!-- User Info -->
<template v-slot:body-cell-user="props">
<q-td :props="props">
<div class="flex items-center gap-3">
<div class="w-10 h-10 bg-primary-100 rounded-full flex items-center justify-center">
<q-icon name="person" color="primary" />
</div>
<div>
<div class="font-medium text-primary-600 hover:underline cursor-pointer">
{{ getFullName(props.row) }}
</div>
<div class="text-sm text-gray-500">{{ props.row.email }}</div>
</div>
</div>
</q-td>
</template>
<!-- Role Badge -->
<template v-slot:body-cell-role="props">
<q-td :props="props">
<q-badge
:color="getRoleBadgeColor(props.row.role.code)"
:label="props.row.role.name.th"
class="px-3 py-1"
/>
</q-td>
</template>
<!-- Date -->
<template v-slot:body-cell-created_at="props">
<q-td :props="props">
{{ formatDate(props.row.created_at) }}
</q-td>
</template>
<!-- Actions -->
<template v-slot:body-cell-actions="props">
<q-td :props="props">
<q-btn flat round dense icon="more_vert">
<q-menu>
<q-list style="min-width: 150px">
<q-item clickable v-close-popup @click="viewUser(props.row)">
<q-item-section avatar>
<q-icon name="visibility" color="grey" />
</q-item-section>
<q-item-section></q-item-section>
</q-item>
<q-item clickable v-close-popup @click="changeRole(props.row)">
<q-item-section avatar>
<q-icon name="swap_horiz" color="primary" />
</q-item-section>
<q-item-section>แก Role</q-item-section>
</q-item>
<q-separator />
<q-item clickable v-close-popup @click="confirmDelete(props.row)">
<q-item-section avatar>
<q-icon name="delete" color="negative" />
</q-item-section>
<q-item-section class="text-negative">ลบ</q-item-section>
</q-item>
</q-list>
</q-menu>
</q-btn>
</q-td>
</template>
</q-table>
</div>
<!-- View User Modal -->
<q-dialog v-model="showViewModal">
<q-card style="min-width: 500px">
<q-card-section class="row items-center q-pb-none">
<div class="text-h6">รายละเอยดผใช</div>
<q-space />
<q-btn icon="close" flat round dense @click="showViewModal = false" />
</q-card-section>
<q-card-section v-if="selectedUser">
<!-- Avatar & Name -->
<div class="flex items-center gap-4 mb-6">
<div class="w-20 h-20 bg-primary-100 rounded-full flex items-center justify-center text-3xl">
<q-icon name="person" color="primary" size="40px" />
</div>
<div>
<div class="text-xl font-bold text-gray-900">
{{ selectedUser.profile.prefix?.th }}{{ selectedUser.profile.first_name }} {{ selectedUser.profile.last_name }}
</div>
<div class="text-gray-500">@{{ selectedUser.username }}</div>
<q-badge :color="getRoleBadgeColor(selectedUser.role.code)" class="mt-1">
{{ selectedUser.role.name.th }}
</q-badge>
</div>
</div>
<!-- Info Grid -->
<div class="grid grid-cols-2 gap-4">
<div>
<div class="text-sm text-gray-500">เมล</div>
<div class="font-medium">{{ selectedUser.email }}</div>
</div>
<div>
<div class="text-sm text-gray-500">เบอรโทร</div>
<div class="font-medium">{{ selectedUser.profile.phone || '-' }}</div>
</div>
<div>
<div class="text-sm text-gray-500">นทลงทะเบยน</div>
<div class="font-medium">{{ formatDate(selectedUser.created_at) }}</div>
</div>
<div>
<div class="text-sm text-gray-500">พเดทลาส</div>
<div class="font-medium">{{ formatDate(selectedUser.updated_at) }}</div>
</div>
</div>
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="ปิด" color="primary" @click="showViewModal = false" />
</q-card-actions>
</q-card>
</q-dialog>
</div>
</template>
<script setup lang="ts">
import { useQuasar } from 'quasar';
import { adminService, type AdminUserResponse } from '~/services/admin.service';
definePageMeta({
layout: 'admin',
middleware: ['auth', 'admin']
});
const $q = useQuasar();
// Data
const users = ref<AdminUserResponse[]>([]);
const loading = ref(true);
const searchQuery = ref('');
const filterRole = ref<string | null>(null);
const showAddModal = ref(false);
const showViewModal = ref(false);
const selectedUser = ref<AdminUserResponse | null>(null);
const pagination = ref({
page: 1,
rowsPerPage: 10
});
// Table columns
const columns = [
{ name: 'user', label: 'ผู้ใช้งาน', field: 'user', align: 'left' as const },
{ name: 'role', label: 'บทบาท', field: 'role', align: 'center' as const },
{ name: 'created_at', label: 'ลงทะเบียน', field: 'created_at', align: 'center' as const },
{ name: 'actions', label: 'Actions', field: 'actions', align: 'center' as const }
];
// Filter options
const roleOptions = [
{ label: 'บทบาททั้งหมด', value: null },
{ label: 'Instructor', value: 'INSTRUCTOR' },
{ label: 'Student', value: 'STUDENT' },
{ label: 'Admin', value: 'ADMIN' }
]
// Stats computed
const stats = computed(() => {
const total = users.value.length;
const admin = users.value.filter(u => u.role.code === 'ADMIN').length;
const instructor = users.value.filter(u => u.role.code === 'INSTRUCTOR').length;
const student = users.value.filter(u => u.role.code === 'STUDENT').length;
return { total, admin, instructor, student };
});
// Filtered users
const filteredUsers = computed(() => {
let result = users.value;
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase();
result = result.filter(user =>
user.profile.first_name.toLowerCase().includes(query) ||
user.profile.last_name.toLowerCase().includes(query) ||
user.email.toLowerCase().includes(query) ||
user.username.toLowerCase().includes(query)
);
}
if (filterRole.value) {
result = result.filter(user => user.role.code === filterRole.value);
}
return result;
});
// Methods
const fetchUsers = async () => {
loading.value = true;
try {
users.value = await adminService.getUsers();
} catch (error) {
$q.notify({
type: 'negative',
message: 'ไม่สามารถโหลดข้อมูลผู้ใช้ได้',
position: 'top'
});
} finally {
loading.value = false;
}
};
const getFullName = (user: AdminUserResponse) => {
const prefix = user.profile.prefix?.th || '';
return `${prefix}${user.profile.first_name} ${user.profile.last_name}`;
};
const getRoleBadgeColor = (roleCode: string) => {
const colors: Record<string, string> = {
ADMIN: 'red',
INSTRUCTOR: 'orange',
STUDENT: 'blue'
};
return colors[roleCode] || 'grey';
};
const formatDate = (date: string) => {
return new Date(date).toLocaleDateString('th-TH', {
day: 'numeric',
month: 'short',
year: '2-digit'
});
};
const viewUser = (user: AdminUserResponse) => {
selectedUser.value = user;
showViewModal.value = true;
};
const changeRole = (user: AdminUserResponse) => {
const roleIds: Record<string, number> = {
INSTRUCTOR: 1,
STUDENT: 2,
ADMIN: 3
};
$q.dialog({
title: 'เปลี่ยน Role',
message: `เลือก Role ใหม่สำหรับ ${user.profile.first_name}`,
options: {
type: 'radio',
model: roleIds[user.role.code],
items: [
{ label: 'Instructor', value: 1 },
{ label: 'Student', value: 2 },
{ label: 'Admin', value: 3 }
]
},
cancel: true,
persistent: true
}).onOk(async (roleId: number) => {
try {
await adminService.updateUserRole(user.id, roleId);
$q.notify({
type: 'positive',
message: 'เปลี่ยน Role สำเร็จ',
position: 'top'
});
fetchUsers();
} catch (error) {
$q.notify({
type: 'negative',
message: 'เกิดข้อผิดพลาดในการเปลี่ยน Role',
position: 'top'
});
}
});
};
const confirmDelete = (user: AdminUserResponse) => {
$q.dialog({
title: 'ยืนยันการลบ',
message: `คุณต้องการลบผู้ใช้ "${user.profile.first_name} ${user.profile.last_name}" หรือไม่?`,
cancel: true,
persistent: true
}).onOk(async () => {
try {
await adminService.deleteUser(user.id);
$q.notify({
type: 'positive',
message: 'ลบผู้ใช้สำเร็จ',
position: 'top'
});
fetchUsers();
} catch (error) {
$q.notify({
type: 'negative',
message: 'เกิดข้อผิดพลาดในการลบผู้ใช้',
position: 'top'
});
}
});
};
const exportExcel = () => {
$q.notify({
type: 'info',
message: 'กำลังส่งออกไฟล์ Excel...',
position: 'top'
});
};
// Lifecycle
onMounted(() => {
fetchUsers();
});
</script>

View file

@ -5,13 +5,13 @@
<div class="flex justify-between items-center"> <div class="flex justify-between items-center">
<div> <div>
<h1 class="text-2xl font-bold text-gray-900"> <h1 class="text-2xl font-bold text-gray-900">
สวสด, {{ authStore.user?.fullName || 'อาจารย์' }} สวสด, {{ authStore.user?.firstName }} {{ authStore.user?.lastName || 'อาจารย์' }}
</h1> </h1>
<p class="text-gray-600 mt-2">นดอนรบกลบสระบบ</p> <p class="text-gray-600 mt-2">นดอนรบกลบสระบบ</p>
</div> </div>
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<div class="text-right"> <div class="text-right">
<div class="text-sm font-semibold text-gray-900">{{ authStore.user?.fullName || 'อาจารย์ทดสอบ' }}</div> <div class="text-sm font-semibold text-gray-900">{{ authStore.user?.firstName }} {{ authStore.user?.lastName || 'อาจารย์ทดสอบ' }}</div>
<div class="text-xs text-gray-500">{{ authStore.user?.role || 'INSTRUCTOR' }}</div> <div class="text-xs text-gray-500">{{ authStore.user?.role || 'INSTRUCTOR' }}</div>
</div> </div>
<div <div
@ -23,7 +23,7 @@
<!-- User Info Header --> <!-- User Info Header -->
<q-item class="bg-primary-50"> <q-item class="bg-primary-50">
<q-item-section> <q-item-section>
<q-item-label class="text-weight-bold">{{ authStore.user?.fullName }}</q-item-label> <q-item-label class="text-weight-bold">{{ authStore.user?.firstName }} {{ authStore.user?.lastName }}</q-item-label>
<q-item-label caption>{{ authStore.user?.email }}</q-item-label> <q-item-label caption>{{ authStore.user?.email }}</q-item-label>
</q-item-section> </q-item-section>
</q-item> </q-item>

View file

@ -7,7 +7,7 @@
</div> </div>
<!-- Profile Card --> <!-- Profile Card -->
<AppCard class="mb-10 "> <div class="mb-10 ">
<div class="flex flex-col md:flex-row gap-8"> <div class="flex flex-col md:flex-row gap-8">
<!-- Avatar Section --> <!-- Avatar Section -->
<div class="flex flex-col items-center"> <div class="flex flex-col items-center">
@ -49,7 +49,7 @@
<div> <div>
<div class="text-sm text-gray-600 mb-1">ตำแหน</div> <div class="text-sm text-gray-600 mb-1">ตำแหน</div>
<div class="text-lg text-gray-900"> <div class="text-lg text-gray-900">
<q-badge color="primary">{{ getRoleLabel(profile.role) }}</q-badge> <q-badge color="primary">{{ profile.roleName || getRoleLabel(profile.role) }}</q-badge>
</div> </div>
</div> </div>
@ -77,7 +77,7 @@
</div> </div>
</div> </div>
</div> </div>
</AppCard> </div>
<!-- Stats Cards <!-- Stats Cards
<div class="grid grid-cols-1 md:grid-cols-2 gap-5 mt-5"> <div class="grid grid-cols-1 md:grid-cols-2 gap-5 mt-5">
@ -108,11 +108,11 @@
<q-card-section> <q-card-section>
<q-form @submit="handleUpdateProfile"> <q-form @submit="handleUpdateProfile">
<q-input <q-input
v-model="editForm.fullName" v-model="editForm.firstName"
label="ชื่อ-นามสกุล" label="ชื่อ"
outlined outlined
class="mb-4" class="mb-4"
:rules="[val => !!val || 'กรุณากรอกชื่อ-นามสกุล']" :rules="[val => !!val || 'กรุณากรอกชื่อ']"
> >
<template v-slot:prepend> <template v-slot:prepend>
<q-icon name="person" /> <q-icon name="person" />
@ -120,27 +120,14 @@
</q-input> </q-input>
<q-input <q-input
v-model="editForm.email" v-model="editForm.lastName"
label="อีเมล" label="นามสกุล"
type="email"
outlined outlined
class="mb-4" class="mb-4"
:rules="[val => !!val || 'กรุณากรอกอีเมล']" :rules="[val => !!val || 'กรุณากรอกนามสกุล']"
> >
<template v-slot:prepend> <template v-slot:prepend>
<q-icon name="email" /> <q-icon name="person" />
</template>
</q-input>
<q-input
v-model="editForm.username"
label="Username"
outlined
class="mb-4"
:rules="[val => !!val || 'กรุณากรอก username']"
>
<template v-slot:prepend>
<q-icon name="account_circle" />
</template> </template>
</q-input> </q-input>
@ -274,6 +261,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { useQuasar } from 'quasar'; import { useQuasar } from 'quasar';
import { userService, type UserProfileResponse } from '~/services/user.service';
definePageMeta({ definePageMeta({
layout: 'instructor', layout: 'instructor',
@ -283,15 +271,19 @@ definePageMeta({
const $q = useQuasar(); const $q = useQuasar();
const authStore = useAuthStore(); const authStore = useAuthStore();
// Loading state
const loading = ref(true);
// Profile data // Profile data
const profile = ref({ const profile = ref({
fullName: 'อาจารย์ทดสอบ ระบบอีเลินนิ่ง', fullName: '',
email: 'instructor@example.com', email: '',
username: 'instructor_test', username: '',
phone: '081-234-5678', phone: '',
role: 'INSTRUCTOR', role: '',
roleName: '',
avatar: '👨‍🏫', avatar: '👨‍🏫',
createdAt: '2024-01-01' createdAt: ''
}); });
const stats = ref({ const stats = ref({
@ -303,9 +295,8 @@ const stats = ref({
const showEditModal = ref(false); const showEditModal = ref(false);
const saving = ref(false); const saving = ref(false);
const editForm = ref({ const editForm = ref({
fullName: '', firstName: '',
email: '', lastName: '',
username: '',
phone: '' phone: ''
}); });
@ -351,11 +342,15 @@ const handleUpdateProfile = async () => {
saving.value = true; saving.value = true;
try { try {
// TODO: Call API to update profile // Call real API to update profile
await new Promise(resolve => setTimeout(resolve, 1000)); await userService.updateProfile({
first_name: editForm.value.firstName,
last_name: editForm.value.lastName,
phone: editForm.value.phone || null
});
// Update local data // Refresh profile data from API
profile.value = { ...profile.value, ...editForm.value }; await fetchProfile();
$q.notify({ $q.notify({
type: 'positive', type: 'positive',
@ -364,7 +359,7 @@ const handleUpdateProfile = async () => {
}); });
showEditModal.value = false; showEditModal.value = false;
} catch (error) { } catch (error: any) {
$q.notify({ $q.notify({
type: 'negative', type: 'negative',
message: 'เกิดข้อผิดพลาด กรุณาลองใหม่อีกครั้ง', message: 'เกิดข้อผิดพลาด กรุณาลองใหม่อีกครั้ง',
@ -379,8 +374,11 @@ const handleChangePassword = async () => {
changingPassword.value = true; changingPassword.value = true;
try { try {
// TODO: Call API to change password // Call real API to change password
await new Promise(resolve => setTimeout(resolve, 1000)); await userService.changePassword(
passwordForm.value.currentPassword,
passwordForm.value.newPassword
);
$q.notify({ $q.notify({
type: 'positive', type: 'positive',
@ -394,10 +392,12 @@ const handleChangePassword = async () => {
newPassword: '', newPassword: '',
confirmPassword: '' confirmPassword: ''
}; };
} catch (error) { } catch (error: any) {
$q.notify({ $q.notify({
type: 'negative', type: 'negative',
message: 'รหัสผ่านปัจจุบันไม่ถูกต้อง', message: error.response?.status === 401
? 'รหัสผ่านปัจจุบันไม่ถูกต้อง'
: 'เกิดข้อผิดพลาดในการเปลี่ยนรหัสผ่าน',
position: 'top' position: 'top'
}); });
} finally { } finally {
@ -408,12 +408,46 @@ const handleChangePassword = async () => {
// Watch edit modal // Watch edit modal
watch(showEditModal, (newVal) => { watch(showEditModal, (newVal) => {
if (newVal) { if (newVal) {
// Split fullName into firstName and lastName for editing
const nameParts = profile.value.fullName.split(' ');
editForm.value = { editForm.value = {
fullName: profile.value.fullName, firstName: nameParts[0] || '',
email: profile.value.email, lastName: nameParts.slice(1).join(' ') || '',
username: profile.value.username,
phone: profile.value.phone phone: profile.value.phone
}; };
} }
}); });
// Fetch profile from API
const fetchProfile = async () => {
loading.value = true;
try {
const data = await userService.getProfile();
// Map API response to profile
profile.value = {
fullName: `${data.profile.first_name} ${data.profile.last_name}`,
email: data.email,
username: data.username,
phone: data.profile.phone || '',
role: data.role.code,
roleName: data.role.name.th,
avatar: data.profile.avatar_url || '👨‍🏫',
createdAt: data.created_at
};
} catch (error) {
$q.notify({
type: 'negative',
message: 'ไม่สามารถโหลดข้อมูลโปรไฟล์ได้',
position: 'top'
});
} finally {
loading.value = false;
}
};
// Load profile on mount
onMounted(() => {
fetchProfile();
});
</script> </script>

View file

@ -38,7 +38,11 @@
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<q-checkbox v-model="rememberMe" label="จดจำฉันไว้" /> <q-checkbox v-model="rememberMe" label="จดจำฉันไว้" />
<a href="#" class="text-sm text-primary-600 hover:text-primary-700">มรหสผาน?</a> <a
href="#"
class="text-sm text-primary-600 hover:text-primary-700"
@click.prevent="showForgotModal = true"
>มรหสผาน?</a>
</div> </div>
<q-btn <q-btn
@ -54,22 +58,78 @@
<p>ทดสอบ: admin@elearning.local / instructor@elearning.local</p> <p>ทดสอบ: admin@elearning.local / instructor@elearning.local</p>
</div> </div>
</q-card-section> </q-card-section>
<!-- Forgot Password Modal -->
<q-dialog v-model="showForgotModal" persistent>
<q-card style="min-width: 400px">
<q-card-section class="row items-center q-pb-none">
<div class="text-h6">มรหสผาน</div>
<q-space />
<q-btn icon="close" flat round dense @click="showForgotModal = false" />
</q-card-section>
<q-card-section>
<p class="text-gray-600 mb-4">
กรอกอเมลของค เราจะสงลงกสำหรบรเซตรหสผานไปให
</p>
<q-form @submit="handleForgotPassword">
<q-input
v-model="forgotEmail"
label="อีเมล"
type="email"
outlined
:rules="[val => !!val || 'กรุณากรอกอีเมล']"
>
<template v-slot:prepend>
<q-icon name="email" />
</template>
</q-input>
<div class="flex justify-end gap-2 mt-4">
<q-btn
flat
label="ยกเลิก"
color="grey-7"
@click="showForgotModal = false"
/>
<q-btn
type="submit"
label="ส่งลิงก์รีเซ็ต"
color="primary"
:loading="forgotLoading"
/>
</div>
</q-form>
</q-card-section>
</q-card>
</q-dialog>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useQuasar } from 'quasar'; import { useQuasar } from 'quasar';
import { authService } from '~/services/auth.service';
definePageMeta({ definePageMeta({
layout: 'auth' layout: 'auth'
}); });
const $q = useQuasar(); const $q = useQuasar();
const authStore = useAuthStore(); const authStore = useAuthStore();
const router = useRouter(); const router = useRouter();
// Login form
const email = ref(''); const email = ref('');
const password = ref(''); const password = ref('');
const showPassword = ref(false); const showPassword = ref(false);
const rememberMe = ref(false); const rememberMe = ref(false);
const loading = ref(false); const loading = ref(false);
// Forgot password
const showForgotModal = ref(false);
const forgotEmail = ref('');
const forgotLoading = ref(false);
const handleLogin = async () => { const handleLogin = async () => {
loading.value = true; loading.value = true;
try { try {
@ -86,14 +146,38 @@ const handleLogin = async () => {
} else if (authStore.isAdmin) { } else if (authStore.isAdmin) {
router.push('/admin'); router.push('/admin');
} }
} catch (error) { } catch (error: any) {
$q.notify({ $q.notify({
type: 'negative', type: 'negative',
message: 'อีเมลหรือรหัสผ่านไม่ถูกต้อง', message: error.message || 'อีเมลหรือรหัสผ่านไม่ถูกต้อง',
position: 'top' position: 'top'
}); });
} finally { } finally {
loading.value = false; loading.value = false;
} }
}; };
const handleForgotPassword = async () => {
forgotLoading.value = true;
try {
await authService.forgotPassword(forgotEmail.value);
$q.notify({
type: 'positive',
message: 'ส่งลิงก์รีเซ็ตรหัสผ่านไปยังอีเมลของคุณแล้ว',
position: 'top'
});
showForgotModal.value = false;
forgotEmail.value = '';
} catch (error: any) {
$q.notify({
type: 'negative',
message: error.message || 'เกิดข้อผิดพลาด กรุณาลองใหม่',
position: 'top'
});
} finally {
forgotLoading.value = false;
}
};
</script> </script>

View file

@ -0,0 +1,138 @@
<template>
<div class="p-6 items-center justify-center">
<q-card-section>
<div class="text-center mb-8">
<h1 class="text-3xl font-bold text-gray-900">E-Learning</h1>
<p class="text-gray-600 mt-2">งรหสผานใหม</p>
</div>
<!-- Success Message -->
<div v-if="success" class="text-center">
<q-icon name="check_circle" size="80px" color="positive" class="mb-4" />
<h2 class="text-xl font-semibold text-gray-900 mb-2">เซตรหสผานสำเร!</h2>
<p class="text-gray-600 mb-6">ณสามารถเขาสระบบดวยรหสผานใหมไดแล</p>
<q-btn
color="primary"
label="ไปหน้าเข้าสู่ระบบ"
@click="router.push('/login')"
/>
</div>
<!-- Reset Form -->
<div v-else>
<q-form @submit="handleResetPassword" class="space-y-4">
<q-input
v-model="password"
label="รหัสผ่านใหม่"
:type="showPassword ? 'text' : 'password'"
outlined
:rules="[
val => !!val || 'กรุณากรอกรหัสผ่าน',
val => val.length >= 8 || 'รหัสผ่านต้องมีอย่างน้อย 8 ตัวอักษร'
]"
>
<template v-slot:prepend>
<q-icon name="lock" />
</template>
<template v-slot:append>
<q-icon
:name="showPassword ? 'visibility_off' : 'visibility'"
class="cursor-pointer"
@click="showPassword = !showPassword"
/>
</template>
</q-input>
<q-input
v-model="confirmPassword"
label="ยืนยันรหัสผ่านใหม่"
:type="showConfirmPassword ? 'text' : 'password'"
outlined
:rules="[
val => !!val || 'กรุณายืนยันรหัสผ่าน',
val => val === password || 'รหัสผ่านไม่ตรงกัน'
]"
>
<template v-slot:prepend>
<q-icon name="lock" />
</template>
<template v-slot:append>
<q-icon
:name="showConfirmPassword ? 'visibility_off' : 'visibility'"
class="cursor-pointer"
@click="showConfirmPassword = !showConfirmPassword"
/>
</template>
</q-input>
<q-btn
type="submit"
color="primary"
label="ตั้งรหัสผ่านใหม่"
class="w-full"
size="lg"
:loading="loading"
/>
</q-form>
<div class="mt-6 text-center">
<NuxtLink to="/login" class="text-sm text-primary-600 hover:text-primary-700">
กลบหนาเขาสระบบ
</NuxtLink>
</div>
</div>
</q-card-section>
</div>
</template>
<script setup lang="ts">
import { useQuasar } from 'quasar';
import { authService } from '~/services/auth.service';
definePageMeta({
layout: 'auth'
});
const $q = useQuasar();
const router = useRouter();
const route = useRoute();
// Form
const password = ref('');
const confirmPassword = ref('');
const showPassword = ref(false);
const showConfirmPassword = ref(false);
const loading = ref(false);
const success = ref(false);
// Get token from URL
const token = computed(() => route.query.token as string);
// Check if token exists
onMounted(() => {
if (!token.value) {
$q.notify({
type: 'negative',
message: 'ลิงก์รีเซ็ตรหัสผ่านไม่ถูกต้อง',
position: 'top'
});
router.push('/login');
}
});
const handleResetPassword = async () => {
loading.value = true;
try {
await authService.resetPassword(token.value, password.value);
success.value = true;
} catch (error: any) {
$q.notify({
type: 'negative',
message: error.message || 'เกิดข้อผิดพลาด กรุณาลองใหม่',
position: 'top'
});
} finally {
loading.value = false;
}
};
</script>

View file

@ -0,0 +1,11 @@
export default defineNuxtPlugin({
name: 'auth-restore',
parallel: false,
setup() {
const authStore = useAuthStore();
// Restore auth state from cookies on app initialization
// useCookie works on both server and client
authStore.checkAuth();
}
});

View file

@ -1,8 +0,0 @@
export default defineNuxtPlugin(() => {
const authStore = useAuthStore();
// Restore auth state from localStorage before any navigation
if (process.client) {
authStore.checkAuth();
}
});

View file

@ -0,0 +1,358 @@
// API Response structure for user list
export interface AdminUserResponse {
id: number;
username: string;
email: string;
created_at: string;
updated_at: string;
role: {
code: string;
name: {
en: string;
th: string;
};
};
profile: {
prefix: {
en: string;
th: string;
};
first_name: string;
last_name: string;
avatar_url: string | null;
birth_date: string | null;
phone: string | null;
};
}
export interface UsersListResponse {
code: number;
message: string;
data: AdminUserResponse[];
}
// Mock data for development
const MOCK_USERS: AdminUserResponse[] = [
{
id: 1,
username: 'admin',
email: 'admin@elearning.local',
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
role: { code: 'ADMIN', name: { en: 'Administrator', th: 'ผู้ดูแลระบบ' } },
profile: {
prefix: { en: 'Mr.', th: 'นาย' },
first_name: 'Admin',
last_name: 'User',
avatar_url: null,
birth_date: null,
phone: '0812345678'
}
},
{
id: 2,
username: 'instructor',
email: 'instructor@elearning.local',
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
role: { code: 'INSTRUCTOR', name: { en: 'Instructor', th: 'ผู้สอน' } },
profile: {
prefix: { en: 'Mr.', th: 'นาย' },
first_name: 'John',
last_name: 'Instructor',
avatar_url: null,
birth_date: null,
phone: '0812345679'
}
},
{
id: 3,
username: 'student',
email: 'student@elearning.local',
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
role: { code: 'STUDENT', name: { en: 'Student', th: 'นักเรียน' } },
profile: {
prefix: { en: 'Ms.', th: 'นางสาว' },
first_name: 'Jane',
last_name: 'Student',
avatar_url: null,
birth_date: null,
phone: '0812345680'
}
}
];
// Helper function to get auth token from cookie or localStorage
const getAuthToken = (): string => {
const tokenCookie = useCookie('token');
return tokenCookie.value || '';
};
export const adminService = {
async getUsers(): Promise<AdminUserResponse[]> {
const config = useRuntimeConfig();
const useMockData = config.public.useMockData as boolean;
if (useMockData) {
await new Promise(resolve => setTimeout(resolve, 500));
return MOCK_USERS;
}
const token = getAuthToken();
const response = await $fetch<UsersListResponse>('/api/admin/usermanagement/users', {
baseURL: config.public.apiBaseUrl as string,
headers: {
Authorization: `Bearer ${token}`
}
});
return response.data;
},
async getUserById(id: number): Promise<AdminUserResponse> {
const config = useRuntimeConfig();
const useMockData = config.public.useMockData as boolean;
if (useMockData) {
await new Promise(resolve => setTimeout(resolve, 300));
const user = MOCK_USERS.find(u => u.id === id);
if (!user) throw new Error('User not found');
return user;
}
const token = getAuthToken();
const response = await $fetch<AdminUserResponse>(`/api/admin/usermanagement/users/${id}`, {
baseURL: config.public.apiBaseUrl as string,
headers: {
Authorization: `Bearer ${token}`
}
});
return response;
},
async updateUserRole(userId: number, roleId: number): Promise<void> {
const config = useRuntimeConfig();
const useMockData = config.public.useMockData as boolean;
if (useMockData) {
await new Promise(resolve => setTimeout(resolve, 500));
return;
}
const token = getAuthToken();
await $fetch(`/api/admin/usermanagement/role/${userId}`, {
method: 'PUT',
baseURL: config.public.apiBaseUrl as string,
headers: {
Authorization: `Bearer ${token}`
},
body: {
id: userId,
role_id: roleId
}
});
},
async deleteUser(userId: number): Promise<void> {
const config = useRuntimeConfig();
const useMockData = config.public.useMockData as boolean;
if (useMockData) {
await new Promise(resolve => setTimeout(resolve, 500));
return;
}
const token = getAuthToken();
await $fetch(`/api/admin/usermanagement/users/${userId}`, {
method: 'DELETE',
baseURL: config.public.apiBaseUrl as string,
headers: {
Authorization: `Bearer ${token}`
}
});
},
// ============ Categories ============
async getCategories(): Promise<CategoryResponse[]> {
const config = useRuntimeConfig();
const useMockData = config.public.useMockData as boolean;
if (useMockData) {
await new Promise(resolve => setTimeout(resolve, 500));
return MOCK_CATEGORIES;
}
const token = getAuthToken();
const response = await $fetch<CategoriesListResponse>('/api/admin/categories', {
baseURL: config.public.apiBaseUrl as string,
headers: {
Authorization: `Bearer ${token}`
}
});
return response.data.categories;
},
async createCategory(data: CreateCategoryRequest): Promise<CategoryResponse> {
const config = useRuntimeConfig();
const useMockData = config.public.useMockData as boolean;
if (useMockData) {
await new Promise(resolve => setTimeout(resolve, 500));
return { ...MOCK_CATEGORIES[0], id: Date.now() };
}
const token = getAuthToken();
const response = await $fetch<CategoryResponse>('/api/admin/categories', {
method: 'POST',
baseURL: config.public.apiBaseUrl as string,
headers: {
Authorization: `Bearer ${token}`
},
body: data
});
return response;
},
async updateCategory(id: number, data: UpdateCategoryRequest): Promise<CategoryResponse> {
const config = useRuntimeConfig();
const useMockData = config.public.useMockData as boolean;
if (useMockData) {
await new Promise(resolve => setTimeout(resolve, 500));
return { ...MOCK_CATEGORIES[0], id };
}
const token = getAuthToken();
const response = await $fetch<CategoryResponse>(`/api/admin/categories/${id}`, {
method: 'PUT',
baseURL: config.public.apiBaseUrl as string,
headers: {
Authorization: `Bearer ${token}`
},
body: data
});
return response;
},
async deleteCategory(id: number): Promise<void> {
const config = useRuntimeConfig();
const useMockData = config.public.useMockData as boolean;
if (useMockData) {
await new Promise(resolve => setTimeout(resolve, 500));
return;
}
const token = getAuthToken();
await $fetch(`/api/admin/categories/${id}`, {
method: 'DELETE',
baseURL: config.public.apiBaseUrl as string,
headers: {
Authorization: `Bearer ${token}`
}
});
}
};
// Category interfaces
export interface CategoryResponse {
id: number;
name: {
en: string;
th: string;
};
slug: string;
description: {
en: string;
th: string;
};
icon: string;
sort_order: number;
is_active: boolean;
created_at: string;
created_by: number;
updated_at: string;
updated_by: number | null;
}
export interface CategoriesListResponse {
code: number;
message: string;
data: {
categories: CategoryResponse[];
};
}
export interface CreateCategoryRequest {
name: {
en: string;
th: string;
};
slug: string;
description: {
en: string;
th: string;
};
created_by?: number;
}
export interface UpdateCategoryRequest {
id: number;
name: {
en: string;
th: string;
};
slug: string;
description: {
en: string;
th: string;
};
}
// Mock categories
const MOCK_CATEGORIES: CategoryResponse[] = [
{
id: 1,
name: { en: 'Web Development', th: 'การพัฒนาเว็บไซต์' },
slug: 'web-development',
description: { en: 'Learn web development', th: 'หลักสูตรเกี่ยวกับการพัฒนาเว็บไซต์และเว็บแอปพลิเคชัน' },
icon: 'code',
sort_order: 1,
is_active: true,
created_at: '2024-01-15T00:00:00Z',
created_by: 1,
updated_at: '2024-01-15T00:00:00Z',
updated_by: null
},
{
id: 2,
name: { en: 'Mobile Development', th: 'การพัฒนาแอปพลิเคชันมือถือ' },
slug: 'mobile-development',
description: { en: 'Learn mobile app development', th: 'หลักสูตรเกี่ยวกับการพัฒนาแอปพลิเคชันบนมือถือ' },
icon: 'smartphone',
sort_order: 2,
is_active: true,
created_at: '2024-01-20T00:00:00Z',
created_by: 1,
updated_at: '2024-01-20T00:00:00Z',
updated_by: null
},
{
id: 3,
name: { en: 'Database', th: 'ฐานข้อมูล' },
slug: 'database',
description: { en: 'Learn database management', th: 'หลักสูตรเกี่ยวกับการออกแบบและจัดการฐานข้อมูล' },
icon: 'storage',
sort_order: 3,
is_active: true,
created_at: '2024-02-01T00:00:00Z',
created_by: 1,
updated_at: '2024-02-01T00:00:00Z',
updated_by: null
}
];

View file

@ -41,14 +41,72 @@ export interface LoginResponse {
user: { user: {
id: string; id: string;
email: string; email: string;
fullName: string; firstName: string;
lastName: string;
role: string; role: string;
}; };
} }
// Mock data for development
const MOCK_USERS = [
{
email: 'admin@elearning.local',
password: 'password',
user: {
id: '1',
email: 'admin@elearning.local',
firstName: 'ผู้ดูแล',
lastName: 'ระบบ',
role: 'ADMIN'
}
},
{
email: 'instructor@elearning.local',
password: 'password',
user: {
id: '2',
email: 'instructor@elearning.local',
firstName: 'อาจารย์',
lastName: 'ทดสอบ',
role: 'INSTRUCTOR'
}
}
];
export const authService = { export const authService = {
async login(email: string, password: string): Promise<LoginResponse> { async login(email: string, password: string): Promise<LoginResponse> {
const config = useRuntimeConfig(); const config = useRuntimeConfig();
const useMockData = config.public.useMockData as boolean;
// Use Mock Data
if (useMockData) {
return this.mockLogin(email, password);
}
// Use Real API
return this.apiLogin(email, password);
},
// Mock login for development
async mockLogin(email: string, password: string): Promise<LoginResponse> {
await new Promise(resolve => setTimeout(resolve, 500)); // Simulate delay
const mockUser = MOCK_USERS.find(u => u.email === email);
if (!mockUser || mockUser.password !== password) {
throw new Error('อีเมลหรือรหัสผ่านไม่ถูกต้อง');
}
return {
token: 'mock-jwt-token',
refreshToken: 'mock-refresh-token',
user: mockUser.user
};
},
// Real API login
async apiLogin(email: string, password: string): Promise<LoginResponse> {
const config = useRuntimeConfig();
try { try {
const response = await $fetch<ApiLoginResponse>('/api/auth/login', { const response = await $fetch<ApiLoginResponse>('/api/auth/login', {
@ -60,6 +118,11 @@ export const authService = {
} }
}); });
// Check if user role is STUDENT - block login
if (response.user.role.code === 'STUDENT') {
throw new Error('ไม่สามารถเข้าสู่ระบบได้ ระบบนี้สำหรับผู้สอนและผู้ดูแลระบบเท่านั้น');
}
// Transform API response to frontend format // Transform API response to frontend format
return { return {
token: response.token, token: response.token,
@ -67,26 +130,68 @@ export const authService = {
user: { user: {
id: response.user.id.toString(), id: response.user.id.toString(),
email: response.user.email, email: response.user.email,
fullName: `${response.user.profile.first_name} ${response.user.profile.last_name}`, firstName: response.user.profile.first_name,
role: response.user.role.code // Use role.code (ADMIN, INSTRUCTOR, etc.) lastName: response.user.profile.last_name,
role: response.user.role.code
} }
}; };
} catch (error: any) { } catch (error: any) {
// Re-throw custom errors (like STUDENT role block)
if (error.message && !error.response) {
throw error;
}
// Handle API errors // Handle API errors
if (error.response?.status === 401) { if (error.response?.status === 401) {
throw new Error('Invalid credentials'); throw new Error('อีเมลหรือรหัสผ่านไม่ถูกต้อง');
} }
throw new Error('Login failed'); throw new Error('เกิดข้อผิดพลาดในการเข้าสู่ระบบ');
} }
}, },
async logout(): Promise<void> { async logout(): Promise<void> {
// TODO: Call logout API if available // Clear cookies
// For now, just clear local storage const tokenCookie = useCookie('token');
if (process.client) { const refreshTokenCookie = useCookie('refreshToken');
localStorage.removeItem('token'); const userCookie = useCookie('user');
localStorage.removeItem('refreshToken');
localStorage.removeItem('user'); tokenCookie.value = null;
refreshTokenCookie.value = null;
userCookie.value = null;
},
async forgotPassword(email: string): Promise<void> {
const config = useRuntimeConfig();
const useMockData = config.public.useMockData as boolean;
if (useMockData) {
// Mock: simulate sending email
await new Promise(resolve => setTimeout(resolve, 1000));
return;
} }
// Real API
await $fetch('/api/auth/reset-request', {
method: 'POST',
baseURL: config.public.apiBaseUrl as string,
body: { email }
});
},
async resetPassword(token: string, password: string): Promise<void> {
const config = useRuntimeConfig();
const useMockData = config.public.useMockData as boolean;
if (useMockData) {
// Mock: simulate reset password
await new Promise(resolve => setTimeout(resolve, 1000));
return;
}
// Real API
await $fetch('/api/auth/reset-password', {
method: 'POST',
baseURL: config.public.apiBaseUrl as string,
body: { token, password }
});
} }
}; };

View file

@ -0,0 +1,182 @@
// API Response structure from /api/user/me
export interface UserProfileResponse {
id: number;
username: string;
email: string;
updated_at: string;
created_at: string;
role: {
code: string;
name: {
en: string;
th: string;
};
};
profile: {
prefix: {
en: string;
th: string;
};
first_name: string;
last_name: string;
avatar_url: string | null;
birth_date: string | null;
phone: string | null;
};
}
// Request body for PUT /api/user/me
export interface UpdateProfileRequest {
prefix?: {
en: string;
th: string;
};
first_name: string;
last_name: string;
phone?: string | null;
avatar_url?: string | null;
birth_date?: string | null;
}
// Mock profile data for development
const MOCK_PROFILES: Record<string, UserProfileResponse> = {
'admin@elearning.local': {
id: 1,
username: 'admin',
email: 'admin@elearning.local',
updated_at: '2024-01-01T00:00:00Z',
created_at: '2024-01-01T00:00:00Z',
role: {
code: 'ADMIN',
name: { en: 'Administrator', th: 'ผู้ดูแลระบบ' }
},
profile: {
prefix: { en: 'Mr.', th: 'นาย' },
first_name: 'ผู้ดูแล',
last_name: 'ระบบ',
avatar_url: null,
birth_date: null,
phone: '081-234-5678'
}
},
'instructor@elearning.local': {
id: 2,
username: 'instructor',
email: 'instructor@elearning.local',
updated_at: '2024-01-01T00:00:00Z',
created_at: '2024-01-01T00:00:00Z',
role: {
code: 'INSTRUCTOR',
name: { en: 'Instructor', th: 'ผู้สอน' }
},
profile: {
prefix: { en: 'Mr.', th: 'นาย' },
first_name: 'อาจารย์',
last_name: 'ทดสอบ',
avatar_url: null,
birth_date: null,
phone: '081-234-5678'
}
}
};
// Helper function to get auth token from cookie
const getAuthToken = (): string => {
const tokenCookie = useCookie('token');
return tokenCookie.value || '';
};
export const userService = {
async getProfile(): Promise<UserProfileResponse> {
const config = useRuntimeConfig();
const useMockData = config.public.useMockData as boolean;
if (useMockData) {
return this.mockGetProfile();
}
return this.apiGetProfile();
},
// Mock get profile
async mockGetProfile(): Promise<UserProfileResponse> {
await new Promise(resolve => setTimeout(resolve, 300));
const userCookie = useCookie('user');
if (!userCookie.value) throw new Error('User not found');
const user = typeof userCookie.value === 'string'
? JSON.parse(userCookie.value)
: userCookie.value;
const mockProfile = MOCK_PROFILES[user.email];
if (!mockProfile) {
throw new Error('Profile not found');
}
return mockProfile;
},
// Real API get profile
async apiGetProfile(): Promise<UserProfileResponse> {
const config = useRuntimeConfig();
const token = getAuthToken();
const response = await $fetch<UserProfileResponse>('/api/user/me', {
baseURL: config.public.apiBaseUrl as string,
headers: {
Authorization: `Bearer ${token}`
}
});
return response;
},
async updateProfile(data: UpdateProfileRequest): Promise<UserProfileResponse> {
const config = useRuntimeConfig();
const useMockData = config.public.useMockData as boolean;
if (useMockData) {
await new Promise(resolve => setTimeout(resolve, 500));
// In mock mode, just return the current profile with updates
const profile = await this.mockGetProfile();
return { ...profile, profile: { ...profile.profile, ...data } };
}
const token = getAuthToken();
const response = await $fetch<UserProfileResponse>('/api/user/me', {
method: 'PUT',
baseURL: config.public.apiBaseUrl as string,
headers: {
Authorization: `Bearer ${token}`
},
body: data
});
return response;
},
async changePassword(oldPassword: string, newPassword: string): Promise<void> {
const config = useRuntimeConfig();
const useMockData = config.public.useMockData as boolean;
if (useMockData) {
await new Promise(resolve => setTimeout(resolve, 500));
// In mock mode, just simulate success
return;
}
const token = getAuthToken();
await $fetch('/api/user/change-password', {
method: 'POST',
baseURL: config.public.apiBaseUrl as string,
headers: {
Authorization: `Bearer ${token}`
},
body: {
oldPassword,
newPassword
}
});
}
};

View file

@ -4,7 +4,8 @@ import { authService } from '~/services/auth.service';
interface User { interface User {
id: string; id: string;
email: string; email: string;
fullName: string; firstName: string;
lastName: string;
role: 'INSTRUCTOR' | 'ADMIN' | 'STUDENT'; role: 'INSTRUCTOR' | 'ADMIN' | 'STUDENT';
} }
@ -24,23 +25,32 @@ export const useAuthStore = defineStore('auth', {
actions: { actions: {
async login(email: string, password: string) { async login(email: string, password: string) {
try { try {
// Call real API
const response = await authService.login(email, password); const response = await authService.login(email, password);
this.token = response.token; this.token = response.token;
this.user = response.user as User; this.user = response.user as User;
this.isAuthenticated = true; this.isAuthenticated = true;
// Save to localStorage (including refreshToken) // Save to cookies
if (process.client) { const tokenCookie = useCookie('token', {
localStorage.setItem('token', this.token); maxAge: 60 * 60 * 24, // 24 hours
localStorage.setItem('refreshToken', response.refreshToken); sameSite: 'strict'
localStorage.setItem('user', JSON.stringify(this.user)); });
} const refreshTokenCookie = useCookie('refreshToken', {
maxAge: 60 * 60 * 24 * 7, // 7 days
sameSite: 'strict'
});
const userCookie = useCookie('user', {
maxAge: 60 * 60 * 24, // 24 hours
sameSite: 'strict'
});
tokenCookie.value = this.token;
refreshTokenCookie.value = response.refreshToken;
userCookie.value = JSON.stringify(this.user);
return { token: this.token, user: this.user }; return { token: this.token, user: this.user };
} catch (error: any) { } catch (error: any) {
// Re-throw error to be handled by login page
throw error; throw error;
} }
}, },
@ -50,21 +60,30 @@ export const useAuthStore = defineStore('auth', {
this.token = null; this.token = null;
this.isAuthenticated = false; this.isAuthenticated = false;
if (process.client) { // Clear cookies
localStorage.removeItem('token'); const tokenCookie = useCookie('token');
localStorage.removeItem('user'); const refreshTokenCookie = useCookie('refreshToken');
} const userCookie = useCookie('user');
tokenCookie.value = null;
refreshTokenCookie.value = null;
userCookie.value = null;
}, },
checkAuth() { checkAuth() {
if (process.client) { const tokenCookie = useCookie('token');
const token = localStorage.getItem('token'); const userCookie = useCookie('user');
const user = localStorage.getItem('user');
if (token && user) { if (tokenCookie.value && userCookie.value) {
this.token = token; this.token = tokenCookie.value;
this.user = JSON.parse(user); try {
this.user = typeof userCookie.value === 'string'
? JSON.parse(userCookie.value)
: userCookie.value;
this.isAuthenticated = true; this.isAuthenticated = true;
} catch (e) {
// Invalid user data
this.logout();
} }
} }
} }